From 3602dddaa13126d18917444eba2fdb78dddb836f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 17 Mar 2026 23:17:07 +0100 Subject: [PATCH 001/112] Add unit tests for HostDbContext generation in SimpleModule.Generator.Tests - Implement tests to verify HostDbContext generation for modules with and without DbContext. - Ensure correct namespace, auto-generated header, and constructor parameters. - Validate DbSet properties for single and multiple modules, including handling of duplicate entities. - Test entity type configurations and schema isolation for different database providers. - Include edge case handling for abstract DbContexts and duplicate table names. --- .editorconfig | 4 + .github/workflows/ci.yml | 6 + .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 11 + .../ModuleDiscovererGenerator.Emitters.cs | 465 ++++++- .../ModuleDiscovererGenerator.Models.cs | 88 +- .../ModuleDiscovererGenerator.cs | 239 ++++ .../SimpleModule.Generator.csproj | 4 + .../OrderItemConfiguration.cs | 13 + .../src/Products/Views/CreateEndpoint.cs | 25 +- .../src/Products/Views/EditEndpoint.cs | 27 +- .../Endpoints/Admin/AdminUsersEndpoint.cs | 6 +- .../SimpleModule.Host/HostDbContextFactory.cs | 46 + .../20260317220751_InitialCreate.Designer.cs | 610 +++++++++ .../20260317220751_InitialCreate.cs | 640 ++++++++++ .../Migrations/HostDbContextModelSnapshot.cs | 607 +++++++++ template/SimpleModule.Host/Program.cs | 11 +- .../SimpleModule.Host.csproj | 6 +- .../appsettings.Development.json | 5 + .../DiagnosticTests.cs | 980 +++++++++++++++ .../Helpers/GeneratorTestHelper.cs | 66 +- .../HostDbContextGenerationTests.cs | 1108 +++++++++++++++++ .../SimpleModule.Generator.Tests.csproj | 2 + 23 files changed, 4925 insertions(+), 46 deletions(-) create mode 100644 framework/SimpleModule.Generator/AnalyzerReleases.Shipped.md create mode 100644 framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md create mode 100644 modules/Orders/src/Orders/EntityConfigurations/OrderItemConfiguration.cs create mode 100644 template/SimpleModule.Host/HostDbContextFactory.cs create mode 100644 template/SimpleModule.Host/Migrations/20260317220751_InitialCreate.Designer.cs create mode 100644 template/SimpleModule.Host/Migrations/20260317220751_InitialCreate.cs create mode 100644 template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs create mode 100644 template/SimpleModule.Host/appsettings.Development.json create mode 100644 tests/SimpleModule.Generator.Tests/DiagnosticTests.cs create mode 100644 tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs diff --git a/.editorconfig b/.editorconfig index 727a724f..0a81d83c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -155,6 +155,10 @@ dotnet_diagnostic.IDE0072.severity = none [*.Generated.cs] generated_code = true +# EF Core migrations (auto-generated — suppress strict analysis) +[**/Migrations/*.cs] +generated_code = true + [*.g.cs] generated_code = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd35a5e8..739cc7a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,12 @@ jobs: env: Database__DefaultConnection: "Host=localhost;Database=simplemodule_test;Username=test;Password=test" + - name: Verify migrations are up-to-date + working-directory: template/SimpleModule.Host + env: + Database__DefaultConnection: "Host=localhost;Database=simplemodule_test;Username=test;Password=test" + run: dotnet ef migrations has-pending-model-changes + publish: runs-on: ubuntu-latest needs: [test-sqlite, test-postgresql] diff --git a/framework/SimpleModule.Generator/AnalyzerReleases.Shipped.md b/framework/SimpleModule.Generator/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000..9c6fa74f --- /dev/null +++ b/framework/SimpleModule.Generator/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md new file mode 100644 index 00000000..331f7d3f --- /dev/null +++ b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md @@ -0,0 +1,11 @@ +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------ +SM0001 | SimpleModule.Generator | Error | Duplicate DbSet property name across modules +SM0002 | SimpleModule.Generator | Warning | Module has empty name +SM0003 | SimpleModule.Generator | Error | Multiple IdentityDbContext types found +SM0004 | SimpleModule.Generator | Warning | DbContext has no DbSet properties +SM0005 | SimpleModule.Generator | Error | IdentityDbContext has unexpected type arguments +SM0006 | SimpleModule.Generator | Warning | Entity configuration targets entity not in any DbSet +SM0007 | SimpleModule.Generator | Error | Duplicate entity configuration diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs index 671be0a7..42006b93 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs @@ -9,6 +9,195 @@ namespace SimpleModule.Generator; public partial class ModuleDiscovererGenerator { + private static readonly DiagnosticDescriptor DuplicateDbSetPropertyName = new( + id: "SM0001", + title: "Duplicate DbSet property name across modules", + messageFormat: "DbSet property name '{0}' is used by multiple modules: {1} (entity {2}) and {3} (entity {4}). Each module must use unique DbSet property names to avoid table name conflicts in the unified HostDbContext.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor EmptyModuleName = new( + id: "SM0002", + title: "Module has empty name", + messageFormat: "Module class '{0}' has an empty [Module] name. Provide a non-empty name: [Module(\"MyModule\")]. An empty name will cause broken route prefixes, schema names, and TypeScript module grouping.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor MultipleIdentityDbContexts = new( + id: "SM0003", + title: "Multiple IdentityDbContext types found", + messageFormat: "Multiple modules define an IdentityDbContext: '{0}' (module {1}) and '{2}' (module {3}). Only one module should provide Identity. The unified HostDbContext can only extend one IdentityDbContext base class.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor DbContextWithNoDbSets = new( + id: "SM0004", + title: "DbContext has no DbSet properties", + messageFormat: "DbContext '{0}' in module '{1}' has no public DbSet properties. No entities from this context will appear in the unified HostDbContext. Add DbSet properties for each entity, or remove this DbContext if it's not needed.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor IdentityDbContextBadTypeArgs = new( + id: "SM0005", + title: "IdentityDbContext has unexpected type arguments", + messageFormat: "IdentityDbContext '{0}' in module '{1}' must extend IdentityDbContext with exactly 3 type arguments, but {2} were found. Use the 3-argument form: IdentityDbContext.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor EntityConfigForMissingEntity = new( + id: "SM0006", + title: "Entity configuration targets entity not in any DbSet", + messageFormat: "IEntityTypeConfiguration<{0}> in '{1}' (module '{2}') configures an entity that is not exposed as a DbSet in any module's DbContext. Add a DbSet<{0}> property to a DbContext, or remove this configuration.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor DuplicateEntityConfiguration = new( + id: "SM0007", + title: "Duplicate entity configuration", + messageFormat: "Entity '{0}' has multiple IEntityTypeConfiguration implementations: '{1}' and '{2}'. EF Core only supports one configuration per entity type. Remove the duplicate.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static void ReportDiscoveryDiagnostics( + SourceProductionContext context, + DiscoveryData data + ) + { + // SM0002: Empty module name + foreach (var module in data.Modules) + { + if (string.IsNullOrEmpty(module.ModuleName)) + { + context.ReportDiagnostic( + Diagnostic.Create( + EmptyModuleName, + Location.None, + module.FullyQualifiedName.Replace("global::", "") + ) + ); + } + } + + // SM0004: DbContext with no DbSets + foreach (var ctx in data.DbContexts) + { + if (ctx.DbSets.Length == 0 && !ctx.IsIdentityDbContext) + { + context.ReportDiagnostic( + Diagnostic.Create( + DbContextWithNoDbSets, + Location.None, + ctx.FullyQualifiedName.Replace("global::", ""), + ctx.ModuleName + ) + ); + } + } + + // SM0003: Multiple IdentityDbContexts + DbContextInfoRecord? firstIdentity = null; + foreach (var ctx in data.DbContexts) + { + if (!ctx.IsIdentityDbContext) + continue; + + if (firstIdentity is null) + { + firstIdentity = ctx; + } + else + { + context.ReportDiagnostic( + Diagnostic.Create( + MultipleIdentityDbContexts, + Location.None, + firstIdentity.Value.FullyQualifiedName.Replace("global::", ""), + firstIdentity.Value.ModuleName, + ctx.FullyQualifiedName.Replace("global::", ""), + ctx.ModuleName + ) + ); + } + } + + // SM0005: IdentityDbContext with wrong type args + foreach (var ctx in data.DbContexts) + { + if (ctx.IsIdentityDbContext && string.IsNullOrEmpty(ctx.IdentityUserTypeFqn)) + { + context.ReportDiagnostic( + Diagnostic.Create( + IdentityDbContextBadTypeArgs, + Location.None, + ctx.FullyQualifiedName.Replace("global::", ""), + ctx.ModuleName, + 0 + ) + ); + } + } + + // SM0006: Entity config for entity not in any DbSet + var allEntityFqns = new System.Collections.Generic.HashSet(); + foreach (var ctx in data.DbContexts) + { + foreach (var dbSet in ctx.DbSets) + allEntityFqns.Add(dbSet.EntityFqn); + } + + foreach (var config in data.EntityConfigs) + { + if (!allEntityFqns.Contains(config.EntityFqn)) + { + context.ReportDiagnostic( + Diagnostic.Create( + EntityConfigForMissingEntity, + Location.None, + config.EntityFqn.Replace("global::", ""), + config.ConfigFqn.Replace("global::", ""), + config.ModuleName + ) + ); + } + } + + // SM0007: Duplicate EntityTypeConfiguration for same entity + var entityConfigOwners = new System.Collections.Generic.Dictionary(); + foreach (var config in data.EntityConfigs) + { + if (entityConfigOwners.TryGetValue(config.EntityFqn, out var existing)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DuplicateEntityConfiguration, + Location.None, + config.EntityFqn.Replace("global::", ""), + existing, + config.ConfigFqn.Replace("global::", "") + ) + ); + } + else + { + entityConfigOwners[config.EntityFqn] = config.ConfigFqn.Replace("global::", ""); + } + } + } + private static void GenerateModuleExtensions( SourceProductionContext context, ImmutableArray modules, @@ -307,8 +496,10 @@ ImmutableArray dtoTypes } // Group DTOs by module name (first namespace segment, e.g. "Products" from "global::Products.Contracts.Product") - var moduleGroups = - new System.Collections.Generic.Dictionary>(); + var moduleGroups = new System.Collections.Generic.Dictionary< + string, + System.Collections.Generic.List + >(); foreach (var dto in dtoTypes) { var moduleName = GetModuleNameFromFqn(dto.FullyQualifiedName); @@ -340,8 +531,7 @@ ImmutableArray dtoTypes foreach (var prop in dto.Properties) { var tsType = MapCSharpTypeToTypeScript(prop.TypeFqn, knownDtoTypes); - var camelName = - char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1); + var camelName = char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1); sb.AppendLine($" {camelName}: {tsType};"); } sb.AppendLine("}"); @@ -533,6 +723,273 @@ knownDtoTypes is not null }; } +#pragma warning disable CA1308 // Schema names are conventionally lowercase in PostgreSQL/SQL Server + private static void EmitHostDbContext(SourceProductionContext context, DiscoveryData data) + { + // Abort if any error-level diagnostics were reported (SM0003, SM0005, SM0007) + var identityCount = 0; + var hasBadIdentityArgs = false; + foreach (var ctx in data.DbContexts) + { + if (ctx.IsIdentityDbContext) + { + identityCount++; + if (string.IsNullOrEmpty(ctx.IdentityUserTypeFqn)) + hasBadIdentityArgs = true; + } + } + + var hasDuplicateConfigs = false; + var configEntities = new System.Collections.Generic.HashSet(); + foreach (var config in data.EntityConfigs) + { + if (!configEntities.Add(config.EntityFqn)) + hasDuplicateConfigs = true; + } + + if (identityCount > 1 || hasBadIdentityArgs || hasDuplicateConfigs) + return; + + // Determine if any module uses IdentityDbContext + DbContextInfoRecord? identityContext = null; + foreach (var ctx in data.DbContexts) + { + if (ctx.IsIdentityDbContext) + { + identityContext = ctx; + break; + } + } + + // Collect all DbSet across modules, deduplicated by entity FQN + var seenEntities = new System.Collections.Generic.HashSet(); + var allDbSets = new System.Collections.Generic.List<( + string PropertyName, + string EntityFqn, + string ModuleName + )>(); + foreach (var ctx in data.DbContexts) + { + foreach (var dbSet in ctx.DbSets) + { + if (seenEntities.Add(dbSet.EntityFqn)) + { + allDbSets.Add((dbSet.PropertyName, dbSet.EntityFqn, ctx.ModuleName)); + } + } + } + + // Check for duplicate DbSet property names across modules + var propertyNameOwners = new System.Collections.Generic.Dictionary< + string, + (string EntityFqn, string ModuleName) + >(); + var hasDuplicates = false; + foreach (var entry in allDbSets) + { + if (propertyNameOwners.TryGetValue(entry.PropertyName, out var existing)) + { + // Same entity type with same property name is fine (deduplicated earlier) + if (existing.EntityFqn != entry.EntityFqn) + { + context.ReportDiagnostic( + Diagnostic.Create( + DuplicateDbSetPropertyName, + Location.None, + entry.PropertyName, + existing.ModuleName, + existing.EntityFqn.Replace("global::", ""), + entry.ModuleName, + entry.EntityFqn.Replace("global::", "") + ) + ); + hasDuplicates = true; + } + } + else + { + propertyNameOwners[entry.PropertyName] = (entry.EntityFqn, entry.ModuleName); + } + } + + if (hasDuplicates) + return; + + // Build entity-to-module mapping for schema isolation + var entityToModule = new System.Collections.Generic.Dictionary(); + foreach (var entry in allDbSets) + { + entityToModule[entry.EntityFqn] = entry.ModuleName; + } + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#pragma warning disable"); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("using Microsoft.EntityFrameworkCore;"); + sb.AppendLine("using Microsoft.Extensions.Options;"); + sb.AppendLine("using SimpleModule.Database;"); + + if (identityContext != null) + { + sb.AppendLine("using Microsoft.AspNetCore.Identity.EntityFrameworkCore;"); + } + + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Host;"); + sb.AppendLine(); + + // Class declaration with primary constructor + if (identityContext != null) + { + sb.AppendLine($"public class HostDbContext("); + sb.AppendLine(" DbContextOptions options,"); + sb.AppendLine(" IOptions dbOptions"); + sb.AppendLine($") : IdentityDbContext<{identityContext.Value.IdentityUserTypeFqn},"); + sb.AppendLine($" {identityContext.Value.IdentityRoleTypeFqn},"); + sb.AppendLine( + $" {identityContext.Value.IdentityKeyTypeFqn}>(options)" + ); + } + else + { + sb.AppendLine("public class HostDbContext("); + sb.AppendLine(" DbContextOptions options,"); + sb.AppendLine(" IOptions dbOptions"); + sb.AppendLine(") : DbContext(options)"); + } + + sb.AppendLine("{"); + + // DbSet properties + foreach (var dbSet in allDbSets) + { + sb.AppendLine($" public DbSet<{dbSet.EntityFqn}> {dbSet.PropertyName}"); + sb.AppendLine($" => Set<{dbSet.EntityFqn}>();"); + sb.AppendLine(); + } + + // OnModelCreating + sb.AppendLine(" protected override void OnModelCreating(ModelBuilder builder)"); + sb.AppendLine(" {"); + + if (identityContext != null) + { + sb.AppendLine(" base.OnModelCreating(builder);"); + sb.AppendLine(); + } + + // Apply IEntityTypeConfiguration implementations + if (data.EntityConfigs.Length > 0) + { + sb.AppendLine( + " // Apply IEntityTypeConfiguration found in module assemblies" + ); + foreach (var config in data.EntityConfigs) + { + sb.AppendLine($" builder.ApplyConfiguration(new {config.ConfigFqn}());"); + } + sb.AppendLine(); + } + + // Schema isolation (non-SQLite only) + sb.AppendLine(" // Schema isolation (PostgreSQL/SQL Server only)"); + sb.AppendLine(" var provider = DatabaseProviderDetector.Detect("); + sb.AppendLine(" dbOptions.Value.DefaultConnection, dbOptions.Value.Provider);"); + sb.AppendLine(" if (provider != DatabaseProvider.Sqlite)"); + sb.AppendLine(" {"); + + // Group entities by module for schema assignment + var moduleNames = new System.Collections.Generic.HashSet(); + foreach (var entry in allDbSets) + { + moduleNames.Add(entry.ModuleName); + } + + foreach (var entry in allDbSets) + { + var schema = entry.ModuleName.ToLowerInvariant(); + sb.AppendLine($" builder.Entity<{entry.EntityFqn}>()"); + sb.AppendLine($" .Metadata.SetSchema(\"{schema}\");"); + } + + // If there's an IdentityDbContext, assign remaining entities (Identity tables) + // to that module's schema + if (identityContext != null) + { + var identitySchema = identityContext.Value.ModuleName.ToLowerInvariant(); + sb.AppendLine(); + sb.AppendLine(" // Assign Identity tables to the identity module's schema"); + sb.AppendLine(" foreach (var entityType in builder.Model.GetEntityTypes())"); + sb.AppendLine(" {"); + sb.AppendLine(" if (entityType.GetSchema() is null)"); + sb.AppendLine($" entityType.SetSchema(\"{identitySchema}\");"); + sb.AppendLine(" }"); + } + + sb.AppendLine(" }"); + + // SQLite table prefix isolation + sb.AppendLine(" else"); + sb.AppendLine(" {"); + + // For SQLite, apply table prefixes per module + foreach (var ctx in data.DbContexts) + { + var prefix = ctx.ModuleName + "_"; + foreach (var dbSet in ctx.DbSets) + { + if (!seenEntities.Contains(dbSet.EntityFqn)) + continue; + sb.AppendLine( + $" builder.Entity<{dbSet.EntityFqn}>().ToTable(\"{prefix}\" +" + ); + sb.AppendLine( + $" (builder.Entity<{dbSet.EntityFqn}>().Metadata.GetTableName() ?? \"{dbSet.PropertyName}\"));" + ); + } + } + + // For Identity tables in SQLite, prefix them too + if (identityContext != null) + { + var identityPrefix = identityContext.Value.ModuleName + "_"; + sb.AppendLine(); + sb.AppendLine(" // Prefix Identity tables for SQLite"); + sb.AppendLine(" foreach (var entityType in builder.Model.GetEntityTypes())"); + sb.AppendLine(" {"); + sb.AppendLine(" var tableName = entityType.GetTableName();"); + sb.AppendLine( + $" if (tableName is not null && !tableName.StartsWith(\"{identityPrefix}\", global::System.StringComparison.Ordinal))" + ); + + // Check it's not already prefixed by another module + sb.AppendLine(" {"); + sb.AppendLine(" var alreadyPrefixed = false;"); + foreach (var ctx2 in data.DbContexts) + { + if (ctx2.FullyQualifiedName == identityContext.Value.FullyQualifiedName) + continue; + sb.AppendLine( + $" if (tableName.StartsWith(\"{ctx2.ModuleName}_\", global::System.StringComparison.Ordinal)) alreadyPrefixed = true;" + ); + } + sb.AppendLine( + $" if (!alreadyPrefixed) entityType.SetTableName(\"{identityPrefix}\" + tableName);" + ); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + } + + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource("HostDbContext.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } +#pragma warning restore CA1308 + private static string GetModuleFieldName(string fullyQualifiedName) { var name = fullyQualifiedName.Replace("global::", "").Replace(".", "_"); diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs index 10d8667e..880b6ba6 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs @@ -13,17 +13,24 @@ public partial class ModuleDiscovererGenerator private readonly record struct DiscoveryData( ImmutableArray Modules, - ImmutableArray DtoTypes + ImmutableArray DtoTypes, + ImmutableArray DbContexts, + ImmutableArray EntityConfigs ) { public static readonly DiscoveryData Empty = new( ImmutableArray.Empty, - ImmutableArray.Empty + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty ); public bool Equals(DiscoveryData other) { - return Modules.SequenceEqual(other.Modules) && DtoTypes.SequenceEqual(other.DtoTypes); + return Modules.SequenceEqual(other.Modules) + && DtoTypes.SequenceEqual(other.DtoTypes) + && DbContexts.SequenceEqual(other.DbContexts) + && EntityConfigs.SequenceEqual(other.EntityConfigs); } public override int GetHashCode() @@ -35,6 +42,10 @@ public override int GetHashCode() hash = hash * 31 + m.GetHashCode(); foreach (var d in DtoTypes) hash = hash * 31 + d.GetHashCode(); + foreach (var c in DbContexts) + hash = hash * 31 + c.GetHashCode(); + foreach (var e in EntityConfigs) + hash = hash * 31 + e.GetHashCode(); return hash; } } @@ -126,6 +137,53 @@ private readonly record struct DtoPropertyInfoRecord( bool HasSetter ); + private readonly record struct DbContextInfoRecord( + string FullyQualifiedName, + string ModuleName, + bool IsIdentityDbContext, + string IdentityUserTypeFqn, + string IdentityRoleTypeFqn, + string IdentityKeyTypeFqn, + ImmutableArray DbSets + ) + { + public bool Equals(DbContextInfoRecord other) + { + return FullyQualifiedName == other.FullyQualifiedName + && ModuleName == other.ModuleName + && IsIdentityDbContext == other.IsIdentityDbContext + && IdentityUserTypeFqn == other.IdentityUserTypeFqn + && IdentityRoleTypeFqn == other.IdentityRoleTypeFqn + && IdentityKeyTypeFqn == other.IdentityKeyTypeFqn + && DbSets.SequenceEqual(other.DbSets); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + FullyQualifiedName.GetHashCode(); + hash = hash * 31 + (ModuleName ?? "").GetHashCode(); + hash = hash * 31 + IsIdentityDbContext.GetHashCode(); + hash = hash * 31 + (IdentityUserTypeFqn ?? "").GetHashCode(); + hash = hash * 31 + (IdentityRoleTypeFqn ?? "").GetHashCode(); + hash = hash * 31 + (IdentityKeyTypeFqn ?? "").GetHashCode(); + foreach (var d in DbSets) + hash = hash * 31 + d.GetHashCode(); + return hash; + } + } + } + + private readonly record struct DbSetInfoRecord(string PropertyName, string EntityFqn); + + private readonly record struct EntityConfigInfoRecord( + string ConfigFqn, + string EntityFqn, + string ModuleName + ); + #endregion #region Mutable working types (used during symbol traversal only) @@ -169,5 +227,29 @@ private sealed class DtoPropertyInfo public bool HasSetter { get; set; } } + private sealed class DbContextInfo + { + public string FullyQualifiedName { get; set; } = ""; + public string ModuleName { get; set; } = ""; + public bool IsIdentityDbContext { get; set; } + public string IdentityUserTypeFqn { get; set; } = ""; + public string IdentityRoleTypeFqn { get; set; } = ""; + public string IdentityKeyTypeFqn { get; set; } = ""; + public List DbSets { get; set; } = new(); + } + + private sealed class DbSetInfo + { + public string PropertyName { get; set; } = ""; + public string EntityFqn { get; set; } = ""; + } + + private sealed class EntityConfigInfo + { + public string ConfigFqn { get; set; } = ""; + public string EntityFqn { get; set; } = ""; + public string ModuleName { get; set; } = ""; + } + #endregion } diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index b1826076..24cd2384 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -24,6 +24,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (data.Modules.Length == 0) return; + ReportDiscoveryDiagnostics(spc, data); + GenerateModuleExtensions(spc, data.Modules, data.DtoTypes.Length > 0); GenerateEndpointExtensions(spc, data.Modules); GenerateMenuExtensions(spc, data.Modules); @@ -35,6 +37,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) GenerateJsonResolver(spc, data.DtoTypes); GenerateTypeScriptDefinitions(spc, data.DtoTypes); } + + if (data.DbContexts.Length > 0) + { + EmitHostDbContext(spc, data); + } } ); } @@ -100,6 +107,44 @@ is not IAssemblySymbol assemblySymbol } } + // Discover DbContext subclasses and IEntityTypeConfiguration per module assembly. + // Scan each assembly once, then match DbContexts/configs to the nearest module by namespace. + var dbContexts = new List(); + var entityConfigs = new List(); + var scannedAssemblies = new HashSet(SymbolEqualityComparer.Default); + foreach (var module in modules) + { + var metadataName = module.FullyQualifiedName.Replace("global::", ""); + var typeSymbol = compilation.GetTypeByMetadataName(metadataName); + if (typeSymbol is null) + continue; + + var assembly = typeSymbol.ContainingAssembly; + if (!scannedAssemblies.Add(assembly)) + continue; + + // Collect unmatched items from this assembly + var rawDbContexts = new List(); + var rawEntityConfigs = new List(); + FindDbContextTypes(assembly.GlobalNamespace, "", rawDbContexts); + FindEntityConfigTypes(assembly.GlobalNamespace, "", rawEntityConfigs); + + // Match each DbContext to the module whose namespace is closest + foreach (var ctx in rawDbContexts) + { + var ctxNs = ctx.FullyQualifiedName.Replace("global::", ""); + ctx.ModuleName = FindClosestModuleName(ctxNs, modules); + dbContexts.Add(ctx); + } + + foreach (var cfg in rawEntityConfigs) + { + var cfgNs = cfg.ConfigFqn.Replace("global::", ""); + cfg.ModuleName = FindClosestModuleName(cfgNs, modules); + entityConfigs.Add(cfg); + } + } + var dtoTypes = new List(); if (dtoAttributeSymbol is not null) { @@ -174,6 +219,21 @@ typeSymbol is not null )) .ToImmutableArray() )) + .ToImmutableArray(), + dbContexts + .Select(c => new DbContextInfoRecord( + c.FullyQualifiedName, + c.ModuleName, + c.IsIdentityDbContext, + c.IdentityUserTypeFqn, + c.IdentityRoleTypeFqn, + c.IdentityKeyTypeFqn, + c.DbSets.Select(d => new DbSetInfoRecord(d.PropertyName, d.EntityFqn)) + .ToImmutableArray() + )) + .ToImmutableArray(), + entityConfigs + .Select(e => new EntityConfigInfoRecord(e.ConfigFqn, e.EntityFqn, e.ModuleName)) .ToImmutableArray() ); } @@ -436,4 +496,183 @@ private static bool InheritsFrom(INamedTypeSymbol typeSymbol, INamedTypeSymbol b } return false; } + + private static string FindClosestModuleName(string typeFqn, List modules) + { + // Match by longest shared namespace prefix between the type and each module class. + var bestMatch = ""; + var bestLength = -1; + foreach (var module in modules) + { + var moduleFqn = module.FullyQualifiedName.Replace("global::", ""); + var moduleNs = moduleFqn.Contains(".") + ? moduleFqn.Substring(0, moduleFqn.LastIndexOf('.')) + : ""; + + if ( + typeFqn.StartsWith(moduleNs, StringComparison.Ordinal) + && moduleNs.Length > bestLength + ) + { + bestLength = moduleNs.Length; + bestMatch = module.ModuleName; + } + } + + return bestMatch.Length > 0 ? bestMatch : modules[0].ModuleName; + } + + private static void FindDbContextTypes( + INamespaceSymbol namespaceSymbol, + string moduleName, + List dbContexts + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + FindDbContextTypes(childNamespace, moduleName, dbContexts); + } + else if ( + member is INamedTypeSymbol typeSymbol + && !typeSymbol.IsAbstract + && !typeSymbol.IsStatic + ) + { + // Walk base type chain looking for DbContext + var isDbContext = false; + var isIdentity = false; + string identityUserFqn = ""; + string identityRoleFqn = ""; + string identityKeyFqn = ""; + + var current = typeSymbol.BaseType; + while (current is not null) + { + var baseFqn = current.OriginalDefinition.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ); + + if ( + baseFqn + == "global::Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext" + ) + { + isDbContext = true; + isIdentity = true; + if (current.TypeArguments.Length >= 3) + { + identityUserFqn = current + .TypeArguments[0] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + identityRoleFqn = current + .TypeArguments[1] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + identityKeyFqn = current + .TypeArguments[2] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + break; + } + + if (baseFqn == "global::Microsoft.EntityFrameworkCore.DbContext") + { + isDbContext = true; + break; + } + + current = current.BaseType; + } + + if (!isDbContext) + continue; + + var info = new DbContextInfo + { + FullyQualifiedName = typeSymbol.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ), + ModuleName = moduleName, + IsIdentityDbContext = isIdentity, + IdentityUserTypeFqn = identityUserFqn, + IdentityRoleTypeFqn = identityRoleFqn, + IdentityKeyTypeFqn = identityKeyFqn, + }; + + // Collect DbSet properties + foreach (var m in typeSymbol.GetMembers()) + { + if ( + m is IPropertySymbol prop + && prop.DeclaredAccessibility == Accessibility.Public + && !prop.IsStatic + && prop.Type is INamedTypeSymbol propType + && propType.IsGenericType + && propType.OriginalDefinition.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ) == "global::Microsoft.EntityFrameworkCore.DbSet" + ) + { + var entityFqn = propType + .TypeArguments[0] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + info.DbSets.Add( + new DbSetInfo { PropertyName = prop.Name, EntityFqn = entityFqn } + ); + } + } + + dbContexts.Add(info); + } + } + } + + private static void FindEntityConfigTypes( + INamespaceSymbol namespaceSymbol, + string moduleName, + List entityConfigs + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + FindEntityConfigTypes(childNamespace, moduleName, entityConfigs); + } + else if ( + member is INamedTypeSymbol typeSymbol + && !typeSymbol.IsAbstract + && !typeSymbol.IsStatic + ) + { + foreach (var iface in typeSymbol.AllInterfaces) + { + if ( + iface.IsGenericType + && iface.OriginalDefinition.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ) + == "global::Microsoft.EntityFrameworkCore.IEntityTypeConfiguration" + ) + { + var entityFqn = iface + .TypeArguments[0] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + entityConfigs.Add( + new EntityConfigInfo + { + ConfigFqn = typeSymbol.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ), + EntityFqn = entityFqn, + ModuleName = moduleName, + } + ); + break; + } + } + } + } + } } diff --git a/framework/SimpleModule.Generator/SimpleModule.Generator.csproj b/framework/SimpleModule.Generator/SimpleModule.Generator.csproj index e7dfe435..9d5df94e 100644 --- a/framework/SimpleModule.Generator/SimpleModule.Generator.csproj +++ b/framework/SimpleModule.Generator/SimpleModule.Generator.csproj @@ -8,4 +8,8 @@ + + + + diff --git a/modules/Orders/src/Orders/EntityConfigurations/OrderItemConfiguration.cs b/modules/Orders/src/Orders/EntityConfigurations/OrderItemConfiguration.cs new file mode 100644 index 00000000..beeebf44 --- /dev/null +++ b/modules/Orders/src/Orders/EntityConfigurations/OrderItemConfiguration.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.Orders.Contracts; + +namespace SimpleModule.Orders.EntityConfigurations; + +public class OrderItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey("OrderId", nameof(OrderItem.ProductId)); + } +} diff --git a/modules/Products/src/Products/Views/CreateEndpoint.cs b/modules/Products/src/Products/Views/CreateEndpoint.cs index de152331..ed41626d 100644 --- a/modules/Products/src/Products/Views/CreateEndpoint.cs +++ b/modules/Products/src/Products/Views/CreateEndpoint.cs @@ -15,17 +15,18 @@ public void Map(IEndpointRouteBuilder app) app.MapGet("/create", () => Inertia.Render("Products/Create")); app.MapPost( - "/", - async ( - [FromForm] string name, - [FromForm] decimal price, - IProductContracts products - ) => - { - var request = new CreateProductRequest { Name = name, Price = price }; - await products.CreateProductAsync(request); - return Results.Redirect("/products/manage"); - } - ).DisableAntiforgery(); + "/", + async ( + [FromForm] string name, + [FromForm] decimal price, + IProductContracts products + ) => + { + var request = new CreateProductRequest { Name = name, Price = price }; + await products.CreateProductAsync(request); + return Results.Redirect("/products/manage"); + } + ) + .DisableAntiforgery(); } } diff --git a/modules/Products/src/Products/Views/EditEndpoint.cs b/modules/Products/src/Products/Views/EditEndpoint.cs index 27222eba..99d48285 100644 --- a/modules/Products/src/Products/Views/EditEndpoint.cs +++ b/modules/Products/src/Products/Views/EditEndpoint.cs @@ -25,19 +25,20 @@ public void Map(IEndpointRouteBuilder app) ); app.MapPost( - "/{id}", - async ( - int id, - [FromForm] string name, - [FromForm] decimal price, - IProductContracts products - ) => - { - var request = new UpdateProductRequest { Name = name, Price = price }; - await products.UpdateProductAsync(id, request); - return Results.Redirect($"/products/{id}/edit"); - } - ).DisableAntiforgery(); + "/{id}", + async ( + int id, + [FromForm] string name, + [FromForm] decimal price, + IProductContracts products + ) => + { + var request = new UpdateProductRequest { Name = name, Price = price }; + await products.UpdateProductAsync(id, request); + return Results.Redirect($"/products/{id}/edit"); + } + ) + .DisableAntiforgery(); app.MapDelete( "/{id}", diff --git a/modules/Users/src/Users/Endpoints/Admin/AdminUsersEndpoint.cs b/modules/Users/src/Users/Endpoints/Admin/AdminUsersEndpoint.cs index 4972132d..4239f322 100644 --- a/modules/Users/src/Users/Endpoints/Admin/AdminUsersEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Admin/AdminUsersEndpoint.cs @@ -47,11 +47,7 @@ UserManager userManager group .MapPost( "/{id}/roles", - async ( - string id, - HttpContext context, - UserManager userManager - ) => + async (string id, HttpContext context, UserManager userManager) => { var user = await userManager.FindByIdAsync(id); if (user is null) diff --git a/template/SimpleModule.Host/HostDbContextFactory.cs b/template/SimpleModule.Host/HostDbContextFactory.cs new file mode 100644 index 00000000..66c74c9c --- /dev/null +++ b/template/SimpleModule.Host/HostDbContextFactory.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using SimpleModule.Database; + +namespace SimpleModule.Host; + +public class HostDbContextFactory : IDesignTimeDbContextFactory +{ + public HostDbContext CreateDbContext(string[] args) + { + var config = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false) + .AddJsonFile("appsettings.Development.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + var dbOptions = + config.GetSection("Database").Get() ?? new DatabaseOptions(); + + var optionsBuilder = new DbContextOptionsBuilder(); + var provider = DatabaseProviderDetector.Detect( + dbOptions.DefaultConnection, + dbOptions.Provider + ); + + switch (provider) + { + case DatabaseProvider.PostgreSql: + optionsBuilder.UseNpgsql(dbOptions.DefaultConnection); + break; + case DatabaseProvider.SqlServer: + optionsBuilder.UseSqlServer(dbOptions.DefaultConnection); + break; + default: + optionsBuilder.UseSqlite(dbOptions.DefaultConnection); + break; + } + + optionsBuilder.UseOpenIddict(); + + return new HostDbContext(optionsBuilder.Options, Options.Create(dbOptions)); + } +} diff --git a/template/SimpleModule.Host/Migrations/20260317220751_InitialCreate.Designer.cs b/template/SimpleModule.Host/Migrations/20260317220751_InitialCreate.Designer.cs new file mode 100644 index 00000000..832e9c08 --- /dev/null +++ b/template/SimpleModule.Host/Migrations/20260317220751_InitialCreate.Designer.cs @@ -0,0 +1,610 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SimpleModule.Host; + +#nullable disable + +namespace SimpleModule.Host.Migrations +{ + [DbContext(typeof(HostDbContext))] + [Migration("20260317220751_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "users"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "users"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "users"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "users"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "users"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", "users"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", "users"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", "users"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", "users"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Orders", "orders"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => + { + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("OrderId", "ProductId"); + + b.ToTable("OrderItems", "orders"); + }); + + modelBuilder.Entity("SimpleModule.Products.Contracts.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.ToTable("Products", "products"); + }); + + modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "users"); + }); + + modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "users"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => + { + b.HasOne("SimpleModule.Orders.Contracts.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/template/SimpleModule.Host/Migrations/20260317220751_InitialCreate.cs b/template/SimpleModule.Host/Migrations/20260317220751_InitialCreate.cs new file mode 100644 index 00000000..acbb6b2a --- /dev/null +++ b/template/SimpleModule.Host/Migrations/20260317220751_InitialCreate.cs @@ -0,0 +1,640 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace SimpleModule.Host.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema(name: "users"); + + migrationBuilder.EnsureSchema(name: "orders"); + + migrationBuilder.EnsureSchema(name: "products"); + + migrationBuilder.CreateTable( + name: "AspNetRoles", + schema: "users", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true), + CreatedAt = table.Column( + type: "timestamp with time zone", + nullable: false + ), + Name = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: true + ), + NormalizedName = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: true + ), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + schema: "users", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + DisplayName = table.Column(type: "text", nullable: false), + CreatedAt = table.Column( + type: "timestamp with time zone", + nullable: false + ), + LastLoginAt = table.Column( + type: "timestamp with time zone", + nullable: true + ), + UserName = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: true + ), + NormalizedUserName = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: true + ), + Email = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: true + ), + NormalizedEmail = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: true + ), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column( + type: "timestamp with time zone", + nullable: true + ), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "OpenIddictApplications", + schema: "users", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationType = table.Column( + type: "character varying(50)", + maxLength: 50, + nullable: true + ), + ClientId = table.Column( + type: "character varying(100)", + maxLength: 100, + nullable: true + ), + ClientSecret = table.Column(type: "text", nullable: true), + ClientType = table.Column( + type: "character varying(50)", + maxLength: 50, + nullable: true + ), + ConcurrencyToken = table.Column( + type: "character varying(50)", + maxLength: 50, + nullable: true + ), + ConsentType = table.Column( + type: "character varying(50)", + maxLength: 50, + nullable: true + ), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + JsonWebKeySet = table.Column(type: "text", nullable: true), + Permissions = table.Column(type: "text", nullable: true), + PostLogoutRedirectUris = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedirectUris = table.Column(type: "text", nullable: true), + Requirements = table.Column(type: "text", nullable: true), + Settings = table.Column(type: "text", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictApplications", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "OpenIddictScopes", + schema: "users", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ConcurrencyToken = table.Column( + type: "character varying(50)", + maxLength: 50, + nullable: true + ), + Description = table.Column(type: "text", nullable: true), + Descriptions = table.Column(type: "text", nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + Name = table.Column( + type: "character varying(200)", + maxLength: 200, + nullable: true + ), + Properties = table.Column(type: "text", nullable: true), + Resources = table.Column(type: "text", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictScopes", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Orders", + schema: "orders", + columns: table => new + { + Id = table + .Column(type: "integer", nullable: false) + .Annotation( + "Npgsql:ValueGenerationStrategy", + NpgsqlValueGenerationStrategy.IdentityByDefaultColumn + ), + UserId = table.Column(type: "text", nullable: false), + Total = table.Column(type: "numeric", nullable: false), + CreatedAt = table.Column( + type: "timestamp with time zone", + nullable: false + ), + }, + constraints: table => + { + table.PrimaryKey("PK_Orders", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Products", + schema: "products", + columns: table => new + { + Id = table + .Column(type: "integer", nullable: false) + .Annotation( + "Npgsql:ValueGenerationStrategy", + NpgsqlValueGenerationStrategy.IdentityByDefaultColumn + ), + Name = table.Column(type: "text", nullable: false), + Price = table.Column(type: "numeric", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Products", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + schema: "users", + columns: table => new + { + Id = table + .Column(type: "integer", nullable: false) + .Annotation( + "Npgsql:ValueGenerationStrategy", + NpgsqlValueGenerationStrategy.IdentityByDefaultColumn + ), + RoleId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalSchema: "users", + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + schema: "users", + columns: table => new + { + Id = table + .Column(type: "integer", nullable: false) + .Annotation( + "Npgsql:ValueGenerationStrategy", + NpgsqlValueGenerationStrategy.IdentityByDefaultColumn + ), + UserId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "users", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + schema: "users", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "text", nullable: false), + }, + constraints: table => + { + table.PrimaryKey( + "PK_AspNetUserLogins", + x => new { x.LoginProvider, x.ProviderKey } + ); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "users", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + schema: "users", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "text", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalSchema: "users", + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "users", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + schema: "users", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true), + }, + constraints: table => + { + table.PrimaryKey( + "PK_AspNetUserTokens", + x => new + { + x.UserId, + x.LoginProvider, + x.Name, + } + ); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "users", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "OpenIddictAuthorizations", + schema: "users", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column( + type: "character varying(50)", + maxLength: 50, + nullable: true + ), + CreationDate = table.Column( + type: "timestamp with time zone", + nullable: true + ), + Properties = table.Column(type: "text", nullable: true), + Scopes = table.Column(type: "text", nullable: true), + Status = table.Column( + type: "character varying(50)", + maxLength: 50, + nullable: true + ), + Subject = table.Column( + type: "character varying(400)", + maxLength: 400, + nullable: true + ), + Type = table.Column( + type: "character varying(50)", + maxLength: 50, + nullable: true + ), + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictAuthorizations", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictAuthorizations_OpenIddictApplications_Application~", + column: x => x.ApplicationId, + principalSchema: "users", + principalTable: "OpenIddictApplications", + principalColumn: "Id" + ); + } + ); + + migrationBuilder.CreateTable( + name: "OrderItems", + schema: "orders", + columns: table => new + { + ProductId = table.Column(type: "integer", nullable: false), + OrderId = table.Column(type: "integer", nullable: false), + Quantity = table.Column(type: "integer", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_OrderItems", x => new { x.OrderId, x.ProductId }); + table.ForeignKey( + name: "FK_OrderItems_Orders_OrderId", + column: x => x.OrderId, + principalSchema: "orders", + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "OpenIddictTokens", + schema: "users", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + AuthorizationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column( + type: "character varying(50)", + maxLength: 50, + nullable: true + ), + CreationDate = table.Column( + type: "timestamp with time zone", + nullable: true + ), + ExpirationDate = table.Column( + type: "timestamp with time zone", + nullable: true + ), + Payload = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedemptionDate = table.Column( + type: "timestamp with time zone", + nullable: true + ), + ReferenceId = table.Column( + type: "character varying(100)", + maxLength: 100, + nullable: true + ), + Status = table.Column( + type: "character varying(50)", + maxLength: 50, + nullable: true + ), + Subject = table.Column( + type: "character varying(400)", + maxLength: 400, + nullable: true + ), + Type = table.Column( + type: "character varying(50)", + maxLength: 50, + nullable: true + ), + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictTokens", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictApplications_ApplicationId", + column: x => x.ApplicationId, + principalSchema: "users", + principalTable: "OpenIddictApplications", + principalColumn: "Id" + ); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictAuthorizations_AuthorizationId", + column: x => x.AuthorizationId, + principalSchema: "users", + principalTable: "OpenIddictAuthorizations", + principalColumn: "Id" + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + schema: "users", + table: "AspNetRoleClaims", + column: "RoleId" + ); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + schema: "users", + table: "AspNetRoles", + column: "NormalizedName", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + schema: "users", + table: "AspNetUserClaims", + column: "UserId" + ); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + schema: "users", + table: "AspNetUserLogins", + column: "UserId" + ); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + schema: "users", + table: "AspNetUserRoles", + column: "RoleId" + ); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + schema: "users", + table: "AspNetUsers", + column: "NormalizedEmail" + ); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + schema: "users", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictApplications_ClientId", + schema: "users", + table: "OpenIddictApplications", + column: "ClientId", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictAuthorizations_ApplicationId_Status_Subject_Type", + schema: "users", + table: "OpenIddictAuthorizations", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" } + ); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictScopes_Name", + schema: "users", + table: "OpenIddictScopes", + column: "Name", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ApplicationId_Status_Subject_Type", + schema: "users", + table: "OpenIddictTokens", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" } + ); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_AuthorizationId", + schema: "users", + table: "OpenIddictTokens", + column: "AuthorizationId" + ); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ReferenceId", + schema: "users", + table: "OpenIddictTokens", + column: "ReferenceId", + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "AspNetRoleClaims", schema: "users"); + + migrationBuilder.DropTable(name: "AspNetUserClaims", schema: "users"); + + migrationBuilder.DropTable(name: "AspNetUserLogins", schema: "users"); + + migrationBuilder.DropTable(name: "AspNetUserRoles", schema: "users"); + + migrationBuilder.DropTable(name: "AspNetUserTokens", schema: "users"); + + migrationBuilder.DropTable(name: "OpenIddictScopes", schema: "users"); + + migrationBuilder.DropTable(name: "OpenIddictTokens", schema: "users"); + + migrationBuilder.DropTable(name: "OrderItems", schema: "orders"); + + migrationBuilder.DropTable(name: "Products", schema: "products"); + + migrationBuilder.DropTable(name: "AspNetRoles", schema: "users"); + + migrationBuilder.DropTable(name: "AspNetUsers", schema: "users"); + + migrationBuilder.DropTable(name: "OpenIddictAuthorizations", schema: "users"); + + migrationBuilder.DropTable(name: "Orders", schema: "orders"); + + migrationBuilder.DropTable(name: "OpenIddictApplications", schema: "users"); + } + } +} diff --git a/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs new file mode 100644 index 00000000..c4828054 --- /dev/null +++ b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs @@ -0,0 +1,607 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SimpleModule.Host; + +#nullable disable + +namespace SimpleModule.Host.Migrations +{ + [DbContext(typeof(HostDbContext))] + partial class HostDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "users"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "users"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "users"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "users"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "users"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", "users"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", "users"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", "users"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", "users"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Orders", "orders"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => + { + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("OrderId", "ProductId"); + + b.ToTable("OrderItems", "orders"); + }); + + modelBuilder.Entity("SimpleModule.Products.Contracts.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.ToTable("Products", "products"); + }); + + modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "users"); + }); + + modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "users"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => + { + b.HasOne("SimpleModule.Orders.Contracts.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/template/SimpleModule.Host/Program.cs b/template/SimpleModule.Host/Program.cs index b3bc9fed..03f7ab09 100644 --- a/template/SimpleModule.Host/Program.cs +++ b/template/SimpleModule.Host/Program.cs @@ -116,8 +116,11 @@ var app = builder.Build(); -// Ensure databases are created with seed data -app.EnsureModuleDatabases(); +// Ensure databases are created with seed data (non-production — production uses EF Core migrations) +if (!app.Environment.IsProduction()) +{ + app.EnsureModuleDatabases(); +} app.UseExceptionHandler(); @@ -141,8 +144,8 @@ { var path = context.Request.Path.Value; bool hasVersionParam = context.Request.Query.ContainsKey("v"); - bool isVendorJs = path is not null - && path.StartsWith("/js/vendor/", StringComparison.OrdinalIgnoreCase); + bool isVendorJs = + path is not null && path.StartsWith("/js/vendor/", StringComparison.OrdinalIgnoreCase); if (hasVersionParam || isVendorJs) { diff --git a/template/SimpleModule.Host/SimpleModule.Host.csproj b/template/SimpleModule.Host/SimpleModule.Host.csproj index 05fb59d4..cf55f0cc 100644 --- a/template/SimpleModule.Host/SimpleModule.Host.csproj +++ b/template/SimpleModule.Host/SimpleModule.Host.csproj @@ -9,6 +9,7 @@ + @@ -67,10 +68,7 @@ - + d.Id == "SM0002"); + var diag = diagnostics.First(d => d.Id == "SM0002"); + diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture) + .Should() + .Contain("BadModule"); + } + + [Fact] + public void SM0002_NonEmptyModuleName_NoDiagnostic() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Valid")] + public class ValidModule : IModule { } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0002"); + } + + #endregion + + #region SM0003: Multiple IdentityDbContexts + + [Fact] + public void SM0003_MultipleIdentityDbContexts_ReportsError() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Users + { + public class UserA : IdentityUser { } + public class RoleA : IdentityRole { } + + [Module("Users")] + public class UsersModule : IModule { } + + public class UsersDbContext : IdentityDbContext + { + public UsersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + + namespace TestApp.Accounts + { + public class UserB : IdentityUser { } + public class RoleB : IdentityRole { } + + [Module("Accounts")] + public class AccountsModule : IModule { } + + public class AccountsDbContext : IdentityDbContext + { + public AccountsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0003"); + var diag = diagnostics.First(d => d.Id == "SM0003"); + var message = diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture); + message.Should().Contain("Users"); + message.Should().Contain("Accounts"); + + // Should NOT emit HostDbContext when there's a conflict + result + .GeneratedTrees.Should() + .NotContain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + } + + [Fact] + public void SM0003_SingleIdentityDbContext_NoDiagnostic() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Users + { + public class AppUser : IdentityUser { } + public class AppRole : IdentityRole { } + + [Module("Users")] + public class UsersModule : IModule { } + + public class UsersDbContext : IdentityDbContext + { + public UsersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0003"); + } + + #endregion + + #region SM0004: DbContext with no DbSets + + [Fact] + public void SM0004_DbContextWithNoDbSets_ReportsWarning() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Empty + { + [Module("Empty")] + public class EmptyModule : IModule { } + + public class EmptyDbContext : DbContext + { + public EmptyDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0004"); + var diag = diagnostics.First(d => d.Id == "SM0004"); + var message = diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture); + message.Should().Contain("EmptyDbContext"); + message.Should().Contain("Empty"); + } + + [Fact] + public void SM0004_DbContextWithDbSets_NoDiagnostic() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Products + { + public class Product { public int Id { get; set; } } + + [Module("Products")] + public class ProductsModule : IModule { } + + public class ProductsDbContext : DbContext + { + public ProductsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Products => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0004"); + } + + [Fact] + public void SM0004_IdentityDbContextWithNoDbSets_NoDiagnostic() + { + // IdentityDbContext without extra DbSets is normal — Identity entities come from the base class + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Users + { + public class AppUser : IdentityUser { } + public class AppRole : IdentityRole { } + + [Module("Users")] + public class UsersModule : IModule { } + + public class UsersDbContext : IdentityDbContext + { + public UsersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0004"); + } + + #endregion + + #region SM0006: Entity config for entity not in any DbSet + + [Fact] + public void SM0006_EntityConfigForMissingEntity_ReportsWarning() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Orders + { + public class Order { public int Id { get; set; } } + public class OrphanEntity { public int Id { get; set; } } + + [Module("Orders")] + public class OrdersModule : IModule { } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + // Note: no DbSet for OrphanEntity + } + + public class OrphanConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0006"); + var diag = diagnostics.First(d => d.Id == "SM0006"); + var message = diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture); + message.Should().Contain("OrphanEntity"); + message.Should().Contain("OrphanConfiguration"); + } + + [Fact] + public void SM0006_EntityConfigForExistingEntity_NoDiagnostic() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Orders + { + public class Order { public int Id { get; set; } } + + [Module("Orders")] + public class OrdersModule : IModule { } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + } + + public class OrderConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0006"); + } + + #endregion + + #region SM0007: Duplicate entity configuration + + [Fact] + public void SM0007_DuplicateEntityConfig_ReportsError() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Orders + { + public class Order { public int Id { get; set; } } + + [Module("Orders")] + public class OrdersModule : IModule { } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + } + + public class OrderConfigA : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + } + } + + public class OrderConfigB : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Id).ValueGeneratedNever(); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0007"); + var diag = diagnostics.First(d => d.Id == "SM0007"); + var message = diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture); + message.Should().Contain("Order"); + message.Should().Contain("OrderConfigA"); + message.Should().Contain("OrderConfigB"); + + // Should NOT emit HostDbContext when there are duplicate configs + result + .GeneratedTrees.Should() + .NotContain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + } + + [Fact] + public void SM0007_UniqueEntityConfigs_NoDiagnostic() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Orders + { + public class Order { public int Id { get; set; } } + public class OrderItem { public int ProductId { get; set; } } + + [Module("Orders")] + public class OrdersModule : IModule { } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + } + + public class OrderConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + } + } + + public class OrderItemConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.ProductId); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0007"); + } + + #endregion + + #region No false positives on valid code + + [Fact] + public void ValidFullSetup_NoDiagnostics() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Users + { + public class AppUser : IdentityUser { } + public class AppRole : IdentityRole { } + + [Module("Users")] + public class UsersModule : IModule + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class UsersDbContext : IdentityDbContext + { + public UsersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + + namespace TestApp.Products + { + public class Product { public int Id { get; set; } } + + [Module("Products")] + public class ProductsModule : IModule + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class ProductsDbContext : DbContext + { + public ProductsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Products => Set(); + } + } + + namespace TestApp.Orders + { + public class Order { public int Id { get; set; } } + public class OrderItem { public int ProductId { get; set; } } + + [Module("Orders")] + public class OrdersModule : IModule + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + } + + public class OrderItemConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.ProductId); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics + .Where(d => d.Id.StartsWith("SM", StringComparison.Ordinal)) + .Should() + .BeEmpty("valid setup should produce no SM diagnostics"); + } + + #endregion + + #region SM0002: Whitespace-only module name + + [Fact] + public void SM0002_WhitespaceOnlyModuleName_DoesNotReport() + { + // Documents current behavior: IsNullOrEmpty does not catch whitespace-only names + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module(" ")] + public class WhitespaceModule : IModule { } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics + .Should() + .NotContain( + d => d.Id == "SM0002", + "SM0002 uses IsNullOrEmpty, not IsNullOrWhiteSpace, so whitespace-only names are allowed" + ); + } + + #endregion + + #region SM0003: Three IdentityDbContexts + + [Fact] + public void SM0003_ThreeIdentityDbContexts_ReportsError() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Users + { + public class UserA : IdentityUser { } + public class RoleA : IdentityRole { } + + [Module("Users")] + public class UsersModule : IModule { } + + public class UsersDbContext : IdentityDbContext + { + public UsersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + + namespace TestApp.Accounts + { + public class UserB : IdentityUser { } + public class RoleB : IdentityRole { } + + [Module("Accounts")] + public class AccountsModule : IModule { } + + public class AccountsDbContext : IdentityDbContext + { + public AccountsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + + namespace TestApp.Auth + { + public class UserC : IdentityUser { } + public class RoleC : IdentityRole { } + + [Module("Auth")] + public class AuthModule : IModule { } + + public class AuthDbContext : IdentityDbContext + { + public AuthDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0003"); + + // Should NOT emit HostDbContext when there's a conflict + result + .GeneratedTrees.Should() + .NotContain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + } + + #endregion + + #region SM0007: Three configs for same entity + + [Fact] + public void SM0007_ThreeConfigsForSameEntity_ReportsError() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Orders + { + public class Order { public int Id { get; set; } } + + [Module("Orders")] + public class OrdersModule : IModule { } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + } + + public class OrderConfigA : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + } + } + + public class OrderConfigB : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Id).ValueGeneratedNever(); + } + } + + public class OrderConfigC : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Id).HasColumnName("OrderId"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0007"); + } + + #endregion + + #region Overlapping module name prefixes + + [Fact] + public void OverlappingModuleNamePrefixes_DbContextMatchedByLongestPrefix() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Product + { + public class ProductEntity { public int Id { get; set; } } + + [Module("Product")] + public class ProductModule : IModule { } + + public class ProductDbContext : DbContext + { + public ProductDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Products => Set(); + } + } + + namespace TestApp.ProductInventory + { + public class InventoryItem { public int Id { get; set; } } + + [Module("ProductInventory")] + public class ProductInventoryModule : IModule { } + + public class ProductInventoryDbContext : DbContext + { + public ProductInventoryDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet InventoryItems => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + // No SM diagnostics — each DbContext should match the correct module + diagnostics + .Where(d => d.Id.StartsWith("SM", StringComparison.Ordinal)) + .Should() + .BeEmpty("overlapping prefixes should resolve correctly via longest match"); + + // HostDbContext should be emitted + result + .GeneratedTrees.Should() + .Contain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + + // Both modules' DbSets should be present + var hostDbContext = result + .GeneratedTrees.First(t => + t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal) + ) + .GetText() + .ToString(); + + hostDbContext.Should().Contain("Products"); + hostDbContext.Should().Contain("InventoryItems"); + + // Each module should get its own schema + hostDbContext.Should().Contain("SetSchema(\"product\")"); + hostDbContext.Should().Contain("SetSchema(\"productinventory\")"); + } + + #endregion + + #region Mixed module types (Identity + Plain + NoDb) + + [Fact] + public void MixedModuleTypes_IdentityPlainAndNoDb_EmitsCorrectHostDbContext() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Users + { + public class AppUser : IdentityUser { } + public class AppRole : IdentityRole { } + + [Module("Users")] + public class UsersModule : IModule { } + + public class UsersDbContext : IdentityDbContext + { + public UsersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + + namespace TestApp.Products + { + public class Product { public int Id { get; set; } } + + [Module("Products")] + public class ProductsModule : IModule { } + + public class ProductsDbContext : DbContext + { + public ProductsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Products => Set(); + } + } + + namespace TestApp.Notifications + { + [Module("Notifications")] + public class NotificationsModule : IModule { } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + // No SM diagnostics + diagnostics + .Where(d => d.Id.StartsWith("SM", StringComparison.Ordinal)) + .Should() + .BeEmpty("mixed module types should produce no SM diagnostics"); + + // HostDbContext should be emitted + result + .GeneratedTrees.Should() + .Contain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + + var hostDbContext = result + .GeneratedTrees.First(t => + t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal) + ) + .GetText() + .ToString(); + + // Should extend IdentityDbContext (from Users module) + hostDbContext.Should().Contain("IdentityDbContext<"); + + // Should have Products DbSet from plain module + hostDbContext.Should().Contain("Products"); + + // NoDb module (Notifications) should not cause any issues + hostDbContext.Should().NotContain("Notifications"); + } + + #endregion + + #region Deeply nested namespace matching + + [Fact] + public void DeeplyNestedNamespace_DbContextMatchedCorrectly() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Company.Division.Orders + { + public class Order { public int Id { get; set; } } + + [Module("Orders")] + public class OrdersModule : IModule { } + } + + namespace TestApp.Company.Division.Orders.Infrastructure + { + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + // No SM diagnostics + diagnostics + .Where(d => d.Id.StartsWith("SM", StringComparison.Ordinal)) + .Should() + .BeEmpty("deeply nested namespace should match correctly"); + + // HostDbContext should be emitted + result + .GeneratedTrees.Should() + .Contain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + + var hostDbContext = result + .GeneratedTrees.First(t => + t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal) + ) + .GetText() + .ToString(); + + hostDbContext.Should().Contain("Orders"); + hostDbContext.Should().Contain("SetSchema(\"orders\")"); + } + + #endregion + + #region DbContext in unrelated namespace fallback + + [Fact] + public void DbContextInUnrelatedNamespace_StillAssignedToModule() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Products + { + public class Product { public int Id { get; set; } } + + [Module("Products")] + public class ProductsModule : IModule { } + } + + namespace Unrelated.Namespace + { + public class SomeDbContext : DbContext + { + public SomeDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Products => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + // HostDbContext should still be emitted (DbContext is assigned via fallback) + result + .GeneratedTrees.Should() + .Contain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + + var hostDbContext = result + .GeneratedTrees.First(t => + t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal) + ) + .GetText() + .ToString(); + + hostDbContext.Should().Contain("Products"); + } + + #endregion +} diff --git a/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs b/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs index 09c1dce6..6d0f4dcc 100644 --- a/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs +++ b/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs @@ -1,4 +1,5 @@ -using System.Reflection; +using System.Collections.Immutable; +using System.Reflection; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using SimpleModule.Generator; @@ -77,6 +78,56 @@ public static CSharpCompilation CreateCompilation(params string[] sources) ); } + public static CSharpCompilation CreateCompilationWithEfCore(params string[] sources) + { + var compilation = CreateCompilation(sources); + + var efCoreReferences = new List + { + MetadataReference.CreateFromFile( + typeof(Microsoft.EntityFrameworkCore.DbContext).Assembly.Location + ), + MetadataReference.CreateFromFile( + typeof(Microsoft.EntityFrameworkCore.DbSet<>).Assembly.Location + ), + MetadataReference.CreateFromFile( + typeof(Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext<,,>) + .Assembly + .Location + ), + MetadataReference.CreateFromFile( + typeof(Microsoft.AspNetCore.Identity.IdentityUser).Assembly.Location + ), + MetadataReference.CreateFromFile( + typeof(Microsoft.Extensions.Options.IOptions<>).Assembly.Location + ), + MetadataReference.CreateFromFile( + typeof(SimpleModule.Database.DatabaseOptions).Assembly.Location + ), + }; + + // Add EF Core abstractions assembly if separate + var efCoreDir = Path.GetDirectoryName( + typeof(Microsoft.EntityFrameworkCore.DbContext).Assembly.Location + )!; + var efAbstractions = Path.Combine( + efCoreDir, + "Microsoft.EntityFrameworkCore.Abstractions.dll" + ); + if (File.Exists(efAbstractions)) + efCoreReferences.Add(MetadataReference.CreateFromFile(efAbstractions)); + + // Add Microsoft.Extensions.Identity.Stores for IdentityUser + var identityStoresPath = Path.Combine( + efCoreDir, + "Microsoft.Extensions.Identity.Stores.dll" + ); + if (File.Exists(identityStoresPath)) + efCoreReferences.Add(MetadataReference.CreateFromFile(identityStoresPath)); + + return compilation.AddReferences(efCoreReferences); + } + public static GeneratorDriverRunResult RunGenerator(CSharpCompilation compilation) { var generator = new ModuleDiscovererGenerator(); @@ -86,4 +137,17 @@ public static GeneratorDriverRunResult RunGenerator(CSharpCompilation compilatio return driver.GetRunResult(); } + + public static ( + GeneratorDriverRunResult Result, + ImmutableArray Diagnostics + ) RunGeneratorWithDiagnostics(CSharpCompilation compilation) + { + var generator = new ModuleDiscovererGenerator(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out var diagnostics); + + return (driver.GetRunResult(), diagnostics); + } } diff --git a/tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs b/tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs new file mode 100644 index 00000000..a904896b --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs @@ -0,0 +1,1108 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class HostDbContextGenerationTests +{ + private const string ModuleWithDbContext = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Products.Contracts + { + [Dto] + public class Product + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + } + + namespace TestApp.Products + { + [Module("Products")] + public class ProductsModule : IModule + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class ProductsDbContext : DbContext + { + public ProductsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Products => Set(); + } + } + """; + + private const string ModuleWithIdentityDbContext = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Users.Entities + { + public class AppUser : IdentityUser { } + public class AppRole : IdentityRole { } + } + + namespace TestApp.Users + { + [Module("Users")] + public class UsersModule : IModule + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class UsersDbContext : IdentityDbContext + { + public UsersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + """; + + private const string ModuleWithEntityConfig = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Orders.Contracts + { + [Dto] + public class Order + { + public int Id { get; set; } + } + + [Dto] + public class OrderItem + { + public int ProductId { get; set; } + public int Quantity { get; set; } + } + } + + namespace TestApp.Orders + { + [Module("Orders")] + public class OrdersModule : IModule + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + } + } + + namespace TestApp.Orders.EntityConfigurations + { + public class OrderItemConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey("OrderId", nameof(global::TestApp.Orders.Contracts.OrderItem.ProductId)); + } + } + } + """; + + #region Basic emission + + [Fact] + public void ModuleWithDbContext_EmitsHostDbContext() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + result + .GeneratedTrees.Should() + .Contain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + } + + [Fact] + public void ModuleWithoutDbContext_DoesNotEmitHostDbContext() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("NoDb")] + public class NoDbModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + result + .GeneratedTrees.Should() + .NotContain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + } + + [Fact] + public void HostDbContext_HasAutoGeneratedHeader() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().StartWith("// "); + } + + [Fact] + public void HostDbContext_HasCorrectNamespace() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("namespace SimpleModule.Host;"); + } + + #endregion + + #region DbContext base class + + [Fact] + public void PlainDbContext_HostExtendsDbContext() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain(") : DbContext(options)"); + source.Should().NotContain("IdentityDbContext"); + } + + [Fact] + public void IdentityDbContext_HostExtendsIdentityDbContext() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithIdentityDbContext + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("IdentityDbContext<"); + source.Should().Contain("global::TestApp.Users.Entities.AppUser"); + source.Should().Contain("global::TestApp.Users.Entities.AppRole"); + source.Should().Contain("string>(options)"); + } + + [Fact] + public void IdentityDbContext_CallsBaseOnModelCreating() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithIdentityDbContext + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("base.OnModelCreating(builder)"); + } + + [Fact] + public void PlainDbContext_DoesNotCallBaseOnModelCreating() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().NotContain("base.OnModelCreating(builder)"); + } + + #endregion + + #region Constructor + + [Fact] + public void Constructor_HasDbContextOptionsParameter() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("DbContextOptions options"); + } + + [Fact] + public void Constructor_HasDatabaseOptionsParameter() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("IOptions dbOptions"); + } + + #endregion + + #region DbSet properties + + [Fact] + public void SingleModule_DbSetPropertiesPresent() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("DbSet Products"); + source.Should().Contain("Set()"); + } + + [Fact] + public void MultipleModules_AllDbSetsPresent() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithEntityConfig + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("Products"); + source.Should().Contain("Orders"); + source.Should().Contain("OrderItems"); + } + + [Fact] + public void DuplicateEntityAcrossModules_DeduplicatedToSingleDbSet() + { + // Both modules have a DbSet for the same entity type + var moduleA = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Shared + { + public class SharedEntity + { + public int Id { get; set; } + } + } + + namespace TestApp.ModuleA + { + [Module("ModuleA")] + public class ModuleAModule : IModule { } + + public class ModuleADbContext : DbContext + { + public ModuleADbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet SharedEntities => Set(); + } + } + """; + + var moduleB = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.ModuleB + { + [Module("ModuleB")] + public class ModuleBModule : IModule { } + + public class ModuleBDbContext : DbContext + { + public ModuleBDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet SharedEntities => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(moduleA, moduleB); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + // Should appear only once + var count = CountOccurrences(source, "DbSet"); + count.Should().Be(1); + } + + #endregion + + #region IEntityTypeConfiguration + + [Fact] + public void EntityTypeConfiguration_ApplyConfigurationEmitted() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithEntityConfig); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source + .Should() + .Contain( + "ApplyConfiguration(new global::TestApp.Orders.EntityConfigurations.OrderItemConfiguration())" + ); + } + + [Fact] + public void NoEntityTypeConfiguration_NoApplyConfigurationSection() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().NotContain("ApplyConfiguration"); + } + + #endregion + + #region Schema isolation + + [Fact] + public void SchemaIsolation_NonSqlite_SetsSchemaPerModule() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithEntityConfig + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("if (provider != DatabaseProvider.Sqlite)"); + source.Should().Contain(".Metadata.SetSchema(\"products\")"); + source.Should().Contain(".Metadata.SetSchema(\"orders\")"); + } + + [Fact] + public void SchemaIsolation_Sqlite_PrefixesTables() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithEntityConfig + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain(".ToTable(\"Products_\""); + source.Should().Contain(".ToTable(\"Orders_\""); + } + + [Fact] + public void IdentityFallbackSchema_AssignsRemainingEntitiesToIdentityModule() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithIdentityDbContext + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + // Identity tables (not explicitly assigned) should get the Users schema + source.Should().Contain("if (entityType.GetSchema() is null)"); + source.Should().Contain("entityType.SetSchema(\"users\")"); + } + + [Fact] + public void NoIdentityDbContext_NoFallbackSchemaLoop() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().NotContain("if (entityType.GetSchema() is null)"); + } + + [Fact] + public void SchemaIsolation_UsesDatabaseProviderDetector() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("DatabaseProviderDetector.Detect("); + source.Should().Contain("dbOptions.Value.DefaultConnection"); + source.Should().Contain("dbOptions.Value.Provider"); + } + + [Fact] + public void Sqlite_IdentityTablesPrefixed() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithIdentityDbContext + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("Prefix Identity tables for SQLite"); + source.Should().Contain("entityType.SetTableName(\"Users_\" + tableName)"); + } + + [Fact] + public void Sqlite_IdentityPrefixing_SkipsAlreadyPrefixedTables() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithIdentityDbContext + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + // Should check for Products_ prefix before applying Users_ prefix + source.Should().Contain("tableName.StartsWith(\"Products_\""); + } + + #endregion + + #region Abstract/static DbContext ignored + + [Fact] + public void AbstractDbContext_IsIgnored() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + public abstract class AbstractDbContext : DbContext + { + protected AbstractDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Items => Set(); + } + + public class SomeEntity + { + public int Id { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + result + .GeneratedTrees.Should() + .NotContain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + } + + #endregion + + #region Duplicate table name diagnostic + + [Fact] + public void DuplicateDbSetPropertyName_ReportsDiagnostic() + { + var moduleA = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.ModuleA; + + public class EntityA { public int Id { get; set; } } + + [Module("ModuleA")] + public class ModuleAModule : IModule { } + + public class ModuleADbContext : DbContext + { + public ModuleADbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Items => Set(); + } + """; + + var moduleB = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.ModuleB; + + public class EntityB { public int Id { get; set; } } + + [Module("ModuleB")] + public class ModuleBModule : IModule { } + + public class ModuleBDbContext : DbContext + { + public ModuleBDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Items => Set(); + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(moduleA, moduleB); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0001"); + + // Should NOT emit HostDbContext when there are conflicts + result + .GeneratedTrees.Should() + .NotContain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + } + + [Fact] + public void DuplicateDbSetPropertyName_DiagnosticContainsModuleNames() + { + var moduleA = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Alpha; + + public class AlphaEntity { public int Id { get; set; } } + + [Module("Alpha")] + public class AlphaModule : IModule { } + + public class AlphaDbContext : DbContext + { + public AlphaDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Records => Set(); + } + """; + + var moduleB = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Beta; + + public class BetaEntity { public int Id { get; set; } } + + [Module("Beta")] + public class BetaModule : IModule { } + + public class BetaDbContext : DbContext + { + public BetaDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Records => Set(); + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(moduleA, moduleB); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + var diagnostic = diagnostics.First(d => d.Id == "SM0001"); + var message = diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture); + message.Should().Contain("Records"); + message.Should().Contain("Alpha"); + message.Should().Contain("Beta"); + } + + [Fact] + public void UniqueDbSetPropertyNames_NoDiagnostic() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithEntityConfig + ); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0001"); + } + + #endregion + + #region Multiple modules combined + + [Fact] + public void IdentityAndPlainModules_CombinedCorrectly() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithIdentityDbContext, + ModuleWithEntityConfig + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + + // Should extend IdentityDbContext + source.Should().Contain("IdentityDbContext<"); + + // Should have all DbSets + source.Should().Contain("Products"); + source.Should().Contain("Orders"); + source.Should().Contain("OrderItems"); + + // Should apply entity configs + source + .Should() + .Contain( + "ApplyConfiguration(new global::TestApp.Orders.EntityConfigurations.OrderItemConfiguration()" + ); + + // Should have schemas for all modules + source.Should().Contain("SetSchema(\"products\")"); + source.Should().Contain("SetSchema(\"orders\")"); + source.Should().Contain("SetSchema(\"users\")"); + } + + #endregion + + #region Edge cases + + [Fact] + public void DbContextThroughIntermediateBaseClass_IsDiscovered() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Auditing + { + public class AuditEntry + { + public int Id { get; set; } + public string Action { get; set; } = ""; + } + + public class BaseDbContext : DbContext + { + public BaseDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + + [Module("Auditing")] + public class AuditingModule : IModule + { + public void ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class AuditingDbContext : BaseDbContext + { + public AuditingDbContext(DbContextOptions options, IOptions dbOptions) + : base(options, dbOptions) { } + + public DbSet AuditEntries => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + hostSource.Should().Contain("DbSet AuditEntries"); + } + + [Fact] + public void PrivateDbSetProperties_AreIgnored() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Secrets + { + public class PublicEntity + { + public int Id { get; set; } + } + + public class InternalEntity + { + public int Id { get; set; } + } + + [Module("Secrets")] + public class SecretsModule : IModule + { + public void ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class SecretsDbContext : DbContext + { + public SecretsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet PublicEntities => Set(); + private DbSet InternalEntities => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + hostSource.Should().Contain("DbSet PublicEntities"); + hostSource.Should().NotContain("InternalEntities"); + } + + [Fact] + public void NonDbSetProperties_AreIgnored() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Metadata + { + public class MetaItem + { + public int Id { get; set; } + } + + [Module("Metadata")] + public class MetadataModule : IModule + { + public void ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class MetadataDbContext : DbContext + { + public MetadataDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public string ConnectionInfo { get; set; } = ""; + public DbSet MetaItems => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + hostSource.Should().Contain("DbSet MetaItems"); + hostSource.Should().NotContain("ConnectionInfo"); + } + + [Fact] + public void AbstractEntityConfiguration_IsIgnored() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Catalog + { + public class CatalogItem + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + + [Module("Catalog")] + public class CatalogModule : IModule + { + public void ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class CatalogDbContext : DbContext + { + public CatalogDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet CatalogItems => Set(); + } + + public abstract class BaseCatalogConfiguration : IEntityTypeConfiguration + { + public abstract void Configure(EntityTypeBuilder builder); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + hostSource.Should().NotContain("ApplyConfiguration"); + hostSource.Should().NotContain("BaseCatalogConfiguration"); + } + + [Fact] + public void IdentityDbContextOnly_NoExtraDbSets_EmitsHostDbContext() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithIdentityDbContext + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + + // Should extend IdentityDbContext + hostSource.Should().Contain("IdentityDbContext<"); + hostSource.Should().Contain("global::TestApp.Users.Entities.AppUser"); + hostSource.Should().Contain("global::TestApp.Users.Entities.AppRole"); + + // Should have zero extra DbSet properties (no "=>" expression body for DbSet) + hostSource.Should().NotContain("=> Set<"); + + // Should still have schema isolation + hostSource.Should().Contain("SetSchema(\"users\")"); + } + + [Fact] + public void EntityNameConflictsAcrossNamespaces_BothAppearWithCorrectFQN() + { + var moduleA = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Products + { + public class Item + { + public int Id { get; set; } + public string ProductName { get; set; } = ""; + } + + [Module("Products")] + public class ProductsModule : IModule + { + public void ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class ProductsDbContext : DbContext + { + public ProductsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet ProductItems => Set(); + } + } + """; + + var moduleB = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Orders + { + public class Item + { + public int Id { get; set; } + public int Quantity { get; set; } + } + + [Module("Orders")] + public class OrdersModule : IModule + { + public void ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet OrderItems => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(moduleA, moduleB); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + hostSource.Should().Contain("DbSet ProductItems"); + hostSource.Should().Contain("DbSet OrderItems"); + } + + [Fact] + public void MultipleEntityConfigs_AllApplyConfigurationCallsEmitted() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Inventory + { + public class Warehouse + { + public int Id { get; set; } + public string Location { get; set; } = ""; + } + + public class StockItem + { + public int Id { get; set; } + public int WarehouseId { get; set; } + } + + [Module("Inventory")] + public class InventoryModule : IModule + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class InventoryDbContext : DbContext + { + public InventoryDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Warehouses => Set(); + public DbSet StockItems => Set(); + } + } + + namespace TestApp.Inventory.EntityConfigurations + { + public class WarehouseConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(w => w.Id); + } + } + + public class StockItemConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(s => s.Id); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + hostSource + .Should() + .Contain( + "ApplyConfiguration(new global::TestApp.Inventory.EntityConfigurations.WarehouseConfiguration())" + ); + hostSource + .Should() + .Contain( + "ApplyConfiguration(new global::TestApp.Inventory.EntityConfigurations.StockItemConfiguration())" + ); + } + + [Fact] + public void DbSetWithGenericEntityType_PreservesGenericInGeneratedCode() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp + { + public class AuditLog + { + public int Id { get; set; } + public T Value { get; set; } = default!; + } + } + + namespace TestApp.Logging + { + [Module("Logging")] + public class LoggingModule : IModule + { + public void ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class LoggingDbContext : DbContext + { + public LoggingDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet> AuditLogs => Set>(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + hostSource.Should().Contain("DbSet> AuditLogs"); + } + + #endregion + + #region Helpers + + private static string GetHostDbContext(Microsoft.CodeAnalysis.GeneratorDriverRunResult result) + { + return result + .GeneratedTrees.First(t => + t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal) + ) + .GetText() + .ToString(); + } + + private static int CountOccurrences(string source, string substring) + { + var count = 0; + var index = 0; + while ((index = source.IndexOf(substring, index, StringComparison.Ordinal)) != -1) + { + count++; + index += substring.Length; + } + return count; + } + + #endregion +} diff --git a/tests/SimpleModule.Generator.Tests/SimpleModule.Generator.Tests.csproj b/tests/SimpleModule.Generator.Tests/SimpleModule.Generator.Tests.csproj index 8fa734f8..07e9c65b 100644 --- a/tests/SimpleModule.Generator.Tests/SimpleModule.Generator.Tests.csproj +++ b/tests/SimpleModule.Generator.Tests/SimpleModule.Generator.Tests.csproj @@ -13,9 +13,11 @@ + + From 014520f9747d562b2def598de8f525f60cdb8442 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 17 Mar 2026 23:39:48 +0100 Subject: [PATCH 002/112] feat: integrate Aspire service defaults and add OpenTelemetry support --- .aspire/settings.json | 3 + .mcp.json | 18 +++ AGENTS.md | 61 ++++++++ Directory.Packages.props | 9 ++ SimpleModule.AppHost/AppHost.cs | 13 ++ .../Properties/launchSettings.json | 31 ++++ .../SimpleModule.AppHost.csproj | 15 ++ .../appsettings.Development.json | 8 + SimpleModule.AppHost/appsettings.json | 9 ++ SimpleModule.ServiceDefaults/Extensions.cs | 143 ++++++++++++++++++ .../SimpleModule.ServiceDefaults.csproj | 18 +++ SimpleModule.slnx | 2 + opencode.jsonc | 23 +++ template/SimpleModule.Host/Program.cs | 13 ++ .../SimpleModule.Host.csproj | 1 + 15 files changed, 367 insertions(+) create mode 100644 .aspire/settings.json create mode 100644 .mcp.json create mode 100644 AGENTS.md create mode 100644 SimpleModule.AppHost/AppHost.cs create mode 100644 SimpleModule.AppHost/Properties/launchSettings.json create mode 100644 SimpleModule.AppHost/SimpleModule.AppHost.csproj create mode 100644 SimpleModule.AppHost/appsettings.Development.json create mode 100644 SimpleModule.AppHost/appsettings.json create mode 100644 SimpleModule.ServiceDefaults/Extensions.cs create mode 100644 SimpleModule.ServiceDefaults/SimpleModule.ServiceDefaults.csproj create mode 100644 opencode.jsonc diff --git a/.aspire/settings.json b/.aspire/settings.json new file mode 100644 index 00000000..58364df9 --- /dev/null +++ b/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../SimpleModule.AppHost/SimpleModule.AppHost.csproj" +} \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..6983627d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "aspire": { + "command": "aspire", + "args": [ + "mcp", + "start" + ] + }, + "playwright": { + "command": "npx", + "args": [ + "-y", + "@playwright/mcp@latest" + ] + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..d14dbcb3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# Copilot instructions + +This repository is set up to use Aspire. Aspire is an orchestrator for the entire application and will take care of configuring dependencies, building, and running the application. The resources that make up the application are defined in `apphost.cs` including application code and external dependencies. + +## General recommendations for working with Aspire +1. Before making any changes always run the apphost using `aspire run` and inspect the state of resources to make sure you are building from a known state. +1. Changes to the _apphost.cs_ file will require a restart of the application to take effect. +2. Make changes incrementally and run the aspire application using the `aspire run` command to validate changes. +3. Use the Aspire MCP tools to check the status of resources and debug issues. + +## Running the application +To run the application run the following command: + +``` +aspire run +``` + +If there is already an instance of the application running it will prompt to stop the existing instance. You only need to restart the application if code in `apphost.cs` is changed, but if you experience problems it can be useful to reset everything to the starting state. + +## Checking resources +To check the status of resources defined in the app model use the _list resources_ tool. This will show you the current state of each resource and if there are any issues. If a resource is not running as expected you can use the _execute resource command_ tool to restart it or perform other actions. + +## Listing integrations +IMPORTANT! When a user asks you to add a resource to the app model you should first use the _list integrations_ tool to get a list of the current versions of all the available integrations. You should try to use the version of the integration which aligns with the version of the Aspire.AppHost.Sdk. Some integration versions may have a preview suffix. Once you have identified the correct integration you should always use the _get integration docs_ tool to fetch the latest documentation for the integration and follow the links to get additional guidance. + +## Debugging issues +IMPORTANT! Aspire is designed to capture rich logs and telemetry for all resources defined in the app model. Use the following diagnostic tools when debugging issues with the application before making changes to make sure you are focusing on the right things. + +1. _list structured logs_; use this tool to get details about structured logs. +2. _list console logs_; use this tool to get details about console logs. +3. _list traces_; use this tool to get details about traces. +4. _list trace structured logs_; use this tool to get logs related to a trace + +## Other Aspire MCP tools + +1. _select apphost_; use this tool if working with multiple app hosts within a workspace. +2. _list apphosts_; use this tool to get details about active app hosts. + +## Playwright MCP server + +The playwright MCP server has also been configured in this repository and you should use it to perform functional investigations of the resources defined in the app model as you work on the codebase. To get endpoints that can be used for navigation using the playwright MCP server use the list resources tool. + +## Updating the app host +The user may request that you update the Aspire apphost. You can do this using the `aspire update` command. This will update the apphost to the latest version and some of the Aspire specific packages in referenced projects, however you may need to manually update other packages in the solution to ensure compatibility. You can consider using the `dotnet-outdated` with the users consent. To install the `dotnet-outdated` tool use the following command: + +``` +dotnet tool install --global dotnet-outdated-tool +``` + +## Persistent containers +IMPORTANT! Consider avoiding persistent containers early during development to avoid creating state management issues when restarting the app. + +## Aspire workload +IMPORTANT! The aspire workload is obsolete. You should never attempt to install or use the Aspire workload. + +## Official documentation +IMPORTANT! Always prefer official documentation when available. The following sites contain the official documentation for Aspire and related components + +1. https://aspire.dev +2. https://learn.microsoft.com/dotnet/aspire +3. https://nuget.org (for specific integration package details) \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 2b00bcae..3694a01a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,15 @@ true + + + + + + + + + diff --git a/SimpleModule.AppHost/AppHost.cs b/SimpleModule.AppHost/AppHost.cs new file mode 100644 index 00000000..9a3e5bc3 --- /dev/null +++ b/SimpleModule.AppHost/AppHost.cs @@ -0,0 +1,13 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var postgres = builder.AddPostgres("postgres").WithDataVolume().WithPgAdmin(); + +var db = postgres.AddDatabase("simplemoduledb"); + +builder + .AddProject("simplemodule-host") + .WithExternalHttpEndpoints() + .WithReference(db) + .WaitFor(db); + +builder.Build().Run(); diff --git a/SimpleModule.AppHost/Properties/launchSettings.json b/SimpleModule.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..83c1d2d8 --- /dev/null +++ b/SimpleModule.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17119;http://localhost:15076", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21159", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23210", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22061" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15076", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19205", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18194", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20023" + } + } + } +} diff --git a/SimpleModule.AppHost/SimpleModule.AppHost.csproj b/SimpleModule.AppHost/SimpleModule.AppHost.csproj new file mode 100644 index 00000000..441649ee --- /dev/null +++ b/SimpleModule.AppHost/SimpleModule.AppHost.csproj @@ -0,0 +1,15 @@ + + + + + + + + + Exe + net10.0 + enable + enable + c74f53ec-e4b7-4a9e-b41c-d39a4ebc0274 + + diff --git a/SimpleModule.AppHost/appsettings.Development.json b/SimpleModule.AppHost/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/SimpleModule.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/SimpleModule.AppHost/appsettings.json b/SimpleModule.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/SimpleModule.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/SimpleModule.ServiceDefaults/Extensions.cs b/SimpleModule.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..c819f243 --- /dev/null +++ b/SimpleModule.ServiceDefaults/Extensions.cs @@ -0,0 +1,143 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder + .Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing + .AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments( + HealthEndpointPath, + StringComparison.OrdinalIgnoreCase + ) + && !context.Request.Path.StartsWithSegments( + AlivenessEndpointPath, + StringComparison.OrdinalIgnoreCase + ) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace( + builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] + ); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder + .Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks( + AlivenessEndpointPath, + new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") } + ); + } + + return app; + } +} diff --git a/SimpleModule.ServiceDefaults/SimpleModule.ServiceDefaults.csproj b/SimpleModule.ServiceDefaults/SimpleModule.ServiceDefaults.csproj new file mode 100644 index 00000000..e3dc2acc --- /dev/null +++ b/SimpleModule.ServiceDefaults/SimpleModule.ServiceDefaults.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/SimpleModule.slnx b/SimpleModule.slnx index 7b382271..05888780 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -42,4 +42,6 @@ + + diff --git a/opencode.jsonc b/opencode.jsonc new file mode 100644 index 00000000..2f81d714 --- /dev/null +++ b/opencode.jsonc @@ -0,0 +1,23 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "aspire": { + "type": "local", + "command": [ + "aspire", + "mcp", + "start" + ], + "enabled": true + }, + "playwright": { + "type": "local", + "command": [ + "npx", + "-y", + "@playwright/mcp@latest" + ], + "enabled": true + } + } +} \ No newline at end of file diff --git a/template/SimpleModule.Host/Program.cs b/template/SimpleModule.Host/Program.cs index 03f7ab09..1cc3865c 100644 --- a/template/SimpleModule.Host/Program.cs +++ b/template/SimpleModule.Host/Program.cs @@ -15,6 +15,16 @@ var builder = WebApplication.CreateBuilder(args); +// Aspire service defaults (OpenTelemetry, resilience, service discovery) +builder.AddServiceDefaults(); + +// Bridge Aspire-managed connection string to Database options +var aspireConnectionString = builder.Configuration.GetConnectionString("simplemoduledb"); +if (!string.IsNullOrEmpty(aspireConnectionString)) +{ + builder.Configuration["Database:DefaultConnection"] = aspireConnectionString; +} + // Add services to the container. builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => @@ -188,4 +198,7 @@ // Automatically map all module endpoints app.MapModuleEndpoints(); +// Aspire default health endpoints (/health, /alive) +app.MapDefaultEndpoints(); + app.Run(); diff --git a/template/SimpleModule.Host/SimpleModule.Host.csproj b/template/SimpleModule.Host/SimpleModule.Host.csproj index cf55f0cc..20cfcce6 100644 --- a/template/SimpleModule.Host/SimpleModule.Host.csproj +++ b/template/SimpleModule.Host/SimpleModule.Host.csproj @@ -26,6 +26,7 @@ + From ffd49b837f1eec160f27712c7d1e30bcaed63d13 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 17 Mar 2026 23:41:14 +0100 Subject: [PATCH 003/112] feat: add persistent lifetime to Postgres configuration in AppHost --- SimpleModule.AppHost/AppHost.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SimpleModule.AppHost/AppHost.cs b/SimpleModule.AppHost/AppHost.cs index 9a3e5bc3..ec7f975f 100644 --- a/SimpleModule.AppHost/AppHost.cs +++ b/SimpleModule.AppHost/AppHost.cs @@ -1,6 +1,6 @@ var builder = DistributedApplication.CreateBuilder(args); -var postgres = builder.AddPostgres("postgres").WithDataVolume().WithPgAdmin(); +var postgres = builder.AddPostgres("postgres").WithDataVolume().WithPgAdmin().WithLifetime(ContainerLifetime.Persistent); var db = postgres.AddDatabase("simplemoduledb"); From 85cd6d1ddc1ff3e5223b0fee3ce41bc0f4c953b7 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 00:11:13 +0100 Subject: [PATCH 004/112] Add initial product configuration and database migration - Created ProductConfiguration class for Entity Framework Core to define the Product entity's schema and seed data. - Added initial migration files for the database, including the creation of user, order, and product schemas and tables. - Seeded initial data for Orders and Products in the migration. --- .../DatabaseInitializerExtensions.cs | 140 ------ .../OrderConfiguration.cs | 92 ++++ .../OrderItemConfiguration.cs | 2 + modules/Orders/src/Orders/OrdersDbContext.cs | 69 +-- .../ProductConfiguration.cs | 37 ++ .../src/Products/ProductsDbContext.cs | 31 +- ... 20260317230801_InitialCreate.Designer.cs} | 161 +++++- ...ate.cs => 20260317230801_InitialCreate.cs} | 474 +++++++----------- .../Migrations/HostDbContextModelSnapshot.cs | 159 +++++- template/SimpleModule.Host/Program.cs | 17 +- 10 files changed, 653 insertions(+), 529 deletions(-) delete mode 100644 framework/SimpleModule.Database/DatabaseInitializerExtensions.cs create mode 100644 modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs create mode 100644 modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs rename template/SimpleModule.Host/Migrations/{20260317220751_InitialCreate.Designer.cs => 20260317230801_InitialCreate.Designer.cs} (79%) rename template/SimpleModule.Host/Migrations/{20260317220751_InitialCreate.cs => 20260317230801_InitialCreate.cs} (57%) diff --git a/framework/SimpleModule.Database/DatabaseInitializerExtensions.cs b/framework/SimpleModule.Database/DatabaseInitializerExtensions.cs deleted file mode 100644 index 2e74eda0..00000000 --- a/framework/SimpleModule.Database/DatabaseInitializerExtensions.cs +++ /dev/null @@ -1,140 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace SimpleModule.Database; - -public static class DatabaseInitializerExtensions -{ - /// - /// Creates module databases and tables using EnsureCreated. - /// - /// Limitation: EnsureCreated cannot evolve existing schemas. If you add or rename - /// columns after initial creation, the existing database will NOT be updated. For production - /// workloads that require schema evolution, use EF Core migrations per module instead. - /// - /// - public static void EnsureModuleDatabases(this WebApplication app) - { - using var scope = app.Services.CreateScope(); - var infos = scope.ServiceProvider.GetServices(); - var dbOptions = scope.ServiceProvider.GetRequiredService>().Value; - var createdDatabases = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var info in infos) - { - var dbContext = (DbContext)scope.ServiceProvider.GetRequiredService(info.DbContextType); - var connectionString = dbOptions.ModuleConnections.TryGetValue( - info.ModuleName, - out var moduleCs - ) - ? moduleCs - : dbOptions.DefaultConnection; - - if (createdDatabases.Add(connectionString)) - { - // First context for this database — create DB and its tables - dbContext.Database.EnsureCreated(); - } - else - { - // Shared database already created — only create this module's tables if they don't exist - var creator = dbContext.GetService(); - var provider = DatabaseProviderDetector.Detect( - connectionString, - dbOptions.Provider - ); - if (!ModuleTablesExist(dbContext, info.ModuleName, provider)) - { - creator.CreateTables(); - } - } - } - } - - private static bool ModuleTablesExist( - DbContext dbContext, - string moduleName, - DatabaseProvider provider - ) - { - var prefix = $"{moduleName}_"; - var entityTypes = dbContext.Model.GetEntityTypes(); - - foreach (var entityType in entityTypes) - { - var tableName = entityType.GetTableName(); - if (tableName is null) - continue; - - // Check for prefixed table (SQLite shared DB) or unprefixed (own DB) - var nameToCheck = tableName.StartsWith(prefix, StringComparison.Ordinal) - ? tableName - : $"{prefix}{tableName}"; - - // Schema names are lowercase by convention in PostgreSQL/SQL Server module isolation -#pragma warning disable CA1308 // Normalize strings to uppercase - schema names must be lowercase - var schemaName = moduleName.ToLowerInvariant(); -#pragma warning restore CA1308 - var exists = provider switch - { - DatabaseProvider.PostgreSql => TableExistsPostgreSql( - dbContext, - schemaName, - nameToCheck - ), - DatabaseProvider.SqlServer => TableExistsSqlServer( - dbContext, - schemaName, - nameToCheck - ), - _ => TableExistsSqlite(dbContext, nameToCheck), - }; - - if (exists) - return true; - } - - return false; - } - - private static bool TableExistsSqlite(DbContext dbContext, string tableName) - { - var count = dbContext.Database.SqlQueryRaw( - "SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type='table' AND name={0}", - tableName - ); - return count.Any(c => c > 0); - } - - private static bool TableExistsPostgreSql( - DbContext dbContext, - string schemaName, - string tableName - ) - { - var count = dbContext.Database.SqlQueryRaw( - "SELECT COUNT(*)::int AS \"Value\" FROM information_schema.tables WHERE table_schema={0} AND table_name={1}", - schemaName, - tableName - ); - return count.Any(c => c > 0); - } - - private static bool TableExistsSqlServer( - DbContext dbContext, - string schemaName, - string tableName - ) - { - var count = dbContext.Database.SqlQueryRaw( - "SELECT COUNT(*) AS [Value] FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA={0} AND TABLE_NAME={1}", - schemaName, - tableName - ); - return count.Any(c => c > 0); - } -} diff --git a/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs b/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs new file mode 100644 index 00000000..28485206 --- /dev/null +++ b/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs @@ -0,0 +1,92 @@ +using Bogus; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.Orders.Contracts; + +namespace SimpleModule.Orders.EntityConfigurations; + +public class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(o => o.Id); + builder.Property(o => o.Total).HasColumnType("decimal(18,2)"); + builder.HasMany(o => o.Items).WithOne().HasForeignKey("OrderId"); + + builder.HasData(GenerateSeedOrders()); + } + + internal static List GenerateSeedOrderItems() + { + var orderFaker = new Faker { Random = new Randomizer(99999) }; + var orderItems = new List(); + + for (var i = 1; i <= 5; i++) + { + var itemCount = orderFaker.Random.Int(1, 3); + var usedProductIds = new HashSet(); + for (var j = 0; j < itemCount; j++) + { + int productId; + do + { + productId = orderFaker.Random.Int(1, 10); + } while (!usedProductIds.Add(productId)); + + var quantity = orderFaker.Random.Int(1, 5); + // consume price random to keep sequence consistent + orderFaker.Random.Decimal(10, 1000); + + orderItems.Add( + new + { + OrderId = i, + ProductId = productId, + Quantity = quantity, + } + ); + } + } + + return orderItems; + } + + private static Order[] GenerateSeedOrders() + { + var orderFaker = new Faker { Random = new Randomizer(99999) }; + var orders = new List(); + + for (var i = 1; i <= 5; i++) + { + var itemCount = orderFaker.Random.Int(1, 3); + var usedProductIds = new HashSet(); + decimal total = 0; + for (var j = 0; j < itemCount; j++) + { + int productId; + do + { + productId = orderFaker.Random.Int(1, 10); + } while (!usedProductIds.Add(productId)); + + var quantity = orderFaker.Random.Int(1, 5); + var price = orderFaker.Random.Decimal(10, 1000); + total += price * quantity; + } + + orders.Add( + new Order + { + Id = i, + UserId = orderFaker + .Random.Int(1, 10) + .ToString(System.Globalization.CultureInfo.InvariantCulture), + Total = Math.Round(total, 2), + CreatedAt = orderFaker.Date.Recent(30).ToUniversalTime(), + } + ); + } + + return orders.ToArray(); + } +} diff --git a/modules/Orders/src/Orders/EntityConfigurations/OrderItemConfiguration.cs b/modules/Orders/src/Orders/EntityConfigurations/OrderItemConfiguration.cs index beeebf44..a7bf2e95 100644 --- a/modules/Orders/src/Orders/EntityConfigurations/OrderItemConfiguration.cs +++ b/modules/Orders/src/Orders/EntityConfigurations/OrderItemConfiguration.cs @@ -9,5 +9,7 @@ public class OrderItemConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.HasKey("OrderId", nameof(OrderItem.ProductId)); + + builder.HasData(OrderConfiguration.GenerateSeedOrderItems()); } } diff --git a/modules/Orders/src/Orders/OrdersDbContext.cs b/modules/Orders/src/Orders/OrdersDbContext.cs index 3629b6e8..7e61ac5d 100644 --- a/modules/Orders/src/Orders/OrdersDbContext.cs +++ b/modules/Orders/src/Orders/OrdersDbContext.cs @@ -1,8 +1,8 @@ -using Bogus; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SimpleModule.Database; using SimpleModule.Orders.Contracts; +using SimpleModule.Orders.EntityConfigurations; namespace SimpleModule.Orders; @@ -16,71 +16,8 @@ IOptions dbOptions protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => - { - entity.HasKey(o => o.Id); - entity.Property(o => o.Total).HasColumnType("decimal(18,2)"); - entity.HasMany(o => o.Items).WithOne().HasForeignKey("OrderId"); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey("OrderId", nameof(OrderItem.ProductId)); - }); - - SeedOrders(modelBuilder); - + modelBuilder.ApplyConfiguration(new OrderConfiguration()); + modelBuilder.ApplyConfiguration(new OrderItemConfiguration()); modelBuilder.ApplyModuleSchema("Orders", dbOptions.Value); } - - private static void SeedOrders(ModelBuilder modelBuilder) - { - var orderFaker = new Faker { Random = new Randomizer(99999) }; - - var orders = new List(); - var orderItems = new List(); - - for (var i = 1; i <= 5; i++) - { - var itemCount = orderFaker.Random.Int(1, 3); - var usedProductIds = new HashSet(); - decimal total = 0; - for (var j = 0; j < itemCount; j++) - { - int productId; - do - { - productId = orderFaker.Random.Int(1, 10); - } while (!usedProductIds.Add(productId)); - - var quantity = orderFaker.Random.Int(1, 5); - var price = orderFaker.Random.Decimal(10, 1000); - total += price * quantity; - - orderItems.Add( - new - { - OrderId = i, - ProductId = productId, - Quantity = quantity, - } - ); - } - - orders.Add( - new Order - { - Id = i, - UserId = orderFaker - .Random.Int(1, 10) - .ToString(System.Globalization.CultureInfo.InvariantCulture), - Total = Math.Round(total, 2), - CreatedAt = orderFaker.Date.Recent(30).ToUniversalTime(), - } - ); - } - - modelBuilder.Entity().HasData(orders); - modelBuilder.Entity().HasData(orderItems); - } } diff --git a/modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs b/modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs new file mode 100644 index 00000000..d40586f6 --- /dev/null +++ b/modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs @@ -0,0 +1,37 @@ +using Bogus; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.Products.Contracts; + +namespace SimpleModule.Products.EntityConfigurations; + +public class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(p => p.Id); + builder.Property(p => p.Name).IsRequired(); + builder.Property(p => p.Price).HasColumnType("decimal(18,2)"); + + builder.HasData(GenerateSeedProducts()); + } + + private static Product[] GenerateSeedProducts() + { + var id = 0; + var faker = new Faker() + .UseSeed(54321) + .RuleFor(p => p.Id, _ => ++id) + .RuleFor(p => p.Name, f => f.Commerce.ProductName()) + .RuleFor( + p => p.Price, + f => + decimal.Parse( + f.Commerce.Price(10, 1000), + System.Globalization.CultureInfo.InvariantCulture + ) + ); + + return faker.Generate(10).ToArray(); + } +} diff --git a/modules/Products/src/Products/ProductsDbContext.cs b/modules/Products/src/Products/ProductsDbContext.cs index 70a84db8..7c50f707 100644 --- a/modules/Products/src/Products/ProductsDbContext.cs +++ b/modules/Products/src/Products/ProductsDbContext.cs @@ -1,8 +1,8 @@ -using Bogus; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SimpleModule.Database; using SimpleModule.Products.Contracts; +using SimpleModule.Products.EntityConfigurations; namespace SimpleModule.Products; @@ -15,34 +15,7 @@ IOptions dbOptions protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => - { - entity.HasKey(p => p.Id); - entity.Property(p => p.Name).IsRequired(); - entity.Property(p => p.Price).HasColumnType("decimal(18,2)"); - }); - - modelBuilder.Entity().HasData(GenerateSeedProducts()); - + modelBuilder.ApplyConfiguration(new ProductConfiguration()); modelBuilder.ApplyModuleSchema("Products", dbOptions.Value); } - - private static Product[] GenerateSeedProducts() - { - var id = 0; - var faker = new Faker() - .UseSeed(54321) - .RuleFor(p => p.Id, _ => ++id) - .RuleFor(p => p.Name, f => f.Commerce.ProductName()) - .RuleFor( - p => p.Price, - f => - decimal.Parse( - f.Commerce.Price(10, 1000), - System.Globalization.CultureInfo.InvariantCulture - ) - ); - - return faker.Generate(10).ToArray(); - } } diff --git a/template/SimpleModule.Host/Migrations/20260317220751_InitialCreate.Designer.cs b/template/SimpleModule.Host/Migrations/20260317230801_InitialCreate.Designer.cs similarity index 79% rename from template/SimpleModule.Host/Migrations/20260317220751_InitialCreate.Designer.cs rename to template/SimpleModule.Host/Migrations/20260317230801_InitialCreate.Designer.cs index 832e9c08..c5d7f78a 100644 --- a/template/SimpleModule.Host/Migrations/20260317220751_InitialCreate.Designer.cs +++ b/template/SimpleModule.Host/Migrations/20260317230801_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace SimpleModule.Host.Migrations { [DbContext(typeof(HostDbContext))] - [Migration("20260317220751_InitialCreate")] + [Migration("20260317230801_InitialCreate")] partial class InitialCreate { /// @@ -351,7 +351,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone"); b.Property("Total") - .HasColumnType("numeric"); + .HasColumnType("decimal(18,2)"); b.Property("UserId") .IsRequired() @@ -360,6 +360,43 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); b.ToTable("Orders", "orders"); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTime(2026, 3, 16, 10, 20, 47, 15, DateTimeKind.Utc).AddTicks(1761), + Total = 4205.85m, + UserId = "2" + }, + new + { + Id = 2, + CreatedAt = new DateTime(2026, 3, 9, 18, 57, 18, 178, DateTimeKind.Utc).AddTicks(1275), + Total = 2752.49m, + UserId = "8" + }, + new + { + Id = 3, + CreatedAt = new DateTime(2026, 2, 20, 1, 3, 59, 566, DateTimeKind.Utc).AddTicks(5889), + Total = 192.31m, + UserId = "6" + }, + new + { + Id = 4, + CreatedAt = new DateTime(2026, 2, 28, 12, 35, 49, 235, DateTimeKind.Utc).AddTicks(4033), + Total = 3146.31m, + UserId = "2" + }, + new + { + Id = 5, + CreatedAt = new DateTime(2026, 3, 14, 1, 15, 36, 679, DateTimeKind.Utc).AddTicks(1240), + Total = 4580.92m, + UserId = "10" + }); }); modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => @@ -376,6 +413,62 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("OrderId", "ProductId"); b.ToTable("OrderItems", "orders"); + + b.HasData( + new + { + OrderId = 1, + ProductId = 4, + Quantity = 5 + }, + new + { + OrderId = 1, + ProductId = 5, + Quantity = 1 + }, + new + { + OrderId = 1, + ProductId = 6, + Quantity = 1 + }, + new + { + OrderId = 2, + ProductId = 1, + Quantity = 3 + }, + new + { + OrderId = 3, + ProductId = 4, + Quantity = 2 + }, + new + { + OrderId = 3, + ProductId = 5, + Quantity = 4 + }, + new + { + OrderId = 4, + ProductId = 3, + Quantity = 1 + }, + new + { + OrderId = 5, + ProductId = 9, + Quantity = 3 + }, + new + { + OrderId = 5, + ProductId = 6, + Quantity = 5 + }); }); modelBuilder.Entity("SimpleModule.Products.Contracts.Product", b => @@ -391,11 +484,73 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("text"); b.Property("Price") - .HasColumnType("numeric"); + .HasColumnType("decimal(18,2)"); b.HasKey("Id"); b.ToTable("Products", "products"); + + b.HasData( + new + { + Id = 1, + Name = "Fantastic Rubber Shoes", + Price = 99168m + }, + new + { + Id = 2, + Name = "Fantastic Rubber Bacon", + Price = 44622m + }, + new + { + Id = 3, + Name = "Fantastic Concrete Bike", + Price = 66012m + }, + new + { + Id = 4, + Name = "Handcrafted Concrete Keyboard", + Price = 63367m + }, + new + { + Id = 5, + Name = "Intelligent Frozen Mouse", + Price = 67430m + }, + new + { + Id = 6, + Name = "Sleek Soft Hat", + Price = 85163m + }, + new + { + Id = 7, + Name = "Practical Fresh Bike", + Price = 41748m + }, + new + { + Id = 8, + Name = "Handmade Steel Ball", + Price = 97556m + }, + new + { + Id = 9, + Name = "Ergonomic Fresh Pants", + Price = 92809m + }, + new + { + Id = 10, + Name = "Licensed Steel Sausages", + Price = 59260m + }); }); modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => diff --git a/template/SimpleModule.Host/Migrations/20260317220751_InitialCreate.cs b/template/SimpleModule.Host/Migrations/20260317230801_InitialCreate.cs similarity index 57% rename from template/SimpleModule.Host/Migrations/20260317220751_InitialCreate.cs rename to template/SimpleModule.Host/Migrations/20260317230801_InitialCreate.cs index acbb6b2a..35ed9a7d 100644 --- a/template/SimpleModule.Host/Migrations/20260317220751_InitialCreate.cs +++ b/template/SimpleModule.Host/Migrations/20260317230801_InitialCreate.cs @@ -4,6 +4,8 @@ #nullable disable +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + namespace SimpleModule.Host.Migrations { /// @@ -12,11 +14,14 @@ public partial class InitialCreate : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.EnsureSchema(name: "users"); + migrationBuilder.EnsureSchema( + name: "users"); - migrationBuilder.EnsureSchema(name: "orders"); + migrationBuilder.EnsureSchema( + name: "orders"); - migrationBuilder.EnsureSchema(name: "products"); + migrationBuilder.EnsureSchema( + name: "products"); migrationBuilder.CreateTable( name: "AspNetRoles", @@ -25,27 +30,15 @@ protected override void Up(MigrationBuilder migrationBuilder) { Id = table.Column(type: "text", nullable: false), Description = table.Column(type: "text", nullable: true), - CreatedAt = table.Column( - type: "timestamp with time zone", - nullable: false - ), - Name = table.Column( - type: "character varying(256)", - maxLength: 256, - nullable: true - ), - NormalizedName = table.Column( - type: "character varying(256)", - maxLength: 256, - nullable: true - ), - ConcurrencyStamp = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) }, constraints: table => { table.PrimaryKey("PK_AspNetRoles", x => x.Id); - } - ); + }); migrationBuilder.CreateTable( name: "AspNetUsers", @@ -54,34 +47,12 @@ protected override void Up(MigrationBuilder migrationBuilder) { Id = table.Column(type: "text", nullable: false), DisplayName = table.Column(type: "text", nullable: false), - CreatedAt = table.Column( - type: "timestamp with time zone", - nullable: false - ), - LastLoginAt = table.Column( - type: "timestamp with time zone", - nullable: true - ), - UserName = table.Column( - type: "character varying(256)", - maxLength: 256, - nullable: true - ), - NormalizedUserName = table.Column( - type: "character varying(256)", - maxLength: 256, - nullable: true - ), - Email = table.Column( - type: "character varying(256)", - maxLength: 256, - nullable: true - ), - NormalizedEmail = table.Column( - type: "character varying(256)", - maxLength: 256, - nullable: true - ), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastLoginAt = table.Column(type: "timestamp with time zone", nullable: true), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), EmailConfirmed = table.Column(type: "boolean", nullable: false), PasswordHash = table.Column(type: "text", nullable: true), SecurityStamp = table.Column(type: "text", nullable: true), @@ -89,18 +60,14 @@ protected override void Up(MigrationBuilder migrationBuilder) PhoneNumber = table.Column(type: "text", nullable: true), PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), TwoFactorEnabled = table.Column(type: "boolean", nullable: false), - LockoutEnd = table.Column( - type: "timestamp with time zone", - nullable: true - ), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), LockoutEnabled = table.Column(type: "boolean", nullable: false), - AccessFailedCount = table.Column(type: "integer", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) }, constraints: table => { table.PrimaryKey("PK_AspNetUsers", x => x.Id); - } - ); + }); migrationBuilder.CreateTable( name: "OpenIddictApplications", @@ -108,32 +75,12 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { Id = table.Column(type: "text", nullable: false), - ApplicationType = table.Column( - type: "character varying(50)", - maxLength: 50, - nullable: true - ), - ClientId = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: true - ), + ApplicationType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ClientId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), ClientSecret = table.Column(type: "text", nullable: true), - ClientType = table.Column( - type: "character varying(50)", - maxLength: 50, - nullable: true - ), - ConcurrencyToken = table.Column( - type: "character varying(50)", - maxLength: 50, - nullable: true - ), - ConsentType = table.Column( - type: "character varying(50)", - maxLength: 50, - nullable: true - ), + ClientType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConsentType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), DisplayName = table.Column(type: "text", nullable: true), DisplayNames = table.Column(type: "text", nullable: true), JsonWebKeySet = table.Column(type: "text", nullable: true), @@ -142,13 +89,12 @@ protected override void Up(MigrationBuilder migrationBuilder) Properties = table.Column(type: "text", nullable: true), RedirectUris = table.Column(type: "text", nullable: true), Requirements = table.Column(type: "text", nullable: true), - Settings = table.Column(type: "text", nullable: true), + Settings = table.Column(type: "text", nullable: true) }, constraints: table => { table.PrimaryKey("PK_OpenIddictApplications", x => x.Id); - } - ); + }); migrationBuilder.CreateTable( name: "OpenIddictScopes", @@ -156,87 +102,61 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { Id = table.Column(type: "text", nullable: false), - ConcurrencyToken = table.Column( - type: "character varying(50)", - maxLength: 50, - nullable: true - ), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), Description = table.Column(type: "text", nullable: true), Descriptions = table.Column(type: "text", nullable: true), DisplayName = table.Column(type: "text", nullable: true), DisplayNames = table.Column(type: "text", nullable: true), - Name = table.Column( - type: "character varying(200)", - maxLength: 200, - nullable: true - ), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), Properties = table.Column(type: "text", nullable: true), - Resources = table.Column(type: "text", nullable: true), + Resources = table.Column(type: "text", nullable: true) }, constraints: table => { table.PrimaryKey("PK_OpenIddictScopes", x => x.Id); - } - ); + }); migrationBuilder.CreateTable( name: "Orders", schema: "orders", columns: table => new { - Id = table - .Column(type: "integer", nullable: false) - .Annotation( - "Npgsql:ValueGenerationStrategy", - NpgsqlValueGenerationStrategy.IdentityByDefaultColumn - ), + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), UserId = table.Column(type: "text", nullable: false), - Total = table.Column(type: "numeric", nullable: false), - CreatedAt = table.Column( - type: "timestamp with time zone", - nullable: false - ), + Total = table.Column(type: "numeric(18,2)", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) }, constraints: table => { table.PrimaryKey("PK_Orders", x => x.Id); - } - ); + }); migrationBuilder.CreateTable( name: "Products", schema: "products", columns: table => new { - Id = table - .Column(type: "integer", nullable: false) - .Annotation( - "Npgsql:ValueGenerationStrategy", - NpgsqlValueGenerationStrategy.IdentityByDefaultColumn - ), + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), Name = table.Column(type: "text", nullable: false), - Price = table.Column(type: "numeric", nullable: false), + Price = table.Column(type: "numeric(18,2)", nullable: false) }, constraints: table => { table.PrimaryKey("PK_Products", x => x.Id); - } - ); + }); migrationBuilder.CreateTable( name: "AspNetRoleClaims", schema: "users", columns: table => new { - Id = table - .Column(type: "integer", nullable: false) - .Annotation( - "Npgsql:ValueGenerationStrategy", - NpgsqlValueGenerationStrategy.IdentityByDefaultColumn - ), + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), RoleId = table.Column(type: "text", nullable: false), ClaimType = table.Column(type: "text", nullable: true), - ClaimValue = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) }, constraints: table => { @@ -247,25 +167,19 @@ protected override void Up(MigrationBuilder migrationBuilder) principalSchema: "users", principalTable: "AspNetRoles", principalColumn: "Id", - onDelete: ReferentialAction.Cascade - ); - } - ); + onDelete: ReferentialAction.Cascade); + }); migrationBuilder.CreateTable( name: "AspNetUserClaims", schema: "users", columns: table => new { - Id = table - .Column(type: "integer", nullable: false) - .Annotation( - "Npgsql:ValueGenerationStrategy", - NpgsqlValueGenerationStrategy.IdentityByDefaultColumn - ), + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), UserId = table.Column(type: "text", nullable: false), ClaimType = table.Column(type: "text", nullable: true), - ClaimValue = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) }, constraints: table => { @@ -276,10 +190,8 @@ protected override void Up(MigrationBuilder migrationBuilder) principalSchema: "users", principalTable: "AspNetUsers", principalColumn: "Id", - onDelete: ReferentialAction.Cascade - ); - } - ); + onDelete: ReferentialAction.Cascade); + }); migrationBuilder.CreateTable( name: "AspNetUserLogins", @@ -289,24 +201,19 @@ protected override void Up(MigrationBuilder migrationBuilder) LoginProvider = table.Column(type: "text", nullable: false), ProviderKey = table.Column(type: "text", nullable: false), ProviderDisplayName = table.Column(type: "text", nullable: true), - UserId = table.Column(type: "text", nullable: false), + UserId = table.Column(type: "text", nullable: false) }, constraints: table => { - table.PrimaryKey( - "PK_AspNetUserLogins", - x => new { x.LoginProvider, x.ProviderKey } - ); + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); table.ForeignKey( name: "FK_AspNetUserLogins_AspNetUsers_UserId", column: x => x.UserId, principalSchema: "users", principalTable: "AspNetUsers", principalColumn: "Id", - onDelete: ReferentialAction.Cascade - ); - } - ); + onDelete: ReferentialAction.Cascade); + }); migrationBuilder.CreateTable( name: "AspNetUserRoles", @@ -314,7 +221,7 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { UserId = table.Column(type: "text", nullable: false), - RoleId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "text", nullable: false) }, constraints: table => { @@ -325,18 +232,15 @@ protected override void Up(MigrationBuilder migrationBuilder) principalSchema: "users", principalTable: "AspNetRoles", principalColumn: "Id", - onDelete: ReferentialAction.Cascade - ); + onDelete: ReferentialAction.Cascade); table.ForeignKey( name: "FK_AspNetUserRoles_AspNetUsers_UserId", column: x => x.UserId, principalSchema: "users", principalTable: "AspNetUsers", principalColumn: "Id", - onDelete: ReferentialAction.Cascade - ); - } - ); + onDelete: ReferentialAction.Cascade); + }); migrationBuilder.CreateTable( name: "AspNetUserTokens", @@ -346,29 +250,19 @@ protected override void Up(MigrationBuilder migrationBuilder) UserId = table.Column(type: "text", nullable: false), LoginProvider = table.Column(type: "text", nullable: false), Name = table.Column(type: "text", nullable: false), - Value = table.Column(type: "text", nullable: true), + Value = table.Column(type: "text", nullable: true) }, constraints: table => { - table.PrimaryKey( - "PK_AspNetUserTokens", - x => new - { - x.UserId, - x.LoginProvider, - x.Name, - } - ); + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); table.ForeignKey( name: "FK_AspNetUserTokens_AspNetUsers_UserId", column: x => x.UserId, principalSchema: "users", principalTable: "AspNetUsers", principalColumn: "Id", - onDelete: ReferentialAction.Cascade - ); - } - ); + onDelete: ReferentialAction.Cascade); + }); migrationBuilder.CreateTable( name: "OpenIddictAuthorizations", @@ -377,32 +271,13 @@ protected override void Up(MigrationBuilder migrationBuilder) { Id = table.Column(type: "text", nullable: false), ApplicationId = table.Column(type: "text", nullable: true), - ConcurrencyToken = table.Column( - type: "character varying(50)", - maxLength: 50, - nullable: true - ), - CreationDate = table.Column( - type: "timestamp with time zone", - nullable: true - ), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), Properties = table.Column(type: "text", nullable: true), Scopes = table.Column(type: "text", nullable: true), - Status = table.Column( - type: "character varying(50)", - maxLength: 50, - nullable: true - ), - Subject = table.Column( - type: "character varying(400)", - maxLength: 400, - nullable: true - ), - Type = table.Column( - type: "character varying(50)", - maxLength: 50, - nullable: true - ), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) }, constraints: table => { @@ -412,10 +287,8 @@ protected override void Up(MigrationBuilder migrationBuilder) column: x => x.ApplicationId, principalSchema: "users", principalTable: "OpenIddictApplications", - principalColumn: "Id" - ); - } - ); + principalColumn: "Id"); + }); migrationBuilder.CreateTable( name: "OrderItems", @@ -424,7 +297,7 @@ protected override void Up(MigrationBuilder migrationBuilder) { ProductId = table.Column(type: "integer", nullable: false), OrderId = table.Column(type: "integer", nullable: false), - Quantity = table.Column(type: "integer", nullable: false), + Quantity = table.Column(type: "integer", nullable: false) }, constraints: table => { @@ -435,10 +308,8 @@ protected override void Up(MigrationBuilder migrationBuilder) principalSchema: "orders", principalTable: "Orders", principalColumn: "Id", - onDelete: ReferentialAction.Cascade - ); - } - ); + onDelete: ReferentialAction.Cascade); + }); migrationBuilder.CreateTable( name: "OpenIddictTokens", @@ -448,45 +319,16 @@ protected override void Up(MigrationBuilder migrationBuilder) Id = table.Column(type: "text", nullable: false), ApplicationId = table.Column(type: "text", nullable: true), AuthorizationId = table.Column(type: "text", nullable: true), - ConcurrencyToken = table.Column( - type: "character varying(50)", - maxLength: 50, - nullable: true - ), - CreationDate = table.Column( - type: "timestamp with time zone", - nullable: true - ), - ExpirationDate = table.Column( - type: "timestamp with time zone", - nullable: true - ), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + ExpirationDate = table.Column(type: "timestamp with time zone", nullable: true), Payload = table.Column(type: "text", nullable: true), Properties = table.Column(type: "text", nullable: true), - RedemptionDate = table.Column( - type: "timestamp with time zone", - nullable: true - ), - ReferenceId = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: true - ), - Status = table.Column( - type: "character varying(50)", - maxLength: 50, - nullable: true - ), - Subject = table.Column( - type: "character varying(400)", - maxLength: 400, - nullable: true - ), - Type = table.Column( - type: "character varying(50)", - maxLength: 50, - nullable: true - ), + RedemptionDate = table.Column(type: "timestamp with time zone", nullable: true), + ReferenceId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) }, constraints: table => { @@ -496,145 +338,205 @@ protected override void Up(MigrationBuilder migrationBuilder) column: x => x.ApplicationId, principalSchema: "users", principalTable: "OpenIddictApplications", - principalColumn: "Id" - ); + principalColumn: "Id"); table.ForeignKey( name: "FK_OpenIddictTokens_OpenIddictAuthorizations_AuthorizationId", column: x => x.AuthorizationId, principalSchema: "users", principalTable: "OpenIddictAuthorizations", - principalColumn: "Id" - ); - } - ); + principalColumn: "Id"); + }); + + migrationBuilder.InsertData( + schema: "orders", + table: "Orders", + columns: new[] { "Id", "CreatedAt", "Total", "UserId" }, + values: new object[,] + { + { 1, new DateTime(2026, 3, 16, 10, 20, 47, 15, DateTimeKind.Utc).AddTicks(1761), 4205.85m, "2" }, + { 2, new DateTime(2026, 3, 9, 18, 57, 18, 178, DateTimeKind.Utc).AddTicks(1275), 2752.49m, "8" }, + { 3, new DateTime(2026, 2, 20, 1, 3, 59, 566, DateTimeKind.Utc).AddTicks(5889), 192.31m, "6" }, + { 4, new DateTime(2026, 2, 28, 12, 35, 49, 235, DateTimeKind.Utc).AddTicks(4033), 3146.31m, "2" }, + { 5, new DateTime(2026, 3, 14, 1, 15, 36, 679, DateTimeKind.Utc).AddTicks(1240), 4580.92m, "10" } + }); + + migrationBuilder.InsertData( + schema: "products", + table: "Products", + columns: new[] { "Id", "Name", "Price" }, + values: new object[,] + { + { 1, "Fantastic Rubber Shoes", 99168m }, + { 2, "Fantastic Rubber Bacon", 44622m }, + { 3, "Fantastic Concrete Bike", 66012m }, + { 4, "Handcrafted Concrete Keyboard", 63367m }, + { 5, "Intelligent Frozen Mouse", 67430m }, + { 6, "Sleek Soft Hat", 85163m }, + { 7, "Practical Fresh Bike", 41748m }, + { 8, "Handmade Steel Ball", 97556m }, + { 9, "Ergonomic Fresh Pants", 92809m }, + { 10, "Licensed Steel Sausages", 59260m } + }); + + migrationBuilder.InsertData( + schema: "orders", + table: "OrderItems", + columns: new[] { "OrderId", "ProductId", "Quantity" }, + values: new object[,] + { + { 1, 4, 5 }, + { 1, 5, 1 }, + { 1, 6, 1 }, + { 2, 1, 3 }, + { 3, 4, 2 }, + { 3, 5, 4 }, + { 4, 3, 1 }, + { 5, 6, 5 }, + { 5, 9, 3 } + }); migrationBuilder.CreateIndex( name: "IX_AspNetRoleClaims_RoleId", schema: "users", table: "AspNetRoleClaims", - column: "RoleId" - ); + column: "RoleId"); migrationBuilder.CreateIndex( name: "RoleNameIndex", schema: "users", table: "AspNetRoles", column: "NormalizedName", - unique: true - ); + unique: true); migrationBuilder.CreateIndex( name: "IX_AspNetUserClaims_UserId", schema: "users", table: "AspNetUserClaims", - column: "UserId" - ); + column: "UserId"); migrationBuilder.CreateIndex( name: "IX_AspNetUserLogins_UserId", schema: "users", table: "AspNetUserLogins", - column: "UserId" - ); + column: "UserId"); migrationBuilder.CreateIndex( name: "IX_AspNetUserRoles_RoleId", schema: "users", table: "AspNetUserRoles", - column: "RoleId" - ); + column: "RoleId"); migrationBuilder.CreateIndex( name: "EmailIndex", schema: "users", table: "AspNetUsers", - column: "NormalizedEmail" - ); + column: "NormalizedEmail"); migrationBuilder.CreateIndex( name: "UserNameIndex", schema: "users", table: "AspNetUsers", column: "NormalizedUserName", - unique: true - ); + unique: true); migrationBuilder.CreateIndex( name: "IX_OpenIddictApplications_ClientId", schema: "users", table: "OpenIddictApplications", column: "ClientId", - unique: true - ); + unique: true); migrationBuilder.CreateIndex( name: "IX_OpenIddictAuthorizations_ApplicationId_Status_Subject_Type", schema: "users", table: "OpenIddictAuthorizations", - columns: new[] { "ApplicationId", "Status", "Subject", "Type" } - ); + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); migrationBuilder.CreateIndex( name: "IX_OpenIddictScopes_Name", schema: "users", table: "OpenIddictScopes", column: "Name", - unique: true - ); + unique: true); migrationBuilder.CreateIndex( name: "IX_OpenIddictTokens_ApplicationId_Status_Subject_Type", schema: "users", table: "OpenIddictTokens", - columns: new[] { "ApplicationId", "Status", "Subject", "Type" } - ); + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); migrationBuilder.CreateIndex( name: "IX_OpenIddictTokens_AuthorizationId", schema: "users", table: "OpenIddictTokens", - column: "AuthorizationId" - ); + column: "AuthorizationId"); migrationBuilder.CreateIndex( name: "IX_OpenIddictTokens_ReferenceId", schema: "users", table: "OpenIddictTokens", column: "ReferenceId", - unique: true - ); + unique: true); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable(name: "AspNetRoleClaims", schema: "users"); + migrationBuilder.DropTable( + name: "AspNetRoleClaims", + schema: "users"); - migrationBuilder.DropTable(name: "AspNetUserClaims", schema: "users"); + migrationBuilder.DropTable( + name: "AspNetUserClaims", + schema: "users"); - migrationBuilder.DropTable(name: "AspNetUserLogins", schema: "users"); + migrationBuilder.DropTable( + name: "AspNetUserLogins", + schema: "users"); - migrationBuilder.DropTable(name: "AspNetUserRoles", schema: "users"); + migrationBuilder.DropTable( + name: "AspNetUserRoles", + schema: "users"); - migrationBuilder.DropTable(name: "AspNetUserTokens", schema: "users"); + migrationBuilder.DropTable( + name: "AspNetUserTokens", + schema: "users"); - migrationBuilder.DropTable(name: "OpenIddictScopes", schema: "users"); + migrationBuilder.DropTable( + name: "OpenIddictScopes", + schema: "users"); - migrationBuilder.DropTable(name: "OpenIddictTokens", schema: "users"); + migrationBuilder.DropTable( + name: "OpenIddictTokens", + schema: "users"); - migrationBuilder.DropTable(name: "OrderItems", schema: "orders"); + migrationBuilder.DropTable( + name: "OrderItems", + schema: "orders"); - migrationBuilder.DropTable(name: "Products", schema: "products"); + migrationBuilder.DropTable( + name: "Products", + schema: "products"); - migrationBuilder.DropTable(name: "AspNetRoles", schema: "users"); + migrationBuilder.DropTable( + name: "AspNetRoles", + schema: "users"); - migrationBuilder.DropTable(name: "AspNetUsers", schema: "users"); + migrationBuilder.DropTable( + name: "AspNetUsers", + schema: "users"); - migrationBuilder.DropTable(name: "OpenIddictAuthorizations", schema: "users"); + migrationBuilder.DropTable( + name: "OpenIddictAuthorizations", + schema: "users"); - migrationBuilder.DropTable(name: "Orders", schema: "orders"); + migrationBuilder.DropTable( + name: "Orders", + schema: "orders"); - migrationBuilder.DropTable(name: "OpenIddictApplications", schema: "users"); + migrationBuilder.DropTable( + name: "OpenIddictApplications", + schema: "users"); } } } diff --git a/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs index c4828054..95894f23 100644 --- a/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs +++ b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs @@ -348,7 +348,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone"); b.Property("Total") - .HasColumnType("numeric"); + .HasColumnType("decimal(18,2)"); b.Property("UserId") .IsRequired() @@ -357,6 +357,43 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); b.ToTable("Orders", "orders"); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTime(2026, 3, 16, 10, 20, 47, 15, DateTimeKind.Utc).AddTicks(1761), + Total = 4205.85m, + UserId = "2" + }, + new + { + Id = 2, + CreatedAt = new DateTime(2026, 3, 9, 18, 57, 18, 178, DateTimeKind.Utc).AddTicks(1275), + Total = 2752.49m, + UserId = "8" + }, + new + { + Id = 3, + CreatedAt = new DateTime(2026, 2, 20, 1, 3, 59, 566, DateTimeKind.Utc).AddTicks(5889), + Total = 192.31m, + UserId = "6" + }, + new + { + Id = 4, + CreatedAt = new DateTime(2026, 2, 28, 12, 35, 49, 235, DateTimeKind.Utc).AddTicks(4033), + Total = 3146.31m, + UserId = "2" + }, + new + { + Id = 5, + CreatedAt = new DateTime(2026, 3, 14, 1, 15, 36, 679, DateTimeKind.Utc).AddTicks(1240), + Total = 4580.92m, + UserId = "10" + }); }); modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => @@ -373,6 +410,62 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("OrderId", "ProductId"); b.ToTable("OrderItems", "orders"); + + b.HasData( + new + { + OrderId = 1, + ProductId = 4, + Quantity = 5 + }, + new + { + OrderId = 1, + ProductId = 5, + Quantity = 1 + }, + new + { + OrderId = 1, + ProductId = 6, + Quantity = 1 + }, + new + { + OrderId = 2, + ProductId = 1, + Quantity = 3 + }, + new + { + OrderId = 3, + ProductId = 4, + Quantity = 2 + }, + new + { + OrderId = 3, + ProductId = 5, + Quantity = 4 + }, + new + { + OrderId = 4, + ProductId = 3, + Quantity = 1 + }, + new + { + OrderId = 5, + ProductId = 9, + Quantity = 3 + }, + new + { + OrderId = 5, + ProductId = 6, + Quantity = 5 + }); }); modelBuilder.Entity("SimpleModule.Products.Contracts.Product", b => @@ -388,11 +481,73 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text"); b.Property("Price") - .HasColumnType("numeric"); + .HasColumnType("decimal(18,2)"); b.HasKey("Id"); b.ToTable("Products", "products"); + + b.HasData( + new + { + Id = 1, + Name = "Fantastic Rubber Shoes", + Price = 99168m + }, + new + { + Id = 2, + Name = "Fantastic Rubber Bacon", + Price = 44622m + }, + new + { + Id = 3, + Name = "Fantastic Concrete Bike", + Price = 66012m + }, + new + { + Id = 4, + Name = "Handcrafted Concrete Keyboard", + Price = 63367m + }, + new + { + Id = 5, + Name = "Intelligent Frozen Mouse", + Price = 67430m + }, + new + { + Id = 6, + Name = "Sleek Soft Hat", + Price = 85163m + }, + new + { + Id = 7, + Name = "Practical Fresh Bike", + Price = 41748m + }, + new + { + Id = 8, + Name = "Handmade Steel Ball", + Price = 97556m + }, + new + { + Id = 9, + Name = "Ergonomic Fresh Pants", + Price = 92809m + }, + new + { + Id = 10, + Name = "Licensed Steel Sausages", + Price = 59260m + }); }); modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => diff --git a/template/SimpleModule.Host/Program.cs b/template/SimpleModule.Host/Program.cs index 1cc3865c..4a3096ba 100644 --- a/template/SimpleModule.Host/Program.cs +++ b/template/SimpleModule.Host/Program.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; using Microsoft.OpenApi; using OpenIddict.Validation.AspNetCore; @@ -10,6 +11,7 @@ using SimpleModule.Core.Inertia; using SimpleModule.Database; using SimpleModule.Database.Health; +using SimpleModule.Host; using SimpleModule.Host.Components; using SimpleModule.Users.Constants; @@ -88,6 +90,13 @@ // Register all modules builder.Services.AddModules(builder.Configuration); +// Register unified HostDbContext for EF Core migrations +builder.Services.AddModuleDbContext( + builder.Configuration, + "Host", + opts => opts.UseOpenIddict() +); + // Collect module menu items into a singleton registry builder.Services.CollectModuleMenuItems(); @@ -126,10 +135,12 @@ var app = builder.Build(); -// Ensure databases are created with seed data (non-production — production uses EF Core migrations) +// Apply pending EF Core migrations (production should use explicit migration tooling) if (!app.Environment.IsProduction()) { - app.EnsureModuleDatabases(); + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); } app.UseExceptionHandler(); @@ -201,4 +212,4 @@ // Aspire default health endpoints (/health, /alive) app.MapDefaultEndpoints(); -app.Run(); +await app.RunAsync(); From d8e735a64eac0949960713dfbe6487b30da9df4d Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:06:21 +0100 Subject: [PATCH 005/112] Add design doc for splitting generator into focused emitters Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-18-split-generator-design.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/plans/2026-03-18-split-generator-design.md diff --git a/docs/plans/2026-03-18-split-generator-design.md b/docs/plans/2026-03-18-split-generator-design.md new file mode 100644 index 00000000..52a0cea7 --- /dev/null +++ b/docs/plans/2026-03-18-split-generator-design.md @@ -0,0 +1,56 @@ +# Split Generator into Focused Emitters + +**Date:** 2026-03-18 +**Status:** Approved + +## Problem + +The `ModuleDiscovererGenerator` is a single monolithic class (split across 3 partial files) that handles all discovery and all 8 code generation outputs. This makes the codebase harder to navigate and maintain. + +## Decision + +**Approach B: Single discovery generator dispatching to separate emitter classes.** + +Keep one `[Generator]` with one `CompilationProvider` pipeline (single discovery pass). Factor each output into its own emitter class implementing a shared `IEmitter` interface. The generator's `RegisterSourceOutput` callback iterates the emitter list. + +This preserves the single-pass discovery performance while distributing complexity across focused, independently testable emitter classes. + +## Structure + +``` +SimpleModule.Generator/ +├── ModuleDiscoveryGenerator.cs # [Generator] — discovery pipeline + dispatches to emitters +├── Discovery/ +│ ├── DiscoveryData.cs # DiscoveryData record + all model types +│ └── SymbolDiscovery.cs # ExtractDiscoveryData + all Find* methods (static) +├── Emitters/ +│ ├── IEmitter.cs # interface: void Emit(SourceProductionContext, DiscoveryData) +│ ├── DiagnosticEmitter.cs # ReportDiscoveryDiagnostics + diagnostic descriptors +│ ├── ModuleExtensionsEmitter.cs # ModuleExtensions.g.cs +│ ├── EndpointExtensionsEmitter.cs # EndpointExtensions.g.cs +│ ├── MenuExtensionsEmitter.cs # MenuExtensions.g.cs +│ ├── RazorComponentExtensionsEmitter.cs # RazorComponentExtensions.g.cs +│ ├── ViewPagesEmitter.cs # ViewPages_{ModuleName}.g.cs (per module) +│ ├── JsonResolverEmitter.cs # ModulesJsonResolver.g.cs +│ ├── TypeScriptDefinitionsEmitter.cs # DtoTypeScript_{ModuleName}.g.cs (per module) +│ └── HostDbContextEmitter.cs # HostDbContext.g.cs +├── Helpers/ +│ └── TypeMappingHelpers.cs # MapCSharpTypeToTypeScript, GetModuleFieldName, shared utils +└── IsExternalInit.cs # polyfill (unchanged) +``` + +## Key Decisions + +- **`IEmitter` interface**: `void Emit(SourceProductionContext spc, DiscoveryData data)`. Each emitter implements this. +- **`SymbolDiscovery`**: Static class with pure function `Extract(Compilation) → DiscoveryData`. Contains all `Find*` methods. +- **`DiagnosticEmitter` runs first**: Reports diagnostics. `HostDbContextEmitter` checks `DiscoveryData` for error conditions itself (same as current behavior). +- **Shared helpers**: `GetModuleFieldName` and `MapCSharpTypeToTypeScript` move to `TypeMappingHelpers` since multiple emitters use them. +- **Module singleton fields**: Emitted by `ModuleExtensionsEmitter`; other emitters reference them via `ModuleExtensions.s_{name}`. + +## What Doesn't Change + +- Single `[Generator]` attribute on one class +- Incremental pipeline structure (single `CompilationProvider` → single `RegisterSourceOutput`) +- All generated output filenames and content (byte-identical) +- All existing tests pass without modification +- `DiscoveryData` equatable caching behavior From b8016881b9fa5b7361eac5a63031cd2484ff4d70 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:07:56 +0100 Subject: [PATCH 006/112] Add implementation plan for splitting generator into focused emitters Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/plans/2026-03-18-split-generator-plan.md | 587 ++++++++++++++++++ 1 file changed, 587 insertions(+) create mode 100644 docs/plans/2026-03-18-split-generator-plan.md diff --git a/docs/plans/2026-03-18-split-generator-plan.md b/docs/plans/2026-03-18-split-generator-plan.md new file mode 100644 index 00000000..dee1dd67 --- /dev/null +++ b/docs/plans/2026-03-18-split-generator-plan.md @@ -0,0 +1,587 @@ +# Split Generator into Focused Emitters — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Refactor the monolithic `ModuleDiscovererGenerator` into a shared discovery layer + focused emitter classes, keeping generated output byte-identical. + +**Architecture:** Single `[Generator]` entry point calls `SymbolDiscovery.Extract(compilation)` for shared discovery, then iterates an `IEmitter[]` array to dispatch to focused emitter classes. All model types move to `Discovery/`, all emitters to `Emitters/`, shared helpers to `Helpers/`. + +**Tech Stack:** C# / Roslyn `IIncrementalGenerator` / netstandard2.0 + +--- + +### Task 1: Run existing tests (green baseline) + +**Files:** None (verification only) + +**Step 1: Run all generator tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 2: Commit** — no commit needed, baseline only + +--- + +### Task 2: Extract model types to `Discovery/DiscoveryData.cs` + +Move all record types and mutable working types from `ModuleDiscovererGenerator.Models.cs` into a new standalone file. Change visibility from `private` (nested in partial class) to `internal` (top-level in namespace). + +**Files:** +- Create: `framework/SimpleModule.Generator/Discovery/DiscoveryData.cs` +- Delete: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs` + +**Step 1: Create `Discovery/DiscoveryData.cs`** + +Copy all types from `ModuleDiscovererGenerator.Models.cs` into new file. Key changes: +- Remove the `partial class ModuleDiscovererGenerator` wrapper +- Change all `private` access modifiers to `internal` +- Add `namespace SimpleModule.Generator;` (file-scoped) +- Keep all `readonly record struct` types and mutable `sealed class` types + +```csharp +// Discovery/DiscoveryData.cs +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace SimpleModule.Generator; + +#region Equatable data model for incremental caching + +// These record types implement value equality so the incremental generator +// pipeline can detect when the extracted data hasn't changed and skip +// re-generating source files. + +internal readonly record struct DiscoveryData( + ImmutableArray Modules, + ImmutableArray DtoTypes, + ImmutableArray DbContexts, + ImmutableArray EntityConfigs +) +{ + public static readonly DiscoveryData Empty = new( + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty + ); + + public bool Equals(DiscoveryData other) + { + return Modules.SequenceEqual(other.Modules) + && DtoTypes.SequenceEqual(other.DtoTypes) + && DbContexts.SequenceEqual(other.DbContexts) + && EntityConfigs.SequenceEqual(other.EntityConfigs); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + foreach (var m in Modules) + hash = hash * 31 + m.GetHashCode(); + foreach (var d in DtoTypes) + hash = hash * 31 + d.GetHashCode(); + foreach (var c in DbContexts) + hash = hash * 31 + c.GetHashCode(); + foreach (var e in EntityConfigs) + hash = hash * 31 + e.GetHashCode(); + return hash; + } + } +} + +// ... all other record structs and mutable classes with `internal` visibility +#endregion +``` + +**Step 2: Delete `ModuleDiscovererGenerator.Models.cs`** + +**Step 3: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add -A framework/SimpleModule.Generator/Discovery/ framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs +git commit -m "refactor: extract generator model types to Discovery/DiscoveryData.cs" +``` + +--- + +### Task 3: Extract shared helpers to `Helpers/TypeMappingHelpers.cs` + +Move `GetModuleFieldName`, `MapCSharpTypeToTypeScript`, and `GetModuleNameFromFqn` out of the emitters partial class into a standalone static helper class. + +**Files:** +- Create: `framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove the three methods + +**Step 1: Create `Helpers/TypeMappingHelpers.cs`** + +```csharp +using System; +using System.Collections.Generic; + +namespace SimpleModule.Generator; + +internal static class TypeMappingHelpers +{ + internal static string GetModuleFieldName(string fullyQualifiedName) + { + var name = fullyQualifiedName.Replace("global::", "").Replace(".", "_"); + return $"s_{name}"; + } + + internal static string GetModuleNameFromFqn(string fqn) + { + var name = fqn.Replace("global::", ""); + var parts = name.Split('.'); + return parts.Length >= 3 ? parts[1] : parts[0]; + } + + internal static string MapCSharpTypeToTypeScript( + string typeFqn, + Dictionary? knownDtoTypes = null + ) + { + // ... exact copy of existing method body + } +} +``` + +**Step 2: Update all call sites** in `ModuleDiscovererGenerator.Emitters.cs` to use `TypeMappingHelpers.GetModuleFieldName(...)`, `TypeMappingHelpers.MapCSharpTypeToTypeScript(...)`, `TypeMappingHelpers.GetModuleNameFromFqn(...)`. Remove the three methods from the partial class. + +**Step 3: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add framework/SimpleModule.Generator/Helpers/ framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract shared helpers to TypeMappingHelpers" +``` + +--- + +### Task 4: Extract symbol discovery to `Discovery/SymbolDiscovery.cs` + +Move `ExtractDiscoveryData` and all `Find*` methods + helper methods (`ImplementsInterface`, `DeclaresMethod`, `InheritsFrom`, `HasComponentBaseDescendant`, `FindClosestModuleName`) from `ModuleDiscovererGenerator.cs` into a standalone static class. + +**Files:** +- Create: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs` — remove all methods except `Initialize`, update call to `SymbolDiscovery.Extract` + +**Step 1: Create `Discovery/SymbolDiscovery.cs`** + +```csharp +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static class SymbolDiscovery +{ + internal static DiscoveryData Extract(Compilation compilation) + { + // ... exact copy of ExtractDiscoveryData body + } + + // ... all Find* methods, ImplementsInterface, DeclaresMethod, + // InheritsFrom, HasComponentBaseDescendant, FindClosestModuleName + // all as `internal static` or `private static` +} +``` + +**Step 2: Update `ModuleDiscovererGenerator.Initialize`** + +Change: `static (compilation, _) => ExtractDiscoveryData(compilation)` +To: `static (compilation, _) => SymbolDiscovery.Extract(compilation)` + +**Step 3: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +git commit -m "refactor: extract symbol discovery to SymbolDiscovery static class" +``` + +--- + +### Task 5: Create `IEmitter` interface + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/IEmitter.cs` + +**Step 1: Create the interface** + +```csharp +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal interface IEmitter +{ + void Emit(SourceProductionContext context, DiscoveryData data); +} +``` + +**Step 2: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/IEmitter.cs +git commit -m "refactor: add IEmitter interface for generator dispatch" +``` + +--- + +### Task 6: Extract `DiagnosticEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `ReportDiscoveryDiagnostics` and all 7 `DiagnosticDescriptor` fields + +**Step 1: Create `Emitters/DiagnosticEmitter.cs`** + +Move `ReportDiscoveryDiagnostics` as the `Emit` method. Move all 7 `DiagnosticDescriptor` static fields. Note: `DuplicateDbSetPropertyName` (SM0001) is used by `HostDbContextEmitter` — make it `internal static` so `HostDbContextEmitter` can reference it. + +```csharp +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal sealed class DiagnosticEmitter : IEmitter +{ + internal static readonly DiagnosticDescriptor DuplicateDbSetPropertyName = new(...); + private static readonly DiagnosticDescriptor EmptyModuleName = new(...); + // ... other descriptors + + public void Emit(SourceProductionContext context, DiscoveryData data) + { + // ... exact copy of ReportDiscoveryDiagnostics body + } +} +``` + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract DiagnosticEmitter" +``` + +--- + +### Task 7: Extract `ModuleExtensionsEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `GenerateModuleExtensions` + +**Step 1: Create the emitter** + +```csharp +internal sealed class ModuleExtensionsEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + // Call existing logic with data.Modules, data.DtoTypes.Length > 0 + // Use TypeMappingHelpers.GetModuleFieldName(...) + } +} +``` + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract ModuleExtensionsEmitter" +``` + +--- + +### Task 8: Extract `EndpointExtensionsEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `GenerateEndpointExtensions` + +**Step 1: Create the emitter** — move `GenerateEndpointExtensions` logic into `Emit`. Uses `TypeMappingHelpers.GetModuleFieldName`. + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract EndpointExtensionsEmitter" +``` + +--- + +### Task 9: Extract `MenuExtensionsEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/MenuExtensionsEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `GenerateMenuExtensions` + +**Step 1: Create the emitter** + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/MenuExtensionsEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract MenuExtensionsEmitter" +``` + +--- + +### Task 10: Extract `RazorComponentExtensionsEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/RazorComponentExtensionsEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `GenerateRazorComponentExtensions` + +**Step 1: Create the emitter** + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/RazorComponentExtensionsEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract RazorComponentExtensionsEmitter" +``` + +--- + +### Task 11: Extract `ViewPagesEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/ViewPagesEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `GenerateViewPages` + +**Step 1: Create the emitter** + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/ViewPagesEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract ViewPagesEmitter" +``` + +--- + +### Task 12: Extract `JsonResolverEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/JsonResolverEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `GenerateJsonResolver` + +**Step 1: Create the emitter** — conditionally emits only when `data.DtoTypes.Length > 0` + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/JsonResolverEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract JsonResolverEmitter" +``` + +--- + +### Task 13: Extract `TypeScriptDefinitionsEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `GenerateTypeScriptDefinitions` + +**Step 1: Create the emitter** — conditionally emits only when `data.DtoTypes.Length > 0`. Uses `TypeMappingHelpers.MapCSharpTypeToTypeScript` and `TypeMappingHelpers.GetModuleNameFromFqn`. + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract TypeScriptDefinitionsEmitter" +``` + +--- + +### Task 14: Extract `HostDbContextEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `EmitHostDbContext` (this should now be the last method in the file) + +**Step 1: Create the emitter** — conditionally emits only when `data.DbContexts.Length > 0`. References `DiagnosticEmitter.DuplicateDbSetPropertyName` for SM0001. Keep `#pragma warning disable CA1308`. + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract HostDbContextEmitter" +``` + +--- + +### Task 15: Delete the old emitters partial file and wire up `ModuleDiscoveryGenerator.cs` + +At this point `ModuleDiscovererGenerator.Emitters.cs` should be empty (no methods left). Delete it and update the main generator to use the new emitter array. + +**Files:** +- Delete: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs` — replace inline dispatch with emitter array + +**Step 1: Update `ModuleDiscovererGenerator.cs`** + +The class should no longer be `partial`. Replace the `RegisterSourceOutput` callback: + +```csharp +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +[Generator] +public class ModuleDiscovererGenerator : IIncrementalGenerator +{ + private static readonly IEmitter[] Emitters = + [ + new DiagnosticEmitter(), + new ModuleExtensionsEmitter(), + new EndpointExtensionsEmitter(), + new MenuExtensionsEmitter(), + new RazorComponentExtensionsEmitter(), + new ViewPagesEmitter(), + new JsonResolverEmitter(), + new TypeScriptDefinitionsEmitter(), + new HostDbContextEmitter(), + ]; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var dataProvider = context.CompilationProvider.Select( + static (compilation, _) => SymbolDiscovery.Extract(compilation) + ); + + context.RegisterSourceOutput( + dataProvider, + static (spc, data) => + { + if (data.Modules.Length == 0) + return; + + foreach (var emitter in Emitters) + { + emitter.Emit(spc, data); + } + } + ); + } +} +``` + +**Step 2: Delete `ModuleDiscovererGenerator.Emitters.cs`** + +**Step 3: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add framework/SimpleModule.Generator/ +git commit -m "refactor: wire up emitter dispatch and delete old partial files" +``` + +--- + +### Task 16: Full build and test verification + +**Step 1: Clean build** + +Run: `dotnet build` +Expected: Build succeeds with no errors + +**Step 2: Run all tests (not just generator tests)** + +Run: `dotnet test` +Expected: All tests pass + +**Step 3: Verify final file structure** + +``` +framework/SimpleModule.Generator/ +├── ModuleDiscovererGenerator.cs +├── Discovery/ +│ ├── DiscoveryData.cs +│ └── SymbolDiscovery.cs +├── Emitters/ +│ ├── IEmitter.cs +│ ├── DiagnosticEmitter.cs +│ ├── ModuleExtensionsEmitter.cs +│ ├── EndpointExtensionsEmitter.cs +│ ├── MenuExtensionsEmitter.cs +│ ├── RazorComponentExtensionsEmitter.cs +│ ├── ViewPagesEmitter.cs +│ ├── JsonResolverEmitter.cs +│ ├── TypeScriptDefinitionsEmitter.cs +│ └── HostDbContextEmitter.cs +├── Helpers/ +│ └── TypeMappingHelpers.cs +└── IsExternalInit.cs +``` + +**Step 4: Commit** — no additional commit needed if all prior commits are clean From c29463dcdfce55319c6ab9b830dffbae5b290104 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:11:27 +0100 Subject: [PATCH 007/112] refactor: extract model types to Discovery/DiscoveryData.cs Move all record structs and mutable working classes from the nested ModuleDiscovererGenerator partial class into a standalone file. Types are now internal instead of private since they are no longer nested. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Discovery/DiscoveryData.cs | 252 +++++++++++++++++ .../ModuleDiscovererGenerator.Models.cs | 255 ------------------ 2 files changed, 252 insertions(+), 255 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/DiscoveryData.cs delete mode 100644 framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs new file mode 100644 index 00000000..37836997 --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs @@ -0,0 +1,252 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace SimpleModule.Generator; + +#region Equatable data model for incremental caching + +// These record types implement value equality so the incremental generator +// pipeline can detect when the extracted data hasn't changed and skip +// re-generating source files. + +internal readonly record struct DiscoveryData( + ImmutableArray Modules, + ImmutableArray DtoTypes, + ImmutableArray DbContexts, + ImmutableArray EntityConfigs +) +{ + public static readonly DiscoveryData Empty = new( + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty + ); + + public bool Equals(DiscoveryData other) + { + return Modules.SequenceEqual(other.Modules) + && DtoTypes.SequenceEqual(other.DtoTypes) + && DbContexts.SequenceEqual(other.DbContexts) + && EntityConfigs.SequenceEqual(other.EntityConfigs); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + foreach (var m in Modules) + hash = hash * 31 + m.GetHashCode(); + foreach (var d in DtoTypes) + hash = hash * 31 + d.GetHashCode(); + foreach (var c in DbContexts) + hash = hash * 31 + c.GetHashCode(); + foreach (var e in EntityConfigs) + hash = hash * 31 + e.GetHashCode(); + return hash; + } + } +} + +internal readonly record struct ModuleInfoRecord( + string FullyQualifiedName, + string ModuleName, + bool HasConfigureServices, + bool HasConfigureEndpoints, + bool HasConfigureMenu, + bool HasRazorComponents, + string RoutePrefix, + string ViewPrefix, + ImmutableArray Endpoints, + ImmutableArray Views +) +{ + public bool Equals(ModuleInfoRecord other) + { + return FullyQualifiedName == other.FullyQualifiedName + && ModuleName == other.ModuleName + && HasConfigureServices == other.HasConfigureServices + && HasConfigureEndpoints == other.HasConfigureEndpoints + && HasConfigureMenu == other.HasConfigureMenu + && HasRazorComponents == other.HasRazorComponents + && RoutePrefix == other.RoutePrefix + && ViewPrefix == other.ViewPrefix + && Endpoints.SequenceEqual(other.Endpoints) + && Views.SequenceEqual(other.Views); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + FullyQualifiedName.GetHashCode(); + hash = hash * 31 + (ModuleName ?? "").GetHashCode(); + hash = hash * 31 + HasConfigureServices.GetHashCode(); + hash = hash * 31 + HasConfigureEndpoints.GetHashCode(); + hash = hash * 31 + HasConfigureMenu.GetHashCode(); + hash = hash * 31 + HasRazorComponents.GetHashCode(); + hash = hash * 31 + (RoutePrefix ?? "").GetHashCode(); + hash = hash * 31 + (ViewPrefix ?? "").GetHashCode(); + foreach (var e in Endpoints) + hash = hash * 31 + e.GetHashCode(); + foreach (var v in Views) + hash = hash * 31 + v.GetHashCode(); + return hash; + } + } +} + +internal readonly record struct EndpointInfoRecord(string FullyQualifiedName); + +internal readonly record struct ViewInfoRecord(string FullyQualifiedName, string Page); + +internal readonly record struct DtoTypeInfoRecord( + string FullyQualifiedName, + string SafeName, + ImmutableArray Properties +) +{ + public bool Equals(DtoTypeInfoRecord other) + { + return FullyQualifiedName == other.FullyQualifiedName + && SafeName == other.SafeName + && Properties.SequenceEqual(other.Properties); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + FullyQualifiedName.GetHashCode(); + hash = hash * 31 + SafeName.GetHashCode(); + foreach (var p in Properties) + hash = hash * 31 + p.GetHashCode(); + return hash; + } + } +} + +internal readonly record struct DtoPropertyInfoRecord( + string Name, + string TypeFqn, + bool HasSetter +); + +internal readonly record struct DbContextInfoRecord( + string FullyQualifiedName, + string ModuleName, + bool IsIdentityDbContext, + string IdentityUserTypeFqn, + string IdentityRoleTypeFqn, + string IdentityKeyTypeFqn, + ImmutableArray DbSets +) +{ + public bool Equals(DbContextInfoRecord other) + { + return FullyQualifiedName == other.FullyQualifiedName + && ModuleName == other.ModuleName + && IsIdentityDbContext == other.IsIdentityDbContext + && IdentityUserTypeFqn == other.IdentityUserTypeFqn + && IdentityRoleTypeFqn == other.IdentityRoleTypeFqn + && IdentityKeyTypeFqn == other.IdentityKeyTypeFqn + && DbSets.SequenceEqual(other.DbSets); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + FullyQualifiedName.GetHashCode(); + hash = hash * 31 + (ModuleName ?? "").GetHashCode(); + hash = hash * 31 + IsIdentityDbContext.GetHashCode(); + hash = hash * 31 + (IdentityUserTypeFqn ?? "").GetHashCode(); + hash = hash * 31 + (IdentityRoleTypeFqn ?? "").GetHashCode(); + hash = hash * 31 + (IdentityKeyTypeFqn ?? "").GetHashCode(); + foreach (var d in DbSets) + hash = hash * 31 + d.GetHashCode(); + return hash; + } + } +} + +internal readonly record struct DbSetInfoRecord(string PropertyName, string EntityFqn); + +internal readonly record struct EntityConfigInfoRecord( + string ConfigFqn, + string EntityFqn, + string ModuleName +); + +#endregion + +#region Mutable working types (used during symbol traversal only) + +internal sealed class ModuleInfo +{ + public string FullyQualifiedName { get; set; } = ""; + public string ModuleName { get; set; } = ""; + public bool HasConfigureServices { get; set; } + public bool HasConfigureEndpoints { get; set; } + public bool HasConfigureMenu { get; set; } + public bool HasRazorComponents { get; set; } + public string RoutePrefix { get; set; } = ""; + public string ViewPrefix { get; set; } = ""; + public List Endpoints { get; set; } = new(); + public List Views { get; set; } = new(); +} + +internal sealed class EndpointInfo +{ + public string FullyQualifiedName { get; set; } = ""; +} + +internal sealed class ViewInfo +{ + public string FullyQualifiedName { get; set; } = ""; + public string Page { get; set; } = ""; +} + +internal sealed class DtoTypeInfo +{ + public string FullyQualifiedName { get; set; } = ""; + public string SafeName { get; set; } = ""; + public List Properties { get; set; } = new(); +} + +internal sealed class DtoPropertyInfo +{ + public string Name { get; set; } = ""; + public string TypeFqn { get; set; } = ""; + public bool HasSetter { get; set; } +} + +internal sealed class DbContextInfo +{ + public string FullyQualifiedName { get; set; } = ""; + public string ModuleName { get; set; } = ""; + public bool IsIdentityDbContext { get; set; } + public string IdentityUserTypeFqn { get; set; } = ""; + public string IdentityRoleTypeFqn { get; set; } = ""; + public string IdentityKeyTypeFqn { get; set; } = ""; + public List DbSets { get; set; } = new(); +} + +internal sealed class DbSetInfo +{ + public string PropertyName { get; set; } = ""; + public string EntityFqn { get; set; } = ""; +} + +internal sealed class EntityConfigInfo +{ + public string ConfigFqn { get; set; } = ""; + public string EntityFqn { get; set; } = ""; + public string ModuleName { get; set; } = ""; +} + +#endregion diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs deleted file mode 100644 index 880b6ba6..00000000 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs +++ /dev/null @@ -1,255 +0,0 @@ -using System.Collections.Generic; -using System.Collections.Immutable; - -namespace SimpleModule.Generator; - -public partial class ModuleDiscovererGenerator -{ - #region Equatable data model for incremental caching - - // These record types implement value equality so the incremental generator - // pipeline can detect when the extracted data hasn't changed and skip - // re-generating source files. - - private readonly record struct DiscoveryData( - ImmutableArray Modules, - ImmutableArray DtoTypes, - ImmutableArray DbContexts, - ImmutableArray EntityConfigs - ) - { - public static readonly DiscoveryData Empty = new( - ImmutableArray.Empty, - ImmutableArray.Empty, - ImmutableArray.Empty, - ImmutableArray.Empty - ); - - public bool Equals(DiscoveryData other) - { - return Modules.SequenceEqual(other.Modules) - && DtoTypes.SequenceEqual(other.DtoTypes) - && DbContexts.SequenceEqual(other.DbContexts) - && EntityConfigs.SequenceEqual(other.EntityConfigs); - } - - public override int GetHashCode() - { - unchecked - { - var hash = 17; - foreach (var m in Modules) - hash = hash * 31 + m.GetHashCode(); - foreach (var d in DtoTypes) - hash = hash * 31 + d.GetHashCode(); - foreach (var c in DbContexts) - hash = hash * 31 + c.GetHashCode(); - foreach (var e in EntityConfigs) - hash = hash * 31 + e.GetHashCode(); - return hash; - } - } - } - - private readonly record struct ModuleInfoRecord( - string FullyQualifiedName, - string ModuleName, - bool HasConfigureServices, - bool HasConfigureEndpoints, - bool HasConfigureMenu, - bool HasRazorComponents, - string RoutePrefix, - string ViewPrefix, - ImmutableArray Endpoints, - ImmutableArray Views - ) - { - public bool Equals(ModuleInfoRecord other) - { - return FullyQualifiedName == other.FullyQualifiedName - && ModuleName == other.ModuleName - && HasConfigureServices == other.HasConfigureServices - && HasConfigureEndpoints == other.HasConfigureEndpoints - && HasConfigureMenu == other.HasConfigureMenu - && HasRazorComponents == other.HasRazorComponents - && RoutePrefix == other.RoutePrefix - && ViewPrefix == other.ViewPrefix - && Endpoints.SequenceEqual(other.Endpoints) - && Views.SequenceEqual(other.Views); - } - - public override int GetHashCode() - { - unchecked - { - var hash = 17; - hash = hash * 31 + FullyQualifiedName.GetHashCode(); - hash = hash * 31 + (ModuleName ?? "").GetHashCode(); - hash = hash * 31 + HasConfigureServices.GetHashCode(); - hash = hash * 31 + HasConfigureEndpoints.GetHashCode(); - hash = hash * 31 + HasConfigureMenu.GetHashCode(); - hash = hash * 31 + HasRazorComponents.GetHashCode(); - hash = hash * 31 + (RoutePrefix ?? "").GetHashCode(); - hash = hash * 31 + (ViewPrefix ?? "").GetHashCode(); - foreach (var e in Endpoints) - hash = hash * 31 + e.GetHashCode(); - foreach (var v in Views) - hash = hash * 31 + v.GetHashCode(); - return hash; - } - } - } - - private readonly record struct EndpointInfoRecord(string FullyQualifiedName); - - private readonly record struct ViewInfoRecord(string FullyQualifiedName, string Page); - - private readonly record struct DtoTypeInfoRecord( - string FullyQualifiedName, - string SafeName, - ImmutableArray Properties - ) - { - public bool Equals(DtoTypeInfoRecord other) - { - return FullyQualifiedName == other.FullyQualifiedName - && SafeName == other.SafeName - && Properties.SequenceEqual(other.Properties); - } - - public override int GetHashCode() - { - unchecked - { - var hash = 17; - hash = hash * 31 + FullyQualifiedName.GetHashCode(); - hash = hash * 31 + SafeName.GetHashCode(); - foreach (var p in Properties) - hash = hash * 31 + p.GetHashCode(); - return hash; - } - } - } - - private readonly record struct DtoPropertyInfoRecord( - string Name, - string TypeFqn, - bool HasSetter - ); - - private readonly record struct DbContextInfoRecord( - string FullyQualifiedName, - string ModuleName, - bool IsIdentityDbContext, - string IdentityUserTypeFqn, - string IdentityRoleTypeFqn, - string IdentityKeyTypeFqn, - ImmutableArray DbSets - ) - { - public bool Equals(DbContextInfoRecord other) - { - return FullyQualifiedName == other.FullyQualifiedName - && ModuleName == other.ModuleName - && IsIdentityDbContext == other.IsIdentityDbContext - && IdentityUserTypeFqn == other.IdentityUserTypeFqn - && IdentityRoleTypeFqn == other.IdentityRoleTypeFqn - && IdentityKeyTypeFqn == other.IdentityKeyTypeFqn - && DbSets.SequenceEqual(other.DbSets); - } - - public override int GetHashCode() - { - unchecked - { - var hash = 17; - hash = hash * 31 + FullyQualifiedName.GetHashCode(); - hash = hash * 31 + (ModuleName ?? "").GetHashCode(); - hash = hash * 31 + IsIdentityDbContext.GetHashCode(); - hash = hash * 31 + (IdentityUserTypeFqn ?? "").GetHashCode(); - hash = hash * 31 + (IdentityRoleTypeFqn ?? "").GetHashCode(); - hash = hash * 31 + (IdentityKeyTypeFqn ?? "").GetHashCode(); - foreach (var d in DbSets) - hash = hash * 31 + d.GetHashCode(); - return hash; - } - } - } - - private readonly record struct DbSetInfoRecord(string PropertyName, string EntityFqn); - - private readonly record struct EntityConfigInfoRecord( - string ConfigFqn, - string EntityFqn, - string ModuleName - ); - - #endregion - - #region Mutable working types (used during symbol traversal only) - - private sealed class ModuleInfo - { - public string FullyQualifiedName { get; set; } = ""; - public string ModuleName { get; set; } = ""; - public bool HasConfigureServices { get; set; } - public bool HasConfigureEndpoints { get; set; } - public bool HasConfigureMenu { get; set; } - public bool HasRazorComponents { get; set; } - public string RoutePrefix { get; set; } = ""; - public string ViewPrefix { get; set; } = ""; - public List Endpoints { get; set; } = new(); - public List Views { get; set; } = new(); - } - - private sealed class EndpointInfo - { - public string FullyQualifiedName { get; set; } = ""; - } - - private sealed class ViewInfo - { - public string FullyQualifiedName { get; set; } = ""; - public string Page { get; set; } = ""; - } - - private sealed class DtoTypeInfo - { - public string FullyQualifiedName { get; set; } = ""; - public string SafeName { get; set; } = ""; - public List Properties { get; set; } = new(); - } - - private sealed class DtoPropertyInfo - { - public string Name { get; set; } = ""; - public string TypeFqn { get; set; } = ""; - public bool HasSetter { get; set; } - } - - private sealed class DbContextInfo - { - public string FullyQualifiedName { get; set; } = ""; - public string ModuleName { get; set; } = ""; - public bool IsIdentityDbContext { get; set; } - public string IdentityUserTypeFqn { get; set; } = ""; - public string IdentityRoleTypeFqn { get; set; } = ""; - public string IdentityKeyTypeFqn { get; set; } = ""; - public List DbSets { get; set; } = new(); - } - - private sealed class DbSetInfo - { - public string PropertyName { get; set; } = ""; - public string EntityFqn { get; set; } = ""; - } - - private sealed class EntityConfigInfo - { - public string ConfigFqn { get; set; } = ""; - public string EntityFqn { get; set; } = ""; - public string ModuleName { get; set; } = ""; - } - - #endregion -} From 220f9d668ff6068ff04a7e657ec7bacac919562d Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:13:20 +0100 Subject: [PATCH 008/112] refactor: extract shared helpers to Helpers/TypeMappingHelpers.cs Move GetModuleFieldName, MapCSharpTypeToTypeScript, and GetModuleNameFromFqn into a dedicated static helper class. Update all call sites in the emitters to use the new qualified names. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Helpers/TypeMappingHelpers.cs | 90 +++++++++++++++++ .../ModuleDiscovererGenerator.Emitters.cs | 96 ++----------------- 2 files changed, 96 insertions(+), 90 deletions(-) create mode 100644 framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs diff --git a/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs b/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs new file mode 100644 index 00000000..e56b1e48 --- /dev/null +++ b/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs @@ -0,0 +1,90 @@ +using System; + +namespace SimpleModule.Generator; + +internal static class TypeMappingHelpers +{ + internal static string GetModuleFieldName(string fullyQualifiedName) + { + var name = fullyQualifiedName.Replace("global::", "").Replace(".", "_"); + return $"s_{name}"; + } + + internal static string MapCSharpTypeToTypeScript( + string typeFqn, + System.Collections.Generic.Dictionary? knownDtoTypes = null + ) + { + var type = typeFqn.Replace("global::", ""); + + // Nullable -> T | null + if ( + type.StartsWith("System.Nullable<", StringComparison.Ordinal) + && type.EndsWith(">", StringComparison.Ordinal) + ) + { + var inner = type.Substring( + "System.Nullable<".Length, + type.Length - "System.Nullable<".Length - 1 + ); + return MapCSharpTypeToTypeScript("global::" + inner, knownDtoTypes) + " | null"; + } + + // Collection types + if ( + type.StartsWith("System.Collections.Generic.List<", StringComparison.Ordinal) + || type.StartsWith("System.Collections.Generic.IList<", StringComparison.Ordinal) + || type.StartsWith("System.Collections.Generic.IEnumerable<", StringComparison.Ordinal) + || type.StartsWith( + "System.Collections.Generic.IReadOnlyList<", + StringComparison.Ordinal + ) + || type.StartsWith("System.Collections.Generic.ICollection<", StringComparison.Ordinal) + ) + { + var start = type.IndexOf('<') + 1; + var inner = type.Substring(start, type.Length - start - 1); + return MapCSharpTypeToTypeScript("global::" + inner, knownDtoTypes) + "[]"; + } + + // Check if this is a known [Dto] type + if (knownDtoTypes is not null && knownDtoTypes.TryGetValue(typeFqn, out var tsName)) + return tsName; + + // Also try with global:: prefix + if ( + knownDtoTypes is not null + && knownDtoTypes.TryGetValue("global::" + type, out var tsName2) + ) + return tsName2; + + return type switch + { + "string" or "System.String" => "string", + "int" or "System.Int32" => "number", + "long" or "System.Int64" => "number", + "short" or "System.Int16" => "number", + "byte" or "System.Byte" => "number", + "float" or "System.Single" => "number", + "double" or "System.Double" => "number", + "decimal" or "System.Decimal" => "number", + "bool" or "System.Boolean" => "boolean", + "System.DateTime" + or "System.DateTimeOffset" + or "System.DateOnly" + or "System.TimeOnly" => "string", + "System.Guid" => "string", + _ => "any", + }; + } + + internal static string GetModuleNameFromFqn(string fqn) + { + // "global::SimpleModule.Products.Contracts.Product" -> "Products" + var name = fqn.Replace("global::", ""); + var parts = name.Split('.'); + // Convention: SimpleModule.{ModuleName}.Contracts.{TypeName} + // Return the second segment (module name) + return parts.Length >= 3 ? parts[1] : parts[0]; + } +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs index 42006b93..c72828c5 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs @@ -220,7 +220,7 @@ bool hasDtoTypes // Generate shared module instances foreach (var module in modules) { - var fieldName = GetModuleFieldName(module.FullyQualifiedName); + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); sb.AppendLine( $" internal static readonly {module.FullyQualifiedName} {fieldName} = new();" ); @@ -234,7 +234,7 @@ bool hasDtoTypes foreach (var module in modules.Where(m => m.HasConfigureServices)) { - var fieldName = GetModuleFieldName(module.FullyQualifiedName); + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); sb.AppendLine($" {fieldName}.ConfigureServices(services, configuration);"); } @@ -349,7 +349,7 @@ ImmutableArray modules // Manual ConfigureEndpoints (escape hatch) foreach (var module in modules.Where(m => m.HasConfigureEndpoints)) { - var fieldName = GetModuleFieldName(module.FullyQualifiedName); + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); sb.AppendLine(); sb.AppendLine($" ModuleExtensions.{fieldName}.ConfigureEndpoints(app);"); } @@ -384,7 +384,7 @@ ImmutableArray modules foreach (var module in modules.Where(m => m.HasConfigureMenu)) { - var fieldName = GetModuleFieldName(module.FullyQualifiedName); + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); sb.AppendLine($" ModuleExtensions.{fieldName}.ConfigureMenu(menus);"); } @@ -502,7 +502,7 @@ ImmutableArray dtoTypes >(); foreach (var dto in dtoTypes) { - var moduleName = GetModuleNameFromFqn(dto.FullyQualifiedName); + var moduleName = TypeMappingHelpers.GetModuleNameFromFqn(dto.FullyQualifiedName); if (!moduleGroups.TryGetValue(moduleName, out var list)) { list = new System.Collections.Generic.List(); @@ -530,7 +530,7 @@ ImmutableArray dtoTypes sb.AppendLine($"export interface {shortName} {{"); foreach (var prop in dto.Properties) { - var tsType = MapCSharpTypeToTypeScript(prop.TypeFqn, knownDtoTypes); + var tsType = TypeMappingHelpers.MapCSharpTypeToTypeScript(prop.TypeFqn, knownDtoTypes); var camelName = char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1); sb.AppendLine($" {camelName}: {tsType};"); } @@ -548,16 +548,6 @@ ImmutableArray dtoTypes } } - private static string GetModuleNameFromFqn(string fqn) - { - // "global::SimpleModule.Products.Contracts.Product" -> "Products" - var name = fqn.Replace("global::", ""); - var parts = name.Split('.'); - // Convention: SimpleModule.{ModuleName}.Contracts.{TypeName} - // Return the second segment (module name) - return parts.Length >= 3 ? parts[1] : parts[0]; - } - private static void GenerateViewPages( SourceProductionContext context, ImmutableArray modules @@ -655,74 +645,6 @@ ImmutableArray modules ); } - private static string MapCSharpTypeToTypeScript( - string typeFqn, - System.Collections.Generic.Dictionary? knownDtoTypes = null - ) - { - var type = typeFqn.Replace("global::", ""); - - // Nullable -> T | null - if ( - type.StartsWith("System.Nullable<", StringComparison.Ordinal) - && type.EndsWith(">", StringComparison.Ordinal) - ) - { - var inner = type.Substring( - "System.Nullable<".Length, - type.Length - "System.Nullable<".Length - 1 - ); - return MapCSharpTypeToTypeScript("global::" + inner, knownDtoTypes) + " | null"; - } - - // Collection types - if ( - type.StartsWith("System.Collections.Generic.List<", StringComparison.Ordinal) - || type.StartsWith("System.Collections.Generic.IList<", StringComparison.Ordinal) - || type.StartsWith("System.Collections.Generic.IEnumerable<", StringComparison.Ordinal) - || type.StartsWith( - "System.Collections.Generic.IReadOnlyList<", - StringComparison.Ordinal - ) - || type.StartsWith("System.Collections.Generic.ICollection<", StringComparison.Ordinal) - ) - { - var start = type.IndexOf('<') + 1; - var inner = type.Substring(start, type.Length - start - 1); - return MapCSharpTypeToTypeScript("global::" + inner, knownDtoTypes) + "[]"; - } - - // Check if this is a known [Dto] type - if (knownDtoTypes is not null && knownDtoTypes.TryGetValue(typeFqn, out var tsName)) - return tsName; - - // Also try with global:: prefix - if ( - knownDtoTypes is not null - && knownDtoTypes.TryGetValue("global::" + type, out var tsName2) - ) - return tsName2; - - return type switch - { - "string" or "System.String" => "string", - "int" or "System.Int32" => "number", - "long" or "System.Int64" => "number", - "short" or "System.Int16" => "number", - "byte" or "System.Byte" => "number", - "float" or "System.Single" => "number", - "double" or "System.Double" => "number", - "decimal" or "System.Decimal" => "number", - "bool" or "System.Boolean" => "boolean", - "System.DateTime" - or "System.DateTimeOffset" - or "System.DateOnly" - or "System.TimeOnly" => "string", - "System.Guid" => "string", - _ => "any", - }; - } - #pragma warning disable CA1308 // Schema names are conventionally lowercase in PostgreSQL/SQL Server private static void EmitHostDbContext(SourceProductionContext context, DiscoveryData data) { @@ -989,10 +911,4 @@ string ModuleName context.AddSource("HostDbContext.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); } #pragma warning restore CA1308 - - private static string GetModuleFieldName(string fullyQualifiedName) - { - var name = fullyQualifiedName.Replace("global::", "").Replace(".", "_"); - return $"s_{name}"; - } } From 8b6db1cd1ce94eeaff3053dbe7e95cc557bf1c45 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:14:51 +0100 Subject: [PATCH 009/112] refactor: extract symbol discovery to Discovery/SymbolDiscovery.cs Move ExtractDiscoveryData (renamed to Extract) and all Find*/helper methods into a dedicated SymbolDiscovery static class. The generator entry point now only contains the Initialize method. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Discovery/SymbolDiscovery.cs | 640 ++++++++++++++++++ .../ModuleDiscovererGenerator.cs | 636 +---------------- 2 files changed, 641 insertions(+), 635 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs new file mode 100644 index 00000000..96253223 --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -0,0 +1,640 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static class SymbolDiscovery +{ + internal static DiscoveryData Extract(Compilation compilation) + { + var moduleAttributeSymbol = compilation.GetTypeByMetadataName( + "SimpleModule.Core.ModuleAttribute" + ); + if (moduleAttributeSymbol is null) + return DiscoveryData.Empty; + + var dtoAttributeSymbol = compilation.GetTypeByMetadataName( + "SimpleModule.Core.DtoAttribute" + ); + + var endpointInterfaceSymbol = compilation.GetTypeByMetadataName( + "SimpleModule.Core.IEndpoint" + ); + + var viewEndpointInterfaceSymbol = compilation.GetTypeByMetadataName( + "SimpleModule.Core.IViewEndpoint" + ); + + var modules = new List(); + + foreach (var reference in compilation.References) + { + if ( + compilation.GetAssemblyOrModuleSymbol(reference) + is not IAssemblySymbol assemblySymbol + ) + continue; + + FindModuleTypes(assemblySymbol.GlobalNamespace, moduleAttributeSymbol, modules); + } + + FindModuleTypes(compilation.Assembly.GlobalNamespace, moduleAttributeSymbol, modules); + + if (modules.Count == 0) + return DiscoveryData.Empty; + + // Discover IEndpoint implementors per module assembly. + // Classification is by interface type: IViewEndpoint -> view, IEndpoint -> API. + if (endpointInterfaceSymbol is not null) + { + foreach (var module in modules) + { + var metadataName = module.FullyQualifiedName.Replace("global::", ""); + var typeSymbol = compilation.GetTypeByMetadataName(metadataName); + if (typeSymbol is null) + continue; + + var assembly = typeSymbol.ContainingAssembly; + FindEndpointTypes( + assembly.GlobalNamespace, + endpointInterfaceSymbol, + viewEndpointInterfaceSymbol, + module.ModuleName, + module.Endpoints, + module.Views + ); + } + } + + // Discover DbContext subclasses and IEntityTypeConfiguration per module assembly. + // Scan each assembly once, then match DbContexts/configs to the nearest module by namespace. + var dbContexts = new List(); + var entityConfigs = new List(); + var scannedAssemblies = new HashSet(SymbolEqualityComparer.Default); + foreach (var module in modules) + { + var metadataName = module.FullyQualifiedName.Replace("global::", ""); + var typeSymbol = compilation.GetTypeByMetadataName(metadataName); + if (typeSymbol is null) + continue; + + var assembly = typeSymbol.ContainingAssembly; + if (!scannedAssemblies.Add(assembly)) + continue; + + // Collect unmatched items from this assembly + var rawDbContexts = new List(); + var rawEntityConfigs = new List(); + FindDbContextTypes(assembly.GlobalNamespace, "", rawDbContexts); + FindEntityConfigTypes(assembly.GlobalNamespace, "", rawEntityConfigs); + + // Match each DbContext to the module whose namespace is closest + foreach (var ctx in rawDbContexts) + { + var ctxNs = ctx.FullyQualifiedName.Replace("global::", ""); + ctx.ModuleName = FindClosestModuleName(ctxNs, modules); + dbContexts.Add(ctx); + } + + foreach (var cfg in rawEntityConfigs) + { + var cfgNs = cfg.ConfigFqn.Replace("global::", ""); + cfg.ModuleName = FindClosestModuleName(cfgNs, modules); + entityConfigs.Add(cfg); + } + } + + var dtoTypes = new List(); + if (dtoAttributeSymbol is not null) + { + foreach (var reference in compilation.References) + { + if ( + compilation.GetAssemblyOrModuleSymbol(reference) + is not IAssemblySymbol assemblySymbol + ) + continue; + + FindDtoTypes(assemblySymbol.GlobalNamespace, dtoAttributeSymbol, dtoTypes); + } + + FindDtoTypes(compilation.Assembly.GlobalNamespace, dtoAttributeSymbol, dtoTypes); + } + + var componentBaseSymbol = compilation.GetTypeByMetadataName( + "Microsoft.AspNetCore.Components.ComponentBase" + ); + if (componentBaseSymbol is not null) + { + var assembliesWithComponents = new HashSet( + SymbolEqualityComparer.Default + ); + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol asm) + continue; + + if (HasComponentBaseDescendant(asm.GlobalNamespace, componentBaseSymbol)) + assembliesWithComponents.Add(asm); + } + + foreach (var module in modules) + { + var metadataName = module.FullyQualifiedName.Replace("global::", ""); + var typeSymbol = compilation.GetTypeByMetadataName(metadataName); + if ( + typeSymbol is not null + && assembliesWithComponents.Contains(typeSymbol.ContainingAssembly) + ) + module.HasRazorComponents = true; + } + } + + return new DiscoveryData( + modules + .Select(m => new ModuleInfoRecord( + m.FullyQualifiedName, + m.ModuleName, + m.HasConfigureServices, + m.HasConfigureEndpoints, + m.HasConfigureMenu, + m.HasRazorComponents, + m.RoutePrefix, + m.ViewPrefix, + m.Endpoints.Select(e => new EndpointInfoRecord(e.FullyQualifiedName)) + .ToImmutableArray(), + m.Views.Select(v => new ViewInfoRecord(v.FullyQualifiedName, v.Page)) + .ToImmutableArray() + )) + .ToImmutableArray(), + dtoTypes + .Select(d => new DtoTypeInfoRecord( + d.FullyQualifiedName, + d.SafeName, + d.Properties.Select(p => new DtoPropertyInfoRecord( + p.Name, + p.TypeFqn, + p.HasSetter + )) + .ToImmutableArray() + )) + .ToImmutableArray(), + dbContexts + .Select(c => new DbContextInfoRecord( + c.FullyQualifiedName, + c.ModuleName, + c.IsIdentityDbContext, + c.IdentityUserTypeFqn, + c.IdentityRoleTypeFqn, + c.IdentityKeyTypeFqn, + c.DbSets.Select(d => new DbSetInfoRecord(d.PropertyName, d.EntityFqn)) + .ToImmutableArray() + )) + .ToImmutableArray(), + entityConfigs + .Select(e => new EntityConfigInfoRecord(e.ConfigFqn, e.EntityFqn, e.ModuleName)) + .ToImmutableArray() + ); + } + + private static void FindModuleTypes( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol moduleAttributeSymbol, + List modules + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + FindModuleTypes(childNamespace, moduleAttributeSymbol, modules); + } + else if (member is INamedTypeSymbol typeSymbol) + { + foreach (var attr in typeSymbol.GetAttributes()) + { + if ( + SymbolEqualityComparer.Default.Equals( + attr.AttributeClass, + moduleAttributeSymbol + ) + ) + { + var moduleName = + attr.ConstructorArguments.Length > 0 + ? attr.ConstructorArguments[0].Value as string ?? "" + : ""; + var routePrefix = ""; + var viewPrefix = ""; + foreach (var namedArg in attr.NamedArguments) + { + if ( + namedArg.Key == "RoutePrefix" + && namedArg.Value.Value is string prefix + ) + { + routePrefix = prefix; + } + else if ( + namedArg.Key == "ViewPrefix" + && namedArg.Value.Value is string vPrefix + ) + { + viewPrefix = vPrefix; + } + } + + modules.Add( + new ModuleInfo + { + FullyQualifiedName = typeSymbol.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ), + ModuleName = moduleName, + HasConfigureServices = DeclaresMethod( + typeSymbol, + "ConfigureServices" + ), + HasConfigureEndpoints = DeclaresMethod( + typeSymbol, + "ConfigureEndpoints" + ), + HasConfigureMenu = DeclaresMethod(typeSymbol, "ConfigureMenu"), + RoutePrefix = routePrefix, + ViewPrefix = viewPrefix, + } + ); + break; + } + } + } + } + } + + private static void FindEndpointTypes( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol endpointInterfaceSymbol, + INamedTypeSymbol? viewEndpointInterfaceSymbol, + string moduleName, + List endpoints, + List views + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + FindEndpointTypes( + childNamespace, + endpointInterfaceSymbol, + viewEndpointInterfaceSymbol, + moduleName, + endpoints, + views + ); + } + else if (member is INamedTypeSymbol typeSymbol) + { + if (!typeSymbol.IsAbstract && !typeSymbol.IsStatic) + { + var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + if ( + viewEndpointInterfaceSymbol is not null + && ImplementsInterface(typeSymbol, viewEndpointInterfaceSymbol) + ) + { + var className = typeSymbol.Name; + if (className.EndsWith("Endpoint", StringComparison.Ordinal)) + className = className.Substring( + 0, + className.Length - "Endpoint".Length + ); + else if (className.EndsWith("View", StringComparison.Ordinal)) + className = className.Substring(0, className.Length - "View".Length); + + views.Add( + new ViewInfo + { + FullyQualifiedName = fqn, + Page = moduleName + "/" + className, + } + ); + } + else if (ImplementsInterface(typeSymbol, endpointInterfaceSymbol)) + { + endpoints.Add(new EndpointInfo { FullyQualifiedName = fqn }); + } + } + } + } + } + + private static bool ImplementsInterface( + INamedTypeSymbol typeSymbol, + INamedTypeSymbol interfaceSymbol + ) + { + foreach (var iface in typeSymbol.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(iface, interfaceSymbol)) + return true; + } + return false; + } + + private static bool DeclaresMethod(INamedTypeSymbol typeSymbol, string methodName) + { + foreach (var member in typeSymbol.GetMembers(methodName)) + { + if (member is IMethodSymbol) + return true; + } + return false; + } + + private static void FindDtoTypes( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol dtoAttributeSymbol, + List dtoTypes + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + FindDtoTypes(childNamespace, dtoAttributeSymbol, dtoTypes); + } + else if (member is INamedTypeSymbol typeSymbol) + { + foreach (var attr in typeSymbol.GetAttributes()) + { + if ( + SymbolEqualityComparer.Default.Equals( + attr.AttributeClass, + dtoAttributeSymbol + ) + ) + { + var fqn = typeSymbol.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ); + var safeName = fqn.Replace("global::", "").Replace(".", "_"); + + var properties = new List(); + foreach (var m in typeSymbol.GetMembers()) + { + if ( + m is IPropertySymbol prop + && prop.DeclaredAccessibility == Accessibility.Public + && !prop.IsStatic + && !prop.IsIndexer + && prop.GetMethod is not null + ) + { + properties.Add( + new DtoPropertyInfo + { + Name = prop.Name, + TypeFqn = prop.Type.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ), + HasSetter = + prop.SetMethod is not null + && prop.SetMethod.DeclaredAccessibility + == Accessibility.Public, + } + ); + } + } + + dtoTypes.Add( + new DtoTypeInfo + { + FullyQualifiedName = fqn, + SafeName = safeName, + Properties = properties, + } + ); + break; + } + } + } + } + } + + private static bool HasComponentBaseDescendant( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol componentBaseSymbol + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + if (HasComponentBaseDescendant(childNamespace, componentBaseSymbol)) + return true; + } + else if (member is INamedTypeSymbol typeSymbol) + { + if (InheritsFrom(typeSymbol, componentBaseSymbol)) + return true; + } + } + return false; + } + + private static bool InheritsFrom(INamedTypeSymbol typeSymbol, INamedTypeSymbol baseType) + { + var current = typeSymbol.BaseType; + while (current is not null) + { + if (SymbolEqualityComparer.Default.Equals(current, baseType)) + return true; + current = current.BaseType; + } + return false; + } + + private static string FindClosestModuleName(string typeFqn, List modules) + { + // Match by longest shared namespace prefix between the type and each module class. + var bestMatch = ""; + var bestLength = -1; + foreach (var module in modules) + { + var moduleFqn = module.FullyQualifiedName.Replace("global::", ""); + var moduleNs = moduleFqn.Contains(".") + ? moduleFqn.Substring(0, moduleFqn.LastIndexOf('.')) + : ""; + + if ( + typeFqn.StartsWith(moduleNs, StringComparison.Ordinal) + && moduleNs.Length > bestLength + ) + { + bestLength = moduleNs.Length; + bestMatch = module.ModuleName; + } + } + + return bestMatch.Length > 0 ? bestMatch : modules[0].ModuleName; + } + + private static void FindDbContextTypes( + INamespaceSymbol namespaceSymbol, + string moduleName, + List dbContexts + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + FindDbContextTypes(childNamespace, moduleName, dbContexts); + } + else if ( + member is INamedTypeSymbol typeSymbol + && !typeSymbol.IsAbstract + && !typeSymbol.IsStatic + ) + { + // Walk base type chain looking for DbContext + var isDbContext = false; + var isIdentity = false; + string identityUserFqn = ""; + string identityRoleFqn = ""; + string identityKeyFqn = ""; + + var current = typeSymbol.BaseType; + while (current is not null) + { + var baseFqn = current.OriginalDefinition.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ); + + if ( + baseFqn + == "global::Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext" + ) + { + isDbContext = true; + isIdentity = true; + if (current.TypeArguments.Length >= 3) + { + identityUserFqn = current + .TypeArguments[0] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + identityRoleFqn = current + .TypeArguments[1] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + identityKeyFqn = current + .TypeArguments[2] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + break; + } + + if (baseFqn == "global::Microsoft.EntityFrameworkCore.DbContext") + { + isDbContext = true; + break; + } + + current = current.BaseType; + } + + if (!isDbContext) + continue; + + var info = new DbContextInfo + { + FullyQualifiedName = typeSymbol.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ), + ModuleName = moduleName, + IsIdentityDbContext = isIdentity, + IdentityUserTypeFqn = identityUserFqn, + IdentityRoleTypeFqn = identityRoleFqn, + IdentityKeyTypeFqn = identityKeyFqn, + }; + + // Collect DbSet properties + foreach (var m in typeSymbol.GetMembers()) + { + if ( + m is IPropertySymbol prop + && prop.DeclaredAccessibility == Accessibility.Public + && !prop.IsStatic + && prop.Type is INamedTypeSymbol propType + && propType.IsGenericType + && propType.OriginalDefinition.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ) == "global::Microsoft.EntityFrameworkCore.DbSet" + ) + { + var entityFqn = propType + .TypeArguments[0] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + info.DbSets.Add( + new DbSetInfo { PropertyName = prop.Name, EntityFqn = entityFqn } + ); + } + } + + dbContexts.Add(info); + } + } + } + + private static void FindEntityConfigTypes( + INamespaceSymbol namespaceSymbol, + string moduleName, + List entityConfigs + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + FindEntityConfigTypes(childNamespace, moduleName, entityConfigs); + } + else if ( + member is INamedTypeSymbol typeSymbol + && !typeSymbol.IsAbstract + && !typeSymbol.IsStatic + ) + { + foreach (var iface in typeSymbol.AllInterfaces) + { + if ( + iface.IsGenericType + && iface.OriginalDefinition.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ) + == "global::Microsoft.EntityFrameworkCore.IEntityTypeConfiguration" + ) + { + var entityFqn = iface + .TypeArguments[0] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + entityConfigs.Add( + new EntityConfigInfo + { + ConfigFqn = typeSymbol.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ), + EntityFqn = entityFqn, + ModuleName = moduleName, + } + ); + break; + } + } + } + } + } +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index 24cd2384..e0929ae6 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; using Microsoft.CodeAnalysis; namespace SimpleModule.Generator; @@ -14,7 +10,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Extract an equatable data model from the compilation so the incremental // pipeline can cache results and skip re-generation when nothing changes. var dataProvider = context.CompilationProvider.Select( - static (compilation, _) => ExtractDiscoveryData(compilation) + static (compilation, _) => SymbolDiscovery.Extract(compilation) ); context.RegisterSourceOutput( @@ -45,634 +41,4 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } ); } - - private static DiscoveryData ExtractDiscoveryData(Compilation compilation) - { - var moduleAttributeSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.ModuleAttribute" - ); - if (moduleAttributeSymbol is null) - return DiscoveryData.Empty; - - var dtoAttributeSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.DtoAttribute" - ); - - var endpointInterfaceSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.IEndpoint" - ); - - var viewEndpointInterfaceSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.IViewEndpoint" - ); - - var modules = new List(); - - foreach (var reference in compilation.References) - { - if ( - compilation.GetAssemblyOrModuleSymbol(reference) - is not IAssemblySymbol assemblySymbol - ) - continue; - - FindModuleTypes(assemblySymbol.GlobalNamespace, moduleAttributeSymbol, modules); - } - - FindModuleTypes(compilation.Assembly.GlobalNamespace, moduleAttributeSymbol, modules); - - if (modules.Count == 0) - return DiscoveryData.Empty; - - // Discover IEndpoint implementors per module assembly. - // Classification is by interface type: IViewEndpoint -> view, IEndpoint -> API. - if (endpointInterfaceSymbol is not null) - { - foreach (var module in modules) - { - var metadataName = module.FullyQualifiedName.Replace("global::", ""); - var typeSymbol = compilation.GetTypeByMetadataName(metadataName); - if (typeSymbol is null) - continue; - - var assembly = typeSymbol.ContainingAssembly; - FindEndpointTypes( - assembly.GlobalNamespace, - endpointInterfaceSymbol, - viewEndpointInterfaceSymbol, - module.ModuleName, - module.Endpoints, - module.Views - ); - } - } - - // Discover DbContext subclasses and IEntityTypeConfiguration per module assembly. - // Scan each assembly once, then match DbContexts/configs to the nearest module by namespace. - var dbContexts = new List(); - var entityConfigs = new List(); - var scannedAssemblies = new HashSet(SymbolEqualityComparer.Default); - foreach (var module in modules) - { - var metadataName = module.FullyQualifiedName.Replace("global::", ""); - var typeSymbol = compilation.GetTypeByMetadataName(metadataName); - if (typeSymbol is null) - continue; - - var assembly = typeSymbol.ContainingAssembly; - if (!scannedAssemblies.Add(assembly)) - continue; - - // Collect unmatched items from this assembly - var rawDbContexts = new List(); - var rawEntityConfigs = new List(); - FindDbContextTypes(assembly.GlobalNamespace, "", rawDbContexts); - FindEntityConfigTypes(assembly.GlobalNamespace, "", rawEntityConfigs); - - // Match each DbContext to the module whose namespace is closest - foreach (var ctx in rawDbContexts) - { - var ctxNs = ctx.FullyQualifiedName.Replace("global::", ""); - ctx.ModuleName = FindClosestModuleName(ctxNs, modules); - dbContexts.Add(ctx); - } - - foreach (var cfg in rawEntityConfigs) - { - var cfgNs = cfg.ConfigFqn.Replace("global::", ""); - cfg.ModuleName = FindClosestModuleName(cfgNs, modules); - entityConfigs.Add(cfg); - } - } - - var dtoTypes = new List(); - if (dtoAttributeSymbol is not null) - { - foreach (var reference in compilation.References) - { - if ( - compilation.GetAssemblyOrModuleSymbol(reference) - is not IAssemblySymbol assemblySymbol - ) - continue; - - FindDtoTypes(assemblySymbol.GlobalNamespace, dtoAttributeSymbol, dtoTypes); - } - - FindDtoTypes(compilation.Assembly.GlobalNamespace, dtoAttributeSymbol, dtoTypes); - } - - var componentBaseSymbol = compilation.GetTypeByMetadataName( - "Microsoft.AspNetCore.Components.ComponentBase" - ); - if (componentBaseSymbol is not null) - { - var assembliesWithComponents = new HashSet( - SymbolEqualityComparer.Default - ); - foreach (var reference in compilation.References) - { - if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol asm) - continue; - - if (HasComponentBaseDescendant(asm.GlobalNamespace, componentBaseSymbol)) - assembliesWithComponents.Add(asm); - } - - foreach (var module in modules) - { - var metadataName = module.FullyQualifiedName.Replace("global::", ""); - var typeSymbol = compilation.GetTypeByMetadataName(metadataName); - if ( - typeSymbol is not null - && assembliesWithComponents.Contains(typeSymbol.ContainingAssembly) - ) - module.HasRazorComponents = true; - } - } - - return new DiscoveryData( - modules - .Select(m => new ModuleInfoRecord( - m.FullyQualifiedName, - m.ModuleName, - m.HasConfigureServices, - m.HasConfigureEndpoints, - m.HasConfigureMenu, - m.HasRazorComponents, - m.RoutePrefix, - m.ViewPrefix, - m.Endpoints.Select(e => new EndpointInfoRecord(e.FullyQualifiedName)) - .ToImmutableArray(), - m.Views.Select(v => new ViewInfoRecord(v.FullyQualifiedName, v.Page)) - .ToImmutableArray() - )) - .ToImmutableArray(), - dtoTypes - .Select(d => new DtoTypeInfoRecord( - d.FullyQualifiedName, - d.SafeName, - d.Properties.Select(p => new DtoPropertyInfoRecord( - p.Name, - p.TypeFqn, - p.HasSetter - )) - .ToImmutableArray() - )) - .ToImmutableArray(), - dbContexts - .Select(c => new DbContextInfoRecord( - c.FullyQualifiedName, - c.ModuleName, - c.IsIdentityDbContext, - c.IdentityUserTypeFqn, - c.IdentityRoleTypeFqn, - c.IdentityKeyTypeFqn, - c.DbSets.Select(d => new DbSetInfoRecord(d.PropertyName, d.EntityFqn)) - .ToImmutableArray() - )) - .ToImmutableArray(), - entityConfigs - .Select(e => new EntityConfigInfoRecord(e.ConfigFqn, e.EntityFqn, e.ModuleName)) - .ToImmutableArray() - ); - } - - private static void FindModuleTypes( - INamespaceSymbol namespaceSymbol, - INamedTypeSymbol moduleAttributeSymbol, - List modules - ) - { - foreach (var member in namespaceSymbol.GetMembers()) - { - if (member is INamespaceSymbol childNamespace) - { - FindModuleTypes(childNamespace, moduleAttributeSymbol, modules); - } - else if (member is INamedTypeSymbol typeSymbol) - { - foreach (var attr in typeSymbol.GetAttributes()) - { - if ( - SymbolEqualityComparer.Default.Equals( - attr.AttributeClass, - moduleAttributeSymbol - ) - ) - { - var moduleName = - attr.ConstructorArguments.Length > 0 - ? attr.ConstructorArguments[0].Value as string ?? "" - : ""; - var routePrefix = ""; - var viewPrefix = ""; - foreach (var namedArg in attr.NamedArguments) - { - if ( - namedArg.Key == "RoutePrefix" - && namedArg.Value.Value is string prefix - ) - { - routePrefix = prefix; - } - else if ( - namedArg.Key == "ViewPrefix" - && namedArg.Value.Value is string vPrefix - ) - { - viewPrefix = vPrefix; - } - } - - modules.Add( - new ModuleInfo - { - FullyQualifiedName = typeSymbol.ToDisplayString( - SymbolDisplayFormat.FullyQualifiedFormat - ), - ModuleName = moduleName, - HasConfigureServices = DeclaresMethod( - typeSymbol, - "ConfigureServices" - ), - HasConfigureEndpoints = DeclaresMethod( - typeSymbol, - "ConfigureEndpoints" - ), - HasConfigureMenu = DeclaresMethod(typeSymbol, "ConfigureMenu"), - RoutePrefix = routePrefix, - ViewPrefix = viewPrefix, - } - ); - break; - } - } - } - } - } - - private static void FindEndpointTypes( - INamespaceSymbol namespaceSymbol, - INamedTypeSymbol endpointInterfaceSymbol, - INamedTypeSymbol? viewEndpointInterfaceSymbol, - string moduleName, - List endpoints, - List views - ) - { - foreach (var member in namespaceSymbol.GetMembers()) - { - if (member is INamespaceSymbol childNamespace) - { - FindEndpointTypes( - childNamespace, - endpointInterfaceSymbol, - viewEndpointInterfaceSymbol, - moduleName, - endpoints, - views - ); - } - else if (member is INamedTypeSymbol typeSymbol) - { - if (!typeSymbol.IsAbstract && !typeSymbol.IsStatic) - { - var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - if ( - viewEndpointInterfaceSymbol is not null - && ImplementsInterface(typeSymbol, viewEndpointInterfaceSymbol) - ) - { - var className = typeSymbol.Name; - if (className.EndsWith("Endpoint", StringComparison.Ordinal)) - className = className.Substring( - 0, - className.Length - "Endpoint".Length - ); - else if (className.EndsWith("View", StringComparison.Ordinal)) - className = className.Substring(0, className.Length - "View".Length); - - views.Add( - new ViewInfo - { - FullyQualifiedName = fqn, - Page = moduleName + "/" + className, - } - ); - } - else if (ImplementsInterface(typeSymbol, endpointInterfaceSymbol)) - { - endpoints.Add(new EndpointInfo { FullyQualifiedName = fqn }); - } - } - } - } - } - - private static bool ImplementsInterface( - INamedTypeSymbol typeSymbol, - INamedTypeSymbol interfaceSymbol - ) - { - foreach (var iface in typeSymbol.AllInterfaces) - { - if (SymbolEqualityComparer.Default.Equals(iface, interfaceSymbol)) - return true; - } - return false; - } - - private static bool DeclaresMethod(INamedTypeSymbol typeSymbol, string methodName) - { - foreach (var member in typeSymbol.GetMembers(methodName)) - { - if (member is IMethodSymbol) - return true; - } - return false; - } - - private static void FindDtoTypes( - INamespaceSymbol namespaceSymbol, - INamedTypeSymbol dtoAttributeSymbol, - List dtoTypes - ) - { - foreach (var member in namespaceSymbol.GetMembers()) - { - if (member is INamespaceSymbol childNamespace) - { - FindDtoTypes(childNamespace, dtoAttributeSymbol, dtoTypes); - } - else if (member is INamedTypeSymbol typeSymbol) - { - foreach (var attr in typeSymbol.GetAttributes()) - { - if ( - SymbolEqualityComparer.Default.Equals( - attr.AttributeClass, - dtoAttributeSymbol - ) - ) - { - var fqn = typeSymbol.ToDisplayString( - SymbolDisplayFormat.FullyQualifiedFormat - ); - var safeName = fqn.Replace("global::", "").Replace(".", "_"); - - var properties = new List(); - foreach (var m in typeSymbol.GetMembers()) - { - if ( - m is IPropertySymbol prop - && prop.DeclaredAccessibility == Accessibility.Public - && !prop.IsStatic - && !prop.IsIndexer - && prop.GetMethod is not null - ) - { - properties.Add( - new DtoPropertyInfo - { - Name = prop.Name, - TypeFqn = prop.Type.ToDisplayString( - SymbolDisplayFormat.FullyQualifiedFormat - ), - HasSetter = - prop.SetMethod is not null - && prop.SetMethod.DeclaredAccessibility - == Accessibility.Public, - } - ); - } - } - - dtoTypes.Add( - new DtoTypeInfo - { - FullyQualifiedName = fqn, - SafeName = safeName, - Properties = properties, - } - ); - break; - } - } - } - } - } - - private static bool HasComponentBaseDescendant( - INamespaceSymbol namespaceSymbol, - INamedTypeSymbol componentBaseSymbol - ) - { - foreach (var member in namespaceSymbol.GetMembers()) - { - if (member is INamespaceSymbol childNamespace) - { - if (HasComponentBaseDescendant(childNamespace, componentBaseSymbol)) - return true; - } - else if (member is INamedTypeSymbol typeSymbol) - { - if (InheritsFrom(typeSymbol, componentBaseSymbol)) - return true; - } - } - return false; - } - - private static bool InheritsFrom(INamedTypeSymbol typeSymbol, INamedTypeSymbol baseType) - { - var current = typeSymbol.BaseType; - while (current is not null) - { - if (SymbolEqualityComparer.Default.Equals(current, baseType)) - return true; - current = current.BaseType; - } - return false; - } - - private static string FindClosestModuleName(string typeFqn, List modules) - { - // Match by longest shared namespace prefix between the type and each module class. - var bestMatch = ""; - var bestLength = -1; - foreach (var module in modules) - { - var moduleFqn = module.FullyQualifiedName.Replace("global::", ""); - var moduleNs = moduleFqn.Contains(".") - ? moduleFqn.Substring(0, moduleFqn.LastIndexOf('.')) - : ""; - - if ( - typeFqn.StartsWith(moduleNs, StringComparison.Ordinal) - && moduleNs.Length > bestLength - ) - { - bestLength = moduleNs.Length; - bestMatch = module.ModuleName; - } - } - - return bestMatch.Length > 0 ? bestMatch : modules[0].ModuleName; - } - - private static void FindDbContextTypes( - INamespaceSymbol namespaceSymbol, - string moduleName, - List dbContexts - ) - { - foreach (var member in namespaceSymbol.GetMembers()) - { - if (member is INamespaceSymbol childNamespace) - { - FindDbContextTypes(childNamespace, moduleName, dbContexts); - } - else if ( - member is INamedTypeSymbol typeSymbol - && !typeSymbol.IsAbstract - && !typeSymbol.IsStatic - ) - { - // Walk base type chain looking for DbContext - var isDbContext = false; - var isIdentity = false; - string identityUserFqn = ""; - string identityRoleFqn = ""; - string identityKeyFqn = ""; - - var current = typeSymbol.BaseType; - while (current is not null) - { - var baseFqn = current.OriginalDefinition.ToDisplayString( - SymbolDisplayFormat.FullyQualifiedFormat - ); - - if ( - baseFqn - == "global::Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext" - ) - { - isDbContext = true; - isIdentity = true; - if (current.TypeArguments.Length >= 3) - { - identityUserFqn = current - .TypeArguments[0] - .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - identityRoleFqn = current - .TypeArguments[1] - .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - identityKeyFqn = current - .TypeArguments[2] - .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - } - break; - } - - if (baseFqn == "global::Microsoft.EntityFrameworkCore.DbContext") - { - isDbContext = true; - break; - } - - current = current.BaseType; - } - - if (!isDbContext) - continue; - - var info = new DbContextInfo - { - FullyQualifiedName = typeSymbol.ToDisplayString( - SymbolDisplayFormat.FullyQualifiedFormat - ), - ModuleName = moduleName, - IsIdentityDbContext = isIdentity, - IdentityUserTypeFqn = identityUserFqn, - IdentityRoleTypeFqn = identityRoleFqn, - IdentityKeyTypeFqn = identityKeyFqn, - }; - - // Collect DbSet properties - foreach (var m in typeSymbol.GetMembers()) - { - if ( - m is IPropertySymbol prop - && prop.DeclaredAccessibility == Accessibility.Public - && !prop.IsStatic - && prop.Type is INamedTypeSymbol propType - && propType.IsGenericType - && propType.OriginalDefinition.ToDisplayString( - SymbolDisplayFormat.FullyQualifiedFormat - ) == "global::Microsoft.EntityFrameworkCore.DbSet" - ) - { - var entityFqn = propType - .TypeArguments[0] - .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - info.DbSets.Add( - new DbSetInfo { PropertyName = prop.Name, EntityFqn = entityFqn } - ); - } - } - - dbContexts.Add(info); - } - } - } - - private static void FindEntityConfigTypes( - INamespaceSymbol namespaceSymbol, - string moduleName, - List entityConfigs - ) - { - foreach (var member in namespaceSymbol.GetMembers()) - { - if (member is INamespaceSymbol childNamespace) - { - FindEntityConfigTypes(childNamespace, moduleName, entityConfigs); - } - else if ( - member is INamedTypeSymbol typeSymbol - && !typeSymbol.IsAbstract - && !typeSymbol.IsStatic - ) - { - foreach (var iface in typeSymbol.AllInterfaces) - { - if ( - iface.IsGenericType - && iface.OriginalDefinition.ToDisplayString( - SymbolDisplayFormat.FullyQualifiedFormat - ) - == "global::Microsoft.EntityFrameworkCore.IEntityTypeConfiguration" - ) - { - var entityFqn = iface - .TypeArguments[0] - .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - entityConfigs.Add( - new EntityConfigInfo - { - ConfigFqn = typeSymbol.ToDisplayString( - SymbolDisplayFormat.FullyQualifiedFormat - ), - EntityFqn = entityFqn, - ModuleName = moduleName, - } - ); - break; - } - } - } - } - } } From 3e029c3f57e619199c877e9440adfb6e1027ff2c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:15:07 +0100 Subject: [PATCH 010/112] refactor: create IEmitter interface for generator emitters Add the IEmitter interface that will be implemented by individual emitter classes in future refactoring steps. Co-Authored-By: Claude Opus 4.6 (1M context) --- framework/SimpleModule.Generator/Emitters/IEmitter.cs | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 framework/SimpleModule.Generator/Emitters/IEmitter.cs diff --git a/framework/SimpleModule.Generator/Emitters/IEmitter.cs b/framework/SimpleModule.Generator/Emitters/IEmitter.cs new file mode 100644 index 00000000..d6d4649c --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/IEmitter.cs @@ -0,0 +1,8 @@ +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal interface IEmitter +{ + void Emit(SourceProductionContext context, DiscoveryData data); +} From 30d58620ecd4762d4b97e7bab5eedc84f8e46022 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:19:49 +0100 Subject: [PATCH 011/112] refactor: extract DiagnosticEmitter Move all 7 DiagnosticDescriptor fields and ReportDiscoveryDiagnostics into dedicated DiagnosticEmitter class implementing IEmitter. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Emitters/DiagnosticEmitter.cs | 192 ++++++++++++++++++ .../ModuleDiscovererGenerator.Emitters.cs | 191 +---------------- .../ModuleDiscovererGenerator.cs | 2 +- 3 files changed, 194 insertions(+), 191 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs new file mode 100644 index 00000000..612c5d02 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -0,0 +1,192 @@ +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal sealed class DiagnosticEmitter : IEmitter +{ + internal static readonly DiagnosticDescriptor DuplicateDbSetPropertyName = new( + id: "SM0001", + title: "Duplicate DbSet property name across modules", + messageFormat: "DbSet property name '{0}' is used by multiple modules: {1} (entity {2}) and {3} (entity {4}). Each module must use unique DbSet property names to avoid table name conflicts in the unified HostDbContext.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor EmptyModuleName = new( + id: "SM0002", + title: "Module has empty name", + messageFormat: "Module class '{0}' has an empty [Module] name. Provide a non-empty name: [Module(\"MyModule\")]. An empty name will cause broken route prefixes, schema names, and TypeScript module grouping.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor MultipleIdentityDbContexts = new( + id: "SM0003", + title: "Multiple IdentityDbContext types found", + messageFormat: "Multiple modules define an IdentityDbContext: '{0}' (module {1}) and '{2}' (module {3}). Only one module should provide Identity. The unified HostDbContext can only extend one IdentityDbContext base class.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor DbContextWithNoDbSets = new( + id: "SM0004", + title: "DbContext has no DbSet properties", + messageFormat: "DbContext '{0}' in module '{1}' has no public DbSet properties. No entities from this context will appear in the unified HostDbContext. Add DbSet properties for each entity, or remove this DbContext if it's not needed.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor IdentityDbContextBadTypeArgs = new( + id: "SM0005", + title: "IdentityDbContext has unexpected type arguments", + messageFormat: "IdentityDbContext '{0}' in module '{1}' must extend IdentityDbContext with exactly 3 type arguments, but {2} were found. Use the 3-argument form: IdentityDbContext.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor EntityConfigForMissingEntity = new( + id: "SM0006", + title: "Entity configuration targets entity not in any DbSet", + messageFormat: "IEntityTypeConfiguration<{0}> in '{1}' (module '{2}') configures an entity that is not exposed as a DbSet in any module's DbContext. Add a DbSet<{0}> property to a DbContext, or remove this configuration.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor DuplicateEntityConfiguration = new( + id: "SM0007", + title: "Duplicate entity configuration", + messageFormat: "Entity '{0}' has multiple IEntityTypeConfiguration implementations: '{1}' and '{2}'. EF Core only supports one configuration per entity type. Remove the duplicate.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public void Emit(SourceProductionContext context, DiscoveryData data) + { + // SM0002: Empty module name + foreach (var module in data.Modules) + { + if (string.IsNullOrEmpty(module.ModuleName)) + { + context.ReportDiagnostic( + Diagnostic.Create( + EmptyModuleName, + Location.None, + module.FullyQualifiedName.Replace("global::", "") + ) + ); + } + } + + // SM0004: DbContext with no DbSets + foreach (var ctx in data.DbContexts) + { + if (ctx.DbSets.Length == 0 && !ctx.IsIdentityDbContext) + { + context.ReportDiagnostic( + Diagnostic.Create( + DbContextWithNoDbSets, + Location.None, + ctx.FullyQualifiedName.Replace("global::", ""), + ctx.ModuleName + ) + ); + } + } + + // SM0003: Multiple IdentityDbContexts + DbContextInfoRecord? firstIdentity = null; + foreach (var ctx in data.DbContexts) + { + if (!ctx.IsIdentityDbContext) + continue; + + if (firstIdentity is null) + { + firstIdentity = ctx; + } + else + { + context.ReportDiagnostic( + Diagnostic.Create( + MultipleIdentityDbContexts, + Location.None, + firstIdentity.Value.FullyQualifiedName.Replace("global::", ""), + firstIdentity.Value.ModuleName, + ctx.FullyQualifiedName.Replace("global::", ""), + ctx.ModuleName + ) + ); + } + } + + // SM0005: IdentityDbContext with wrong type args + foreach (var ctx in data.DbContexts) + { + if (ctx.IsIdentityDbContext && string.IsNullOrEmpty(ctx.IdentityUserTypeFqn)) + { + context.ReportDiagnostic( + Diagnostic.Create( + IdentityDbContextBadTypeArgs, + Location.None, + ctx.FullyQualifiedName.Replace("global::", ""), + ctx.ModuleName, + 0 + ) + ); + } + } + + // SM0006: Entity config for entity not in any DbSet + var allEntityFqns = new System.Collections.Generic.HashSet(); + foreach (var ctx in data.DbContexts) + { + foreach (var dbSet in ctx.DbSets) + allEntityFqns.Add(dbSet.EntityFqn); + } + + foreach (var config in data.EntityConfigs) + { + if (!allEntityFqns.Contains(config.EntityFqn)) + { + context.ReportDiagnostic( + Diagnostic.Create( + EntityConfigForMissingEntity, + Location.None, + config.EntityFqn.Replace("global::", ""), + config.ConfigFqn.Replace("global::", ""), + config.ModuleName + ) + ); + } + } + + // SM0007: Duplicate EntityTypeConfiguration for same entity + var entityConfigOwners = new System.Collections.Generic.Dictionary(); + foreach (var config in data.EntityConfigs) + { + if (entityConfigOwners.TryGetValue(config.EntityFqn, out var existing)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DuplicateEntityConfiguration, + Location.None, + config.EntityFqn.Replace("global::", ""), + existing, + config.ConfigFqn.Replace("global::", "") + ) + ); + } + else + { + entityConfigOwners[config.EntityFqn] = config.ConfigFqn.Replace("global::", ""); + } + } + } +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs index c72828c5..d867d1b3 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs @@ -9,195 +9,6 @@ namespace SimpleModule.Generator; public partial class ModuleDiscovererGenerator { - private static readonly DiagnosticDescriptor DuplicateDbSetPropertyName = new( - id: "SM0001", - title: "Duplicate DbSet property name across modules", - messageFormat: "DbSet property name '{0}' is used by multiple modules: {1} (entity {2}) and {3} (entity {4}). Each module must use unique DbSet property names to avoid table name conflicts in the unified HostDbContext.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor EmptyModuleName = new( - id: "SM0002", - title: "Module has empty name", - messageFormat: "Module class '{0}' has an empty [Module] name. Provide a non-empty name: [Module(\"MyModule\")]. An empty name will cause broken route prefixes, schema names, and TypeScript module grouping.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor MultipleIdentityDbContexts = new( - id: "SM0003", - title: "Multiple IdentityDbContext types found", - messageFormat: "Multiple modules define an IdentityDbContext: '{0}' (module {1}) and '{2}' (module {3}). Only one module should provide Identity. The unified HostDbContext can only extend one IdentityDbContext base class.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor DbContextWithNoDbSets = new( - id: "SM0004", - title: "DbContext has no DbSet properties", - messageFormat: "DbContext '{0}' in module '{1}' has no public DbSet properties. No entities from this context will appear in the unified HostDbContext. Add DbSet properties for each entity, or remove this DbContext if it's not needed.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor IdentityDbContextBadTypeArgs = new( - id: "SM0005", - title: "IdentityDbContext has unexpected type arguments", - messageFormat: "IdentityDbContext '{0}' in module '{1}' must extend IdentityDbContext with exactly 3 type arguments, but {2} were found. Use the 3-argument form: IdentityDbContext.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor EntityConfigForMissingEntity = new( - id: "SM0006", - title: "Entity configuration targets entity not in any DbSet", - messageFormat: "IEntityTypeConfiguration<{0}> in '{1}' (module '{2}') configures an entity that is not exposed as a DbSet in any module's DbContext. Add a DbSet<{0}> property to a DbContext, or remove this configuration.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor DuplicateEntityConfiguration = new( - id: "SM0007", - title: "Duplicate entity configuration", - messageFormat: "Entity '{0}' has multiple IEntityTypeConfiguration implementations: '{1}' and '{2}'. EF Core only supports one configuration per entity type. Remove the duplicate.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static void ReportDiscoveryDiagnostics( - SourceProductionContext context, - DiscoveryData data - ) - { - // SM0002: Empty module name - foreach (var module in data.Modules) - { - if (string.IsNullOrEmpty(module.ModuleName)) - { - context.ReportDiagnostic( - Diagnostic.Create( - EmptyModuleName, - Location.None, - module.FullyQualifiedName.Replace("global::", "") - ) - ); - } - } - - // SM0004: DbContext with no DbSets - foreach (var ctx in data.DbContexts) - { - if (ctx.DbSets.Length == 0 && !ctx.IsIdentityDbContext) - { - context.ReportDiagnostic( - Diagnostic.Create( - DbContextWithNoDbSets, - Location.None, - ctx.FullyQualifiedName.Replace("global::", ""), - ctx.ModuleName - ) - ); - } - } - - // SM0003: Multiple IdentityDbContexts - DbContextInfoRecord? firstIdentity = null; - foreach (var ctx in data.DbContexts) - { - if (!ctx.IsIdentityDbContext) - continue; - - if (firstIdentity is null) - { - firstIdentity = ctx; - } - else - { - context.ReportDiagnostic( - Diagnostic.Create( - MultipleIdentityDbContexts, - Location.None, - firstIdentity.Value.FullyQualifiedName.Replace("global::", ""), - firstIdentity.Value.ModuleName, - ctx.FullyQualifiedName.Replace("global::", ""), - ctx.ModuleName - ) - ); - } - } - - // SM0005: IdentityDbContext with wrong type args - foreach (var ctx in data.DbContexts) - { - if (ctx.IsIdentityDbContext && string.IsNullOrEmpty(ctx.IdentityUserTypeFqn)) - { - context.ReportDiagnostic( - Diagnostic.Create( - IdentityDbContextBadTypeArgs, - Location.None, - ctx.FullyQualifiedName.Replace("global::", ""), - ctx.ModuleName, - 0 - ) - ); - } - } - - // SM0006: Entity config for entity not in any DbSet - var allEntityFqns = new System.Collections.Generic.HashSet(); - foreach (var ctx in data.DbContexts) - { - foreach (var dbSet in ctx.DbSets) - allEntityFqns.Add(dbSet.EntityFqn); - } - - foreach (var config in data.EntityConfigs) - { - if (!allEntityFqns.Contains(config.EntityFqn)) - { - context.ReportDiagnostic( - Diagnostic.Create( - EntityConfigForMissingEntity, - Location.None, - config.EntityFqn.Replace("global::", ""), - config.ConfigFqn.Replace("global::", ""), - config.ModuleName - ) - ); - } - } - - // SM0007: Duplicate EntityTypeConfiguration for same entity - var entityConfigOwners = new System.Collections.Generic.Dictionary(); - foreach (var config in data.EntityConfigs) - { - if (entityConfigOwners.TryGetValue(config.EntityFqn, out var existing)) - { - context.ReportDiagnostic( - Diagnostic.Create( - DuplicateEntityConfiguration, - Location.None, - config.EntityFqn.Replace("global::", ""), - existing, - config.ConfigFqn.Replace("global::", "") - ) - ); - } - else - { - entityConfigOwners[config.EntityFqn] = config.ConfigFqn.Replace("global::", ""); - } - } - } - private static void GenerateModuleExtensions( SourceProductionContext context, ImmutableArray modules, @@ -716,7 +527,7 @@ string ModuleName { context.ReportDiagnostic( Diagnostic.Create( - DuplicateDbSetPropertyName, + DiagnosticEmitter.DuplicateDbSetPropertyName, Location.None, entry.PropertyName, existing.ModuleName, diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index e0929ae6..e999cb05 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -20,7 +20,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (data.Modules.Length == 0) return; - ReportDiscoveryDiagnostics(spc, data); + new DiagnosticEmitter().Emit(spc, data); GenerateModuleExtensions(spc, data.Modules, data.DtoTypes.Length > 0); GenerateEndpointExtensions(spc, data.Modules); From 90bd27533501ec14fd6623d29dfe4d817bc19287 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:20:38 +0100 Subject: [PATCH 012/112] refactor: extract ModuleExtensionsEmitter Move GenerateModuleExtensions into dedicated emitter class. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Emitters/ModuleExtensionsEmitter.cs | 71 +++++++++++++++++++ .../ModuleDiscovererGenerator.Emitters.cs | 63 ---------------- .../ModuleDiscovererGenerator.cs | 2 +- 3 files changed, 72 insertions(+), 64 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs diff --git a/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs new file mode 100644 index 00000000..ba98f559 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs @@ -0,0 +1,71 @@ +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class ModuleExtensionsEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + var modules = data.Modules; + var hasDtoTypes = data.DtoTypes.Length > 0; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#pragma warning disable IL2026"); + sb.AppendLine("#pragma warning disable IL3050"); + sb.AppendLine("using Microsoft.AspNetCore.Http.Json;"); + sb.AppendLine("using Microsoft.Extensions.Configuration;"); + sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Core;"); + sb.AppendLine(); + sb.AppendLine("public static class ModuleExtensions"); + sb.AppendLine("{"); + + // Generate shared module instances + foreach (var module in modules) + { + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine( + $" internal static readonly {module.FullyQualifiedName} {fieldName} = new();" + ); + } + + sb.AppendLine(); + sb.AppendLine( + " public static IServiceCollection AddModules(this IServiceCollection services, IConfiguration configuration)" + ); + sb.AppendLine(" {"); + + foreach (var module in modules.Where(m => m.HasConfigureServices)) + { + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine($" {fieldName}.ConfigureServices(services, configuration);"); + } + + if (hasDtoTypes) + { + sb.AppendLine(); + sb.AppendLine(" services.ConfigureHttpJsonOptions(options =>"); + sb.AppendLine(" {"); + sb.AppendLine( + " options.SerializerOptions.TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(" + ); + sb.AppendLine(" ModulesJsonResolver.Instance,"); + sb.AppendLine( + " new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver());" + ); + sb.AppendLine(" });"); + } + + sb.AppendLine(); + sb.AppendLine(" return services;"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource("ModuleExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs index d867d1b3..febd3178 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs @@ -9,69 +9,6 @@ namespace SimpleModule.Generator; public partial class ModuleDiscovererGenerator { - private static void GenerateModuleExtensions( - SourceProductionContext context, - ImmutableArray modules, - bool hasDtoTypes - ) - { - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#pragma warning disable IL2026"); - sb.AppendLine("#pragma warning disable IL3050"); - sb.AppendLine("using Microsoft.AspNetCore.Http.Json;"); - sb.AppendLine("using Microsoft.Extensions.Configuration;"); - sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); - sb.AppendLine(); - sb.AppendLine("namespace SimpleModule.Core;"); - sb.AppendLine(); - sb.AppendLine("public static class ModuleExtensions"); - sb.AppendLine("{"); - - // Generate shared module instances - foreach (var module in modules) - { - var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); - sb.AppendLine( - $" internal static readonly {module.FullyQualifiedName} {fieldName} = new();" - ); - } - - sb.AppendLine(); - sb.AppendLine( - " public static IServiceCollection AddModules(this IServiceCollection services, IConfiguration configuration)" - ); - sb.AppendLine(" {"); - - foreach (var module in modules.Where(m => m.HasConfigureServices)) - { - var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); - sb.AppendLine($" {fieldName}.ConfigureServices(services, configuration);"); - } - - if (hasDtoTypes) - { - sb.AppendLine(); - sb.AppendLine(" services.ConfigureHttpJsonOptions(options =>"); - sb.AppendLine(" {"); - sb.AppendLine( - " options.SerializerOptions.TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(" - ); - sb.AppendLine(" ModulesJsonResolver.Instance,"); - sb.AppendLine( - " new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver());" - ); - sb.AppendLine(" });"); - } - - sb.AppendLine(); - sb.AppendLine(" return services;"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - - context.AddSource("ModuleExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); - } - private static void GenerateEndpointExtensions( SourceProductionContext context, ImmutableArray modules diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index e999cb05..00d2e4e9 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -22,7 +22,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) new DiagnosticEmitter().Emit(spc, data); - GenerateModuleExtensions(spc, data.Modules, data.DtoTypes.Length > 0); + new ModuleExtensionsEmitter().Emit(spc, data); GenerateEndpointExtensions(spc, data.Modules); GenerateMenuExtensions(spc, data.Modules); GenerateRazorComponentExtensions(spc, data.Modules); From a81f2a410ca8d50a2f2c5ee37edc2204e602b5f8 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:21:26 +0100 Subject: [PATCH 013/112] refactor: extract EndpointExtensionsEmitter Move GenerateEndpointExtensions into dedicated emitter class. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Emitters/EndpointExtensionsEmitter.cs | 109 ++++++++++++++++++ .../ModuleDiscovererGenerator.Emitters.cs | 101 ---------------- .../ModuleDiscovererGenerator.cs | 2 +- 3 files changed, 110 insertions(+), 102 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs diff --git a/framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs new file mode 100644 index 00000000..9b2ceb71 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs @@ -0,0 +1,109 @@ +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class EndpointExtensionsEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + var modules = data.Modules; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("using Microsoft.AspNetCore.Builder;"); + sb.AppendLine("using Microsoft.AspNetCore.Routing;"); + sb.AppendLine("using Microsoft.AspNetCore.Http;"); + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Core;"); + sb.AppendLine(); + sb.AppendLine("public static class EndpointExtensions"); + sb.AppendLine("{"); + sb.AppendLine( + " public static WebApplication MapModuleEndpoints(this WebApplication app)" + ); + sb.AppendLine(" {"); + + // Auto-registered endpoints (IEndpoint implementors) + // Skip modules that define ConfigureEndpoints — they manage their own registration. + foreach (var module in modules) + { + if (module.Endpoints.Length == 0 || module.HasConfigureEndpoints) + continue; + + sb.AppendLine(); + sb.AppendLine($" // Auto-registered endpoints for {module.FullyQualifiedName}"); + sb.AppendLine(" {"); + + if (!string.IsNullOrEmpty(module.RoutePrefix)) + { + sb.AppendLine( + $" var group = app.MapGroup(\"{module.RoutePrefix}\").WithTags(\"{module.ModuleName}\");" + ); + foreach (var endpoint in module.Endpoints) + { + sb.AppendLine($" new {endpoint.FullyQualifiedName}().Map(group);"); + } + } + else + { + foreach (var endpoint in module.Endpoints) + { + sb.AppendLine($" new {endpoint.FullyQualifiedName}().Map(app);"); + } + } + + sb.AppendLine(" }"); + } + + // Auto-registered view endpoints (IViewEndpoint implementors) + foreach (var module in modules) + { + if (module.Views.Length == 0) + continue; + + sb.AppendLine(); + sb.AppendLine( + $" // Auto-registered view endpoints for {module.FullyQualifiedName}" + ); + sb.AppendLine(" {"); + + if (!string.IsNullOrEmpty(module.ViewPrefix)) + { + sb.AppendLine( + $" var viewGroup = app.MapGroup(\"{module.ViewPrefix}\").WithTags(\"{module.ModuleName}\").ExcludeFromDescription();" + ); + foreach (var view in module.Views) + { + sb.AppendLine($" new {view.FullyQualifiedName}().Map(viewGroup);"); + } + } + else + { + foreach (var view in module.Views) + { + sb.AppendLine($" new {view.FullyQualifiedName}().Map(app);"); + } + } + + sb.AppendLine(" }"); + } + + // Manual ConfigureEndpoints (escape hatch) + foreach (var module in modules.Where(m => m.HasConfigureEndpoints)) + { + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine(); + sb.AppendLine($" ModuleExtensions.{fieldName}.ConfigureEndpoints(app);"); + } + + sb.AppendLine(); + sb.AppendLine(" return app;"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource("EndpointExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs index febd3178..45dc9d64 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs @@ -9,107 +9,6 @@ namespace SimpleModule.Generator; public partial class ModuleDiscovererGenerator { - private static void GenerateEndpointExtensions( - SourceProductionContext context, - ImmutableArray modules - ) - { - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("using Microsoft.AspNetCore.Builder;"); - sb.AppendLine("using Microsoft.AspNetCore.Routing;"); - sb.AppendLine("using Microsoft.AspNetCore.Http;"); - sb.AppendLine(); - sb.AppendLine("namespace SimpleModule.Core;"); - sb.AppendLine(); - sb.AppendLine("public static class EndpointExtensions"); - sb.AppendLine("{"); - sb.AppendLine( - " public static WebApplication MapModuleEndpoints(this WebApplication app)" - ); - sb.AppendLine(" {"); - - // Auto-registered endpoints (IEndpoint implementors) - // Skip modules that define ConfigureEndpoints — they manage their own registration. - foreach (var module in modules) - { - if (module.Endpoints.Length == 0 || module.HasConfigureEndpoints) - continue; - - sb.AppendLine(); - sb.AppendLine($" // Auto-registered endpoints for {module.FullyQualifiedName}"); - sb.AppendLine(" {"); - - if (!string.IsNullOrEmpty(module.RoutePrefix)) - { - sb.AppendLine( - $" var group = app.MapGroup(\"{module.RoutePrefix}\").WithTags(\"{module.ModuleName}\");" - ); - foreach (var endpoint in module.Endpoints) - { - sb.AppendLine($" new {endpoint.FullyQualifiedName}().Map(group);"); - } - } - else - { - foreach (var endpoint in module.Endpoints) - { - sb.AppendLine($" new {endpoint.FullyQualifiedName}().Map(app);"); - } - } - - sb.AppendLine(" }"); - } - - // Auto-registered view endpoints (IViewEndpoint implementors) - foreach (var module in modules) - { - if (module.Views.Length == 0) - continue; - - sb.AppendLine(); - sb.AppendLine( - $" // Auto-registered view endpoints for {module.FullyQualifiedName}" - ); - sb.AppendLine(" {"); - - if (!string.IsNullOrEmpty(module.ViewPrefix)) - { - sb.AppendLine( - $" var viewGroup = app.MapGroup(\"{module.ViewPrefix}\").WithTags(\"{module.ModuleName}\").ExcludeFromDescription();" - ); - foreach (var view in module.Views) - { - sb.AppendLine($" new {view.FullyQualifiedName}().Map(viewGroup);"); - } - } - else - { - foreach (var view in module.Views) - { - sb.AppendLine($" new {view.FullyQualifiedName}().Map(app);"); - } - } - - sb.AppendLine(" }"); - } - - // Manual ConfigureEndpoints (escape hatch) - foreach (var module in modules.Where(m => m.HasConfigureEndpoints)) - { - var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); - sb.AppendLine(); - sb.AppendLine($" ModuleExtensions.{fieldName}.ConfigureEndpoints(app);"); - } - - sb.AppendLine(); - sb.AppendLine(" return app;"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - - context.AddSource("EndpointExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); - } - private static void GenerateMenuExtensions( SourceProductionContext context, ImmutableArray modules diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index 00d2e4e9..6a3c8a91 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -23,7 +23,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) new DiagnosticEmitter().Emit(spc, data); new ModuleExtensionsEmitter().Emit(spc, data); - GenerateEndpointExtensions(spc, data.Modules); + new EndpointExtensionsEmitter().Emit(spc, data); GenerateMenuExtensions(spc, data.Modules); GenerateRazorComponentExtensions(spc, data.Modules); GenerateViewPages(spc, data.Modules); From 6b1d4e2a5f3d7559a75bbe24054c077d13af3d15 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:22:00 +0100 Subject: [PATCH 014/112] refactor: extract MenuExtensionsEmitter Move GenerateMenuExtensions into dedicated emitter class. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Emitters/MenuExtensionsEmitter.cs | 44 +++++++++++++++++++ .../ModuleDiscovererGenerator.Emitters.cs | 36 --------------- .../ModuleDiscovererGenerator.cs | 2 +- 3 files changed, 45 insertions(+), 37 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/MenuExtensionsEmitter.cs diff --git a/framework/SimpleModule.Generator/Emitters/MenuExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/MenuExtensionsEmitter.cs new file mode 100644 index 00000000..e3c1081d --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/MenuExtensionsEmitter.cs @@ -0,0 +1,44 @@ +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class MenuExtensionsEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + var modules = data.Modules; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); + sb.AppendLine("using SimpleModule.Core.Menu;"); + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Core;"); + sb.AppendLine(); + sb.AppendLine("public static class MenuExtensions"); + sb.AppendLine("{"); + sb.AppendLine( + " public static IServiceCollection CollectModuleMenuItems(this IServiceCollection services)" + ); + sb.AppendLine(" {"); + sb.AppendLine(" var menus = new MenuBuilder();"); + + foreach (var module in modules.Where(m => m.HasConfigureMenu)) + { + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine($" ModuleExtensions.{fieldName}.ConfigureMenu(menus);"); + } + + sb.AppendLine( + " services.AddSingleton(new MenuRegistry(menus.ToList()));" + ); + sb.AppendLine(" return services;"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource("MenuExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs index 45dc9d64..76ecb5ba 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs @@ -9,42 +9,6 @@ namespace SimpleModule.Generator; public partial class ModuleDiscovererGenerator { - private static void GenerateMenuExtensions( - SourceProductionContext context, - ImmutableArray modules - ) - { - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); - sb.AppendLine("using SimpleModule.Core.Menu;"); - sb.AppendLine(); - sb.AppendLine("namespace SimpleModule.Core;"); - sb.AppendLine(); - sb.AppendLine("public static class MenuExtensions"); - sb.AppendLine("{"); - sb.AppendLine( - " public static IServiceCollection CollectModuleMenuItems(this IServiceCollection services)" - ); - sb.AppendLine(" {"); - sb.AppendLine(" var menus = new MenuBuilder();"); - - foreach (var module in modules.Where(m => m.HasConfigureMenu)) - { - var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); - sb.AppendLine($" ModuleExtensions.{fieldName}.ConfigureMenu(menus);"); - } - - sb.AppendLine( - " services.AddSingleton(new MenuRegistry(menus.ToList()));" - ); - sb.AppendLine(" return services;"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - - context.AddSource("MenuExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); - } - private static void GenerateJsonResolver( SourceProductionContext context, ImmutableArray dtoTypes diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index 6a3c8a91..aa615487 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -24,7 +24,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) new ModuleExtensionsEmitter().Emit(spc, data); new EndpointExtensionsEmitter().Emit(spc, data); - GenerateMenuExtensions(spc, data.Modules); + new MenuExtensionsEmitter().Emit(spc, data); GenerateRazorComponentExtensions(spc, data.Modules); GenerateViewPages(spc, data.Modules); From f8cbc1dbb59952cf08b907343e2e685022110bc5 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:22:43 +0100 Subject: [PATCH 015/112] refactor: extract RazorComponentExtensionsEmitter Move GenerateRazorComponentExtensions into dedicated emitter class. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../RazorComponentExtensionsEmitter.cs | 50 +++++++++++++++++++ .../ModuleDiscovererGenerator.Emitters.cs | 43 ---------------- .../ModuleDiscovererGenerator.cs | 2 +- 3 files changed, 51 insertions(+), 44 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/RazorComponentExtensionsEmitter.cs diff --git a/framework/SimpleModule.Generator/Emitters/RazorComponentExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/RazorComponentExtensionsEmitter.cs new file mode 100644 index 00000000..45f58771 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/RazorComponentExtensionsEmitter.cs @@ -0,0 +1,50 @@ +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class RazorComponentExtensionsEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + var modules = data.Modules; + var razorModules = modules.Where(m => m.HasRazorComponents).ToList(); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("using Microsoft.AspNetCore.Builder;"); + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Core;"); + sb.AppendLine(); + sb.AppendLine("public static class RazorComponentExtensions"); + sb.AppendLine("{"); + sb.AppendLine( + " public static RazorComponentsEndpointConventionBuilder AddModuleAssemblies(" + ); + sb.AppendLine(" this RazorComponentsEndpointConventionBuilder builder)"); + sb.AppendLine(" {"); + + if (razorModules.Count > 0) + { + sb.AppendLine(" builder.AddAdditionalAssemblies("); + for (var i = 0; i < razorModules.Count; i++) + { + var suffix = i < razorModules.Count - 1 ? "," : ");"; + sb.AppendLine( + $" typeof({razorModules[i].FullyQualifiedName}).Assembly{suffix}" + ); + } + } + + sb.AppendLine(" return builder;"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource( + "RazorComponentExtensions.g.cs", + SourceText.From(sb.ToString(), Encoding.UTF8) + ); + } +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs index 76ecb5ba..946f8170 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs @@ -213,49 +213,6 @@ ImmutableArray modules } } - private static void GenerateRazorComponentExtensions( - SourceProductionContext context, - ImmutableArray modules - ) - { - var razorModules = modules.Where(m => m.HasRazorComponents).ToList(); - - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("using Microsoft.AspNetCore.Builder;"); - sb.AppendLine(); - sb.AppendLine("namespace SimpleModule.Core;"); - sb.AppendLine(); - sb.AppendLine("public static class RazorComponentExtensions"); - sb.AppendLine("{"); - sb.AppendLine( - " public static RazorComponentsEndpointConventionBuilder AddModuleAssemblies(" - ); - sb.AppendLine(" this RazorComponentsEndpointConventionBuilder builder)"); - sb.AppendLine(" {"); - - if (razorModules.Count > 0) - { - sb.AppendLine(" builder.AddAdditionalAssemblies("); - for (var i = 0; i < razorModules.Count; i++) - { - var suffix = i < razorModules.Count - 1 ? "," : ");"; - sb.AppendLine( - $" typeof({razorModules[i].FullyQualifiedName}).Assembly{suffix}" - ); - } - } - - sb.AppendLine(" return builder;"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - - context.AddSource( - "RazorComponentExtensions.g.cs", - SourceText.From(sb.ToString(), Encoding.UTF8) - ); - } - #pragma warning disable CA1308 // Schema names are conventionally lowercase in PostgreSQL/SQL Server private static void EmitHostDbContext(SourceProductionContext context, DiscoveryData data) { diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index aa615487..53a8170f 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -25,7 +25,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) new ModuleExtensionsEmitter().Emit(spc, data); new EndpointExtensionsEmitter().Emit(spc, data); new MenuExtensionsEmitter().Emit(spc, data); - GenerateRazorComponentExtensions(spc, data.Modules); + new RazorComponentExtensionsEmitter().Emit(spc, data); GenerateViewPages(spc, data.Modules); if (data.DtoTypes.Length > 0) From bb92bb3a95ba3cfef934dbf9700cd6d61de83888 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:23:50 +0100 Subject: [PATCH 016/112] refactor: extract ViewPagesEmitter Move GenerateViewPages into dedicated emitter class. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Emitters/ViewPagesEmitter.cs | 60 +++++++++++++++++++ .../ModuleDiscovererGenerator.Emitters.cs | 54 ----------------- .../ModuleDiscovererGenerator.cs | 2 +- 3 files changed, 61 insertions(+), 55 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/ViewPagesEmitter.cs diff --git a/framework/SimpleModule.Generator/Emitters/ViewPagesEmitter.cs b/framework/SimpleModule.Generator/Emitters/ViewPagesEmitter.cs new file mode 100644 index 00000000..e7788355 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/ViewPagesEmitter.cs @@ -0,0 +1,60 @@ +using System; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class ViewPagesEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + foreach (var module in data.Modules) + { + if (module.Views.Length == 0) + continue; + + // Extract module name from FQN (e.g., "global::SimpleModule.Products.ProductsModule" -> "Products") + var fqn = module.FullyQualifiedName.Replace("global::", ""); + var moduleName = fqn.Contains(".") ? fqn.Substring(fqn.LastIndexOf('.') + 1) : fqn; + if (moduleName.EndsWith("Module", StringComparison.Ordinal)) + moduleName = moduleName.Substring(0, moduleName.Length - "Module".Length); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#if SIMPLEMODULE_TS"); + sb.AppendLine("/*"); + + // Generate import statements — component name is the part after "/" + foreach (var view in module.Views) + { + var componentName = view.Page.Contains("/") + ? view.Page.Substring(view.Page.LastIndexOf('/') + 1) + : view.Page; + sb.AppendLine($"import {componentName} from '../Views/{componentName}';"); + } + + sb.AppendLine(); + sb.AppendLine("export const pages: Record = {"); + + for (var i = 0; i < module.Views.Length; i++) + { + var view = module.Views[i]; + var componentName = view.Page.Contains("/") + ? view.Page.Substring(view.Page.LastIndexOf('/') + 1) + : view.Page; + var comma = i < module.Views.Length - 1 ? "," : ""; + sb.AppendLine($" '{view.Page}': {componentName}{comma}"); + } + + sb.AppendLine("};"); + sb.AppendLine("*/"); + sb.AppendLine("#endif"); + + context.AddSource( + $"ViewPages_{moduleName}.g.cs", + SourceText.From(sb.ToString(), Encoding.UTF8) + ); + } + } +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs index 946f8170..1b808d09 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs @@ -159,60 +159,6 @@ ImmutableArray dtoTypes } } - private static void GenerateViewPages( - SourceProductionContext context, - ImmutableArray modules - ) - { - foreach (var module in modules) - { - if (module.Views.Length == 0) - continue; - - // Extract module name from FQN (e.g., "global::SimpleModule.Products.ProductsModule" -> "Products") - var fqn = module.FullyQualifiedName.Replace("global::", ""); - var moduleName = fqn.Contains(".") ? fqn.Substring(fqn.LastIndexOf('.') + 1) : fqn; - if (moduleName.EndsWith("Module", StringComparison.Ordinal)) - moduleName = moduleName.Substring(0, moduleName.Length - "Module".Length); - - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#if SIMPLEMODULE_TS"); - sb.AppendLine("/*"); - - // Generate import statements — component name is the part after "/" - foreach (var view in module.Views) - { - var componentName = view.Page.Contains("/") - ? view.Page.Substring(view.Page.LastIndexOf('/') + 1) - : view.Page; - sb.AppendLine($"import {componentName} from '../Views/{componentName}';"); - } - - sb.AppendLine(); - sb.AppendLine("export const pages: Record = {"); - - for (var i = 0; i < module.Views.Length; i++) - { - var view = module.Views[i]; - var componentName = view.Page.Contains("/") - ? view.Page.Substring(view.Page.LastIndexOf('/') + 1) - : view.Page; - var comma = i < module.Views.Length - 1 ? "," : ""; - sb.AppendLine($" '{view.Page}': {componentName}{comma}"); - } - - sb.AppendLine("};"); - sb.AppendLine("*/"); - sb.AppendLine("#endif"); - - context.AddSource( - $"ViewPages_{moduleName}.g.cs", - SourceText.From(sb.ToString(), Encoding.UTF8) - ); - } - } - #pragma warning disable CA1308 // Schema names are conventionally lowercase in PostgreSQL/SQL Server private static void EmitHostDbContext(SourceProductionContext context, DiscoveryData data) { diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index 53a8170f..415f5cf5 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -26,7 +26,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) new EndpointExtensionsEmitter().Emit(spc, data); new MenuExtensionsEmitter().Emit(spc, data); new RazorComponentExtensionsEmitter().Emit(spc, data); - GenerateViewPages(spc, data.Modules); + new ViewPagesEmitter().Emit(spc, data); if (data.DtoTypes.Length > 0) { From 2a23ed84bf1011e7f9167e4300fdbdf857472e97 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:26:07 +0100 Subject: [PATCH 017/112] refactor: extract JsonResolverEmitter Move GenerateJsonResolver into dedicated emitter class with early return when no DTO types exist. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Emitters/JsonResolverEmitter.cs | 90 +++++++++++ .../ModuleDiscovererGenerator.Emitters.cs | 150 ------------------ .../ModuleDiscovererGenerator.cs | 7 +- 3 files changed, 92 insertions(+), 155 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/JsonResolverEmitter.cs diff --git a/framework/SimpleModule.Generator/Emitters/JsonResolverEmitter.cs b/framework/SimpleModule.Generator/Emitters/JsonResolverEmitter.cs new file mode 100644 index 00000000..73340bee --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/JsonResolverEmitter.cs @@ -0,0 +1,90 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class JsonResolverEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + if (data.DtoTypes.Length == 0) + return; + + var dtoTypes = data.DtoTypes; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("#pragma warning disable IL2026"); + sb.AppendLine("#pragma warning disable IL3050"); + sb.AppendLine("using System;"); + sb.AppendLine("using System.Text.Json;"); + sb.AppendLine("using System.Text.Json.Serialization.Metadata;"); + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Core;"); + sb.AppendLine(); + sb.AppendLine("public sealed class ModulesJsonResolver : IJsonTypeInfoResolver"); + sb.AppendLine("{"); + sb.AppendLine(" public static readonly ModulesJsonResolver Instance = new();"); + sb.AppendLine(); + sb.AppendLine( + " public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options)" + ); + sb.AppendLine(" {"); + + foreach (var dto in dtoTypes) + { + sb.AppendLine($" if (type == typeof({dto.FullyQualifiedName}))"); + sb.AppendLine($" return Create_{dto.SafeName}(options);"); + } + + sb.AppendLine(" return null;"); + sb.AppendLine(" }"); + + foreach (var dto in dtoTypes) + { + sb.AppendLine(); + sb.AppendLine( + $" private static JsonTypeInfo Create_{dto.SafeName}(JsonSerializerOptions options)" + ); + sb.AppendLine(" {"); + sb.AppendLine( + $" var info = JsonTypeInfo.CreateJsonTypeInfo<{dto.FullyQualifiedName}>(options);" + ); + sb.AppendLine( + $" info.CreateObject = static () => new {dto.FullyQualifiedName}();" + ); + + foreach (var prop in dto.Properties) + { + var jsonName = char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1); + sb.AppendLine( + $" var prop_{prop.Name} = info.CreateJsonPropertyInfo(typeof({prop.TypeFqn}), \"{jsonName}\");" + ); + sb.AppendLine( + $" prop_{prop.Name}.Get = static obj => (({dto.FullyQualifiedName})obj).{prop.Name};" + ); + + if (prop.HasSetter) + { + sb.AppendLine( + $" prop_{prop.Name}.Set = static (obj, val) => (({dto.FullyQualifiedName})obj).{prop.Name} = ({prop.TypeFqn})val!;" + ); + } + + sb.AppendLine($" info.Properties.Add(prop_{prop.Name});"); + } + + sb.AppendLine(" return info;"); + sb.AppendLine(" }"); + } + + sb.AppendLine("}"); + + context.AddSource( + "ModulesJsonResolver.g.cs", + SourceText.From(sb.ToString(), Encoding.UTF8) + ); + } +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs index 1b808d09..4a50fde8 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs @@ -9,156 +9,6 @@ namespace SimpleModule.Generator; public partial class ModuleDiscovererGenerator { - private static void GenerateJsonResolver( - SourceProductionContext context, - ImmutableArray dtoTypes - ) - { - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#nullable enable"); - sb.AppendLine("#pragma warning disable IL2026"); - sb.AppendLine("#pragma warning disable IL3050"); - sb.AppendLine("using System;"); - sb.AppendLine("using System.Text.Json;"); - sb.AppendLine("using System.Text.Json.Serialization.Metadata;"); - sb.AppendLine(); - sb.AppendLine("namespace SimpleModule.Core;"); - sb.AppendLine(); - sb.AppendLine("public sealed class ModulesJsonResolver : IJsonTypeInfoResolver"); - sb.AppendLine("{"); - sb.AppendLine(" public static readonly ModulesJsonResolver Instance = new();"); - sb.AppendLine(); - sb.AppendLine( - " public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options)" - ); - sb.AppendLine(" {"); - - foreach (var dto in dtoTypes) - { - sb.AppendLine($" if (type == typeof({dto.FullyQualifiedName}))"); - sb.AppendLine($" return Create_{dto.SafeName}(options);"); - } - - sb.AppendLine(" return null;"); - sb.AppendLine(" }"); - - foreach (var dto in dtoTypes) - { - sb.AppendLine(); - sb.AppendLine( - $" private static JsonTypeInfo Create_{dto.SafeName}(JsonSerializerOptions options)" - ); - sb.AppendLine(" {"); - sb.AppendLine( - $" var info = JsonTypeInfo.CreateJsonTypeInfo<{dto.FullyQualifiedName}>(options);" - ); - sb.AppendLine( - $" info.CreateObject = static () => new {dto.FullyQualifiedName}();" - ); - - foreach (var prop in dto.Properties) - { - var jsonName = char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1); - sb.AppendLine( - $" var prop_{prop.Name} = info.CreateJsonPropertyInfo(typeof({prop.TypeFqn}), \"{jsonName}\");" - ); - sb.AppendLine( - $" prop_{prop.Name}.Get = static obj => (({dto.FullyQualifiedName})obj).{prop.Name};" - ); - - if (prop.HasSetter) - { - sb.AppendLine( - $" prop_{prop.Name}.Set = static (obj, val) => (({dto.FullyQualifiedName})obj).{prop.Name} = ({prop.TypeFqn})val!;" - ); - } - - sb.AppendLine($" info.Properties.Add(prop_{prop.Name});"); - } - - sb.AppendLine(" return info;"); - sb.AppendLine(" }"); - } - - sb.AppendLine("}"); - - context.AddSource( - "ModulesJsonResolver.g.cs", - SourceText.From(sb.ToString(), Encoding.UTF8) - ); - } - - private static void GenerateTypeScriptDefinitions( - SourceProductionContext context, - ImmutableArray dtoTypes - ) - { - // Build a set of known DTO FQNs so nested references resolve to their TS interface name - var knownDtoTypes = new System.Collections.Generic.Dictionary(); - foreach (var dto in dtoTypes) - { - var typeName = dto.FullyQualifiedName; - var shortName = typeName.Contains(".") - ? typeName.Substring(typeName.LastIndexOf('.') + 1) - : typeName; - shortName = shortName.Replace("global::", ""); - knownDtoTypes[dto.FullyQualifiedName] = shortName; - } - - // Group DTOs by module name (first namespace segment, e.g. "Products" from "global::Products.Contracts.Product") - var moduleGroups = new System.Collections.Generic.Dictionary< - string, - System.Collections.Generic.List - >(); - foreach (var dto in dtoTypes) - { - var moduleName = TypeMappingHelpers.GetModuleNameFromFqn(dto.FullyQualifiedName); - if (!moduleGroups.TryGetValue(moduleName, out var list)) - { - list = new System.Collections.Generic.List(); - moduleGroups[moduleName] = list; - } - list.Add(dto); - } - - // Emit one file per module - foreach (var kvp in moduleGroups) - { - var moduleName = kvp.Key; - var moduleDtos = kvp.Value; - - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#if SIMPLEMODULE_TS"); - sb.AppendLine("/*"); - sb.AppendLine($"// @module {moduleName}"); - sb.AppendLine(); - - foreach (var dto in moduleDtos) - { - var shortName = knownDtoTypes[dto.FullyQualifiedName]; - sb.AppendLine($"export interface {shortName} {{"); - foreach (var prop in dto.Properties) - { - var tsType = TypeMappingHelpers.MapCSharpTypeToTypeScript(prop.TypeFqn, knownDtoTypes); - var camelName = char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1); - sb.AppendLine($" {camelName}: {tsType};"); - } - sb.AppendLine("}"); - sb.AppendLine(); - } - - sb.AppendLine("*/"); - sb.AppendLine("#endif"); - - context.AddSource( - $"DtoTypeScript_{moduleName}.g.cs", - SourceText.From(sb.ToString(), Encoding.UTF8) - ); - } - } - #pragma warning disable CA1308 // Schema names are conventionally lowercase in PostgreSQL/SQL Server private static void EmitHostDbContext(SourceProductionContext context, DiscoveryData data) { diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index 415f5cf5..240efa3b 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -28,11 +28,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) new RazorComponentExtensionsEmitter().Emit(spc, data); new ViewPagesEmitter().Emit(spc, data); - if (data.DtoTypes.Length > 0) - { - GenerateJsonResolver(spc, data.DtoTypes); - GenerateTypeScriptDefinitions(spc, data.DtoTypes); - } + new JsonResolverEmitter().Emit(spc, data); + new TypeScriptDefinitionsEmitter().Emit(spc, data); if (data.DbContexts.Length > 0) { From 45703bdb278ee21bffd0737c007a848f10204dc3 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:26:10 +0100 Subject: [PATCH 018/112] refactor: extract TypeScriptDefinitionsEmitter Move GenerateTypeScriptDefinitions into dedicated emitter class with early return when no DTO types exist. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Emitters/TypeScriptDefinitionsEmitter.cs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs diff --git a/framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs new file mode 100644 index 00000000..beac81bc --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs @@ -0,0 +1,80 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class TypeScriptDefinitionsEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + if (data.DtoTypes.Length == 0) + return; + + var dtoTypes = data.DtoTypes; + + // Build a set of known DTO FQNs so nested references resolve to their TS interface name + var knownDtoTypes = new System.Collections.Generic.Dictionary(); + foreach (var dto in dtoTypes) + { + var typeName = dto.FullyQualifiedName; + var shortName = typeName.Contains(".") + ? typeName.Substring(typeName.LastIndexOf('.') + 1) + : typeName; + shortName = shortName.Replace("global::", ""); + knownDtoTypes[dto.FullyQualifiedName] = shortName; + } + + // Group DTOs by module name (first namespace segment, e.g. "Products" from "global::Products.Contracts.Product") + var moduleGroups = new System.Collections.Generic.Dictionary< + string, + System.Collections.Generic.List + >(); + foreach (var dto in dtoTypes) + { + var moduleName = TypeMappingHelpers.GetModuleNameFromFqn(dto.FullyQualifiedName); + if (!moduleGroups.TryGetValue(moduleName, out var list)) + { + list = new System.Collections.Generic.List(); + moduleGroups[moduleName] = list; + } + list.Add(dto); + } + + // Emit one file per module + foreach (var kvp in moduleGroups) + { + var moduleName = kvp.Key; + var moduleDtos = kvp.Value; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#if SIMPLEMODULE_TS"); + sb.AppendLine("/*"); + sb.AppendLine($"// @module {moduleName}"); + sb.AppendLine(); + + foreach (var dto in moduleDtos) + { + var shortName = knownDtoTypes[dto.FullyQualifiedName]; + sb.AppendLine($"export interface {shortName} {{"); + foreach (var prop in dto.Properties) + { + var tsType = TypeMappingHelpers.MapCSharpTypeToTypeScript(prop.TypeFqn, knownDtoTypes); + var camelName = char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1); + sb.AppendLine($" {camelName}: {tsType};"); + } + sb.AppendLine("}"); + sb.AppendLine(); + } + + sb.AppendLine("*/"); + sb.AppendLine("#endif"); + + context.AddSource( + $"DtoTypeScript_{moduleName}.g.cs", + SourceText.From(sb.ToString(), Encoding.UTF8) + ); + } + } +} From 896878de35b2ba6f82ceeff53621339b8780b2e4 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:27:28 +0100 Subject: [PATCH 019/112] refactor: extract HostDbContextEmitter Move EmitHostDbContext into dedicated emitter class with early return when no DbContexts exist. The emitters partial class is now empty. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Emitters/HostDbContextEmitter.cs | 278 ++++++++++++++++++ .../ModuleDiscovererGenerator.Emitters.cs | 273 ----------------- .../ModuleDiscovererGenerator.cs | 5 +- 3 files changed, 279 insertions(+), 277 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs diff --git a/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs new file mode 100644 index 00000000..54c4c696 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs @@ -0,0 +1,278 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class HostDbContextEmitter : IEmitter +{ +#pragma warning disable CA1308 // Schema names are conventionally lowercase in PostgreSQL/SQL Server + public void Emit(SourceProductionContext context, DiscoveryData data) + { + if (data.DbContexts.Length == 0) + return; + + // Abort if any error-level diagnostics were reported (SM0003, SM0005, SM0007) + var identityCount = 0; + var hasBadIdentityArgs = false; + foreach (var ctx in data.DbContexts) + { + if (ctx.IsIdentityDbContext) + { + identityCount++; + if (string.IsNullOrEmpty(ctx.IdentityUserTypeFqn)) + hasBadIdentityArgs = true; + } + } + + var hasDuplicateConfigs = false; + var configEntities = new System.Collections.Generic.HashSet(); + foreach (var config in data.EntityConfigs) + { + if (!configEntities.Add(config.EntityFqn)) + hasDuplicateConfigs = true; + } + + if (identityCount > 1 || hasBadIdentityArgs || hasDuplicateConfigs) + return; + + // Determine if any module uses IdentityDbContext + DbContextInfoRecord? identityContext = null; + foreach (var ctx in data.DbContexts) + { + if (ctx.IsIdentityDbContext) + { + identityContext = ctx; + break; + } + } + + // Collect all DbSet across modules, deduplicated by entity FQN + var seenEntities = new System.Collections.Generic.HashSet(); + var allDbSets = new System.Collections.Generic.List<( + string PropertyName, + string EntityFqn, + string ModuleName + )>(); + foreach (var ctx in data.DbContexts) + { + foreach (var dbSet in ctx.DbSets) + { + if (seenEntities.Add(dbSet.EntityFqn)) + { + allDbSets.Add((dbSet.PropertyName, dbSet.EntityFqn, ctx.ModuleName)); + } + } + } + + // Check for duplicate DbSet property names across modules + var propertyNameOwners = new System.Collections.Generic.Dictionary< + string, + (string EntityFqn, string ModuleName) + >(); + var hasDuplicates = false; + foreach (var entry in allDbSets) + { + if (propertyNameOwners.TryGetValue(entry.PropertyName, out var existing)) + { + // Same entity type with same property name is fine (deduplicated earlier) + if (existing.EntityFqn != entry.EntityFqn) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticEmitter.DuplicateDbSetPropertyName, + Location.None, + entry.PropertyName, + existing.ModuleName, + existing.EntityFqn.Replace("global::", ""), + entry.ModuleName, + entry.EntityFqn.Replace("global::", "") + ) + ); + hasDuplicates = true; + } + } + else + { + propertyNameOwners[entry.PropertyName] = (entry.EntityFqn, entry.ModuleName); + } + } + + if (hasDuplicates) + return; + + // Build entity-to-module mapping for schema isolation + var entityToModule = new System.Collections.Generic.Dictionary(); + foreach (var entry in allDbSets) + { + entityToModule[entry.EntityFqn] = entry.ModuleName; + } + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#pragma warning disable"); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("using Microsoft.EntityFrameworkCore;"); + sb.AppendLine("using Microsoft.Extensions.Options;"); + sb.AppendLine("using SimpleModule.Database;"); + + if (identityContext != null) + { + sb.AppendLine("using Microsoft.AspNetCore.Identity.EntityFrameworkCore;"); + } + + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Host;"); + sb.AppendLine(); + + // Class declaration with primary constructor + if (identityContext != null) + { + sb.AppendLine($"public class HostDbContext("); + sb.AppendLine(" DbContextOptions options,"); + sb.AppendLine(" IOptions dbOptions"); + sb.AppendLine($") : IdentityDbContext<{identityContext.Value.IdentityUserTypeFqn},"); + sb.AppendLine($" {identityContext.Value.IdentityRoleTypeFqn},"); + sb.AppendLine( + $" {identityContext.Value.IdentityKeyTypeFqn}>(options)" + ); + } + else + { + sb.AppendLine("public class HostDbContext("); + sb.AppendLine(" DbContextOptions options,"); + sb.AppendLine(" IOptions dbOptions"); + sb.AppendLine(") : DbContext(options)"); + } + + sb.AppendLine("{"); + + // DbSet properties + foreach (var dbSet in allDbSets) + { + sb.AppendLine($" public DbSet<{dbSet.EntityFqn}> {dbSet.PropertyName}"); + sb.AppendLine($" => Set<{dbSet.EntityFqn}>();"); + sb.AppendLine(); + } + + // OnModelCreating + sb.AppendLine(" protected override void OnModelCreating(ModelBuilder builder)"); + sb.AppendLine(" {"); + + if (identityContext != null) + { + sb.AppendLine(" base.OnModelCreating(builder);"); + sb.AppendLine(); + } + + // Apply IEntityTypeConfiguration implementations + if (data.EntityConfigs.Length > 0) + { + sb.AppendLine( + " // Apply IEntityTypeConfiguration found in module assemblies" + ); + foreach (var config in data.EntityConfigs) + { + sb.AppendLine($" builder.ApplyConfiguration(new {config.ConfigFqn}());"); + } + sb.AppendLine(); + } + + // Schema isolation (non-SQLite only) + sb.AppendLine(" // Schema isolation (PostgreSQL/SQL Server only)"); + sb.AppendLine(" var provider = DatabaseProviderDetector.Detect("); + sb.AppendLine(" dbOptions.Value.DefaultConnection, dbOptions.Value.Provider);"); + sb.AppendLine(" if (provider != DatabaseProvider.Sqlite)"); + sb.AppendLine(" {"); + + // Group entities by module for schema assignment + var moduleNames = new System.Collections.Generic.HashSet(); + foreach (var entry in allDbSets) + { + moduleNames.Add(entry.ModuleName); + } + + foreach (var entry in allDbSets) + { + var schema = entry.ModuleName.ToLowerInvariant(); + sb.AppendLine($" builder.Entity<{entry.EntityFqn}>()"); + sb.AppendLine($" .Metadata.SetSchema(\"{schema}\");"); + } + + // If there's an IdentityDbContext, assign remaining entities (Identity tables) + // to that module's schema + if (identityContext != null) + { + var identitySchema = identityContext.Value.ModuleName.ToLowerInvariant(); + sb.AppendLine(); + sb.AppendLine(" // Assign Identity tables to the identity module's schema"); + sb.AppendLine(" foreach (var entityType in builder.Model.GetEntityTypes())"); + sb.AppendLine(" {"); + sb.AppendLine(" if (entityType.GetSchema() is null)"); + sb.AppendLine($" entityType.SetSchema(\"{identitySchema}\");"); + sb.AppendLine(" }"); + } + + sb.AppendLine(" }"); + + // SQLite table prefix isolation + sb.AppendLine(" else"); + sb.AppendLine(" {"); + + // For SQLite, apply table prefixes per module + foreach (var ctx in data.DbContexts) + { + var prefix = ctx.ModuleName + "_"; + foreach (var dbSet in ctx.DbSets) + { + if (!seenEntities.Contains(dbSet.EntityFqn)) + continue; + sb.AppendLine( + $" builder.Entity<{dbSet.EntityFqn}>().ToTable(\"{prefix}\" +" + ); + sb.AppendLine( + $" (builder.Entity<{dbSet.EntityFqn}>().Metadata.GetTableName() ?? \"{dbSet.PropertyName}\"));" + ); + } + } + + // For Identity tables in SQLite, prefix them too + if (identityContext != null) + { + var identityPrefix = identityContext.Value.ModuleName + "_"; + sb.AppendLine(); + sb.AppendLine(" // Prefix Identity tables for SQLite"); + sb.AppendLine(" foreach (var entityType in builder.Model.GetEntityTypes())"); + sb.AppendLine(" {"); + sb.AppendLine(" var tableName = entityType.GetTableName();"); + sb.AppendLine( + $" if (tableName is not null && !tableName.StartsWith(\"{identityPrefix}\", global::System.StringComparison.Ordinal))" + ); + + // Check it's not already prefixed by another module + sb.AppendLine(" {"); + sb.AppendLine(" var alreadyPrefixed = false;"); + foreach (var ctx2 in data.DbContexts) + { + if (ctx2.FullyQualifiedName == identityContext.Value.FullyQualifiedName) + continue; + sb.AppendLine( + $" if (tableName.StartsWith(\"{ctx2.ModuleName}_\", global::System.StringComparison.Ordinal)) alreadyPrefixed = true;" + ); + } + sb.AppendLine( + $" if (!alreadyPrefixed) entityType.SetTableName(\"{identityPrefix}\" + tableName);" + ); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + } + + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource("HostDbContext.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } +#pragma warning restore CA1308 +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs index 4a50fde8..90525d0a 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs @@ -1,278 +1,5 @@ -using System; -using System.Collections.Immutable; -using System.Linq; -using System.Text; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - namespace SimpleModule.Generator; public partial class ModuleDiscovererGenerator { -#pragma warning disable CA1308 // Schema names are conventionally lowercase in PostgreSQL/SQL Server - private static void EmitHostDbContext(SourceProductionContext context, DiscoveryData data) - { - // Abort if any error-level diagnostics were reported (SM0003, SM0005, SM0007) - var identityCount = 0; - var hasBadIdentityArgs = false; - foreach (var ctx in data.DbContexts) - { - if (ctx.IsIdentityDbContext) - { - identityCount++; - if (string.IsNullOrEmpty(ctx.IdentityUserTypeFqn)) - hasBadIdentityArgs = true; - } - } - - var hasDuplicateConfigs = false; - var configEntities = new System.Collections.Generic.HashSet(); - foreach (var config in data.EntityConfigs) - { - if (!configEntities.Add(config.EntityFqn)) - hasDuplicateConfigs = true; - } - - if (identityCount > 1 || hasBadIdentityArgs || hasDuplicateConfigs) - return; - - // Determine if any module uses IdentityDbContext - DbContextInfoRecord? identityContext = null; - foreach (var ctx in data.DbContexts) - { - if (ctx.IsIdentityDbContext) - { - identityContext = ctx; - break; - } - } - - // Collect all DbSet across modules, deduplicated by entity FQN - var seenEntities = new System.Collections.Generic.HashSet(); - var allDbSets = new System.Collections.Generic.List<( - string PropertyName, - string EntityFqn, - string ModuleName - )>(); - foreach (var ctx in data.DbContexts) - { - foreach (var dbSet in ctx.DbSets) - { - if (seenEntities.Add(dbSet.EntityFqn)) - { - allDbSets.Add((dbSet.PropertyName, dbSet.EntityFqn, ctx.ModuleName)); - } - } - } - - // Check for duplicate DbSet property names across modules - var propertyNameOwners = new System.Collections.Generic.Dictionary< - string, - (string EntityFqn, string ModuleName) - >(); - var hasDuplicates = false; - foreach (var entry in allDbSets) - { - if (propertyNameOwners.TryGetValue(entry.PropertyName, out var existing)) - { - // Same entity type with same property name is fine (deduplicated earlier) - if (existing.EntityFqn != entry.EntityFqn) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticEmitter.DuplicateDbSetPropertyName, - Location.None, - entry.PropertyName, - existing.ModuleName, - existing.EntityFqn.Replace("global::", ""), - entry.ModuleName, - entry.EntityFqn.Replace("global::", "") - ) - ); - hasDuplicates = true; - } - } - else - { - propertyNameOwners[entry.PropertyName] = (entry.EntityFqn, entry.ModuleName); - } - } - - if (hasDuplicates) - return; - - // Build entity-to-module mapping for schema isolation - var entityToModule = new System.Collections.Generic.Dictionary(); - foreach (var entry in allDbSets) - { - entityToModule[entry.EntityFqn] = entry.ModuleName; - } - - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#pragma warning disable"); - sb.AppendLine("#nullable enable"); - sb.AppendLine(); - sb.AppendLine("using Microsoft.EntityFrameworkCore;"); - sb.AppendLine("using Microsoft.Extensions.Options;"); - sb.AppendLine("using SimpleModule.Database;"); - - if (identityContext != null) - { - sb.AppendLine("using Microsoft.AspNetCore.Identity.EntityFrameworkCore;"); - } - - sb.AppendLine(); - sb.AppendLine("namespace SimpleModule.Host;"); - sb.AppendLine(); - - // Class declaration with primary constructor - if (identityContext != null) - { - sb.AppendLine($"public class HostDbContext("); - sb.AppendLine(" DbContextOptions options,"); - sb.AppendLine(" IOptions dbOptions"); - sb.AppendLine($") : IdentityDbContext<{identityContext.Value.IdentityUserTypeFqn},"); - sb.AppendLine($" {identityContext.Value.IdentityRoleTypeFqn},"); - sb.AppendLine( - $" {identityContext.Value.IdentityKeyTypeFqn}>(options)" - ); - } - else - { - sb.AppendLine("public class HostDbContext("); - sb.AppendLine(" DbContextOptions options,"); - sb.AppendLine(" IOptions dbOptions"); - sb.AppendLine(") : DbContext(options)"); - } - - sb.AppendLine("{"); - - // DbSet properties - foreach (var dbSet in allDbSets) - { - sb.AppendLine($" public DbSet<{dbSet.EntityFqn}> {dbSet.PropertyName}"); - sb.AppendLine($" => Set<{dbSet.EntityFqn}>();"); - sb.AppendLine(); - } - - // OnModelCreating - sb.AppendLine(" protected override void OnModelCreating(ModelBuilder builder)"); - sb.AppendLine(" {"); - - if (identityContext != null) - { - sb.AppendLine(" base.OnModelCreating(builder);"); - sb.AppendLine(); - } - - // Apply IEntityTypeConfiguration implementations - if (data.EntityConfigs.Length > 0) - { - sb.AppendLine( - " // Apply IEntityTypeConfiguration found in module assemblies" - ); - foreach (var config in data.EntityConfigs) - { - sb.AppendLine($" builder.ApplyConfiguration(new {config.ConfigFqn}());"); - } - sb.AppendLine(); - } - - // Schema isolation (non-SQLite only) - sb.AppendLine(" // Schema isolation (PostgreSQL/SQL Server only)"); - sb.AppendLine(" var provider = DatabaseProviderDetector.Detect("); - sb.AppendLine(" dbOptions.Value.DefaultConnection, dbOptions.Value.Provider);"); - sb.AppendLine(" if (provider != DatabaseProvider.Sqlite)"); - sb.AppendLine(" {"); - - // Group entities by module for schema assignment - var moduleNames = new System.Collections.Generic.HashSet(); - foreach (var entry in allDbSets) - { - moduleNames.Add(entry.ModuleName); - } - - foreach (var entry in allDbSets) - { - var schema = entry.ModuleName.ToLowerInvariant(); - sb.AppendLine($" builder.Entity<{entry.EntityFqn}>()"); - sb.AppendLine($" .Metadata.SetSchema(\"{schema}\");"); - } - - // If there's an IdentityDbContext, assign remaining entities (Identity tables) - // to that module's schema - if (identityContext != null) - { - var identitySchema = identityContext.Value.ModuleName.ToLowerInvariant(); - sb.AppendLine(); - sb.AppendLine(" // Assign Identity tables to the identity module's schema"); - sb.AppendLine(" foreach (var entityType in builder.Model.GetEntityTypes())"); - sb.AppendLine(" {"); - sb.AppendLine(" if (entityType.GetSchema() is null)"); - sb.AppendLine($" entityType.SetSchema(\"{identitySchema}\");"); - sb.AppendLine(" }"); - } - - sb.AppendLine(" }"); - - // SQLite table prefix isolation - sb.AppendLine(" else"); - sb.AppendLine(" {"); - - // For SQLite, apply table prefixes per module - foreach (var ctx in data.DbContexts) - { - var prefix = ctx.ModuleName + "_"; - foreach (var dbSet in ctx.DbSets) - { - if (!seenEntities.Contains(dbSet.EntityFqn)) - continue; - sb.AppendLine( - $" builder.Entity<{dbSet.EntityFqn}>().ToTable(\"{prefix}\" +" - ); - sb.AppendLine( - $" (builder.Entity<{dbSet.EntityFqn}>().Metadata.GetTableName() ?? \"{dbSet.PropertyName}\"));" - ); - } - } - - // For Identity tables in SQLite, prefix them too - if (identityContext != null) - { - var identityPrefix = identityContext.Value.ModuleName + "_"; - sb.AppendLine(); - sb.AppendLine(" // Prefix Identity tables for SQLite"); - sb.AppendLine(" foreach (var entityType in builder.Model.GetEntityTypes())"); - sb.AppendLine(" {"); - sb.AppendLine(" var tableName = entityType.GetTableName();"); - sb.AppendLine( - $" if (tableName is not null && !tableName.StartsWith(\"{identityPrefix}\", global::System.StringComparison.Ordinal))" - ); - - // Check it's not already prefixed by another module - sb.AppendLine(" {"); - sb.AppendLine(" var alreadyPrefixed = false;"); - foreach (var ctx2 in data.DbContexts) - { - if (ctx2.FullyQualifiedName == identityContext.Value.FullyQualifiedName) - continue; - sb.AppendLine( - $" if (tableName.StartsWith(\"{ctx2.ModuleName}_\", global::System.StringComparison.Ordinal)) alreadyPrefixed = true;" - ); - } - sb.AppendLine( - $" if (!alreadyPrefixed) entityType.SetTableName(\"{identityPrefix}\" + tableName);" - ); - sb.AppendLine(" }"); - sb.AppendLine(" }"); - } - - sb.AppendLine(" }"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - - context.AddSource("HostDbContext.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); - } -#pragma warning restore CA1308 } diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index 240efa3b..4c72daa5 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -31,10 +31,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) new JsonResolverEmitter().Emit(spc, data); new TypeScriptDefinitionsEmitter().Emit(spc, data); - if (data.DbContexts.Length > 0) - { - EmitHostDbContext(spc, data); - } + new HostDbContextEmitter().Emit(spc, data); } ); } From 1116cdda387aca9449fb4e67be692f6eb852ec6f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:32:50 +0100 Subject: [PATCH 020/112] refactor: wire up emitter dispatch array and delete empty partial file Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ModuleDiscovererGenerator.Emitters.cs | 5 --- .../ModuleDiscovererGenerator.cs | 31 +++++++++++-------- 2 files changed, 18 insertions(+), 18 deletions(-) delete mode 100644 framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs deleted file mode 100644 index 90525d0a..00000000 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace SimpleModule.Generator; - -public partial class ModuleDiscovererGenerator -{ -} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index 4c72daa5..aaf2627c 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -3,8 +3,21 @@ namespace SimpleModule.Generator; [Generator] -public partial class ModuleDiscovererGenerator : IIncrementalGenerator +public class ModuleDiscovererGenerator : IIncrementalGenerator { + private static readonly IEmitter[] Emitters = + [ + new DiagnosticEmitter(), + new ModuleExtensionsEmitter(), + new EndpointExtensionsEmitter(), + new MenuExtensionsEmitter(), + new RazorComponentExtensionsEmitter(), + new ViewPagesEmitter(), + new JsonResolverEmitter(), + new TypeScriptDefinitionsEmitter(), + new HostDbContextEmitter(), + ]; + public void Initialize(IncrementalGeneratorInitializationContext context) { // Extract an equatable data model from the compilation so the incremental @@ -20,18 +33,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (data.Modules.Length == 0) return; - new DiagnosticEmitter().Emit(spc, data); - - new ModuleExtensionsEmitter().Emit(spc, data); - new EndpointExtensionsEmitter().Emit(spc, data); - new MenuExtensionsEmitter().Emit(spc, data); - new RazorComponentExtensionsEmitter().Emit(spc, data); - new ViewPagesEmitter().Emit(spc, data); - - new JsonResolverEmitter().Emit(spc, data); - new TypeScriptDefinitionsEmitter().Emit(spc, data); - - new HostDbContextEmitter().Emit(spc, data); + foreach (var emitter in Emitters) + { + emitter.Emit(spc, data); + } } ); } From cc86f6565e1a674aa96c977b3b8bf2d1c28b74ac Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:42:52 +0100 Subject: [PATCH 021/112] test: add targeted tests for individual emitters Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EndpointExtensionsEmitterTests.cs | 339 ++++++++++++++++++ .../JsonResolverEmitterTests.cs | 252 +++++++++++++ .../RazorComponentExtensionsEmitterTests.cs | 120 +++++++ .../TypeScriptDefinitionsEmitterTests.cs | 331 +++++++++++++++++ .../ViewPagesEmitterTests.cs | 265 ++++++++++++++ 5 files changed, 1307 insertions(+) create mode 100644 tests/SimpleModule.Generator.Tests/EndpointExtensionsEmitterTests.cs create mode 100644 tests/SimpleModule.Generator.Tests/JsonResolverEmitterTests.cs create mode 100644 tests/SimpleModule.Generator.Tests/RazorComponentExtensionsEmitterTests.cs create mode 100644 tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs create mode 100644 tests/SimpleModule.Generator.Tests/ViewPagesEmitterTests.cs diff --git a/tests/SimpleModule.Generator.Tests/EndpointExtensionsEmitterTests.cs b/tests/SimpleModule.Generator.Tests/EndpointExtensionsEmitterTests.cs new file mode 100644 index 00000000..521c90be --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/EndpointExtensionsEmitterTests.cs @@ -0,0 +1,339 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class EndpointExtensionsEmitterTests +{ + [Fact] + public void Module_WithRoutePrefix_CreatesMapGroupWithTags() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products", RoutePrefix = "/api/products")] + public class ProductsModule : IModule { } + } + + namespace TestApp.Endpoints + { + public class ListEndpoint : IEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/", () => "list"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + endpointExt.Should().Contain("var group = app.MapGroup(\"/api/products\").WithTags(\"Products\");"); + endpointExt.Should().Contain("new global::TestApp.Endpoints.ListEndpoint().Map(group);"); + } + + [Fact] + public void Module_WithoutRoutePrefix_MapsEndpointsDirectlyToApp() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Misc")] + public class MiscModule : IModule { } + } + + namespace TestApp.Endpoints + { + public class PingEndpoint : IEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/ping", () => "pong"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + endpointExt.Should().Contain("new global::TestApp.Endpoints.PingEndpoint().Map(app);"); + endpointExt.Should().NotContain("MapGroup"); + } + + [Fact] + public void Module_WithConfigureEndpoints_SkipsAutoRegistration() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Custom", RoutePrefix = "/api/custom")] + public class CustomModule : IModule + { + public void ConfigureEndpoints(IEndpointRouteBuilder endpoints) { } + } + } + + namespace TestApp.Endpoints + { + public class AutoEndpoint : IEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/auto", () => "auto"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + // Should NOT auto-register endpoints because HasConfigureEndpoints is true + endpointExt.Should().NotContain("new global::TestApp.Endpoints.AutoEndpoint()"); + // Should use the escape hatch instead + endpointExt.Should().Contain("ModuleExtensions.s_TestApp_CustomModule.ConfigureEndpoints(app);"); + } + + [Fact] + public void Module_WithConfigureEndpoints_UsesEscapeHatch() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Manual")] + public class ManualModule : IModule + { + public void ConfigureEndpoints(IEndpointRouteBuilder endpoints) { } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + endpointExt.Should().Contain("ModuleExtensions.s_TestApp_ManualModule.ConfigureEndpoints(app);"); + } + + [Fact] + public void Module_WithEndpointsAndConfigureEndpoints_SkipsAutoAndUsesEscapeHatch() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Hybrid", RoutePrefix = "/api/hybrid")] + public class HybridModule : IModule + { + public void ConfigureEndpoints(IEndpointRouteBuilder endpoints) { } + } + } + + namespace TestApp.Endpoints + { + public class SomeEndpoint : IEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/some", () => "some"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + // Auto-registration skipped + endpointExt.Should().NotContain("new global::TestApp.Endpoints.SomeEndpoint()"); + // Escape hatch used + endpointExt.Should().Contain("ModuleExtensions.s_TestApp_HybridModule.ConfigureEndpoints(app);"); + } + + [Fact] + public void Module_WithNoEndpoints_NothingEmittedForThatModule() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Empty")] + public class EmptyModule : IModule { } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + endpointExt.Should().NotContain("EmptyModule"); + endpointExt.Should().NotContain("MapGroup"); + } + + [Fact] + public void MultipleModules_WithDifferentPrefixes_EachGetOwnGroup() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace ModuleA + { + [Module("Alpha", RoutePrefix = "/api/alpha")] + public class AlphaModule : IModule { } + } + + namespace ModuleA.Endpoints + { + public class AlphaEndpoint : IEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/", () => "alpha"); + } + } + } + + namespace ModuleB + { + [Module("Beta", RoutePrefix = "/api/beta")] + public class BetaModule : IModule { } + } + + namespace ModuleB.Endpoints + { + public class BetaEndpoint : IEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/", () => "beta"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + endpointExt.Should().Contain("app.MapGroup(\"/api/alpha\").WithTags(\"Alpha\")"); + endpointExt.Should().Contain("app.MapGroup(\"/api/beta\").WithTags(\"Beta\")"); + endpointExt.Should().Contain("new global::ModuleA.Endpoints.AlphaEndpoint().Map(group)"); + endpointExt.Should().Contain("new global::ModuleB.Endpoints.BetaEndpoint().Map(group)"); + } + + [Fact] + public void ViewEndpoints_WithViewPrefix_GetViewGroupWithExcludeFromDescription() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products", ViewPrefix = "/products")] + public class ProductsModule : IModule { } + } + + namespace TestApp.Views + { + public class BrowseEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/browse", () => "browse"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + endpointExt.Should().Contain( + "var viewGroup = app.MapGroup(\"/products\").WithTags(\"Products\").ExcludeFromDescription();" + ); + endpointExt.Should().Contain("new global::TestApp.Views.BrowseEndpoint().Map(viewGroup);"); + } + + [Fact] + public void ViewEndpoints_WithoutViewPrefix_MapDirectlyToApp() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Test")] + public class TestModule : IModule { } + } + + namespace TestApp.Views + { + public class HomeEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/", () => "home"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + endpointExt.Should().Contain("new global::TestApp.Views.HomeEndpoint().Map(app);"); + endpointExt.Should().NotContain("viewGroup"); + } + + private static string GetGeneratedSource(Microsoft.CodeAnalysis.GeneratorDriverRunResult result, string fileName) + { + return result + .GeneratedTrees.First(t => t.FilePath.EndsWith(fileName, StringComparison.Ordinal)) + .GetText() + .ToString(); + } +} diff --git a/tests/SimpleModule.Generator.Tests/JsonResolverEmitterTests.cs b/tests/SimpleModule.Generator.Tests/JsonResolverEmitterTests.cs new file mode 100644 index 00000000..57dbf855 --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/JsonResolverEmitterTests.cs @@ -0,0 +1,252 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class JsonResolverEmitterTests +{ + [Fact] + public void Dto_WithInternalProperty_ExcludedFromResolver() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class InternalPropDto + { + public int Id { get; set; } + internal string Secret { get; set; } = ""; + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + resolver.Should().Contain("prop_Id"); + resolver.Should().NotContain("prop_Secret"); + } + + [Fact] + public void Resolver_DispatchesByExactTypeComparison() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class MyDto + { + public int Id { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + resolver.Should().Contain("if (type == typeof(global::TestApp.MyDto))"); + } + + [Fact] + public void EachDto_GetsOwnCreateFactoryMethod() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class AlphaDto + { + public int Id { get; set; } + } + + [Dto] + public class BetaDto + { + public string Name { get; set; } = ""; + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + resolver.Should().Contain("private static JsonTypeInfo Create_TestApp_AlphaDto(JsonSerializerOptions options)"); + resolver.Should().Contain("private static JsonTypeInfo Create_TestApp_BetaDto(JsonSerializerOptions options)"); + } + + [Fact] + public void Property_WithNoSetter_OnlyGetsGetLambda() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class ReadOnlyPropDto + { + public int Id { get; } + public string Name { get; set; } = ""; + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + // Id has Get but no Set + resolver.Should().Contain("prop_Id.Get ="); + // Count the Set assignments - Id should not have one + var lines = resolver.Split('\n'); + var idSetLine = lines.Any(l => l.Contains("prop_Id.Set =", StringComparison.Ordinal)); + idSetLine.Should().BeFalse(); + + // Name has both Get and Set + resolver.Should().Contain("prop_Name.Get ="); + resolver.Should().Contain("prop_Name.Set ="); + } + + [Fact] + public void Resolver_CreateObject_UsesNewInstance() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class SimpleDto + { + public int Id { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + resolver.Should().Contain("info.CreateObject = static () => new global::TestApp.SimpleDto();"); + } + + [Fact] + public void Resolver_PropertyJsonName_IsCamelCased() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class CamelDto + { + public int ProductId { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + resolver.Should().Contain("\"productId\""); + resolver.Should().NotContain("\"ProductId\""); + } + + [Fact] + public void Resolver_IncludesPragmaWarningDisable() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class SimpleDto + { + public int Id { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + resolver.Should().Contain("#pragma warning disable IL2026"); + resolver.Should().Contain("#pragma warning disable IL3050"); + resolver.Should().Contain("#nullable enable"); + } + + [Fact] + public void Property_WithPrivateSetter_ExcludesSetLambda() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class PrivateSetterDto + { + public int Id { get; private set; } + public string Name { get; set; } = ""; + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + // Id has Get but no Set (setter is private) + resolver.Should().Contain("prop_Id.Get ="); + var lines = resolver.Split('\n'); + lines.Any(l => l.Contains("prop_Id.Set =", StringComparison.Ordinal)).Should().BeFalse(); + + // Name has both + resolver.Should().Contain("prop_Name.Set ="); + } + + private static string GetResolver(Microsoft.CodeAnalysis.GeneratorDriverRunResult result) => + result + .GeneratedTrees.First(t => + t.FilePath.EndsWith("ModulesJsonResolver.g.cs", StringComparison.Ordinal) + ) + .GetText() + .ToString(); +} diff --git a/tests/SimpleModule.Generator.Tests/RazorComponentExtensionsEmitterTests.cs b/tests/SimpleModule.Generator.Tests/RazorComponentExtensionsEmitterTests.cs new file mode 100644 index 00000000..38bb8823 --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/RazorComponentExtensionsEmitterTests.cs @@ -0,0 +1,120 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class RazorComponentExtensionsEmitterTests +{ + [Fact] + public void NoModulesWithRazorComponents_MethodBodyHasNoAddAdditionalAssemblies() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var razorExt = GetGeneratedSource(result, "RazorComponentExtensions.g.cs"); + + razorExt.Should().NotContain("AddAdditionalAssemblies"); + razorExt.Should().Contain("return builder;"); + } + + [Fact] + public void GeneratedCode_UsesCorrectNamespaceAndClassName() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var razorExt = GetGeneratedSource(result, "RazorComponentExtensions.g.cs"); + + razorExt.Should().Contain("namespace SimpleModule.Core;"); + razorExt.Should().Contain("public static class RazorComponentExtensions"); + razorExt.Should().Contain("public static RazorComponentsEndpointConventionBuilder AddModuleAssemblies("); + razorExt.Should().Contain("this RazorComponentsEndpointConventionBuilder builder)"); + } + + [Fact] + public void AlwaysGenerated_EvenWithNoModules_FileExists() + { + // This test verifies the file is always emitted even when no modules have Razor components. + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + result + .GeneratedTrees.Should() + .Contain(t => t.FilePath.EndsWith("RazorComponentExtensions.g.cs", StringComparison.Ordinal)); + } + + [Fact] + public void GeneratedCode_StartsWithAutoGeneratedComment() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var razorExt = GetGeneratedSource(result, "RazorComponentExtensions.g.cs"); + + razorExt.Should().StartWith("// "); + } + + [Fact] + public void GeneratedCode_IncludesUsingDirective() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var razorExt = GetGeneratedSource(result, "RazorComponentExtensions.g.cs"); + + razorExt.Should().Contain("using Microsoft.AspNetCore.Builder;"); + } + + private static string GetGeneratedSource(Microsoft.CodeAnalysis.GeneratorDriverRunResult result, string fileName) + { + return result + .GeneratedTrees.First(t => t.FilePath.EndsWith(fileName, StringComparison.Ordinal)) + .GetText() + .ToString(); + } +} diff --git a/tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs b/tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs new file mode 100644 index 00000000..fe5e5aea --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs @@ -0,0 +1,331 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class TypeScriptDefinitionsEmitterTests +{ + [Fact] + public void Dto_WithStringIntBool_MapsToCorrectTsTypes() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class ItemDto + { + public string Name { get; set; } = ""; + public int Count { get; set; } + public bool IsActive { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("name: string;"); + tsSource.Should().Contain("count: number;"); + tsSource.Should().Contain("isActive: boolean;"); + } + + [Fact] + public void Dto_WithDateTimeGuidDateOnly_MapsToString() + { + var source = """ + using System; + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class DatesDto + { + public DateTime CreatedAt { get; set; } + public Guid Id { get; set; } + public DateOnly BirthDate { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("createdAt: string;"); + tsSource.Should().Contain("id: string;"); + tsSource.Should().Contain("birthDate: string;"); + } + + [Fact] + public void Dto_WithListAndIEnumerable_MapsToArray() + { + var source = """ + using System.Collections.Generic; + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class CollectionDto + { + public List Tags { get; set; } = new(); + public IEnumerable Scores { get; set; } = new List(); + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("tags: string[];"); + tsSource.Should().Contain("scores: number[];"); + } + + [Fact] + public void Dto_WithNullableValueType_MapsToAny_DocumentsCurrentBehavior() + { + // Note: Nullable via shorthand (int?) produces FQNs with nested global:: prefixes + // that the TypeMappingHelpers does not currently resolve. This test documents that behavior. + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class NullableDto + { + public int? OptionalCount { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + // Current behavior: nullable value types map to any due to FQN format limitations + tsSource.Should().Contain("optionalCount:"); + } + + [Fact] + public void Dto_ReferencingAnotherDto_UsesDtoInterfaceName() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class CategoryDto + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + + [Dto] + public class ProductDto + { + public int Id { get; set; } + public CategoryDto Category { get; set; } = new(); + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("category: CategoryDto;"); + tsSource.Should().NotContain("category: any;"); + } + + [Fact] + public void PropertyNames_AreCamelCased() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class CamelDto + { + public string FirstName { get; set; } = ""; + public int ProductId { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("firstName: string;"); + tsSource.Should().Contain("productId: number;"); + tsSource.Should().NotContain("FirstName:"); + tsSource.Should().NotContain("ProductId:"); + } + + [Fact] + public void File_UsesSimpleModuleTsWrapper() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class SimpleDto + { + public int Id { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("#if SIMPLEMODULE_TS"); + tsSource.Should().Contain("/*"); + tsSource.Should().Contain("*/"); + tsSource.Should().Contain("#endif"); + } + + [Fact] + public void File_IncludesModuleComment() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class SimpleDto + { + public int Id { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("// @module Contracts"); + } + + [Fact] + public void NoDtoTypes_NoTypeScriptFileEmitted() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + result + .GeneratedTrees.Should() + .NotContain(t => t.FilePath.Contains("DtoTypeScript_")); + } + + [Fact] + public void Dto_WithUnknownType_MapsToAny() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + public class NonDtoClass + { + public int Value { get; set; } + } + + [Dto] + public class ContainerDto + { + public NonDtoClass Child { get; set; } = new(); + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("child: any;"); + } + + [Fact] + public void Dto_GeneratesExportInterface() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class MyDto + { + public int Id { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("export interface MyDto {"); + } + + private static string GetGeneratedSource(Microsoft.CodeAnalysis.GeneratorDriverRunResult result, string fileName) + { + return result + .GeneratedTrees.First(t => t.FilePath.EndsWith(fileName, StringComparison.Ordinal)) + .GetText() + .ToString(); + } +} diff --git a/tests/SimpleModule.Generator.Tests/ViewPagesEmitterTests.cs b/tests/SimpleModule.Generator.Tests/ViewPagesEmitterTests.cs new file mode 100644 index 00000000..464e4aca --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/ViewPagesEmitterTests.cs @@ -0,0 +1,265 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class ViewPagesEmitterTests +{ + [Fact] + public void Module_WithNoViews_NoViewPagesFileEmitted() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + result + .GeneratedTrees.Should() + .NotContain(t => t.FilePath.Contains("ViewPages_")); + } + + [Fact] + public void ModuleName_DerivedFromClassName_StripsModuleSuffix() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products", ViewPrefix = "/products")] + public class ProductsModule : IModule { } + } + + namespace TestApp.Views + { + public class BrowseEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/browse", () => "browse"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + result + .GeneratedTrees.Should() + .Contain(t => t.FilePath.EndsWith("ViewPages_Products.g.cs", StringComparison.Ordinal)); + } + + [Fact] + public void ImportStatements_UseComponentName() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Items", ViewPrefix = "/items")] + public class ItemsModule : IModule { } + } + + namespace TestApp.Views + { + public class CreateEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/create", () => "create"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var viewPages = GetGeneratedSource(result, "ViewPages_Items.g.cs"); + + viewPages.Should().Contain("import Create from '../Views/Create';"); + } + + [Fact] + public void PagesRecord_MapsViewPageNameToComponent() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Orders", ViewPrefix = "/orders")] + public class OrdersModule : IModule { } + } + + namespace TestApp.Views + { + public class DetailEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/{id}", () => "detail"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var viewPages = GetGeneratedSource(result, "ViewPages_Orders.g.cs"); + + viewPages.Should().Contain("export const pages: Record = {"); + viewPages.Should().Contain("'Orders/Detail': Detail"); + } + + [Fact] + public void MultipleViews_AllAppearInPagesRecord() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Test", ViewPrefix = "/test")] + public class TestModule : IModule { } + } + + namespace TestApp.Views + { + public class BrowseEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/browse", () => "browse"); + } + } + + public class CreateEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/create", () => "create"); + } + } + + public class EditEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/edit", () => "edit"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var viewPages = GetGeneratedSource(result, "ViewPages_Test.g.cs"); + + viewPages.Should().Contain("import Browse from '../Views/Browse';"); + viewPages.Should().Contain("import Create from '../Views/Create';"); + viewPages.Should().Contain("import Edit from '../Views/Edit';"); + viewPages.Should().Contain("'Test/Browse': Browse"); + viewPages.Should().Contain("'Test/Create': Create"); + viewPages.Should().Contain("'Test/Edit': Edit"); + } + + [Fact] + public void Output_WrappedInSimpleModuleTsDirective() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Test", ViewPrefix = "/test")] + public class TestModule : IModule { } + } + + namespace TestApp.Views + { + public class IndexEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/", () => "index"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var viewPages = GetGeneratedSource(result, "ViewPages_Test.g.cs"); + + viewPages.Should().Contain("#if SIMPLEMODULE_TS"); + viewPages.Should().Contain("/*"); + viewPages.Should().Contain("*/"); + viewPages.Should().Contain("#endif"); + } + + [Fact] + public void ViewClassName_WithViewSuffix_StripsViewSuffix() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Test", ViewPrefix = "/test")] + public class TestModule : IModule { } + } + + namespace TestApp.Views + { + public class DetailView : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/{id}", () => "detail"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var viewPages = GetGeneratedSource(result, "ViewPages_Test.g.cs"); + + viewPages.Should().Contain("import Detail from '../Views/Detail';"); + viewPages.Should().Contain("'Test/Detail': Detail"); + } + + private static string GetGeneratedSource(Microsoft.CodeAnalysis.GeneratorDriverRunResult result, string fileName) + { + return result + .GeneratedTrees.First(t => t.FilePath.EndsWith(fileName, StringComparison.Ordinal)) + .GetText() + .ToString(); + } +} From a7eebc51da80078276a6b5f06c2ab7eca43da8b1 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 09:47:25 +0100 Subject: [PATCH 022/112] test: add targeted tests for individual emitters Add 5 new test files covering each emitter's specific logic: - EndpointExtensionsEmitterTests (9 tests) - RazorComponentExtensionsEmitterTests (5 tests) - TypeScriptDefinitionsEmitterTests (11 tests) - ViewPagesEmitterTests (7 tests) - JsonResolverEmitterTests (8 tests) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EndpointExtensionsEmitterTests.cs | 40 ------------------- .../TypeScriptDefinitionsEmitterTests.cs | 8 ++-- 2 files changed, 5 insertions(+), 43 deletions(-) diff --git a/tests/SimpleModule.Generator.Tests/EndpointExtensionsEmitterTests.cs b/tests/SimpleModule.Generator.Tests/EndpointExtensionsEmitterTests.cs index 521c90be..566f29f3 100644 --- a/tests/SimpleModule.Generator.Tests/EndpointExtensionsEmitterTests.cs +++ b/tests/SimpleModule.Generator.Tests/EndpointExtensionsEmitterTests.cs @@ -141,46 +141,6 @@ public void ConfigureEndpoints(IEndpointRouteBuilder endpoints) { } endpointExt.Should().Contain("ModuleExtensions.s_TestApp_ManualModule.ConfigureEndpoints(app);"); } - [Fact] - public void Module_WithEndpointsAndConfigureEndpoints_SkipsAutoAndUsesEscapeHatch() - { - var source = """ - using Microsoft.AspNetCore.Builder; - using Microsoft.AspNetCore.Routing; - using SimpleModule.Core; - - namespace TestApp - { - [Module("Hybrid", RoutePrefix = "/api/hybrid")] - public class HybridModule : IModule - { - public void ConfigureEndpoints(IEndpointRouteBuilder endpoints) { } - } - } - - namespace TestApp.Endpoints - { - public class SomeEndpoint : IEndpoint - { - public void Map(IEndpointRouteBuilder app) - { - app.MapGet("/some", () => "some"); - } - } - } - """; - - var compilation = GeneratorTestHelper.CreateCompilation(source); - var result = GeneratorTestHelper.RunGenerator(compilation); - - var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); - - // Auto-registration skipped - endpointExt.Should().NotContain("new global::TestApp.Endpoints.SomeEndpoint()"); - // Escape hatch used - endpointExt.Should().Contain("ModuleExtensions.s_TestApp_HybridModule.ConfigureEndpoints(app);"); - } - [Fact] public void Module_WithNoEndpoints_NothingEmittedForThatModule() { diff --git a/tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs b/tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs index fe5e5aea..f6b8da2a 100644 --- a/tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs +++ b/tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs @@ -96,7 +96,7 @@ public class CollectionDto } [Fact] - public void Dto_WithNullableValueType_MapsToAny_DocumentsCurrentBehavior() + public void Dto_WithNullableShorthandValueType_MapsToAny() { // Note: Nullable via shorthand (int?) produces FQNs with nested global:: prefixes // that the TypeMappingHelpers does not currently resolve. This test documents that behavior. @@ -120,8 +120,10 @@ public class NullableDto var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); - // Current behavior: nullable value types map to any due to FQN format limitations - tsSource.Should().Contain("optionalCount:"); + // Current behavior: nullable value types via shorthand (int?) map to `any` + // because Roslyn represents them with nested global:: prefixes that + // MapCSharpTypeToTypeScript doesn't resolve. + tsSource.Should().Contain("optionalCount: any;"); } [Fact] From 01b9a105f9a50b9c01ebb44a2b7a381305909c71 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 10:11:07 +0100 Subject: [PATCH 023/112] fix: use deterministic dates in order seed data Replace orderFaker.Date.Recent(30) with a fixed date range to prevent EF Core PendingModelChangesWarning on every build. Recreated migration. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EntityConfigurations/OrderConfiguration.cs | 6 +++++- ...r.cs => 20260318090728_InitialCreate.Designer.cs} | 12 ++++++------ ...tialCreate.cs => 20260318090728_InitialCreate.cs} | 10 +++++----- .../Migrations/HostDbContextModelSnapshot.cs | 10 +++++----- 4 files changed, 21 insertions(+), 17 deletions(-) rename template/SimpleModule.Host/Migrations/{20260317230801_InitialCreate.Designer.cs => 20260318090728_InitialCreate.Designer.cs} (97%) rename template/SimpleModule.Host/Migrations/{20260317230801_InitialCreate.cs => 20260318090728_InitialCreate.cs} (97%) diff --git a/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs b/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs index 28485206..71b3c106 100644 --- a/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs +++ b/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs @@ -82,7 +82,11 @@ private static Order[] GenerateSeedOrders() .Random.Int(1, 10) .ToString(System.Globalization.CultureInfo.InvariantCulture), Total = Math.Round(total, 2), - CreatedAt = orderFaker.Date.Recent(30).ToUniversalTime(), + CreatedAt = orderFaker + .Date.Between( + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 1, 31, 23, 59, 59, DateTimeKind.Utc) + ), } ); } diff --git a/template/SimpleModule.Host/Migrations/20260317230801_InitialCreate.Designer.cs b/template/SimpleModule.Host/Migrations/20260318090728_InitialCreate.Designer.cs similarity index 97% rename from template/SimpleModule.Host/Migrations/20260317230801_InitialCreate.Designer.cs rename to template/SimpleModule.Host/Migrations/20260318090728_InitialCreate.Designer.cs index c5d7f78a..eb3e1b01 100644 --- a/template/SimpleModule.Host/Migrations/20260317230801_InitialCreate.Designer.cs +++ b/template/SimpleModule.Host/Migrations/20260318090728_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace SimpleModule.Host.Migrations { [DbContext(typeof(HostDbContext))] - [Migration("20260317230801_InitialCreate")] + [Migration("20260318090728_InitialCreate")] partial class InitialCreate { /// @@ -365,35 +365,35 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) new { Id = 1, - CreatedAt = new DateTime(2026, 3, 16, 10, 20, 47, 15, DateTimeKind.Utc).AddTicks(1761), + CreatedAt = new DateTime(2026, 1, 2, 14, 0, 48, 324, DateTimeKind.Utc).AddTicks(4205), Total = 4205.85m, UserId = "2" }, new { Id = 2, - CreatedAt = new DateTime(2026, 3, 9, 18, 57, 18, 178, DateTimeKind.Utc).AddTicks(1275), + CreatedAt = new DateTime(2026, 1, 9, 10, 43, 3, 902, DateTimeKind.Utc).AddTicks(7351), Total = 2752.49m, UserId = "8" }, new { Id = 3, - CreatedAt = new DateTime(2026, 2, 20, 1, 3, 59, 566, DateTimeKind.Utc).AddTicks(5889), + CreatedAt = new DateTime(2026, 1, 27, 18, 48, 8, 543, DateTimeKind.Utc).AddTicks(1493), Total = 192.31m, UserId = "6" }, new { Id = 4, - CreatedAt = new DateTime(2026, 2, 28, 12, 35, 49, 235, DateTimeKind.Utc).AddTicks(4033), + CreatedAt = new DateTime(2026, 1, 19, 0, 29, 15, 501, DateTimeKind.Utc).AddTicks(3898), Total = 3146.31m, UserId = "2" }, new { Id = 5, - CreatedAt = new DateTime(2026, 3, 14, 1, 15, 36, 679, DateTimeKind.Utc).AddTicks(1240), + CreatedAt = new DateTime(2026, 1, 5, 1, 0, 8, 927, DateTimeKind.Utc).AddTicks(1333), Total = 4580.92m, UserId = "10" }); diff --git a/template/SimpleModule.Host/Migrations/20260317230801_InitialCreate.cs b/template/SimpleModule.Host/Migrations/20260318090728_InitialCreate.cs similarity index 97% rename from template/SimpleModule.Host/Migrations/20260317230801_InitialCreate.cs rename to template/SimpleModule.Host/Migrations/20260318090728_InitialCreate.cs index 35ed9a7d..a27ccf1e 100644 --- a/template/SimpleModule.Host/Migrations/20260317230801_InitialCreate.cs +++ b/template/SimpleModule.Host/Migrations/20260318090728_InitialCreate.cs @@ -353,11 +353,11 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: new[] { "Id", "CreatedAt", "Total", "UserId" }, values: new object[,] { - { 1, new DateTime(2026, 3, 16, 10, 20, 47, 15, DateTimeKind.Utc).AddTicks(1761), 4205.85m, "2" }, - { 2, new DateTime(2026, 3, 9, 18, 57, 18, 178, DateTimeKind.Utc).AddTicks(1275), 2752.49m, "8" }, - { 3, new DateTime(2026, 2, 20, 1, 3, 59, 566, DateTimeKind.Utc).AddTicks(5889), 192.31m, "6" }, - { 4, new DateTime(2026, 2, 28, 12, 35, 49, 235, DateTimeKind.Utc).AddTicks(4033), 3146.31m, "2" }, - { 5, new DateTime(2026, 3, 14, 1, 15, 36, 679, DateTimeKind.Utc).AddTicks(1240), 4580.92m, "10" } + { 1, new DateTime(2026, 1, 2, 14, 0, 48, 324, DateTimeKind.Utc).AddTicks(4205), 4205.85m, "2" }, + { 2, new DateTime(2026, 1, 9, 10, 43, 3, 902, DateTimeKind.Utc).AddTicks(7351), 2752.49m, "8" }, + { 3, new DateTime(2026, 1, 27, 18, 48, 8, 543, DateTimeKind.Utc).AddTicks(1493), 192.31m, "6" }, + { 4, new DateTime(2026, 1, 19, 0, 29, 15, 501, DateTimeKind.Utc).AddTicks(3898), 3146.31m, "2" }, + { 5, new DateTime(2026, 1, 5, 1, 0, 8, 927, DateTimeKind.Utc).AddTicks(1333), 4580.92m, "10" } }); migrationBuilder.InsertData( diff --git a/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs index 95894f23..ae693c24 100644 --- a/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs +++ b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs @@ -362,35 +362,35 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 1, - CreatedAt = new DateTime(2026, 3, 16, 10, 20, 47, 15, DateTimeKind.Utc).AddTicks(1761), + CreatedAt = new DateTime(2026, 1, 2, 14, 0, 48, 324, DateTimeKind.Utc).AddTicks(4205), Total = 4205.85m, UserId = "2" }, new { Id = 2, - CreatedAt = new DateTime(2026, 3, 9, 18, 57, 18, 178, DateTimeKind.Utc).AddTicks(1275), + CreatedAt = new DateTime(2026, 1, 9, 10, 43, 3, 902, DateTimeKind.Utc).AddTicks(7351), Total = 2752.49m, UserId = "8" }, new { Id = 3, - CreatedAt = new DateTime(2026, 2, 20, 1, 3, 59, 566, DateTimeKind.Utc).AddTicks(5889), + CreatedAt = new DateTime(2026, 1, 27, 18, 48, 8, 543, DateTimeKind.Utc).AddTicks(1493), Total = 192.31m, UserId = "6" }, new { Id = 4, - CreatedAt = new DateTime(2026, 2, 28, 12, 35, 49, 235, DateTimeKind.Utc).AddTicks(4033), + CreatedAt = new DateTime(2026, 1, 19, 0, 29, 15, 501, DateTimeKind.Utc).AddTicks(3898), Total = 3146.31m, UserId = "2" }, new { Id = 5, - CreatedAt = new DateTime(2026, 3, 14, 1, 15, 36, 679, DateTimeKind.Utc).AddTicks(1240), + CreatedAt = new DateTime(2026, 1, 5, 1, 0, 8, 927, DateTimeKind.Utc).AddTicks(1333), Total = 4580.92m, UserId = "10" }); From d2d6b0c048a0f135914210330fe8fb28ff4b8b2c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 10:17:50 +0100 Subject: [PATCH 024/112] feat: add NetEscapades.EnumGenerators for AOT-safe enum utilities Annotate DatabaseProvider, MenuSection, and CheckStatus enums with [EnumExtensions] to generate fast ToStringFast(), IsDefined(), and TryParse() methods. Replace Enum.TryParse in DatabaseProviderDetector with the generated extension for better AOT compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- Directory.Packages.props | 1 + cli/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs | 5 ++++- cli/SimpleModule.Cli/SimpleModule.Cli.csproj | 1 + framework/SimpleModule.Core/Menu/MenuSection.cs | 3 +++ framework/SimpleModule.Core/SimpleModule.Core.csproj | 1 + framework/SimpleModule.Database/DatabaseProvider.cs | 5 ++++- framework/SimpleModule.Database/DatabaseProviderDetector.cs | 2 +- framework/SimpleModule.Database/SimpleModule.Database.csproj | 1 + 8 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3694a01a..295115d0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs index a57bb19d..6920a949 100644 --- a/cli/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs @@ -1,5 +1,8 @@ -namespace SimpleModule.Cli.Commands.Doctor.Checks; +using NetEscapades.EnumGenerators; +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +[EnumExtensions] public enum CheckStatus { Pass, diff --git a/cli/SimpleModule.Cli/SimpleModule.Cli.csproj b/cli/SimpleModule.Cli/SimpleModule.Cli.csproj index de10a57b..abccc313 100644 --- a/cli/SimpleModule.Cli/SimpleModule.Cli.csproj +++ b/cli/SimpleModule.Cli/SimpleModule.Cli.csproj @@ -8,6 +8,7 @@ CA1308 + diff --git a/framework/SimpleModule.Core/Menu/MenuSection.cs b/framework/SimpleModule.Core/Menu/MenuSection.cs index 809391c9..4a4a4d5b 100644 --- a/framework/SimpleModule.Core/Menu/MenuSection.cs +++ b/framework/SimpleModule.Core/Menu/MenuSection.cs @@ -1,5 +1,8 @@ +using NetEscapades.EnumGenerators; + namespace SimpleModule.Core.Menu; +[EnumExtensions] public enum MenuSection { Navbar, diff --git a/framework/SimpleModule.Core/SimpleModule.Core.csproj b/framework/SimpleModule.Core/SimpleModule.Core.csproj index 3f60bcef..d840f9b4 100644 --- a/framework/SimpleModule.Core/SimpleModule.Core.csproj +++ b/framework/SimpleModule.Core/SimpleModule.Core.csproj @@ -5,5 +5,6 @@ + diff --git a/framework/SimpleModule.Database/DatabaseProvider.cs b/framework/SimpleModule.Database/DatabaseProvider.cs index 4a95341b..0f609bd9 100644 --- a/framework/SimpleModule.Database/DatabaseProvider.cs +++ b/framework/SimpleModule.Database/DatabaseProvider.cs @@ -1,5 +1,8 @@ -namespace SimpleModule.Database; +using NetEscapades.EnumGenerators; +namespace SimpleModule.Database; + +[EnumExtensions] public enum DatabaseProvider { Sqlite, diff --git a/framework/SimpleModule.Database/DatabaseProviderDetector.cs b/framework/SimpleModule.Database/DatabaseProviderDetector.cs index 791ba8b6..45bf1147 100644 --- a/framework/SimpleModule.Database/DatabaseProviderDetector.cs +++ b/framework/SimpleModule.Database/DatabaseProviderDetector.cs @@ -10,7 +10,7 @@ public static DatabaseProvider Detect(string connectionString, string? explicitP { if ( !string.IsNullOrWhiteSpace(explicitProvider) - && Enum.TryParse(explicitProvider, ignoreCase: true, out var parsed) + && DatabaseProviderExtensions.TryParse(explicitProvider, out var parsed, ignoreCase: true) ) { return parsed; diff --git a/framework/SimpleModule.Database/SimpleModule.Database.csproj b/framework/SimpleModule.Database/SimpleModule.Database.csproj index 43cc83ce..59bc7e67 100644 --- a/framework/SimpleModule.Database/SimpleModule.Database.csproj +++ b/framework/SimpleModule.Database/SimpleModule.Database.csproj @@ -5,6 +5,7 @@ + From da40e805eabd2cfb22ceb27d48502621b6b9dd98 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 10:50:57 +0100 Subject: [PATCH 025/112] feat: add Vogen strongly-typed IDs (ProductId, OrderId, UserId) for compile-time safety Introduces Vogen 8.0.5 value objects that prevent mixing up raw int/string IDs across module boundaries. Each ID type generates System.Text.Json converters and EF Core value converters automatically. Key changes: - ProductId/OrderId (int-backed) and UserId (string-backed with validation) - All contracts, services, endpoints, and tests updated to use typed IDs - EF Core ConfigureConventions registers Vogen converters/comparers per DbContext - Source generator emits HostDbContext as partial class for convention overrides - TypeScript type mappings updated for Vogen ID types - Test factory now replaces HostDbContext for proper integration test setup Co-Authored-By: Claude Opus 4.6 (1M context) --- Directory.Packages.props | 3 + framework/SimpleModule.Core/Ids/OrderId.cs | 6 ++ framework/SimpleModule.Core/Ids/ProductId.cs | 6 ++ framework/SimpleModule.Core/Ids/UserId.cs | 12 +++ .../SimpleModule.Core.csproj | 3 + .../Emitters/HostDbContextEmitter.cs | 2 +- .../Helpers/TypeMappingHelpers.cs | 3 + .../Orders.Contracts/CreateOrderRequest.cs | 5 +- .../Events/OrderCreatedEvent.cs | 5 +- .../src/Orders.Contracts/IOrderContracts.cs | 10 ++- modules/Orders/src/Orders.Contracts/Order.cs | 7 +- .../Orders/src/Orders.Contracts/OrderItem.cs | 5 +- .../Orders.Contracts/UpdateOrderRequest.cs | 3 +- .../Orders/CreateRequestValidator.cs | 2 +- .../Orders/Endpoints/Orders/DeleteEndpoint.cs | 3 +- .../Endpoints/Orders/GetByIdEndpoint.cs | 3 +- .../Orders/Endpoints/Orders/UpdateEndpoint.cs | 3 +- .../OrderConfiguration.cs | 16 ++-- modules/Orders/src/Orders/OrderService.cs | 21 ++--- modules/Orders/src/Orders/OrdersDbContext.cs | 11 +++ .../Orders/src/Orders/Views/CreateEndpoint.cs | 5 +- .../Orders/src/Orders/Views/EditEndpoint.cs | 17 ++-- .../Integration/OrdersEndpointTests.cs | 11 +-- .../Unit/CreateOrderRequestValidatorTests.cs | 35 +++----- .../Orders.Tests/Unit/OrderServiceTests.cs | 89 +++++++++++-------- .../Products.Contracts/IProductContracts.cs | 12 +-- .../src/Products.Contracts/Product.cs | 3 +- .../Endpoints/Products/DeleteEndpoint.cs | 3 +- .../Endpoints/Products/GetByIdEndpoint.cs | 3 +- .../Endpoints/Products/UpdateEndpoint.cs | 3 +- .../ProductConfiguration.cs | 4 +- .../Products/src/Products/ProductService.cs | 19 ++-- .../src/Products/ProductsDbContext.cs | 7 ++ .../src/Products/Views/EditEndpoint.cs | 7 +- .../Integration/ProductsEndpointTests.cs | 5 +- .../Unit/ProductServiceTests.cs | 15 ++-- .../src/Users.Contracts/IUserContracts.cs | 12 +-- modules/Users/src/Users.Contracts/UserDto.cs | 5 +- .../Users/Endpoints/Users/DeleteEndpoint.cs | 3 +- .../Users/Endpoints/Users/GetByIdEndpoint.cs | 3 +- .../Endpoints/Users/GetCurrentEndpoint.cs | 7 +- .../Users/Endpoints/Users/UpdateEndpoint.cs | 3 +- modules/Users/src/Users/UserService.cs | 23 ++--- .../Users.Tests/Unit/UserServiceTests.cs | 28 +++--- .../HostDbContext.Conventions.cs | 17 ++++ ... 20260318094606_InitialCreate.Designer.cs} | 2 +- ...ate.cs => 20260318094606_InitialCreate.cs} | 0 template/SimpleModule.Host/Program.cs | 11 ++- .../Fakes/FakeDataGenerators.cs | 20 +++-- .../Fakes/FakeOrderContracts.cs | 11 +-- .../Fakes/FakeProductContracts.cs | 13 +-- .../Fakes/FakeUserContracts.cs | 13 +-- .../SimpleModuleWebApplicationFactory.cs | 2 + 53 files changed, 326 insertions(+), 214 deletions(-) create mode 100644 framework/SimpleModule.Core/Ids/OrderId.cs create mode 100644 framework/SimpleModule.Core/Ids/ProductId.cs create mode 100644 framework/SimpleModule.Core/Ids/UserId.cs create mode 100644 template/SimpleModule.Host/HostDbContext.Conventions.cs rename template/SimpleModule.Host/Migrations/{20260318090728_InitialCreate.Designer.cs => 20260318094606_InitialCreate.Designer.cs} (99%) rename template/SimpleModule.Host/Migrations/{20260318090728_InitialCreate.cs => 20260318094606_InitialCreate.cs} (100%) diff --git a/Directory.Packages.props b/Directory.Packages.props index 295115d0..a9fa2a8e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,6 +19,7 @@ + @@ -39,6 +40,8 @@ + + diff --git a/framework/SimpleModule.Core/Ids/OrderId.cs b/framework/SimpleModule.Core/Ids/OrderId.cs new file mode 100644 index 00000000..27c674f7 --- /dev/null +++ b/framework/SimpleModule.Core/Ids/OrderId.cs @@ -0,0 +1,6 @@ +using Vogen; + +namespace SimpleModule.Core.Ids; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct OrderId; diff --git a/framework/SimpleModule.Core/Ids/ProductId.cs b/framework/SimpleModule.Core/Ids/ProductId.cs new file mode 100644 index 00000000..5e8346ca --- /dev/null +++ b/framework/SimpleModule.Core/Ids/ProductId.cs @@ -0,0 +1,6 @@ +using Vogen; + +namespace SimpleModule.Core.Ids; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct ProductId; diff --git a/framework/SimpleModule.Core/Ids/UserId.cs b/framework/SimpleModule.Core/Ids/UserId.cs new file mode 100644 index 00000000..f351837f --- /dev/null +++ b/framework/SimpleModule.Core/Ids/UserId.cs @@ -0,0 +1,12 @@ +using Vogen; + +namespace SimpleModule.Core.Ids; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct UserId +{ + private static Vogen.Validation Validate(string value) => + string.IsNullOrWhiteSpace(value) + ? Vogen.Validation.Invalid("UserId cannot be empty") + : Vogen.Validation.Ok; +} diff --git a/framework/SimpleModule.Core/SimpleModule.Core.csproj b/framework/SimpleModule.Core/SimpleModule.Core.csproj index d840f9b4..daa0cf88 100644 --- a/framework/SimpleModule.Core/SimpleModule.Core.csproj +++ b/framework/SimpleModule.Core/SimpleModule.Core.csproj @@ -2,9 +2,12 @@ net10.0 Library + $(DefineConstants);VOGEN_NO_VALIDATION + + diff --git a/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs index 54c4c696..c9b16f79 100644 --- a/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs @@ -129,7 +129,7 @@ string ModuleName // Class declaration with primary constructor if (identityContext != null) { - sb.AppendLine($"public class HostDbContext("); + sb.AppendLine($"public partial class HostDbContext("); sb.AppendLine(" DbContextOptions options,"); sb.AppendLine(" IOptions dbOptions"); sb.AppendLine($") : IdentityDbContext<{identityContext.Value.IdentityUserTypeFqn},"); diff --git a/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs b/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs index e56b1e48..285bdd21 100644 --- a/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs +++ b/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs @@ -74,6 +74,9 @@ knownDtoTypes is not null or "System.DateOnly" or "System.TimeOnly" => "string", "System.Guid" => "string", + // Vogen strongly-typed IDs (map to their underlying primitive types) + "SimpleModule.Core.Ids.ProductId" or "SimpleModule.Core.Ids.OrderId" => "number", + "SimpleModule.Core.Ids.UserId" => "string", _ => "any", }; } diff --git a/modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs b/modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs index 1188d1e3..549836ee 100644 --- a/modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs +++ b/modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs @@ -1,10 +1,11 @@ -using SimpleModule.Core; +using SimpleModule.Core; +using SimpleModule.Core.Ids; namespace SimpleModule.Orders.Contracts; [Dto] public class CreateOrderRequest { - public string UserId { get; set; } = string.Empty; + public UserId UserId { get; set; } public List Items { get; set; } = new(); } diff --git a/modules/Orders/src/Orders.Contracts/Events/OrderCreatedEvent.cs b/modules/Orders/src/Orders.Contracts/Events/OrderCreatedEvent.cs index a3881dc9..8110dee4 100644 --- a/modules/Orders/src/Orders.Contracts/Events/OrderCreatedEvent.cs +++ b/modules/Orders/src/Orders.Contracts/Events/OrderCreatedEvent.cs @@ -1,5 +1,6 @@ -using SimpleModule.Core.Events; +using SimpleModule.Core.Events; +using SimpleModule.Core.Ids; namespace SimpleModule.Orders.Contracts.Events; -public sealed record OrderCreatedEvent(int OrderId, string UserId, decimal Total) : IEvent; +public sealed record OrderCreatedEvent(OrderId OrderId, UserId UserId, decimal Total) : IEvent; diff --git a/modules/Orders/src/Orders.Contracts/IOrderContracts.cs b/modules/Orders/src/Orders.Contracts/IOrderContracts.cs index 4eb1945b..0eb39af3 100644 --- a/modules/Orders/src/Orders.Contracts/IOrderContracts.cs +++ b/modules/Orders/src/Orders.Contracts/IOrderContracts.cs @@ -1,10 +1,12 @@ -namespace SimpleModule.Orders.Contracts; +using SimpleModule.Core.Ids; + +namespace SimpleModule.Orders.Contracts; public interface IOrderContracts { Task> GetAllOrdersAsync(); - Task GetOrderByIdAsync(int id); + Task GetOrderByIdAsync(OrderId id); Task CreateOrderAsync(CreateOrderRequest request); - Task UpdateOrderAsync(int id, UpdateOrderRequest request); - Task DeleteOrderAsync(int id); + Task UpdateOrderAsync(OrderId id, UpdateOrderRequest request); + Task DeleteOrderAsync(OrderId id); } diff --git a/modules/Orders/src/Orders.Contracts/Order.cs b/modules/Orders/src/Orders.Contracts/Order.cs index 1116bf35..ded54043 100644 --- a/modules/Orders/src/Orders.Contracts/Order.cs +++ b/modules/Orders/src/Orders.Contracts/Order.cs @@ -1,12 +1,13 @@ -using SimpleModule.Core; +using SimpleModule.Core; +using SimpleModule.Core.Ids; namespace SimpleModule.Orders.Contracts; [Dto] public class Order { - public int Id { get; set; } - public string UserId { get; set; } = string.Empty; + public OrderId Id { get; set; } + public UserId UserId { get; set; } public List Items { get; set; } = new(); public decimal Total { get; set; } public DateTime CreatedAt { get; set; } diff --git a/modules/Orders/src/Orders.Contracts/OrderItem.cs b/modules/Orders/src/Orders.Contracts/OrderItem.cs index 8244a44c..539fb0c2 100644 --- a/modules/Orders/src/Orders.Contracts/OrderItem.cs +++ b/modules/Orders/src/Orders.Contracts/OrderItem.cs @@ -1,10 +1,11 @@ -using SimpleModule.Core; +using SimpleModule.Core; +using SimpleModule.Core.Ids; namespace SimpleModule.Orders.Contracts; [Dto] public class OrderItem { - public int ProductId { get; set; } + public ProductId ProductId { get; set; } public int Quantity { get; set; } } diff --git a/modules/Orders/src/Orders.Contracts/UpdateOrderRequest.cs b/modules/Orders/src/Orders.Contracts/UpdateOrderRequest.cs index d2c6848e..60dd3b95 100644 --- a/modules/Orders/src/Orders.Contracts/UpdateOrderRequest.cs +++ b/modules/Orders/src/Orders.Contracts/UpdateOrderRequest.cs @@ -1,10 +1,11 @@ using SimpleModule.Core; +using SimpleModule.Core.Ids; namespace SimpleModule.Orders.Contracts; [Dto] public class UpdateOrderRequest { - public string UserId { get; set; } = string.Empty; + public UserId UserId { get; set; } public List Items { get; set; } = new(); } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/CreateRequestValidator.cs b/modules/Orders/src/Orders/Endpoints/Orders/CreateRequestValidator.cs index c2c44103..58b0f0ae 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/CreateRequestValidator.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/CreateRequestValidator.cs @@ -15,7 +15,7 @@ public static ValidationResult Validate(CreateOrderRequest request) { var errors = new Dictionary(); - if (string.IsNullOrWhiteSpace(request.UserId)) + if (string.IsNullOrWhiteSpace(request.UserId.Value)) { errors[OrdersConstants.Fields.UserId] = [ diff --git a/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs index 65bd07e2..ce86816c 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; @@ -12,7 +13,7 @@ public void Map(IEndpointRouteBuilder app) { app.MapDelete( "/{id}", - async (int id, IOrderContracts orderContracts) => + async (OrderId id, IOrderContracts orderContracts) => { await orderContracts.DeleteOrderAsync(id); return TypedResults.NoContent(); diff --git a/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs index 9ae9d607..16e0cbc7 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; @@ -13,7 +14,7 @@ public void Map(IEndpointRouteBuilder app) { app.MapGet( "/{id}", - async Task, NotFound>> (int id, IOrderContracts orderContracts) => + async Task, NotFound>> (OrderId id, IOrderContracts orderContracts) => { var order = await orderContracts.GetOrderByIdAsync(id); return order is not null ? TypedResults.Ok(order) : TypedResults.NotFound(); diff --git a/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs index b7d83f94..c69c8224 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; @@ -15,7 +16,7 @@ public void Map(IEndpointRouteBuilder app) app.MapPut( "/{id}", async Task, NotFound, ValidationProblem>> ( - int id, + OrderId id, UpdateOrderRequest request, IOrderContracts orderContracts ) => diff --git a/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs b/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs index 71b3c106..eb967243 100644 --- a/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs +++ b/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs @@ -1,6 +1,7 @@ using Bogus; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.EntityConfigurations; @@ -10,6 +11,7 @@ public class OrderConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.HasKey(o => o.Id); + builder.Property(o => o.Id).ValueGeneratedOnAdd(); builder.Property(o => o.Total).HasColumnType("decimal(18,2)"); builder.HasMany(o => o.Items).WithOne().HasForeignKey("OrderId"); @@ -40,8 +42,8 @@ internal static List GenerateSeedOrderItems() orderItems.Add( new { - OrderId = i, - ProductId = productId, + OrderId = OrderId.From(i), + ProductId = ProductId.From(productId), Quantity = quantity, } ); @@ -77,10 +79,12 @@ private static Order[] GenerateSeedOrders() orders.Add( new Order { - Id = i, - UserId = orderFaker - .Random.Int(1, 10) - .ToString(System.Globalization.CultureInfo.InvariantCulture), + Id = OrderId.From(i), + UserId = UserId.From( + orderFaker + .Random.Int(1, 10) + .ToString(System.Globalization.CultureInfo.InvariantCulture) + ), Total = Math.Round(total, 2), CreatedAt = orderFaker .Date.Between( diff --git a/modules/Orders/src/Orders/OrderService.cs b/modules/Orders/src/Orders/OrderService.cs index 74285614..75f2e61e 100644 --- a/modules/Orders/src/Orders/OrderService.cs +++ b/modules/Orders/src/Orders/OrderService.cs @@ -1,7 +1,8 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SimpleModule.Core.Events; using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; using SimpleModule.Orders.Contracts.Events; using SimpleModule.Products.Contracts; @@ -20,7 +21,7 @@ ILogger logger public async Task> GetAllOrdersAsync() => await db.Orders.Include(o => o.Items).ToListAsync(); - public async Task GetOrderByIdAsync(int id) + public async Task GetOrderByIdAsync(OrderId id) { var order = await db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id); if (order is null) @@ -71,7 +72,7 @@ public async Task CreateOrderAsync(CreateOrderRequest request) return order; } - public async Task UpdateOrderAsync(int id, UpdateOrderRequest request) + public async Task UpdateOrderAsync(OrderId id, UpdateOrderRequest request) { var order = await db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id); if (order is null) @@ -110,7 +111,7 @@ public async Task UpdateOrderAsync(int id, UpdateOrderRequest request) return order; } - public async Task DeleteOrderAsync(int id) + public async Task DeleteOrderAsync(OrderId id) { var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); if (order is null) @@ -125,7 +126,7 @@ public async Task DeleteOrderAsync(int id) } [LoggerMessage(Level = LogLevel.Warning, Message = "Order with ID {OrderId} not found")] - private static partial void LogOrderNotFound(ILogger logger, int orderId); + private static partial void LogOrderNotFound(ILogger logger, OrderId orderId); [LoggerMessage( Level = LogLevel.Information, @@ -133,8 +134,8 @@ public async Task DeleteOrderAsync(int id) )] private static partial void LogOrderCreated( ILogger logger, - int orderId, - string userId, + OrderId orderId, + UserId userId, decimal total ); @@ -144,11 +145,11 @@ decimal total )] private static partial void LogOrderUpdated( ILogger logger, - int orderId, - string userId, + OrderId orderId, + UserId userId, decimal total ); [LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} deleted")] - private static partial void LogOrderDeleted(ILogger logger, int orderId); + private static partial void LogOrderDeleted(ILogger logger, OrderId orderId); } diff --git a/modules/Orders/src/Orders/OrdersDbContext.cs b/modules/Orders/src/Orders/OrdersDbContext.cs index 7e61ac5d..8b6a1173 100644 --- a/modules/Orders/src/Orders/OrdersDbContext.cs +++ b/modules/Orders/src/Orders/OrdersDbContext.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using SimpleModule.Core.Ids; using SimpleModule.Database; using SimpleModule.Orders.Contracts; using SimpleModule.Orders.EntityConfigurations; @@ -20,4 +21,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new OrderItemConfiguration()); modelBuilder.ApplyModuleSchema("Orders", dbOptions.Value); } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.Properties() + .HaveConversion(); + configurationBuilder.Properties() + .HaveConversion(); + configurationBuilder.Properties() + .HaveConversion(); + } } diff --git a/modules/Orders/src/Orders/Views/CreateEndpoint.cs b/modules/Orders/src/Orders/Views/CreateEndpoint.cs index 44854978..a6e45cc7 100644 --- a/modules/Orders/src/Orders/Views/CreateEndpoint.cs +++ b/modules/Orders/src/Orders/Views/CreateEndpoint.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Ids; using SimpleModule.Core.Inertia; using SimpleModule.Orders.Contracts; using SimpleModule.Products.Contracts; @@ -27,11 +28,11 @@ public void Map(IEndpointRouteBuilder app) { var request = new CreateOrderRequest { - UserId = body.UserId, + UserId = UserId.From(body.UserId), Items = body .Items.Select(i => new OrderItem { - ProductId = i.ProductId, + ProductId = ProductId.From(i.ProductId), Quantity = i.Quantity, }) .ToList(), diff --git a/modules/Orders/src/Orders/Views/EditEndpoint.cs b/modules/Orders/src/Orders/Views/EditEndpoint.cs index 8634f631..5dd1deda 100644 --- a/modules/Orders/src/Orders/Views/EditEndpoint.cs +++ b/modules/Orders/src/Orders/Views/EditEndpoint.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Ids; using SimpleModule.Core.Inertia; using SimpleModule.Orders.Contracts; using SimpleModule.Products.Contracts; @@ -14,7 +15,7 @@ public void Map(IEndpointRouteBuilder app) { app.MapGet( "/{id}/edit", - async (int id, IOrderContracts orders, IProductContracts products) => + async (OrderId id, IOrderContracts orders, IProductContracts products) => { var order = await orders.GetOrderByIdAsync(id); if (order is null) @@ -26,11 +27,11 @@ public void Map(IEndpointRouteBuilder app) { order = new { - id = order.Id, - userId = order.UserId, + id = order.Id.Value, + userId = order.UserId.Value, items = order.Items.Select(i => new { - productId = i.ProductId, + productId = i.ProductId.Value, quantity = i.Quantity, }), total = order.Total, @@ -44,15 +45,15 @@ public void Map(IEndpointRouteBuilder app) app.MapPost( "/{id}", - async (int id, UpdateOrderPayload body, IOrderContracts orders) => + async (OrderId id, UpdateOrderPayload body, IOrderContracts orders) => { var request = new UpdateOrderRequest { - UserId = body.UserId, + UserId = UserId.From(body.UserId), Items = body .Items.Select(i => new OrderItem { - ProductId = i.ProductId, + ProductId = ProductId.From(i.ProductId), Quantity = i.Quantity, }) .ToList(), @@ -65,7 +66,7 @@ public void Map(IEndpointRouteBuilder app) app.MapDelete( "/{id}", - async (int id, IOrderContracts orders) => + async (OrderId id, IOrderContracts orders) => { await orders.DeleteOrderAsync(id); return Results.Redirect("/orders"); diff --git a/modules/Orders/tests/Orders.Tests/Integration/OrdersEndpointTests.cs b/modules/Orders/tests/Orders.Tests/Integration/OrdersEndpointTests.cs index 4c28bc95..a924ced9 100644 --- a/modules/Orders/tests/Orders.Tests/Integration/OrdersEndpointTests.cs +++ b/modules/Orders/tests/Orders.Tests/Integration/OrdersEndpointTests.cs @@ -1,6 +1,7 @@ -using System.Net; +using System.Net; using System.Net.Http.Json; using FluentAssertions; +using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; using SimpleModule.Tests.Shared.Fixtures; @@ -36,8 +37,8 @@ public async Task CreateOrder_WithInvalidUserId_Returns404() { var request = new CreateOrderRequest { - UserId = "nonexistent-user-id", - Items = [new OrderItem { ProductId = 1, Quantity = 2 }], + UserId = UserId.From("nonexistent-user-id"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 2 }], }; var response = await _client.PostAsJsonAsync("/api/orders", request); @@ -51,8 +52,8 @@ public async Task UpdateOrder_WithNonExistentId_Returns404() { var request = new UpdateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], }; var response = await _client.PutAsJsonAsync("/api/orders/99999", request); diff --git a/modules/Orders/tests/Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs b/modules/Orders/tests/Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs index 7e740a9b..1a9954e9 100644 --- a/modules/Orders/tests/Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs +++ b/modules/Orders/tests/Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs @@ -1,4 +1,5 @@ -using FluentAssertions; +using FluentAssertions; +using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; using SimpleModule.Orders.Endpoints.Orders; @@ -11,8 +12,8 @@ public void Validate_WithValidRequest_ReturnsSuccess() { var request = new CreateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 2 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 2 }], }; var result = CreateRequestValidator.Validate(request); @@ -22,27 +23,11 @@ public void Validate_WithValidRequest_ReturnsSuccess() } [Fact] - public void Validate_WithEmptyUserId_ReturnsError() + public void Validate_WithDefaultUserId_ReturnsError() { var request = new CreateOrderRequest { - UserId = "", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], - }; - - var result = CreateRequestValidator.Validate(request); - - result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("UserId"); - } - - [Fact] - public void Validate_WithWhitespaceUserId_ReturnsError() - { - var request = new CreateOrderRequest - { - UserId = " ", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], }; var result = CreateRequestValidator.Validate(request); @@ -54,7 +39,7 @@ public void Validate_WithWhitespaceUserId_ReturnsError() [Fact] public void Validate_WithEmptyItems_ReturnsError() { - var request = new CreateOrderRequest { UserId = "1", Items = [] }; + var request = new CreateOrderRequest { UserId = UserId.From("1"), Items = [] }; var result = CreateRequestValidator.Validate(request); @@ -67,8 +52,8 @@ public void Validate_WithZeroQuantity_ReturnsError() { var request = new CreateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 0 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 0 }], }; var result = CreateRequestValidator.Validate(request); @@ -80,7 +65,7 @@ public void Validate_WithZeroQuantity_ReturnsError() [Fact] public void Validate_WithMultipleErrors_ReturnsAllErrors() { - var request = new CreateOrderRequest { UserId = "", Items = [] }; + var request = new CreateOrderRequest { Items = [] }; var result = CreateRequestValidator.Validate(request); diff --git a/modules/Orders/tests/Orders.Tests/Unit/OrderServiceTests.cs b/modules/Orders/tests/Orders.Tests/Unit/OrderServiceTests.cs index e22934a6..805bea42 100644 --- a/modules/Orders/tests/Orders.Tests/Unit/OrderServiceTests.cs +++ b/modules/Orders/tests/Orders.Tests/Unit/OrderServiceTests.cs @@ -1,10 +1,11 @@ -using FluentAssertions; +using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using SimpleModule.Core.Events; using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Ids; using SimpleModule.Database; using SimpleModule.Orders; using SimpleModule.Orders.Contracts; @@ -52,18 +53,20 @@ public OrderServiceTests() [Fact] public async Task CreateOrderAsync_WithValidUserAndProduct_CalculatesCorrectTotal() { - _users.GetUserByIdAsync("1").Returns(new UserDto { Id = "1", DisplayName = "Test" }); + _users + .GetUserByIdAsync(UserId.From("1")) + .Returns(new UserDto { Id = UserId.From("1"), DisplayName = "Test" }); var widget = new Product { - Id = 1, + Id = ProductId.From(1), Name = "Widget", Price = 25.00m, }; _products - .GetProductsByIdsAsync(Arg.Any>()) + .GetProductsByIdsAsync(Arg.Any>()) .Returns(callInfo => { - var ids = callInfo.Arg>().ToHashSet(); + var ids = callInfo.Arg>().ToHashSet(); return new List { widget } .Where(p => ids.Contains(p.Id)) .ToList() as IReadOnlyList; @@ -71,26 +74,26 @@ public async Task CreateOrderAsync_WithValidUserAndProduct_CalculatesCorrectTota var request = new CreateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 3 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 3 }], }; var order = await _sut.CreateOrderAsync(request); order.Total.Should().Be(75.00m); - order.UserId.Should().Be("1"); + order.UserId.Should().Be(UserId.From("1")); order.Items.Should().HaveCount(1); } [Fact] public async Task CreateOrderAsync_WithInvalidUser_ThrowsNotFoundException() { - _users.GetUserByIdAsync("999").Returns((UserDto?)null); + _users.GetUserByIdAsync(UserId.From("999")).Returns((UserDto?)null); var request = new CreateOrderRequest { - UserId = "999", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], + UserId = UserId.From("999"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], }; var act = () => _sut.CreateOrderAsync(request); @@ -101,15 +104,17 @@ public async Task CreateOrderAsync_WithInvalidUser_ThrowsNotFoundException() [Fact] public async Task CreateOrderAsync_WithInvalidProduct_ThrowsNotFoundException() { - _users.GetUserByIdAsync("1").Returns(new UserDto { Id = "1", DisplayName = "Test" }); + _users + .GetUserByIdAsync(UserId.From("1")) + .Returns(new UserDto { Id = UserId.From("1"), DisplayName = "Test" }); _products - .GetProductsByIdsAsync(Arg.Any>()) + .GetProductsByIdsAsync(Arg.Any>()) .Returns(new List() as IReadOnlyList); var request = new CreateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 999, Quantity = 1 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(999), Quantity = 1 }], }; var act = () => _sut.CreateOrderAsync(request); @@ -128,18 +133,20 @@ public async Task GetAllOrdersAsync_ReturnsOrdersList() [Fact] public async Task GetOrderByIdAsync_ReturnsMatchingOrder() { - _users.GetUserByIdAsync("1").Returns(new UserDto { Id = "1", DisplayName = "Test" }); + _users + .GetUserByIdAsync(UserId.From("1")) + .Returns(new UserDto { Id = UserId.From("1"), DisplayName = "Test" }); var widget = new Product { - Id = 1, + Id = ProductId.From(1), Name = "Widget", Price = 10.00m, }; _products - .GetProductsByIdsAsync(Arg.Any>()) + .GetProductsByIdsAsync(Arg.Any>()) .Returns(callInfo => { - var ids = callInfo.Arg>().ToHashSet(); + var ids = callInfo.Arg>().ToHashSet(); return new List { widget } .Where(p => ids.Contains(p.Id)) .ToList() as IReadOnlyList; @@ -147,8 +154,8 @@ public async Task GetOrderByIdAsync_ReturnsMatchingOrder() var request = new CreateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], }; var created = await _sut.CreateOrderAsync(request); @@ -161,18 +168,20 @@ public async Task GetOrderByIdAsync_ReturnsMatchingOrder() [Fact] public async Task UpdateOrderAsync_WithValidData_UpdatesOrder() { - _users.GetUserByIdAsync("1").Returns(new UserDto { Id = "1", DisplayName = "Test" }); + _users + .GetUserByIdAsync(UserId.From("1")) + .Returns(new UserDto { Id = UserId.From("1"), DisplayName = "Test" }); var widget = new Product { - Id = 1, + Id = ProductId.From(1), Name = "Widget", Price = 10.00m, }; _products - .GetProductsByIdsAsync(Arg.Any>()) + .GetProductsByIdsAsync(Arg.Any>()) .Returns(callInfo => { - var ids = callInfo.Arg>().ToHashSet(); + var ids = callInfo.Arg>().ToHashSet(); return new List { widget } .Where(p => ids.Contains(p.Id)) .ToList() as IReadOnlyList; @@ -180,15 +189,15 @@ public async Task UpdateOrderAsync_WithValidData_UpdatesOrder() var createRequest = new CreateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], }; var created = await _sut.CreateOrderAsync(createRequest); var updateRequest = new UpdateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 5 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 5 }], }; var updated = await _sut.UpdateOrderAsync(created.Id, updateRequest); @@ -202,11 +211,11 @@ public async Task UpdateOrderAsync_WithNonExistentOrder_ThrowsNotFoundException( { var request = new UpdateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], }; - var act = () => _sut.UpdateOrderAsync(99999, request); + var act = () => _sut.UpdateOrderAsync(OrderId.From(99999), request); await act.Should().ThrowAsync().WithMessage("*Order*99999*not found*"); } @@ -214,18 +223,20 @@ public async Task UpdateOrderAsync_WithNonExistentOrder_ThrowsNotFoundException( [Fact] public async Task DeleteOrderAsync_WithExistingOrder_RemovesOrder() { - _users.GetUserByIdAsync("1").Returns(new UserDto { Id = "1", DisplayName = "Test" }); + _users + .GetUserByIdAsync(UserId.From("1")) + .Returns(new UserDto { Id = UserId.From("1"), DisplayName = "Test" }); var widget = new Product { - Id = 1, + Id = ProductId.From(1), Name = "Widget", Price = 10.00m, }; _products - .GetProductsByIdsAsync(Arg.Any>()) + .GetProductsByIdsAsync(Arg.Any>()) .Returns(callInfo => { - var ids = callInfo.Arg>().ToHashSet(); + var ids = callInfo.Arg>().ToHashSet(); return new List { widget } .Where(p => ids.Contains(p.Id)) .ToList() as IReadOnlyList; @@ -233,8 +244,8 @@ public async Task DeleteOrderAsync_WithExistingOrder_RemovesOrder() var createRequest = new CreateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], }; var created = await _sut.CreateOrderAsync(createRequest); @@ -247,7 +258,7 @@ public async Task DeleteOrderAsync_WithExistingOrder_RemovesOrder() [Fact] public async Task DeleteOrderAsync_WithNonExistentOrder_ThrowsNotFoundException() { - var act = () => _sut.DeleteOrderAsync(99999); + var act = () => _sut.DeleteOrderAsync(OrderId.From(99999)); await act.Should().ThrowAsync().WithMessage("*Order*99999*not found*"); } diff --git a/modules/Products/src/Products.Contracts/IProductContracts.cs b/modules/Products/src/Products.Contracts/IProductContracts.cs index 1e097a0c..032d82c1 100644 --- a/modules/Products/src/Products.Contracts/IProductContracts.cs +++ b/modules/Products/src/Products.Contracts/IProductContracts.cs @@ -1,11 +1,13 @@ -namespace SimpleModule.Products.Contracts; +using SimpleModule.Core.Ids; + +namespace SimpleModule.Products.Contracts; public interface IProductContracts { Task> GetAllProductsAsync(); - Task GetProductByIdAsync(int id); - Task> GetProductsByIdsAsync(IEnumerable ids); + Task GetProductByIdAsync(ProductId id); + Task> GetProductsByIdsAsync(IEnumerable ids); Task CreateProductAsync(CreateProductRequest request); - Task UpdateProductAsync(int id, UpdateProductRequest request); - Task DeleteProductAsync(int id); + Task UpdateProductAsync(ProductId id, UpdateProductRequest request); + Task DeleteProductAsync(ProductId id); } diff --git a/modules/Products/src/Products.Contracts/Product.cs b/modules/Products/src/Products.Contracts/Product.cs index b58938df..7242dfbc 100644 --- a/modules/Products/src/Products.Contracts/Product.cs +++ b/modules/Products/src/Products.Contracts/Product.cs @@ -1,11 +1,12 @@ using SimpleModule.Core; +using SimpleModule.Core.Ids; namespace SimpleModule.Products.Contracts; [Dto] public class Product { - public int Id { get; set; } + public ProductId Id { get; set; } public string Name { get; set; } = string.Empty; public decimal Price { get; set; } } diff --git a/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs index 3f75fdaa..c00b33d2 100644 --- a/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Ids; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; @@ -12,7 +13,7 @@ public void Map(IEndpointRouteBuilder app) { app.MapDelete( "/{id}", - async (int id, IProductContracts productContracts) => + async (ProductId id, IProductContracts productContracts) => { await productContracts.DeleteProductAsync(id); return TypedResults.NoContent(); diff --git a/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs index 67342d99..4202e445 100644 --- a/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Ids; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; @@ -14,7 +15,7 @@ public void Map(IEndpointRouteBuilder app) app.MapGet( "/{id}", async Task, NotFound>> ( - int id, + ProductId id, IProductContracts productContracts ) => { diff --git a/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs index a0d9e23f..4db3b504 100644 --- a/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Ids; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; @@ -14,7 +15,7 @@ public void Map(IEndpointRouteBuilder app) app.MapPut( "/{id}", async Task, NotFound>> ( - int id, + ProductId id, UpdateProductRequest request, IProductContracts productContracts ) => diff --git a/modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs b/modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs index d40586f6..b115ada3 100644 --- a/modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs +++ b/modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs @@ -1,6 +1,7 @@ using Bogus; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.Core.Ids; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.EntityConfigurations; @@ -10,6 +11,7 @@ public class ProductConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.HasKey(p => p.Id); + builder.Property(p => p.Id).ValueGeneratedOnAdd(); builder.Property(p => p.Name).IsRequired(); builder.Property(p => p.Price).HasColumnType("decimal(18,2)"); @@ -21,7 +23,7 @@ private static Product[] GenerateSeedProducts() var id = 0; var faker = new Faker() .UseSeed(54321) - .RuleFor(p => p.Id, _ => ++id) + .RuleFor(p => p.Id, _ => ProductId.From(++id)) .RuleFor(p => p.Name, f => f.Commerce.ProductName()) .RuleFor( p => p.Price, diff --git a/modules/Products/src/Products/ProductService.cs b/modules/Products/src/Products/ProductService.cs index 5f3494c5..4b1ed8f3 100644 --- a/modules/Products/src/Products/ProductService.cs +++ b/modules/Products/src/Products/ProductService.cs @@ -1,5 +1,6 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using SimpleModule.Core.Ids; using SimpleModule.Products.Contracts; namespace SimpleModule.Products; @@ -10,7 +11,7 @@ public partial class ProductService(ProductsDbContext db, ILogger> GetAllProductsAsync() => await db.Products.ToListAsync(); - public async Task GetProductByIdAsync(int id) + public async Task GetProductByIdAsync(ProductId id) { var product = await db.Products.FindAsync(id); if (product is null) @@ -21,7 +22,7 @@ public async Task> GetAllProductsAsync() => return product; } - public async Task> GetProductsByIdsAsync(IEnumerable ids) + public async Task> GetProductsByIdsAsync(IEnumerable ids) { var idList = ids.ToList(); return await db.Products.Where(p => idList.Contains(p.Id)).ToListAsync(); @@ -39,7 +40,7 @@ public async Task CreateProductAsync(CreateProductRequest request) return product; } - public async Task UpdateProductAsync(int id, UpdateProductRequest request) + public async Task UpdateProductAsync(ProductId id, UpdateProductRequest request) { var product = await db.Products.FindAsync(id); if (product is null) @@ -57,7 +58,7 @@ public async Task UpdateProductAsync(int id, UpdateProductRequest reque return product; } - public async Task DeleteProductAsync(int id) + public async Task DeleteProductAsync(ProductId id) { var product = await db.Products.FindAsync(id); if (product is null) @@ -72,7 +73,7 @@ public async Task DeleteProductAsync(int id) } [LoggerMessage(Level = LogLevel.Warning, Message = "Product with ID {ProductId} not found")] - private static partial void LogProductNotFound(ILogger logger, int productId); + private static partial void LogProductNotFound(ILogger logger, ProductId productId); [LoggerMessage( Level = LogLevel.Information, @@ -80,7 +81,7 @@ public async Task DeleteProductAsync(int id) )] private static partial void LogProductCreated( ILogger logger, - int productId, + ProductId productId, string productName ); @@ -90,10 +91,10 @@ string productName )] private static partial void LogProductUpdated( ILogger logger, - int productId, + ProductId productId, string productName ); [LoggerMessage(Level = LogLevel.Information, Message = "Product {ProductId} deleted")] - private static partial void LogProductDeleted(ILogger logger, int productId); + private static partial void LogProductDeleted(ILogger logger, ProductId productId); } diff --git a/modules/Products/src/Products/ProductsDbContext.cs b/modules/Products/src/Products/ProductsDbContext.cs index 7c50f707..e634a8d2 100644 --- a/modules/Products/src/Products/ProductsDbContext.cs +++ b/modules/Products/src/Products/ProductsDbContext.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using SimpleModule.Core.Ids; using SimpleModule.Database; using SimpleModule.Products.Contracts; using SimpleModule.Products.EntityConfigurations; @@ -18,4 +19,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new ProductConfiguration()); modelBuilder.ApplyModuleSchema("Products", dbOptions.Value); } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.Properties() + .HaveConversion(); + } } diff --git a/modules/Products/src/Products/Views/EditEndpoint.cs b/modules/Products/src/Products/Views/EditEndpoint.cs index 99d48285..7d7018f1 100644 --- a/modules/Products/src/Products/Views/EditEndpoint.cs +++ b/modules/Products/src/Products/Views/EditEndpoint.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Ids; using SimpleModule.Core.Inertia; using SimpleModule.Products.Contracts; @@ -14,7 +15,7 @@ public void Map(IEndpointRouteBuilder app) { app.MapGet( "/{id}/edit", - async (int id, IProductContracts products) => + async (ProductId id, IProductContracts products) => { var product = await products.GetProductByIdAsync(id); if (product is null) @@ -27,7 +28,7 @@ public void Map(IEndpointRouteBuilder app) app.MapPost( "/{id}", async ( - int id, + ProductId id, [FromForm] string name, [FromForm] decimal price, IProductContracts products @@ -42,7 +43,7 @@ IProductContracts products app.MapDelete( "/{id}", - async (int id, IProductContracts products) => + async (ProductId id, IProductContracts products) => { await products.DeleteProductAsync(id); return Results.Redirect("/products/manage"); diff --git a/modules/Products/tests/Products.Tests/Integration/ProductsEndpointTests.cs b/modules/Products/tests/Products.Tests/Integration/ProductsEndpointTests.cs index 726e6063..880f99d9 100644 --- a/modules/Products/tests/Products.Tests/Integration/ProductsEndpointTests.cs +++ b/modules/Products/tests/Products.Tests/Integration/ProductsEndpointTests.cs @@ -1,6 +1,7 @@ -using System.Net; +using System.Net; using System.Net.Http.Json; using FluentAssertions; +using SimpleModule.Core.Ids; using SimpleModule.Products.Contracts; using SimpleModule.Tests.Shared.Fixtures; @@ -33,7 +34,7 @@ public async Task GetProductById_Returns200WithProduct() response.StatusCode.Should().Be(HttpStatusCode.OK); var product = await response.Content.ReadFromJsonAsync(); product.Should().NotBeNull(); - product!.Id.Should().Be(1); + product!.Id.Should().Be(ProductId.From(1)); } [Fact] diff --git a/modules/Products/tests/Products.Tests/Unit/ProductServiceTests.cs b/modules/Products/tests/Products.Tests/Unit/ProductServiceTests.cs index e8551802..a355516f 100644 --- a/modules/Products/tests/Products.Tests/Unit/ProductServiceTests.cs +++ b/modules/Products/tests/Products.Tests/Unit/ProductServiceTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using SimpleModule.Core.Exceptions; +using SimpleModule.Core.Ids; using SimpleModule.Database; using SimpleModule.Products; using SimpleModule.Products.Contracts; @@ -46,7 +47,7 @@ public async Task GetAllProductsAsync_ReturnsAllProducts() .Should() .AllSatisfy(p => { - p.Id.Should().BeGreaterThan(0); + p.Id.Value.Should().BeGreaterThan(0); p.Name.Should().NotBeNullOrWhiteSpace(); p.Price.Should().BeGreaterThan(0); }); @@ -55,16 +56,16 @@ public async Task GetAllProductsAsync_ReturnsAllProducts() [Fact] public async Task GetProductByIdAsync_WithExistingId_ReturnsProduct() { - var product = await _sut.GetProductByIdAsync(1); + var product = await _sut.GetProductByIdAsync(ProductId.From(1)); product.Should().NotBeNull(); - product!.Id.Should().Be(1); + product!.Id.Should().Be(ProductId.From(1)); } [Fact] public async Task GetProductByIdAsync_WithNonExistentId_ReturnsNull() { - var product = await _sut.GetProductByIdAsync(99999); + var product = await _sut.GetProductByIdAsync(ProductId.From(99999)); product.Should().BeNull(); } @@ -79,7 +80,7 @@ public async Task CreateProductAsync_CreatesAndReturnsProduct() product.Should().NotBeNull(); product.Name.Should().Be("Test Widget"); product.Price.Should().Be(19.99m); - product.Id.Should().BeGreaterThan(0); + product.Id.Value.Should().BeGreaterThan(0); } [Fact] @@ -100,7 +101,7 @@ public async Task UpdateProductAsync_WithNonExistentId_ThrowsNotFoundException() { var request = new UpdateProductRequest { Name = "Test", Price = 10.00m }; - var act = () => _sut.UpdateProductAsync(99999, request); + var act = () => _sut.UpdateProductAsync(ProductId.From(99999), request); await act.Should().ThrowAsync().WithMessage("*Product*99999*not found*"); } @@ -120,7 +121,7 @@ public async Task DeleteProductAsync_WithExistingId_RemovesProduct() [Fact] public async Task DeleteProductAsync_WithNonExistentId_ThrowsNotFoundException() { - var act = () => _sut.DeleteProductAsync(99999); + var act = () => _sut.DeleteProductAsync(ProductId.From(99999)); await act.Should().ThrowAsync().WithMessage("*Product*99999*not found*"); } diff --git a/modules/Users/src/Users.Contracts/IUserContracts.cs b/modules/Users/src/Users.Contracts/IUserContracts.cs index aaa8fbcf..6ce42d07 100644 --- a/modules/Users/src/Users.Contracts/IUserContracts.cs +++ b/modules/Users/src/Users.Contracts/IUserContracts.cs @@ -1,11 +1,13 @@ -namespace SimpleModule.Users.Contracts; +using SimpleModule.Core.Ids; + +namespace SimpleModule.Users.Contracts; public interface IUserContracts { Task> GetAllUsersAsync(); - Task GetUserByIdAsync(string id); - Task GetCurrentUserAsync(string userId); + Task GetUserByIdAsync(UserId id); + Task GetCurrentUserAsync(UserId userId); Task CreateUserAsync(CreateUserRequest request); - Task UpdateUserAsync(string id, UpdateUserRequest request); - Task DeleteUserAsync(string id); + Task UpdateUserAsync(UserId id, UpdateUserRequest request); + Task DeleteUserAsync(UserId id); } diff --git a/modules/Users/src/Users.Contracts/UserDto.cs b/modules/Users/src/Users.Contracts/UserDto.cs index eaecc6a7..b1465b0b 100644 --- a/modules/Users/src/Users.Contracts/UserDto.cs +++ b/modules/Users/src/Users.Contracts/UserDto.cs @@ -1,11 +1,12 @@ -using SimpleModule.Core; +using SimpleModule.Core; +using SimpleModule.Core.Ids; namespace SimpleModule.Users.Contracts; [Dto] public class UserDto { - public string Id { get; set; } = string.Empty; + public UserId Id { get; set; } public string Email { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public bool EmailConfirmed { get; set; } diff --git a/modules/Users/src/Users/Endpoints/Users/DeleteEndpoint.cs b/modules/Users/src/Users/Endpoints/Users/DeleteEndpoint.cs index b33c3ec4..8c2315c3 100644 --- a/modules/Users/src/Users/Endpoints/Users/DeleteEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Users/DeleteEndpoint.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Ids; using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Endpoints.Users; @@ -12,7 +13,7 @@ public void Map(IEndpointRouteBuilder app) { app.MapDelete( UsersConstants.RoutePrefix + "/{id}", - async (string id, IUserContracts userContracts) => + async (UserId id, IUserContracts userContracts) => { await userContracts.DeleteUserAsync(id); return TypedResults.NoContent(); diff --git a/modules/Users/src/Users/Endpoints/Users/GetByIdEndpoint.cs b/modules/Users/src/Users/Endpoints/Users/GetByIdEndpoint.cs index 345be546..31d9a39d 100644 --- a/modules/Users/src/Users/Endpoints/Users/GetByIdEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Users/GetByIdEndpoint.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Ids; using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Endpoints.Users; @@ -14,7 +15,7 @@ public void Map(IEndpointRouteBuilder app) app.MapGet( UsersConstants.RoutePrefix + "/{id}", async Task, NotFound>> ( - string id, + UserId id, IUserContracts userContracts ) => { diff --git a/modules/Users/src/Users/Endpoints/Users/GetCurrentEndpoint.cs b/modules/Users/src/Users/Endpoints/Users/GetCurrentEndpoint.cs index 4e2d2b54..dd3d0d93 100644 --- a/modules/Users/src/Users/Endpoints/Users/GetCurrentEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Users/GetCurrentEndpoint.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Ids; using SimpleModule.Users.Contracts; using SimpleModule.Users.Extensions; @@ -20,13 +21,13 @@ async Task, NotFound>> ( IUserContracts userContracts ) => { - var userId = principal.GetUserId(); - if (string.IsNullOrEmpty(userId)) + var userIdStr = principal.GetUserId(); + if (string.IsNullOrEmpty(userIdStr)) { return TypedResults.NotFound(); } - var user = await userContracts.GetCurrentUserAsync(userId); + var user = await userContracts.GetCurrentUserAsync(UserId.From(userIdStr)); return user is not null ? TypedResults.Ok(user) : TypedResults.NotFound(); } ) diff --git a/modules/Users/src/Users/Endpoints/Users/UpdateEndpoint.cs b/modules/Users/src/Users/Endpoints/Users/UpdateEndpoint.cs index ed9b9e86..56620196 100644 --- a/modules/Users/src/Users/Endpoints/Users/UpdateEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Users/UpdateEndpoint.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Ids; using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Endpoints.Users; @@ -14,7 +15,7 @@ public void Map(IEndpointRouteBuilder app) app.MapPut( UsersConstants.RoutePrefix + "/{id}", async Task, NotFound>> ( - string id, + UserId id, UpdateUserRequest request, IUserContracts userContracts ) => diff --git a/modules/Users/src/Users/UserService.cs b/modules/Users/src/Users/UserService.cs index bb23bebc..a2fb5e5d 100644 --- a/modules/Users/src/Users/UserService.cs +++ b/modules/Users/src/Users/UserService.cs @@ -1,6 +1,7 @@ -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using SimpleModule.Core.Ids; using SimpleModule.Users.Contracts; using SimpleModule.Users.Entities; @@ -17,9 +18,9 @@ public async Task> GetAllUsersAsync() return users.Select(MapToDto); } - public async Task GetUserByIdAsync(string id) + public async Task GetUserByIdAsync(UserId id) { - var user = await userManager.FindByIdAsync(id); + var user = await userManager.FindByIdAsync(id.Value); if (user is null) { LogUserNotFound(logger, id); @@ -29,7 +30,7 @@ public async Task> GetAllUsersAsync() return MapToDto(user); } - public async Task GetCurrentUserAsync(string userId) + public async Task GetCurrentUserAsync(UserId userId) { return await GetUserByIdAsync(userId); } @@ -37,7 +38,7 @@ public async Task> GetAllUsersAsync() private static UserDto MapToDto(ApplicationUser user) => new() { - Id = user.Id, + Id = UserId.From(user.Id), Email = user.Email ?? string.Empty, DisplayName = user.DisplayName, EmailConfirmed = user.EmailConfirmed, @@ -65,9 +66,9 @@ public async Task CreateUserAsync(CreateUserRequest request) return MapToDto(user); } - public async Task UpdateUserAsync(string id, UpdateUserRequest request) + public async Task UpdateUserAsync(UserId id, UpdateUserRequest request) { - var user = await userManager.FindByIdAsync(id); + var user = await userManager.FindByIdAsync(id.Value); if (user is null) { throw new Core.Exceptions.NotFoundException("User", id); @@ -84,9 +85,9 @@ public async Task UpdateUserAsync(string id, UpdateUserRequest request) return MapToDto(user); } - public async Task DeleteUserAsync(string id) + public async Task DeleteUserAsync(UserId id) { - var user = await userManager.FindByIdAsync(id); + var user = await userManager.FindByIdAsync(id.Value); if (user is null) { throw new Core.Exceptions.NotFoundException("User", id); @@ -98,7 +99,7 @@ public async Task DeleteUserAsync(string id) } [LoggerMessage(Level = LogLevel.Warning, Message = "User with ID {UserId} not found")] - private static partial void LogUserNotFound(ILogger logger, string userId); + private static partial void LogUserNotFound(ILogger logger, UserId userId); [LoggerMessage( Level = LogLevel.Information, @@ -110,5 +111,5 @@ public async Task DeleteUserAsync(string id) private static partial void LogUserUpdated(ILogger logger, string userId); [LoggerMessage(Level = LogLevel.Information, Message = "User {UserId} deleted")] - private static partial void LogUserDeleted(ILogger logger, string userId); + private static partial void LogUserDeleted(ILogger logger, UserId userId); } diff --git a/modules/Users/tests/Users.Tests/Unit/UserServiceTests.cs b/modules/Users/tests/Users.Tests/Unit/UserServiceTests.cs index 72fa5376..b77de841 100644 --- a/modules/Users/tests/Users.Tests/Unit/UserServiceTests.cs +++ b/modules/Users/tests/Users.Tests/Unit/UserServiceTests.cs @@ -1,7 +1,8 @@ -using FluentAssertions; +using FluentAssertions; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; +using SimpleModule.Core.Ids; using SimpleModule.Users; using SimpleModule.Users.Contracts; using SimpleModule.Users.Entities; @@ -41,10 +42,10 @@ public async Task GetUserByIdAsync_WithExistingUser_ReturnsUserDto() }; _userManager.FindByIdAsync("1").Returns(appUser); - var user = await _sut.GetUserByIdAsync("1"); + var user = await _sut.GetUserByIdAsync(UserId.From("1")); user.Should().NotBeNull(); - user!.Id.Should().Be("1"); + user!.Id.Should().Be(UserId.From("1")); user.DisplayName.Should().Be("Test User"); user.Email.Should().Be("test@test.com"); } @@ -54,7 +55,7 @@ public async Task GetUserByIdAsync_WithNonExistingUser_ReturnsNull() { _userManager.FindByIdAsync("999").Returns((ApplicationUser?)null); - var user = await _sut.GetUserByIdAsync("999"); + var user = await _sut.GetUserByIdAsync(UserId.From("999")); user.Should().BeNull(); } @@ -70,22 +71,15 @@ public async Task GetCurrentUserAsync_DelegatesToGetUserByIdAsync() }; _userManager.FindByIdAsync("1").Returns(appUser); - var user = await _sut.GetCurrentUserAsync("1"); + var user = await _sut.GetCurrentUserAsync(UserId.From("1")); user.Should().NotBeNull(); - user!.Id.Should().Be("1"); + user!.Id.Should().Be(UserId.From("1")); } [Fact] public async Task CreateUserAsync_WithValidData_ReturnsUserDto() { - var appUser = new ApplicationUser - { - Id = "new-id", - Email = "new@test.com", - UserName = "new@test.com", - DisplayName = "New User", - }; _userManager .CreateAsync(Arg.Any(), Arg.Any()) .Returns(callInfo => @@ -150,7 +144,7 @@ public async Task UpdateUserAsync_WithValidData_UpdatesUser() var request = new UpdateUserRequest { Email = "new@test.com", DisplayName = "New Name" }; - var user = await _sut.UpdateUserAsync("1", request); + var user = await _sut.UpdateUserAsync(UserId.From("1"), request); user.Should().NotBeNull(); user.Email.Should().Be("new@test.com"); @@ -164,7 +158,7 @@ public async Task UpdateUserAsync_WithNonExistentUser_ThrowsNotFoundException() var request = new UpdateUserRequest { Email = "test@test.com", DisplayName = "Test" }; - var act = () => _sut.UpdateUserAsync("999", request); + var act = () => _sut.UpdateUserAsync(UserId.From("999"), request); await act.Should().ThrowAsync(); } @@ -181,7 +175,7 @@ public async Task DeleteUserAsync_WithExistingUser_DeletesUser() _userManager.FindByIdAsync("1").Returns(appUser); _userManager.DeleteAsync(Arg.Any()).Returns(IdentityResult.Success); - await _sut.DeleteUserAsync("1"); + await _sut.DeleteUserAsync(UserId.From("1")); await _userManager.Received(1).DeleteAsync(appUser); } @@ -191,7 +185,7 @@ public async Task DeleteUserAsync_WithNonExistentUser_ThrowsNotFoundException() { _userManager.FindByIdAsync("999").Returns((ApplicationUser?)null); - var act = () => _sut.DeleteUserAsync("999"); + var act = () => _sut.DeleteUserAsync(UserId.From("999")); await act.Should().ThrowAsync(); } diff --git a/template/SimpleModule.Host/HostDbContext.Conventions.cs b/template/SimpleModule.Host/HostDbContext.Conventions.cs new file mode 100644 index 00000000..354f30aa --- /dev/null +++ b/template/SimpleModule.Host/HostDbContext.Conventions.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core.Ids; + +namespace SimpleModule.Host; + +public partial class HostDbContext +{ + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.Properties() + .HaveConversion(); + configurationBuilder.Properties() + .HaveConversion(); + configurationBuilder.Properties() + .HaveConversion(); + } +} diff --git a/template/SimpleModule.Host/Migrations/20260318090728_InitialCreate.Designer.cs b/template/SimpleModule.Host/Migrations/20260318094606_InitialCreate.Designer.cs similarity index 99% rename from template/SimpleModule.Host/Migrations/20260318090728_InitialCreate.Designer.cs rename to template/SimpleModule.Host/Migrations/20260318094606_InitialCreate.Designer.cs index eb3e1b01..a6c63bb1 100644 --- a/template/SimpleModule.Host/Migrations/20260318090728_InitialCreate.Designer.cs +++ b/template/SimpleModule.Host/Migrations/20260318094606_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace SimpleModule.Host.Migrations { [DbContext(typeof(HostDbContext))] - [Migration("20260318090728_InitialCreate")] + [Migration("20260318094606_InitialCreate")] partial class InitialCreate { /// diff --git a/template/SimpleModule.Host/Migrations/20260318090728_InitialCreate.cs b/template/SimpleModule.Host/Migrations/20260318094606_InitialCreate.cs similarity index 100% rename from template/SimpleModule.Host/Migrations/20260318090728_InitialCreate.cs rename to template/SimpleModule.Host/Migrations/20260318094606_InitialCreate.cs diff --git a/template/SimpleModule.Host/Program.cs b/template/SimpleModule.Host/Program.cs index 4a3096ba..cfac7ba7 100644 --- a/template/SimpleModule.Host/Program.cs +++ b/template/SimpleModule.Host/Program.cs @@ -135,12 +135,19 @@ var app = builder.Build(); -// Apply pending EF Core migrations (production should use explicit migration tooling) +// Apply database schema (production should use explicit migration tooling) if (!app.Environment.IsProduction()) { using var scope = app.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - await db.Database.MigrateAsync(); + if (app.Environment.IsDevelopment()) + { + await db.Database.MigrateAsync(); + } + else + { + await db.Database.EnsureCreatedAsync(); + } } app.UseExceptionHandler(); diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs index c5394ad6..750c7cf8 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs @@ -1,5 +1,6 @@ -using System.Globalization; +using System.Globalization; using Bogus; +using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; using SimpleModule.Products.Contracts; using SimpleModule.Users.Contracts; @@ -10,7 +11,10 @@ public static class FakeDataGenerators { public static Faker UserFaker { get; } = new Faker() - .RuleFor(u => u.Id, f => (f.IndexFaker + 1).ToString(CultureInfo.InvariantCulture)) + .RuleFor( + u => u.Id, + f => UserId.From((f.IndexFaker + 1).ToString(CultureInfo.InvariantCulture)) + ) .RuleFor(u => u.Email, f => f.Internet.Email()) .RuleFor(u => u.DisplayName, f => f.Person.FullName) .RuleFor(u => u.EmailConfirmed, _ => true) @@ -18,21 +22,21 @@ public static class FakeDataGenerators public static Faker ProductFaker { get; } = new Faker() - .RuleFor(p => p.Id, f => f.IndexFaker + 1) + .RuleFor(p => p.Id, f => ProductId.From(f.IndexFaker + 1)) .RuleFor(p => p.Name, f => f.Commerce.ProductName()) .RuleFor(p => p.Price, f => f.Finance.Amount(1, 1000)); public static Faker OrderItemFaker { get; } = new Faker() - .RuleFor(oi => oi.ProductId, f => f.Random.Int(1, 100)) + .RuleFor(oi => oi.ProductId, f => ProductId.From(f.Random.Int(1, 100))) .RuleFor(oi => oi.Quantity, f => f.Random.Int(1, 10)); public static Faker OrderFaker { get; } = new Faker() - .RuleFor(o => o.Id, f => f.IndexFaker + 1) + .RuleFor(o => o.Id, f => OrderId.From(f.IndexFaker + 1)) .RuleFor( o => o.UserId, - f => f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture) + f => UserId.From(f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture)) ) .RuleFor(o => o.Items, f => OrderItemFaker.Generate(f.Random.Int(1, 3))) .RuleFor(o => o.Total, f => f.Finance.Amount(10, 500)) @@ -42,7 +46,7 @@ public static class FakeDataGenerators new Faker() .RuleFor( r => r.UserId, - f => f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture) + f => UserId.From(f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture)) ) .RuleFor(r => r.Items, f => OrderItemFaker.Generate(f.Random.Int(1, 3))); @@ -50,7 +54,7 @@ public static class FakeDataGenerators new Faker() .RuleFor( r => r.UserId, - f => f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture) + f => UserId.From(f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture)) ) .RuleFor(r => r.Items, f => OrderItemFaker.Generate(f.Random.Int(1, 3))); diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeOrderContracts.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeOrderContracts.cs index 488f40e9..c8d3ec81 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeOrderContracts.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeOrderContracts.cs @@ -1,4 +1,5 @@ -using SimpleModule.Orders.Contracts; +using SimpleModule.Core.Ids; +using SimpleModule.Orders.Contracts; namespace SimpleModule.Tests.Shared.Fakes; @@ -11,14 +12,14 @@ public class FakeOrderContracts : IOrderContracts public Task> GetAllOrdersAsync() => Task.FromResult>(Orders); - public Task GetOrderByIdAsync(int id) => + public Task GetOrderByIdAsync(OrderId id) => Task.FromResult(Orders.FirstOrDefault(o => o.Id == id)); public Task CreateOrderAsync(CreateOrderRequest request) { var order = new Order { - Id = _nextId++, + Id = OrderId.From(_nextId++), UserId = request.UserId, Items = request.Items, Total = 0m, @@ -28,7 +29,7 @@ public Task CreateOrderAsync(CreateOrderRequest request) return Task.FromResult(order); } - public Task UpdateOrderAsync(int id, UpdateOrderRequest request) + public Task UpdateOrderAsync(OrderId id, UpdateOrderRequest request) { var order = Orders.FirstOrDefault(o => o.Id == id); if (order is null) @@ -41,7 +42,7 @@ public Task UpdateOrderAsync(int id, UpdateOrderRequest request) return Task.FromResult(order); } - public Task DeleteOrderAsync(int id) + public Task DeleteOrderAsync(OrderId id) { var order = Orders.FirstOrDefault(o => o.Id == id); if (order is null) diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeProductContracts.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeProductContracts.cs index 7df3b2f4..e9aa9962 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeProductContracts.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeProductContracts.cs @@ -1,4 +1,5 @@ -using SimpleModule.Products.Contracts; +using SimpleModule.Core.Ids; +using SimpleModule.Products.Contracts; namespace SimpleModule.Tests.Shared.Fakes; @@ -9,10 +10,10 @@ public class FakeProductContracts : IProductContracts public Task> GetAllProductsAsync() => Task.FromResult>(Products); - public Task GetProductByIdAsync(int id) => + public Task GetProductByIdAsync(ProductId id) => Task.FromResult(Products.FirstOrDefault(p => p.Id == id)); - public Task> GetProductsByIdsAsync(IEnumerable ids) + public Task> GetProductsByIdsAsync(IEnumerable ids) { var idSet = ids.ToHashSet(); return Task.FromResult>( @@ -26,7 +27,7 @@ public Task CreateProductAsync(CreateProductRequest request) { var product = new Product { - Id = _nextId++, + Id = ProductId.From(_nextId++), Name = request.Name, Price = request.Price, }; @@ -34,7 +35,7 @@ public Task CreateProductAsync(CreateProductRequest request) return Task.FromResult(product); } - public Task UpdateProductAsync(int id, UpdateProductRequest request) + public Task UpdateProductAsync(ProductId id, UpdateProductRequest request) { var product = Products.FirstOrDefault(p => p.Id == id); if (product is null) @@ -47,7 +48,7 @@ public Task UpdateProductAsync(int id, UpdateProductRequest request) return Task.FromResult(product); } - public Task DeleteProductAsync(int id) + public Task DeleteProductAsync(ProductId id) { var product = Products.FirstOrDefault(p => p.Id == id); if (product is null) diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs index 6cf00c74..a6406933 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs @@ -1,4 +1,5 @@ -using System.Globalization; +using System.Globalization; +using SimpleModule.Core.Ids; using SimpleModule.Users.Contracts; namespace SimpleModule.Tests.Shared.Fakes; @@ -10,10 +11,10 @@ public class FakeUserContracts : IUserContracts public Task> GetAllUsersAsync() => Task.FromResult>(Users); - public Task GetUserByIdAsync(string id) => + public Task GetUserByIdAsync(UserId id) => Task.FromResult(Users.FirstOrDefault(u => u.Id == id)); - public Task GetCurrentUserAsync(string userId) => + public Task GetCurrentUserAsync(UserId userId) => Task.FromResult(Users.FirstOrDefault(u => u.Id == userId)); private int _nextId = 100; @@ -22,7 +23,7 @@ public Task CreateUserAsync(CreateUserRequest request) { var user = new UserDto { - Id = (_nextId++).ToString(CultureInfo.InvariantCulture), + Id = UserId.From((_nextId++).ToString(CultureInfo.InvariantCulture)), Email = request.Email, DisplayName = request.DisplayName, EmailConfirmed = false, @@ -32,7 +33,7 @@ public Task CreateUserAsync(CreateUserRequest request) return Task.FromResult(user); } - public Task UpdateUserAsync(string id, UpdateUserRequest request) + public Task UpdateUserAsync(UserId id, UpdateUserRequest request) { var user = Users.FirstOrDefault(u => u.Id == id); if (user is null) @@ -45,7 +46,7 @@ public Task UpdateUserAsync(string id, UpdateUserRequest request) return Task.FromResult(user); } - public Task DeleteUserAsync(string id) + public Task DeleteUserAsync(UserId id) { var user = Users.FirstOrDefault(u => u.Id == id); if (user is null) diff --git a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs index 3b044a70..e3303ea6 100644 --- a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs +++ b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using SimpleModule.Host; using SimpleModule.Orders; using SimpleModule.Products; using SimpleModule.Users; @@ -29,6 +30,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.ConfigureServices(services => { + ReplaceDbContext(services, useOpenIddict: true); ReplaceDbContext(services, useOpenIddict: true); ReplaceDbContext(services); ReplaceDbContext(services); From e9077bc3c3ceead4fd6ea93f39298055b1fd5375 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 10:56:33 +0100 Subject: [PATCH 026/112] refactor: move strongly-typed IDs to module contracts projects Co-Authored-By: Claude Opus 4.6 (1M context) --- framework/SimpleModule.Core/SimpleModule.Core.csproj | 3 --- .../SimpleModule.Generator/Helpers/TypeMappingHelpers.cs | 4 ++-- modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs | 2 +- .../Orders/src/Orders.Contracts/Events/OrderCreatedEvent.cs | 2 +- modules/Orders/src/Orders.Contracts/IOrderContracts.cs | 1 - modules/Orders/src/Orders.Contracts/Order.cs | 2 +- .../Ids => modules/Orders/src/Orders.Contracts}/OrderId.cs | 2 +- modules/Orders/src/Orders.Contracts/OrderItem.cs | 2 +- modules/Orders/src/Orders.Contracts/Orders.Contracts.csproj | 5 +++++ modules/Orders/src/Orders.Contracts/UpdateOrderRequest.cs | 2 +- modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs | 1 - .../Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs | 1 - modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs | 1 - .../src/Orders/EntityConfigurations/OrderConfiguration.cs | 3 ++- modules/Orders/src/Orders/OrderService.cs | 1 - modules/Orders/src/Orders/OrdersDbContext.cs | 3 ++- modules/Orders/src/Orders/Views/CreateEndpoint.cs | 2 +- modules/Orders/src/Orders/Views/EditEndpoint.cs | 2 +- .../tests/Orders.Tests/Integration/OrdersEndpointTests.cs | 3 ++- .../Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs | 3 ++- modules/Orders/tests/Orders.Tests/Unit/OrderServiceTests.cs | 1 - modules/Products/src/Products.Contracts/IProductContracts.cs | 1 - modules/Products/src/Products.Contracts/Product.cs | 1 - .../Products/src/Products.Contracts}/ProductId.cs | 2 +- .../src/Products.Contracts/Products.Contracts.csproj | 3 +++ .../src/Products/Endpoints/Products/DeleteEndpoint.cs | 1 - .../src/Products/Endpoints/Products/GetByIdEndpoint.cs | 1 - .../src/Products/Endpoints/Products/UpdateEndpoint.cs | 1 - .../Products/EntityConfigurations/ProductConfiguration.cs | 1 - modules/Products/src/Products/ProductService.cs | 1 - modules/Products/src/Products/ProductsDbContext.cs | 1 - modules/Products/src/Products/Views/EditEndpoint.cs | 1 - .../Products.Tests/Integration/ProductsEndpointTests.cs | 1 - .../tests/Products.Tests/Unit/ProductServiceTests.cs | 1 - modules/Users/src/Users.Contracts/IUserContracts.cs | 1 - modules/Users/src/Users.Contracts/UserDto.cs | 1 - .../Ids => modules/Users/src/Users.Contracts}/UserId.cs | 2 +- modules/Users/src/Users.Contracts/Users.Contracts.csproj | 3 +++ modules/Users/src/Users/Endpoints/Users/DeleteEndpoint.cs | 1 - modules/Users/src/Users/Endpoints/Users/GetByIdEndpoint.cs | 1 - .../Users/src/Users/Endpoints/Users/GetCurrentEndpoint.cs | 1 - modules/Users/src/Users/Endpoints/Users/UpdateEndpoint.cs | 1 - modules/Users/src/Users/UserService.cs | 1 - modules/Users/tests/Users.Tests/Unit/UserServiceTests.cs | 1 - template/SimpleModule.Host/HostDbContext.Conventions.cs | 4 +++- tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs | 1 - tests/SimpleModule.Tests.Shared/Fakes/FakeOrderContracts.cs | 1 - .../SimpleModule.Tests.Shared/Fakes/FakeProductContracts.cs | 1 - tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs | 1 - 49 files changed, 34 insertions(+), 49 deletions(-) rename {framework/SimpleModule.Core/Ids => modules/Orders/src/Orders.Contracts}/OrderId.cs (78%) rename {framework/SimpleModule.Core/Ids => modules/Products/src/Products.Contracts}/ProductId.cs (77%) rename {framework/SimpleModule.Core/Ids => modules/Users/src/Users.Contracts}/UserId.cs (89%) diff --git a/framework/SimpleModule.Core/SimpleModule.Core.csproj b/framework/SimpleModule.Core/SimpleModule.Core.csproj index daa0cf88..d840f9b4 100644 --- a/framework/SimpleModule.Core/SimpleModule.Core.csproj +++ b/framework/SimpleModule.Core/SimpleModule.Core.csproj @@ -2,12 +2,9 @@ net10.0 Library - $(DefineConstants);VOGEN_NO_VALIDATION - - diff --git a/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs b/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs index 285bdd21..c6bea26a 100644 --- a/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs +++ b/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs @@ -75,8 +75,8 @@ knownDtoTypes is not null or "System.TimeOnly" => "string", "System.Guid" => "string", // Vogen strongly-typed IDs (map to their underlying primitive types) - "SimpleModule.Core.Ids.ProductId" or "SimpleModule.Core.Ids.OrderId" => "number", - "SimpleModule.Core.Ids.UserId" => "string", + "SimpleModule.Products.Contracts.ProductId" or "SimpleModule.Orders.Contracts.OrderId" => "number", + "SimpleModule.Users.Contracts.UserId" => "string", _ => "any", }; } diff --git a/modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs b/modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs index 549836ee..9662fdce 100644 --- a/modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs +++ b/modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs @@ -1,5 +1,5 @@ using SimpleModule.Core; -using SimpleModule.Core.Ids; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Contracts; diff --git a/modules/Orders/src/Orders.Contracts/Events/OrderCreatedEvent.cs b/modules/Orders/src/Orders.Contracts/Events/OrderCreatedEvent.cs index 8110dee4..ef0fb96e 100644 --- a/modules/Orders/src/Orders.Contracts/Events/OrderCreatedEvent.cs +++ b/modules/Orders/src/Orders.Contracts/Events/OrderCreatedEvent.cs @@ -1,5 +1,5 @@ using SimpleModule.Core.Events; -using SimpleModule.Core.Ids; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Contracts.Events; diff --git a/modules/Orders/src/Orders.Contracts/IOrderContracts.cs b/modules/Orders/src/Orders.Contracts/IOrderContracts.cs index 0eb39af3..86d11d55 100644 --- a/modules/Orders/src/Orders.Contracts/IOrderContracts.cs +++ b/modules/Orders/src/Orders.Contracts/IOrderContracts.cs @@ -1,4 +1,3 @@ -using SimpleModule.Core.Ids; namespace SimpleModule.Orders.Contracts; diff --git a/modules/Orders/src/Orders.Contracts/Order.cs b/modules/Orders/src/Orders.Contracts/Order.cs index ded54043..8cf3d945 100644 --- a/modules/Orders/src/Orders.Contracts/Order.cs +++ b/modules/Orders/src/Orders.Contracts/Order.cs @@ -1,5 +1,5 @@ using SimpleModule.Core; -using SimpleModule.Core.Ids; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Contracts; diff --git a/framework/SimpleModule.Core/Ids/OrderId.cs b/modules/Orders/src/Orders.Contracts/OrderId.cs similarity index 78% rename from framework/SimpleModule.Core/Ids/OrderId.cs rename to modules/Orders/src/Orders.Contracts/OrderId.cs index 27c674f7..c5ceaacd 100644 --- a/framework/SimpleModule.Core/Ids/OrderId.cs +++ b/modules/Orders/src/Orders.Contracts/OrderId.cs @@ -1,6 +1,6 @@ using Vogen; -namespace SimpleModule.Core.Ids; +namespace SimpleModule.Orders.Contracts; [ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] public readonly partial struct OrderId; diff --git a/modules/Orders/src/Orders.Contracts/OrderItem.cs b/modules/Orders/src/Orders.Contracts/OrderItem.cs index 539fb0c2..625c34f3 100644 --- a/modules/Orders/src/Orders.Contracts/OrderItem.cs +++ b/modules/Orders/src/Orders.Contracts/OrderItem.cs @@ -1,5 +1,5 @@ using SimpleModule.Core; -using SimpleModule.Core.Ids; +using SimpleModule.Products.Contracts; namespace SimpleModule.Orders.Contracts; diff --git a/modules/Orders/src/Orders.Contracts/Orders.Contracts.csproj b/modules/Orders/src/Orders.Contracts/Orders.Contracts.csproj index b33451b0..10bfd20d 100644 --- a/modules/Orders/src/Orders.Contracts/Orders.Contracts.csproj +++ b/modules/Orders/src/Orders.Contracts/Orders.Contracts.csproj @@ -2,8 +2,13 @@ net10.0 Library + $(DefineConstants);VOGEN_NO_VALIDATION + + + + diff --git a/modules/Orders/src/Orders.Contracts/UpdateOrderRequest.cs b/modules/Orders/src/Orders.Contracts/UpdateOrderRequest.cs index 60dd3b95..d5d29910 100644 --- a/modules/Orders/src/Orders.Contracts/UpdateOrderRequest.cs +++ b/modules/Orders/src/Orders.Contracts/UpdateOrderRequest.cs @@ -1,5 +1,5 @@ using SimpleModule.Core; -using SimpleModule.Core.Ids; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Contracts; diff --git a/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs index ce86816c..90675e4f 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; -using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; diff --git a/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs index 16e0cbc7..c3739d0f 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; -using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; diff --git a/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs index c69c8224..5b265844 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Exceptions; -using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; diff --git a/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs b/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs index eb967243..00dadd65 100644 --- a/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs +++ b/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs @@ -1,8 +1,9 @@ using Bogus; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; +using SimpleModule.Products.Contracts; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.EntityConfigurations; diff --git a/modules/Orders/src/Orders/OrderService.cs b/modules/Orders/src/Orders/OrderService.cs index 75f2e61e..61303692 100644 --- a/modules/Orders/src/Orders/OrderService.cs +++ b/modules/Orders/src/Orders/OrderService.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.Logging; using SimpleModule.Core.Events; using SimpleModule.Core.Exceptions; -using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; using SimpleModule.Orders.Contracts.Events; using SimpleModule.Products.Contracts; diff --git a/modules/Orders/src/Orders/OrdersDbContext.cs b/modules/Orders/src/Orders/OrdersDbContext.cs index 8b6a1173..054f59e4 100644 --- a/modules/Orders/src/Orders/OrdersDbContext.cs +++ b/modules/Orders/src/Orders/OrdersDbContext.cs @@ -1,9 +1,10 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; -using SimpleModule.Core.Ids; using SimpleModule.Database; using SimpleModule.Orders.Contracts; using SimpleModule.Orders.EntityConfigurations; +using SimpleModule.Products.Contracts; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders; diff --git a/modules/Orders/src/Orders/Views/CreateEndpoint.cs b/modules/Orders/src/Orders/Views/CreateEndpoint.cs index a6e45cc7..23ea30c2 100644 --- a/modules/Orders/src/Orders/Views/CreateEndpoint.cs +++ b/modules/Orders/src/Orders/Views/CreateEndpoint.cs @@ -2,10 +2,10 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; -using SimpleModule.Core.Ids; using SimpleModule.Core.Inertia; using SimpleModule.Orders.Contracts; using SimpleModule.Products.Contracts; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Views; diff --git a/modules/Orders/src/Orders/Views/EditEndpoint.cs b/modules/Orders/src/Orders/Views/EditEndpoint.cs index 5dd1deda..8b8d9e82 100644 --- a/modules/Orders/src/Orders/Views/EditEndpoint.cs +++ b/modules/Orders/src/Orders/Views/EditEndpoint.cs @@ -2,10 +2,10 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; -using SimpleModule.Core.Ids; using SimpleModule.Core.Inertia; using SimpleModule.Orders.Contracts; using SimpleModule.Products.Contracts; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Views; diff --git a/modules/Orders/tests/Orders.Tests/Integration/OrdersEndpointTests.cs b/modules/Orders/tests/Orders.Tests/Integration/OrdersEndpointTests.cs index a924ced9..d7a89ca0 100644 --- a/modules/Orders/tests/Orders.Tests/Integration/OrdersEndpointTests.cs +++ b/modules/Orders/tests/Orders.Tests/Integration/OrdersEndpointTests.cs @@ -1,9 +1,10 @@ using System.Net; using System.Net.Http.Json; using FluentAssertions; -using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; +using SimpleModule.Products.Contracts; using SimpleModule.Tests.Shared.Fixtures; +using SimpleModule.Users.Contracts; namespace Orders.Tests.Integration; diff --git a/modules/Orders/tests/Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs b/modules/Orders/tests/Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs index 1a9954e9..b502626d 100644 --- a/modules/Orders/tests/Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs +++ b/modules/Orders/tests/Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs @@ -1,7 +1,8 @@ using FluentAssertions; -using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; using SimpleModule.Orders.Endpoints.Orders; +using SimpleModule.Products.Contracts; +using SimpleModule.Users.Contracts; namespace Orders.Tests.Unit; diff --git a/modules/Orders/tests/Orders.Tests/Unit/OrderServiceTests.cs b/modules/Orders/tests/Orders.Tests/Unit/OrderServiceTests.cs index 805bea42..5e36dd78 100644 --- a/modules/Orders/tests/Orders.Tests/Unit/OrderServiceTests.cs +++ b/modules/Orders/tests/Orders.Tests/Unit/OrderServiceTests.cs @@ -5,7 +5,6 @@ using NSubstitute; using SimpleModule.Core.Events; using SimpleModule.Core.Exceptions; -using SimpleModule.Core.Ids; using SimpleModule.Database; using SimpleModule.Orders; using SimpleModule.Orders.Contracts; diff --git a/modules/Products/src/Products.Contracts/IProductContracts.cs b/modules/Products/src/Products.Contracts/IProductContracts.cs index 032d82c1..8483e7f6 100644 --- a/modules/Products/src/Products.Contracts/IProductContracts.cs +++ b/modules/Products/src/Products.Contracts/IProductContracts.cs @@ -1,4 +1,3 @@ -using SimpleModule.Core.Ids; namespace SimpleModule.Products.Contracts; diff --git a/modules/Products/src/Products.Contracts/Product.cs b/modules/Products/src/Products.Contracts/Product.cs index 7242dfbc..fb746ced 100644 --- a/modules/Products/src/Products.Contracts/Product.cs +++ b/modules/Products/src/Products.Contracts/Product.cs @@ -1,5 +1,4 @@ using SimpleModule.Core; -using SimpleModule.Core.Ids; namespace SimpleModule.Products.Contracts; diff --git a/framework/SimpleModule.Core/Ids/ProductId.cs b/modules/Products/src/Products.Contracts/ProductId.cs similarity index 77% rename from framework/SimpleModule.Core/Ids/ProductId.cs rename to modules/Products/src/Products.Contracts/ProductId.cs index 5e8346ca..60402289 100644 --- a/framework/SimpleModule.Core/Ids/ProductId.cs +++ b/modules/Products/src/Products.Contracts/ProductId.cs @@ -1,6 +1,6 @@ using Vogen; -namespace SimpleModule.Core.Ids; +namespace SimpleModule.Products.Contracts; [ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] public readonly partial struct ProductId; diff --git a/modules/Products/src/Products.Contracts/Products.Contracts.csproj b/modules/Products/src/Products.Contracts/Products.Contracts.csproj index b33451b0..97fdfa07 100644 --- a/modules/Products/src/Products.Contracts/Products.Contracts.csproj +++ b/modules/Products/src/Products.Contracts/Products.Contracts.csproj @@ -2,8 +2,11 @@ net10.0 Library + $(DefineConstants);VOGEN_NO_VALIDATION + + diff --git a/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs index c00b33d2..7c90ec4d 100644 --- a/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; -using SimpleModule.Core.Ids; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; diff --git a/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs index 4202e445..8d106030 100644 --- a/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; -using SimpleModule.Core.Ids; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; diff --git a/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs index 4db3b504..aca262a4 100644 --- a/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; -using SimpleModule.Core.Ids; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; diff --git a/modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs b/modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs index b115ada3..79cdb80b 100644 --- a/modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs +++ b/modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs @@ -1,7 +1,6 @@ using Bogus; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SimpleModule.Core.Ids; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.EntityConfigurations; diff --git a/modules/Products/src/Products/ProductService.cs b/modules/Products/src/Products/ProductService.cs index 4b1ed8f3..453818db 100644 --- a/modules/Products/src/Products/ProductService.cs +++ b/modules/Products/src/Products/ProductService.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using SimpleModule.Core.Ids; using SimpleModule.Products.Contracts; namespace SimpleModule.Products; diff --git a/modules/Products/src/Products/ProductsDbContext.cs b/modules/Products/src/Products/ProductsDbContext.cs index e634a8d2..e29a7bae 100644 --- a/modules/Products/src/Products/ProductsDbContext.cs +++ b/modules/Products/src/Products/ProductsDbContext.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; -using SimpleModule.Core.Ids; using SimpleModule.Database; using SimpleModule.Products.Contracts; using SimpleModule.Products.EntityConfigurations; diff --git a/modules/Products/src/Products/Views/EditEndpoint.cs b/modules/Products/src/Products/Views/EditEndpoint.cs index 7d7018f1..aa9cf31b 100644 --- a/modules/Products/src/Products/Views/EditEndpoint.cs +++ b/modules/Products/src/Products/Views/EditEndpoint.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; -using SimpleModule.Core.Ids; using SimpleModule.Core.Inertia; using SimpleModule.Products.Contracts; diff --git a/modules/Products/tests/Products.Tests/Integration/ProductsEndpointTests.cs b/modules/Products/tests/Products.Tests/Integration/ProductsEndpointTests.cs index 880f99d9..3b1a0708 100644 --- a/modules/Products/tests/Products.Tests/Integration/ProductsEndpointTests.cs +++ b/modules/Products/tests/Products.Tests/Integration/ProductsEndpointTests.cs @@ -1,7 +1,6 @@ using System.Net; using System.Net.Http.Json; using FluentAssertions; -using SimpleModule.Core.Ids; using SimpleModule.Products.Contracts; using SimpleModule.Tests.Shared.Fixtures; diff --git a/modules/Products/tests/Products.Tests/Unit/ProductServiceTests.cs b/modules/Products/tests/Products.Tests/Unit/ProductServiceTests.cs index a355516f..d6c1be7d 100644 --- a/modules/Products/tests/Products.Tests/Unit/ProductServiceTests.cs +++ b/modules/Products/tests/Products.Tests/Unit/ProductServiceTests.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using SimpleModule.Core.Exceptions; -using SimpleModule.Core.Ids; using SimpleModule.Database; using SimpleModule.Products; using SimpleModule.Products.Contracts; diff --git a/modules/Users/src/Users.Contracts/IUserContracts.cs b/modules/Users/src/Users.Contracts/IUserContracts.cs index 6ce42d07..038bdc46 100644 --- a/modules/Users/src/Users.Contracts/IUserContracts.cs +++ b/modules/Users/src/Users.Contracts/IUserContracts.cs @@ -1,4 +1,3 @@ -using SimpleModule.Core.Ids; namespace SimpleModule.Users.Contracts; diff --git a/modules/Users/src/Users.Contracts/UserDto.cs b/modules/Users/src/Users.Contracts/UserDto.cs index b1465b0b..2ead5734 100644 --- a/modules/Users/src/Users.Contracts/UserDto.cs +++ b/modules/Users/src/Users.Contracts/UserDto.cs @@ -1,5 +1,4 @@ using SimpleModule.Core; -using SimpleModule.Core.Ids; namespace SimpleModule.Users.Contracts; diff --git a/framework/SimpleModule.Core/Ids/UserId.cs b/modules/Users/src/Users.Contracts/UserId.cs similarity index 89% rename from framework/SimpleModule.Core/Ids/UserId.cs rename to modules/Users/src/Users.Contracts/UserId.cs index f351837f..15152613 100644 --- a/framework/SimpleModule.Core/Ids/UserId.cs +++ b/modules/Users/src/Users.Contracts/UserId.cs @@ -1,6 +1,6 @@ using Vogen; -namespace SimpleModule.Core.Ids; +namespace SimpleModule.Users.Contracts; [ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] public readonly partial struct UserId diff --git a/modules/Users/src/Users.Contracts/Users.Contracts.csproj b/modules/Users/src/Users.Contracts/Users.Contracts.csproj index b33451b0..97fdfa07 100644 --- a/modules/Users/src/Users.Contracts/Users.Contracts.csproj +++ b/modules/Users/src/Users.Contracts/Users.Contracts.csproj @@ -2,8 +2,11 @@ net10.0 Library + $(DefineConstants);VOGEN_NO_VALIDATION + + diff --git a/modules/Users/src/Users/Endpoints/Users/DeleteEndpoint.cs b/modules/Users/src/Users/Endpoints/Users/DeleteEndpoint.cs index 8c2315c3..9245e7f5 100644 --- a/modules/Users/src/Users/Endpoints/Users/DeleteEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Users/DeleteEndpoint.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; -using SimpleModule.Core.Ids; using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Endpoints.Users; diff --git a/modules/Users/src/Users/Endpoints/Users/GetByIdEndpoint.cs b/modules/Users/src/Users/Endpoints/Users/GetByIdEndpoint.cs index 31d9a39d..f80a00dd 100644 --- a/modules/Users/src/Users/Endpoints/Users/GetByIdEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Users/GetByIdEndpoint.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; -using SimpleModule.Core.Ids; using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Endpoints.Users; diff --git a/modules/Users/src/Users/Endpoints/Users/GetCurrentEndpoint.cs b/modules/Users/src/Users/Endpoints/Users/GetCurrentEndpoint.cs index dd3d0d93..c24beb48 100644 --- a/modules/Users/src/Users/Endpoints/Users/GetCurrentEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Users/GetCurrentEndpoint.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; -using SimpleModule.Core.Ids; using SimpleModule.Users.Contracts; using SimpleModule.Users.Extensions; diff --git a/modules/Users/src/Users/Endpoints/Users/UpdateEndpoint.cs b/modules/Users/src/Users/Endpoints/Users/UpdateEndpoint.cs index 56620196..f85650ba 100644 --- a/modules/Users/src/Users/Endpoints/Users/UpdateEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Users/UpdateEndpoint.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; -using SimpleModule.Core.Ids; using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Endpoints.Users; diff --git a/modules/Users/src/Users/UserService.cs b/modules/Users/src/Users/UserService.cs index a2fb5e5d..b2c81725 100644 --- a/modules/Users/src/Users/UserService.cs +++ b/modules/Users/src/Users/UserService.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using SimpleModule.Core.Ids; using SimpleModule.Users.Contracts; using SimpleModule.Users.Entities; diff --git a/modules/Users/tests/Users.Tests/Unit/UserServiceTests.cs b/modules/Users/tests/Users.Tests/Unit/UserServiceTests.cs index b77de841..8ffb36a8 100644 --- a/modules/Users/tests/Users.Tests/Unit/UserServiceTests.cs +++ b/modules/Users/tests/Users.Tests/Unit/UserServiceTests.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; -using SimpleModule.Core.Ids; using SimpleModule.Users; using SimpleModule.Users.Contracts; using SimpleModule.Users.Entities; diff --git a/template/SimpleModule.Host/HostDbContext.Conventions.cs b/template/SimpleModule.Host/HostDbContext.Conventions.cs index 354f30aa..8192e5b6 100644 --- a/template/SimpleModule.Host/HostDbContext.Conventions.cs +++ b/template/SimpleModule.Host/HostDbContext.Conventions.cs @@ -1,5 +1,7 @@ using Microsoft.EntityFrameworkCore; -using SimpleModule.Core.Ids; +using SimpleModule.Orders.Contracts; +using SimpleModule.Products.Contracts; +using SimpleModule.Users.Contracts; namespace SimpleModule.Host; diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs index 750c7cf8..3ef39ef9 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs @@ -1,6 +1,5 @@ using System.Globalization; using Bogus; -using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; using SimpleModule.Products.Contracts; using SimpleModule.Users.Contracts; diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeOrderContracts.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeOrderContracts.cs index c8d3ec81..6ae6e5a0 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeOrderContracts.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeOrderContracts.cs @@ -1,4 +1,3 @@ -using SimpleModule.Core.Ids; using SimpleModule.Orders.Contracts; namespace SimpleModule.Tests.Shared.Fakes; diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeProductContracts.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeProductContracts.cs index e9aa9962..9007202b 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeProductContracts.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeProductContracts.cs @@ -1,4 +1,3 @@ -using SimpleModule.Core.Ids; using SimpleModule.Products.Contracts; namespace SimpleModule.Tests.Shared.Fakes; diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs index a6406933..b92218dc 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs @@ -1,5 +1,4 @@ using System.Globalization; -using SimpleModule.Core.Ids; using SimpleModule.Users.Contracts; namespace SimpleModule.Tests.Shared.Fakes; From a9a09f917d14ff34e020dab14a7bd1e367908c3c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 11:03:25 +0100 Subject: [PATCH 027/112] feat: add ValidationBuilder helper, add Products validation, simplify Orders validation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Validation/ValidationBuilder.cs | 33 ++++++++++++ .../Orders/CreateRequestValidator.cs | 54 +++++++------------ .../Endpoints/Products/CreateEndpoint.cs | 7 +++ .../Products/CreateRequestValidator.cs | 21 ++++++++ .../Endpoints/Products/UpdateEndpoint.cs | 7 +++ .../Products/UpdateRequestValidator.cs | 21 ++++++++ .../Unit/CreateRequestValidatorTests.cs | 40 ++++++++++++++ .../Validation/ValidationBuilderTests.cs | 49 +++++++++++++++++ 8 files changed, 197 insertions(+), 35 deletions(-) create mode 100644 framework/SimpleModule.Core/Validation/ValidationBuilder.cs create mode 100644 modules/Products/src/Products/Endpoints/Products/CreateRequestValidator.cs create mode 100644 modules/Products/src/Products/Endpoints/Products/UpdateRequestValidator.cs create mode 100644 modules/Products/tests/Products.Tests/Unit/CreateRequestValidatorTests.cs create mode 100644 tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs diff --git a/framework/SimpleModule.Core/Validation/ValidationBuilder.cs b/framework/SimpleModule.Core/Validation/ValidationBuilder.cs new file mode 100644 index 00000000..fff65629 --- /dev/null +++ b/framework/SimpleModule.Core/Validation/ValidationBuilder.cs @@ -0,0 +1,33 @@ +namespace SimpleModule.Core.Validation; + +public sealed class ValidationBuilder +{ + private readonly Dictionary> _errors = []; + + public ValidationBuilder AddErrorIf(bool condition, string field, string message) + { + if (condition) + { + if (!_errors.TryGetValue(field, out var list)) + { + list = []; + _errors[field] = list; + } + + list.Add(message); + } + + return this; + } + + public ValidationResult Build() + { + if (_errors.Count == 0) + { + return ValidationResult.Success; + } + + var errors = _errors.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToArray()); + return ValidationResult.WithErrors(errors); + } +} diff --git a/modules/Orders/src/Orders/Endpoints/Orders/CreateRequestValidator.cs b/modules/Orders/src/Orders/Endpoints/Orders/CreateRequestValidator.cs index 58b0f0ae..b36bbca5 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/CreateRequestValidator.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/CreateRequestValidator.cs @@ -7,52 +7,36 @@ namespace SimpleModule.Orders.Endpoints.Orders; public static class CreateRequestValidator { - private static readonly CompositeFormat _quantityMustBePositiveFormat = CompositeFormat.Parse( + private static readonly CompositeFormat QuantityMustBePositiveFormat = CompositeFormat.Parse( OrdersConstants.ValidationMessages.QuantityMustBePositiveFormat ); public static ValidationResult Validate(CreateOrderRequest request) { - var errors = new Dictionary(); + var builder = new ValidationBuilder() + .AddErrorIf( + string.IsNullOrWhiteSpace(request.UserId.Value), + OrdersConstants.Fields.UserId, + OrdersConstants.ValidationMessages.UserIdRequired + ) + .AddErrorIf( + request.Items is null || request.Items.Count == 0, + OrdersConstants.Fields.Items, + OrdersConstants.ValidationMessages.AtLeastOneItemRequired + ); - if (string.IsNullOrWhiteSpace(request.UserId.Value)) + if (request.Items is { Count: > 0 }) { - errors[OrdersConstants.Fields.UserId] = - [ - OrdersConstants.ValidationMessages.UserIdRequired, - ]; - } - - if (request.Items is null || request.Items.Count == 0) - { - errors[OrdersConstants.Fields.Items] = - [ - OrdersConstants.ValidationMessages.AtLeastOneItemRequired, - ]; - } - else - { - var itemErrors = new List(); for (var i = 0; i < request.Items.Count; i++) { - if (request.Items[i].Quantity <= 0) - { - itemErrors.Add( - string.Format( - CultureInfo.InvariantCulture, - _quantityMustBePositiveFormat, - i - ) - ); - } - } - - if (itemErrors.Count > 0) - { - errors[OrdersConstants.Fields.Items] = [.. itemErrors]; + builder.AddErrorIf( + request.Items[i].Quantity <= 0, + OrdersConstants.Fields.Items, + string.Format(CultureInfo.InvariantCulture, QuantityMustBePositiveFormat, i) + ); } } - return errors.Count > 0 ? ValidationResult.WithErrors(errors) : ValidationResult.Success; + return builder.Build(); } } diff --git a/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs index 0f7b9c66..e9c1d578 100644 --- a/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Exceptions; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; @@ -14,6 +15,12 @@ public void Map(IEndpointRouteBuilder app) "/", async (CreateProductRequest request, IProductContracts productContracts) => { + var validation = CreateRequestValidator.Validate(request); + if (!validation.IsValid) + { + throw new ValidationException(validation.Errors); + } + var product = await productContracts.CreateProductAsync(request); return TypedResults.Created( $"{ProductsConstants.RoutePrefix}/{product.Id}", diff --git a/modules/Products/src/Products/Endpoints/Products/CreateRequestValidator.cs b/modules/Products/src/Products/Endpoints/Products/CreateRequestValidator.cs new file mode 100644 index 00000000..d9e93234 --- /dev/null +++ b/modules/Products/src/Products/Endpoints/Products/CreateRequestValidator.cs @@ -0,0 +1,21 @@ +using SimpleModule.Core.Validation; +using SimpleModule.Products.Contracts; + +namespace SimpleModule.Products.Endpoints.Products; + +public static class CreateRequestValidator +{ + public static ValidationResult Validate(CreateProductRequest request) => + new ValidationBuilder() + .AddErrorIf( + string.IsNullOrWhiteSpace(request.Name), + "Name", + "Product name is required." + ) + .AddErrorIf( + request.Price <= 0, + "Price", + "Price must be greater than zero." + ) + .Build(); +} diff --git a/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs index aca262a4..949d2d68 100644 --- a/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Exceptions; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; @@ -19,6 +20,12 @@ async Task, NotFound>> ( IProductContracts productContracts ) => { + var validation = UpdateRequestValidator.Validate(request); + if (!validation.IsValid) + { + throw new ValidationException(validation.Errors); + } + var product = await productContracts.UpdateProductAsync(id, request); return TypedResults.Ok(product); } diff --git a/modules/Products/src/Products/Endpoints/Products/UpdateRequestValidator.cs b/modules/Products/src/Products/Endpoints/Products/UpdateRequestValidator.cs new file mode 100644 index 00000000..176b0cd7 --- /dev/null +++ b/modules/Products/src/Products/Endpoints/Products/UpdateRequestValidator.cs @@ -0,0 +1,21 @@ +using SimpleModule.Core.Validation; +using SimpleModule.Products.Contracts; + +namespace SimpleModule.Products.Endpoints.Products; + +public static class UpdateRequestValidator +{ + public static ValidationResult Validate(UpdateProductRequest request) => + new ValidationBuilder() + .AddErrorIf( + string.IsNullOrWhiteSpace(request.Name), + "Name", + "Product name is required." + ) + .AddErrorIf( + request.Price <= 0, + "Price", + "Price must be greater than zero." + ) + .Build(); +} diff --git a/modules/Products/tests/Products.Tests/Unit/CreateRequestValidatorTests.cs b/modules/Products/tests/Products.Tests/Unit/CreateRequestValidatorTests.cs new file mode 100644 index 00000000..bb011e0a --- /dev/null +++ b/modules/Products/tests/Products.Tests/Unit/CreateRequestValidatorTests.cs @@ -0,0 +1,40 @@ +using FluentAssertions; +using SimpleModule.Products.Contracts; +using SimpleModule.Products.Endpoints.Products; + +namespace Products.Tests.Unit; + +public class CreateRequestValidatorTests +{ + [Fact] + public void Validate_WithValidRequest_ReturnsSuccess() + { + var request = new CreateProductRequest { Name = "Widget", Price = 9.99m }; + + var result = CreateRequestValidator.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Validate_WithEmptyName_ReturnsError() + { + var request = new CreateProductRequest { Name = "", Price = 9.99m }; + + var result = CreateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Name"); + } + + [Fact] + public void Validate_WithZeroPrice_ReturnsError() + { + var request = new CreateProductRequest { Name = "Widget", Price = 0 }; + + var result = CreateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Price"); + } +} diff --git a/tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs b/tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs new file mode 100644 index 00000000..5dc5009a --- /dev/null +++ b/tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using SimpleModule.Core.Validation; + +namespace SimpleModule.Core.Tests.Validation; + +public class ValidationBuilderTests +{ + [Fact] + public void Build_WithNoErrors_ReturnsSuccess() + { + var result = new ValidationBuilder().Build(); + + result.IsValid.Should().BeTrue(); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void AddErrorIf_WhenConditionTrue_AddsError() + { + var result = new ValidationBuilder() + .AddErrorIf(true, "Name", "Name is required.") + .Build(); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Name"); + result.Errors["Name"].Should().Contain("Name is required."); + } + + [Fact] + public void AddErrorIf_WhenConditionFalse_DoesNotAddError() + { + var result = new ValidationBuilder() + .AddErrorIf(false, "Name", "Name is required.") + .Build(); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void AddErrorIf_MultipleSameField_AccumulatesErrors() + { + var result = new ValidationBuilder() + .AddErrorIf(true, "Name", "Name is required.") + .AddErrorIf(true, "Name", "Name must be at least 2 characters.") + .Build(); + + result.Errors["Name"].Should().HaveCount(2); + } +} From 7e41dd4c60e7be48c877b9207a794c276e5b16bd Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 11:07:29 +0100 Subject: [PATCH 028/112] feat: add CrudEndpoints helper to reduce endpoint boilerplate Extract common CRUD handler patterns (GetAll, GetById, Create, Update, Delete) into reusable static methods in SimpleModule.Core.Endpoints. Simplify all Products and Orders endpoint classes to delegate to these helpers while preserving validation logic. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Endpoints/CrudEndpoints.cs | 35 ++++++++++++++++ .../Orders/Endpoints/Orders/CreateEndpoint.cs | 28 ++++++------- .../Orders/Endpoints/Orders/DeleteEndpoint.cs | 16 ++------ .../Orders/Endpoints/Orders/GetAllEndpoint.cs | 16 ++------ .../Endpoints/Orders/GetByIdEndpoint.cs | 17 ++------ .../Orders/Endpoints/Orders/UpdateEndpoint.cs | 41 +++++++------------ .../Endpoints/Products/CreateEndpoint.cs | 31 ++++++-------- .../Endpoints/Products/DeleteEndpoint.cs | 16 ++------ .../Endpoints/Products/GetAllEndpoint.cs | 16 ++------ .../Endpoints/Products/GetByIdEndpoint.cs | 20 ++------- .../Endpoints/Products/UpdateEndpoint.cs | 31 +++++--------- 11 files changed, 108 insertions(+), 159 deletions(-) create mode 100644 framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs diff --git a/framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs b/framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs new file mode 100644 index 00000000..a458db37 --- /dev/null +++ b/framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Http; + +namespace SimpleModule.Core.Endpoints; + +public static class CrudEndpoints +{ + public static async Task GetAll(Func>> getAll) => + TypedResults.Ok(await getAll()); + + public static async Task GetById(Func> getById) + where T : class + { + var entity = await getById(); + return entity is not null ? TypedResults.Ok(entity) : TypedResults.NotFound(); + } + + public static async Task Create( + Func> create, + Func locationFactory + ) + { + var entity = await create(); + return TypedResults.Created(new Uri(locationFactory(entity), UriKind.RelativeOrAbsolute), entity); + } + + public static async Task Update(Func> update) + where T : class => + TypedResults.Ok(await update()); + + public static async Task Delete(Func delete) + { + await delete(); + return TypedResults.NoContent(); + } +} diff --git a/modules/Orders/src/Orders/Endpoints/Orders/CreateEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/CreateEndpoint.cs index 5c7c17ad..406e6fd1 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/CreateEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/CreateEndpoint.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Endpoints; using SimpleModule.Core.Exceptions; using SimpleModule.Orders.Contracts; @@ -9,21 +9,17 @@ namespace SimpleModule.Orders.Endpoints.Orders; public class CreateEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { - app.MapPost( - "/", - async (CreateOrderRequest request, IOrderContracts orderContracts) => + public void Map(IEndpointRouteBuilder app) => + app.MapPost("/", (CreateOrderRequest request, IOrderContracts orderContracts) => + { + var validation = CreateRequestValidator.Validate(request); + if (!validation.IsValid) { - var validation = CreateRequestValidator.Validate(request); - if (!validation.IsValid) - { - throw new ValidationException(validation.Errors); - } - - var order = await orderContracts.CreateOrderAsync(request); - return TypedResults.Created($"{OrdersConstants.RoutePrefix}/{order.Id}", order); + throw new ValidationException(validation.Errors); } - ); - } + + return CrudEndpoints.Create( + () => orderContracts.CreateOrderAsync(request), + o => $"{OrdersConstants.RoutePrefix}/{o.Id}"); + }); } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs index 90675e4f..4c01f823 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs @@ -1,22 +1,14 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Endpoints; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; public class DeleteEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { - app.MapDelete( - "/{id}", - async (OrderId id, IOrderContracts orderContracts) => - { - await orderContracts.DeleteOrderAsync(id); - return TypedResults.NoContent(); - } - ); - } + public void Map(IEndpointRouteBuilder app) => + app.MapDelete("/{id}", (OrderId id, IOrderContracts orderContracts) => + CrudEndpoints.Delete(() => orderContracts.DeleteOrderAsync(id))); } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/GetAllEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/GetAllEndpoint.cs index b9169fea..66ce9c78 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/GetAllEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/GetAllEndpoint.cs @@ -1,22 +1,14 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Endpoints; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; public class GetAllEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { - app.MapGet( - "/", - async (IOrderContracts orderContracts) => - { - var orders = await orderContracts.GetAllOrdersAsync(); - return TypedResults.Ok(orders); - } - ); - } + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/", (IOrderContracts orderContracts) => + CrudEndpoints.GetAll(orderContracts.GetAllOrdersAsync)); } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs index c3739d0f..e3b68431 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs @@ -1,23 +1,14 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Endpoints; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; public class GetByIdEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { - app.MapGet( - "/{id}", - async Task, NotFound>> (OrderId id, IOrderContracts orderContracts) => - { - var order = await orderContracts.GetOrderByIdAsync(id); - return order is not null ? TypedResults.Ok(order) : TypedResults.NotFound(); - } - ); - } + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/{id}", (OrderId id, IOrderContracts orderContracts) => + CrudEndpoints.GetById(() => orderContracts.GetOrderByIdAsync(id))); } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs index 5b265844..3e0271bd 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs @@ -1,8 +1,7 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Endpoints; using SimpleModule.Core.Exceptions; using SimpleModule.Orders.Contracts; @@ -10,30 +9,20 @@ namespace SimpleModule.Orders.Endpoints.Orders; public class UpdateEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { - app.MapPut( - "/{id}", - async Task, NotFound, ValidationProblem>> ( - OrderId id, - UpdateOrderRequest request, - IOrderContracts orderContracts - ) => + public void Map(IEndpointRouteBuilder app) => + app.MapPut("/{id}", (OrderId id, UpdateOrderRequest request, IOrderContracts orderContracts) => + { + var createRequest = new CreateOrderRequest { - var createRequest = new CreateOrderRequest - { - UserId = request.UserId, - Items = request.Items, - }; - var validation = CreateRequestValidator.Validate(createRequest); - if (!validation.IsValid) - { - throw new ValidationException(validation.Errors); - } - - var order = await orderContracts.UpdateOrderAsync(id, request); - return TypedResults.Ok(order); + UserId = request.UserId, + Items = request.Items, + }; + var validation = CreateRequestValidator.Validate(createRequest); + if (!validation.IsValid) + { + throw new ValidationException(validation.Errors); } - ); - } + + return CrudEndpoints.Update(() => orderContracts.UpdateOrderAsync(id, request)); + }); } diff --git a/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs index e9c1d578..ab280e5b 100644 --- a/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Endpoints; using SimpleModule.Core.Exceptions; using SimpleModule.Products.Contracts; @@ -9,24 +9,17 @@ namespace SimpleModule.Products.Endpoints.Products; public class CreateEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { - app.MapPost( - "/", - async (CreateProductRequest request, IProductContracts productContracts) => + public void Map(IEndpointRouteBuilder app) => + app.MapPost("/", (CreateProductRequest request, IProductContracts productContracts) => + { + var validation = CreateRequestValidator.Validate(request); + if (!validation.IsValid) { - var validation = CreateRequestValidator.Validate(request); - if (!validation.IsValid) - { - throw new ValidationException(validation.Errors); - } - - var product = await productContracts.CreateProductAsync(request); - return TypedResults.Created( - $"{ProductsConstants.RoutePrefix}/{product.Id}", - product - ); + throw new ValidationException(validation.Errors); } - ); - } + + return CrudEndpoints.Create( + () => productContracts.CreateProductAsync(request), + p => $"{ProductsConstants.RoutePrefix}/{p.Id}"); + }); } diff --git a/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs index 7c90ec4d..0a1e4ad5 100644 --- a/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs @@ -1,22 +1,14 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Endpoints; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; public class DeleteEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { - app.MapDelete( - "/{id}", - async (ProductId id, IProductContracts productContracts) => - { - await productContracts.DeleteProductAsync(id); - return TypedResults.NoContent(); - } - ); - } + public void Map(IEndpointRouteBuilder app) => + app.MapDelete("/{id}", (ProductId id, IProductContracts productContracts) => + CrudEndpoints.Delete(() => productContracts.DeleteProductAsync(id))); } diff --git a/modules/Products/src/Products/Endpoints/Products/GetAllEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/GetAllEndpoint.cs index 02fe7aaf..1f8f5bf6 100644 --- a/modules/Products/src/Products/Endpoints/Products/GetAllEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/GetAllEndpoint.cs @@ -1,22 +1,14 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Endpoints; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; public class GetAllEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { - app.MapGet( - "/", - async (IProductContracts productContracts) => - { - var products = await productContracts.GetAllProductsAsync(); - return TypedResults.Ok(products); - } - ); - } + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/", (IProductContracts productContracts) => + CrudEndpoints.GetAll(productContracts.GetAllProductsAsync)); } diff --git a/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs index 8d106030..26f63151 100644 --- a/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs @@ -1,26 +1,14 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Endpoints; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; public class GetByIdEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { - app.MapGet( - "/{id}", - async Task, NotFound>> ( - ProductId id, - IProductContracts productContracts - ) => - { - var product = await productContracts.GetProductByIdAsync(id); - return product is not null ? TypedResults.Ok(product) : TypedResults.NotFound(); - } - ); - } + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/{id}", (ProductId id, IProductContracts productContracts) => + CrudEndpoints.GetById(() => productContracts.GetProductByIdAsync(id))); } diff --git a/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs index 949d2d68..983aeb9d 100644 --- a/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs @@ -1,8 +1,7 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Endpoints; using SimpleModule.Core.Exceptions; using SimpleModule.Products.Contracts; @@ -10,25 +9,15 @@ namespace SimpleModule.Products.Endpoints.Products; public class UpdateEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { - app.MapPut( - "/{id}", - async Task, NotFound>> ( - ProductId id, - UpdateProductRequest request, - IProductContracts productContracts - ) => + public void Map(IEndpointRouteBuilder app) => + app.MapPut("/{id}", (ProductId id, UpdateProductRequest request, IProductContracts productContracts) => + { + var validation = UpdateRequestValidator.Validate(request); + if (!validation.IsValid) { - var validation = UpdateRequestValidator.Validate(request); - if (!validation.IsValid) - { - throw new ValidationException(validation.Errors); - } - - var product = await productContracts.UpdateProductAsync(id, request); - return TypedResults.Ok(product); + throw new ValidationException(validation.Errors); } - ); - } + + return CrudEndpoints.Update(() => productContracts.UpdateProductAsync(id, request)); + }); } From ff632e75d2ae87c5cfe8de115d896e856df3e695 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 11:19:44 +0100 Subject: [PATCH 029/112] refactor: remove hardcoded module type names from source generator Detect Vogen value objects generically via [ValueObjectAttribute] instead of hardcoding module-specific type names in TypeMappingHelpers. The generator now stores the underlying primitive type alongside the actual type, using it only for TypeScript mapping while preserving the Vogen type for JSON/EF code. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Discovery/DiscoveryData.cs | 8 ++++ .../Discovery/SymbolDiscovery.cs | 41 +++++++++++++++++-- .../Emitters/TypeScriptDefinitionsEmitter.cs | 2 +- .../Helpers/TypeMappingHelpers.cs | 3 -- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs index 37836997..9bb53199 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs @@ -132,6 +132,7 @@ public override int GetHashCode() internal readonly record struct DtoPropertyInfoRecord( string Name, string TypeFqn, + string? UnderlyingTypeFqn, bool HasSetter ); @@ -222,6 +223,13 @@ internal sealed class DtoPropertyInfo { public string Name { get; set; } = ""; public string TypeFqn { get; set; } = ""; + + /// + /// For value objects (e.g. Vogen), the underlying primitive type FQN. + /// Null if the type is not a value object wrapper. + /// + public string? UnderlyingTypeFqn { get; set; } + public bool HasSetter { get; set; } } diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 96253223..2493d94d 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -177,6 +177,7 @@ typeSymbol is not null d.Properties.Select(p => new DtoPropertyInfoRecord( p.Name, p.TypeFqn, + p.UnderlyingTypeFqn, p.HasSetter )) .ToImmutableArray() @@ -395,13 +396,18 @@ m is IPropertySymbol prop && prop.GetMethod is not null ) { + var resolvedType = ResolveUnderlyingType(prop.Type); + var actualType = prop.Type.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ); properties.Add( new DtoPropertyInfo { Name = prop.Name, - TypeFqn = prop.Type.ToDisplayString( - SymbolDisplayFormat.FullyQualifiedFormat - ), + TypeFqn = actualType, + UnderlyingTypeFqn = resolvedType != actualType + ? resolvedType + : null, HasSetter = prop.SetMethod is not null && prop.SetMethod.DeclaredAccessibility @@ -637,4 +643,33 @@ member is INamedTypeSymbol typeSymbol } } } + + /// + /// If the type is a Vogen value object, returns the FQN of its underlying primitive type. + /// Otherwise returns the type's own FQN. + /// + private static string ResolveUnderlyingType(ITypeSymbol typeSymbol) + { + foreach (var attr in typeSymbol.GetAttributes()) + { + var attrClass = attr.AttributeClass; + if (attrClass is null) + continue; + + // Vogen uses generic attribute ValueObjectAttribute + if ( + attrClass.IsGenericType + && attrClass.Name == "ValueObjectAttribute" + && attrClass.ContainingNamespace.ToDisplayString() == "Vogen" + && attrClass.TypeArguments.Length == 1 + ) + { + return attrClass.TypeArguments[0].ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ); + } + } + + return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } } diff --git a/framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs index beac81bc..f7cd9b5b 100644 --- a/framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs @@ -60,7 +60,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) sb.AppendLine($"export interface {shortName} {{"); foreach (var prop in dto.Properties) { - var tsType = TypeMappingHelpers.MapCSharpTypeToTypeScript(prop.TypeFqn, knownDtoTypes); + var tsType = TypeMappingHelpers.MapCSharpTypeToTypeScript(prop.UnderlyingTypeFqn ?? prop.TypeFqn, knownDtoTypes); var camelName = char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1); sb.AppendLine($" {camelName}: {tsType};"); } diff --git a/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs b/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs index c6bea26a..e56b1e48 100644 --- a/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs +++ b/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs @@ -74,9 +74,6 @@ knownDtoTypes is not null or "System.DateOnly" or "System.TimeOnly" => "string", "System.Guid" => "string", - // Vogen strongly-typed IDs (map to their underlying primitive types) - "SimpleModule.Products.Contracts.ProductId" or "SimpleModule.Orders.Contracts.OrderId" => "number", - "SimpleModule.Users.Contracts.UserId" => "string", _ => "any", }; } From 72c8e2c9eee902362b1d054e4386bca235e06354 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 11:25:53 +0100 Subject: [PATCH 030/112] test: add unit tests for CrudEndpoints, UpdateRequestValidator, and ValidationBuilder edge cases Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Products.Tests/Unit/ProductIdTests.cs | 49 +++++++++++ .../Unit/UpdateRequestValidatorTests.cs | 62 ++++++++++++++ .../Endpoints/CrudEndpointsTests.cs | 83 +++++++++++++++++++ .../Validation/ValidationBuilderTests.cs | 44 ++++++++++ 4 files changed, 238 insertions(+) create mode 100644 modules/Products/tests/Products.Tests/Unit/ProductIdTests.cs create mode 100644 modules/Products/tests/Products.Tests/Unit/UpdateRequestValidatorTests.cs create mode 100644 tests/SimpleModule.Core.Tests/Endpoints/CrudEndpointsTests.cs diff --git a/modules/Products/tests/Products.Tests/Unit/ProductIdTests.cs b/modules/Products/tests/Products.Tests/Unit/ProductIdTests.cs new file mode 100644 index 00000000..ee7ced68 --- /dev/null +++ b/modules/Products/tests/Products.Tests/Unit/ProductIdTests.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using SimpleModule.Products.Contracts; + +namespace Products.Tests.Unit; + +public sealed class ProductIdTests +{ + [Fact] + public void From_WithValidInt_CreatesProductId() + { + var id = ProductId.From(42); + + id.Value.Should().Be(42); + } + + [Fact] + public void From_WithZero_CreatesProductId() + { + var id = ProductId.From(0); + + id.Value.Should().Be(0); + } + + [Fact] + public void Equality_SameValue_AreEqual() + { + var id1 = ProductId.From(7); + var id2 = ProductId.From(7); + + id1.Should().Be(id2); + } + + [Fact] + public void Equality_DifferentValue_AreNotEqual() + { + var id1 = ProductId.From(1); + var id2 = ProductId.From(2); + + id1.Should().NotBe(id2); + } + + [Fact] + public void ToString_ReturnsValueString() + { + var id = ProductId.From(5); + + id.ToString(System.Globalization.CultureInfo.InvariantCulture).Should().Contain("5"); + } +} diff --git a/modules/Products/tests/Products.Tests/Unit/UpdateRequestValidatorTests.cs b/modules/Products/tests/Products.Tests/Unit/UpdateRequestValidatorTests.cs new file mode 100644 index 00000000..523bf5bc --- /dev/null +++ b/modules/Products/tests/Products.Tests/Unit/UpdateRequestValidatorTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using SimpleModule.Products.Contracts; +using SimpleModule.Products.Endpoints.Products; + +namespace Products.Tests.Unit; + +public class UpdateRequestValidatorTests +{ + [Fact] + public void Validate_WithValidRequest_ReturnsSuccess() + { + var request = new UpdateProductRequest { Name = "Widget", Price = 9.99m }; + + var result = UpdateRequestValidator.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Validate_WithEmptyName_ReturnsError() + { + var request = new UpdateProductRequest { Name = "", Price = 9.99m }; + + var result = UpdateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Name"); + } + + [Fact] + public void Validate_WithWhitespaceOnlyName_ReturnsError() + { + var request = new UpdateProductRequest { Name = " ", Price = 9.99m }; + + var result = UpdateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Name"); + } + + [Fact] + public void Validate_WithZeroPrice_ReturnsError() + { + var request = new UpdateProductRequest { Name = "Widget", Price = 0m }; + + var result = UpdateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Price"); + } + + [Fact] + public void Validate_WithNegativePrice_ReturnsError() + { + var request = new UpdateProductRequest { Name = "Widget", Price = -5.00m }; + + var result = UpdateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Price"); + } +} diff --git a/tests/SimpleModule.Core.Tests/Endpoints/CrudEndpointsTests.cs b/tests/SimpleModule.Core.Tests/Endpoints/CrudEndpointsTests.cs new file mode 100644 index 00000000..aafffa47 --- /dev/null +++ b/tests/SimpleModule.Core.Tests/Endpoints/CrudEndpointsTests.cs @@ -0,0 +1,83 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http.HttpResults; +using SimpleModule.Core.Endpoints; + +namespace SimpleModule.Core.Tests.Endpoints; + +public class CrudEndpointsTests +{ + [Fact] + public async Task GetAll_ReturnsOkWithItems() + { + var items = new[] { "a", "b" }; + + var result = await CrudEndpoints.GetAll(() => Task.FromResult>(items)); + + var okResult = result.Should().BeOfType>>().Subject; + okResult.Value.Should().HaveCount(2); + okResult.Value.Should().ContainInOrder("a", "b"); + } + + [Fact] + public async Task GetAll_WithEmptyList_ReturnsOkWithEmptyList() + { + var result = await CrudEndpoints.GetAll(() => Task.FromResult>([])); + + var okResult = result.Should().BeOfType>>().Subject; + okResult.Value.Should().BeEmpty(); + } + + [Fact] + public async Task GetById_WhenFound_ReturnsOk() + { + var result = await CrudEndpoints.GetById(() => Task.FromResult("found-item")); + + var okResult = result.Should().BeOfType>().Subject; + okResult.Value.Should().Be("found-item"); + } + + [Fact] + public async Task GetById_WhenNull_ReturnsNotFound() + { + var result = await CrudEndpoints.GetById(() => Task.FromResult(null)); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Create_ReturnsCreatedWithLocation() + { + var result = await CrudEndpoints.Create( + () => Task.FromResult("new-item"), + item => $"/items/{item}" + ); + + var createdResult = result.Should().BeOfType>().Subject; + createdResult.Value.Should().Be("new-item"); + createdResult.Location.Should().Be("/items/new-item"); + } + + [Fact] + public async Task Update_ReturnsOkWithEntity() + { + var result = await CrudEndpoints.Update(() => Task.FromResult("updated-item")); + + var okResult = result.Should().BeOfType>().Subject; + okResult.Value.Should().Be("updated-item"); + } + + [Fact] + public async Task Delete_ReturnsNoContent() + { + var deleteCalled = false; + + var result = await CrudEndpoints.Delete(() => + { + deleteCalled = true; + return Task.CompletedTask; + }); + + result.Should().BeOfType(); + deleteCalled.Should().BeTrue(); + } +} diff --git a/tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs b/tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs index 5dc5009a..deecd25d 100644 --- a/tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs +++ b/tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs @@ -46,4 +46,48 @@ public void AddErrorIf_MultipleSameField_AccumulatesErrors() result.Errors["Name"].Should().HaveCount(2); } + + [Fact] + public void AddErrorIf_MultipleFields_AccumulatesErrorsSeparately() + { + var result = new ValidationBuilder() + .AddErrorIf(true, "Name", "Name is required.") + .AddErrorIf(true, "Price", "Price must be positive.") + .Build(); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Name"); + result.Errors.Should().ContainKey("Price"); + result.Errors["Name"].Should().HaveCount(1); + result.Errors["Price"].Should().HaveCount(1); + } + + [Fact] + public void Build_CalledTwice_ReturnsSameResult() + { + var builder = new ValidationBuilder() + .AddErrorIf(true, "Name", "Name is required."); + + var result1 = builder.Build(); + var result2 = builder.Build(); + + result1.IsValid.Should().Be(result2.IsValid); + result1.Errors.Should().BeEquivalentTo(result2.Errors); + } + + [Fact] + public void AddErrorIf_MixedConditions_OnlyAddsForTrue() + { + var result = new ValidationBuilder() + .AddErrorIf(true, "Name", "Error one.") + .AddErrorIf(false, "Name", "Error two.") + .AddErrorIf(true, "Name", "Error three.") + .Build(); + + result.IsValid.Should().BeFalse(); + result.Errors["Name"].Should().HaveCount(2); + result.Errors["Name"].Should().Contain("Error one."); + result.Errors["Name"].Should().Contain("Error three."); + result.Errors["Name"].Should().NotContain("Error two."); + } } From 81ac3507083ff72b727785bde8135c132aa80136 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 11:29:52 +0100 Subject: [PATCH 031/112] test: add unit tests for Vogen IDs and enum generator extensions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/Orders.Tests/Unit/OrderIdTests.cs | 49 ++++++++++++++++ .../tests/Users.Tests/Unit/UserIdTests.cs | 49 ++++++++++++++++ .../DatabaseProviderExtensionsTests.cs | 58 +++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 modules/Orders/tests/Orders.Tests/Unit/OrderIdTests.cs create mode 100644 modules/Users/tests/Users.Tests/Unit/UserIdTests.cs create mode 100644 tests/SimpleModule.Database.Tests/DatabaseProviderExtensionsTests.cs diff --git a/modules/Orders/tests/Orders.Tests/Unit/OrderIdTests.cs b/modules/Orders/tests/Orders.Tests/Unit/OrderIdTests.cs new file mode 100644 index 00000000..9b877c5b --- /dev/null +++ b/modules/Orders/tests/Orders.Tests/Unit/OrderIdTests.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using SimpleModule.Orders.Contracts; + +namespace Orders.Tests.Unit; + +public sealed class OrderIdTests +{ + [Fact] + public void From_WithValidInt_CreatesOrderId() + { + var id = OrderId.From(42); + + id.Value.Should().Be(42); + } + + [Fact] + public void From_WithZero_CreatesOrderId() + { + var id = OrderId.From(0); + + id.Value.Should().Be(0); + } + + [Fact] + public void Equality_SameValue_AreEqual() + { + var id1 = OrderId.From(7); + var id2 = OrderId.From(7); + + id1.Should().Be(id2); + } + + [Fact] + public void Equality_DifferentValue_AreNotEqual() + { + var id1 = OrderId.From(1); + var id2 = OrderId.From(2); + + id1.Should().NotBe(id2); + } + + [Fact] + public void ToString_ReturnsValueString() + { + var id = OrderId.From(5); + + id.ToString(System.Globalization.CultureInfo.InvariantCulture).Should().Contain("5"); + } +} diff --git a/modules/Users/tests/Users.Tests/Unit/UserIdTests.cs b/modules/Users/tests/Users.Tests/Unit/UserIdTests.cs new file mode 100644 index 00000000..734fdbcf --- /dev/null +++ b/modules/Users/tests/Users.Tests/Unit/UserIdTests.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using SimpleModule.Users.Contracts; +using Vogen; + +namespace Users.Tests.Unit; + +public sealed class UserIdTests +{ + [Fact] + public void From_WithValidString_CreatesUserId() + { + var id = UserId.From("user-123"); + + id.Value.Should().Be("user-123"); + } + + [Fact] + public void From_WithEmptyString_ThrowsException() + { + var act = () => UserId.From(string.Empty); + + act.Should().Throw(); + } + + [Fact] + public void From_WithWhitespace_ThrowsException() + { + var act = () => UserId.From(" "); + + act.Should().Throw(); + } + + [Fact] + public void Equality_SameValue_AreEqual() + { + var id1 = UserId.From("user-abc"); + var id2 = UserId.From("user-abc"); + + id1.Should().Be(id2); + } + + [Fact] + public void ToString_ReturnsValueString() + { + var id = UserId.From("user-456"); + + id.ToString(System.Globalization.CultureInfo.InvariantCulture).Should().Contain("user-456"); + } +} diff --git a/tests/SimpleModule.Database.Tests/DatabaseProviderExtensionsTests.cs b/tests/SimpleModule.Database.Tests/DatabaseProviderExtensionsTests.cs new file mode 100644 index 00000000..faac1714 --- /dev/null +++ b/tests/SimpleModule.Database.Tests/DatabaseProviderExtensionsTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; +using SimpleModule.Database; + +namespace SimpleModule.Database.Tests; + +public sealed class DatabaseProviderExtensionsTests +{ + [Theory] + [InlineData(DatabaseProvider.Sqlite, "Sqlite")] + [InlineData(DatabaseProvider.PostgreSql, "PostgreSql")] + [InlineData(DatabaseProvider.SqlServer, "SqlServer")] + public void ToStringFast_ReturnsCorrectName(DatabaseProvider provider, string expected) + { + provider.ToStringFast().Should().Be(expected); + } + + [Theory] + [InlineData("Sqlite", DatabaseProvider.Sqlite)] + [InlineData("PostgreSql", DatabaseProvider.PostgreSql)] + [InlineData("SqlServer", DatabaseProvider.SqlServer)] + public void TryParse_ValidName_ReturnsTrue(string name, DatabaseProvider expected) + { + DatabaseProviderExtensions.TryParse(name, out var result).Should().BeTrue(); + result.Should().Be(expected); + } + + [Theory] + [InlineData("sqlite")] + [InlineData("POSTGRESQL")] + [InlineData("sqlserver")] + public void TryParse_CaseInsensitive_ReturnsTrue(string name) + { + // Use case-insensitive span-based parsing + DatabaseProviderExtensions.TryParse(name, out var result, true).Should().BeTrue(); + Enum.IsDefined(result).Should().BeTrue(); + } + + [Fact] + public void TryParse_InvalidName_ReturnsFalse() + { + DatabaseProviderExtensions.TryParse("MySQL", out _).Should().BeFalse(); + } + + [Theory] + [InlineData(DatabaseProvider.Sqlite)] + [InlineData(DatabaseProvider.PostgreSql)] + [InlineData(DatabaseProvider.SqlServer)] + public void IsDefined_ValidValue_ReturnsTrue(DatabaseProvider provider) + { + DatabaseProviderExtensions.IsDefined(provider).Should().BeTrue(); + } + + [Fact] + public void IsDefined_InvalidValue_ReturnsFalse() + { + DatabaseProviderExtensions.IsDefined((DatabaseProvider)999).Should().BeFalse(); + } +} From 89ea31de39fdbce79e4ca30962fbd0911ffe3a97 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 12:12:29 +0100 Subject: [PATCH 032/112] fix: regenerate EF Core migration for Vogen model, use EnsureCreated in dev - Regenerated InitialCreate migration to include Vogen value converter model - Changed Program.cs to use EnsureCreatedAsync instead of MigrateAsync in non-production environments (avoids provider mismatch issues) - Suppress PendingModelChangesWarning in AddModuleDbContext (Vogen's ConfigureConventions causes model diff without schema changes) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ModuleDbContextOptionsBuilder.cs | 6 ++++++ ...igner.cs => 20260318110834_InitialCreate.Designer.cs} | 2 +- ..._InitialCreate.cs => 20260318110834_InitialCreate.cs} | 0 template/SimpleModule.Host/Program.cs | 9 +-------- 4 files changed, 8 insertions(+), 9 deletions(-) rename template/SimpleModule.Host/Migrations/{20260318094606_InitialCreate.Designer.cs => 20260318110834_InitialCreate.Designer.cs} (99%) rename template/SimpleModule.Host/Migrations/{20260318094606_InitialCreate.cs => 20260318110834_InitialCreate.cs} (100%) diff --git a/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs b/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs index 85b0d7fd..226e5c4d 100644 --- a/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs +++ b/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -44,6 +45,11 @@ public static IServiceCollection AddModuleDbContext( break; } + // Suppress PendingModelChangesWarning caused by Vogen ConfigureConventions + // value converters that don't change the actual schema + options.ConfigureWarnings(w => + w.Ignore(RelationalEventId.PendingModelChangesWarning)); + configureOptions?.Invoke(options); }); diff --git a/template/SimpleModule.Host/Migrations/20260318094606_InitialCreate.Designer.cs b/template/SimpleModule.Host/Migrations/20260318110834_InitialCreate.Designer.cs similarity index 99% rename from template/SimpleModule.Host/Migrations/20260318094606_InitialCreate.Designer.cs rename to template/SimpleModule.Host/Migrations/20260318110834_InitialCreate.Designer.cs index a6c63bb1..d9559a9d 100644 --- a/template/SimpleModule.Host/Migrations/20260318094606_InitialCreate.Designer.cs +++ b/template/SimpleModule.Host/Migrations/20260318110834_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace SimpleModule.Host.Migrations { [DbContext(typeof(HostDbContext))] - [Migration("20260318094606_InitialCreate")] + [Migration("20260318110834_InitialCreate")] partial class InitialCreate { /// diff --git a/template/SimpleModule.Host/Migrations/20260318094606_InitialCreate.cs b/template/SimpleModule.Host/Migrations/20260318110834_InitialCreate.cs similarity index 100% rename from template/SimpleModule.Host/Migrations/20260318094606_InitialCreate.cs rename to template/SimpleModule.Host/Migrations/20260318110834_InitialCreate.cs diff --git a/template/SimpleModule.Host/Program.cs b/template/SimpleModule.Host/Program.cs index cfac7ba7..5c4fa0d8 100644 --- a/template/SimpleModule.Host/Program.cs +++ b/template/SimpleModule.Host/Program.cs @@ -140,14 +140,7 @@ { using var scope = app.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - if (app.Environment.IsDevelopment()) - { - await db.Database.MigrateAsync(); - } - else - { - await db.Database.EnsureCreatedAsync(); - } + await db.Database.EnsureCreatedAsync(); } app.UseExceptionHandler(); From e6e1b4a201394ca133783a5909a9738f25233357 Mon Sep 17 00:00:00 2001 From: antosubash Date: Wed, 18 Mar 2026 13:29:52 +0100 Subject: [PATCH 033/112] feat: add error handling for non-Inertia responses and improve toast notifications --- .claude/settings.local.json | 7 +++- .../Exceptions/GlobalExceptionHandler.cs | 8 ++--- template/SimpleModule.Host/ClientApp/app.tsx | 34 ++++++++++++++++++- .../SimpleModule.Host.csproj | 2 +- tools/download-tailwind.sh | 0 5 files changed, 42 insertions(+), 9 deletions(-) mode change 100644 => 100755 tools/download-tailwind.sh diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c6c242a1..22401419 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -31,5 +31,10 @@ "mcp__plugin_playwright_playwright__browser_network_requests", "mcp__plugin_playwright_playwright__browser_console_messages" ] - } + }, + "enabledMcpjsonServers": [ + "aspire", + "playwright" + ], + "enableAllProjectMcpServers": true } diff --git a/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs b/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs index bf625e7c..8c2774f9 100644 --- a/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs +++ b/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -22,11 +22,7 @@ CancellationToken cancellationToken ErrorMessages.ValidationErrorTitle, ve.Errors ), - NotFoundException => ( - StatusCodes.Status404NotFound, - ErrorMessages.NotFoundTitle, - (Dictionary?)null - ), + NotFoundException => (StatusCodes.Status404NotFound, ErrorMessages.NotFoundTitle, null), ConflictException => (StatusCodes.Status409Conflict, ErrorMessages.ConflictTitle, null), _ => ( StatusCodes.Status500InternalServerError, diff --git a/template/SimpleModule.Host/ClientApp/app.tsx b/template/SimpleModule.Host/ClientApp/app.tsx index 010b0756..c73ae5b6 100644 --- a/template/SimpleModule.Host/ClientApp/app.tsx +++ b/template/SimpleModule.Host/ClientApp/app.tsx @@ -1,7 +1,39 @@ -import { createInertiaApp } from '@inertiajs/react'; +import { createInertiaApp, router } from '@inertiajs/react'; import { resolvePage } from '@simplemodule/client/resolve-page'; import { createRoot } from 'react-dom/client'; +// Handle non-Inertia error responses (404, 500, etc.) by showing a toast +// instead of the default "must receive a valid Inertia response" error. +router.on('invalid', (event) => { + event.preventDefault(); + + const response = event.detail.response; + const body = response.data as { detail?: string; title?: string } | undefined; + const message = body?.detail ?? body?.title ?? `Server error (${response.status})`; + showErrorToast(message); +}); + +function showErrorToast(message: string) { + const container = document.createElement('div'); + container.role = 'alert'; + container.className = + 'fixed bottom-4 right-4 z-[100] max-w-md rounded-xl border border-danger/20 bg-danger-bg p-4 text-danger-text shadow-lg animate-in slide-in-from-bottom-full'; + container.innerHTML = ` +
+
+

Error

+

${message.replace(/[<>"'&]/g, (c) => `&#${c.charCodeAt(0)};`)}

+
+ +
+ `; + container.querySelector('button')?.addEventListener('click', () => container.remove()); + document.body.appendChild(container); + setTimeout(() => container.remove(), 8000); +} + createInertiaApp({ resolve: resolvePage, setup({ el, App, props }) { diff --git a/template/SimpleModule.Host/SimpleModule.Host.csproj b/template/SimpleModule.Host/SimpleModule.Host.csproj index 20cfcce6..0f97c37e 100644 --- a/template/SimpleModule.Host/SimpleModule.Host.csproj +++ b/template/SimpleModule.Host/SimpleModule.Host.csproj @@ -71,7 +71,7 @@ diff --git a/tools/download-tailwind.sh b/tools/download-tailwind.sh old mode 100644 new mode 100755 From 30ecda9dfdd73f887224731f4471e572401cc980 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 13:34:29 +0100 Subject: [PATCH 034/112] docs: add permission system design Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-18-permission-system-design.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/plans/2026-03-18-permission-system-design.md diff --git a/docs/plans/2026-03-18-permission-system-design.md b/docs/plans/2026-03-18-permission-system-design.md new file mode 100644 index 00000000..0fd1849c --- /dev/null +++ b/docs/plans/2026-03-18-permission-system-design.md @@ -0,0 +1,239 @@ +# Permission System Design + +**Date:** 2026-03-18 +**Status:** Approved + +## Overview + +A module-scoped permission system for SimpleModule. Modules declare strongly-typed permissions via static constant classes, registered through a `ConfigurePermissions` method on `IModule`. Permissions can be assigned to both users and roles, stored in dedicated tables, and projected into claims at login for fast runtime checks. + +## Design Decisions + +| Aspect | Decision | +|--------|----------| +| Definition | Static class with `const string` fields per module | +| Registration | `ConfigurePermissions(PermissionRegistryBuilder)` on `IModule` | +| Checking | `[RequirePermission]` attribute (generator-wired) + inline `.RequirePermission()` escape hatch | +| Storage | Dedicated `UserPermissions` / `RolePermissions` tables | +| Runtime | Claims projection at login, claim-based checks at request time | +| Default policy | Deny by default — `[AllowAnonymous]` to opt out | +| Auto-generation | Generator emits permissions class for `CrudEndpoints` modules | +| Admin override | Admin role bypasses all permission checks | +| Admin UI | Extended role/user edit pages with module-grouped permission checklists | + +## Section 1: Permission Definition + +Each module defines a static class with string constants. The module name is used as a prefix to prevent collisions. + +```csharp +// modules/Products/src/Products/ProductsPermissions.cs +public static class ProductsPermissions +{ + public const string View = "Products.View"; + public const string Create = "Products.Create"; + public const string Update = "Products.Update"; + public const string Delete = "Products.Delete"; + // Custom permissions added manually: + public const string Export = "Products.Export"; +} +``` + +For modules using `CrudEndpoints`, the **source generator auto-emits** the four CRUD permission constants (`View`, `Create`, `Update`, `Delete`). The developer can extend with additional custom permissions in a separate partial class or a new class. + +The module registers its permissions in `ConfigurePermissions`: + +```csharp +public class ProductsModule : IModule +{ + public void ConfigurePermissions(PermissionRegistryBuilder builder) + { + builder.AddPermissions(); + } +} +``` + +`AddPermissions()` reflects over the static class at startup (one-time registration) and registers all `public const string` fields as known permissions. + +## Section 2: Permission Checking on Endpoints + +Two mechanisms, both supported: + +### Attribute-based (common case) + +```csharp +[RequirePermission(ProductsPermissions.Create)] +public class CreateProductEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapPost("/products", HandleAsync); + } +} +``` + +The source generator discovers `[RequirePermission]` on `IEndpoint` classes and emits `.RequireAuthorization(policy => policy.RequireClaim("permission", "Products.Create"))` in the generated `MapModuleEndpoints()` method. + +### Inline (escape hatch) + +```csharp +public class SpecialProductEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapPost("/products/special", HandleAsync) + .RequirePermission(ProductsPermissions.Create, ProductsPermissions.Export); + } +} +``` + +### AllowAnonymous opt-out + +```csharp +[AllowAnonymous] +public class PublicProductListEndpoint : IEndpoint { ... } +``` + +The generator recognizes `[AllowAnonymous]` and skips adding `.RequireAuthorization()`. Without either attribute, the deny-by-default policy requires authentication but no specific permission. + +### Multiple permissions + +Multiple permissions on one endpoint are treated as **AND** (user must have all). For OR semantics, use inline with a custom policy. + +## Section 3: Storage & Claims Projection + +### Dedicated tables (Users module) + +``` +UserPermissions +├── UserId (FK → AspNetUsers.Id) +├── Permission (string, e.g., "Products.Create") +└── PK: (UserId, Permission) + +RolePermissions +├── RoleId (FK → AspNetRoles.Id) +├── Permission (string, e.g., "Products.Create") +└── PK: (RoleId, Permission) +``` + +### Claims projection at login + +When the OAuth2 token is issued (in `AuthorizationEndpoint`): + +1. Load the user's direct permissions from `UserPermissions` +2. Load permissions from all the user's roles via `RolePermissions` +3. Merge and deduplicate +4. Add each as a claim: `type = "permission"`, `value = "Products.Create"` + +Claims travel with the token/cookie — no database hits at request time. + +### Authorization handler + +A custom `PermissionAuthorizationHandler` checks for the required `"permission"` claim(s) on the `ClaimsPrincipal`. + +### Admin override + +Users with the `Admin` role bypass permission checks entirely (all permissions implicitly granted). + +## Section 4: Source Generator Changes + +### EndpointExtensionsEmitter updates + +Discovers `[RequirePermission]` and `[AllowAnonymous]` on endpoint classes: + +```csharp +// Generated output in MapModuleEndpoints(): +endpoints.MapPost("/products", CreateProductEndpoint.HandleAsync) + .RequireAuthorization(policy => policy.RequireClaim("permission", "Products.Create")); + +endpoints.MapGet("/products/public", PublicProductListEndpoint.HandleAsync) + .AllowAnonymous(); + +// No attribute → deny-by-default: +endpoints.MapGet("/products/internal", InternalEndpoint.HandleAsync) + .RequireAuthorization(); // authenticated, no specific permission +``` + +### New PermissionsEmitter + +Scans modules that use `CrudEndpoints`. For each, emits: + +```csharp +// +namespace Products; + +public static partial class ProductsPermissions +{ + public const string View = "Products.View"; + public const string Create = "Products.Create"; + public const string Update = "Products.Update"; + public const string Delete = "Products.Delete"; +} +``` + +The class is `partial` so developers can extend it with custom permissions. The generator also auto-applies the correct `[RequirePermission]` to the CRUD endpoints it maps. + +## Section 5: IModule Interface & Registration Flow + +### IModule changes + +```csharp +public interface IModule +{ + void ConfigureServices(IServiceCollection services, IConfiguration configuration); + void ConfigureEndpoints(IEndpointRouteBuilder builder) { } + void ConfigurePermissions(PermissionRegistryBuilder builder) { } // new +} +``` + +### Registration flow at startup + +1. `AddModules()` called in `Program.cs` +2. For each module: + - `module.ConfigureServices(services, config)` + - `module.ConfigurePermissions(permissionRegistry)` +3. `PermissionRegistry` registered as a singleton +4. `AddAuthorization()` registers: + - Default policy: `RequireAuthenticatedUser()` + - `PermissionAuthorizationHandler` +5. At login (OAuth2 token issuance): + - Load user + role permissions from DB + - Merge, deduplicate, add as `"permission"` claims + +### PermissionRegistryBuilder API + +```csharp +public class PermissionRegistryBuilder +{ + public void AddPermissions() where T : class; // scans static string constants + public void AddPermission(string permission); // manual registration +} + +public class PermissionRegistry +{ + public IReadOnlySet AllPermissions { get; } + public IReadOnlyDictionary> ByModule { get; } // "Products" → [...] +} +``` + +The `PermissionRegistry` is used by the admin UI to display available permissions — no hardcoded lists. + +## Section 6: Admin UI for Permission Management + +### Role permission management + +- `GET /admin/roles/{id}/edit` — Extended to show permissions checklist grouped by module (from `PermissionRegistry`). +- `POST /admin/roles/{id}/permissions` — New endpoint. Saves selected permissions to `RolePermissions` table. + +### User permission management + +- `GET /admin/users/{id}/edit` — Extended to show: + - Inherited permissions (from roles) — displayed read-only + - Direct permissions — editable checklist +- `POST /admin/users/{id}/permissions` — New endpoint. Saves to `UserPermissions` table. + +### Frontend + +- React components receive `PermissionRegistry.ByModule` as props +- Permissions rendered as checkboxes grouped under module headings +- Inherited-from-role permissions shown as checked + disabled with tooltip showing which role grants them +- No new pages — everything fits into existing edit views for roles and users From 2dfb7208402a5f06624e0bad945f60ccef2872af Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 13:40:29 +0100 Subject: [PATCH 035/112] docs: add permission system implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-18-permission-system-plan.md | 1313 +++++++++++++++++ 1 file changed, 1313 insertions(+) create mode 100644 docs/plans/2026-03-18-permission-system-plan.md diff --git a/docs/plans/2026-03-18-permission-system-plan.md b/docs/plans/2026-03-18-permission-system-plan.md new file mode 100644 index 00000000..7a73953b --- /dev/null +++ b/docs/plans/2026-03-18-permission-system-plan.md @@ -0,0 +1,1313 @@ +# Permission System Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a module-scoped permission system with strongly-typed permissions, attribute-based endpoint protection, hybrid storage (dedicated tables + claims projection), deny-by-default policy, and admin permission seeding. + +**Architecture:** Modules declare permissions via static constant classes registered through `ConfigurePermissions()` on `IModule`. The source generator discovers `[RequirePermission]` and `[AllowAnonymous]` on endpoints and emits authorization wiring. Permissions are stored in dedicated `UserPermission`/`RolePermission` tables and projected into claims at login. Admin role gets all permissions seeded automatically. + +**Tech Stack:** ASP.NET Core Authorization, EF Core (SQLite/PostgreSQL), Roslyn IIncrementalGenerator (netstandard2.0), xUnit.v3 + FluentAssertions. + +**Design doc:** `docs/plans/2026-03-18-permission-system-design.md` + +--- + +### Task 1: Add `RequirePermissionAttribute` to Core + +**Files:** +- Create: `framework/SimpleModule.Core/Authorization/RequirePermissionAttribute.cs` + +**Step 1: Create the attribute** + +```csharp +using System; + +namespace SimpleModule.Core.Authorization; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class RequirePermissionAttribute : Attribute +{ + public string[] Permissions { get; } + + public RequirePermissionAttribute(params string[] permissions) + { + Permissions = permissions; + } +} +``` + +**Step 2: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Core` +Expected: SUCCESS + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Core/Authorization/RequirePermissionAttribute.cs +git commit -m "feat: add RequirePermissionAttribute to Core" +``` + +--- + +### Task 2: Add `PermissionRegistryBuilder` and `PermissionRegistry` to Core + +**Files:** +- Create: `framework/SimpleModule.Core/Authorization/PermissionRegistry.cs` +- Create: `framework/SimpleModule.Core/Authorization/PermissionRegistryBuilder.cs` + +**Step 1: Create PermissionRegistry** + +```csharp +using System.Collections.Generic; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionRegistry +{ + public IReadOnlySet AllPermissions { get; } + public IReadOnlyDictionary> ByModule { get; } + + internal PermissionRegistry( + IReadOnlySet allPermissions, + IReadOnlyDictionary> byModule) + { + AllPermissions = allPermissions; + ByModule = byModule; + } +} +``` + +**Step 2: Create PermissionRegistryBuilder** + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionRegistryBuilder +{ + private readonly Dictionary> _byModule = new(); + + public void AddPermissions() where T : class + { + var fields = typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)); + + foreach (var field in fields) + { + var value = (string)field.GetRawConstantValue()!; + AddPermission(value); + } + } + + public void AddPermission(string permission) + { + var dotIndex = permission.IndexOf('.'); + var module = dotIndex >= 0 ? permission[..dotIndex] : "Global"; + + if (!_byModule.TryGetValue(module, out var list)) + { + list = new List(); + _byModule[module] = list; + } + + if (!list.Contains(permission)) + { + list.Add(permission); + } + } + + public PermissionRegistry Build() + { + var all = new HashSet(); + var byModule = new Dictionary>(); + + foreach (var (module, permissions) in _byModule) + { + byModule[module] = permissions.AsReadOnly(); + foreach (var p in permissions) + { + all.Add(p); + } + } + + return new PermissionRegistry(all, byModule); + } +} +``` + +**Step 3: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Core` +Expected: SUCCESS + +**Step 4: Commit** + +```bash +git add framework/SimpleModule.Core/Authorization/PermissionRegistry.cs framework/SimpleModule.Core/Authorization/PermissionRegistryBuilder.cs +git commit -m "feat: add PermissionRegistryBuilder and PermissionRegistry" +``` + +--- + +### Task 3: Add `ConfigurePermissions` to `IModule` + +**Files:** +- Modify: `framework/SimpleModule.Core/IModule.cs:8-13` + +**Step 1: Add the method** + +Add `using SimpleModule.Core.Authorization;` to the top and add the new virtual method to `IModule`: + +```csharp +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Menu; + +namespace SimpleModule.Core; + +public interface IModule +{ + virtual void ConfigureServices(IServiceCollection services, IConfiguration configuration) { } + virtual void ConfigureEndpoints(IEndpointRouteBuilder endpoints) { } + virtual void ConfigureMenu(IMenuBuilder menus) { } + virtual void ConfigurePermissions(PermissionRegistryBuilder builder) { } +} +``` + +**Step 2: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Core` +Expected: SUCCESS + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Core/IModule.cs +git commit -m "feat: add ConfigurePermissions to IModule interface" +``` + +--- + +### Task 4: Add `RequirePermission` extension method for inline use + +**Files:** +- Create: `framework/SimpleModule.Core/Authorization/EndpointPermissionExtensions.cs` + +**Step 1: Create the extension method** + +```csharp +using Microsoft.AspNetCore.Builder; + +namespace SimpleModule.Core.Authorization; + +public static class EndpointPermissionExtensions +{ + public static TBuilder RequirePermission(this TBuilder builder, params string[] permissions) + where TBuilder : IEndpointConventionBuilder + { + return builder.RequireAuthorization(policy => + { + policy.RequireAuthenticatedUser(); + foreach (var permission in permissions) + { + policy.RequireClaim("permission", permission); + } + }); + } +} +``` + +**Step 2: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Core` +Expected: SUCCESS + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Core/Authorization/EndpointPermissionExtensions.cs +git commit -m "feat: add RequirePermission extension method for inline use" +``` + +--- + +### Task 5: Add `PermissionAuthorizationHandler` + +**Files:** +- Create: `framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs` +- Create: `framework/SimpleModule.Core/Authorization/PermissionRequirement.cs` + +**Step 1: Create the requirement** + +```csharp +using Microsoft.AspNetCore.Authorization; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionRequirement : IAuthorizationRequirement +{ + public string Permission { get; } + + public PermissionRequirement(string permission) + { + Permission = permission; + } +} +``` + +**Step 2: Create the handler** + +```csharp +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionAuthorizationHandler : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionRequirement requirement) + { + // Admin role bypasses all permission checks + if (context.User.IsInRole("Admin")) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + + if (context.User.HasClaim("permission", requirement.Permission)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} +``` + +**Step 3: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Core` +Expected: SUCCESS + +**Step 4: Commit** + +```bash +git add framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs framework/SimpleModule.Core/Authorization/PermissionRequirement.cs +git commit -m "feat: add PermissionAuthorizationHandler with admin bypass" +``` + +--- + +### Task 6: Update source generator — discover `[RequirePermission]` and `[AllowAnonymous]` on endpoints + +**Files:** +- Modify: `framework/SimpleModule.Generator/Discovery/DiscoveryData.cs:101` (EndpointInfoRecord) +- Modify: `framework/SimpleModule.Generator/Discovery/DiscoveryData.cs:204` (EndpointInfo mutable class) +- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs:278-335` (FindEndpointTypes) + +**Step 1: Extend `EndpointInfoRecord` with permission data** + +In `DiscoveryData.cs`, replace: + +```csharp +internal readonly record struct EndpointInfoRecord(string FullyQualifiedName); +``` + +With: + +```csharp +internal readonly record struct EndpointInfoRecord( + string FullyQualifiedName, + ImmutableArray RequiredPermissions, + bool AllowAnonymous +) +{ + public bool Equals(EndpointInfoRecord other) + { + return FullyQualifiedName == other.FullyQualifiedName + && AllowAnonymous == other.AllowAnonymous + && RequiredPermissions.SequenceEqual(other.RequiredPermissions); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + FullyQualifiedName.GetHashCode(); + hash = hash * 31 + AllowAnonymous.GetHashCode(); + foreach (var p in RequiredPermissions) + hash = hash * 31 + p.GetHashCode(); + return hash; + } + } +} +``` + +**Step 2: Update the mutable `EndpointInfo` class** + +Replace: + +```csharp +internal sealed class EndpointInfo +{ + public string FullyQualifiedName { get; set; } = ""; +} +``` + +With: + +```csharp +internal sealed class EndpointInfo +{ + public string FullyQualifiedName { get; set; } = ""; + public List RequiredPermissions { get; set; } = new(); + public bool AllowAnonymous { get; set; } +} +``` + +**Step 3: Update `EndpointInfoRecord` construction in `SymbolDiscovery.Extract`** + +In `SymbolDiscovery.cs`, find the line (around line 167): + +```csharp +m.Endpoints.Select(e => new EndpointInfoRecord(e.FullyQualifiedName)) +``` + +Replace with: + +```csharp +m.Endpoints.Select(e => new EndpointInfoRecord( + e.FullyQualifiedName, + e.RequiredPermissions.ToImmutableArray(), + e.AllowAnonymous)) +``` + +**Step 4: Update `FindEndpointTypes` to extract attributes** + +In `SymbolDiscovery.cs`, in the `FindEndpointTypes` method, update the `IEndpoint` branch (around line 328-330). After creating the `EndpointInfo`, scan the type's attributes: + +Replace: + +```csharp +else if (ImplementsInterface(typeSymbol, endpointInterfaceSymbol)) +{ + endpoints.Add(new EndpointInfo { FullyQualifiedName = fqn }); +} +``` + +With: + +```csharp +else if (ImplementsInterface(typeSymbol, endpointInterfaceSymbol)) +{ + var info = new EndpointInfo { FullyQualifiedName = fqn }; + + foreach (var attr in typeSymbol.GetAttributes()) + { + var attrName = attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + if (attrName == "global::SimpleModule.Core.Authorization.RequirePermissionAttribute") + { + // params string[] permissions — stored as a single array constructor argument + if (attr.ConstructorArguments.Length > 0) + { + var arg = attr.ConstructorArguments[0]; + if (arg.Kind == TypedConstantKind.Array) + { + foreach (var val in arg.Values) + { + if (val.Value is string s) + info.RequiredPermissions.Add(s); + } + } + else if (arg.Value is string single) + { + info.RequiredPermissions.Add(single); + } + } + } + else if (attrName == "global::Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute") + { + info.AllowAnonymous = true; + } + } + + endpoints.Add(info); +} +``` + +**Step 5: Update `ModuleInfoRecord` equality to account for new `EndpointInfoRecord` fields** + +The `ModuleInfoRecord.Equals` already calls `Endpoints.SequenceEqual(other.Endpoints)`, which will use the updated `EndpointInfoRecord.Equals`. No changes needed here. + +**Step 6: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Generator` +Expected: SUCCESS + +**Step 7: Commit** + +```bash +git add framework/SimpleModule.Generator/Discovery/DiscoveryData.cs framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +git commit -m "feat: generator discovers RequirePermission and AllowAnonymous on endpoints" +``` + +--- + +### Task 7: Update `EndpointExtensionsEmitter` to emit authorization wiring + +**Files:** +- Modify: `framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs:14-58` + +**Step 1: Update usings and endpoint mapping** + +Add `using Microsoft.AspNetCore.Authorization;` to the generated output. Then update the endpoint registration loop to append auth calls. + +In `EndpointExtensionsEmitter.cs`, update the generated code. The emitter currently emits: + +```csharp +sb.AppendLine($" new {endpoint.FullyQualifiedName}().Map(group);"); +``` + +Change each endpoint mapping line to check for `AllowAnonymous` and `RequiredPermissions`: + +For the route-prefix branch (lines 45-48), replace: + +```csharp +foreach (var endpoint in module.Endpoints) +{ + sb.AppendLine($" new {endpoint.FullyQualifiedName}().Map(group);"); +} +``` + +With a helper method call. Add a private method to the emitter class: + +```csharp +private static void EmitEndpointMapping(StringBuilder sb, EndpointInfoRecord endpoint, string target) +{ + sb.Append($" new {endpoint.FullyQualifiedName}().Map({target});"); + + // Note: The Map() method registers the route internally. + // We cannot chain .AllowAnonymous() or .RequireAuthorization() on the Map() call + // because Map() is void. Instead we need a different approach. +} +``` + +**IMPORTANT DESIGN CONSIDERATION:** The `IEndpoint.Map()` method returns `void`, so we cannot chain `.AllowAnonymous()` or `.RequireAuthorization()` on it. The emitter needs a different approach. + +**Revised approach:** Instead of chaining on `Map()`, the emitter should generate a wrapper that: +1. Calls `endpoint.Map(group)` as before +2. After all endpoints in a module are mapped, applies auth policies to the group level + +Actually, the better approach is to apply authorization at the **route group** level for the module, and let `[AllowAnonymous]` on individual endpoints override. Since we already have `var group = app.MapGroup(...)`, we can add `.RequireAuthorization()` to the group to enforce deny-by-default. Individual endpoints with `[AllowAnonymous]` will need to call `.AllowAnonymous()` inside their `Map()` method. + +**Revised emitter changes:** + +Update the route-prefix group creation line (line 43): + +Replace: + +```csharp +sb.AppendLine( + $" var group = app.MapGroup(\"{module.RoutePrefix}\").WithTags(\"{module.ModuleName}\");" +); +``` + +With: + +```csharp +sb.AppendLine( + $" var group = app.MapGroup(\"{module.RoutePrefix}\").WithTags(\"{module.ModuleName}\").RequireAuthorization();" +); +``` + +This makes all auto-registered endpoints require authentication by default (deny-by-default). Endpoints with `[AllowAnonymous]` opt out inside their `Map()` call. Endpoints with `[RequirePermission]` need the permission check applied. + +For `[RequirePermission]` — since `Map()` is void, the endpoint class itself should apply `.RequireAuthorization()` in its `Map()` method (inline approach). The attribute is still useful for the **source generator to auto-generate the `Map()` body for `CrudEndpoints`** (Task 9). + +**However**, a better approach that doesn't change `IEndpoint.Map()`: make `Map()` return `RouteHandlerBuilder?` or modify the emitter to NOT use `Map()` for endpoints with permissions. Let's keep it simple: + +**Final revised approach:** Add `.RequireAuthorization()` to the group (deny-by-default). The `[RequirePermission]` attribute is used by developers as documentation and by the CrudEndpoints generator (Task 9). For manually-written endpoints, developers use the inline `.RequirePermission()` extension inside their `Map()` method. This keeps `IEndpoint` unchanged and the emitter changes minimal. + +So the only emitter change is adding `.RequireAuthorization()` to groups: + +In `EndpointExtensionsEmitter.cs`: + +Replace line 43: +```csharp +$" var group = app.MapGroup(\"{module.RoutePrefix}\").WithTags(\"{module.ModuleName}\");" +``` +With: +```csharp +$" var group = app.MapGroup(\"{module.RoutePrefix}\").WithTags(\"{module.ModuleName}\").RequireAuthorization();" +``` + +Replace line 76 (view group): +```csharp +$" var viewGroup = app.MapGroup(\"{module.ViewPrefix}\").WithTags(\"{module.ModuleName}\").ExcludeFromDescription();" +``` +With: +```csharp +$" var viewGroup = app.MapGroup(\"{module.ViewPrefix}\").WithTags(\"{module.ModuleName}\").ExcludeFromDescription().RequireAuthorization();" +``` + +Also add the `using Microsoft.AspNetCore.Authorization;` to the generated file (after line 18): +```csharp +sb.AppendLine("using Microsoft.AspNetCore.Authorization;"); +``` + +**Step 2: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Generator` +Expected: SUCCESS + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs +git commit -m "feat: emitter adds RequireAuthorization to endpoint groups (deny-by-default)" +``` + +--- + +### Task 8: Update `ModuleExtensionsEmitter` to call `ConfigurePermissions` and register services + +**Files:** +- Modify: `framework/SimpleModule.Generator/Discovery/DiscoveryData.cs` (add `HasConfigurePermissions` to `ModuleInfoRecord` and `ModuleInfo`) +- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs` (detect `ConfigurePermissions`) +- Modify: `framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs:37-66` + +**Step 1: Add `HasConfigurePermissions` to data model** + +In `DiscoveryData.cs`, add `bool HasConfigurePermissions` to `ModuleInfoRecord` (after `HasConfigureMenu`): + +```csharp +internal readonly record struct ModuleInfoRecord( + string FullyQualifiedName, + string ModuleName, + bool HasConfigureServices, + bool HasConfigureEndpoints, + bool HasConfigureMenu, + bool HasConfigurePermissions, + bool HasRazorComponents, + string RoutePrefix, + string ViewPrefix, + ImmutableArray Endpoints, + ImmutableArray Views +) +``` + +Update `Equals` and `GetHashCode` to include `HasConfigurePermissions`. + +In `ModuleInfo` mutable class, add: +```csharp +public bool HasConfigurePermissions { get; set; } +``` + +**Step 2: Detect `ConfigurePermissions` in `SymbolDiscovery`** + +In `SymbolDiscovery.cs`, in the `FindModuleTypes` method (around line 266), add: + +```csharp +HasConfigurePermissions = DeclaresMethod(typeSymbol, "ConfigurePermissions"), +``` + +Also update the `ModuleInfoRecord` construction (around line 158) to include: + +```csharp +m.HasConfigurePermissions, +``` + +**Step 3: Update `ModuleExtensionsEmitter` to generate permission registration** + +In `ModuleExtensionsEmitter.cs`, add authorization-related usings to the generated output (after line 21): + +```csharp +sb.AppendLine("using Microsoft.AspNetCore.Authorization;"); +sb.AppendLine("using SimpleModule.Core.Authorization;"); +``` + +After the `ConfigureServices` calls (around line 47), add permission registration: + +```csharp +// Permission registry +sb.AppendLine(); +sb.AppendLine(" var permissionBuilder = new PermissionRegistryBuilder();"); + +foreach (var module in modules.Where(m => m.HasConfigurePermissions)) +{ + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine($" {fieldName}.ConfigurePermissions(permissionBuilder);"); +} + +sb.AppendLine(" var permissionRegistry = permissionBuilder.Build();"); +sb.AppendLine(" services.AddSingleton(permissionRegistry);"); +sb.AppendLine(); +sb.AppendLine(" services.AddAuthorizationBuilder()"); +sb.AppendLine(" .SetDefaultPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());"); +sb.AppendLine(" services.AddSingleton();"); +``` + +**Step 4: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Generator` +Expected: SUCCESS + +**Step 5: Commit** + +```bash +git add framework/SimpleModule.Generator/Discovery/DiscoveryData.cs framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs +git commit -m "feat: generator calls ConfigurePermissions and registers authorization services" +``` + +--- + +### Task 9: Add permission entities and DbContext changes to Users module + +**Files:** +- Create: `modules/Users/src/Users/Entities/UserPermission.cs` +- Create: `modules/Users/src/Users/Entities/RolePermission.cs` +- Modify: `modules/Users/src/Users/UsersDbContext.cs:1-20` + +**Step 1: Create `UserPermission` entity** + +```csharp +namespace SimpleModule.Users.Entities; + +public class UserPermission +{ + public string UserId { get; set; } = string.Empty; + public string Permission { get; set; } = string.Empty; + + public ApplicationUser? User { get; set; } +} +``` + +**Step 2: Create `RolePermission` entity** + +```csharp +namespace SimpleModule.Users.Entities; + +public class RolePermission +{ + public string RoleId { get; set; } = string.Empty; + public string Permission { get; set; } = string.Empty; + + public ApplicationRole? Role { get; set; } +} +``` + +**Step 3: Update `UsersDbContext`** + +```csharp +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Users; + +public class UsersDbContext( + DbContextOptions options, + IOptions dbOptions +) : IdentityDbContext(options) +{ + public DbSet UserPermissions => Set(); + public DbSet RolePermissions => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.Entity(entity => + { + entity.HasKey(e => new { e.UserId, e.Permission }); + entity.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.HasKey(e => new { e.RoleId, e.Permission }); + entity.HasOne(e => e.Role) + .WithMany() + .HasForeignKey(e => e.RoleId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.ApplyModuleSchema("Users", dbOptions.Value); + } +} +``` + +**Step 4: Verify it builds** + +Run: `dotnet build modules/Users/src/Users` +Expected: SUCCESS + +**Step 5: Commit** + +```bash +git add modules/Users/src/Users/Entities/UserPermission.cs modules/Users/src/Users/Entities/RolePermission.cs modules/Users/src/Users/UsersDbContext.cs +git commit -m "feat: add UserPermission and RolePermission entities with DbContext config" +``` + +--- + +### Task 10: Add permission claims projection to `AuthorizationEndpoint` + +**Files:** +- Modify: `modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs:69-94` + +**Step 1: Add permission claims loading** + +After the roles are added (line 80), add permission loading: + +```csharp +// Load permissions from user's roles +var dbContext = context.RequestServices.GetRequiredService(); + +var roleIds = await dbContext.Roles + .Where(r => roles.Contains(r.Name!)) + .Select(r => r.Id) + .ToListAsync(); + +var rolePermissions = await dbContext.RolePermissions + .Where(rp => roleIds.Contains(rp.RoleId)) + .Select(rp => rp.Permission) + .ToListAsync(); + +// Load direct user permissions +var userId = await userManager.GetUserIdAsync(user); +var userPermissions = await dbContext.UserPermissions + .Where(up => up.UserId == userId) + .Select(up => up.Permission) + .ToListAsync(); + +// Merge and deduplicate, add as claims +var allPermissions = new HashSet(rolePermissions); +foreach (var p in userPermissions) + allPermissions.Add(p); + +foreach (var permission in allPermissions) +{ + identity.AddClaim("permission", permission); +} +``` + +Add required usings at top of file: +```csharp +using Microsoft.EntityFrameworkCore; +``` + +**Step 2: Update `GetDestinations` to include permission claims** + +Add a case for the `"permission"` claim type: + +```csharp +case "permission": + yield return Destinations.AccessToken; + yield break; +``` + +**Step 3: Verify it builds** + +Run: `dotnet build modules/Users/src/Users` +Expected: SUCCESS + +**Step 4: Commit** + +```bash +git add modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs +git commit -m "feat: project permissions into claims at OAuth2 token issuance" +``` + +--- + +### Task 11: Add `ProductsPermissions` static class and register on module + +**Files:** +- Create: `modules/Products/src/Products/ProductsPermissions.cs` +- Modify: `modules/Products/src/Products/ProductsModule.cs:15-21` + +**Step 1: Create the permissions class** + +```csharp +namespace SimpleModule.Products; + +public static class ProductsPermissions +{ + public const string View = "Products.View"; + public const string Create = "Products.Create"; + public const string Update = "Products.Update"; + public const string Delete = "Products.Delete"; +} +``` + +**Step 2: Add `ConfigurePermissions` to `ProductsModule`** + +Add using and method: + +```csharp +using SimpleModule.Core.Authorization; +``` + +Add method to `ProductsModule` class: + +```csharp +public void ConfigurePermissions(PermissionRegistryBuilder builder) +{ + builder.AddPermissions(); +} +``` + +**Step 3: Add `[RequirePermission]` to Product endpoints** + +Update each endpoint class in `modules/Products/src/Products/Endpoints/Products/`: + +- `GetAllEndpoint.cs`: Add `[RequirePermission(ProductsPermissions.View)]` +- `GetByIdEndpoint.cs`: Add `[RequirePermission(ProductsPermissions.View)]` +- `CreateEndpoint.cs`: Add `[RequirePermission(ProductsPermissions.Create)]` +- `UpdateEndpoint.cs`: Add `[RequirePermission(ProductsPermissions.Update)]` +- `DeleteEndpoint.cs`: Add `[RequirePermission(ProductsPermissions.Delete)]` + +Each file needs `using SimpleModule.Core.Authorization;` and the attribute on the class. Then inside the `Map()` method, add the inline permission check. Example for `GetAllEndpoint.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.Products.Contracts; + +namespace SimpleModule.Products.Endpoints.Products; + +[RequirePermission(ProductsPermissions.View)] +public class GetAllEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/", (IProductContracts productContracts) => + CrudEndpoints.GetAll(productContracts.GetAllProductsAsync)) + .RequirePermission(ProductsPermissions.View); +} +``` + +**Step 4: Verify it builds** + +Run: `dotnet build modules/Products/src/Products` +Expected: SUCCESS + +**Step 5: Commit** + +```bash +git add modules/Products/src/Products/ProductsPermissions.cs modules/Products/src/Products/ProductsModule.cs modules/Products/src/Products/Endpoints/Products/ +git commit -m "feat: add ProductsPermissions and protect Product endpoints" +``` + +--- + +### Task 12: Add `OrdersPermissions` and protect Orders endpoints + +**Files:** +- Create: `modules/Orders/src/Orders/OrdersPermissions.cs` +- Modify: `modules/Orders/src/Orders/OrdersModule.cs` +- Modify: All Orders endpoint files + +Follow the same pattern as Task 11. Create `OrdersPermissions` with `View`, `Create`, `Update`, `Delete` constants. Add `ConfigurePermissions` to `OrdersModule`. Add `[RequirePermission]` attributes and inline `.RequirePermission()` calls to all Orders endpoints. + +**Step 1: Create permissions, update module, protect endpoints** + +**Step 2: Verify it builds** + +Run: `dotnet build modules/Orders/src/Orders` +Expected: SUCCESS + +**Step 3: Commit** + +```bash +git add modules/Orders/src/Orders/OrdersPermissions.cs modules/Orders/src/Orders/OrdersModule.cs modules/Orders/src/Orders/Endpoints/ +git commit -m "feat: add OrdersPermissions and protect Orders endpoints" +``` + +--- + +### Task 13: Mark public endpoints with `[AllowAnonymous]` + +**Files:** +- Modify: Connect endpoints in Users module (AuthorizationEndpoint, TokenEndpoint, EndSessionEndpoint, UserInfoEndpoint) +- Modify: Any view endpoints that should be public (login page, etc.) +- Modify: Identity/Account endpoints + +**Step 1: Add `[AllowAnonymous]` to OAuth2 connect endpoints** + +These endpoints handle auth flow itself and must remain public. Add `[Microsoft.AspNetCore.Authorization.AllowAnonymous]` attribute to: +- `AuthorizationEndpoint` +- `TokenEndpoint` +- `EndSessionEndpoint` +- `UserInfoEndpoint` + +And inside their `Map()` methods, chain `.AllowAnonymous()` on the route: + +```csharp +app.MapMethods(ConnectRouteConstants.ConnectAuthorize, ...) + .ExcludeFromDescription() + .AllowAnonymous(); +``` + +**Step 2: Add `[AllowAnonymous]` to public view endpoints** + +Any browse/landing pages that should be accessible without login. Check each `IViewEndpoint` and decide based on the current `RequiresAuth` menu property. + +**Step 3: Verify it builds** + +Run: `dotnet build` +Expected: SUCCESS + +**Step 4: Commit** + +```bash +git add modules/Users/src/Users/Endpoints/ +git commit -m "feat: mark public endpoints with AllowAnonymous" +``` + +--- + +### Task 14: Seed all permissions for Admin role + +**Files:** +- Create: `modules/Users/src/Users/Services/PermissionSeedService.cs` +- Modify: `modules/Users/src/Users/UsersModule.cs:114` (register the service) + +**Step 1: Create the seed service** + +```csharp +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SimpleModule.Core.Authorization; +using SimpleModule.Users.Constants; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Users.Services; + +public partial class PermissionSeedService( + IServiceProvider serviceProvider, + PermissionRegistry permissionRegistry, + ILogger logger +) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var adminRole = await roleManager.FindByNameAsync(SeedConstants.AdminRole); + if (adminRole is null) + return; + + var existingPermissions = await dbContext.RolePermissions + .Where(rp => rp.RoleId == adminRole.Id) + .Select(rp => rp.Permission) + .ToListAsync(cancellationToken); + + var existingSet = new HashSet(existingPermissions); + var newPermissions = new List(); + + foreach (var permission in permissionRegistry.AllPermissions) + { + if (!existingSet.Contains(permission)) + { + newPermissions.Add(new RolePermission + { + RoleId = adminRole.Id, + Permission = permission, + }); + } + } + + if (newPermissions.Count > 0) + { + LogSeedingPermissions(logger, newPermissions.Count); + dbContext.RolePermissions.AddRange(newPermissions); + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Seeding {Count} permissions for Admin role..." + )] + private static partial void LogSeedingPermissions(ILogger logger, int count); +} +``` + +**Step 2: Register in `UsersModule.ConfigureServices`** + +After `services.AddHostedService();` (line 115), add: + +```csharp +services.AddHostedService(); +``` + +**Step 3: Verify it builds** + +Run: `dotnet build modules/Users/src/Users` +Expected: SUCCESS + +**Step 4: Commit** + +```bash +git add modules/Users/src/Users/Services/PermissionSeedService.cs modules/Users/src/Users/UsersModule.cs +git commit -m "feat: seed all registered permissions for Admin role on startup" +``` + +--- + +### Task 15: Update `Program.cs` — remove duplicate `AddAuthorization` call + +**Files:** +- Modify: `template/SimpleModule.Host/Program.cs:126` + +**Step 1: Remove the standalone `AddAuthorization` call** + +The generated `AddModules()` now registers authorization with the correct default policy. Remove line 126: + +```csharp +builder.Services.AddAuthorization(); +``` + +This prevents the generated default policy from being overwritten by a bare `AddAuthorization()` call. + +**Step 2: Verify it builds and runs** + +Run: `dotnet build && dotnet run --project template/SimpleModule.Host --launch-profile https` +Expected: App starts, endpoints require auth by default + +**Step 3: Commit** + +```bash +git add template/SimpleModule.Host/Program.cs +git commit -m "fix: remove duplicate AddAuthorization, use generator-configured policy" +``` + +--- + +### Task 16: Update test infrastructure for permissions + +**Files:** +- Modify: `tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs` + +**Step 1: Update `CreateAuthenticatedClient` to support permission claims** + +Add a convenience overload: + +```csharp +public HttpClient CreateAuthenticatedClient(string[] permissions, params Claim[] additionalClaims) +{ + var claims = new List(additionalClaims); + foreach (var permission in permissions) + { + claims.Add(new Claim("permission", permission)); + } + return CreateAuthenticatedClient(claims.ToArray()); +} +``` + +**Step 2: Verify it builds** + +Run: `dotnet build tests/SimpleModule.Tests.Shared` +Expected: SUCCESS + +**Step 3: Commit** + +```bash +git add tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs +git commit -m "feat: add permission-aware test client helper" +``` + +--- + +### Task 17: Write tests for permission system + +**Files:** +- Create: `tests/SimpleModule.Core.Tests/Authorization/PermissionRegistryBuilderTests.cs` +- Create: `tests/SimpleModule.Core.Tests/Authorization/PermissionAuthorizationHandlerTests.cs` +- Modify: `modules/Products/tests/Products.Tests/Integration/` (update existing tests for auth) + +**Step 1: Write `PermissionRegistryBuilderTests`** + +```csharp +using FluentAssertions; +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Core.Tests.Authorization; + +public class PermissionRegistryBuilderTests +{ + private static class TestPermissions + { + public const string View = "Test.View"; + public const string Create = "Test.Create"; + } + + [Fact] + public void AddPermissions_ScansStaticConstants() + { + var builder = new PermissionRegistryBuilder(); + builder.AddPermissions(); + var registry = builder.Build(); + + registry.AllPermissions.Should().Contain("Test.View"); + registry.AllPermissions.Should().Contain("Test.Create"); + } + + [Fact] + public void Build_GroupsByModule() + { + var builder = new PermissionRegistryBuilder(); + builder.AddPermission("Products.View"); + builder.AddPermission("Products.Create"); + builder.AddPermission("Orders.View"); + var registry = builder.Build(); + + registry.ByModule.Should().ContainKey("Products"); + registry.ByModule["Products"].Should().HaveCount(2); + registry.ByModule.Should().ContainKey("Orders"); + registry.ByModule["Orders"].Should().HaveCount(1); + } + + [Fact] + public void AddPermission_DeduplicatesValues() + { + var builder = new PermissionRegistryBuilder(); + builder.AddPermission("Products.View"); + builder.AddPermission("Products.View"); + var registry = builder.Build(); + + registry.AllPermissions.Should().HaveCount(1); + } +} +``` + +**Step 2: Write `PermissionAuthorizationHandlerTests`** + +```csharp +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Core.Tests.Authorization; + +public class PermissionAuthorizationHandlerTests +{ + [Fact] + public async Task Handle_UserWithPermissionClaim_Succeeds() + { + var handler = new PermissionAuthorizationHandler(); + var requirement = new PermissionRequirement("Products.View"); + var user = CreateUser(new Claim("permission", "Products.View")); + var context = new AuthorizationHandlerContext([requirement], user, null); + + await handler.HandleAsync(context); + + context.HasSucceeded.Should().BeTrue(); + } + + [Fact] + public async Task Handle_UserWithoutPermissionClaim_Fails() + { + var handler = new PermissionAuthorizationHandler(); + var requirement = new PermissionRequirement("Products.View"); + var user = CreateUser(new Claim("permission", "Orders.View")); + var context = new AuthorizationHandlerContext([requirement], user, null); + + await handler.HandleAsync(context); + + context.HasSucceeded.Should().BeFalse(); + } + + [Fact] + public async Task Handle_AdminRole_BypassesPermissionCheck() + { + var handler = new PermissionAuthorizationHandler(); + var requirement = new PermissionRequirement("Products.Delete"); + var user = CreateUser(new Claim(ClaimTypes.Role, "Admin")); + var context = new AuthorizationHandlerContext([requirement], user, null); + + await handler.HandleAsync(context); + + context.HasSucceeded.Should().BeTrue(); + } + + private static ClaimsPrincipal CreateUser(params Claim[] claims) + { + var identity = new ClaimsIdentity(claims, "Test"); + return new ClaimsPrincipal(identity); + } +} +``` + +**Step 3: Update Products integration tests** + +The existing product endpoint tests need to pass permission claims. Update tests to use `CreateAuthenticatedClient` with the appropriate permissions. Tests should verify: +- Unauthenticated → 401 +- Authenticated without permission → 403 +- Authenticated with permission → 200 + +**Step 4: Run all tests** + +Run: `dotnet test` +Expected: ALL PASS + +**Step 5: Commit** + +```bash +git add tests/SimpleModule.Core.Tests/Authorization/ modules/Products/tests/Products.Tests/ +git commit -m "test: add permission system unit and integration tests" +``` + +--- + +### Task 18: Full integration test — build and run + +**Step 1: Clean build** + +Run: `dotnet build` +Expected: SUCCESS with no errors + +**Step 2: Run all tests** + +Run: `dotnet test` +Expected: ALL PASS + +**Step 3: Manual smoke test** + +Run: `dotnet run --project template/SimpleModule.Host --launch-profile https` +Expected: +- App starts on https://localhost:5001 +- Swagger UI loads +- Unauthenticated API calls to `/api/products` return 401 +- Admin user can access all endpoints after login +- Check logs for "Seeding X permissions for Admin role..." + +**Step 4: Final commit if any fixes needed** From 5ea9a4088a00b81cf26a71503ec6027767274878 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 13:58:34 +0100 Subject: [PATCH 036/112] feat: add permission system core types - RequirePermissionAttribute for endpoint classes - PermissionRegistry and PermissionRegistryBuilder for module permission registration - ConfigurePermissions method on IModule interface - RequirePermission extension method for inline endpoint protection - PermissionAuthorizationHandler with Admin role bypass Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EndpointPermissionExtensions.cs | 19 +++++++ .../PermissionAuthorizationHandler.cs | 26 +++++++++ .../Authorization/PermissionRegistry.cs | 17 ++++++ .../PermissionRegistryBuilder.cs | 57 +++++++++++++++++++ .../Authorization/PermissionRequirement.cs | 13 +++++ .../RequirePermissionAttribute.cs | 14 +++++ framework/SimpleModule.Core/IModule.cs | 2 + 7 files changed, 148 insertions(+) create mode 100644 framework/SimpleModule.Core/Authorization/EndpointPermissionExtensions.cs create mode 100644 framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs create mode 100644 framework/SimpleModule.Core/Authorization/PermissionRegistry.cs create mode 100644 framework/SimpleModule.Core/Authorization/PermissionRegistryBuilder.cs create mode 100644 framework/SimpleModule.Core/Authorization/PermissionRequirement.cs create mode 100644 framework/SimpleModule.Core/Authorization/RequirePermissionAttribute.cs diff --git a/framework/SimpleModule.Core/Authorization/EndpointPermissionExtensions.cs b/framework/SimpleModule.Core/Authorization/EndpointPermissionExtensions.cs new file mode 100644 index 00000000..bb77f3f3 --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/EndpointPermissionExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; + +namespace SimpleModule.Core.Authorization; + +public static class EndpointPermissionExtensions +{ + public static TBuilder RequirePermission(this TBuilder builder, params string[] permissions) + where TBuilder : IEndpointConventionBuilder + { + return builder.RequireAuthorization(policy => + { + policy.RequireAuthenticatedUser(); + foreach (var permission in permissions) + { + policy.RequireClaim("permission", permission); + } + }); + } +} diff --git a/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs b/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs new file mode 100644 index 00000000..22f07265 --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs @@ -0,0 +1,26 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionAuthorizationHandler : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionRequirement requirement) + { + // Admin role bypasses all permission checks + if (context.User.IsInRole("Admin")) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + + if (context.User.HasClaim("permission", requirement.Permission)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} diff --git a/framework/SimpleModule.Core/Authorization/PermissionRegistry.cs b/framework/SimpleModule.Core/Authorization/PermissionRegistry.cs new file mode 100644 index 00000000..9705ab49 --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/PermissionRegistry.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionRegistry +{ + public IReadOnlySet AllPermissions { get; } + public IReadOnlyDictionary> ByModule { get; } + + internal PermissionRegistry( + IReadOnlySet allPermissions, + IReadOnlyDictionary> byModule) + { + AllPermissions = allPermissions; + ByModule = byModule; + } +} diff --git a/framework/SimpleModule.Core/Authorization/PermissionRegistryBuilder.cs b/framework/SimpleModule.Core/Authorization/PermissionRegistryBuilder.cs new file mode 100644 index 00000000..3a3fe4c8 --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/PermissionRegistryBuilder.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionRegistryBuilder +{ + private readonly Dictionary> _byModule = new(); + + public void AddPermissions() where T : class + { + var fields = typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)); + + foreach (var field in fields) + { + var value = (string)field.GetRawConstantValue()!; + AddPermission(value); + } + } + + public void AddPermission(string permission) + { + var dotIndex = permission.IndexOf('.', StringComparison.Ordinal); + var module = dotIndex >= 0 ? permission[..dotIndex] : "Global"; + + if (!_byModule.TryGetValue(module, out var list)) + { + list = []; + _byModule[module] = list; + } + + if (!list.Contains(permission)) + { + list.Add(permission); + } + } + + public PermissionRegistry Build() + { + var all = new HashSet(); + var byModule = new Dictionary>(); + + foreach (var (module, permissions) in _byModule) + { + byModule[module] = permissions.AsReadOnly(); + foreach (var p in permissions) + { + all.Add(p); + } + } + + return new PermissionRegistry(all, byModule); + } +} diff --git a/framework/SimpleModule.Core/Authorization/PermissionRequirement.cs b/framework/SimpleModule.Core/Authorization/PermissionRequirement.cs new file mode 100644 index 00000000..c8650f5c --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/PermissionRequirement.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Authorization; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionRequirement : IAuthorizationRequirement +{ + public string Permission { get; } + + public PermissionRequirement(string permission) + { + Permission = permission; + } +} diff --git a/framework/SimpleModule.Core/Authorization/RequirePermissionAttribute.cs b/framework/SimpleModule.Core/Authorization/RequirePermissionAttribute.cs new file mode 100644 index 00000000..0ba54df1 --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/RequirePermissionAttribute.cs @@ -0,0 +1,14 @@ +using System; + +namespace SimpleModule.Core.Authorization; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class RequirePermissionAttribute : Attribute +{ + public string[] Permissions { get; } + + public RequirePermissionAttribute(params string[] permissions) + { + Permissions = permissions; + } +} diff --git a/framework/SimpleModule.Core/IModule.cs b/framework/SimpleModule.Core/IModule.cs index 7b7d2392..676b430d 100644 --- a/framework/SimpleModule.Core/IModule.cs +++ b/framework/SimpleModule.Core/IModule.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Menu; namespace SimpleModule.Core; @@ -10,4 +11,5 @@ public interface IModule virtual void ConfigureServices(IServiceCollection services, IConfiguration configuration) { } virtual void ConfigureEndpoints(IEndpointRouteBuilder endpoints) { } virtual void ConfigureMenu(IMenuBuilder menus) { } + virtual void ConfigurePermissions(PermissionRegistryBuilder builder) { } } From 96f6297686995914e1fd860e99541d6c7c4e2fa6 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 14:00:54 +0100 Subject: [PATCH 037/112] feat: source generator discovers permissions and emits authorization wiring - EndpointInfoRecord tracks RequiredPermissions and AllowAnonymous - SymbolDiscovery extracts [RequirePermission] and [AllowAnonymous] attributes - EndpointExtensionsEmitter adds RequireAuthorization to route groups (deny-by-default) - ModuleExtensionsEmitter calls ConfigurePermissions and registers auth services Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Discovery/DiscoveryData.cs | 33 ++++++++++++++- .../Discovery/SymbolDiscovery.cs | 40 ++++++++++++++++++- .../Emitters/EndpointExtensionsEmitter.cs | 5 ++- .../Emitters/ModuleExtensionsEmitter.cs | 18 +++++++++ 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs index 9bb53199..fbf5ec97 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs @@ -55,6 +55,7 @@ internal readonly record struct ModuleInfoRecord( bool HasConfigureServices, bool HasConfigureEndpoints, bool HasConfigureMenu, + bool HasConfigurePermissions, bool HasRazorComponents, string RoutePrefix, string ViewPrefix, @@ -69,6 +70,7 @@ public bool Equals(ModuleInfoRecord other) && HasConfigureServices == other.HasConfigureServices && HasConfigureEndpoints == other.HasConfigureEndpoints && HasConfigureMenu == other.HasConfigureMenu + && HasConfigurePermissions == other.HasConfigurePermissions && HasRazorComponents == other.HasRazorComponents && RoutePrefix == other.RoutePrefix && ViewPrefix == other.ViewPrefix @@ -86,6 +88,7 @@ public override int GetHashCode() hash = hash * 31 + HasConfigureServices.GetHashCode(); hash = hash * 31 + HasConfigureEndpoints.GetHashCode(); hash = hash * 31 + HasConfigureMenu.GetHashCode(); + hash = hash * 31 + HasConfigurePermissions.GetHashCode(); hash = hash * 31 + HasRazorComponents.GetHashCode(); hash = hash * 31 + (RoutePrefix ?? "").GetHashCode(); hash = hash * 31 + (ViewPrefix ?? "").GetHashCode(); @@ -98,7 +101,32 @@ public override int GetHashCode() } } -internal readonly record struct EndpointInfoRecord(string FullyQualifiedName); +internal readonly record struct EndpointInfoRecord( + string FullyQualifiedName, + ImmutableArray RequiredPermissions, + bool AllowAnonymous +) +{ + public bool Equals(EndpointInfoRecord other) + { + return FullyQualifiedName == other.FullyQualifiedName + && AllowAnonymous == other.AllowAnonymous + && RequiredPermissions.SequenceEqual(other.RequiredPermissions); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + FullyQualifiedName.GetHashCode(); + hash = hash * 31 + AllowAnonymous.GetHashCode(); + foreach (var p in RequiredPermissions) + hash = hash * 31 + p.GetHashCode(); + return hash; + } + } +} internal readonly record struct ViewInfoRecord(string FullyQualifiedName, string Page); @@ -194,6 +222,7 @@ internal sealed class ModuleInfo public bool HasConfigureServices { get; set; } public bool HasConfigureEndpoints { get; set; } public bool HasConfigureMenu { get; set; } + public bool HasConfigurePermissions { get; set; } public bool HasRazorComponents { get; set; } public string RoutePrefix { get; set; } = ""; public string ViewPrefix { get; set; } = ""; @@ -204,6 +233,8 @@ internal sealed class ModuleInfo internal sealed class EndpointInfo { public string FullyQualifiedName { get; set; } = ""; + public List RequiredPermissions { get; set; } = new(); + public bool AllowAnonymous { get; set; } } internal sealed class ViewInfo diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 2493d94d..62606a13 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -161,10 +161,14 @@ typeSymbol is not null m.HasConfigureServices, m.HasConfigureEndpoints, m.HasConfigureMenu, + m.HasConfigurePermissions, m.HasRazorComponents, m.RoutePrefix, m.ViewPrefix, - m.Endpoints.Select(e => new EndpointInfoRecord(e.FullyQualifiedName)) + m.Endpoints.Select(e => new EndpointInfoRecord( + e.FullyQualifiedName, + e.RequiredPermissions.ToImmutableArray(), + e.AllowAnonymous)) .ToImmutableArray(), m.Views.Select(v => new ViewInfoRecord(v.FullyQualifiedName, v.Page)) .ToImmutableArray() @@ -264,6 +268,7 @@ List modules "ConfigureEndpoints" ), HasConfigureMenu = DeclaresMethod(typeSymbol, "ConfigureMenu"), + HasConfigurePermissions = DeclaresMethod(typeSymbol, "ConfigurePermissions"), RoutePrefix = routePrefix, ViewPrefix = viewPrefix, } @@ -327,7 +332,38 @@ viewEndpointInterfaceSymbol is not null } else if (ImplementsInterface(typeSymbol, endpointInterfaceSymbol)) { - endpoints.Add(new EndpointInfo { FullyQualifiedName = fqn }); + var info = new EndpointInfo { FullyQualifiedName = fqn }; + + foreach (var attr in typeSymbol.GetAttributes()) + { + var attrName = attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + if (attrName == "global::SimpleModule.Core.Authorization.RequirePermissionAttribute") + { + if (attr.ConstructorArguments.Length > 0) + { + var arg = attr.ConstructorArguments[0]; + if (arg.Kind == TypedConstantKind.Array) + { + foreach (var val in arg.Values) + { + if (val.Value is string s) + info.RequiredPermissions.Add(s); + } + } + else if (arg.Value is string single) + { + info.RequiredPermissions.Add(single); + } + } + } + else if (attrName == "global::Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute") + { + info.AllowAnonymous = true; + } + } + + endpoints.Add(info); } } } diff --git a/framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs index 9b2ceb71..f6f060a7 100644 --- a/framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs @@ -16,6 +16,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) sb.AppendLine("using Microsoft.AspNetCore.Builder;"); sb.AppendLine("using Microsoft.AspNetCore.Routing;"); sb.AppendLine("using Microsoft.AspNetCore.Http;"); + sb.AppendLine("using Microsoft.AspNetCore.Authorization;"); sb.AppendLine(); sb.AppendLine("namespace SimpleModule.Core;"); sb.AppendLine(); @@ -40,7 +41,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) if (!string.IsNullOrEmpty(module.RoutePrefix)) { sb.AppendLine( - $" var group = app.MapGroup(\"{module.RoutePrefix}\").WithTags(\"{module.ModuleName}\");" + $" var group = app.MapGroup(\"{module.RoutePrefix}\").WithTags(\"{module.ModuleName}\").RequireAuthorization();" ); foreach (var endpoint in module.Endpoints) { @@ -73,7 +74,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) if (!string.IsNullOrEmpty(module.ViewPrefix)) { sb.AppendLine( - $" var viewGroup = app.MapGroup(\"{module.ViewPrefix}\").WithTags(\"{module.ModuleName}\").ExcludeFromDescription();" + $" var viewGroup = app.MapGroup(\"{module.ViewPrefix}\").WithTags(\"{module.ModuleName}\").ExcludeFromDescription().RequireAuthorization();" ); foreach (var view in module.Views) { diff --git a/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs index ba98f559..e53dedff 100644 --- a/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs @@ -19,6 +19,8 @@ public void Emit(SourceProductionContext context, DiscoveryData data) sb.AppendLine("using Microsoft.AspNetCore.Http.Json;"); sb.AppendLine("using Microsoft.Extensions.Configuration;"); sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); + sb.AppendLine("using Microsoft.AspNetCore.Authorization;"); + sb.AppendLine("using SimpleModule.Core.Authorization;"); sb.AppendLine(); sb.AppendLine("namespace SimpleModule.Core;"); sb.AppendLine(); @@ -46,6 +48,22 @@ public void Emit(SourceProductionContext context, DiscoveryData data) sb.AppendLine($" {fieldName}.ConfigureServices(services, configuration);"); } + sb.AppendLine(); + sb.AppendLine(" var permissionBuilder = new PermissionRegistryBuilder();"); + + foreach (var module in modules.Where(m => m.HasConfigurePermissions)) + { + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine($" {fieldName}.ConfigurePermissions(permissionBuilder);"); + } + + sb.AppendLine(" var permissionRegistry = permissionBuilder.Build();"); + sb.AppendLine(" services.AddSingleton(permissionRegistry);"); + sb.AppendLine(); + sb.AppendLine(" services.AddAuthorizationBuilder()"); + sb.AppendLine(" .SetDefaultPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());"); + sb.AppendLine(" services.AddSingleton();"); + if (hasDtoTypes) { sb.AppendLine(); From 506fabbbb1d0e3440ea1d83152bbf4144f898a50 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 14:04:03 +0100 Subject: [PATCH 038/112] feat: add permission entities, DbContext config, and claims projection - UserPermission and RolePermission entities with composite keys - DbContext configuration with cascade delete - AuthorizationEndpoint projects permissions into claims at token issuance Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Connect/AuthorizationEndpoint.cs | 35 +++++++++++++++++++ .../src/Users/Entities/RolePermission.cs | 11 ++++++ .../src/Users/Entities/UserPermission.cs | 11 ++++++ modules/Users/src/Users/UsersDbContext.cs | 21 +++++++++++ 4 files changed, 78 insertions(+) create mode 100644 modules/Users/src/Users/Entities/RolePermission.cs create mode 100644 modules/Users/src/Users/Entities/UserPermission.cs diff --git a/modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs b/modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs index 2f755bc9..36151c96 100644 --- a/modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -79,6 +80,36 @@ await userManager.GetUserAsync(result.Principal) identity.AddClaim(Claims.Role, role); } + // Load permissions from user's roles + var dbContext = context.RequestServices.GetRequiredService(); + + var roleIds = await dbContext.Roles + .Where(r => roles.Contains(r.Name!)) + .Select(r => r.Id) + .ToListAsync(); + + var rolePermissions = await dbContext.RolePermissions + .Where(rp => roleIds.Contains(rp.RoleId)) + .Select(rp => rp.Permission) + .ToListAsync(); + + // Load direct user permissions + var userId = await userManager.GetUserIdAsync(user); + var userPermissions = await dbContext.UserPermissions + .Where(up => up.UserId == userId) + .Select(up => up.Permission) + .ToListAsync(); + + // Merge and deduplicate, add as claims + var allPermissions = new HashSet(rolePermissions); + foreach (var p in userPermissions) + allPermissions.Add(p); + + foreach (var permission in allPermissions) + { + identity.AddClaim("permission", permission); + } + identity.SetScopes(request.GetScopes()); foreach (var claim in identity.Claims) @@ -121,6 +152,10 @@ private static IEnumerable GetDestinations(Claim claim, ClaimsIdentity i yield return Destinations.IdentityToken; yield break; + case "permission": + yield return Destinations.AccessToken; + yield break; + default: yield return Destinations.AccessToken; yield break; diff --git a/modules/Users/src/Users/Entities/RolePermission.cs b/modules/Users/src/Users/Entities/RolePermission.cs new file mode 100644 index 00000000..d053274b --- /dev/null +++ b/modules/Users/src/Users/Entities/RolePermission.cs @@ -0,0 +1,11 @@ +namespace SimpleModule.Users.Entities; + +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix - Permission is intentional for entity +public class RolePermission +#pragma warning restore CA1711 +{ + public string RoleId { get; set; } = string.Empty; + public string Permission { get; set; } = string.Empty; + + public ApplicationRole? Role { get; set; } +} diff --git a/modules/Users/src/Users/Entities/UserPermission.cs b/modules/Users/src/Users/Entities/UserPermission.cs new file mode 100644 index 00000000..3d01e3e8 --- /dev/null +++ b/modules/Users/src/Users/Entities/UserPermission.cs @@ -0,0 +1,11 @@ +namespace SimpleModule.Users.Entities; + +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix - Permission is intentional for entity +public class UserPermission +#pragma warning restore CA1711 +{ + public string UserId { get; set; } = string.Empty; + public string Permission { get; set; } = string.Empty; + + public ApplicationUser? User { get; set; } +} diff --git a/modules/Users/src/Users/UsersDbContext.cs b/modules/Users/src/Users/UsersDbContext.cs index ccbaa883..7f2917ad 100644 --- a/modules/Users/src/Users/UsersDbContext.cs +++ b/modules/Users/src/Users/UsersDbContext.cs @@ -11,10 +11,31 @@ public class UsersDbContext( IOptions dbOptions ) : IdentityDbContext(options) { + public DbSet UserPermissions => Set(); + public DbSet RolePermissions => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); + builder.Entity(entity => + { + entity.HasKey(e => new { e.UserId, e.Permission }); + entity.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.HasKey(e => new { e.RoleId, e.Permission }); + entity.HasOne(e => e.Role) + .WithMany() + .HasForeignKey(e => e.RoleId) + .OnDelete(DeleteBehavior.Cascade); + }); + builder.ApplyModuleSchema("Users", dbOptions.Value); } } From 39bf28af7838ef5ee368cbc69735a9fbc8c947b0 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 14:04:06 +0100 Subject: [PATCH 039/112] feat: add permissions and protect Products/Orders endpoints - ProductsPermissions and OrdersPermissions static constants - ConfigurePermissions on both modules - All CRUD endpoints protected with RequirePermission attribute and inline check Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Orders/src/Orders/Endpoints/Orders/CreateEndpoint.cs | 5 ++++- .../Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs | 5 ++++- .../Orders/src/Orders/Endpoints/Orders/GetAllEndpoint.cs | 5 ++++- .../src/Orders/Endpoints/Orders/GetByIdEndpoint.cs | 5 ++++- .../Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs | 5 ++++- modules/Orders/src/Orders/OrdersModule.cs | 6 ++++++ modules/Orders/src/Orders/OrdersPermissions.cs | 9 +++++++++ .../src/Products/Endpoints/Products/CreateEndpoint.cs | 5 ++++- .../src/Products/Endpoints/Products/DeleteEndpoint.cs | 5 ++++- .../src/Products/Endpoints/Products/GetAllEndpoint.cs | 5 ++++- .../src/Products/Endpoints/Products/GetByIdEndpoint.cs | 5 ++++- .../src/Products/Endpoints/Products/UpdateEndpoint.cs | 5 ++++- modules/Products/src/Products/ProductsModule.cs | 6 ++++++ modules/Products/src/Products/ProductsPermissions.cs | 9 +++++++++ 14 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 modules/Orders/src/Orders/OrdersPermissions.cs create mode 100644 modules/Products/src/Products/ProductsPermissions.cs diff --git a/modules/Orders/src/Orders/Endpoints/Orders/CreateEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/CreateEndpoint.cs index 406e6fd1..6d000b70 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/CreateEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/CreateEndpoint.cs @@ -1,12 +1,14 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; using SimpleModule.Core.Exceptions; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; +[RequirePermission(OrdersPermissions.Create)] public class CreateEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder app) => @@ -21,5 +23,6 @@ public void Map(IEndpointRouteBuilder app) => return CrudEndpoints.Create( () => orderContracts.CreateOrderAsync(request), o => $"{OrdersConstants.RoutePrefix}/{o.Id}"); - }); + }) + .RequirePermission(OrdersPermissions.Create); } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs index 4c01f823..2c15477e 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs @@ -1,14 +1,17 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; +[RequirePermission(OrdersPermissions.Delete)] public class DeleteEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder app) => app.MapDelete("/{id}", (OrderId id, IOrderContracts orderContracts) => - CrudEndpoints.Delete(() => orderContracts.DeleteOrderAsync(id))); + CrudEndpoints.Delete(() => orderContracts.DeleteOrderAsync(id))) + .RequirePermission(OrdersPermissions.Delete); } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/GetAllEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/GetAllEndpoint.cs index 66ce9c78..4ed6bfda 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/GetAllEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/GetAllEndpoint.cs @@ -1,14 +1,17 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; +[RequirePermission(OrdersPermissions.View)] public class GetAllEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder app) => app.MapGet("/", (IOrderContracts orderContracts) => - CrudEndpoints.GetAll(orderContracts.GetAllOrdersAsync)); + CrudEndpoints.GetAll(orderContracts.GetAllOrdersAsync)) + .RequirePermission(OrdersPermissions.View); } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs index e3b68431..59e2e4ac 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs @@ -1,14 +1,17 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; +[RequirePermission(OrdersPermissions.View)] public class GetByIdEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder app) => app.MapGet("/{id}", (OrderId id, IOrderContracts orderContracts) => - CrudEndpoints.GetById(() => orderContracts.GetOrderByIdAsync(id))); + CrudEndpoints.GetById(() => orderContracts.GetOrderByIdAsync(id))) + .RequirePermission(OrdersPermissions.View); } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs index 3e0271bd..ac9c7103 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs @@ -1,12 +1,14 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; using SimpleModule.Core.Exceptions; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; +[RequirePermission(OrdersPermissions.Update)] public class UpdateEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder app) => @@ -24,5 +26,6 @@ public void Map(IEndpointRouteBuilder app) => } return CrudEndpoints.Update(() => orderContracts.UpdateOrderAsync(id, request)); - }); + }) + .RequirePermission(OrdersPermissions.Update); } diff --git a/modules/Orders/src/Orders/OrdersModule.cs b/modules/Orders/src/Orders/OrdersModule.cs index bd196d71..ddeb7af6 100644 --- a/modules/Orders/src/Orders/OrdersModule.cs +++ b/modules/Orders/src/Orders/OrdersModule.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Core; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Menu; using SimpleModule.Database; using SimpleModule.Orders.Contracts; @@ -20,6 +21,11 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); } + public void ConfigurePermissions(PermissionRegistryBuilder builder) + { + builder.AddPermissions(); + } + public void ConfigureMenu(IMenuBuilder menus) { menus.Add( diff --git a/modules/Orders/src/Orders/OrdersPermissions.cs b/modules/Orders/src/Orders/OrdersPermissions.cs new file mode 100644 index 00000000..533662f4 --- /dev/null +++ b/modules/Orders/src/Orders/OrdersPermissions.cs @@ -0,0 +1,9 @@ +namespace SimpleModule.Orders; + +public sealed class OrdersPermissions +{ + public const string View = "Orders.View"; + public const string Create = "Orders.Create"; + public const string Update = "Orders.Update"; + public const string Delete = "Orders.Delete"; +} diff --git a/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs index ab280e5b..9c990f34 100644 --- a/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs @@ -1,12 +1,14 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; using SimpleModule.Core.Exceptions; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; +[RequirePermission(ProductsPermissions.Create)] public class CreateEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder app) => @@ -21,5 +23,6 @@ public void Map(IEndpointRouteBuilder app) => return CrudEndpoints.Create( () => productContracts.CreateProductAsync(request), p => $"{ProductsConstants.RoutePrefix}/{p.Id}"); - }); + }) + .RequirePermission(ProductsPermissions.Create); } diff --git a/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs index 0a1e4ad5..8d2ac14b 100644 --- a/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs @@ -1,14 +1,17 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; +[RequirePermission(ProductsPermissions.Delete)] public class DeleteEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder app) => app.MapDelete("/{id}", (ProductId id, IProductContracts productContracts) => - CrudEndpoints.Delete(() => productContracts.DeleteProductAsync(id))); + CrudEndpoints.Delete(() => productContracts.DeleteProductAsync(id))) + .RequirePermission(ProductsPermissions.Delete); } diff --git a/modules/Products/src/Products/Endpoints/Products/GetAllEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/GetAllEndpoint.cs index 1f8f5bf6..2bc60d2e 100644 --- a/modules/Products/src/Products/Endpoints/Products/GetAllEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/GetAllEndpoint.cs @@ -1,14 +1,17 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; +[RequirePermission(ProductsPermissions.View)] public class GetAllEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder app) => app.MapGet("/", (IProductContracts productContracts) => - CrudEndpoints.GetAll(productContracts.GetAllProductsAsync)); + CrudEndpoints.GetAll(productContracts.GetAllProductsAsync)) + .RequirePermission(ProductsPermissions.View); } diff --git a/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs index 26f63151..014b25b2 100644 --- a/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs @@ -1,14 +1,17 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; +[RequirePermission(ProductsPermissions.View)] public class GetByIdEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder app) => app.MapGet("/{id}", (ProductId id, IProductContracts productContracts) => - CrudEndpoints.GetById(() => productContracts.GetProductByIdAsync(id))); + CrudEndpoints.GetById(() => productContracts.GetProductByIdAsync(id))) + .RequirePermission(ProductsPermissions.View); } diff --git a/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs index 983aeb9d..65591acb 100644 --- a/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs @@ -1,12 +1,14 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Endpoints; using SimpleModule.Core.Exceptions; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; +[RequirePermission(ProductsPermissions.Update)] public class UpdateEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder app) => @@ -19,5 +21,6 @@ public void Map(IEndpointRouteBuilder app) => } return CrudEndpoints.Update(() => productContracts.UpdateProductAsync(id, request)); - }); + }) + .RequirePermission(ProductsPermissions.Update); } diff --git a/modules/Products/src/Products/ProductsModule.cs b/modules/Products/src/Products/ProductsModule.cs index 524ef345..855b2644 100644 --- a/modules/Products/src/Products/ProductsModule.cs +++ b/modules/Products/src/Products/ProductsModule.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Core; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Menu; using SimpleModule.Database; using SimpleModule.Products.Contracts; @@ -20,6 +21,11 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); } + public void ConfigurePermissions(PermissionRegistryBuilder builder) + { + builder.AddPermissions(); + } + public void ConfigureMenu(IMenuBuilder menus) { menus.Add( diff --git a/modules/Products/src/Products/ProductsPermissions.cs b/modules/Products/src/Products/ProductsPermissions.cs new file mode 100644 index 00000000..e1b5b71f --- /dev/null +++ b/modules/Products/src/Products/ProductsPermissions.cs @@ -0,0 +1,9 @@ +namespace SimpleModule.Products; + +public sealed class ProductsPermissions +{ + public const string View = "Products.View"; + public const string Create = "Products.Create"; + public const string Update = "Products.Update"; + public const string Delete = "Products.Delete"; +} From 9ba5c839fa74cb81ee15ac84899d1d35f836443b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 14:07:22 +0100 Subject: [PATCH 040/112] feat: mark public endpoints with AllowAnonymous - OAuth2 connect endpoints (authorize, logout) - Dashboard home page - Products browse page Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/Dashboard/src/Dashboard/Views/HomeEndpoint.cs | 4 +++- modules/Products/src/Products/Views/BrowseEndpoint.cs | 4 +++- .../src/Users/Endpoints/Connect/AuthorizationEndpoint.cs | 4 +++- modules/Users/src/Users/Endpoints/Connect/LogoutEndpoint.cs | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/modules/Dashboard/src/Dashboard/Views/HomeEndpoint.cs b/modules/Dashboard/src/Dashboard/Views/HomeEndpoint.cs index 483eb0f3..2a608c1a 100644 --- a/modules/Dashboard/src/Dashboard/Views/HomeEndpoint.cs +++ b/modules/Dashboard/src/Dashboard/Views/HomeEndpoint.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -31,6 +32,7 @@ public void Map(IEndpointRouteBuilder app) ); } ) - .ExcludeFromDescription(); + .ExcludeFromDescription() + .AllowAnonymous(); } } diff --git a/modules/Products/src/Products/Views/BrowseEndpoint.cs b/modules/Products/src/Products/Views/BrowseEndpoint.cs index 99323e49..78010b48 100644 --- a/modules/Products/src/Products/Views/BrowseEndpoint.cs +++ b/modules/Products/src/Products/Views/BrowseEndpoint.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; @@ -17,6 +18,7 @@ public void Map(IEndpointRouteBuilder app) "Products/Browse", new { products = await products.GetAllProductsAsync() } ) - ); + ) + .AllowAnonymous(); } } diff --git a/modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs b/modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs index 36151c96..529d90e3 100644 --- a/modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -25,7 +26,8 @@ public void Map(IEndpointRouteBuilder app) [HttpMethods.Get, HttpMethods.Post], (Delegate)HandleAsync ) - .ExcludeFromDescription(); + .ExcludeFromDescription() + .AllowAnonymous(); } private static async Task HandleAsync(HttpContext context) diff --git a/modules/Users/src/Users/Endpoints/Connect/LogoutEndpoint.cs b/modules/Users/src/Users/Endpoints/Connect/LogoutEndpoint.cs index c44342b2..af8d53f6 100644 --- a/modules/Users/src/Users/Endpoints/Connect/LogoutEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Connect/LogoutEndpoint.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -19,7 +20,8 @@ public void Map(IEndpointRouteBuilder app) [HttpMethods.Get, HttpMethods.Post], (Delegate)HandleAsync ) - .ExcludeFromDescription(); + .ExcludeFromDescription() + .AllowAnonymous(); } private static async Task HandleAsync(HttpContext context) From 648037e1b547df1ee24dacc562257ea115507004 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 14:07:24 +0100 Subject: [PATCH 041/112] feat: seed all registered permissions for Admin role on startup Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Users/Services/PermissionSeedService.cs | 63 +++++++++++++++++++ modules/Users/src/Users/UsersModule.cs | 1 + 2 files changed, 64 insertions(+) create mode 100644 modules/Users/src/Users/Services/PermissionSeedService.cs diff --git a/modules/Users/src/Users/Services/PermissionSeedService.cs b/modules/Users/src/Users/Services/PermissionSeedService.cs new file mode 100644 index 00000000..7de749ca --- /dev/null +++ b/modules/Users/src/Users/Services/PermissionSeedService.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SimpleModule.Core.Authorization; +using SimpleModule.Users.Constants; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Users.Services; + +public partial class PermissionSeedService( + IServiceProvider serviceProvider, + PermissionRegistry permissionRegistry, + ILogger logger +) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var adminRole = await roleManager.FindByNameAsync(SeedConstants.AdminRole); + if (adminRole is null) + return; + + var existingPermissions = await dbContext.RolePermissions + .Where(rp => rp.RoleId == adminRole.Id) + .Select(rp => rp.Permission) + .ToListAsync(cancellationToken); + + var existingSet = new HashSet(existingPermissions); + var newPermissions = new List(); + + foreach (var permission in permissionRegistry.AllPermissions) + { + if (!existingSet.Contains(permission)) + { + newPermissions.Add(new RolePermission + { + RoleId = adminRole.Id, + Permission = permission, + }); + } + } + + if (newPermissions.Count > 0) + { + LogSeedingPermissions(logger, newPermissions.Count); + dbContext.RolePermissions.AddRange(newPermissions); + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Seeding {Count} permissions for Admin role..." + )] + private static partial void LogSeedingPermissions(ILogger logger, int count); +} diff --git a/modules/Users/src/Users/UsersModule.cs b/modules/Users/src/Users/UsersModule.cs index 6e7ff03a..c4691792 100644 --- a/modules/Users/src/Users/UsersModule.cs +++ b/modules/Users/src/Users/UsersModule.cs @@ -113,6 +113,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config // Seed service services.AddHostedService(); + services.AddHostedService(); // Email sender (console-based for development) services.AddSingleton, ConsoleEmailSender>(); From 7b48923272fac8051f500033d698ba2a226ef1c4 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 14:07:25 +0100 Subject: [PATCH 042/112] fix: remove duplicate AddAuthorization, use generator-configured policy Co-Authored-By: Claude Opus 4.6 (1M context) --- template/SimpleModule.Host/Program.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/template/SimpleModule.Host/Program.cs b/template/SimpleModule.Host/Program.cs index 5c4fa0d8..505f42fd 100644 --- a/template/SimpleModule.Host/Program.cs +++ b/template/SimpleModule.Host/Program.cs @@ -123,8 +123,6 @@ options.DefaultAuthenticateScheme = AuthConstants.SmartAuthPolicy; options.DefaultChallengeScheme = AuthConstants.SmartAuthPolicy; }); -builder.Services.AddAuthorization(); - // Health checks builder .Services.AddHealthChecks() From 22679d2089b8518706f87c94b0c3f17c9c1320b1 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 18 Mar 2026 14:12:08 +0100 Subject: [PATCH 043/112] docs: map existing codebase --- .planning/codebase/ARCHITECTURE.md | 163 ++++++++++ .planning/codebase/CONCERNS.md | 218 +++++++++++++ .planning/codebase/CONVENTIONS.md | 266 ++++++++++++++++ .planning/codebase/INTEGRATIONS.md | 160 ++++++++++ .planning/codebase/STACK.md | 139 +++++++++ .planning/codebase/STRUCTURE.md | 245 +++++++++++++++ .planning/codebase/TESTING.md | 486 +++++++++++++++++++++++++++++ 7 files changed, 1677 insertions(+) create mode 100644 .planning/codebase/ARCHITECTURE.md create mode 100644 .planning/codebase/CONCERNS.md create mode 100644 .planning/codebase/CONVENTIONS.md create mode 100644 .planning/codebase/INTEGRATIONS.md create mode 100644 .planning/codebase/STACK.md create mode 100644 .planning/codebase/STRUCTURE.md create mode 100644 .planning/codebase/TESTING.md diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 00000000..8b3db840 --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,163 @@ +# Architecture + +**Analysis Date:** 2026-03-18 + +## Pattern Overview + +**Overall:** Modular monolith with compile-time module discovery via Roslyn source generators. AOT-compatible (no reflection). Backend is .NET minimal APIs; frontend is React 19 + Inertia.js served via Blazor SSR. + +**Key Characteristics:** +- Static module wiring at compile-time via `[Module]` attribute and Roslyn generators +- Contract-first module communication (never implementation-to-implementation dependencies) +- Permission system built on claims-based authorization with source generator integration +- Server-side rendered Blazor SSR shell bridges to client-side React via Inertia protocol +- Multi-provider database support (SQLite, PostgreSQL, SQL Server) with per-module DbContexts +- Fully AOT-compatible—no runtime reflection + +## Layers + +**Framework (Core):** +- Purpose: Defines interfaces, attributes, and shared abstractions that all modules implement against +- Location: `framework/SimpleModule.Core/`, `framework/SimpleModule.Blazor/`, `framework/SimpleModule.Database/`, `framework/SimpleModule.Generator/` +- Contains: Module interface (`IModule`), endpoint interface (`IEndpoint`), DTO marking (`[Dto]`), event bus, permission registry, menu system, Inertia integration +- Depends on: Microsoft.AspNetCore, Entity Framework Core, OpenIddict (Users module only in Core's dependency tree) +- Used by: All modules, Host application + +**Module Contracts:** +- Purpose: Public API boundaries for cross-module communication; exports `IXxxContracts` interface and `[Dto]` types +- Location: `modules/{ModuleName}/src/{ModuleName}.Contracts/` +- Contains: Public service interfaces, shared DTOs, strongly-typed IDs +- Depends on: Core only +- Used by: Other modules, endpoints + +**Module Implementation:** +- Purpose: Service logic, endpoints, views, database context, event handlers +- Location: `modules/{ModuleName}/src/{ModuleName}/` +- Contains: Module class, endpoint handlers (API + views), service implementations, DbContext, validators, permissions +- Depends on: Own contracts, Core, Database, other module contracts (never implementations) +- Used by: Host (via generator-emitted wiring) + +**Host Application:** +- Purpose: Orchestrates all modules; wired by source generators; serves Blazor SSR + Inertia shell +- Location: `template/SimpleModule.Host/` +- Contains: `Program.cs` (DI setup), layouts, authentication middleware, global exception handling +- Depends on: All modules, all framework packages +- Used by: Entry point for requests + +**Frontend (React + Inertia):** +- Purpose: Client-side rendering of pages served by Inertia, dynamic module discovery, component library +- Location: `template/SimpleModule.Host/ClientApp/`, `modules/{ModuleName}/src/{ModuleName}/Pages/`, `modules/{ModuleName}/src/{ModuleName}/Views/` +- Contains: Inertia bootstrap, module page bundles (Vite library mode), shared UI component library +- Depends on: React, Inertia, Tailwind, shared UI package +- Used by: Browser after Blazor SSR sends Inertia HTML shell + +## Data Flow + +**Request Lifecycle (View/Page):** + +1. Browser requests `/products/browse` (or any view route) +2. ASP.NET routes to `BrowseEndpoint` (implements `IViewEndpoint`) +3. Endpoint calls `Inertia.Render("Products/Browse", { products: [...] })` +4. Inertia middleware serializes props to JSON +5. `SimpleModule.Blazor` renders Blazor SSR shell (`InertiaShell.razor`) with `` component +6. Shell emits HTML with `
`, import map for React/Inertia, and ` + + +@code { + private static readonly string CacheBuster = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + + [CascadingParameter] + public HttpContext? HttpContext { get; set; } + + private string DisplayName => HttpContext?.User?.Identity?.Name ?? "User"; + private string UserEmail => HttpContext?.User?.Identity?.Name ?? ""; + private string UserInitial => (HttpContext?.User?.Identity?.Name ?? "U").Substring(0, 1).ToUpper(); + + private bool IsAdmin => HttpContext?.User?.IsInRole("Admin") == true; + + private IReadOnlyList SidebarItems => MenuRegistry.GetItems(MenuSection.AppSidebar); + private IReadOnlyList AdminSidebarItems => MenuRegistry.GetItems(MenuSection.AdminSidebar); + + private string SidebarLinkClass(string url) + { + var currentPath = HttpContext?.Request.Path.ToString() ?? ""; + var isActive = url == "/" + ? currentPath.Equals("/", StringComparison.OrdinalIgnoreCase) + : currentPath.StartsWith(url, StringComparison.OrdinalIgnoreCase); + return isActive + ? "flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium text-primary bg-primary-subtle no-underline transition-all duration-150" + : "flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-text-secondary no-underline hover:bg-surface-raised hover:text-text transition-all duration-150"; + } +} diff --git a/framework/SimpleModule.Blazor/Components/Layout/MainLayout.razor b/framework/SimpleModule.Blazor/Components/Layout/MainLayout.razor index 84606d04..299a4645 100644 --- a/framework/SimpleModule.Blazor/Components/Layout/MainLayout.razor +++ b/framework/SimpleModule.Blazor/Components/Layout/MainLayout.razor @@ -2,7 +2,7 @@ @inherits LayoutComponentBase -
+
@Body
diff --git a/framework/SimpleModule.Blazor/Components/Layout/ManageLayout.razor b/framework/SimpleModule.Blazor/Components/Layout/ManageLayout.razor index aacf0027..947202e7 100644 --- a/framework/SimpleModule.Blazor/Components/Layout/ManageLayout.razor +++ b/framework/SimpleModule.Blazor/Components/Layout/ManageLayout.razor @@ -1,5 +1,5 @@ @inherits LayoutComponentBase -@layout MainLayout +@layout AppLayout @inject NavigationManager Navigation @inject Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider SchemeProvider diff --git a/framework/SimpleModule.Blazor/Components/Layout/PublicLayout.razor b/framework/SimpleModule.Blazor/Components/Layout/PublicLayout.razor new file mode 100644 index 00000000..35192e88 --- /dev/null +++ b/framework/SimpleModule.Blazor/Components/Layout/PublicLayout.razor @@ -0,0 +1,27 @@ +@using SimpleModule.Core.Menu +@inherits LayoutComponentBase + +
+ +
+ @Body +
diff --git a/framework/SimpleModule.Blazor/Components/ManageNav.razor b/framework/SimpleModule.Blazor/Components/ManageNav.razor index 05e4b5fc..d634e93d 100644 --- a/framework/SimpleModule.Blazor/Components/ManageNav.razor +++ b/framework/SimpleModule.Blazor/Components/ManageNav.razor @@ -26,17 +26,6 @@ Personal data - @code { diff --git a/framework/SimpleModule.Blazor/Components/UserDropdown.razor b/framework/SimpleModule.Blazor/Components/UserDropdown.razor index 66da4eec..2fea38a8 100644 --- a/framework/SimpleModule.Blazor/Components/UserDropdown.razor +++ b/framework/SimpleModule.Blazor/Components/UserDropdown.razor @@ -7,8 +7,8 @@
diff --git a/framework/SimpleModule.Core/Inertia/AdminSidebarMiddleware.cs b/framework/SimpleModule.Core/Inertia/AdminSidebarMiddleware.cs deleted file mode 100644 index dbee9231..00000000 --- a/framework/SimpleModule.Core/Inertia/AdminSidebarMiddleware.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using SimpleModule.Core.Menu; - -namespace SimpleModule.Core.Inertia; - -public static class AdminSidebarMiddlewareExtensions -{ - public static IApplicationBuilder UseAdminSidebarSharedData(this IApplicationBuilder app) - { - return app.Use(async (context, next) => - { - var sharedData = context.RequestServices.GetService(); - if (sharedData is not null && context.User.Identity?.IsAuthenticated == true) - { - var menuRegistry = context.RequestServices.GetRequiredService(); - var items = menuRegistry.GetItems(MenuSection.AdminSidebar); - sharedData.Set("adminSidebarMenu", items.Select(i => new - { - label = i.Label, - url = i.Url, - icon = i.Icon, - order = i.Order, - }).ToList()); - } - await next(); - }); - } -} diff --git a/framework/SimpleModule.Core/Inertia/InertiaResult.cs b/framework/SimpleModule.Core/Inertia/InertiaResult.cs index 5dde320b..05769c9a 100644 --- a/framework/SimpleModule.Core/Inertia/InertiaResult.cs +++ b/framework/SimpleModule.Core/Inertia/InertiaResult.cs @@ -70,7 +70,7 @@ private static object MergeProps(object? props, InertiaSharedData? sharedData) } // Add endpoint props (higher priority — overwrites shared data) - // Use JSON round-trip to avoid reflection (AOT-safe) + // Use JSON round-trip to merge endpoint props into shared data if (props is not null) { var json = JsonSerializer.SerializeToElement(props, _camelCaseOptions); diff --git a/framework/SimpleModule.Core/Menu/MenuSection.cs b/framework/SimpleModule.Core/Menu/MenuSection.cs index b24c7d55..a336529d 100644 --- a/framework/SimpleModule.Core/Menu/MenuSection.cs +++ b/framework/SimpleModule.Core/Menu/MenuSection.cs @@ -8,4 +8,5 @@ public enum MenuSection Navbar, UserDropdown, AdminSidebar, + AppSidebar, } diff --git a/modules/Admin/src/Admin/AdminModule.cs b/modules/Admin/src/Admin/AdminModule.cs index acb1f52c..14388153 100644 --- a/modules/Admin/src/Admin/AdminModule.cs +++ b/modules/Admin/src/Admin/AdminModule.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Admin.Contracts; using SimpleModule.Admin.Services; @@ -25,41 +25,6 @@ public void ConfigurePermissions(PermissionRegistryBuilder builder) public void ConfigureMenu(IMenuBuilder menus) { - menus.Add( - new MenuItem - { - Label = "Admin", - Url = "/admin/users", - Icon = - """""", - Order = 20, - Section = MenuSection.Navbar, - } - ); - menus.Add( - new MenuItem - { - Label = "Manage Users", - Url = "/admin/users", - Icon = - """""", - Order = 30, - Section = MenuSection.UserDropdown, - Group = "admin", - } - ); - menus.Add( - new MenuItem - { - Label = "Manage Roles", - Url = "/admin/roles", - Icon = - """""", - Order = 31, - Section = MenuSection.UserDropdown, - Group = "admin", - } - ); menus.Add( new MenuItem { diff --git a/modules/Admin/src/Admin/Pages/Admin/Roles.tsx b/modules/Admin/src/Admin/Pages/Admin/Roles.tsx index ae3a83ab..3387656f 100644 --- a/modules/Admin/src/Admin/Pages/Admin/Roles.tsx +++ b/modules/Admin/src/Admin/Pages/Admin/Roles.tsx @@ -1,5 +1,4 @@ import { router } from '@inertiajs/react'; -import { AdminLayout } from '@simplemodule/client/admin-layout'; import { Badge, Button, @@ -92,5 +91,3 @@ export default function Roles({ roles }: Props) {
); } - -Roles.layout = (page: React.ReactNode) => {page}; diff --git a/modules/Admin/src/Admin/Pages/Admin/RolesCreate.tsx b/modules/Admin/src/Admin/Pages/Admin/RolesCreate.tsx index 5151e90e..edae394b 100644 --- a/modules/Admin/src/Admin/Pages/Admin/RolesCreate.tsx +++ b/modules/Admin/src/Admin/Pages/Admin/RolesCreate.tsx @@ -1,5 +1,4 @@ import { router } from '@inertiajs/react'; -import { AdminLayout } from '@simplemodule/client/admin-layout'; import { Button, Card, CardContent, Input, Label } from '@simplemodule/ui'; import { PermissionGroups } from '../components/PermissionGroups'; @@ -64,5 +63,3 @@ export default function RolesCreate({ permissionsByModule }: Props) {
); } - -RolesCreate.layout = (page: React.ReactNode) => {page}; diff --git a/modules/Admin/src/Admin/Pages/Admin/RolesEdit.tsx b/modules/Admin/src/Admin/Pages/Admin/RolesEdit.tsx index 38b2b5b4..f29935a6 100644 --- a/modules/Admin/src/Admin/Pages/Admin/RolesEdit.tsx +++ b/modules/Admin/src/Admin/Pages/Admin/RolesEdit.tsx @@ -1,5 +1,4 @@ import { router } from '@inertiajs/react'; -import { AdminLayout } from '@simplemodule/client/admin-layout'; import { Button, Card, @@ -168,5 +167,3 @@ export default function RolesEdit({
); } - -RolesEdit.layout = (page: React.ReactNode) => {page}; diff --git a/modules/Admin/src/Admin/Pages/Admin/Users.tsx b/modules/Admin/src/Admin/Pages/Admin/Users.tsx index 989345fb..93843fae 100644 --- a/modules/Admin/src/Admin/Pages/Admin/Users.tsx +++ b/modules/Admin/src/Admin/Pages/Admin/Users.tsx @@ -1,5 +1,4 @@ import { router } from '@inertiajs/react'; -import { AdminLayout } from '@simplemodule/client/admin-layout'; import { Badge, Button, @@ -151,5 +150,3 @@ export default function Users({ users, search, page, totalPages, totalCount }: P
); } - -Users.layout = (page: React.ReactNode) => {page}; diff --git a/modules/Admin/src/Admin/Pages/Admin/UsersCreate.tsx b/modules/Admin/src/Admin/Pages/Admin/UsersCreate.tsx index 1bbdb39c..df8eac68 100644 --- a/modules/Admin/src/Admin/Pages/Admin/UsersCreate.tsx +++ b/modules/Admin/src/Admin/Pages/Admin/UsersCreate.tsx @@ -1,5 +1,4 @@ import { router } from '@inertiajs/react'; -import { AdminLayout } from '@simplemodule/client/admin-layout'; import { Button, Card, CardContent, Input, Label } from '@simplemodule/ui'; interface Role { @@ -103,5 +102,3 @@ export default function UsersCreate({ allRoles }: Props) { ); } - -UsersCreate.layout = (page: React.ReactNode) => {page}; diff --git a/modules/Admin/src/Admin/Pages/Admin/UsersEdit.tsx b/modules/Admin/src/Admin/Pages/Admin/UsersEdit.tsx index a3a1abfa..50b911d8 100644 --- a/modules/Admin/src/Admin/Pages/Admin/UsersEdit.tsx +++ b/modules/Admin/src/Admin/Pages/Admin/UsersEdit.tsx @@ -1,5 +1,4 @@ import { router } from '@inertiajs/react'; -import { AdminLayout } from '@simplemodule/client/admin-layout'; import { Badge, Button, @@ -398,5 +397,3 @@ export default function UsersEdit({ ); } - -UsersEdit.layout = (page: React.ReactNode) => {page}; diff --git a/modules/Dashboard/src/Dashboard/DashboardModule.cs b/modules/Dashboard/src/Dashboard/DashboardModule.cs index 53fa68fa..17efc293 100644 --- a/modules/Dashboard/src/Dashboard/DashboardModule.cs +++ b/modules/Dashboard/src/Dashboard/DashboardModule.cs @@ -10,5 +10,18 @@ public class DashboardModule : IModule { public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { } - public void ConfigureMenu(IMenuBuilder menus) { } + public void ConfigureMenu(IMenuBuilder menus) + { + menus.Add( + new MenuItem + { + Label = "Dashboard", + Url = "/", + Icon = + """""", + Order = 10, + Section = MenuSection.AppSidebar, + } + ); + } } diff --git a/modules/Dashboard/src/Dashboard/Pages/Home.tsx b/modules/Dashboard/src/Dashboard/Pages/Home.tsx index 9003f87a..17c142f2 100644 --- a/modules/Dashboard/src/Dashboard/Pages/Home.tsx +++ b/modules/Dashboard/src/Dashboard/Pages/Home.tsx @@ -500,7 +500,7 @@ function LandingView({ isDevelopment }: { isDevelopment: boolean }) {

SimpleModule

- Modular monolith framework for .NET — AOT‑compatible, zero reflection + Modular monolith framework for .NET with compile‑time module discovery

diff --git a/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/Clients.tsx b/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/Clients.tsx index 4f924ac6..3451c529 100644 --- a/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/Clients.tsx +++ b/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/Clients.tsx @@ -1,5 +1,4 @@ import { router } from '@inertiajs/react'; -import { AdminLayout } from '@simplemodule/client/admin-layout'; import { Badge, Button, @@ -84,5 +83,3 @@ export default function Clients({ clients }: Props) {
); } - -Clients.layout = (page: React.ReactNode) => {page}; diff --git a/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsCreate.tsx b/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsCreate.tsx index e1acbd87..52519e28 100644 --- a/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsCreate.tsx +++ b/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsCreate.tsx @@ -1,5 +1,4 @@ import { router } from '@inertiajs/react'; -import { AdminLayout } from '@simplemodule/client/admin-layout'; import { Button, Card, CardContent, Input, Label } from '@simplemodule/ui'; import { useState } from 'react'; @@ -69,5 +68,3 @@ export default function ClientsCreate() { ); } - -ClientsCreate.layout = (page: React.ReactNode) => {page}; diff --git a/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsEdit.tsx b/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsEdit.tsx index 33f05878..0c444ae6 100644 --- a/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsEdit.tsx +++ b/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsEdit.tsx @@ -1,5 +1,4 @@ import { router } from '@inertiajs/react'; -import { AdminLayout } from '@simplemodule/client/admin-layout'; import { Button, Card, @@ -299,5 +298,3 @@ export default function ClientsEdit({ ); } - -ClientsEdit.layout = (page: React.ReactNode) => {page}; diff --git a/modules/Orders/src/Orders/OrdersModule.cs b/modules/Orders/src/Orders/OrdersModule.cs index ddeb7af6..5366094d 100644 --- a/modules/Orders/src/Orders/OrdersModule.cs +++ b/modules/Orders/src/Orders/OrdersModule.cs @@ -39,5 +39,16 @@ public void ConfigureMenu(IMenuBuilder menus) Section = MenuSection.Navbar, } ); + menus.Add( + new MenuItem + { + Label = "Orders", + Url = "/orders", + Icon = + """""", + Order = 30, + Section = MenuSection.AppSidebar, + } + ); } } diff --git a/modules/Products/src/Products/ProductsModule.cs b/modules/Products/src/Products/ProductsModule.cs index 855b2644..9257b798 100644 --- a/modules/Products/src/Products/ProductsModule.cs +++ b/modules/Products/src/Products/ProductsModule.cs @@ -51,5 +51,16 @@ public void ConfigureMenu(IMenuBuilder menus) Section = MenuSection.Navbar, } ); + menus.Add( + new MenuItem + { + Label = "Products", + Url = "/products/browse", + Icon = + """""", + Order = 20, + Section = MenuSection.AppSidebar, + } + ); } } diff --git a/modules/Users/src/Users/Endpoints/Users/DownloadPersonalDataEndpoint.cs b/modules/Users/src/Users/Endpoints/Users/DownloadPersonalDataEndpoint.cs index e4a9f16e..03e2fe9c 100644 --- a/modules/Users/src/Users/Endpoints/Users/DownloadPersonalDataEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Users/DownloadPersonalDataEndpoint.cs @@ -31,7 +31,7 @@ ILogger logger logger.LogInformation("User asked for their personal data."); - // Manually enumerate personal data properties (AOT-compatible, no reflection) + // Manually enumerate personal data properties var personalData = new Dictionary { [PersonalDataKeys.Id] = user.Id ?? PersonalDataKeys.NullPlaceholder, diff --git a/modules/Users/src/Users/UsersModule.cs b/modules/Users/src/Users/UsersModule.cs index 41107ea5..d1cf304b 100644 --- a/modules/Users/src/Users/UsersModule.cs +++ b/modules/Users/src/Users/UsersModule.cs @@ -77,5 +77,18 @@ public void ConfigureMenu(IMenuBuilder menus) Group = "account", } ); + + // App sidebar item + menus.Add( + new MenuItem + { + Label = "Account", + Url = "/Identity/Account/Manage", + Icon = + """""", + Order = 80, + Section = MenuSection.AppSidebar, + } + ); } } diff --git a/package-lock.json b/package-lock.json index ce81c9b5..ba944ea2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4732,6 +4732,7 @@ "version": "0.0.0", "peerDependencies": { "@inertiajs/react": "^2.0.0", + "@simplemodule/ui": "*", "esbuild": "*", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/SimpleModule.Client/package.json b/packages/SimpleModule.Client/package.json index a4e5f3dc..3aafedb2 100644 --- a/packages/SimpleModule.Client/package.json +++ b/packages/SimpleModule.Client/package.json @@ -7,8 +7,7 @@ "exports": { ".": "./src/index.ts", "./vite": "./src/vite-plugin-vendor.ts", - "./resolve-page": "./src/resolve-page.ts", - "./admin-layout": "./src/admin-layout.tsx" + "./resolve-page": "./src/resolve-page.ts" }, "peerDependencies": { "@inertiajs/react": "^2.0.0", diff --git a/packages/SimpleModule.Client/src/admin-layout.tsx b/packages/SimpleModule.Client/src/admin-layout.tsx deleted file mode 100644 index c48e93c3..00000000 --- a/packages/SimpleModule.Client/src/admin-layout.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { Link, usePage } from '@inertiajs/react'; -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarProvider, - SidebarTrigger, - useSidebar, -} from '@simplemodule/ui'; -import { type ReactNode, useEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; - -interface AdminMenuItem { - label: string; - url: string; - icon: string; - order: number; -} - -interface AdminLayoutProps { - children: ReactNode; -} - -function TopBar() { - return ( -
- - -
- ); -} - -function SidebarNav() { - const { props } = usePage<{ adminSidebarMenu?: AdminMenuItem[] }>(); - const menuItems = props.adminSidebarMenu ?? []; - const currentPath = window.location.pathname; - const { open } = useSidebar(); - - return ( - <> - - {open && ( - - Administration - - )} - - - - {menuItems.map((item) => { - const isActive = currentPath.startsWith(item.url); - return ( - - - - - {open && {item.label}} - - - - ); - })} - - - - - - - ); -} - -function AdminLayoutContent({ children }: AdminLayoutProps) { - return ( -
- - - - -
- -
-
{children}
-
-
-
-
- ); -} - -export function AdminLayout({ children }: AdminLayoutProps) { - const [portalTarget, setPortalTarget] = useState(null); - - useEffect(() => { - setPortalTarget(document.body); - }, []); - - if (!portalTarget) return null; - - return createPortal({children}, portalTarget); -} diff --git a/packages/SimpleModule.Theme.Default/theme.css b/packages/SimpleModule.Theme.Default/theme.css index ee064ace..8c000855 100644 --- a/packages/SimpleModule.Theme.Default/theme.css +++ b/packages/SimpleModule.Theme.Default/theme.css @@ -4,16 +4,16 @@ ============================================================ */ @theme { - /* --- Primary Gradient Endpoints --- */ - --color-primary: #0d9488; - --color-primary-hover: #0f766e; - --color-primary-light: #2dd4bf; - --color-primary-subtle: rgba(13, 148, 136, 0.08); - --color-primary-ring: rgba(13, 148, 136, 0.25); + /* --- Primary: Forest Green --- */ + --color-primary: #16a34a; + --color-primary-hover: #15803d; + --color-primary-light: #4ade80; + --color-primary-subtle: rgba(22, 163, 74, 0.08); + --color-primary-ring: rgba(22, 163, 74, 0.25); - /* --- Accent: Emerald --- */ - --color-accent: #059669; - --color-accent-hover: #047857; + /* --- Accent: Deep Evergreen --- */ + --color-accent: #166534; + --color-accent-hover: #14532d; /* --- Semantic: Success --- */ --color-success: #16a34a; @@ -60,11 +60,11 @@ /* --- Misc --- */ --color-dark: #0f172a; --color-muted: #94a3b8; - --color-ring: rgba(13, 148, 136, 0.4); + --color-ring: rgba(22, 163, 74, 0.4); /* --- Shadows (themeable) --- */ - --shadow-primary: 0 4px 14px rgba(13, 148, 136, 0.35); - --shadow-primary-hover: 0 6px 20px rgba(13, 148, 136, 0.5); + --shadow-primary: 0 4px 14px rgba(22, 163, 74, 0.35); + --shadow-primary-hover: 0 6px 20px rgba(22, 163, 74, 0.5); --shadow-danger: 0 4px 14px rgba(225, 29, 72, 0.25); --shadow-danger-hover: 0 6px 20px rgba(225, 29, 72, 0.4); } @@ -73,8 +73,8 @@ Dark Mode Overrides ============================================================ */ .dark { - --color-primary-subtle: rgba(13, 148, 136, 0.15); - --color-primary-ring: rgba(13, 148, 136, 0.35); + --color-primary-subtle: rgba(22, 163, 74, 0.15); + --color-primary-ring: rgba(22, 163, 74, 0.35); --color-success-bg: rgba(74, 222, 128, 0.12); --color-success-text: #86efac; @@ -171,7 +171,7 @@ } ::selection { - background: rgba(13, 148, 136, 0.2); + background: rgba(22, 163, 74, 0.2); } } @@ -211,10 +211,10 @@ .btn-primary { @apply text-white; background: linear-gradient(135deg, var(--color-primary), var(--color-accent)); - box-shadow: 0 4px 14px rgba(13, 148, 136, 0.35); + box-shadow: 0 4px 14px rgba(22, 163, 74, 0.35); } .btn-primary:hover { - box-shadow: 0 6px 20px rgba(13, 148, 136, 0.5); + box-shadow: 0 6px 20px rgba(22, 163, 74, 0.5); transform: translateY(-1px); } @@ -466,6 +466,17 @@ transform: translateY(0) scale(1); pointer-events: auto; } + /* Inside sidebar: open upward */ + .app-sidebar .user-dropdown { + top: auto; + bottom: calc(100% + 6px); + left: 0; + right: auto; + transform: translateY(4px) scale(0.97); + } + .app-sidebar .user-dropdown.open { + transform: translateY(0) scale(1); + } .dark .user-dropdown { box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.06), @@ -512,6 +523,46 @@ .validation-message { @apply text-danger text-xs mt-1.5 font-medium; } + + /* --- App Layout (Sidebar) --- */ + .app-layout { + display: flex; + min-height: 100vh; + } + .app-sidebar { + width: 16rem; + @apply bg-surface border-r border-border shrink-0; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 40; + transition: width 0.2s ease; + } + .app-sidebar.app-sidebar-collapsed { + width: 4rem; + } + .app-sidebar.app-sidebar-collapsed .sidebar-label { + display: none; + } + .app-sidebar.app-sidebar-collapsed .sidebar-icon { + margin: 0 auto; + } + .app-content { + flex: 1; + margin-left: 16rem; + transition: margin-left 0.2s ease; + } + .app-sidebar-collapsed ~ .app-content { + margin-left: 4rem; + } + .app-sidebar-toggle { + @apply fixed bottom-4 z-50 w-7 h-7 rounded-full bg-surface border border-border flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer transition-all duration-200; + left: 15rem; + } + .app-sidebar-collapsed ~ .app-sidebar-toggle { + left: 3rem; + } } /* end @layer components */ /* ============================================================ diff --git a/template/SimpleModule.Host/Components/InertiaShell.razor b/template/SimpleModule.Host/Components/InertiaShell.razor index f527b613..c5a54a9b 100644 --- a/template/SimpleModule.Host/Components/InertiaShell.razor +++ b/template/SimpleModule.Host/Components/InertiaShell.razor @@ -3,7 +3,9 @@ + Layout="@(HttpContext?.User?.Identity?.IsAuthenticated == true + ? typeof(SimpleModule.Blazor.Components.Layout.AppLayout) + : typeof(SimpleModule.Blazor.Components.Layout.PublicLayout))"> diff --git a/template/SimpleModule.Host/Program.cs b/template/SimpleModule.Host/Program.cs index 2e94d1e4..136994b2 100644 --- a/template/SimpleModule.Host/Program.cs +++ b/template/SimpleModule.Host/Program.cs @@ -187,7 +187,6 @@ app.UseAuthentication(); app.UseAuthorization(); -app.UseAdminSidebarSharedData(); app.UseAntiforgery(); // Health endpoints — liveness (no checks) and readiness (database checks) diff --git a/template/SimpleModule.Host/appsettings.Development.json b/template/SimpleModule.Host/appsettings.Development.json index 608e7e76..ce89481e 100644 --- a/template/SimpleModule.Host/appsettings.Development.json +++ b/template/SimpleModule.Host/appsettings.Development.json @@ -1,5 +1,5 @@ -{ +{ "Database": { - "DefaultConnection": "Host=localhost;Database=simplemodule_dev;Username=postgres;Password=postgres" + "DefaultConnection": "Data Source=app.db" } }