diff --git a/src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs b/src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs index d17609f..c5ef0a5 100644 --- a/src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs +++ b/src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs @@ -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(); + + 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, + 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(); + + 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(); + + 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..d42375a 100644 --- a/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs +++ b/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs @@ -20,6 +20,7 @@ public async static Task UpsertAsync( int? commandTimeout = null, Action key = null, Action, IEnumerable, IEnumerable, IEnumerable> outputMap = null, + bool checkConditionOnColumns = true, CancellationToken cancellationToken = default ) where T : class @@ -30,6 +31,7 @@ public async static Task UpsertAsync( commandTimeout: commandTimeout, key: key, outputMap: outputMap, + checkConditionOnColumns: checkConditionOnColumns, cancellationToken: cancellationToken ); } @@ -69,6 +71,10 @@ 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 + /// + /// 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 . + /// /// true if updated, false if not found or not modified (tracked entities) public async static Task UpsertBulkAsync( this IDbConnection connection, @@ -78,6 +84,7 @@ public async static Task UpsertBulkAsync( Action sqlBulkCopy = null, Action key = null, Action, IEnumerable, IEnumerable, IEnumerable> outputMap = null, + bool checkConditionOnColumns = true, CancellationToken cancellationToken = default ) where T : class { @@ -87,7 +94,7 @@ public async static Task UpsertBulkAsync( commandTimeout, sqlBulkCopy: sqlBulkCopy, key: key, - matched: options => options.Update(), + matched: options => options.Update(checkConditionOnColumns: checkConditionOnColumns), notMatchedByTarget: options => options.Insert(), outputMap: outputMap, cancellationToken: cancellationToken @@ -129,6 +136,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 +147,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 +193,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 +219,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)