Skip to content

Commit 863aaa7

Browse files
andreasjordanclaude
andcommitted
Copy-DbaDbTableData - Exclude generated always columns from the writable destination columns
The period columns of a temporal table and the ledger metadata columns are GENERATED ALWAYS: not computed, not rowversion, but just as unwritable, and SMO reports them with Computed = false. The positional mapping counted them, mapped query columns onto them and the server rejected the insert with error 13536. The lookup is guarded by the version because SMO only supports GeneratedAlwaysType on SQL Server 2016 and later. (do Copy-DbaDbTableData) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3a2bc61 commit 863aaa7

2 files changed

Lines changed: 48 additions & 10 deletions

File tree

public/Copy-DbaDbTableData.ps1

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,14 @@ function Copy-DbaDbTableData {
5656
Custom SQL SELECT query to use as the data source instead of copying the entire table or view. Supports 3 or 4-part object names.
5757
Use this when you need to filter rows, join multiple tables, or transform data during the copy operation. Still requires specifying a Table or View parameter for metadata purposes.
5858
59-
Note: Columns are mapped by position onto the writable columns of the destination table, so computed and rowversion columns of the destination do not count and must not have a counterpart in the SELECT list.
59+
Note: Columns are mapped by position onto the writable columns of the destination table, so computed, rowversion and generated always columns of the destination do not count and must not have a counterpart in the SELECT list.
6060
If the destination table has an identity column, include a placeholder value (e.g., 0) in your SELECT list at that position.
6161
The placeholder will be ignored and the identity value auto-generated unless -KeepIdentity is specified.
6262
6363
.PARAMETER ForceExplicitMapping
6464
When used together with Query parameter, force the use of explicit column mapping (name-based) instead of switching over to ordinal position mapping. Use with care if query contains aliases.
6565
Default behaviour when using Query parameter is to use ordinal position mapping, due to the possibility of the query including aliases (SELECT x AS y) which could lead to column mismatching and data not copying.
66-
Positional mapping skips the computed and rowversion columns of the destination table, so the SELECT list only has to match its writable columns.
66+
Positional mapping skips the computed, rowversion and generated always columns of the destination table, so the SELECT list only has to match its writable columns.
6767
6868
.PARAMETER AutoCreateTable
6969
Automatically creates the destination table if it doesn't exist, using the same structure as the source table.
@@ -673,13 +673,15 @@ function Copy-DbaDbTableData {
673673
$bulkCopy.NotifyAfter = $NotifyAfter
674674
$bulkCopy.BulkCopyTimeout = $BulkCopyTimeout
675675

676-
# Get list of writable columns from destination table to avoid insert failures. Computed and rowversion
677-
# columns cannot be written, and they are also what breaks the implicit positional mapping of SqlBulkCopy:
678-
# it counts a computed column (the server then rejects the insert) and silently drops the source column
679-
# that lands on a rowversion column, shifting every column behind it by one (see #10661).
676+
# Get list of writable columns from destination table to avoid insert failures. Computed, rowversion
677+
# and generated always columns (temporal periods, ledger metadata) cannot be written, and they are also
678+
# what breaks the implicit positional mapping of SqlBulkCopy: it counts a computed column (the server
679+
# then rejects the insert) and silently drops the source column that lands on a rowversion column,
680+
# shifting every column behind it by one (see #10661). GeneratedAlwaysType is only supported by SMO on
681+
# SQL Server 2016 and later, so it has to be guarded by the version.
680682
# Refresh the columns collection to ensure it's populated
681683
$desttable.Columns.Refresh()
682-
$destColumns = @($desttable.Columns | Where-Object { -not $PSItem.Computed -and $PSItem.DataType.SqlDataType -ne "Timestamp" } | Select-Object -ExpandProperty Name)
684+
$destColumns = @($desttable.Columns | Where-Object { -not $PSItem.Computed -and $PSItem.DataType.SqlDataType -ne "Timestamp" -and -not ($destServer.VersionMajor -ge 13 -and $PSItem.GeneratedAlwaysType -ne "None") } | Select-Object -ExpandProperty Name)
683685
Write-Message -Level Verbose -Message "Destination table has $($destColumns.Count) writable columns"
684686

685687
# The legacy bulk copy library uses a 4 byte integer to track the RowsCopied, so the only option is to use
@@ -712,7 +714,7 @@ function Copy-DbaDbTableData {
712714
# Custom queries may have different column names/aliases, so they are mapped by position
713715
# Appending -ForceExplicitMapping will override this behaviour and keep explicit column mapping
714716
if (-not (Test-Bound -ParameterName Query) -or $ForceExplicitMapping) {
715-
# Map only columns that exist in both source and destination (excluding computed and rowversion columns)
717+
# Map only columns that exist in both source and destination (excluding computed, rowversion and generated always columns)
716718
for ($i = 0; $i -lt $reader.FieldCount; $i++) {
717719
$sourceColumn = $reader.GetName($i)
718720
if ($destColumns -contains $sourceColumn) {
@@ -723,9 +725,9 @@ function Copy-DbaDbTableData {
723725
}
724726
} else {
725727
# Map the query columns by position onto the writable destination columns. This is what SqlBulkCopy does
726-
# on its own, except that its list also contains the computed and rowversion columns (see above).
728+
# on its own, except that its list also contains the computed, rowversion and generated always columns (see above).
727729
if ($reader.FieldCount -gt $destColumns.Count) {
728-
$columnCountMessage = "The query returns $($reader.FieldCount) columns but $fqtndest has only $($destColumns.Count) writable columns. Computed and rowversion columns cannot be written and do not count."
730+
$columnCountMessage = "The query returns $($reader.FieldCount) columns but $fqtndest has only $($destColumns.Count) writable columns. Computed, rowversion and generated always columns cannot be written and do not count."
729731
$reader.Close()
730732
throw $columnCountMessage
731733
}

tests/Copy-DbaDbTableData.Tests.ps1

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,14 @@ Describe $CommandName -Tag IntegrationTests {
217217
}
218218

219219
Context "When using Query without ForceExplicitMapping and the destination has unwritable columns" {
220+
BeforeDiscovery {
221+
# GENERATED ALWAYS columns arrived with SQL Server 2016, so the temporal scenario below cannot
222+
# be built before that. The value decides a Skip, which Pester needs while it discovers the
223+
# tests, so it cannot be read in BeforeAll.
224+
$discoveryDestServer = Connect-DbaInstance -SqlInstance $TestConfig.InstanceCopy2
225+
$destSupportsTemporal = $discoveryDestServer.VersionMajor -ge 13
226+
}
227+
220228
BeforeAll {
221229
$PSDefaultParameterValues["*-Dba*:EnableException"] = $true
222230

@@ -227,6 +235,11 @@ Describe $CommandName -Tag IntegrationTests {
227235
$null = $destinationDb.Query("CREATE TABLE dbo.dbatoolsci_positional_dest (Id INT, A INT, Computed AS (A * 10), RV ROWVERSION, B INT, C INT)")
228236
$null = $destinationDb.Query("CREATE TABLE dbo.dbatoolsci_positional_identity (Id INT IDENTITY(1, 1), A INT, B INT, C INT)")
229237
$null = $destinationDb.Query("CREATE TABLE dbo.dbatoolsci_positional_rowversion (Id INT, A INT, RV ROWVERSION, B INT, C INT)")
238+
if ($destinationDb.Parent.VersionMajor -ge 13) {
239+
# The period columns of a temporal table are GENERATED ALWAYS: not computed, not rowversion,
240+
# but just as unwritable, and interleaved with the writable columns here on purpose.
241+
$null = $destinationDb.Query("CREATE TABLE dbo.dbatoolsci_positional_temporal (Id INT PRIMARY KEY, A INT, ValidFrom DATETIME2 GENERATED ALWAYS AS ROW START NOT NULL, B INT, ValidTo DATETIME2 GENERATED ALWAYS AS ROW END NOT NULL, C INT, PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.dbatoolsci_positional_temporal_history))")
242+
}
230243

231244
$splatPositional = @{
232245
SqlInstance = $TestConfig.InstanceCopy1
@@ -247,6 +260,13 @@ Describe $CommandName -Tag IntegrationTests {
247260
$null = $destinationDb.Query("DROP TABLE IF EXISTS dbo.dbatoolsci_positional_dest")
248261
$null = $destinationDb.Query("DROP TABLE IF EXISTS dbo.dbatoolsci_positional_identity")
249262
$null = $destinationDb.Query("DROP TABLE IF EXISTS dbo.dbatoolsci_positional_rowversion")
263+
$destinationDb.Tables.Refresh()
264+
if ($destinationDb.Tables | Where-Object Name -eq "dbatoolsci_positional_temporal") {
265+
# System versioning has to be turned off before the temporal table can be dropped.
266+
$null = $destinationDb.Query("ALTER TABLE dbo.dbatoolsci_positional_temporal SET (SYSTEM_VERSIONING = OFF)")
267+
$null = $destinationDb.Query("DROP TABLE dbo.dbatoolsci_positional_temporal")
268+
$null = $destinationDb.Query("DROP TABLE IF EXISTS dbo.dbatoolsci_positional_temporal_history")
269+
}
250270

251271
$PSDefaultParameterValues.Remove("*-Dba*:EnableException")
252272
}
@@ -277,6 +297,22 @@ Describe $CommandName -Tag IntegrationTests {
277297
$destData.C | Should -Be @(33, 333)
278298
}
279299

300+
It "Does not count the generated always columns of a temporal destination" -Skip:(-not $destSupportsTemporal) {
301+
# The period columns are GENERATED ALWAYS, so the server refuses explicit values for them.
302+
# A positional mapping that counts them maps writable source columns onto them and fails.
303+
$splatTemporal = $splatPositional.Clone()
304+
$splatTemporal.DestinationTable = "dbatoolsci_positional_temporal"
305+
$result = Copy-DbaDbTableData @splatTemporal
306+
$WarnVar | Should -BeNullOrEmpty
307+
$result.RowsCopied | Should -Be 2
308+
309+
$destData = $destinationDb.Query("SELECT Id, A, ValidFrom, B, ValidTo, C FROM dbo.dbatoolsci_positional_temporal ORDER BY Id")
310+
$destData.A | Should -Be @(11, 111)
311+
$destData.B | Should -Be @(22, 222)
312+
$destData.C | Should -Be @(33, 333)
313+
$destData.ValidFrom | Should -Not -BeNullOrEmpty
314+
}
315+
280316
It "Still ignores the identity placeholder unless KeepIdentity is used" {
281317
$splatIdentity = $splatPositional.Clone()
282318
$splatIdentity.Query = "SELECT 0, A, B, C FROM dbo.dbatoolsci_positional_source ORDER BY Id"

0 commit comments

Comments
 (0)