Skip to content

Commit

Permalink
Attribute for configuring composite primary keys
Browse files Browse the repository at this point in the history
Fixes #11003

This PR introduces a new `[PrimaryKey]` which follows the same pattern as `[Index]` in that it is applied to the entity type class and takes an ordered list of property names. It takes precedence over any `[Key]` attributes on properties, since these may still be needed for OData or other technologies. `PrimaryKey` and `Keyless` cannot be used on the same type.
  • Loading branch information
ajcvickers committed Mar 9, 2022
1 parent ac143e6 commit af7cc98
Show file tree
Hide file tree
Showing 15 changed files with 690 additions and 90 deletions.
36 changes: 36 additions & 0 deletions src/EFCore.Abstractions/PrimaryKeyAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.EntityFrameworkCore.Utilities;

namespace Microsoft.EntityFrameworkCore;

/// <summary>
/// Specifies a primary key for the entity type mapped to this CLR type. This attribute can be used for both keys made up of a
/// single property, and for composite keys made up of multiple properties. `System.ComponentModel.DataAnnotations.KeyAttribute`
/// can be used instead for single-property keys, in which case the behavior is identical. If both attributes are used, then
/// this attribute takes precedence.
/// </summary>
/// <remarks>
/// See <see href="https://aka.ms/efcore-docs-modeling">Modeling entity types and relationships</see> for more information and examples.
/// </remarks>
[AttributeUsage(AttributeTargets.Class)]
public sealed class PrimaryKeyAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="IndexAttribute" /> class.
/// </summary>
/// <param name="propertyNames">The properties which constitute the index, in order (there must be at least one).</param>
public PrimaryKeyAttribute(params string[] propertyNames)
{
Check.NotEmpty(propertyNames, nameof(propertyNames));
Check.HasNoEmptyElements(propertyNames, nameof(propertyNames));

PropertyNames = propertyNames.ToList();
}

/// <summary>
/// The properties which constitute the index, in order.
/// </summary>
public IReadOnlyList<string> PropertyNames { get; }
}
12 changes: 12 additions & 0 deletions src/EFCore/Metadata/Builders/IConventionEntityTypeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,18 @@ bool CanHaveIndexerProperty(
/// </returns>
IConventionKeyBuilder? PrimaryKey(IReadOnlyList<IConventionProperty>? properties, bool fromDataAnnotation = false);

/// <summary>
/// Sets the properties that make up the primary key for this entity type.
/// </summary>
/// <param name="propertyNames">The names of the properties that make up the primary key.</param>
/// <param name="fromDataAnnotation">Indicates whether the configuration was specified using a data annotation.</param>
/// <returns>An object that can be used to configure the primary key.</returns>
/// <returns>
/// An object that can be used to configure the primary key if it was set on the entity type,
/// <see langword="null" /> otherwise.
/// </returns>
IConventionKeyBuilder? PrimaryKey(IReadOnlyList<string> propertyNames, bool fromDataAnnotation = false);

/// <summary>
/// Returns a value indicating whether the given properties can be set as the primary key for this entity type.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public virtual ConventionSet CreateConventionSet()
var foreignKeyAttributeConvention = new ForeignKeyAttributeConvention(Dependencies);
var relationshipDiscoveryConvention = new RelationshipDiscoveryConvention(Dependencies);
var servicePropertyDiscoveryConvention = new ServicePropertyDiscoveryConvention(Dependencies);
var keyAttributeConvention = new KeyAttributeConvention(Dependencies);
var indexAttributeConvention = new IndexAttributeConvention(Dependencies);
var baseTypeDiscoveryConvention = new BaseTypeDiscoveryConvention(Dependencies);
conventionSet.EntityTypeAddedConventions.Add(new NotMappedEntityTypeAttributeConvention(Dependencies));
Expand All @@ -70,6 +71,7 @@ public virtual ConventionSet CreateConventionSet()
conventionSet.EntityTypeAddedConventions.Add(baseTypeDiscoveryConvention);
conventionSet.EntityTypeAddedConventions.Add(propertyDiscoveryConvention);
conventionSet.EntityTypeAddedConventions.Add(servicePropertyDiscoveryConvention);
conventionSet.EntityTypeAddedConventions.Add(keyAttributeConvention);
conventionSet.EntityTypeAddedConventions.Add(keyDiscoveryConvention);
conventionSet.EntityTypeAddedConventions.Add(indexAttributeConvention);
conventionSet.EntityTypeAddedConventions.Add(inversePropertyAttributeConvention);
Expand All @@ -88,6 +90,7 @@ public virtual ConventionSet CreateConventionSet()
conventionSet.EntityTypeBaseTypeChangedConventions.Add(propertyDiscoveryConvention);
conventionSet.EntityTypeBaseTypeChangedConventions.Add(servicePropertyDiscoveryConvention);
conventionSet.EntityTypeBaseTypeChangedConventions.Add(keyDiscoveryConvention);
conventionSet.EntityTypeBaseTypeChangedConventions.Add(keyAttributeConvention);
conventionSet.EntityTypeBaseTypeChangedConventions.Add(indexAttributeConvention);
conventionSet.EntityTypeBaseTypeChangedConventions.Add(inversePropertyAttributeConvention);
conventionSet.EntityTypeBaseTypeChangedConventions.Add(relationshipDiscoveryConvention);
Expand All @@ -102,7 +105,6 @@ public virtual ConventionSet CreateConventionSet()
conventionSet.EntityTypeMemberIgnoredConventions.Add(keyDiscoveryConvention);
conventionSet.EntityTypeMemberIgnoredConventions.Add(foreignKeyPropertyDiscoveryConvention);

var keyAttributeConvention = new KeyAttributeConvention(Dependencies);
var backingFieldConvention = new BackingFieldConvention(Dependencies);
var concurrencyCheckAttributeConvention = new ConcurrencyCheckAttributeConvention(Dependencies);
var databaseGeneratedAttributeConvention = new DatabaseGeneratedAttributeConvention(Dependencies);
Expand Down
210 changes: 172 additions & 38 deletions src/EFCore/Metadata/Conventions/KeyAttributeConvention.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@
namespace Microsoft.EntityFrameworkCore.Metadata.Conventions;

/// <summary>
/// A convention that configures the entity type key based on the <see cref="KeyAttribute" /> specified on a property.
/// A convention that configures the entity type key based on the <see cref="KeyAttribute" /> specified on a property or
/// <see cref="PrimaryKeyAttribute"/> specified on a CLR type.
/// </summary>
/// <remarks>
/// See <see href="https://aka.ms/efcore-docs-conventions">Model building conventions</see> for more information and examples.
/// </remarks>
public class KeyAttributeConvention : PropertyAttributeConventionBase<KeyAttribute>, IModelFinalizingConvention
public class KeyAttributeConvention
: PropertyAttributeConventionBase<KeyAttribute>,
IModelFinalizingConvention,
IEntityTypeAddedConvention,
IEntityTypeBaseTypeChangedConvention
{
/// <summary>
/// Creates a new instance of <see cref="KeyAttributeConvention" />.
Expand All @@ -23,13 +28,28 @@ public KeyAttributeConvention(ProviderConventionSetBuilderDependencies dependenc
{
}

/// <summary>
/// Called after a property is added to the entity type with an attribute on the associated CLR property or field.
/// </summary>
/// <param name="propertyBuilder">The builder for the property.</param>
/// <param name="attribute">The attribute.</param>
/// <param name="clrMember">The member that has the attribute.</param>
/// <param name="context">Additional information associated with convention execution.</param>
/// <inheritdoc />
public virtual void ProcessEntityTypeAdded(
IConventionEntityTypeBuilder entityTypeBuilder,
IConventionContext<IConventionEntityTypeBuilder> context)
=> CheckAttributesAndEnsurePrimaryKey((EntityType)entityTypeBuilder.Metadata, null, false);

/// <inheritdoc />
public virtual void ProcessEntityTypeBaseTypeChanged(
IConventionEntityTypeBuilder entityTypeBuilder,
IConventionEntityType? newBaseType,
IConventionEntityType? oldBaseType,
IConventionContext<IConventionEntityType> context)
{
if (oldBaseType == null)
{
return;
}

CheckAttributesAndEnsurePrimaryKey((EntityType)entityTypeBuilder.Metadata, null, false);
}

/// <inheritdoc />
protected override void ProcessPropertyAdded(
IConventionPropertyBuilder propertyBuilder,
KeyAttribute attribute,
Expand All @@ -42,8 +62,7 @@ protected override void ProcessPropertyAdded(
switch (entityType.GetIsKeylessConfigurationSource())
{
case ConfigurationSource.DataAnnotation:
Dependencies.Logger
.ConflictingKeylessAndKeyAttributesWarning(propertyBuilder.Metadata);
Dependencies.Logger.ConflictingKeylessAndKeyAttributesWarning(propertyBuilder.Metadata);
return;

case ConfigurationSource.Explicit:
Expand All @@ -52,39 +71,49 @@ protected override void ProcessPropertyAdded(
}
}

if (entityType.BaseType != null)
{
return;
}
CheckAttributesAndEnsurePrimaryKey(
(EntityType)propertyBuilder.Metadata.DeclaringEntityType,
propertyBuilder,
shouldThrow: false);
}

if (entityType.IsKeyless
&& entityType.GetIsKeylessConfigurationSource().Overrides(ConfigurationSource.DataAnnotation))
private bool CheckAttributesAndEnsurePrimaryKey(
EntityType entityType,
IConventionPropertyBuilder? propertyBuilder,
bool shouldThrow)
{
if (entityType.BaseType != null)
{
// TODO: Log a warning that KeyAttribute is being ignored. See issue#20014
// This code path will also be hit when entity is marked as Keyless explicitly
return;
return false;
}

var entityTypeBuilder = entityType.Builder;
var currentKey = entityTypeBuilder.Metadata.FindPrimaryKey();
var properties = new List<string> { propertyBuilder.Metadata.Name };
var primaryKeyAttributeExists = CheckPrimaryKeyAttributesAndEnsurePrimaryKey(entityType, shouldThrow);
var currentKey = entityType.FindPrimaryKey();

if (currentKey != null
&& entityType.GetPrimaryKeyConfigurationSource() == ConfigurationSource.DataAnnotation)
if (!primaryKeyAttributeExists
&& propertyBuilder != null)
{
properties.AddRange(
currentKey.Properties
.Where(p => !p.Name.Equals(propertyBuilder.Metadata.Name, StringComparison.OrdinalIgnoreCase))
.Select(p => p.Name));
if (properties.Count > 1)
var properties = new List<string> { propertyBuilder.Metadata.Name };

if (currentKey != null
&& entityType.GetPrimaryKeyConfigurationSource() == ConfigurationSource.DataAnnotation)
{
properties.Sort(StringComparer.OrdinalIgnoreCase);
entityTypeBuilder.HasNoKey(currentKey, fromDataAnnotation: true);
properties.AddRange(
currentKey.Properties
.Where(p => !p.Name.Equals(propertyBuilder.Metadata.Name, StringComparison.OrdinalIgnoreCase))
.Select(p => p.Name));

if (properties.Count > 1)
{
properties.Sort(StringComparer.OrdinalIgnoreCase);
entityType.Builder.HasNoKey(currentKey, ConfigurationSource.DataAnnotation);
}
}

entityType.Builder.PrimaryKey(properties, ConfigurationSource.DataAnnotation);
}

entityTypeBuilder.PrimaryKey(
entityTypeBuilder.GetOrCreateProperties(properties, fromDataAnnotation: true), fromDataAnnotation: true);
return primaryKeyAttributeExists;
}

/// <inheritdoc />
Expand All @@ -95,17 +124,29 @@ public virtual void ProcessModelFinalizing(
var entityTypes = modelBuilder.Metadata.GetEntityTypes();
foreach (var entityType in entityTypes)
{
var primaryKeyAttributeExits = CheckAttributesAndEnsurePrimaryKey((EntityType)entityType, null, true);

if (entityType.BaseType == null)
{
var currentPrimaryKey = entityType.FindPrimaryKey();
if (currentPrimaryKey?.Properties.Count > 1
&& entityType.GetPrimaryKeyConfigurationSource() == ConfigurationSource.DataAnnotation)
if (!primaryKeyAttributeExits)
{
throw new InvalidOperationException(CoreStrings.CompositePKWithDataAnnotation(entityType.DisplayName()));
var currentPrimaryKey = entityType.FindPrimaryKey();
if (currentPrimaryKey?.Properties.Count > 1
&& entityType.GetPrimaryKeyConfigurationSource() == ConfigurationSource.DataAnnotation)
{
throw new InvalidOperationException(CoreStrings.CompositePKWithDataAnnotation(entityType.DisplayName()));
}
}
}
else
{
if (Attribute.IsDefined(entityType.ClrType, typeof(PrimaryKeyAttribute), inherit: false))
{
throw new InvalidOperationException(
CoreStrings.PrimaryKeyAttributeOnDerivedEntity(
entityType.DisplayName(), entityType.GetRootType().DisplayName()));
}

foreach (var declaredProperty in entityType.GetDeclaredProperties())
{
var memberInfo = declaredProperty.GetIdentifyingMemberInfo();
Expand All @@ -121,4 +162,97 @@ public virtual void ProcessModelFinalizing(
}
}
}

private static bool CheckPrimaryKeyAttributesAndEnsurePrimaryKey(
IConventionEntityType entityType,
bool shouldThrow)
{
var primaryKeyAttribute = entityType.ClrType.GetCustomAttributes<PrimaryKeyAttribute>(true).FirstOrDefault();
if (primaryKeyAttribute == null)
{
return false;
}

if (entityType.ClrType.GetCustomAttributes<KeylessAttribute>(true).Any())
{
throw new InvalidOperationException(
CoreStrings.ConflictingKeylessAndPrimaryKeyAttributes(entityType.DisplayName()));
}

IConventionKeyBuilder? keyBuilder;
if (!shouldThrow)
{
var keyProperties = new List<IConventionProperty>();
foreach (var propertyName in primaryKeyAttribute.PropertyNames)
{
var property = entityType.FindProperty(propertyName);
if (property == null)
{
return true;
}

keyProperties.Add(property);
}

keyBuilder = entityType.Builder.PrimaryKey(keyProperties, fromDataAnnotation: true);
}
else
{
try
{
// Using the PrimaryKey(propertyNames) overload gives us a chance to create a missing property
// e.g. if the CLR property existed but was non-public.
keyBuilder = entityType.Builder.PrimaryKey(primaryKeyAttribute.PropertyNames, fromDataAnnotation: true);
}
catch (InvalidOperationException exception)
{
CheckMissingProperties(primaryKeyAttribute, entityType, exception);

throw;
}
}

if (keyBuilder == null)
{
CheckIgnoredProperties(primaryKeyAttribute, entityType);
}

return true;
}

private static void CheckIgnoredProperties(
PrimaryKeyAttribute primaryKeyAttribute,
IConventionEntityType entityType)
{
foreach (var propertyName in primaryKeyAttribute.PropertyNames)
{
if (entityType.Builder.IsIgnored(propertyName, fromDataAnnotation: true))
{
throw new InvalidOperationException(
CoreStrings.PrimaryKeyDefinedOnIgnoredProperty(
entityType.DisplayName(),
propertyName));
}
}
}

private static void CheckMissingProperties(
PrimaryKeyAttribute primaryKeyAttribute,
IConventionEntityType entityType,
InvalidOperationException innerException)
{
foreach (var propertyName in primaryKeyAttribute.PropertyNames)
{
var property = entityType.FindProperty(propertyName);
if (property == null)
{
throw new InvalidOperationException(
CoreStrings.PrimaryKeyDefinedOnNonExistentProperty(
entityType.DisplayName(),
primaryKeyAttribute.PropertyNames.Format(),
propertyName),
innerException);
}
}
}
}
14 changes: 14 additions & 0 deletions src/EFCore/Metadata/Internal/InternalEntityTypeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ public InternalEntityTypeBuilder(EntityType metadata, InternalModelBuilder model
{
}

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
[DebuggerStepThrough]
IConventionKeyBuilder? IConventionEntityTypeBuilder.PrimaryKey(
IReadOnlyList<string> propertyNames,
bool fromDataAnnotation)
=> PrimaryKey(
propertyNames,
fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention);

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
Expand Down
Loading

0 comments on commit af7cc98

Please sign in to comment.