Skip to content

Commit 0719060

Browse files
committed
Optional Column Mappings
1 parent 3321dfd commit 0719060

File tree

9 files changed

+261
-60
lines changed

9 files changed

+261
-60
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,5 @@ orleans.codegen.cs
132132

133133
# Local History for Visual Studio
134134
.localhistory/
135+
136+
.vs

README.md

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
# SQL Bulk Copy & Merge
22

3+
This library aims to make copying table data between SQL databases easier.
4+
35
SQLBulkCopy is useful to copy between databases, but truncating the destination table each time before copying is not always possible or efficient.
6+
An alternative is to use SQLBulkCopy to copy to a temporary table and then run SQL MERGE between the temporary table and target.
7+
Some other solutions that do this require extra work defining the table schemas or are dependant on a stored proc.
48

5-
To solve this problem, this .NET library has the following methods:
9+
This .NET library has the following methods:
610

7-
### CopyAndMerge
11+
## CopyAndMerge
812
Uses SQLBulkCopy to copy data from a table or view in the source database to a temporary table in the target database before running SQL MERGE from the temporary table to the destination table.
913

1014
The specific steps it performs:
@@ -15,7 +19,7 @@ The specific steps it performs:
1519
- Run MERGE statement
1620
- Drop the temp table
1721

18-
Usage:
22+
### Usage:
1923
```
2024
var copyService = new SqlBulkCopyMergeService(sourceDbConnectionString, targetDbConnectionString);
2125
var result = await copyService.CopyAndMerge(sourceTable, targetTable);
@@ -24,7 +28,17 @@ Console.WriteLine("Rows Updated: " + result.Updated);
2428
Console.WriteLine("Rows Deleted: " + result.Deleted);
2529
```
2630

27-
### CopyLatest
31+
If the column names between tables are different you can specify them, for example:
32+
```
33+
var columnMappings = new List<ColumnMapping>
34+
{
35+
new ColumnMapping("id", "code"),
36+
new ColumnMapping("notes", "description")
37+
};
38+
var result = await copyService.CopyAndMerge(sourceTable, targetTable, columnMappings);
39+
```
40+
41+
## CopyLatest
2842
For source tables that are only ever added to it is more efficient to copy only the new rows into the target table.
2943
This method copies the latest data determined by the keyColumnName.
3044

@@ -35,14 +49,14 @@ If no keyColumnName is specified, the primary key is used. If more than one prim
3549

3650
The result of the source query is directly copied into the target table using SQLBulkCopy.
3751

38-
Usage:
52+
### Usage:
3953
```
4054
var copyService = new SqlBulkCopyMergeService(sourceDbConnectionString, targetDbConnectionString);
4155
var result = await copyService.CopyLatest(sourceTable, targetTable, keyColumnName);
4256
Console.WriteLine("Rows Copied: " + result.RowsCopied);
4357
```
4458

45-
### Notes
59+
## Notes
4660
For the connections, the Source database requires READER permission, the Target database requires READER + WRITER + CREATE TABLE + EXECUTE permissions.
4761

4862
Tested on SQL Server 2019.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace SqlBulkCopyMerge.Models
2+
{
3+
public class ColumnMapping
4+
{
5+
public ColumnMapping() { }
6+
public ColumnMapping(string source, string target)
7+
{
8+
Source = source;
9+
Target = target;
10+
}
11+
12+
public string Source { get; set; }
13+
public string Target { get; set; }
14+
}
15+
}

src/SqlBulkCopyMerge/Models/CopyAndMergeConfig.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
{
33
public class CopyAndMergeConfig
44
{
5+
/// <summary>
6+
/// Set to true if the MERGE is only to Insert and Update rows, not Delete
7+
/// </summary>
8+
public bool NoRowDeletion { get; set; }
9+
510
public SqlBulkCopyConfig SqlBulkCopyConfig { get; set; }
611
public SqlMergeConfig SqlMergeConfig { get; set; }
712
}

src/SqlBulkCopyMerge/SqlBulkCopyMerge.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<PropertyGroup>
44
<Authors>Steve Kirkegard</Authors>
55
<Product>SqlBulkCopyMerge</Product>
6-
<TargetFramework>net5.0</TargetFramework>
6+
<TargetFrameworks>netstandard2.1;net5.0</TargetFrameworks>
77
<Description>Copy data from a table in one database to a table in another database. It does not TRUNCATE the target table. Uses SQLBulkCopy behind the scenes.</Description>
88
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
99
<GenerateDocumentationFile>true</GenerateDocumentationFile>

src/SqlBulkCopyMerge/SqlBulkCopyMergeService.cs

Lines changed: 82 additions & 50 deletions
Large diffs are not rendered by default.

src/SqlBulkCopyMerge/SqlUtilExtensions.cs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
24
using System.Text.RegularExpressions;
35
using Microsoft.SqlServer.Management.Common;
6+
using SqlBulkCopyMerge.Models;
47

58
namespace SqlBulkCopyMerge
69
{
@@ -17,13 +20,13 @@ internal static (string TableSchema, string TableName) GetTableSchemaAndNameInPa
1720
if (r.IsMatch(table))
1821
{
1922
var match = r.Match(table);
20-
if (match.Groups.ContainsKey(regexLabelTableName)
23+
if (match.Groups.Cast<Group>().ToList().Any(a => a.Name == regexLabelTableName)
2124
&& match.Groups[regexLabelTableName].Success
2225
&& !string.IsNullOrWhiteSpace(match.Groups[regexLabelTableName].Value))
2326
{
2427
var tableSchema = "dbo"; // default
2528
var tableName = match.Groups[regexLabelTableName].Value;
26-
if (match.Groups.ContainsKey(regexLabelTableSchema)
29+
if (match.Groups.Cast<Group>().ToList().Any(a => a.Name == regexLabelTableSchema)
2730
&& match.Groups[regexLabelTableSchema].Success
2831
&& !string.IsNullOrWhiteSpace(match.Groups[regexLabelTableSchema].Value))
2932
{
@@ -60,5 +63,33 @@ internal static string AddSquareBrackets(this string val)
6063
if (val == null) return null;
6164
return $"[{RemoveSquareBrackets(val)}]";
6265
}
66+
67+
internal static List<ColumnMapping> ValidateAndFormatColumnMappings(this List<ColumnMapping> columnMappings, List<ColumnSchemaModel> sourceColumns, List<ColumnSchemaModel> targetColumns)
68+
{
69+
if (columnMappings?.Any() == true)
70+
{
71+
// Validate column Mappings
72+
foreach (var columnMapping in columnMappings)
73+
{
74+
if (string.IsNullOrWhiteSpace(columnMapping.Source) ||
75+
string.IsNullOrWhiteSpace(columnMapping.Target))
76+
throw new Exception("One or more column mappings are invalid");
77+
78+
columnMapping.Source = columnMapping.Source.RemoveSquareBrackets();
79+
columnMapping.Target = columnMapping.Target.RemoveSquareBrackets();
80+
81+
if (!sourceColumns.Any(a => string.Equals(a.Name, columnMapping.Source, StringComparison.InvariantCultureIgnoreCase)))
82+
{
83+
throw new Exception($"Invalid Column Mappings. {columnMapping.Source} does not exist");
84+
}
85+
if (!targetColumns.Any(a => string.Equals(a.Name, columnMapping.Target, StringComparison.InvariantCultureIgnoreCase)))
86+
{
87+
throw new Exception($"Invalid Column Mappings. {columnMapping.Target} does not exist");
88+
}
89+
}
90+
}
91+
92+
return columnMappings;
93+
}
6394
}
6495
}

test/SqlBulkCopyMerge.Tests/CopyAndMergeTests.cs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
using System.Threading.Tasks;
1+
using System.Collections.Generic;
2+
using System.Threading.Tasks;
3+
using SqlBulkCopyMerge.Models;
24
using SqlBulkCopyMerge.Tests.Docker;
35
using Xunit;
46
using Xunit.Abstractions;
@@ -43,5 +45,38 @@ public async Task CopiesAndMergesFromViewWithSubsetOfColumnsCorrectly()
4345
Assert.Equal(1, result.Updated);
4446
Assert.Equal(3, result.Deleted);
4547
}
48+
49+
[Fact]
50+
public async Task CopiesAndMergesWithDeletesDisabled()
51+
{
52+
var sourceTable = "test_copy_and_merge_with_deletes_disabled";
53+
var targetTable = sourceTable;
54+
var config = new CopyAndMergeConfig
55+
{
56+
NoRowDeletion = true
57+
};
58+
var result = await _sqlBulkCopyCautiouslyService.CopyAndMerge(sourceTable, targetTable, copyAndMergeConfig: config);
59+
Assert.Equal(1, result.Inserted);
60+
Assert.Equal(1, result.Updated);
61+
Assert.Equal(0, result.Deleted);
62+
}
63+
64+
[Fact]
65+
public async Task CopiesAndMergesWithDifferentColumnNames()
66+
{
67+
var sourceTable = "test_copy_and_merge_with_different_column_names";
68+
var targetTable = "test_copy_and_merge_with_different_column_names_d";
69+
var columnMappings = new List<ColumnMapping>
70+
{
71+
new ColumnMapping("id", "d_id"),
72+
new ColumnMapping("notes", "[d_notes]"),
73+
new ColumnMapping("[timestamp]", "d_timestamp"),
74+
new ColumnMapping("[version_control]", "[d_version_control]")
75+
};
76+
var result = await _sqlBulkCopyCautiouslyService.CopyAndMerge(sourceTable, targetTable, columnMappings);
77+
Assert.Equal(1, result.Inserted);
78+
Assert.Equal(1, result.Updated);
79+
Assert.Equal(1, result.Deleted);
80+
}
4681
}
4782
}

test/SqlBulkCopyMerge.Tests/Docker/SeedDatabases.sql

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,39 @@ CREATE VIEW [dbo].[vtest_copy_and_merge_subset] AS
110110
)
111111
GO
112112

113+
IF EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'test_copy_and_merge_with_deletes_disabled')
114+
DROP TABLE [dbo].[test_copy_and_merge_with_deletes_disabled]
115+
GO
116+
CREATE TABLE [dbo].[test_copy_and_merge_with_deletes_disabled](
117+
[id] INT NOT NULL,
118+
[notes] NVARCHAR(MAX),
119+
[timestamp] DATETIME2,
120+
[geom] GEOMETRY,
121+
[version_control] BINARY(8)
122+
CONSTRAINT [PK_test_copy_and_merge_with_deletes_disabled] PRIMARY KEY ([id])
123+
);
124+
INSERT INTO [dbo].[test_copy_and_merge_with_deletes_disabled] ( [id], [notes], [timestamp], [version_control] ) VALUES
125+
(2, 'Note 2 update', '2020-01-02', CONVERT(VARBINARY(8), 10000002)),
126+
(3, 'Note 3 new', '2020-01-03', CONVERT(VARBINARY(8), 10000003))
127+
GO
128+
129+
IF EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'test_copy_and_merge_with_different_column_names')
130+
DROP TABLE [dbo].[test_copy_and_merge_with_different_column_names]
131+
GO
132+
CREATE TABLE [dbo].[test_copy_and_merge_with_different_column_names](
133+
[id] INT NOT NULL,
134+
[notes] NVARCHAR(MAX),
135+
[timestamp] DATETIME2,
136+
[geom] GEOMETRY,
137+
[notes_2] NVARCHAR(200),
138+
[version_control] BINARY(8)
139+
CONSTRAINT [PK_test_copy_and_merge_with_different_column_names] PRIMARY KEY ([id])
140+
);
141+
INSERT INTO [dbo].[test_copy_and_merge_with_different_column_names] ( [id], [notes], [timestamp], [notes_2], [version_control] ) VALUES
142+
(1, 'Note Update', '2020-01-01', NULL, CONVERT(VARBINARY(8), 10000001)),
143+
(3, 'Note New', '2020-01-03', NULL, CONVERT(VARBINARY(8), 10000003)),
144+
(4, 'Note Unchanged', '2020-01-04', 'Note not included', CONVERT(VARBINARY(8), 10000004))
145+
GO
113146

114147

115148
-- *************************************************************************************************************************
@@ -191,5 +224,39 @@ INSERT INTO [dbo].[test_copy_and_merge_subset] ( [id], [notes], [timestamp_no_ma
191224
(5, NULL, '2020-01-05', CONVERT(VARBINARY(8), 10000050));
192225
GO
193226

227+
IF EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'test_copy_and_merge_with_deletes_disabled')
228+
DROP TABLE [dbo].[test_copy_and_merge_with_deletes_disabled]
229+
GO
230+
CREATE TABLE [dbo].[test_copy_and_merge_with_deletes_disabled](
231+
[id] INT NOT NULL,
232+
[notes] NVARCHAR(MAX),
233+
[timestamp] DATETIME2,
234+
[geom] GEOMETRY,
235+
[version_control] BINARY(8)
236+
CONSTRAINT [PK_test_copy_and_merge_with_deletes_disabled] PRIMARY KEY ([id])
237+
);
238+
INSERT INTO [dbo].[test_copy_and_merge_with_deletes_disabled] ( [id], [notes], [timestamp], [version_control] ) VALUES
239+
(1, 'Note wont be deleted even though it doesnt exist in source', '2020-01-01', CONVERT(VARBINARY(8), 10000001)),
240+
(2, 'Note 2', '2020-01-02', CONVERT(VARBINARY(8), 10000002))
241+
GO
242+
243+
IF EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'test_copy_and_merge_with_different_column_names_d')
244+
DROP TABLE [dbo].[test_copy_and_merge_with_different_column_names_d]
245+
GO
246+
CREATE TABLE [dbo].[test_copy_and_merge_with_different_column_names_d](
247+
[d_id] INT NOT NULL,
248+
[d_notes] NVARCHAR(200),
249+
[d_timestamp] DATETIME2,
250+
[d_geom] GEOMETRY,
251+
[notes_2] NVARCHAR(200),
252+
[d_version_control] BINARY(8)
253+
CONSTRAINT [PK_test_copy_and_merge_with_different_column_names_d] PRIMARY KEY ([d_id])
254+
);
255+
INSERT INTO [dbo].[test_copy_and_merge_with_different_column_names_d] ( [d_id], [d_notes], [d_timestamp], [notes_2], [d_version_control] ) VALUES
256+
(1, 'Note 1', '2020-01-01', NULL, CONVERT(VARBINARY(8), 10000001)),
257+
(2, 'Note 2', '2020-01-02', NULL, CONVERT(VARBINARY(8), 10000002)),
258+
(4, 'Note Unchanged', '2020-01-04', 'Note not updated', CONVERT(VARBINARY(8), 10000004))
259+
GO
260+
194261

195262
USE [master]

0 commit comments

Comments
 (0)