Skip to content
Merged
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
175 changes: 175 additions & 0 deletions src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
record.Name = (record.Id + 2).ToString();

// act
var updated = connection.UpsertAsync(record).Result;

Check warning on line 35 in src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs

View workflow job for this annotation

GitHub Actions / build

Test methods should not use blocking task operations, as they can cause deadlocks. Use an async test method and await instead. (https://xunit.net/xunit.analyzers/rules/xUnit1031)

// assert
var updatedRecords = connection.GetAll<ExplicitKey>();
Expand All @@ -59,7 +59,7 @@
record.Name = (record.Id + 2).ToString();

// act
var updated = connection.UpsertAsync(record).Result;

Check warning on line 62 in src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs

View workflow job for this annotation

GitHub Actions / build

Test methods should not use blocking task operations, as they can cause deadlocks. Use an async test method and await instead. (https://xunit.net/xunit.analyzers/rules/xUnit1031)

// assert
var updatedRecords = connection.GetAll<Identity>();
Expand Down Expand Up @@ -233,6 +233,181 @@
}
}

[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,
outputOptions: options =>
{
options.Map = OutputMapper.Map;
options.MapChangedOnly = 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)),
outputOptions: options =>
{
options.Map = OutputMapper.Map;
options.MapChangedOnly = 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
96 changes: 88 additions & 8 deletions src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,36 @@ public static class MergeExtensions
public async static Task<int> UpsertAsync<T>(
this IDbConnection connection,
T entitiesToUpsert,
Action<IEnumerable<T>, IEnumerable<T>, IEnumerable<PropertyInfo>, IEnumerable<PropertyInfo>> outputMap,
IDbTransaction transaction = null,
int? commandTimeout = null,
Action<MergeKeyOptions> key = null,
Action<IEnumerable<T>, IEnumerable<T>, IEnumerable<PropertyInfo>, IEnumerable<PropertyInfo>> outputMap = null,
CancellationToken cancellationToken = default
)
where T : class
{
return await connection.UpsertAsync(
entitiesToUpsert,
transaction: transaction,
commandTimeout: commandTimeout,
key: key,
outputOptions: options => options.Map = outputMap,
cancellationToken: cancellationToken
);
}

/// <param name="outputOptions">
/// Configures the output map and, via <see cref="OutputOptions{T}.MapChangedOnly"/>, whether the
/// matched entity is only updated (and therefore only mapped) if its columns actually differ. Set
/// MapChangedOnly to false to update and map the entity even if unchanged.
/// </param>
public async static Task<int> UpsertAsync<T>(
this IDbConnection connection,
T entitiesToUpsert,
IDbTransaction transaction = null,
int? commandTimeout = null,
Action<MergeKeyOptions> key = null,
Action<OutputOptions<T>> outputOptions = null,
CancellationToken cancellationToken = default
)
where T : class
Expand All @@ -29,7 +55,7 @@ public async static Task<int> UpsertAsync<T>(
transaction: transaction,
commandTimeout: commandTimeout,
key: key,
outputMap: outputMap,
outputOptions: outputOptions,
cancellationToken: cancellationToken
);
}
Expand Down Expand Up @@ -69,6 +95,33 @@ 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>
public async static Task<int> UpsertBulkAsync<T>(
this IDbConnection connection,
IEnumerable<T> entitiesToUpsert,
Action<IEnumerable<T>, IEnumerable<T>, IEnumerable<PropertyInfo>, IEnumerable<PropertyInfo>> outputMap,
IDbTransaction transaction = null,
int? commandTimeout = null,
Action<SqlBulkCopy> sqlBulkCopy = null,
Action<MergeKeyOptions> key = null,
CancellationToken cancellationToken = default
) where T : class
{
return await connection.UpsertBulkAsync(
entitiesToUpsert,
transaction: transaction,
commandTimeout: commandTimeout,
sqlBulkCopy: sqlBulkCopy,
key: key,
outputOptions: options => options.Map = outputMap,
cancellationToken: cancellationToken
);
}

/// <param name="outputOptions">
/// Configures the output map and, via <see cref="OutputOptions{T}.MapChangedOnly"/>, whether matched
/// entities are only updated (and therefore only mapped) if their columns actually differ. Set
/// MapChangedOnly to false to update and map every matched entity, including unchanged ones.
/// </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 @@ -77,19 +130,22 @@ public async static Task<int> UpsertBulkAsync<T>(
int? commandTimeout = null,
Action<SqlBulkCopy> sqlBulkCopy = null,
Action<MergeKeyOptions> key = null,
Action<IEnumerable<T>, IEnumerable<T>, IEnumerable<PropertyInfo>, IEnumerable<PropertyInfo>> outputMap = null,
Action<OutputOptions<T>> outputOptions = null,
CancellationToken cancellationToken = default
) where T : class
{
var options = new OutputOptions<T>();
outputOptions?.Invoke(options);

return await connection.MergeBulkAsync(
entitiesToUpsert,
transaction,
commandTimeout,
sqlBulkCopy: sqlBulkCopy,
key: key,
matched: options => options.Update(),
notMatchedByTarget: options => options.Insert(),
outputMap: outputMap,
matched: matchedOptions => matchedOptions.Update(checkConditionOnColumns: options.MapChangedOnly),
notMatchedByTarget: notMatchedOptions => notMatchedOptions.Insert(),
outputMap: options.Map,
cancellationToken: cancellationToken
);
}
Expand Down Expand Up @@ -129,6 +185,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 +196,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 +242,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 +268,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
18 changes: 18 additions & 0 deletions src/Simpleverse.Repository.Db/SqlServer/Merge/OutputOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Reflection;
using System;

namespace Simpleverse.Repository.Db.SqlServer.Merge
{
public class OutputOptions<T>
{
public Action<IEnumerable<T>, IEnumerable<T>, IEnumerable<PropertyInfo>, IEnumerable<PropertyInfo>> Map { get; set; }

/// <summary>
/// When true (the default) matched entities are only updated, and therefore only mapped, if their
/// columns actually differ. Set to false to update and map every matched entity, including ones
/// that are unchanged.
/// </summary>
public bool MapChangedOnly { get; set; } = true;
}
}
Loading