Repository navigation
Expand file tree
/
Copy pathCopy-DbaDbTableData.ps1
More file actions
820 lines (709 loc) · 49.3 KB
/
Copy pathCopy-DbaDbTableData.ps1
File metadata and controls
820 lines (709 loc) · 49.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
function Copy-DbaDbTableData {
<#
.SYNOPSIS
Streams table data between SQL Server instances using high-performance bulk copy operations.
.DESCRIPTION
Copies data between SQL Server tables using SQL Bulk Copy for maximum performance and minimal memory usage.
Unlike Invoke-DbaQuery and Write-DbaDbTableData which buffer entire table contents in memory, this function streams data directly from source to destination.
This approach prevents memory exhaustion when copying large tables and provides the fastest data transfer method available.
Supports copying between different servers, databases, and schemas while preserving data integrity options like identity values, constraints, and triggers.
Can automatically create destination tables based on source table structure, making it ideal for data migration, ETL processes, and table replication tasks.
Note: System-versioned temporal tables require special handling. The -AutoCreateTable parameter does not support temporal table creation.
When copying to an existing temporal table, use the -Query parameter to exclude GENERATED ALWAYS columns (e.g., ValidFrom, ValidTo).
Temporal version history cannot be preserved as these values are system-managed.
.PARAMETER SqlInstance
Source SQL Server.You must have sysadmin access and server version must be SQL Server version 2000 or greater.
.PARAMETER SqlCredential
Login to the source instance using alternative credentials. Accepts PowerShell credentials (Get-Credential).
Windows Authentication, SQL Server Authentication, Active Directory - Password, and Active Directory - Integrated are all supported.
For MFA support, please use Connect-DbaInstance.
.PARAMETER Destination
Target SQL Server instance where table data will be copied to. Accepts one or more SQL Server instances.
Specify this when copying data to a different server than the source, or when doing cross-instance data transfers.
.PARAMETER DestinationSqlCredential
Alternative credentials for authenticating to the destination instance. Required when your current Windows credentials don't have access to the target server.
Use this for cross-domain scenarios, SQL authentication, or when the destination requires different security context than the source.
.PARAMETER Database
Source database containing the table or view to copy data from. Required when not using pipeline input.
Must exist on the source instance and your account must have read permissions on the specified objects.
.PARAMETER DestinationDatabase
Target database where copied data will be inserted. Defaults to the same database name as the source.
Use this when copying data to a different database name on the destination instance or for cross-database copies within the same server.
.PARAMETER Table
Source table name to copy data from. Accepts 2-part ([schema].[table]) or 3-part ([database].[schema].[table]) names.
Use square brackets for names with spaces or special characters. Cannot be used simultaneously with the View parameter.
.PARAMETER View
Source view name to copy data from. Accepts 2-part ([schema].[view]) or 3-part ([database].[schema].[view]) names.
Use square brackets for names with spaces or special characters. Cannot be used simultaneously with the Table parameter.
.PARAMETER DestinationTable
Target table name where data will be inserted. Defaults to the same name as the source table.
Use this when copying to a table with a different name or schema, or when specifying 3-part names for cross-database operations.
.PARAMETER Query
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.
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.
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.
If the destination table has an identity column, include a placeholder value (e.g., 0) in your SELECT list at that position.
The placeholder will be ignored and the identity value auto-generated unless -KeepIdentity is specified.
.PARAMETER ForceExplicitMapping
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.
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.
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.
.PARAMETER AutoCreateTable
Automatically creates the destination table if it doesn't exist, using the same structure as the source table.
Essential for initial data migrations or when copying to new environments where destination tables haven't been created yet.
Azure external source tables are created as ordinary destination tables containing only the source column names, data types, collations, and nullability.
.PARAMETER BatchSize
Number of rows to process in each bulk copy batch. Defaults to 50000 rows.
Reduce this value for memory-constrained systems or increase it for faster transfers when copying large tables with sufficient memory.
.PARAMETER NotifyAfter
Number of rows to process before displaying progress updates. Defaults to 5000 rows.
Set to a lower value for frequent updates on small tables or higher for less verbose output on large table copies.
.PARAMETER NoTableLock
Disables the default table lock (TABLOCK) on the destination table during bulk copy operations.
Use this when you need to allow concurrent read access to the destination table, though it may reduce bulk copy performance.
.PARAMETER CheckConstraints
Enables constraint checking during bulk copy operations. By default, constraints are ignored for performance.
Use this when data integrity validation is more important than copy speed, particularly when copying from untrusted sources.
.PARAMETER FireTriggers
Enables INSERT triggers to fire during bulk copy operations. By default, triggers are bypassed for performance.
Use this when you need audit trails, logging, or other trigger-based business logic to execute during the data copy.
.PARAMETER KeepIdentity
Preserves the original identity column values from the source table. By default, the destination generates new identity values.
Essential when copying reference tables or when you need to maintain exact ID relationships across systems.
.PARAMETER KeepNulls
Preserves NULL values from the source data instead of replacing them with destination column defaults.
Use this when you need exact source data reproduction, especially when NULL has specific business meaning versus default values.
.PARAMETER Truncate
Removes all existing data from the destination table before copying new data. Prompts for confirmation unless -Force is used.
Essential for refresh scenarios where you want to replace all destination data with current source data.
.PARAMETER BulkCopyTimeout
Maximum time in seconds to wait for bulk copy operations to complete. Defaults to 5000 seconds (83 minutes).
Increase this value when copying very large tables that may take longer than the default timeout period.
.PARAMETER CommandTimeout
Maximum time in seconds to wait for the source query execution before timing out. Defaults to 0 (no timeout).
Set this when querying large tables or complex views that may take longer to read than typical query timeouts allow.
.PARAMETER InputObject
Accepts table or view objects from Get-DbaDbTable or Get-DbaDbView for pipeline operations.
Use this to copy multiple tables efficiently by piping them from discovery commands.
.PARAMETER WhatIf
If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
.PARAMETER Confirm
If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
.PARAMETER EnableException
By default, when something goes wrong we try to catch it, interpret it and give you a friendly warning message.
This avoids overwhelming you with "sea of red" exceptions, but is inconvenient because it basically disables advanced scripting.
Using this switch turns this "nice by default" feature off and enables you to catch exceptions with your own try/catch.
.PARAMETER UseDefaultFileGroup
Creates new tables using the destination database's default filegroup instead of matching the source table's filegroup name.
Use this when the destination database has different filegroup configurations or when you want all copied tables in the PRIMARY filegroup.
.PARAMETER ScriptingOptionsObject
A scripting options object created by New-DbaScriptingOption that controls how the destination table is scripted when -AutoCreateTable is used.
Use this to control which table properties are included in the CREATE TABLE script, such as indexes, constraints, triggers, and extended properties.
When specified, this takes precedence over -UseDefaultFileGroup. Use New-DbaScriptingOption to create the object and set the desired properties.
.OUTPUTS
PSCustomObject
Returns one object per destination instance per source table copied. If copying one table to two destination instances, returns two objects. If piping multiple tables to one destination, returns one object per table.
Properties:
- SourceInstance: The name of the source SQL Server instance
- SourceDatabase: The name of the source database
- SourceDatabaseID: The unique identifier (ID) of the source database
- SourceSchema: The schema name of the source table or view
- SourceTable: The name of the source table or view
- DestinationInstance: The name of the destination SQL Server instance
- DestinationDatabase: The name of the destination database
- DestinationDatabaseID: The unique identifier (ID) of the destination database
- DestinationSchema: The schema name of the destination table
- DestinationTable: The name of the destination table
- RowsCopied: The total number of rows that were successfully copied (Int64). Supports values greater than 2.1 billion rows.
- Elapsed: The elapsed time as a TimeSpan object representing the duration of the copy operation
.NOTES
Tags: Table, Data
Author: Simone Bizzotto (@niphlod)
Website: https://dbatools.io
Copyright: (c) 2018 by dbatools, licensed under MIT
License: MIT https://opensource.org/licenses/MIT
Column Mapping Behavior:
When using the -Query parameter, columns are mapped by ordinal position (first column to first column, second to second, etc.), not by name.
If your destination table has an identity column, you must include a placeholder value in your SELECT query at that column's position.
The placeholder value (e.g., 0 or NULL) will be ignored and SQL Server will generate the identity value automatically.
Use -KeepIdentity if you want to preserve the actual source identity values instead of generating new ones.
.LINK
https://dbatools.io/Copy-DbaDbTableData
.EXAMPLE
PS C:\> Copy-DbaDbTableData -SqlInstance sql1 -Destination sql2 -Database dbatools_from -Table dbo.test_table
Copies all the data from table dbo.test_table (2-part name) in database dbatools_from on sql1 to table test_table in database dbatools_from on sql2.
.EXAMPLE
PS C:\> Copy-DbaDbTableData -SqlInstance sql1 -Destination sql2 -Database dbatools_from -DestinationDatabase dbatools_dest -Table [Schema].[test table]
Copies all the data from table [Schema].[test table] (2-part name) in database dbatools_from on sql1 to table [Schema].[test table] in database dbatools_dest on sql2
.EXAMPLE
PS C:\> Get-DbaDbTable -SqlInstance sql1 -Database tempdb -Table tb1, tb2 | Copy-DbaDbTableData -DestinationTable tb3
Copies all data from tables tb1 and tb2 in tempdb on sql1 to tb3 in tempdb on sql1
.EXAMPLE
PS C:\> Get-DbaDbTable -SqlInstance sql1 -Database tempdb -Table tb1, tb2 | Copy-DbaDbTableData -Destination sql2
Copies data from tb1 and tb2 in tempdb on sql1 to the same table in tempdb on sql2
.EXAMPLE
PS C:\> Copy-DbaDbTableData -SqlInstance sql1 -Destination sql2 -Database dbatools_from -Table test_table -KeepIdentity -Truncate
Copies all the data in table test_table from sql1 to sql2, using the database dbatools_from, keeping identity columns and truncating the destination
.EXAMPLE
PS C:\> $params = @{
>> SqlInstance = 'sql1'
>> Destination = 'sql2'
>> Database = 'dbatools_from'
>> DestinationDatabase = 'dbatools_dest'
>> Table = '[Schema].[Table]'
>> DestinationTable = '[dbo].[Table.Copy]'
>> KeepIdentity = $true
>> KeepNulls = $true
>> Truncate = $true
>> BatchSize = 10000
>> }
>>
PS C:\> Copy-DbaDbTableData @params
Copies all the data from table [Schema].[Table] (2-part name) in database dbatools_from on sql1 to table [dbo].[Table.Copy] in database dbatools_dest on sql2
Keeps identity columns and Nulls, truncates the destination and processes in BatchSize of 10000.
.EXAMPLE
PS C:\> $params = @{
>> SqlInstance = 'server1'
>> Destination = 'server1'
>> Database = 'AdventureWorks2017'
>> DestinationDatabase = 'AdventureWorks2017'
>> DestinationTable = '[AdventureWorks2017].[Person].[EmailPromotion]'
>> BatchSize = 10000
>> Table = '[OtherDb].[Person].[Person]'
>> Query = "SELECT * FROM [OtherDb].[Person].[Person] where EmailPromotion = 1"
>> }
>>
PS C:\> Copy-DbaDbTableData @params
Copies data returned from the query on server1 into the AdventureWorks2017 on server1, using a 3-part name for the DestinationTable parameter. Copy is processed in BatchSize of 10000 rows.
See the Query param documentation for more details.
.EXAMPLE
Copy-DbaDbTableData -SqlInstance sql1 -Database tempdb -View [tempdb].[dbo].[vw1] -DestinationTable [SampleDb].[SampleSchema].[SampleTable] -AutoCreateTable
Copies all data from [tempdb].[dbo].[vw1] (3-part name) view on instance sql1 to an auto-created table [SampleDb].[SampleSchema].[SampleTable] on instance sql1
.EXAMPLE
PS C:\> $so = New-DbaScriptingOption
PS C:\> $so.DriAll = $true
PS C:\> $so.Indexes = $true
PS C:\> $so.NoFileGroup = $true
PS C:\> Copy-DbaDbTableData -SqlInstance sql1 -Destination sql2 -Database db1 -Table dbo.MyTable -AutoCreateTable -ScriptingOptionsObject $so
Copies all data from dbo.MyTable in db1 on sql1 to an auto-created table on sql2, scripting the destination table with all constraints, indexes, and using the default filegroup.
.EXAMPLE
PS C:\> $params = @{
>> SqlInstance = "SERVER001"
>> Database = "MyDatabaseName"
>> View = "sys.schemas"
>> Query = "SELECT 0, DB_NAME(), [name] FROM sys.schemas"
>> Destination = "SERVER002"
>> DestinationDatabase = "MyOtherDatabaseName"
>> DestinationTable = "syscollect.stage_map_schema_id"
>> }
>>
PS C:\> Copy-DbaDbTableData @params
Copies schema data from sys.schemas to a table with an identity column.
The first SELECT column (0) is a placeholder for the destination's identity column.
Since -KeepIdentity is not specified, the destination auto-generates identity values and ignores the placeholder.
Columns are mapped by position: 0 → ID (ignored), DB_NAME() → database_name, [name] → schema_name.
#>
[CmdletBinding(DefaultParameterSetName = "Default", SupportsShouldProcess)]
param (
[DbaInstanceParameter]$SqlInstance,
[PSCredential]$SqlCredential,
[DbaInstanceParameter[]]$Destination,
[PSCredential]$DestinationSqlCredential,
[string]$Database,
[string]$DestinationDatabase,
[string[]]$Table,
[string[]]$View, # Copy-DbaDbTableData and Copy-DbaDbViewData are consolidated to reduce maintenance cost, so this param is specific to calls of Copy-DbaDbViewData
[string]$Query,
[switch]$ForceExplicitMapping,
[switch]$AutoCreateTable,
[int]$BatchSize = 50000,
[int]$NotifyAfter = 5000,
[string]$DestinationTable,
[switch]$NoTableLock,
[switch]$CheckConstraints,
[switch]$FireTriggers,
[switch]$KeepIdentity,
[switch]$KeepNulls,
[switch]$Truncate,
[int]$BulkCopyTimeout = 5000,
[int]$CommandTimeout = 0,
[switch]$UseDefaultFileGroup,
[Microsoft.SqlServer.Management.Smo.ScriptingOptions]$ScriptingOptionsObject,
[Parameter(ValueFromPipeline)]
[Microsoft.SqlServer.Management.Smo.TableViewBase[]]$InputObject,
[switch]$EnableException
)
begin {
$bulkCopyOptions = 0
$options = "TableLock", "CheckConstraints", "FireTriggers", "KeepIdentity", "KeepNulls", "Default"
foreach ($option in $options) {
$optionValue = Get-Variable $option -ValueOnly -ErrorAction SilentlyContinue
if ($option -eq "TableLock" -and (!$NoTableLock)) {
$optionValue = $true
}
if ($optionValue -eq $true) {
$bulkCopyOptions += $([Microsoft.Data.SqlClient.SqlBulkCopyOptions]::$option).value__
}
}
if (Test-Bound -ParameterName ScriptingOptionsObject) {
$defaultFGScriptingOption = @{
ScriptingOptionsObject = $ScriptingOptionsObject
}
} else {
$defaultFGScriptingOption = @{
ScriptingOptionsObject = $(
$so = New-DbaScriptingOption
$so.NoFileGroup = $UseDefaultFileGroup
$so
)
}
}
}
process {
if ((Test-Bound -Not -ParameterName InputObject) -and ((Test-Bound -Not -ParameterName SqlInstance, Database -And) -or (Test-Bound -Not -ParameterName Table, View))) {
if (Test-Bound -ParameterName Query) {
# Without the hint at Query the message reads as if SqlInstance or Database were missing (see #10676).
Stop-Function -Message "When using Query, you still have to specify SqlInstance, Database and [View|Table]. The query determines the data that is copied, but the command needs the table or view as the source object for its metadata."
} else {
Stop-Function -Message "You must pipe in a table or specify SqlInstance, Database and [View|Table]."
}
return
}
# determine if -Table or -View was used
$SourceObject = $Table
if ((Test-Bound -ParameterName View) -and (Test-Bound -ParameterName Table)) {
Stop-Function -Message "Only one of [View|Table] may be specified."
return
} elseif ( Test-Bound -ParameterName View ) {
$SourceObject = $View
}
if ($SqlInstance) {
if ((Test-Bound -Not -ParameterName Destination, DestinationDatabase, DestinationTable)) {
Stop-Function -Message "Cannot copy $SourceObject into itself. One of the parameters Destination (Server), DestinationDatabase, or DestinationTable must be specified " -Target $SourceObject
return
}
try {
# Ensuring that the default db connection is to the passed in $Database instead of the master db. This way callers don't have to remember to do 3 part queries.
$server = Connect-DbaInstance -SqlInstance $SqlInstance -SqlCredential $SqlCredential -Database $Database
} catch {
Stop-Function -Message "Failure" -Category ConnectionError -ErrorRecord $_ -Target $SqlInstance
return
}
try {
foreach ($sourceDataObject in $SourceObject) {
$dbObject = $null
if ( Test-Bound -ParameterName View ) {
$dbObject = Get-DbaDbView -SqlInstance $server -View $sourceDataObject -Database $Database -EnableException -Verbose:$false
} else {
$dbObject = Get-DbaDbTable -SqlInstance $server -Table $sourceDataObject -Database $Database -EnableException -Verbose:$false
}
if ($dbObject.Count -eq 1) {
$InputObject += $dbObject
} else {
Stop-Function -Message "The object $sourceDataObject matches $($dbObject.Count) objects. Unable to determine which object to copy" -Continue
}
}
} catch {
Stop-Function -Message "Unable to determine source : $SourceObject"
return
}
}
foreach ($sqlObject in $InputObject) {
$Database = $sqlObject.Parent.Name
$server = $sqlObject.Parent.Parent
if ((Test-Bound -Not -ParameterName DestinationTable)) {
$DestinationTable = '[' + $sqlObject.Schema + '].[' + $sqlObject.Name + ']'
}
$newTableParts = Get-ObjectNameParts -ObjectName $DestinationTable
#using FQTN to determine database name
if ($newTableParts.Database) {
$DestinationDatabase = $newTableParts.Database
} elseif ((Test-Bound -Not -ParameterName DestinationDatabase)) {
$DestinationDatabase = $Database
}
if (-not $Destination) {
$Destination = $server
}
foreach ($destinstance in $Destination) {
try {
$destServer = Connect-DbaInstance -SqlInstance $destinstance -SqlCredential $DestinationSqlCredential -Database $DestinationDatabase
} catch {
Stop-Function -Message "Failure" -Category ConnectionError -ErrorRecord $_ -Target $destinstance -Continue
}
if ($DestinationDatabase -notin $destServer.Databases.Name) {
Stop-Function -Message "Database $DestinationDatabase doesn't exist on $destServer"
return
}
$desttable = Get-DbaDbTable -SqlInstance $destServer -Table $DestinationTable -Database $DestinationDatabase -Verbose:$false | Select-Object -First 1
if (-not $desttable -and $AutoCreateTable) {
try {
$tablescript = $null
$schemaNameToReplace = $null
$tableNameToReplace = $null
$isExternalTableScript = $false
$isExternalTable = $false
if (Test-Bound -Not -ParameterName View) {
try {
$isExternalTable = $sqlObject.IsExternal
} catch {
# Older SMO builds can throw while retrieving IsExternal, so preserve the standard scripting path.
Write-Message -Level Debug -Message "Unable to retrieve IsExternal for $sqlObject; using standard table scripting. Error: $_"
}
}
if ( Test-Bound -ParameterName View ) {
#select view into tempdb to generate script
$tempTableName = "$($sqlObject.Name)_table"
$createquery = "SELECT * INTO tempdb..$tempTableName FROM [$($sqlObject.Schema)].[$($sqlObject.Name)] WHERE 1=2"
Invoke-DbaQuery -SqlInstance $server -Database $Database -Query $createquery -EnableException
#refreshing table list to make sure get-dbadbtable will find the new table
$server.Databases['tempdb'].Tables.Refresh($true)
$tempTable = Get-DbaDbTable -SqlInstance $server -Database tempdb -Table $tempTableName
# need these for generating the script of the table and then replacing the schema and name
$schemaNameToReplace = $tempTable.Schema
$tableNameToReplace = $tempTable.Name
$tablescript = $tempTable |
Export-DbaScript @defaultFGScriptingOption -Passthru |
Out-String
# cleanup
Invoke-DbaQuery -SqlInstance $server -Database $Database -Query "DROP TABLE tempdb..$tempTableName" -EnableException
} elseif ($isExternalTable) {
$columnDefinitions = foreach ($column in $sqlObject.Columns) {
$columnName = $column.Name.Replace("]", "]]")
$dataType = $column.DataType
$dataTypeName = $dataType.Name
$sqlDataType = $dataType.SqlDataType.ToString()
if ([string]::IsNullOrEmpty($dataTypeName) -or [string]::IsNullOrEmpty($sqlDataType)) {
throw "Unable to render data type for external table column $($column.Name)."
}
switch -Regex ($sqlDataType) {
"^(Binary|Char|NChar|NVarChar|VarBinary|VarChar)$" {
if ($dataType.MaximumLength -lt 0) {
$dataTypeDeclaration = "$dataTypeName(max)"
} else {
$dataTypeDeclaration = "$dataTypeName($($dataType.MaximumLength))"
}
break
}
"^(NVarCharMax|VarBinaryMax|VarCharMax)$" {
$dataTypeDeclaration = "$dataTypeName(max)"
break
}
"^(Decimal|Numeric)$" {
$dataTypeDeclaration = "$dataTypeName($($dataType.NumericPrecision),$($dataType.NumericScale))"
break
}
"^(DateTime2|DateTimeOffset|Time)$" {
$dataTypeDeclaration = "$dataTypeName($($dataType.NumericScale))"
break
}
"Float" {
if ($dataType.NumericPrecision -gt 0) {
$dataTypeDeclaration = "$dataTypeName($($dataType.NumericPrecision))"
} else {
$dataTypeDeclaration = $dataTypeName
}
break
}
"^(UserDefinedDataType|UserDefinedType)$" {
$escapedTypeName = $dataTypeName.Replace("]", "]]")
if ($dataType.Schema) {
$escapedTypeSchema = $dataType.Schema.Replace("]", "]]")
$dataTypeDeclaration = "[$escapedTypeSchema].[$escapedTypeName]"
} else {
$dataTypeDeclaration = "[$escapedTypeName]"
}
break
}
"Vector" {
if ($dataType.VectorDimensions -le 0) {
throw "Unable to render vector dimensions for external table column $($column.Name)."
}
if ($dataType.VectorBaseType) {
$dataTypeDeclaration = "$dataTypeName($($dataType.VectorDimensions), $($dataType.VectorBaseType))"
} else {
$dataTypeDeclaration = "$dataTypeName($($dataType.VectorDimensions))"
}
break
}
default {
$dataTypeDeclaration = $dataTypeName
}
}
if ($column.Collation) {
$collationClause = " COLLATE $($column.Collation)"
} else {
$collationClause = ""
}
if ($column.Nullable) {
$nullability = "NULL"
} else {
$nullability = "NOT NULL"
}
" [$columnName] $dataTypeDeclaration$collationClause $nullability"
}
if (-not $columnDefinitions) {
throw "Unable to render external table $sqlObject because it has no columns."
}
if ($newTableParts.Schema) {
$targetSchema = $newTableParts.Schema
} else {
$targetSchema = $sqlObject.Schema
}
if ($newTableParts.Name) {
$targetName = $newTableParts.Name
} else {
$targetName = $sqlObject.Name
}
$escapedSchema = $targetSchema.Replace("]", "]]")
$escapedName = $targetName.Replace("]", "]]")
$tablescript = "CREATE TABLE [$escapedSchema].[$escapedName] (`n$($columnDefinitions -join ",`n")`n);"
$isExternalTableScript = $true
} else {
$tablescript = $sqlObject |
Export-DbaScript @defaultFGScriptingOption -Passthru |
Out-String
$schemaNameToReplace = $sqlObject.Schema
$tableNameToReplace = $sqlObject.Name
}
if (-not $isExternalTableScript) {
#replacing table name
if ($newTableParts.Name) {
$rX = "(CREATE|ALTER)( TABLE \[$([regex]::Escape($schemaNameToReplace))\]\.\[)$([regex]::Escape($tableNameToReplace))(\])"
$tablescript = $tablescript -replace $rX, "`${1}`${2}$($newTableParts.Name)`${3}"
}
#replacing table schema
if ($newTableParts.Schema) {
$rX = "(CREATE|ALTER)( TABLE \[)$([regex]::Escape($schemaNameToReplace))(\]\.\[$([regex]::Escape($newTableParts.Name))\])"
$tablescript = $tablescript -replace $rX, "`${1}`${2}$($newTableParts.Schema)`${3}"
}
}
if ($PSCmdlet.ShouldProcess($destServer, "Creating new table: $DestinationTable")) {
Write-Message -Message "New table script: $tablescript" -Level VeryVerbose
Invoke-DbaQuery -SqlInstance $destServer -Database $DestinationDatabase -Query "$tablescript" -EnableException # add some string assurance there
#table list was updated, let's grab a fresh one
$destServer.Databases[$DestinationDatabase].Tables.Refresh()
$desttable = Get-DbaDbTable -SqlInstance $destServer -Table $DestinationTable -Database $DestinationDatabase -Verbose:$false
Write-Message -Message "New table created: $desttable" -Level Verbose
}
} catch {
Stop-Function -Message "Unable to determine destination table: $DestinationTable" -ErrorRecord $_
return
}
}
if (-not $desttable) {
Stop-Function -Message "Table $DestinationTable cannot be found in $DestinationDatabase. Use -AutoCreateTable to automatically create the table on the destination." -Continue
}
if ($server.DatabaseEngineType -eq "SqlAzureDatabase") {
$fqtnfrom = "$sqlObject"
} else {
$fqtnfrom = "$($server.Databases[$Database]).$sqlObject"
}
if ($destServer.DatabaseEngineType -eq "SqlAzureDatabase") {
$fqtndest = "$desttable"
} else {
$fqtndest = "$($destServer.Databases[$DestinationDatabase]).$desttable"
}
if ($fqtndest -eq $fqtnfrom -and $server.Name -eq $destServer.Name -and (Test-Bound -ParameterName Query -Not)) {
Stop-Function -Message "Cannot copy $fqtnfrom on $($server.Name) into $fqtndest on ($destServer.Name). Source and Destination must be different " -Target $Table
return
}
if (Test-Bound -ParameterName Query -Not) {
# Build ORDER BY clause to ensure consistent row order
# This prevents data misalignment when copying tables without explicit ordering
$orderByClause = ""
# Refresh indexes to ensure we have current metadata
$sqlObject.Indexes.Refresh()
# Option 1: Use clustered index columns for ordering (most common and performant)
$clusteredIndex = $sqlObject.Indexes | Where-Object IsClustered -eq $true | Select-Object -First 1
if ($clusteredIndex) {
$orderColumns = $clusteredIndex.IndexedColumns | Sort-Object IndexKeyPosition | ForEach-Object {
$colName = $_.Name
$descending = if ($_.Descending) { " DESC" } else { "" }
"[$colName]$descending"
}
if ($orderColumns) {
$orderByClause = " ORDER BY " + ($orderColumns -join ", ")
Write-Message -Level Verbose -Message "Using clustered index for ordering: $orderByClause"
}
}
# Option 2: If no clustered index, try primary key
if (-not $orderByClause) {
$primaryKey = $sqlObject.Indexes | Where-Object IndexKeyType -eq "DriPrimaryKey" | Select-Object -First 1
if ($primaryKey) {
$orderColumns = $primaryKey.IndexedColumns | Sort-Object IndexKeyPosition | ForEach-Object {
$colName = $_.Name
$descending = if ($_.Descending) { " DESC" } else { "" }
"[$colName]$descending"
}
if ($orderColumns) {
$orderByClause = " ORDER BY " + ($orderColumns -join ", ")
Write-Message -Level Verbose -Message "Using primary key for ordering: $orderByClause"
}
}
}
# Option 3: If using KeepIdentity and an identity column exists, order by it
if (-not $orderByClause -and $KeepIdentity) {
$identityColumn = $sqlObject.Columns | Where-Object Identity -eq $true | Select-Object -First 1
if ($identityColumn) {
$orderByClause = " ORDER BY [$($identityColumn.Name)]"
Write-Message -Level Verbose -Message "Using identity column for ordering: $orderByClause"
}
}
# If no ordering found, log a warning for tables without proper keys
if (-not $orderByClause) {
Write-Message -Level Verbose -Message "No clustered index, primary key, or identity column found for ordering. Row order is not guaranteed."
}
$Query = "SELECT * FROM $fqtnfrom$orderByClause"
$sourceLabel = $fqtnfrom
} else {
$sourceLabel = "Query"
}
$bulkCopyConnection = $null
$cmd = $null
$reader = $null
$bulkCopy = $null
try {
if ($Truncate -eq $true) {
if ($Pscmdlet.ShouldProcess($destServer, "Truncating table $fqtndest")) {
Invoke-DbaQuery -SqlInstance $destServer -Database $DestinationDatabase -Query "TRUNCATE TABLE $fqtndest" -EnableException
}
}
if ($Pscmdlet.ShouldProcess($server, "Copy data from $sourceLabel")) {
$cmd = $server.ConnectionContext.SqlConnectionObject.CreateCommand()
$cmd.CommandTimeout = $CommandTimeout
$cmd.CommandText = $Query
if ($server.ConnectionContext.IsOpen -eq $false) {
$server.ConnectionContext.SqlConnectionObject.Open()
}
$bulkCopyConnection = $destServer.ConnectionContext.SqlConnectionObject.Clone()
$bulkCopyConnection.Open()
$bulkCopy = New-Object Microsoft.Data.SqlClient.SqlBulkCopy($bulkCopyConnection, $bulkCopyOptions, $null)
$bulkCopy.DestinationTableName = $fqtndest
$bulkCopy.EnableStreaming = $true
$bulkCopy.BatchSize = $BatchSize
$bulkCopy.NotifyAfter = $NotifyAfter
$bulkCopy.BulkCopyTimeout = $BulkCopyTimeout
# Get list of writable columns from destination table to avoid insert failures. Computed, rowversion
# and generated always columns (temporal periods, ledger metadata) cannot be written, and they are also
# what breaks the implicit positional mapping of SqlBulkCopy: it counts a computed column (the server
# then rejects the insert) and silently drops the source column that lands on a rowversion column,
# shifting every column behind it by one (see #10661). GeneratedAlwaysType is only supported by SMO on
# SQL Server 2016 and later and on Azure SQL Database, so it has to be guarded the same way. Azure SQL
# Database is named on its own because its product version says 12 no matter what it supports.
$destSupportsGeneratedAlways = $destServer.VersionMajor -ge 13 -or $destServer.DatabaseEngineType -eq "SqlAzureDatabase"
# Refresh the columns collection to ensure it's populated
$desttable.Columns.Refresh()
$destColumns = @($desttable.Columns | Where-Object { -not $PSItem.Computed -and $PSItem.DataType.SqlDataType -ne "Timestamp" -and -not ($destSupportsGeneratedAlways -and $PSItem.GeneratedAlwaysType -ne "None") } | Select-Object -ExpandProperty Name)
Write-Message -Level Verbose -Message "Destination table has $($destColumns.Count) writable columns"
# The legacy bulk copy library uses a 4 byte integer to track the RowsCopied, so the only option is to use
# integer wrap so that copy operations of row counts greater than [int32]::MaxValue will report accurate numbers.
# See https://github.com/dataplat/dbatools/issues/6927 for more details
$script:prevRowsCopied = [int64]0
$script:totalRowsCopied = [int64]0
$elapsed = [System.Diagnostics.Stopwatch]::StartNew()
# Add RowCount output
$bulkCopy.Add_SqlRowsCopied( {
$script:totalRowsCopied += (Get-AdjustedTotalRowsCopied -ReportedRowsCopied $args[1].RowsCopied -PreviousRowsCopied $script:prevRowsCopied).NewRowCountAdded
$tstamp = $(Get-Date -format 'yyyyMMddHHmmss')
Write-Message -Level Verbose -Message "[$tstamp] The bulk copy library reported RowsCopied = $($args[1].RowsCopied). The previous RowsCopied = $($script:prevRowsCopied). The adjusted total rows copied = $($script:totalRowsCopied)"
$RowsPerSec = [math]::Round($script:totalRowsCopied / $elapsed.ElapsedMilliseconds * 1000.0, 1)
Write-Progress -Id 1 -Activity "Inserting rows" -Status ([System.String]::Format("{0} rows ({1} rows/sec)", $script:totalRowsCopied, $RowsPerSec))
# save the previous count of rows copied to be used on the next event notification
$script:prevRowsCopied = $args[1].RowsCopied
})
}
if ($Pscmdlet.ShouldProcess($destServer, "Writing rows to $fqtndest")) {
$reader = $cmd.ExecuteReader()
# Only apply explicit column mapping for straight table copies (not custom queries)
# Custom queries may have different column names/aliases, so they are mapped by position
# Appending -ForceExplicitMapping will override this behaviour and keep explicit column mapping
if (-not (Test-Bound -ParameterName Query) -or $ForceExplicitMapping) {
# Map only columns that exist in both source and destination (excluding computed, rowversion and generated always columns)
for ($i = 0; $i -lt $reader.FieldCount; $i++) {
$sourceColumn = $reader.GetName($i)
if ($destColumns -contains $sourceColumn) {
$null = $bulkCopy.ColumnMappings.Add($sourceColumn, $sourceColumn)
} else {
Write-Message -Level Verbose -Message "Skipping column $sourceColumn (not in destination or not writable)"
}
}
} else {
# Map the query columns by position onto the writable destination columns. This is what SqlBulkCopy does
# on its own, except that its list also contains the computed, rowversion and generated always columns (see above).
if ($reader.FieldCount -gt $destColumns.Count) {
$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."
$reader.Close()
throw $columnCountMessage
}
for ($i = 0; $i -lt $reader.FieldCount; $i++) {
Write-Message -Level Verbose -Message "Mapping query column $i ($($reader.GetName($i))) to destination column $($destColumns[$i])"
$null = $bulkCopy.ColumnMappings.Add($i, $destColumns[$i])
}
}
$bulkCopy.WriteToServer($reader)
$finalRowCountReported = Get-BulkRowsCopiedCount $bulkCopy
# -1 signals that the reflection lookup failed, not a wrapped counter, so it must not
# reach Get-AdjustedTotalRowsCopied: fed in as a row count it inflates the total by
# billions of rows (see #10675). The running total from the notifications is then the
# best number available.
if ($finalRowCountReported -ge 0) {
$script:totalRowsCopied += (Get-AdjustedTotalRowsCopied -ReportedRowsCopied $finalRowCountReported -PreviousRowsCopied $script:prevRowsCopied).NewRowCountAdded
}
$RowsTotal = $script:totalRowsCopied
$TotalTime = [math]::Round($elapsed.Elapsed.TotalSeconds, 1)
Write-Message -Level Verbose -Message "$RowsTotal rows inserted in $TotalTime sec"
if ($RowsTotal -gt 0) {
Write-Progress -Id 1 -Activity "Inserting rows" -Status "Complete" -Completed
}
$server.ConnectionContext.SqlConnectionObject.Close()
$bulkCopy.Close()
$bulkCopy.Dispose()
$reader.Close()
[PSCustomObject]@{
SourceInstance = $server.Name
SourceDatabase = $Database
SourceDatabaseID = $sqlObject.Parent.ID
SourceSchema = $sqlObject.Schema
SourceTable = $sqlObject.Name
DestinationInstance = $destServer.Name
DestinationDatabase = $DestinationDatabase
DestinationDatabaseID = $desttable.Parent.ID
DestinationSchema = $desttable.Schema
DestinationTable = $desttable.Name
RowsCopied = $RowsTotal
Elapsed = [prettytimespan]$elapsed.Elapsed
}
}
} catch {
Stop-Function -Message "Something went wrong" -ErrorRecord $_ -Target $server -continue
} finally {
# The source side is only cleaned up inside the try after a successful WriteToServer. When the bulk
# copy fails, the reader stays open, the SELECT keeps running on the source (waiting on ASYNC_NETWORK_IO
# once the network buffers are full) and holds its schema stability lock on the source table until
# someone kills the session (see #10685). So the leftovers are cleaned up here.
if ($reader -and -not $reader.IsClosed) {
try {
# Cancel the command first: closing a reader with unread rows would otherwise drain the whole
# remaining result set from the source before it returns.
$cmd.Cancel()
$reader.Close()
} catch {
Write-Message -Level Debug -Message "Failed to close the reader on the source: $PSItem"
}
}
if ($bulkCopy) {
$bulkCopy.Close()
$bulkCopy.Dispose()
}
if ($reader -and $server.ConnectionContext.SqlConnectionObject.State -eq "Open") {
$server.ConnectionContext.SqlConnectionObject.Close()
}
if ($bulkCopyConnection) {
$bulkCopyConnection.Close()
$bulkCopyConnection.Dispose()
}
}
}
}
}
}