diff --git a/src/EFCore.Relational/Metadata/Conventions/AddConcurrencyTokenPropertiesConvention.cs b/src/EFCore.Relational/Metadata/Conventions/AddConcurrencyTokenPropertiesConvention.cs new file mode 100644 index 00000000000..d3f5ba290bb --- /dev/null +++ b/src/EFCore.Relational/Metadata/Conventions/AddConcurrencyTokenPropertiesConvention.cs @@ -0,0 +1,189 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; + +namespace Microsoft.EntityFrameworkCore.Metadata.Conventions +{ + /// + /// A convention that finds entity types which share a table + /// which has a concurrency token column where those entity types + /// do not have a property mapped to that column. It then + /// creates a shadow concurrency property mapped to that column + /// on the base-most entity type(s). + /// + public class AddConcurrencyTokenPropertiesConvention : IModelFinalizingConvention + { + /// + /// Creates a new instance of . + /// + /// Parameter object containing dependencies for this convention. + /// Parameter object containing relational dependencies for this convention. + public AddConcurrencyTokenPropertiesConvention( + [NotNull] ProviderConventionSetBuilderDependencies dependencies, + [NotNull] RelationalConventionSetBuilderDependencies relationalDependencies) + { + Dependencies = dependencies; + } + + /// + /// Parameter object containing service dependencies. + /// + protected virtual ProviderConventionSetBuilderDependencies Dependencies { get; } + + public static readonly string ConcurrencyPropertyPrefix = "_concurrency_"; + + /// + public virtual void ProcessModelFinalizing( + IConventionModelBuilder modelBuilder, + IConventionContext context) + { + var maxIdentifierLength = modelBuilder.Metadata.GetMaxIdentifierLength(); + + GetMappings(modelBuilder.Metadata, + out var tableToEntityTypes, out var concurrencyColumnsToProperties); + + foreach (var table in tableToEntityTypes) + { + var tableName = table.Key; + if (!concurrencyColumnsToProperties.TryGetValue(tableName, out var concurrencyColumns)) + { + continue; // this table has no mapped concurrency columns + } + + var entityTypesMappedToTable = table.Value; + + foreach (var concurrencyColumn in concurrencyColumns) + { + var concurrencyColumnName = concurrencyColumn.Key; + var propertiesMappedToConcurrencyColumn = concurrencyColumn.Value; + + var entityTypesMissingConcurrencyColumn = + new Dictionary(); + foreach (var entityType in entityTypesMappedToTable) + { + var foundMappedProperty = false; + foreach (var mappedProperty in propertiesMappedToConcurrencyColumn) + { + var declaringEntityType = mappedProperty.DeclaringEntityType; + if (declaringEntityType.IsAssignableFrom(entityType) + || declaringEntityType.IsInOwnershipPath(entityType) + || entityType.IsInOwnershipPath(declaringEntityType)) + { + foundMappedProperty = true; + break; + } + } + + foundMappedProperty = foundMappedProperty + || entityType.GetAllBaseTypes().SelectMany(t => t.GetDeclaredProperties()) + .Any(p => p.GetColumnName() == concurrencyColumnName); + + if (!foundMappedProperty) + { + // store the entity type which is missing the + // concurrency token property, mapped to an example + // property which _is_ mapped to this concurrency token + // column and which will be used later as a template + entityTypesMissingConcurrencyColumn.Add( + entityType, propertiesMappedToConcurrencyColumn.First()); + } + } + + foreach(var entityTypeToExampleProperty in + BasestEntities(entityTypesMissingConcurrencyColumn)) + { + var entityType = entityTypeToExampleProperty.Key; + var exampleProperty = entityTypeToExampleProperty.Value; + var allExistingProperties = + entityType.GetProperties().Select(p => p.Name) + .Union(entityType.GetNavigations().Select(p => p.Name)) + .ToDictionary(s => s, s => 0); + var concurrencyShadowPropertyName = + Uniquifier.Uniquify(ConcurrencyPropertyPrefix + concurrencyColumnName, allExistingProperties, maxIdentifierLength); + var concurrencyShadowProperty = + entityType.AddProperty(concurrencyShadowPropertyName, exampleProperty.ClrType, null); + concurrencyShadowProperty.SetColumnName(concurrencyColumnName); + concurrencyShadowProperty.SetIsConcurrencyToken(true); + concurrencyShadowProperty.SetValueGenerated(exampleProperty.ValueGenerated); + } + } + } + } + + private void GetMappings(IConventionModel model, + out Dictionary> tableToEntityTypes, + out Dictionary>> concurrencyColumnsToProperties) + { + tableToEntityTypes = new Dictionary>(); + concurrencyColumnsToProperties = new Dictionary>>(); + foreach (var entityType in model.GetEntityTypes()) + { + var tableName = entityType.GetSchemaQualifiedTableName(); + if (tableName == null) + { + continue; // unmapped entityType + } + + if (!tableToEntityTypes.TryGetValue(tableName, out var mappedTypes)) + { + mappedTypes = new List(); + tableToEntityTypes[tableName] = mappedTypes; + } + + mappedTypes.Add(entityType); + + foreach (var property in entityType.GetDeclaredProperties()) + { + if (property.IsConcurrencyToken + && (property.ValueGenerated & ValueGenerated.OnUpdate) != 0) + { + if (!concurrencyColumnsToProperties.TryGetValue(tableName, out var columnToProperties)) + { + columnToProperties = new Dictionary>(); + concurrencyColumnsToProperties[tableName] = columnToProperties; + } + + var columnName = property.GetColumnName(); + if (!columnToProperties.TryGetValue(columnName, out var properties)) + { + properties = new List(); + columnToProperties[columnName] = properties; + } + + properties.Add(property); + } + } + } + } + + // Given a (distinct) IEnumerable of EntityTypes (mapped to T), + // return only the mappings where the EntityType does not inherit + // from any other EntityType in the list. + private static IEnumerable> BasestEntities( + IEnumerable> entityTypeDictionary) + { + var toRemove = new HashSet>(); + foreach (var entityType in entityTypeDictionary) + { + var otherEntityTypes = entityTypeDictionary + .Except(new[] { entityType }).Except(toRemove); + foreach (var otherEntityType in otherEntityTypes) + { + if (otherEntityType.Key.IsAssignableFrom(entityType.Key)) + { + toRemove.Add(entityType); + break; + } + } + } + + return entityTypeDictionary.Except(toRemove); + } + } +} diff --git a/src/EFCore.Relational/Metadata/Conventions/Infrastructure/RelationalConventionSetBuilder.cs b/src/EFCore.Relational/Metadata/Conventions/Infrastructure/RelationalConventionSetBuilder.cs index 9c1a197b01c..3cad44bacdb 100644 --- a/src/EFCore.Relational/Metadata/Conventions/Infrastructure/RelationalConventionSetBuilder.cs +++ b/src/EFCore.Relational/Metadata/Conventions/Infrastructure/RelationalConventionSetBuilder.cs @@ -92,6 +92,12 @@ public override ConventionSet CreateConventionSet() var dbFunctionAttributeConvention = new RelationalDbFunctionAttributeConvention(Dependencies, RelationalDependencies); conventionSet.ModelInitializedConventions.Add(dbFunctionAttributeConvention); + // Use TypeMappingConvention to add the relational store type mapping + // to the generated concurrency token property + ConventionSet.AddBefore( + conventionSet.ModelFinalizingConventions, + new AddConcurrencyTokenPropertiesConvention(Dependencies, RelationalDependencies), + typeof(TypeMappingConvention)); // ModelCleanupConvention would remove the entity types added by QueryableDbFunctionConvention #15898 ConventionSet.AddAfter( conventionSet.ModelFinalizingConventions, diff --git a/src/EFCore.Relational/Metadata/Conventions/SharedTableConvention.cs b/src/EFCore.Relational/Metadata/Conventions/SharedTableConvention.cs index f61d1460fa7..2630d166f47 100644 --- a/src/EFCore.Relational/Metadata/Conventions/SharedTableConvention.cs +++ b/src/EFCore.Relational/Metadata/Conventions/SharedTableConvention.cs @@ -167,7 +167,8 @@ private static void TryUniquifyColumnNames( var usePrefix = property.DeclaringEntityType != otherProperty.DeclaringEntityType || property.IsPrimaryKey() || otherProperty.IsPrimaryKey(); - if (!property.IsPrimaryKey()) + if (!property.IsPrimaryKey() + && !property.IsConcurrencyToken) { var newColumnName = TryUniquify(property, columnName, properties, usePrefix, maxLength); if (newColumnName != null) @@ -177,7 +178,8 @@ private static void TryUniquifyColumnNames( } } - if (!otherProperty.IsPrimaryKey()) + if (!otherProperty.IsPrimaryKey() + && !otherProperty.IsConcurrencyToken) { var newColumnName = TryUniquify(otherProperty, columnName, properties, usePrefix, maxLength); if (newColumnName != null) diff --git a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs index e90d2c759c3..3b7e4ed9362 100644 --- a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs +++ b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs @@ -962,7 +962,7 @@ public virtual void Passes_for_compatible_duplicate_index_names_within_hierarchy } [ConditionalFact] - public virtual void Detects_missing_concurrency_token_on_the_base_type() + public virtual void Missing_concurrency_token_property_is_created_on_the_base_type() { var modelBuilder = CreateConventionalModelBuilder(); modelBuilder.Entity().ToTable(nameof(Animal)) @@ -971,22 +971,70 @@ public virtual void Detects_missing_concurrency_token_on_the_base_type() modelBuilder.Entity() .Property("Version").IsRowVersion().HasColumnName("Version"); - VerifyError( - RelationalStrings.MissingConcurrencyColumn(nameof(Animal), "Version", nameof(Animal)), - modelBuilder.Model); + var model = modelBuilder.Model; + Validate(model); + + var animal = model.FindEntityType(typeof(Animal)); + var concurrencyProperty = animal.FindProperty("_concurrency_Version"); + Assert.True(concurrencyProperty.IsConcurrencyToken); + Assert.True(concurrencyProperty.IsShadowProperty()); + Assert.Equal("Version", concurrencyProperty.GetColumnName()); + Assert.Equal(ValueGenerated.OnAddOrUpdate, concurrencyProperty.ValueGenerated); + } + + [ConditionalFact] + public virtual void Missing_concurrency_token_properties_are_created_on_the_base_most_types() + { + var modelBuilder = CreateConventionalModelBuilder(); + modelBuilder.Entity().ToTable(nameof(Animal)).Property("Version") + .HasColumnName("Version").ValueGeneratedOnUpdate().IsConcurrencyToken(true); + modelBuilder.Entity().HasOne(a => a.FavoritePerson).WithOne().HasForeignKey(p => p.Id); + modelBuilder.Entity().HasOne(a => a.Dwelling).WithOne().HasForeignKey(p => p.Id); + modelBuilder.Entity(); + modelBuilder.Entity().ToTable(nameof(Animal)); + modelBuilder.Entity(); + + var model = modelBuilder.Model; + Validate(model); + + var animal = model.FindEntityType(typeof(Animal)); + var concurrencyProperty = animal.FindProperty("_concurrency_Version"); + Assert.True(concurrencyProperty.IsConcurrencyToken); + Assert.True(concurrencyProperty.IsShadowProperty()); + Assert.Equal("Version", concurrencyProperty.GetColumnName()); + Assert.Equal(ValueGenerated.OnUpdate, concurrencyProperty.ValueGenerated); + + var cat = model.FindEntityType(typeof(Cat)); + Assert.DoesNotContain(cat.GetDeclaredProperties(), p => p.Name == "_concurrency_Version"); + + var animalHouse = model.FindEntityType(typeof(AnimalHouse)); + concurrencyProperty = animalHouse.FindProperty("_concurrency_Version"); + Assert.True(concurrencyProperty.IsConcurrencyToken); + Assert.True(concurrencyProperty.IsShadowProperty()); + Assert.Equal("Version", concurrencyProperty.GetColumnName()); + Assert.Equal(ValueGenerated.OnUpdate, concurrencyProperty.ValueGenerated); + + var theMovie = model.FindEntityType(typeof(TheMovie)); + Assert.DoesNotContain(theMovie.GetDeclaredProperties(), p => p.Name == "_concurrency_Version"); } [ConditionalFact] - public virtual void Detects_missing_concurrency_token_on_the_sharing_type() + public virtual void Missing_concurrency_token_property_is_created_on_the_sharing_type() { var modelBuilder = CreateConventionalModelBuilder(); modelBuilder.Entity().ToTable(nameof(Animal)); modelBuilder.Entity().HasOne(a => a.FavoritePerson).WithOne().HasForeignKey(p => p.Id); modelBuilder.Entity().Property("Version").IsRowVersion().HasColumnName("Version"); - VerifyError( - RelationalStrings.MissingConcurrencyColumn(nameof(Person), "Version", nameof(Animal)), - modelBuilder.Model); + var model = modelBuilder.Model; + Validate(model); + + var personEntityType = model.FindEntityType(typeof(Person)); + var concurrencyProperty = personEntityType.FindProperty("_concurrency_Version"); + Assert.True(concurrencyProperty.IsConcurrencyToken); + Assert.True(concurrencyProperty.IsShadowProperty()); + Assert.Equal("Version", concurrencyProperty.GetColumnName()); + Assert.Equal(ValueGenerated.OnAddOrUpdate, concurrencyProperty.ValueGenerated); } [ConditionalFact] @@ -1116,6 +1164,7 @@ protected class Animal public string Name { get; set; } public Person FavoritePerson { get; set; } + public AnimalHouse Dwelling { get; set; } } protected class Cat : Animal @@ -1138,6 +1187,16 @@ protected class Dog : Animal public int Identity { get; set; } } + protected class AnimalHouse + { + public int Id { get; set; } + } + + protected class TheMovie : AnimalHouse + { + public bool CanHaveAnother { get; set; } + } + protected class Person { public int Id { get; set; }