From daac3bffc69920e54214790717c19309abc4c932 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Thu, 25 Aug 2022 19:43:51 +0300 Subject: [PATCH] Fix sproc TPH mapping (#28875) Fixes #28805 --- .../Update/ColumnModificationParameters.cs | 2 +- .../Update/ModificationCommand.cs | 87 +++++++++++++------ .../Update/ReaderModificationCommandBatch.cs | 23 +---- .../Update/UpdateSqlGenerator.cs | 15 ++-- .../Internal/SqlServerUpdateSqlGenerator.cs | 15 ++-- .../StoredProcedureUpdateContext.cs | 2 +- .../{TphChild.cs => TphChild1.cs} | 4 +- .../StoredProcedureUpdateModel/TphChild2.cs | 11 +++ .../StoredProcedureUpdateFixtureBase.cs | 16 +++- .../Update/StoredProcedureUpdateTestBase.cs | 4 +- .../StoredProcedureUpdateSqlServerTest.cs | 15 ++-- 11 files changed, 123 insertions(+), 71 deletions(-) rename test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/{TphChild.cs => TphChild1.cs} (73%) create mode 100644 test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/TphChild2.cs diff --git a/src/EFCore.Relational/Update/ColumnModificationParameters.cs b/src/EFCore.Relational/Update/ColumnModificationParameters.cs index 5c7f8cae0f1..f2103bbf740 100644 --- a/src/EFCore.Relational/Update/ColumnModificationParameters.cs +++ b/src/EFCore.Relational/Update/ColumnModificationParameters.cs @@ -198,7 +198,7 @@ public ColumnModificationParameters( /// Indicates whether the column is used in the WHERE clause when updating. /// Indicates whether potentially sensitive data (e.g. database values) can be logged. public ColumnModificationParameters( - IUpdateEntry entry, + IUpdateEntry? entry, IProperty? property, IColumnBase column, Func generateParameterName, diff --git a/src/EFCore.Relational/Update/ModificationCommand.cs b/src/EFCore.Relational/Update/ModificationCommand.cs index 69744ad6b91..feb167febd0 100644 --- a/src/EFCore.Relational/Update/ModificationCommand.cs +++ b/src/EFCore.Relational/Update/ModificationCommand.cs @@ -360,7 +360,6 @@ private List GenerateColumnModifications() var nonMainEntry = !_mainEntryAdded || entry != _entries[0]; - IEnumerable columnMappings; var optionalDependentWithAllNull = false; if (StoreStoredProcedure is null) @@ -371,25 +370,24 @@ private List GenerateColumnModifications() continue; } - columnMappings = tableMapping.ColumnMappings; - optionalDependentWithAllNull = entry.EntityState is EntityState.Modified or EntityState.Added && tableMapping.Table.IsOptional(entry.EntityType) && tableMapping.Table.GetRowInternalForeignKeys(entry.EntityType).Any(); + + foreach (var columnMapping in tableMapping.ColumnMappings) + { + HandleColumnModification(columnMapping); + } } - else + else // Stored procedure mapping case { var storedProcedureMapping = GetStoredProcedureMapping(entry.EntityType, EntityState); Check.DebugAssert(storedProcedureMapping is not null, "No sproc mapping but StoredProcedure is not null"); - - columnMappings = storedProcedureMapping.ParameterMappings - .Concat((IEnumerable)storedProcedureMapping.ResultColumnMappings); - - // Stored procedures may have an additional rows affected parameter, result column or return value, which does not have a - // property/column mapping but still needs to have be represented via a column modification. var storedProcedure = storedProcedureMapping.StoredProcedure; + // Stored procedures may have an additional rows affected result column or return value, which does not have a + // property/column mapping but still needs to have be represented via a column modification. IColumnBase? rowsAffectedColumnBase = null; if (storedProcedure.FindRowsAffectedParameter() is { } rowsAffectedParameter) @@ -405,10 +403,12 @@ entry.EntityState is EntityState.Modified or EntityState.Added rowsAffectedColumnBase = rowsAffectedReturnValue; } - if (rowsAffectedColumnBase is not null) + // Add a column modification for rows affected result column/return value. + // A rows affected output parameter is added below in the correct position, with the rest of the parameters. + if (rowsAffectedColumnBase is IStoreStoredProcedureResultColumn or IStoreStoredProcedureReturnValue) { columnModifications.Add(CreateColumnModification(new ColumnModificationParameters( - entry, + entry: null, property: null, rowsAffectedColumnBase, _generateParameterName!, @@ -419,9 +419,56 @@ entry.EntityState is EntityState.Modified or EntityState.Added columnIsCondition: false, _sensitiveLoggingEnabled))); } + + // In TPH, the sproc has parameters for all entity types in the hierarchy; we must generate null column modifications + // for parameters for unrelated entity types. + // Enumerate over the sproc parameters in order, trying to match a corresponding parameter mapping. + // Note that we produce the column modifications in the same order as their sproc parameters; this is important and assumed + // later in the pipeline. + foreach (var parameter in StoreStoredProcedure.Parameters) + { + if (parameter.FindParameterMapping(entry.EntityType) is { } parameterMapping) + { + HandleColumnModification(parameterMapping); + continue; + } + + // The parameter has no corresponding mapping; this is either a sibling property in a TPH hierarchy or a rows affected + // output parameter or return value. + columnModifications.Add(CreateColumnModification(new ColumnModificationParameters( + entry: null, + property: null, + parameter, + _generateParameterName!, + parameter.StoreTypeMapping, + valueIsRead: parameter.Direction.HasFlag(ParameterDirection.Output), + valueIsWrite: parameter.Direction.HasFlag(ParameterDirection.Input), + columnIsKey: false, + columnIsCondition: false, + _sensitiveLoggingEnabled))); + } + + // Note that we only add column modifications for mapped result columns, even though the sproc may return additional result + // columns (e.g. for siblings in TPH). Our result propagation accesses result columns directly by their position. + foreach (var columnMapping in storedProcedureMapping.ResultColumnMappings) + { + HandleColumnModification(columnMapping); + } } - foreach (var columnMapping in columnMappings) + if (optionalDependentWithAllNull && _logger != null) + { + if (_sensitiveLoggingEnabled) + { + _logger.OptionalDependentWithAllNullPropertiesWarningSensitive(entry); + } + else + { + _logger.OptionalDependentWithAllNullPropertiesWarning(entry); + } + } + + void HandleColumnModification(IColumnMappingBase columnMapping) { var property = columnMapping.Property; var column = columnMapping.Column; @@ -488,7 +535,7 @@ entry.EntityState is EntityState.Modified or EntityState.Added { columnPropagator.ColumnModification.AddSharedColumnModification(columnModification); - continue; + return; } columnPropagator.ColumnModification = columnModification; @@ -511,18 +558,6 @@ entry.EntityState is EntityState.Modified or EntityState.Added optionalDependentWithAllNull = false; } } - - if (optionalDependentWithAllNull && _logger != null) - { - if (_sensitiveLoggingEnabled) - { - _logger.OptionalDependentWithAllNullPropertiesWarningSensitive(entry); - } - else - { - _logger.OptionalDependentWithAllNullPropertiesWarning(entry); - } - } } return columnModifications; diff --git a/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs b/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs index ca0466d86d9..391c2859a69 100644 --- a/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs +++ b/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs @@ -266,25 +266,10 @@ public override void Complete(bool moreBatchesExpected) /// The modification command for which to add parameters. protected virtual void AddParameters(IReadOnlyModificationCommand modificationCommand) { - IEnumerable columnModifications; - - if (modificationCommand.StoreStoredProcedure is null) - { - columnModifications = modificationCommand.ColumnModifications; - } - else - { - if (modificationCommand.StoreStoredProcedure.ReturnValue is not null) - { - AddParameter(modificationCommand.ColumnModifications.First(c => c.Column is IStoreStoredProcedureReturnValue)); - } - - columnModifications = modificationCommand.ColumnModifications - .Where(c => c.Column is IStoreStoredProcedureParameter) - .OrderBy(c => ((IStoreStoredProcedureParameter)c.Column!).Position); - } - - foreach (var columnModification in columnModifications) + Check.DebugAssert(!modificationCommand.ColumnModifications.Any(m => m.Column is IStoreStoredProcedureReturnValue) + || modificationCommand.ColumnModifications[0].Column is IStoreStoredProcedureReturnValue, + "ResultValue column modification in non-first position"); + foreach (var columnModification in modificationCommand.ColumnModifications) { AddParameter(columnModification); } diff --git a/src/EFCore.Relational/Update/UpdateSqlGenerator.cs b/src/EFCore.Relational/Update/UpdateSqlGenerator.cs index 3a8c9cde012..818408c25ef 100644 --- a/src/EFCore.Relational/Update/UpdateSqlGenerator.cs +++ b/src/EFCore.Relational/Update/UpdateSqlGenerator.cs @@ -355,13 +355,16 @@ public virtual ResultSetMapping AppendStoredProcedureCall( // Only positional parameter style supported for now, see #28439 - var orderedParameterModifications = command.ColumnModifications - .Where(c => c.Column is IStoreStoredProcedureParameter) - .OrderBy(c => ((IStoreStoredProcedureParameter)c.Column!).Position); - - foreach (var columnModification in orderedParameterModifications) + // Note: the column modifications are already ordered according to the sproc parameter ordering + // (see ModificationCommand.GenerateColumnModifications) + for (var i = 0; i < command.ColumnModifications.Count; i++) { - var parameter = (IStoreStoredProcedureParameter)columnModification.Column!; + var columnModification = command.ColumnModifications[i]; + + if (columnModification.Column is not IStoreStoredProcedureParameter parameter) + { + continue; + } if (first) { diff --git a/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs b/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs index 4ae6e84480c..179379c6e68 100644 --- a/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs +++ b/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs @@ -591,13 +591,16 @@ public override ResultSetMapping AppendStoredProcedureCall( // Only positional parameter style supported for now, see #28439 - var orderedParameterModifications = command.ColumnModifications - .Where(c => c.Column is IStoreStoredProcedureParameter) - .OrderBy(c => ((IStoreStoredProcedureParameter)c.Column!).Position); - - foreach (var columnModification in orderedParameterModifications) + // Note: the column modifications are already ordered according to the sproc parameter ordering + // (see ModificationCommand.GenerateColumnModifications) + for (var i = 0; i < command.ColumnModifications.Count; i++) { - var parameter = (IStoreStoredProcedureParameter)columnModification.Column!; + var columnModification = command.ColumnModifications[i]; + + if (columnModification.Column is not IStoreStoredProcedureParameter parameter) + { + continue; + } if (first) { diff --git a/test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/StoredProcedureUpdateContext.cs b/test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/StoredProcedureUpdateContext.cs index 41bef01f833..b2ebb87e1c0 100644 --- a/test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/StoredProcedureUpdateContext.cs +++ b/test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/StoredProcedureUpdateContext.cs @@ -53,7 +53,7 @@ public DbSet WithInputOutputParameterOnNonConcurrencyToken => Set(nameof(WithInputOutputParameterOnNonConcurrencyToken)); public DbSet TphParent { get; set; } - public DbSet TphChild { get; set; } + public DbSet TphChild { get; set; } public DbSet TptParent { get; set; } public DbSet TptChild { get; set; } public DbSet TptMixedParent { get; set; } diff --git a/test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/TphChild.cs b/test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/TphChild1.cs similarity index 73% rename from test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/TphChild.cs rename to test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/TphChild1.cs index acf252e5ede..1278bb97aca 100644 --- a/test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/TphChild.cs +++ b/test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/TphChild1.cs @@ -3,7 +3,7 @@ namespace Microsoft.EntityFrameworkCore.TestModels.StoredProcedureUpdateModel; -public class TphChild : TphParent +public class TphChild1 : TphParent { - public int ChildProperty { get; set; } + public int Child1Property { get; set; } } diff --git a/test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/TphChild2.cs b/test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/TphChild2.cs new file mode 100644 index 00000000000..3fdf4059531 --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/TestModels/StoredProcedureUpdateModel/TphChild2.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.TestModels.StoredProcedureUpdateModel; + +public class TphChild2 : TphParent +{ + public int Child2InputProperty { get; set; } + public int Child2OutputParameterProperty { get; set; } + public int Child2ResultColumnProperty { get; set; } +} diff --git a/test/EFCore.Relational.Specification.Tests/Update/StoredProcedureUpdateFixtureBase.cs b/test/EFCore.Relational.Specification.Tests/Update/StoredProcedureUpdateFixtureBase.cs index feef547609c..9463d4d6d11 100644 --- a/test/EFCore.Relational.Specification.Tests/Update/StoredProcedureUpdateFixtureBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Update/StoredProcedureUpdateFixtureBase.cs @@ -180,6 +180,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con .HasRowsAffectedReturnValue()); }); + modelBuilder.Entity(); + + modelBuilder.Entity( + b => + { + b.Property(w => w.Child2OutputParameterProperty).HasDefaultValue(8); + b.Property(w => w.Child2ResultColumnProperty).HasDefaultValue(9); + }); + modelBuilder.Entity( b => { @@ -191,11 +200,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con .HasParameter(w => w.Id, pb => pb.IsOutput()) .HasParameter("Discriminator") .HasParameter(w => w.Name) - .HasParameter(nameof(TphChild.ChildProperty))); + .HasParameter(nameof(TphChild1.Child1Property)) + .HasParameter(nameof(TphChild2.Child2InputProperty)) + .HasParameter(nameof(TphChild2.Child2OutputParameterProperty), o => o.IsOutput()) + .HasResultColumn(nameof(TphChild2.Child2ResultColumnProperty))); }); - modelBuilder.Entity().ToTable("Tph"); - modelBuilder.Entity( b => { diff --git a/test/EFCore.Relational.Specification.Tests/Update/StoredProcedureUpdateTestBase.cs b/test/EFCore.Relational.Specification.Tests/Update/StoredProcedureUpdateTestBase.cs index 3878b662591..c127eef6ddf 100644 --- a/test/EFCore.Relational.Specification.Tests/Update/StoredProcedureUpdateTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Update/StoredProcedureUpdateTestBase.cs @@ -514,7 +514,7 @@ public virtual async Task Tph(bool async) { await using var context = CreateContext(); - var entity1 = new TphChild { Name = "Child", ChildProperty = 8 }; + var entity1 = new TphChild1 { Name = "Child", Child1Property = 8 }; context.TphChild.Add(entity1); await SaveChanges(context, async); @@ -525,7 +525,7 @@ public virtual async Task Tph(bool async) var entity2 = context.TphChild.Single(b => b.Id == entity1.Id); Assert.Equal("Child", entity2.Name); - Assert.Equal(8, entity2.ChildProperty); + Assert.Equal(8, entity2.Child1Property); } } diff --git a/test/EFCore.SqlServer.FunctionalTests/Update/StoredProcedureUpdateSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Update/StoredProcedureUpdateSqlServerTest.cs index c962b77bd2b..642e474132f 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Update/StoredProcedureUpdateSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Update/StoredProcedureUpdateSqlServerTest.cs @@ -291,13 +291,15 @@ public override async Task Tph(bool async) await base.Tph(async); AssertSql( - @"@p0='1' (Direction = Output) -@p1='TphChild' (Nullable = false) (Size = 4000) + @"@p0=NULL (Nullable = false) (Direction = Output) (DbType = Int32) +@p1='TphChild1' (Nullable = false) (Size = 4000) @p2='Child' (Size = 4000) @p3='8' (Nullable = true) +@p4=NULL (DbType = Int32) +@p5=NULL (Direction = Output) (DbType = Int32) SET NOCOUNT ON; -EXEC [Tph_Insert] @p0 OUTPUT, @p1, @p2, @p3;"); +EXEC [Tph_Insert] @p0 OUTPUT, @p1, @p2, @p3, @p4, @p5 OUTPUT;"); } public override async Task Tpt(bool async) @@ -535,10 +537,13 @@ AS BEGIN GO -CREATE PROCEDURE Tph_Insert(@Id int OUT, @Discriminator varchar(max), @Name varchar(max), @ChildProperty int) +CREATE PROCEDURE Tph_Insert(@Id int OUT, @Discriminator varchar(max), @Name varchar(max), @Child1Property int, @Child2InputProperty int, @Child2OutputParameterProperty int OUT) AS BEGIN - INSERT INTO [Tph] ([Discriminator], [Name], [ChildProperty]) VALUES (@Discriminator, @Name, @ChildProperty); + DECLARE @Table table ([Child2OutputParameterProperty] int); + INSERT INTO [Tph] ([Discriminator], [Name], [Child1Property], [Child2InputProperty]) OUTPUT [Inserted].[Child2OutputParameterProperty] INTO @Table VALUES (@Discriminator, @Name, @Child1Property, @Child2InputProperty); SET @Id = SCOPE_IDENTITY(); + SELECT @Child2OutputParameterProperty = [Child2OutputParameterProperty] FROM @Table; + SELECT [Child2ResultColumnProperty] FROM [Tph] WHERE [Id] = @Id END; GO