Skip to content

Commit

Permalink
Add schema support infrastructure (#46300)
Browse files Browse the repository at this point in the history
  • Loading branch information
HaoK committed Feb 21, 2023
1 parent 171317d commit bec278e
Show file tree
Hide file tree
Showing 11 changed files with 570 additions and 0 deletions.
54 changes: 54 additions & 0 deletions src/Identity/EntityFrameworkCore/src/IdentityDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,60 @@ public abstract class IdentityDbContext<TUser, TRole, TKey, TUserClaim, TUserRol
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
}

/// <summary>
/// Configures the schema needed for the identity framework for schema version 2.0
/// </summary>
/// <param name="builder">
/// The builder being used to construct the model for this context.
/// </param>
internal override void OnModelCreatingVersion2(ModelBuilder builder)
{
base.OnModelCreatingVersion2(builder);

// Current no differences between Version 2 and Version 1
builder.Entity<TUser>(b =>
{
b.HasMany<TUserRole>().WithOne().HasForeignKey(ur => ur.UserId).IsRequired();
});

builder.Entity<TRole>(b =>
{
b.HasKey(r => r.Id);
b.HasIndex(r => r.NormalizedName).HasDatabaseName("RoleNameIndex").IsUnique();
b.ToTable("AspNetRoles");
b.Property(r => r.ConcurrencyStamp).IsConcurrencyToken();
b.Property(u => u.Name).HasMaxLength(256);
b.Property(u => u.NormalizedName).HasMaxLength(256);
b.HasMany<TUserRole>().WithOne().HasForeignKey(ur => ur.RoleId).IsRequired();
b.HasMany<TRoleClaim>().WithOne().HasForeignKey(rc => rc.RoleId).IsRequired();
});

builder.Entity<TRoleClaim>(b =>
{
b.HasKey(rc => rc.Id);
b.ToTable("AspNetRoleClaims");
});

builder.Entity<TUserRole>(b =>
{
b.HasKey(r => new { r.UserId, r.RoleId });
b.ToTable("AspNetUserRoles");
});
}

/// <summary>
/// Configures the schema needed for the identity framework for schema version 1.0
/// </summary>
/// <param name="builder">
/// The builder being used to construct the model for this context.
/// </param>
internal override void OnModelCreatingVersion1(ModelBuilder builder)
{
base.OnModelCreatingVersion1(builder);
builder.Entity<TUser>(b =>
{
b.HasMany<TUserRole>().WithOne().HasForeignKey(ur => ur.UserId).IsRequired();
Expand Down
138 changes: 138 additions & 0 deletions src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ public abstract class IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, T
/// </summary>
public virtual DbSet<TUserToken> UserTokens { get; set; } = default!;

/// <summary>
/// Gets the schema version used for versioning.
/// </summary>
protected virtual Version SchemaVersion { get => GetStoreOptions()?.SchemaVersion ?? IdentitySchemaVersions.Version1; }

private StoreOptions? GetStoreOptions() => this.GetService<IDbContextOptions>()
.Extensions.OfType<CoreOptionsExtension>()
.FirstOrDefault()?.ApplicationServiceProvider
Expand All @@ -114,6 +119,139 @@ public PersonalDataConverter(IPersonalDataProtector protector) : base(s => prote
/// The builder being used to construct the model for this context.
/// </param>
protected override void OnModelCreating(ModelBuilder builder)
{
var version = GetStoreOptions()?.SchemaVersion ?? IdentitySchemaVersions.Version1;
OnModelCreatingVersion(builder, version);
}

/// <summary>
/// Configures the schema needed for the identity framework for a specific schema version.
/// </summary>
/// <param name="builder">
/// The builder being used to construct the model for this context.
/// </param>
/// <param name="schemaVersion">The schema version.</param>
internal virtual void OnModelCreatingVersion(ModelBuilder builder, Version schemaVersion)
{
if (schemaVersion >= IdentitySchemaVersions.Version2)
{
OnModelCreatingVersion2(builder);
}
else
{
OnModelCreatingVersion1(builder);
}
}

/// <summary>
/// Configures the schema needed for the identity framework for schema version 2.0
/// </summary>
/// <param name="builder">
/// The builder being used to construct the model for this context.
/// </param>
internal virtual void OnModelCreatingVersion2(ModelBuilder builder)
{
// Differences from Version 1:
// - maxKeyLength defaults to 128
// - PhoneNumber has a 256 max length

var storeOptions = GetStoreOptions();
var maxKeyLength = storeOptions?.MaxLengthForKeys ?? 0;
if (maxKeyLength == 0)
{
maxKeyLength = 128;
}
var encryptPersonalData = storeOptions?.ProtectPersonalData ?? false;
PersonalDataConverter? converter = null;

builder.Entity<TUser>(b =>
{
b.HasKey(u => u.Id);
b.HasIndex(u => u.NormalizedUserName).HasDatabaseName("UserNameIndex").IsUnique();
b.HasIndex(u => u.NormalizedEmail).HasDatabaseName("EmailIndex");
b.ToTable("AspNetUsers");
b.Property(u => u.ConcurrencyStamp).IsConcurrencyToken();
b.Property(u => u.UserName).HasMaxLength(256);
b.Property(u => u.NormalizedUserName).HasMaxLength(256);
b.Property(u => u.Email).HasMaxLength(256);
b.Property(u => u.NormalizedEmail).HasMaxLength(256);
b.Property(u => u.PhoneNumber).HasMaxLength(256);
if (encryptPersonalData)
{
converter = new PersonalDataConverter(this.GetService<IPersonalDataProtector>());
var personalDataProps = typeof(TUser).GetProperties().Where(
prop => Attribute.IsDefined(prop, typeof(ProtectedPersonalDataAttribute)));
foreach (var p in personalDataProps)
{
if (p.PropertyType != typeof(string))
{
throw new InvalidOperationException(Resources.CanOnlyProtectStrings);
}
b.Property(typeof(string), p.Name).HasConversion(converter);
}
}
b.HasMany<TUserClaim>().WithOne().HasForeignKey(uc => uc.UserId).IsRequired();
b.HasMany<TUserLogin>().WithOne().HasForeignKey(ul => ul.UserId).IsRequired();
b.HasMany<TUserToken>().WithOne().HasForeignKey(ut => ut.UserId).IsRequired();
});

builder.Entity<TUserClaim>(b =>
{
b.HasKey(uc => uc.Id);
b.ToTable("AspNetUserClaims");
});

builder.Entity<TUserLogin>(b =>
{
b.HasKey(l => new { l.LoginProvider, l.ProviderKey });
if (maxKeyLength > 0)
{
b.Property(l => l.LoginProvider).HasMaxLength(maxKeyLength);
b.Property(l => l.ProviderKey).HasMaxLength(maxKeyLength);
}
b.ToTable("AspNetUserLogins");
});

builder.Entity<TUserToken>(b =>
{
b.HasKey(t => new { t.UserId, t.LoginProvider, t.Name });
if (maxKeyLength > 0)
{
b.Property(t => t.LoginProvider).HasMaxLength(maxKeyLength);
b.Property(t => t.Name).HasMaxLength(maxKeyLength);
}
if (encryptPersonalData)
{
var tokenProps = typeof(TUserToken).GetProperties().Where(
prop => Attribute.IsDefined(prop, typeof(ProtectedPersonalDataAttribute)));
foreach (var p in tokenProps)
{
if (p.PropertyType != typeof(string))
{
throw new InvalidOperationException(Resources.CanOnlyProtectStrings);
}
b.Property(typeof(string), p.Name).HasConversion(converter);
}
}
b.ToTable("AspNetUserTokens");
});
}

/// <summary>
/// Configures the schema needed for the identity framework for schema version 1.0
/// </summary>
/// <param name="builder">
/// The builder being used to construct the model for this context.
/// </param>
internal virtual void OnModelCreatingVersion1(ModelBuilder builder)
{
var storeOptions = GetStoreOptions();
var maxKeyLength = storeOptions?.MaxLengthForKeys ?? 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken>.SchemaVersion.get -> System.Version!
49 changes: 49 additions & 0 deletions src/Identity/EntityFrameworkCore/test/EF.Test/CustomSchemaTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test;

public class CustomSchemaTest : IClassFixture<ScratchDatabaseFixture>
{
private readonly ApplicationBuilder _builder;

public CustomSchemaTest(ScratchDatabaseFixture fixture)
{
var services = new ServiceCollection();
services
.AddLogging()
.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build())
.AddDbContext<CustomVersionDbContext>(o =>
o.UseSqlite(fixture.Connection)
.ConfigureWarnings(b => b.Log(CoreEventId.ManyServiceProvidersCreatedWarning)))
.AddIdentity<IdentityUser, IdentityRole>(o =>
{
// Versions >= 3 are custom
o.Stores.SchemaVersion = new Version(3, 0);
})
.AddEntityFrameworkStores<CustomVersionDbContext>();

_builder = new ApplicationBuilder(services.BuildServiceProvider());
using var scope = _builder.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope();
var db = scope.ServiceProvider.GetRequiredService<CustomVersionDbContext>();
db.Database.EnsureCreated();
}

[Fact]
public void CanAddCustomColumn()
{
using var scope = _builder.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope();
var db = scope.ServiceProvider.GetRequiredService<CustomVersionDbContext>();
VersionTwoSchemaTest.VerifyVersion2Schema(db);
using var sqlConn = (SqliteConnection)db.Database.GetDbConnection();
sqlConn.Open();
Assert.True(DbUtil.VerifyColumns(sqlConn, "CustomColumns", "Id"));
}
}
58 changes: 58 additions & 0 deletions src/Identity/EntityFrameworkCore/test/EF.Test/EmptySchemaTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test;

public class EmptySchemaTest : IClassFixture<ScratchDatabaseFixture>
{
private readonly ApplicationBuilder _builder;

public EmptySchemaTest(ScratchDatabaseFixture fixture)
{
var services = new ServiceCollection();
services
.AddLogging()
.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build())
.AddDbContext<EmptyDbContext>(o =>
o.UseSqlite(fixture.Connection)
.ConfigureWarnings(b => b.Log(CoreEventId.ManyServiceProvidersCreatedWarning)))
.AddIdentity<IdentityUser, IdentityRole>(o =>
{
// Versions >= 10 are empty
o.Stores.SchemaVersion = new Version(11, 0);
})
.AddEntityFrameworkStores<EmptyDbContext>();

_builder = new ApplicationBuilder(services.BuildServiceProvider());
using var scope = _builder.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EmptyDbContext>();
db.Database.EnsureCreated();
}

[Fact]
public void CanIgnoreEverything()
{
using var scope = _builder.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EmptyDbContext>();
VerifyEmptySchema(db);
}

private static void VerifyEmptySchema(EmptyDbContext dbContext)
{
using var sqlConn = (SqliteConnection)dbContext.Database.GetDbConnection();
sqlConn.Open();
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUsers"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetRoles"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserRoles"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserClaims"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserLogins"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserTokens"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test;

public class VersionOneSchemaTest : IClassFixture<ScratchDatabaseFixture>
{
private readonly ApplicationBuilder _builder;

public VersionOneSchemaTest(ScratchDatabaseFixture fixture)
{
var services = new ServiceCollection();

services
.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build())
.AddDbContext<VersionOneDbContext>(o =>
o.UseSqlite(fixture.Connection)
.ConfigureWarnings(b => b.Log(CoreEventId.ManyServiceProvidersCreatedWarning)))
.AddIdentity<IdentityUser, IdentityRole>(o =>
{
o.Stores.MaxLengthForKeys = 128;
})
.AddEntityFrameworkStores<VersionOneDbContext>();

services.AddLogging();

_builder = new ApplicationBuilder(services.BuildServiceProvider());

using var scope = _builder.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope();
var db = scope.ServiceProvider.GetRequiredService<VersionOneDbContext>();
db.Database.EnsureCreated();
}

[Fact]
public void EnsureDefaultSchema()
{
using var scope = _builder.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope();
var db = scope.ServiceProvider.GetRequiredService<VersionOneDbContext>();
VerifyVersion1Schema(db);
}

private static void VerifyVersion1Schema(VersionOneDbContext dbContext)
{
using var sqlConn = (SqliteConnection)dbContext.Database.GetDbConnection();
sqlConn.Open();
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUsers", "Id", "UserName", "Email", "PasswordHash", "SecurityStamp",
"EmailConfirmed", "PhoneNumber", "PhoneNumberConfirmed", "TwoFactorEnabled", "LockoutEnabled",
"LockoutEnd", "AccessFailedCount", "ConcurrencyStamp", "NormalizedUserName", "NormalizedEmail"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetRoles", "Id", "Name", "NormalizedName", "ConcurrencyStamp"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserRoles", "UserId", "RoleId"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserClaims", "Id", "UserId", "ClaimType", "ClaimValue"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserLogins", "UserId", "ProviderKey", "LoginProvider", "ProviderDisplayName"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserTokens", "UserId", "LoginProvider", "Name", "Value"));

Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetUsers", 256, "UserName", "Email", "NormalizedUserName", "NormalizedEmail"));
Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetRoles", 256, "Name", "NormalizedName"));
Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetUserLogins", 128, "LoginProvider", "ProviderKey"));
Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetUserTokens", 128, "LoginProvider", "Name"));

DbUtil.VerifyIndex(sqlConn, "AspNetRoles", "RoleNameIndex", isUnique: true);
DbUtil.VerifyIndex(sqlConn, "AspNetUsers", "UserNameIndex", isUnique: true);
DbUtil.VerifyIndex(sqlConn, "AspNetUsers", "EmailIndex");
}
}
Loading

0 comments on commit bec278e

Please sign in to comment.