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; }