diff --git a/Directory.Packages.props b/Directory.Packages.props index 1587781..0830b7f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,5 +17,13 @@ + + + + + + + + \ No newline at end of file diff --git a/Nocr.Users.sln b/Nocr.Users.sln index 47434f5..1785430 100644 --- a/Nocr.Users.sln +++ b/Nocr.Users.sln @@ -16,6 +16,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nocr.Users.Host", "src\Nocr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nocr.Users.Api.Contracts", "src\Nocr.Users.Api.Contracts\Nocr.Users.Api.Contracts.csproj", "{4DDFB05F-DFC7-4BD6-B593-AD52CF1B002E}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Contracts", "Contracts", "{CA3AA87B-9217-4A2D-9EBA-BFC415B63818}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nocr.Users.Persistence", "src\Nocr.Users.Persistence\Nocr.Users.Persistence.csproj", "{51C01BA8-E3E1-45F4-9DDF-6E08DAF5BB46}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,5 +42,12 @@ Global {4DDFB05F-DFC7-4BD6-B593-AD52CF1B002E}.Debug|Any CPU.Build.0 = Debug|Any CPU {4DDFB05F-DFC7-4BD6-B593-AD52CF1B002E}.Release|Any CPU.ActiveCfg = Release|Any CPU {4DDFB05F-DFC7-4BD6-B593-AD52CF1B002E}.Release|Any CPU.Build.0 = Release|Any CPU + {51C01BA8-E3E1-45F4-9DDF-6E08DAF5BB46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51C01BA8-E3E1-45F4-9DDF-6E08DAF5BB46}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51C01BA8-E3E1-45F4-9DDF-6E08DAF5BB46}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51C01BA8-E3E1-45F4-9DDF-6E08DAF5BB46}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {4DDFB05F-DFC7-4BD6-B593-AD52CF1B002E} = {CA3AA87B-9217-4A2D-9EBA-BFC415B63818} EndGlobalSection EndGlobal diff --git a/src/Nocr.Users.AppServices/ServiceCollectionExtensions.cs b/src/Nocr.Users.AppServices/ServiceCollectionExtensions.cs index 8a73ad2..f3c5a10 100644 --- a/src/Nocr.Users.AppServices/ServiceCollectionExtensions.cs +++ b/src/Nocr.Users.AppServices/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.DependencyInjection; -using Nocr.Users.AppServices.Users.Repositories; using Nocr.Users.AppServices.Users.Services; namespace Nocr.Users.AppServices; @@ -11,8 +10,6 @@ public static class ServiceCollectionExtensions if (services == null) throw new ArgumentNullException(nameof(services)); - // Add registrations here - services.AddSingleton(); services.AddScoped(); return services; diff --git a/src/Nocr.Users.AppServices/Users/Repositories/InMemoryUsersRepository.cs b/src/Nocr.Users.AppServices/Users/Repositories/InMemoryUsersRepository.cs deleted file mode 100644 index 6d71113..0000000 --- a/src/Nocr.Users.AppServices/Users/Repositories/InMemoryUsersRepository.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Nocr.Users.Api.Contracts.Users; - -namespace Nocr.Users.AppServices.Users.Repositories; - -public sealed class InMemoryUsersRepository : IUsersRepository -{ - private long _id = 0; - - private long _userIdentityId = 0; - - private readonly List _users = new(); - - public Task Create(User user, CancellationToken cancellationToken = default) - { - var id = Interlocked.Increment(ref _id); - user.Id = id; - _users.Add(user); - - foreach (var identity in user.Identities) - { - identity.Id = Interlocked.Increment(ref _userIdentityId); - } - - return Task.FromResult(id); - } - - public Task GetUserById(long id, CancellationToken cancellationToken = default) - { - return Task.FromResult(_users.FirstOrDefault(x => x.Id == id)); - } - - public Task GetByIdentity(UserIdentityType identityType, string identity, - CancellationToken cancellationToken = default) - { - var user = _users.SingleOrDefault(x => x.Identities.Any(i => - i.Type == identityType && i.Identity.Equals(identity, StringComparison.OrdinalIgnoreCase))); - - return Task.FromResult(user); - } -} \ No newline at end of file diff --git a/src/Nocr.Users.AppServices/Users/User.cs b/src/Nocr.Users.AppServices/Users/User.cs index 7946f43..f9d3265 100644 --- a/src/Nocr.Users.AppServices/Users/User.cs +++ b/src/Nocr.Users.AppServices/Users/User.cs @@ -10,6 +10,13 @@ public sealed class User public IReadOnlyCollection Identities => _identities; + /// + /// Used by ef. + /// + private User() + { + } + private User(string username, params UserIdentity[] identities) { Username = username; diff --git a/src/Nocr.Users.AppServices/Users/UserIdentity.cs b/src/Nocr.Users.AppServices/Users/UserIdentity.cs index a3b081d..5fd6f19 100644 --- a/src/Nocr.Users.AppServices/Users/UserIdentity.cs +++ b/src/Nocr.Users.AppServices/Users/UserIdentity.cs @@ -8,6 +8,8 @@ public sealed class UserIdentity public string Identity { get; private set; } + public long UserId { get; private set; } + public UserIdentityType Type { get; private set; } private UserIdentity(string identity, UserIdentityType type) diff --git a/src/Nocr.Users.Host/Infrastructure/Startup.cs b/src/Nocr.Users.Host/Infrastructure/Startup.cs index 82567d2..34c4e69 100644 --- a/src/Nocr.Users.Host/Infrastructure/Startup.cs +++ b/src/Nocr.Users.Host/Infrastructure/Startup.cs @@ -1,5 +1,6 @@ using Nocr.Users.AppServices; using Nocr.Users.Core.Dates; +using Nocr.Users.Persistence; namespace Nocr.Users.Host.Infrastructure; @@ -22,6 +23,7 @@ public class Startup services.AddSwaggerGen(); services.AddAppServices(); + services.AddEfPersistence(Configuration.GetConnectionString(nameof(UsersContext))!); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) diff --git a/src/Nocr.Users.Host/Nocr.Users.Host.csproj b/src/Nocr.Users.Host/Nocr.Users.Host.csproj index d5bc9be..6173de6 100644 --- a/src/Nocr.Users.Host/Nocr.Users.Host.csproj +++ b/src/Nocr.Users.Host/Nocr.Users.Host.csproj @@ -6,6 +6,7 @@ + @@ -14,6 +15,7 @@ + diff --git a/src/Nocr.Users.Host/appsettings.Development.json b/src/Nocr.Users.Host/appsettings.Development.json index fdf6b48..31f8835 100644 --- a/src/Nocr.Users.Host/appsettings.Development.json +++ b/src/Nocr.Users.Host/appsettings.Development.json @@ -8,5 +8,8 @@ } } ] + }, + "ConnectionStrings": { + "UsersContext": "server=localhost;port=3307;database=nocr_users;uid=root;pwd=toor" } } diff --git a/src/Nocr.Users.Host/appsettings.DockerCompose.json b/src/Nocr.Users.Host/appsettings.DockerCompose.json index a2e2bb3..5400b9f 100644 --- a/src/Nocr.Users.Host/appsettings.DockerCompose.json +++ b/src/Nocr.Users.Host/appsettings.DockerCompose.json @@ -18,5 +18,8 @@ } } ] + }, + "ConnectionStrings": { + "UsersContext": "server=nocr-users-db;port=3306;database=nocr_users;uid=root;pwd=toor" } } diff --git a/src/Nocr.Users.Persistence/DesignTimeTextMatcherContextFactory.cs b/src/Nocr.Users.Persistence/DesignTimeTextMatcherContextFactory.cs new file mode 100644 index 0000000..4706c3c --- /dev/null +++ b/src/Nocr.Users.Persistence/DesignTimeTextMatcherContextFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace Nocr.Users.Persistence; + +public class DesignTimeTextMatcherContextFactory : IDesignTimeDbContextFactory +{ + public UsersContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + + var connectionString = configuration.GetConnectionString("MariaLocal"); + optionsBuilder.UseMySql(connectionString, new MariaDbServerVersion(MariaDbServerVersion.LatestSupportedServerVersion)); + + return new UsersContext(optionsBuilder.Options); + } +} \ No newline at end of file diff --git a/src/Nocr.Users.Persistence/Migrations/20240330070727_InitialMigration.Designer.cs b/src/Nocr.Users.Persistence/Migrations/20240330070727_InitialMigration.Designer.cs new file mode 100644 index 0000000..464c975 --- /dev/null +++ b/src/Nocr.Users.Persistence/Migrations/20240330070727_InitialMigration.Designer.cs @@ -0,0 +1,93 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nocr.Users.Persistence; + +#nullable disable + +namespace Nocr.Users.Persistence.Migrations +{ + [DbContext(typeof(UsersContext))] + [Migration("20240330070727_InitialMigration")] + partial class InitialMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Nocr.Users.AppServices.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Username") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("UX_users_username"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Nocr.Users.AppServices.Users.UserIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Identity") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Identity", "Type") + .IsUnique() + .HasDatabaseName("UX_users_identity_identity_type"); + + b.ToTable("UserIdentity"); + }); + + modelBuilder.Entity("Nocr.Users.AppServices.Users.UserIdentity", b => + { + b.HasOne("Nocr.Users.AppServices.Users.User", null) + .WithMany("Identities") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Nocr.Users.AppServices.Users.User", b => + { + b.Navigation("Identities"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Nocr.Users.Persistence/Migrations/20240330070727_InitialMigration.cs b/src/Nocr.Users.Persistence/Migrations/20240330070727_InitialMigration.cs new file mode 100644 index 0000000..0ce86a2 --- /dev/null +++ b/src/Nocr.Users.Persistence/Migrations/20240330070727_InitialMigration.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nocr.Users.Persistence.Migrations +{ + /// + public partial class InitialMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Username = table.Column(type: "varchar(255)", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "UserIdentity", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Identity = table.Column(type: "varchar(255)", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + UserId = table.Column(type: "bigint", nullable: false), + Type = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserIdentity", x => x.Id); + table.ForeignKey( + name: "FK_UserIdentity_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_UserIdentity_UserId", + table: "UserIdentity", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "UX_users_identity_identity_type", + table: "UserIdentity", + columns: new[] { "Identity", "Type" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_users_username", + table: "Users", + column: "Username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserIdentity"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/src/Nocr.Users.Persistence/Migrations/UsersContextModelSnapshot.cs b/src/Nocr.Users.Persistence/Migrations/UsersContextModelSnapshot.cs new file mode 100644 index 0000000..353423a --- /dev/null +++ b/src/Nocr.Users.Persistence/Migrations/UsersContextModelSnapshot.cs @@ -0,0 +1,90 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nocr.Users.Persistence; + +#nullable disable + +namespace Nocr.Users.Persistence.Migrations +{ + [DbContext(typeof(UsersContext))] + partial class UsersContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Nocr.Users.AppServices.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Username") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("UX_users_username"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Nocr.Users.AppServices.Users.UserIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Identity") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Identity", "Type") + .IsUnique() + .HasDatabaseName("UX_users_identity_identity_type"); + + b.ToTable("UserIdentity"); + }); + + modelBuilder.Entity("Nocr.Users.AppServices.Users.UserIdentity", b => + { + b.HasOne("Nocr.Users.AppServices.Users.User", null) + .WithMany("Identities") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Nocr.Users.AppServices.Users.User", b => + { + b.Navigation("Identities"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Nocr.Users.Persistence/Nocr.Users.Persistence.csproj b/src/Nocr.Users.Persistence/Nocr.Users.Persistence.csproj new file mode 100644 index 0000000..021cc7b --- /dev/null +++ b/src/Nocr.Users.Persistence/Nocr.Users.Persistence.csproj @@ -0,0 +1,26 @@ + + + + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + Always + + + + diff --git a/src/Nocr.Users.Persistence/ServiceCollectionExtensions.cs b/src/Nocr.Users.Persistence/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..a04b6f2 --- /dev/null +++ b/src/Nocr.Users.Persistence/ServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Nocr.Users.AppServices.Users.Repositories; +using Nocr.Users.Persistence.Users; + +namespace Nocr.Users.Persistence; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddEfPersistence(this IServiceCollection services, string connectionString) + { + if (services == null) + throw new ArgumentNullException(nameof(services)); + + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentNullException(nameof(connectionString)); + + services.AddScoped(); + + services.AddDbContext( + (ctx, context) => + { + context.UseMySql(connectionString, new MariaDbServerVersion(MariaDbServerVersion.LatestSupportedServerVersion), + builder => builder.MigrationsAssembly(typeof(UsersContext).Assembly.FullName)) + .UseLoggerFactory(ctx.GetRequiredService()); + } + ); + + return services; + } +} \ No newline at end of file diff --git a/src/Nocr.Users.Persistence/Users/UserConfiguration.cs b/src/Nocr.Users.Persistence/Users/UserConfiguration.cs new file mode 100644 index 0000000..9eb9075 --- /dev/null +++ b/src/Nocr.Users.Persistence/Users/UserConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Nocr.Users.AppServices.Users; + +namespace Nocr.Users.Persistence.Users; + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + builder.Property(x => x.Username) + .IsRequired(); + + builder.HasIndex(x => x.Username) + .IsUnique() + .HasDatabaseName("UX_users_username"); + + builder + .HasMany(u => u.Identities) + .WithOne() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.NoAction); + } +} + +public class UserIdentityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + builder.Property(x => x.Identity) + .IsRequired(); + + builder.HasIndex(x => new { x.Identity, x.Type }) + .IsUnique() + .HasDatabaseName("UX_users_identity_identity_type"); + } +} \ No newline at end of file diff --git a/src/Nocr.Users.Persistence/Users/UsersRepository.cs b/src/Nocr.Users.Persistence/Users/UsersRepository.cs new file mode 100644 index 0000000..0c4f08a --- /dev/null +++ b/src/Nocr.Users.Persistence/Users/UsersRepository.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Nocr.Users.Api.Contracts.Users; +using Nocr.Users.AppServices.Users; +using Nocr.Users.AppServices.Users.Repositories; + +namespace Nocr.Users.Persistence.Users; + +public class UsersRepository : IUsersRepository +{ + private readonly UsersContext _db; + + public UsersRepository(UsersContext db) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + } + + public async Task Create(User user, CancellationToken cancellationToken = default) + { + var userId = await _db.Users.AddAsync(user, cancellationToken); + await _db.SaveChangesAsync(cancellationToken); + + return userId.Entity.Id; + } + + public Task GetUserById(long id, CancellationToken cancellationToken = default) + { + return _db.Users.Include(x => x.Identities) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken: cancellationToken); + } + + public Task GetByIdentity(UserIdentityType identityType, string identity, + CancellationToken cancellationToken = default) + { + return _db.Users.Include(x => x.Identities) + .FirstOrDefaultAsync(x => x.Identities.Any(i => i.Type == identityType && i.Identity == identity), + cancellationToken: cancellationToken); + } +} \ No newline at end of file diff --git a/src/Nocr.Users.Persistence/UsersContext.cs b/src/Nocr.Users.Persistence/UsersContext.cs new file mode 100644 index 0000000..ff2c275 --- /dev/null +++ b/src/Nocr.Users.Persistence/UsersContext.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Nocr.Users.AppServices.Users; +using Nocr.Users.Persistence.Users; + +namespace Nocr.Users.Persistence; + +public class UsersContext : DbContext +{ + public DbSet Users { get; set; } + + public UsersContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new UserConfiguration()); + modelBuilder.ApplyConfiguration(new UserIdentityConfiguration()); + + base.OnModelCreating(modelBuilder); + } +} \ No newline at end of file diff --git a/src/Nocr.Users.Persistence/appsettings.json b/src/Nocr.Users.Persistence/appsettings.json new file mode 100644 index 0000000..62bb122 --- /dev/null +++ b/src/Nocr.Users.Persistence/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "MariaLocal": "server=localhost;port=3307;database=nocr_users;uid=root;pwd=toor" + } +} \ No newline at end of file