From 4e35f830708e56f3b1b1a86af9b3fe2d6faaee11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Klari=C4=87?= Date: Fri, 31 Jul 2026 12:19:39 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9Efix:=20Output=20mapping=20of=20?= =?UTF-8?q?updated=20entities=20on=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated rows returned by MERGE were matched back onto entities by primary key, so merging on any other columns left the generated key unmapped, as the entity has no key to match on yet. Match updated rows on the columns the merge was performed on instead, which is sound because a row comes back as an update precisely when it matched the entity on those columns. Matched entities that are already in sync produce no OUTPUT row at all, since Update() always added a difference check on the columns. Make that check optional through Update(checkConditionOnColumns) and expose it on UpsertAsync/UpsertBulkAsync, so callers can trade the extra writes for having every matched entity mapped. Co-Authored-By: Claude Opus 5 (1M context) --- .../SqlServer/Merge/UpsertTests.cs | 169 ++++++++++++++++++ .../SqlServer/Merge/MergeActionOptions.cs | 10 +- .../SqlServer/Merge/MergeExtensions.cs | 37 +++- 3 files changed, 211 insertions(+), 5 deletions(-) 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/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) From 45606548f6241e67470e331964e195189a3c8519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Klari=C4=87?= Date: Fri, 31 Jul 2026 12:36:32 +0200 Subject: [PATCH 2/4] Version bump: Simpleverse.Repository.Db 2.1.37 Co-Authored-By: Claude Opus 5 (1M context) --- .../Simpleverse.Repository.Db.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 94196dd4ae408eb37fa7840d79b84149d0e1e24d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Klari=C4=87?= Date: Thu, 6 Aug 2026 13:41:55 +0200 Subject: [PATCH 3/4] Fix comments --- .../SqlServer/Merge/UpsertTests.cs | 14 ++-- .../SqlServer/Merge/MergeExtensions.cs | 69 +++++++++++++++---- .../SqlServer/Merge/OutputOptions.cs | 18 +++++ 3 files changed, 85 insertions(+), 16 deletions(-) create mode 100644 src/Simpleverse.Repository.Db/SqlServer/Merge/OutputOptions.cs diff --git a/src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs b/src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs index c5ef0a5..18da2dc 100644 --- a/src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs +++ b/src/Simpleverse.Repository.Db.Test/SqlServer/Merge/UpsertTests.cs @@ -330,8 +330,11 @@ public void UpsertBulkAsyncWithoutConditionCheckMapsUnchangedMatchedRecordsTest( // act var affected = connection.UpsertBulkAsync( existing, - outputMap: OutputMapper.Map, - checkConditionOnColumns: false + outputOptions: options => + { + options.Map = OutputMapper.Map; + options.MapChangedOnly = false; + } ).Result; // assert @@ -362,8 +365,11 @@ public void UpsertBulkAsyncWithoutConditionCheckMapsUnchangedMatchedRecordsOnCus var affected = connection.UpsertBulkAsync( records, key: options => options.ColumnsByName(nameof(Identity.Name)), - outputMap: OutputMapper.Map, - checkConditionOnColumns: false + outputOptions: options => + { + options.Map = OutputMapper.Map; + options.MapChangedOnly = false; + } ).Result; // assert diff --git a/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs b/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs index d42375a..42d4e1a 100644 --- a/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs +++ b/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs @@ -16,11 +16,31 @@ 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, - bool checkConditionOnColumns = true, + CancellationToken cancellationToken = default + ) + where T : class + { + return await connection.UpsertAsync( + entitiesToUpsert, + transaction: transaction, + commandTimeout: commandTimeout, + key: key, + outputOptions: options => options.Map = outputMap, + cancellationToken: cancellationToken + ); + } + + 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 @@ -30,8 +50,7 @@ public async static Task UpsertAsync( transaction: transaction, commandTimeout: commandTimeout, key: key, - outputMap: outputMap, - checkConditionOnColumns: checkConditionOnColumns, + outputOptions: outputOptions, cancellationToken: cancellationToken ); } @@ -71,9 +90,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 - /// - /// 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, + 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( @@ -83,20 +126,22 @@ public async static Task UpsertBulkAsync( int? commandTimeout = null, Action sqlBulkCopy = null, Action key = null, - Action, IEnumerable, IEnumerable, IEnumerable> outputMap = null, - bool checkConditionOnColumns = true, + 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(checkConditionOnColumns: checkConditionOnColumns), - notMatchedByTarget: options => options.Insert(), - outputMap: outputMap, + matched: matchedOptions => matchedOptions.Update(checkConditionOnColumns: options.MapChangedOnly), + notMatchedByTarget: notMatchedOptions => notMatchedOptions.Insert(), + outputMap: options.Map, cancellationToken: cancellationToken ); } 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; + } +} From f44d60cfc7d1aa0d5b760b2f5617d1aa8cd8ffeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Klari=C4=87?= Date: Thu, 6 Aug 2026 14:12:45 +0200 Subject: [PATCH 4/4] Fix --- .../SqlServer/Merge/MergeExtensions.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs b/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs index 42d4e1a..6e626fb 100644 --- a/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs +++ b/src/Simpleverse.Repository.Db/SqlServer/Merge/MergeExtensions.cs @@ -34,6 +34,11 @@ public async static Task UpsertAsync( ); } + /// + /// 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, @@ -90,7 +95,6 @@ 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 - /// true if updated, false if not found or not modified (tracked entities) public async static Task UpsertBulkAsync( this IDbConnection connection, IEnumerable entitiesToUpsert,