Search code examples
c#entity-frameworkidentityserver4

How to customise Identity Models to rename columns and resize datatypes in IdentityServer4


I've been playing around with this quickstart example & have been trying to see how far I can customise the database (I have an existing database I've been half-trying to replicate).

I'm trying my hand at renaming fields and redefining the datatypes but have run into the following error:

InvalidOperationException: 'Role.NormalisedName' and 'Role.NormalizedName' are both mapped to column 'NormalisedName' in 'Roles' but are configured to use different data types ('nvarchar(max)' and 'nvarchar(256)').

The entity that customises IdentityRole currently looks like this:

public class Role : IdentityRole<int>
{
    public int RoleId
    {
        get => base.Id;
        set => base.Id = value;
    }
    public string NormalisedName
    {
        get => base.NormalizedName;
        set => base.NormalizedName = value;
    }

    public ICollection<RoleClaim> ClaimsCollection { get; set; }
}

And the override in the ApplicationDbContext looks like this:

// "Microsoft.AspNetCore.Identity.IdentityRole"
ModelBuilder.Entity<Role>(E =>
{
    E.Property(P => P.Id)
        .HasColumnName("RoleId")
        .ValueGeneratedOnAdd();

    E.Property(P => P.ConcurrencyStamp)
        .HasMaxLength(512)
        .IsConcurrencyToken();

    E.Property(P => P.Name)
    .HasMaxLength(128);

    E.Property(P => P.NormalizedName)
        .HasMaxLength(128)
        .HasColumnName("NormalisedName");

    E.HasKey(P => P.Id);

    E.HasIndex(P => P.NormalizedName)
        .IsUnique()
        .HasName("IX_Roles_NormalisedName");

    E.ToTable("Roles");
});

The "Roles" table in the database has a width of nvarchar(512) set on both the Name and NormalisedName columns; this is due to the index on NormalisedName, which will warn on the length of a key in a nonclustered index exceeding 1700 bytes. The warning generated will look like this:

Warning! The maximum key length for a nonclustered index is 1700 bytes. The index 'IX_Roles_NormalisedName' has maximum length of 2048 bytes. For some combination of large values, the insert/update operation will fail.

Does anyone have any idea how (or even if) I can impose this limit on this field? Specifying the HasMaxLength() extension method alone doesn't appear to work as I expected.


Solution

  • The reason I was seeing the error was due to my attempt at redefining the column, as opposed to replacing the field and providing a fresh definition for how I wanted the data to be stored in SqlServer.

    Because I was overlaying my edits on the existing stuff in IdentityDbContext, ModelBuilder could see both fields and was trying to wedge everything together a little haphazardly.

    A better example of renaming and removing columns from an Identity object would be the IdentityUser, so I'll use that as an example as it has more things I wanted to change in it.

    The default layout of the object looks as follows:

    public class IdentityUser<TKey> where TKey : IEquatable<TKey>
    {
        public IdentityUser();
        public IdentityUser(string userName);
        public virtual DateTimeOffset? LockoutEnd { get; set; }
        public virtual bool TwoFactorEnabled { get; set; }
        public virtual bool PhoneNumberConfirmed { get; set; }
        public virtual string PhoneNumber { get; set; }
        public virtual string ConcurrencyStamp { get; set; }
        public virtual string SecurityStamp { get; set; }
        public virtual string PasswordHash { get; set; }
        public virtual bool EmailConfirmed { get; set; }
        public virtual string NormalizedEmail { get; set; }
        public virtual string Email { get; set; }
        public virtual string NormalizedUserName { get; set; }
        public virtual string UserName { get; set; }
        public virtual TKey Id { get; set; }
        public virtual bool LockoutEnabled { get; set; }
        public virtual int AccessFailedCount { get; set; }
    
        public override string ToString();
    }
    

    Say we're working database-first and I want to make various changes, like...

    • make the key Id an integer (and the column has the 'identity' attribute in SqlServer)
    • rename the table
    • remove the LockoutEnd field
    • remove the PhoneNumberConfirmed field
    • remove the PhoneNumber field
    • remove the LockoutEnabled field
    • remove the AccessFailedCount field
    • and rename the Id, NormalizedEmail and NormalizedUserName fields

    Easy enough in theory, and in practice providing you know exactly where to look... I actually found the answer to this by accident, trying to resolve another problem. So, from the top.

    Starting from a new "User" object, I needed to first ensure the key type was set.

    public class User : IdentityUser<int>
    {
    }
    

    In my case, because I wanted to rename the field as well as change the type, it made more sense to entirely redefine it; Id became AccountId to make the identifier consistent throughout all of my tables.

    public int AccountId { get; set; }
    

    This is then backed-up in the DbContext (in my case CustomerDbContext), the key type change needs to be confirmed here, this was the standard change to the IdentityDbContext inheritance to specify the types it should be using.

    public partial class CustomerDbContext : IdentityDbContext<User, Role, int>
    {
    }
    

    Within this, and after base.OnModelCreating(ModelBuilder);, I had to define the key to use for this object.

    We may as well change the table name while we're here too.

    protected override void OnModelCreating(ModelBuilder ModelBuilder)
    {
        base.OnModelCreating(ModelBuilder);
    
        ModelBuilder.Entity<User>(Entity =>
        {
            Entity.HasKey(E => E.AccountId);
    
            Entity.ToTable("Users");
        });
    }
    

    The Id field will still raise its' ugly head, but that will be sorted when we remove the fields we don't want from IdentityUser.

    To rename the NormalizedEmail and NormalizedUserName fields, you can just specify the column name for them when building the User entity, for example: ql("(getdate())");

    Entity.Property(E => E.NormalizedEmail)
        .HasColumnName("NormalisedEmail")
        .IsRequired();
    
    Entity.Property(E => E.NormalizedUserName)
        .HasColumnName("NormalisedUserName");
    

    Nice and easy, but you can't change the column definition as far as I can tell, so adding .HasMaxLength(512) to either column, for instance, will make it bug out (the default column width is 450). This is actually the cause of the first error message in my question.

    InvalidOperationException: 'Role.NormalisedName' and 'Role.NormalizedName' are both mapped to column 'NormalisedName' in 'Roles' but are configured to use different data types ('nvarchar(max)' and 'nvarchar(256)').

    I found it easier to fully redefine the fields; adding

    // I _think_ the original fields are used internally, so I
    // couldn't dispense with them entirely.
    public string NormalisedEmail
    {
        get => NormalizedEmail;
        set => NormalizedEmail = value.ToLowerInvariant();
    }
    public string NormalisedUserName
    {
        get => NormalizedUserName;
        set => NormalizedUserName = value.ToLowerInvariant();
    }
    

    to my User class, and

    Entity.Property(E => E.NormalisedEmail)
        .IsRequired()
        .HasMaxLength(512);
    
    Entity.Property(E => E.NormalisedUserName)
        .HasMaxLength(512);
    

    to my Entity<User> definition in CustomerDbContext.

    Finally, to remove all of the unused fields, and the lingering Id, NormalizedEmail and NormalizedUserName fields that are now redefined; simply specify that they should be ignored by the ModelBuilder, using:

    Entity.Ignore(E => E.Id)
        .Ignore(E => E.AccessFailedCount)
        .Ignore(E => E.LockoutEnabled)
        .Ignore(E => E.LockoutEnd)
        .Ignore(E => E.NormalizedEmail)
        .Ignore(E => E.NormalizedUserName)
        .Ignore(E => E.PhoneNumber)
        .Ignore(E => E.PhoneNumberConfirmed);
    

    This was just about enough to allow me to design my project database-first and then bend Identity/IdentityServer around the tables.