Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,175 @@ public void UpsertBulkAsyncIdentityWithMapGeneratedValuesTest()
}
}

[Fact]
public void UpsertBulkAsyncMapsInsertedAndUpdatedTest()
{
using (var profiler = Profile())
using (var connection = _fixture.GetProfiledConnection())
{
// arange
connection.Open();
connection.Truncate<Computed>();

var existing = TestData.ComputedData(5).ToList();
connection.InsertBulkAsync(existing, outputMap: OutputMapper.MapOnce).Wait();

foreach (var record in existing)
{
record.Name = record.Name + "-updated";
record.Value = 0;
record.ValueDate = default;
record.ValueComputed = 0;
}

var added = TestData.ComputedData(3).ToList();
foreach (var record in added)
{
record.Id = 0;
record.Name = "new-" + record.Name;
}

var records = existing.Concat(added).ToList();

// act
var affected = connection.UpsertBulkAsync(records, outputMap: OutputMapper.Map).Result;

// assert
Assert.Equal(8, affected);
Assert.All(records, x => Assert.NotEqual(0, x.Id));
Assert.All(records, x => Assert.Equal(5, x.Value));
Assert.All(records, x => Assert.Equal(10, x.ValueComputed));
Assert.All(records, x => Assert.Equal(new DateTime(2022, 05, 02), x.ValueDate));
}
}

[Fact]
public void UpsertBulkAsyncSkipsUnchangedMatchedRecordsByDefaultTest()
{
using (var profiler = Profile())
using (var connection = _fixture.GetProfiledConnection())
{
// arange
connection.Open();
connection.Truncate<Computed>();

var existing = TestData.ComputedData(5).ToList();
connection.InsertBulkAsync(existing, outputMap: OutputMapper.MapOnce).Wait();

// nothing changed, only the generated values are cleared locally
foreach (var record in existing)
{
record.Value = 0;
record.ValueDate = default;
record.ValueComputed = 0;
}

// act
var affected = connection.UpsertBulkAsync(existing, outputMap: OutputMapper.Map).Result;

// assert
// by default unchanged entities are not written, so they produce no output row to map from
Assert.Equal(0, affected);
Assert.All(existing, x => Assert.Equal(0, x.ValueComputed));
}
}

[Fact]
public void UpsertBulkAsyncWithoutConditionCheckMapsUnchangedMatchedRecordsTest()
{
using (var profiler = Profile())
using (var connection = _fixture.GetProfiledConnection())
{
// arange
connection.Open();
connection.Truncate<Computed>();

var existing = TestData.ComputedData(5).ToList();
connection.InsertBulkAsync(existing, outputMap: OutputMapper.MapOnce).Wait();

// nothing changed, only the generated values are cleared locally
foreach (var record in existing)
{
record.Value = 0;
record.ValueDate = default;
record.ValueComputed = 0;
}

// act
var affected = connection.UpsertBulkAsync(
existing,
outputMap: OutputMapper.Map,
checkConditionOnColumns: false
).Result;

// assert
Assert.Equal(5, affected);
Assert.All(existing, x => Assert.Equal(5, x.Value));
Assert.All(existing, x => Assert.Equal(10, x.ValueComputed));
Assert.All(existing, x => Assert.Equal(new DateTime(2022, 05, 02), x.ValueDate));
}
}

[Fact]
public void UpsertBulkAsyncWithoutConditionCheckMapsUnchangedMatchedRecordsOnCustomKeyTest()
{
using (var profiler = Profile())
using (var connection = _fixture.GetProfiledConnection())
{
// arange
connection.Open();
connection.Truncate<Identity>();

var existing = TestData.IdentityWithoutIdData(3).ToList();
connection.InsertBulkAsync(existing, outputMap: OutputMapper.MapOnce).Wait();

// caller only knows the business key and changes nothing
var records = TestData.IdentityWithoutIdData(3).ToList();

// act
var affected = connection.UpsertBulkAsync(
records,
key: options => options.ColumnsByName(nameof(Identity.Name)),
outputMap: OutputMapper.Map,
checkConditionOnColumns: false
).Result;

// assert
Assert.Equal(3, affected);
Assert.All(records, x => Assert.NotEqual(0, x.Id));
}
}

[Fact]
public void UpsertBulkAsyncMapsUpdatedRecordsMatchedOnCustomKeyTest()
{
using (var profiler = Profile())
using (var connection = _fixture.GetProfiledConnection())
{
// arange
connection.Open();
connection.Truncate<Identity>();

var existing = TestData.IdentityWithoutIdData(3).ToList();
connection.InsertBulkAsync(existing, outputMap: OutputMapper.MapOnce).Wait();

// caller only knows the business key, not the identity
var records = TestData.IdentityWithoutIdData(5).ToList();
foreach (var record in records)
record.From = "changed";

// act
connection.UpsertBulkAsync(
records,
key: options => options.ColumnsByName(nameof(Identity.Name)),
outputMap: OutputMapper.Map
).Wait();

// assert
Assert.All(records, x => Assert.NotEqual(0, x.Id));
}
}

[Fact]
public void UpsertBulkAsyncWriteAttributeTest()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
<PackageRequireLicenseAcceptance>true</PackageRequireLicenseAcceptance>
<PackageTags>Dapper, Bulk, Merge, Upsert, Delete, Insert, Update, Repository</PackageTags>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<Version>2.1.36</Version>
<Version>2.1.37</Version>
<Description>High performance operation for MS SQL Server built for Dapper ORM. Including bulk operations Insert, Update, Delete, Get as well as Upsert both single and bulk.</Description>
<AssemblyVersion>2.1.36.0</AssemblyVersion>
<FileVersion>2.1.36.0</FileVersion>
<AssemblyVersion>2.1.37.0</AssemblyVersion>
<FileVersion>2.1.37.0</FileVersion>
<RepositoryUrl>https://github.com/lukaferlez/Simpleverse.Repository</RepositoryUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<EmbedAllSources>true</EmbedAllSources>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,18 @@ public MergeActionOptions<T> Insert()
return this;
}

public MergeActionOptions<T> Update()
/// <param name="checkConditionOnColumns">
/// When true the update only runs for rows whose columns actually differ. Pass false to update every
/// matched row, which also makes them show up in the OUTPUT clause and therefore in the output mapping.
/// </param>
public MergeActionOptions<T> Update(bool checkConditionOnColumns = true)
{
var typeMeta = TypeMeta.Get<T>();
Action = MergeAction.Update;
ColumnsByPropertyInfo(typeMeta.PropertiesExceptKeyAndComputed);
CheckConditionOnColumns();

if (checkConditionOnColumns)
CheckConditionOnColumns();

return this;
}
Expand Down
37 changes: 34 additions & 3 deletions src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public async static Task<int> UpsertAsync<T>(
int? commandTimeout = null,
Action<MergeKeyOptions> key = null,
Action<IEnumerable<T>, IEnumerable<T>, IEnumerable<PropertyInfo>, IEnumerable<PropertyInfo>> outputMap = null,
bool checkConditionOnColumns = true,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make this more readable/obvious of intent, I'd modify this where instead of the outputmap we will have OutputOptions object that will have an "outputmap" property and a "MapChangedOnly=true", so that you can then set that to false if you want.

Then we should add an override that would take in just the outputmap to maintain backward compatibility.

CancellationToken cancellationToken = default
)
where T : class
Expand All @@ -30,6 +31,7 @@ public async static Task<int> UpsertAsync<T>(
commandTimeout: commandTimeout,
key: key,
outputMap: outputMap,
checkConditionOnColumns: checkConditionOnColumns,
cancellationToken: cancellationToken
);
}
Expand Down Expand Up @@ -69,6 +71,10 @@ public async static Task<int> MergeAsync<T>(
/// <param name="entitiesToUpsert">Entity to be updated</param>
/// <param name="transaction">The transaction to run under, null (the default) if none</param>
/// <param name="commandTimeout">Number of seconds before command execution timeout</param>
/// <param name="checkConditionOnColumns">
/// When true matched entities are only updated if their columns actually differ. Pass false to update
/// every matched entity, which is what makes unchanged entities available to <paramref name="outputMap"/>.
/// </param>
/// <returns>true if updated, false if not found or not modified (tracked entities)</returns>
public async static Task<int> UpsertBulkAsync<T>(
this IDbConnection connection,
Expand All @@ -78,6 +84,7 @@ public async static Task<int> UpsertBulkAsync<T>(
Action<SqlBulkCopy> sqlBulkCopy = null,
Action<MergeKeyOptions> key = null,
Action<IEnumerable<T>, IEnumerable<T>, IEnumerable<PropertyInfo>, IEnumerable<PropertyInfo>> outputMap = null,
bool checkConditionOnColumns = true,
CancellationToken cancellationToken = default
) where T : class
{
Expand All @@ -87,7 +94,7 @@ public async static Task<int> UpsertBulkAsync<T>(
commandTimeout,
sqlBulkCopy: sqlBulkCopy,
key: key,
matched: options => options.Update(),
matched: options => options.Update(checkConditionOnColumns: checkConditionOnColumns),
notMatchedByTarget: options => options.Insert(),
outputMap: outputMap,
cancellationToken: cancellationToken
Expand Down Expand Up @@ -129,6 +136,9 @@ public async static Task<int> MergeBulkAsync<T>(
if (mapGeneratedValues && !typeMeta.PropertiesKeyAndExplicit.Any())
throw new NotSupportedException("Output mapping inserted values is not supported without either a key or explicitkey");

var onColumns = OnColumns(typeMeta, keyAction: key);
var onProperties = OnProperties(typeMeta, onColumns);

return await connection.ExecuteAsync(
entitiesToMerge,
typeMeta.PropertiesExceptComputed,
Expand All @@ -137,7 +147,7 @@ public async static Task<int> MergeBulkAsync<T>(
var sb = new StringBuilder($@"
MERGE INTO {typeMeta.TableName} AS Target
USING {source} AS Source
ON ({OnColumns(typeMeta, keyAction: key).ColumnListEquals(" AND ")})"
ON ({onColumns.ColumnListEquals(" AND ")})"
);
sb.AppendLine();

Expand Down Expand Up @@ -183,7 +193,7 @@ MERGE INTO {typeMeta.TableName} AS Target
outputMap(
entitiesToMerge,
values,
index == 0 ? typeMeta.PropertiesExceptKeyAndComputed : typeMeta.PropertiesKeyAndExplicit,
index == 0 ? typeMeta.PropertiesExceptKeyAndComputed : onProperties,
typeMeta.Properties
);
},
Expand All @@ -209,6 +219,27 @@ public static IEnumerable<string> OnColumns(TypeMeta typeMeta, Action<MergeKeyOp
return options.Columns;
}

/// <summary>
/// Resolves the columns the merge matches on back to properties, so that rows returned for
/// matched entities can be mapped onto the entities they originated from. Matching on the merge
/// columns instead of the key is what allows generated keys to be mapped onto updated entities,
/// which do not necessarily carry the key when merging on other columns.
/// </summary>
public static IEnumerable<PropertyInfo> OnProperties(TypeMeta typeMeta, IEnumerable<string> onColumns)
{
if (onColumns == null)
return typeMeta.PropertiesKeyAndExplicit;

var properties = typeMeta.Properties
.Where(x => onColumns.Contains(x.Name, StringComparer.OrdinalIgnoreCase))
.ToList();

if (properties.Count != onColumns.Count())
return typeMeta.PropertiesKeyAndExplicit;

return properties;
}

public static void Format<T>(this MergeMatchResult result, TypeMeta typeMeta, Action<MergeActionOptions<T>> optionsAction, StringBuilder sb)
{
if (optionsAction == null)
Expand Down
Loading