diff --git a/src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs b/src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs index d17609f..18da2dc 100644 --- a/src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs +++ b/src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs @@ -233,6 +233,181 @@ public void UpsertBulkAsyncIdentityWithMapGeneratedValuesTest() } } + [Fact] + public void UpsertBulkAsyncMapsInsertedAndUpdatedTest() + { + using (var profiler = Profile()) + using (var connection = _fixture.GetProfiledConnection()) + { + // arange + connection.Open(); + connection.Truncate(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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() { diff --git a/src/Simpleverse.Repository.Db/Simpleverse.Repository.Db.csproj b/src/Simpleverse.Repository.Db/Simpleverse.Repository.Db.csproj index a4d0339..0cea49d 100644 --- a/src/Simpleverse.Repository.Db/Simpleverse.Repository.Db.csproj +++ b/src/Simpleverse.Repository.Db/Simpleverse.Repository.Db.csproj @@ -13,10 +13,10 @@ true Dapper, Bulk, Merge, Upsert, Delete, Insert, Update, Repository LICENSE - 2.1.36 + 2.1.37 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. - 2.1.36.0 - 2.1.36.0 + 2.1.37.0 + 2.1.37.0 https://github.com/lukaferlez/Simpleverse.Repository README.md true diff --git a/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeActionOptions.cs b/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeActionOptions.cs index 2a531da..017fe63 100644 --- a/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeActionOptions.cs +++ b/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeActionOptions.cs @@ -22,12 +22,18 @@ public MergeActionOptions Insert() return this; } - public MergeActionOptions Update() + /// + /// 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. + /// + public MergeActionOptions Update(bool checkConditionOnColumns = true) { var typeMeta = TypeMeta.Get(); Action = MergeAction.Update; ColumnsByPropertyInfo(typeMeta.PropertiesExceptKeyAndComputed); - CheckConditionOnColumns(); + + if (checkConditionOnColumns) + CheckConditionOnColumns(); return this; } diff --git a/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs b/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs index a820c5d..6e626fb 100644 --- a/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs +++ b/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs @@ -16,10 +16,36 @@ public static class MergeExtensions public async static Task UpsertAsync( this IDbConnection connection, T entitiesToUpsert, + Action, IEnumerable, IEnumerable, IEnumerable> outputMap, IDbTransaction transaction = null, int? commandTimeout = null, Action key = null, - Action, IEnumerable, IEnumerable, IEnumerable> 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 + ); + } + + /// + /// Configures the output map and, via , 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. + /// + public async static Task UpsertAsync( + this IDbConnection connection, + T entitiesToUpsert, + IDbTransaction transaction = null, + int? commandTimeout = null, + Action key = null, + Action> outputOptions = null, CancellationToken cancellationToken = default ) where T : class @@ -29,7 +55,7 @@ public async static Task UpsertAsync( transaction: transaction, commandTimeout: commandTimeout, key: key, - outputMap: outputMap, + outputOptions: outputOptions, cancellationToken: cancellationToken ); } @@ -69,6 +95,33 @@ public async static Task MergeAsync( /// Entity to be updated /// The transaction to run under, null (the default) if none /// Number of seconds before command execution timeout + public async static Task UpsertBulkAsync( + this IDbConnection connection, + IEnumerable entitiesToUpsert, + Action, IEnumerable, IEnumerable, IEnumerable> outputMap, + IDbTransaction transaction = null, + int? commandTimeout = null, + Action sqlBulkCopy = null, + Action 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 + ); + } + + /// + /// Configures the output map and, via , 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. + /// /// true if updated, false if not found or not modified (tracked entities) public async static Task UpsertBulkAsync( this IDbConnection connection, @@ -77,19 +130,22 @@ public async static Task UpsertBulkAsync( int? commandTimeout = null, Action sqlBulkCopy = null, Action key = null, - Action, IEnumerable, IEnumerable, IEnumerable> outputMap = null, + Action> outputOptions = null, CancellationToken cancellationToken = default ) where T : class { + var options = new OutputOptions(); + 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 ); } @@ -129,6 +185,9 @@ public async static Task MergeBulkAsync( 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, @@ -137,7 +196,7 @@ public async static Task MergeBulkAsync( 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(); @@ -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 ); }, @@ -209,6 +268,27 @@ public static IEnumerable OnColumns(TypeMeta typeMeta, Action + /// 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. + /// + public static IEnumerable OnProperties(TypeMeta typeMeta, IEnumerable 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(this MergeMatchResult result, TypeMeta typeMeta, Action> optionsAction, StringBuilder sb) { if (optionsAction == null) diff --git a/src/Simpleverse.Repository.Db/SqlServer/Merge/OutputOptions.cs b/src/Simpleverse.Repository.Db/SqlServer/Merge/OutputOptions.cs new file mode 100644 index 0000000..3cb6cb0 --- /dev/null +++ b/src/Simpleverse.Repository.Db/SqlServer/Merge/OutputOptions.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Reflection; +using System; + +namespace Simpleverse.Repository.Db.SqlServer.Merge +{ + public class OutputOptions + { + public Action, IEnumerable, IEnumerable, IEnumerable> Map { get; set; } + + /// + /// 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. + /// + public bool MapChangedOnly { get; set; } = true; + } +}