From 4b069fdbafbd8bee4bc02ee07a8ca4d1e75db35f Mon Sep 17 00:00:00 2001 From: BL19 Date: Tue, 25 Aug 2026 14:07:34 +0200 Subject: [PATCH] feat: efcore database schemas --- scripts/migrations.ps1 | 28 +++- scripts/migrations.sh | 39 ++++- .../MySqlDesignTimeDbContextFactory.cs | 7 +- ...SpindleMySqlServiceCollectionExtensions.cs | 18 ++- .../PostgreSqlDesignTimeDbContextFactory.cs | 14 +- ...SpindlePostgreSqlMigrationsSqlGenerator.cs | 46 ++++++ ...lePostgreSqlServiceCollectionExtensions.cs | 47 +++++- .../SpindleSqlServerMigrationsSqlGenerator.cs | 29 ++++ ...dleSqlServerServiceCollectionExtensions.cs | 40 ++++- .../SqlServerDesignTimeDbContextFactory.cs | 14 +- ...pindleSqliteServiceCollectionExtensions.cs | 30 +++- .../SqliteDesignTimeDbContextFactory.cs | 4 +- .../SqliteRetryingExecutionStrategy.cs | 17 ++ .../Properties/AssemblyInfo.cs | 4 + src/Spindle.Persistence.EFCore/README.md | 23 ++- .../SchemaAwareMigrationOperations.cs | 145 ++++++++++++++++++ .../SpindleDbContext.cs | 11 ++ .../SpindleDesignTimeSchema.cs | 22 +++ .../SpindleModelCacheKeyFactory.cs | 21 +++ .../SpindleServiceCollectionExtensions.cs | 7 +- .../ProviderConfigurationTests.cs | 145 ++++++++++++++++++ .../Spindle.Persistence.EFCore.Tests.csproj | 13 ++ 22 files changed, 705 insertions(+), 19 deletions(-) create mode 100644 src/Spindle.Persistence.EFCore.PostgreSQL/SpindlePostgreSqlMigrationsSqlGenerator.cs create mode 100644 src/Spindle.Persistence.EFCore.SqlServer/SpindleSqlServerMigrationsSqlGenerator.cs create mode 100644 src/Spindle.Persistence.EFCore.Sqlite/SqliteRetryingExecutionStrategy.cs create mode 100644 src/Spindle.Persistence.EFCore/Properties/AssemblyInfo.cs create mode 100644 src/Spindle.Persistence.EFCore/SchemaAwareMigrationOperations.cs create mode 100644 src/Spindle.Persistence.EFCore/SpindleDesignTimeSchema.cs create mode 100644 src/Spindle.Persistence.EFCore/SpindleModelCacheKeyFactory.cs create mode 100644 tests/Spindle.Persistence.EFCore.Tests/ProviderConfigurationTests.cs diff --git a/scripts/migrations.ps1 b/scripts/migrations.ps1 index b04b979..03c9d72 100644 --- a/scripts/migrations.ps1 +++ b/scripts/migrations.ps1 @@ -9,6 +9,9 @@ param( [Parameter(Position = 2)] [string] $Argument, + [Parameter(Position = 3)] + [string] $ToMigration, + [switch] $Force, [Alias("h")] @@ -33,17 +36,20 @@ Usage: ./scripts/migrations.ps1 remove [provider|all] [-Force] ./scripts/migrations.ps1 list [provider|all] ./scripts/migrations.ps1 check [provider|all] + ./scripts/migrations.ps1 script [from-migration] [to-migration] Providers: sqlite, postgresql (or postgres), mysql, sqlserver (or mssql), all Environment: SPINDLE_EF_FRAMEWORK Target framework used by dotnet ef (default: net10.0) + SPINDLE_EF_SCHEMA Optional schema for explicit PostgreSQL or SQL Server script validation Examples: ./scripts/migrations.ps1 add all AddFlowPriority ./scripts/migrations.ps1 check postgresql ./scripts/migrations.ps1 remove sqlite + ./scripts/migrations.ps1 script postgresql 0 "@ } @@ -72,7 +78,8 @@ function Invoke-ProviderCommand { param( [string] $ProviderName, [string] $MigrationCommand, - [string] $CommandArgument + [string] $CommandArgument, + [string] $TargetMigration ) $Project = "src/Spindle.Persistence.EFCore.$ProviderName/Spindle.Persistence.EFCore.$ProviderName.csproj" @@ -105,6 +112,17 @@ function Invoke-ProviderCommand { "check" { Invoke-DotNetEf -Arguments (@("migrations", "has-pending-model-changes") + $CommonArguments) } + "script" { + $EfArguments = @("migrations", "script") + if ($CommandArgument) { + $EfArguments += $CommandArgument + } + if ($TargetMigration) { + $EfArguments += $TargetMigration + } + + Invoke-DotNetEf -Arguments ($EfArguments + $CommonArguments + @("--", "--spindle-ef-script")) + } } } @@ -130,10 +148,14 @@ switch ($Command) { throw "The remove command does not accept a positional argument; use -Force if needed." } } - { $_ -in "list", "check" } { } + { $_ -in "list", "check", "script" } { } default { throw "Unknown command: $Command" } } +if ($Command -ne "script" -and $ToMigration) { + throw "The $Command command accepts at most one argument." +} + if ($Force -and $Command -ne "remove") { throw "-Force can only be used with the remove command." } @@ -149,7 +171,7 @@ try { foreach ($ProviderName in $Providers) { $CommandArgument = if ($Force) { "--force" } else { $Argument } - Invoke-ProviderCommand $ProviderName $Command $CommandArgument + Invoke-ProviderCommand $ProviderName $Command $CommandArgument $ToMigration } } finally { diff --git a/scripts/migrations.sh b/scripts/migrations.sh index 9c5bfe0..b437b09 100755 --- a/scripts/migrations.sh +++ b/scripts/migrations.sh @@ -15,17 +15,20 @@ Usage: migrations.sh remove [provider|all] [--force] migrations.sh list [provider|all] migrations.sh check [provider|all] + migrations.sh script [from-migration] [to-migration] Providers: sqlite, postgresql (or postgres), mysql, sqlserver (or mssql), all Environment: SPINDLE_EF_FRAMEWORK Target framework used by dotnet ef (default: net10.0) + SPINDLE_EF_SCHEMA Optional schema for explicit PostgreSQL or SQL Server script validation Examples: ./scripts/migrations.sh add all AddFlowPriority ./scripts/migrations.sh check postgresql ./scripts/migrations.sh remove sqlite + ./scripts/migrations.sh script postgresql 0 EOF } @@ -47,9 +50,14 @@ run_for_provider() { local provider="$1" local command="$2" local argument="${3:-}" + local second_argument="${4:-}" local project="src/Spindle.Persistence.EFCore.${provider}/Spindle.Persistence.EFCore.${provider}.csproj" - printf '\n==> %s: %s\n' "$provider" "$command" + if [[ "$command" == "script" ]]; then + printf '\n==> %s: %s\n' "$provider" "$command" >&2 + else + printf '\n==> %s: %s\n' "$provider" "$command" + fi case "$command" in add) @@ -83,12 +91,29 @@ run_for_provider() { --startup-project "$project" \ --framework "$framework" ;; + script) + local script_arguments=(migrations script) + if [[ -n "$argument" ]]; then + script_arguments+=("$argument") + fi + if [[ -n "$second_argument" ]]; then + script_arguments+=("$second_argument") + fi + + dotnet ef "${script_arguments[@]}" \ + --project "$project" \ + --startup-project "$project" \ + --framework "$framework" \ + -- \ + --spindle-ef-script + ;; esac } command="${1:-help}" requested_provider="${2:-all}" argument="${3:-}" +second_argument="${4:-}" case "$command" in help|-h|--help) @@ -109,7 +134,7 @@ case "$command" in exit 2 fi ;; - list|check) ;; + list|check|script) ;; *) echo "Unknown command: $command" >&2 usage >&2 @@ -117,12 +142,18 @@ case "$command" in ;; esac +if [[ "$command" != "script" && -n "$second_argument" ]]; then + echo "The $command command accepts at most one argument." >&2 + usage >&2 + exit 2 +fi + cd "$repository_root" if [[ "${requested_provider,,}" == "all" ]]; then for provider in Sqlite PostgreSQL MySql SqlServer; do - run_for_provider "$provider" "$command" "$argument" + run_for_provider "$provider" "$command" "$argument" "$second_argument" done else - run_for_provider "$(normalize_provider "$requested_provider")" "$command" "$argument" + run_for_provider "$(normalize_provider "$requested_provider")" "$command" "$argument" "$second_argument" fi diff --git a/src/Spindle.Persistence.EFCore.MySql/MySqlDesignTimeDbContextFactory.cs b/src/Spindle.Persistence.EFCore.MySql/MySqlDesignTimeDbContextFactory.cs index 910c3bc..d5adbca 100644 --- a/src/Spindle.Persistence.EFCore.MySql/MySqlDesignTimeDbContextFactory.cs +++ b/src/Spindle.Persistence.EFCore.MySql/MySqlDesignTimeDbContextFactory.cs @@ -11,7 +11,12 @@ public SpindleDbContext CreateDbContext(string[] args) { var options = new DbContextOptionsBuilder() .UseMySQL("Server=localhost;Database=spindle;User=root;Password=spindle", mysql => - mysql.MigrationsAssembly(typeof(MySqlDesignTimeDbContextFactory).Assembly.FullName)) + mysql + .MigrationsAssembly(typeof(MySqlDesignTimeDbContextFactory).Assembly.FullName) + .EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromMilliseconds(100), + errorNumbersToAdd: null)) .Options; return new SpindleDbContext(options); diff --git a/src/Spindle.Persistence.EFCore.MySql/SpindleMySqlServiceCollectionExtensions.cs b/src/Spindle.Persistence.EFCore.MySql/SpindleMySqlServiceCollectionExtensions.cs index f63fbdc..8a4b0eb 100644 --- a/src/Spindle.Persistence.EFCore.MySql/SpindleMySqlServiceCollectionExtensions.cs +++ b/src/Spindle.Persistence.EFCore.MySql/SpindleMySqlServiceCollectionExtensions.cs @@ -9,12 +9,28 @@ public static class SpindleMySqlServiceCollectionExtensions public static IServiceCollection AddSpindleMySql( this IServiceCollection services, string connectionString) + { + return AddSpindleMySql(services, connectionString, configure: null); + } + + public static IServiceCollection AddSpindleMySql( + this IServiceCollection services, + string connectionString, + Action? configure) { ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); return services.AddSpindleEntityFramework(options => + { options.UseMySQL( connectionString, - mysql => mysql.MigrationsAssembly(typeof(SpindleMySqlServiceCollectionExtensions).Assembly.FullName))); + mysql => mysql + .MigrationsAssembly(typeof(SpindleMySqlServiceCollectionExtensions).Assembly.FullName) + .EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromMilliseconds(100), + errorNumbersToAdd: null)); + configure?.Invoke(options); + }); } } diff --git a/src/Spindle.Persistence.EFCore.PostgreSQL/PostgreSqlDesignTimeDbContextFactory.cs b/src/Spindle.Persistence.EFCore.PostgreSQL/PostgreSqlDesignTimeDbContextFactory.cs index c330b19..10bdeca 100644 --- a/src/Spindle.Persistence.EFCore.PostgreSQL/PostgreSqlDesignTimeDbContextFactory.cs +++ b/src/Spindle.Persistence.EFCore.PostgreSQL/PostgreSqlDesignTimeDbContextFactory.cs @@ -1,5 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; using Spindle.Persistence.EFCore; namespace Spindle.Persistence.EFCore.PostgreSQL; @@ -9,9 +11,19 @@ internal sealed class PostgreSqlDesignTimeDbContextFactory { public SpindleDbContext CreateDbContext(string[] args) { + var schema = SpindleDesignTimeSchema.Get(args); + var options = new DbContextOptionsBuilder() .UseNpgsql("Host=localhost;Database=spindle;Username=postgres;Password=postgres", postgres => - postgres.MigrationsAssembly(typeof(PostgreSqlDesignTimeDbContextFactory).Assembly.FullName)) + postgres + .MigrationsAssembly(typeof(PostgreSqlDesignTimeDbContextFactory).Assembly.FullName) + .MigrationsHistoryTable("__EFMigrationsHistory", schema) + .EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromMilliseconds(100), + errorCodesToAdd: null)) + .ReplaceService() + .ReplaceService() .Options; return new SpindleDbContext(options); diff --git a/src/Spindle.Persistence.EFCore.PostgreSQL/SpindlePostgreSqlMigrationsSqlGenerator.cs b/src/Spindle.Persistence.EFCore.PostgreSQL/SpindlePostgreSqlMigrationsSqlGenerator.cs new file mode 100644 index 0000000..b05cf67 --- /dev/null +++ b/src/Spindle.Persistence.EFCore.PostgreSQL/SpindlePostgreSqlMigrationsSqlGenerator.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations; +using Spindle.Persistence.EFCore; + +#pragma warning disable EF1001 + +namespace Spindle.Persistence.EFCore.PostgreSQL; + +internal sealed class SpindlePostgreSqlMigrationsSqlGenerator( + MigrationsSqlGeneratorDependencies dependencies, + INpgsqlSingletonOptions npgsqlSingletonOptions, + ICurrentDbContext currentContext) + : NpgsqlMigrationsSqlGenerator(dependencies, npgsqlSingletonOptions) +{ + public override IReadOnlyList Generate( + IReadOnlyList operations, + IModel? model, + MigrationsSqlGenerationOptions options) + { + var schema = currentContext.Context is SpindleDbContext spindleContext + ? spindleContext.Schema + : null; + return base.Generate( + SchemaAwareMigrationOperations.Prepare(operations, model, schema), + model, + options); + } + + protected override void Generate( + EnsureSchemaOperation operation, + IModel? model, + MigrationCommandListBuilder builder) + { + builder + .Append("CREATE SCHEMA IF NOT EXISTS ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name)) + .Append(Dependencies.SqlGenerationHelper.StatementTerminator) + .EndCommand(); + } +} + +#pragma warning restore EF1001 diff --git a/src/Spindle.Persistence.EFCore.PostgreSQL/SpindlePostgreSqlServiceCollectionExtensions.cs b/src/Spindle.Persistence.EFCore.PostgreSQL/SpindlePostgreSqlServiceCollectionExtensions.cs index ce2c94f..23b70b9 100644 --- a/src/Spindle.Persistence.EFCore.PostgreSQL/SpindlePostgreSqlServiceCollectionExtensions.cs +++ b/src/Spindle.Persistence.EFCore.PostgreSQL/SpindlePostgreSqlServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.DependencyInjection; using Spindle.Persistence.EFCore; @@ -9,12 +10,56 @@ public static class SpindlePostgreSqlServiceCollectionExtensions public static IServiceCollection AddSpindlePostgreSql( this IServiceCollection services, string connectionString) + { + return AddSpindlePostgreSql(services, connectionString, schema: null, configure: null); + } + + public static IServiceCollection AddSpindlePostgreSql( + this IServiceCollection services, + string connectionString, + Action? configure) + { + return AddSpindlePostgreSql(services, connectionString, schema: null, configure); + } + + public static IServiceCollection AddSpindlePostgreSql( + this IServiceCollection services, + string connectionString, + string? schema) + { + return AddSpindlePostgreSql(services, connectionString, schema, configure: null); + } + + public static IServiceCollection AddSpindlePostgreSql( + this IServiceCollection services, + string connectionString, + string? schema, + Action? configure) { ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + ValidateSchema(schema); return services.AddSpindleEntityFramework(options => + { options.UseNpgsql( connectionString, - postgres => postgres.MigrationsAssembly(typeof(SpindlePostgreSqlServiceCollectionExtensions).Assembly.FullName))); + postgres => postgres + .MigrationsAssembly(typeof(SpindlePostgreSqlServiceCollectionExtensions).Assembly.FullName) + .MigrationsHistoryTable("__EFMigrationsHistory", schema) + .EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromMilliseconds(100), + errorCodesToAdd: null)); + options.ReplaceService(); + configure?.Invoke(options); + }); + } + + private static void ValidateSchema(string? schema) + { + if (schema is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(schema); + } } } diff --git a/src/Spindle.Persistence.EFCore.SqlServer/SpindleSqlServerMigrationsSqlGenerator.cs b/src/Spindle.Persistence.EFCore.SqlServer/SpindleSqlServerMigrationsSqlGenerator.cs new file mode 100644 index 0000000..b4daa16 --- /dev/null +++ b/src/Spindle.Persistence.EFCore.SqlServer/SpindleSqlServerMigrationsSqlGenerator.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Microsoft.EntityFrameworkCore.Update; +using Spindle.Persistence.EFCore; + +namespace Spindle.Persistence.EFCore.SqlServer; + +internal sealed class SpindleSqlServerMigrationsSqlGenerator( + MigrationsSqlGeneratorDependencies dependencies, + ICommandBatchPreparer commandBatchPreparer, + ICurrentDbContext currentContext) + : SqlServerMigrationsSqlGenerator(dependencies, commandBatchPreparer) +{ + public override IReadOnlyList Generate( + IReadOnlyList operations, + IModel? model, + MigrationsSqlGenerationOptions options) + { + var schema = currentContext.Context is SpindleDbContext spindleContext + ? spindleContext.Schema + : null; + return base.Generate( + SchemaAwareMigrationOperations.Prepare(operations, model, schema), + model, + options); + } +} diff --git a/src/Spindle.Persistence.EFCore.SqlServer/SpindleSqlServerServiceCollectionExtensions.cs b/src/Spindle.Persistence.EFCore.SqlServer/SpindleSqlServerServiceCollectionExtensions.cs index f77ee08..18e5186 100644 --- a/src/Spindle.Persistence.EFCore.SqlServer/SpindleSqlServerServiceCollectionExtensions.cs +++ b/src/Spindle.Persistence.EFCore.SqlServer/SpindleSqlServerServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.DependencyInjection; using Spindle.Persistence.EFCore; @@ -9,8 +10,34 @@ public static class SpindleSqlServerServiceCollectionExtensions public static IServiceCollection AddSpindleSqlServer( this IServiceCollection services, string connectionString) + { + return AddSpindleSqlServer(services, connectionString, schema: null, configure: null); + } + + public static IServiceCollection AddSpindleSqlServer( + this IServiceCollection services, + string connectionString, + Action? configure) + { + return AddSpindleSqlServer(services, connectionString, schema: null, configure); + } + + public static IServiceCollection AddSpindleSqlServer( + this IServiceCollection services, + string connectionString, + string? schema) + { + return AddSpindleSqlServer(services, connectionString, schema, configure: null); + } + + public static IServiceCollection AddSpindleSqlServer( + this IServiceCollection services, + string connectionString, + string? schema, + Action? configure) { ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + ValidateSchema(schema); return services.AddSpindleEntityFramework(options => { @@ -18,13 +45,22 @@ public static IServiceCollection AddSpindleSqlServer( connectionString, sqlServer => sqlServer .MigrationsAssembly(typeof(SpindleSqlServerServiceCollectionExtensions).Assembly.FullName) + .MigrationsHistoryTable("__EFMigrationsHistory", schema) .EnableRetryOnFailure( maxRetryCount: 5, maxRetryDelay: TimeSpan.FromMilliseconds(100), errorNumbersToAdd: null )); - options.EnableDetailedErrors() - .EnableSensitiveDataLogging(); + options.ReplaceService(); + configure?.Invoke(options); }); } + + private static void ValidateSchema(string? schema) + { + if (schema is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(schema); + } + } } diff --git a/src/Spindle.Persistence.EFCore.SqlServer/SqlServerDesignTimeDbContextFactory.cs b/src/Spindle.Persistence.EFCore.SqlServer/SqlServerDesignTimeDbContextFactory.cs index b8d8b47..d27eaf0 100644 --- a/src/Spindle.Persistence.EFCore.SqlServer/SqlServerDesignTimeDbContextFactory.cs +++ b/src/Spindle.Persistence.EFCore.SqlServer/SqlServerDesignTimeDbContextFactory.cs @@ -1,5 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; using Spindle.Persistence.EFCore; namespace Spindle.Persistence.EFCore.SqlServer; @@ -9,9 +11,19 @@ internal sealed class SqlServerDesignTimeDbContextFactory { public SpindleDbContext CreateDbContext(string[] args) { + var schema = SpindleDesignTimeSchema.Get(args); + var options = new DbContextOptionsBuilder() .UseSqlServer("Server=localhost;Database=Spindle;User Id=sa;Password=Spindle1!;TrustServerCertificate=True", sqlServer => - sqlServer.MigrationsAssembly(typeof(SqlServerDesignTimeDbContextFactory).Assembly.FullName)) + sqlServer + .MigrationsAssembly(typeof(SqlServerDesignTimeDbContextFactory).Assembly.FullName) + .MigrationsHistoryTable("__EFMigrationsHistory", schema) + .EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromMilliseconds(100), + errorNumbersToAdd: null)) + .ReplaceService() + .ReplaceService() .Options; return new SpindleDbContext(options); diff --git a/src/Spindle.Persistence.EFCore.Sqlite/SpindleSqliteServiceCollectionExtensions.cs b/src/Spindle.Persistence.EFCore.Sqlite/SpindleSqliteServiceCollectionExtensions.cs index cc1e02e..508255e 100644 --- a/src/Spindle.Persistence.EFCore.Sqlite/SpindleSqliteServiceCollectionExtensions.cs +++ b/src/Spindle.Persistence.EFCore.Sqlite/SpindleSqliteServiceCollectionExtensions.cs @@ -10,24 +10,50 @@ public static class SpindleSqliteServiceCollectionExtensions public static IServiceCollection AddSpindleSqlite( this IServiceCollection services, string connectionString) + { + return AddSpindleSqlite(services, connectionString, configure: null); + } + + public static IServiceCollection AddSpindleSqlite( + this IServiceCollection services, + string connectionString, + Action? configure) { ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); return services.AddSpindleEntityFramework(options => + { options.UseSqlite( connectionString, - sqlite => sqlite.MigrationsAssembly(typeof(SpindleSqliteServiceCollectionExtensions).Assembly.FullName))); + sqlite => sqlite + .MigrationsAssembly(typeof(SpindleSqliteServiceCollectionExtensions).Assembly.FullName) + .ExecutionStrategy(dependencies => new SqliteRetryingExecutionStrategy(dependencies))); + configure?.Invoke(options); + }); } public static IServiceCollection AddSpindleSqlite( this IServiceCollection services, SqliteConnection connection) + { + return AddSpindleSqlite(services, connection, configure: null); + } + + public static IServiceCollection AddSpindleSqlite( + this IServiceCollection services, + SqliteConnection connection, + Action? configure) { ArgumentNullException.ThrowIfNull(connection); return services.AddSpindleEntityFramework(options => + { options.UseSqlite( connection, - sqlite => sqlite.MigrationsAssembly(typeof(SpindleSqliteServiceCollectionExtensions).Assembly.FullName))); + sqlite => sqlite + .MigrationsAssembly(typeof(SpindleSqliteServiceCollectionExtensions).Assembly.FullName) + .ExecutionStrategy(dependencies => new SqliteRetryingExecutionStrategy(dependencies))); + configure?.Invoke(options); + }); } } diff --git a/src/Spindle.Persistence.EFCore.Sqlite/SqliteDesignTimeDbContextFactory.cs b/src/Spindle.Persistence.EFCore.Sqlite/SqliteDesignTimeDbContextFactory.cs index 6313274..e081e31 100644 --- a/src/Spindle.Persistence.EFCore.Sqlite/SqliteDesignTimeDbContextFactory.cs +++ b/src/Spindle.Persistence.EFCore.Sqlite/SqliteDesignTimeDbContextFactory.cs @@ -11,7 +11,9 @@ public SpindleDbContext CreateDbContext(string[] args) { var options = new DbContextOptionsBuilder() .UseSqlite("Data Source=spindle.db", sqlite => - sqlite.MigrationsAssembly(typeof(SqliteDesignTimeDbContextFactory).Assembly.FullName)) + sqlite + .MigrationsAssembly(typeof(SqliteDesignTimeDbContextFactory).Assembly.FullName) + .ExecutionStrategy(dependencies => new SqliteRetryingExecutionStrategy(dependencies))) .Options; return new SpindleDbContext(options); diff --git a/src/Spindle.Persistence.EFCore.Sqlite/SqliteRetryingExecutionStrategy.cs b/src/Spindle.Persistence.EFCore.Sqlite/SqliteRetryingExecutionStrategy.cs new file mode 100644 index 0000000..b3860c4 --- /dev/null +++ b/src/Spindle.Persistence.EFCore.Sqlite/SqliteRetryingExecutionStrategy.cs @@ -0,0 +1,17 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore.Storage; + +namespace Spindle.Persistence.EFCore.Sqlite; + +internal sealed class SqliteRetryingExecutionStrategy( + ExecutionStrategyDependencies dependencies) + : ExecutionStrategy(dependencies, maxRetryCount: 5, maxRetryDelay: TimeSpan.FromMilliseconds(100)) +{ + protected override bool ShouldRetryOn(Exception exception) + { + return exception is SqliteException + { + SqliteErrorCode: 5 or 6, + }; + } +} diff --git a/src/Spindle.Persistence.EFCore/Properties/AssemblyInfo.cs b/src/Spindle.Persistence.EFCore/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..aba96e6 --- /dev/null +++ b/src/Spindle.Persistence.EFCore/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Spindle.Persistence.EFCore.PostgreSQL")] +[assembly: InternalsVisibleTo("Spindle.Persistence.EFCore.SqlServer")] diff --git a/src/Spindle.Persistence.EFCore/README.md b/src/Spindle.Persistence.EFCore/README.md index e4104d9..4f7722a 100644 --- a/src/Spindle.Persistence.EFCore/README.md +++ b/src/Spindle.Persistence.EFCore/README.md @@ -19,6 +19,25 @@ services.AddSpindleMySql(mySqlConnectionString); services.AddSpindleSqlServer(sqlServerConnectionString); ``` +All registrations also accept an optional `Action` that +runs after Spindle's provider defaults. Use it for application-specific EF Core +options, such as `EnableSensitiveDataLogging()` when that is appropriate for the +environment. SQL Server sensitive-data logging is disabled by default. + +PostgreSQL and SQL Server additionally accept an optional schema. The schema is +applied to Spindle's entity tables and migrations history table, and is created +by generated migration SQL when needed: + +```csharp +services.AddSpindlePostgreSql( + postgreSqlConnectionString, + schema: "spindle", + configure: options => options.EnableDetailedErrors()); +``` + +MySQL and SQLite do not expose schema parameters because their installed EF Core +providers do not support EF Core schemas. + Create or migrate the schema during application startup using the normal EF Core APIs: ```csharp @@ -37,12 +56,14 @@ The repository includes equivalent Bash and PowerShell helpers that run `dotnet ./scripts/migrations.sh add all AddFlowPriority ./scripts/migrations.sh check all ./scripts/migrations.sh list sqlite +./scripts/migrations.sh script postgresql 0 ``` ```powershell ./scripts/migrations.ps1 add all AddFlowPriority ./scripts/migrations.ps1 check all ./scripts/migrations.ps1 list sqlite +./scripts/migrations.ps1 script postgresql 0 ``` -Supported provider names are `sqlite`, `postgresql`, `mysql`, and `sqlserver`; `all` runs the command for every provider. Use `remove --force` in Bash or `remove -Force` in PowerShell only when a migration can be removed without checking a database. Both scripts use `net10.0` for design-time operations by default; set `SPINDLE_EF_FRAMEWORK` to override it. +Supported provider names are `sqlite`, `postgresql`, `mysql`, and `sqlserver`; `all` runs the command for every provider. Use `remove --force` in Bash or `remove -Force` in PowerShell only when a migration can be removed without checking a database. The `script` command accepts optional `from-migration` and `to-migration` arguments and writes SQL to standard output. Both scripts use `net10.0` for design-time operations by default; set `SPINDLE_EF_FRAMEWORK` to override it. The `add`, `check`, `list`, and `remove` commands always use schema-neutral design-time models. To validate a PostgreSQL or SQL Server migration script for a non-default schema, set `SPINDLE_EF_SCHEMA` before using the `script` command. The marker and value are ignored by the MySQL and SQLite design-time factories. diff --git a/src/Spindle.Persistence.EFCore/SchemaAwareMigrationOperations.cs b/src/Spindle.Persistence.EFCore/SchemaAwareMigrationOperations.cs new file mode 100644 index 0000000..1681364 --- /dev/null +++ b/src/Spindle.Persistence.EFCore/SchemaAwareMigrationOperations.cs @@ -0,0 +1,145 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace Spindle.Persistence.EFCore; + +internal static class SchemaAwareMigrationOperations +{ + public static IReadOnlyList Prepare( + IReadOnlyList operations, + IModel? model, + string? configuredSchema) + { + var schema = model is null + ? configuredSchema + : ((IReadOnlyModel)model).GetDefaultSchema() ?? configuredSchema; + if (string.IsNullOrWhiteSpace(schema)) + { + return operations; + } + + var prepared = new List(operations.Count + 1); + if (!operations.OfType().Any(operation => operation.Name == schema)) + { + prepared.Add(new EnsureSchemaOperation { Name = schema }); + } + + foreach (var operation in operations) + { + ApplySchema(operation, schema); + prepared.Add(operation); + } + + return prepared; + } + + private static void ApplySchema(MigrationOperation operation, string schema) + { + switch (operation) + { + case AddCheckConstraintOperation addCheckConstraint: + addCheckConstraint.Schema ??= schema; + break; + case AddColumnOperation addColumn: + addColumn.Schema ??= schema; + break; + case AddForeignKeyOperation addForeignKey: + addForeignKey.Schema ??= schema; + addForeignKey.PrincipalSchema ??= schema; + break; + case AddPrimaryKeyOperation addPrimaryKey: + addPrimaryKey.Schema ??= schema; + break; + case AddUniqueConstraintOperation addUniqueConstraint: + addUniqueConstraint.Schema ??= schema; + break; + case AlterColumnOperation alterColumn: + alterColumn.Schema ??= schema; + break; + case AlterSequenceOperation alterSequence: + alterSequence.Schema ??= schema; + break; + case AlterTableOperation alterTable: + alterTable.Schema ??= schema; + break; + case CreateIndexOperation createIndex: + createIndex.Schema ??= schema; + break; + case CreateTableOperation createTable: + createTable.Schema ??= schema; + if (createTable.PrimaryKey is not null) + { + ApplySchema(createTable.PrimaryKey, schema); + } + foreach (var column in createTable.Columns) + { + column.Schema ??= schema; + } + + foreach (var foreignKey in createTable.ForeignKeys) + { + ApplySchema(foreignKey, schema); + } + + foreach (var uniqueConstraint in createTable.UniqueConstraints) + { + ApplySchema(uniqueConstraint, schema); + } + + foreach (var checkConstraint in createTable.CheckConstraints) + { + ApplySchema(checkConstraint, schema); + } + + break; + case DeleteDataOperation deleteData: + deleteData.Schema ??= schema; + break; + case DropCheckConstraintOperation dropCheckConstraint: + dropCheckConstraint.Schema ??= schema; + break; + case DropColumnOperation dropColumn: + dropColumn.Schema ??= schema; + break; + case DropForeignKeyOperation dropForeignKey: + dropForeignKey.Schema ??= schema; + break; + case DropIndexOperation dropIndex: + dropIndex.Schema ??= schema; + break; + case DropPrimaryKeyOperation dropPrimaryKey: + dropPrimaryKey.Schema ??= schema; + break; + case DropSequenceOperation dropSequence: + dropSequence.Schema ??= schema; + break; + case DropTableOperation dropTable: + dropTable.Schema ??= schema; + break; + case DropUniqueConstraintOperation dropUniqueConstraint: + dropUniqueConstraint.Schema ??= schema; + break; + case InsertDataOperation insertData: + insertData.Schema ??= schema; + break; + case RenameColumnOperation renameColumn: + renameColumn.Schema ??= schema; + break; + case RenameIndexOperation renameIndex: + renameIndex.Schema ??= schema; + break; + case RenameSequenceOperation renameSequence: + renameSequence.Schema ??= schema; + renameSequence.NewSchema ??= schema; + break; + case RenameTableOperation renameTable: + renameTable.Schema ??= schema; + renameTable.NewSchema ??= schema; + break; + case UpdateDataOperation updateData: + updateData.Schema ??= schema; + break; + } + } +} diff --git a/src/Spindle.Persistence.EFCore/SpindleDbContext.cs b/src/Spindle.Persistence.EFCore/SpindleDbContext.cs index 9617811..3842ad8 100644 --- a/src/Spindle.Persistence.EFCore/SpindleDbContext.cs +++ b/src/Spindle.Persistence.EFCore/SpindleDbContext.cs @@ -1,4 +1,6 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Spindle.Abstractions.Snapshot; using Spindle.Persistence.EFCore.Configuration; @@ -10,6 +12,10 @@ public sealed class SpindleDbContext( DbContextOptions options) : DbContext(options) { + internal string? Schema => RelationalOptionsExtension + .Extract(this.GetService()) + .MigrationsHistoryTableSchema; + internal DbSet ExecutionHistories => Set(); internal DbSet ConditionWaits => Set(); internal DbSet FlowDefinitions => Set(); @@ -55,6 +61,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); + if (Schema is { Length: > 0 } schema) + { + modelBuilder.HasDefaultSchema(schema); + } + modelBuilder.Entity(entity => { entity.ToTable("ExecutionHistories"); diff --git a/src/Spindle.Persistence.EFCore/SpindleDesignTimeSchema.cs b/src/Spindle.Persistence.EFCore/SpindleDesignTimeSchema.cs new file mode 100644 index 0000000..56adbd8 --- /dev/null +++ b/src/Spindle.Persistence.EFCore/SpindleDesignTimeSchema.cs @@ -0,0 +1,22 @@ +namespace Spindle.Persistence.EFCore; + +internal static class SpindleDesignTimeSchema +{ + private const string MigrationScriptArgument = "--spindle-ef-script"; + + public static string? Get(string[] args) + { + ArgumentNullException.ThrowIfNull(args); + + if (!args.Any(argument => string.Equals( + argument, + MigrationScriptArgument, + StringComparison.OrdinalIgnoreCase))) + { + return null; + } + + var schema = Environment.GetEnvironmentVariable("SPINDLE_EF_SCHEMA"); + return string.IsNullOrWhiteSpace(schema) ? null : schema.Trim(); + } +} diff --git a/src/Spindle.Persistence.EFCore/SpindleModelCacheKeyFactory.cs b/src/Spindle.Persistence.EFCore/SpindleModelCacheKeyFactory.cs new file mode 100644 index 0000000..51ae4c6 --- /dev/null +++ b/src/Spindle.Persistence.EFCore/SpindleModelCacheKeyFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Spindle.Persistence.EFCore; + +internal sealed class SpindleModelCacheKeyFactory : IModelCacheKeyFactory +{ + public object Create(DbContext context) + { + return Create(context, designTime: false); + } + + public object Create(DbContext context, bool designTime) + { + return ( + context.GetType(), + context.Database.ProviderName, + context is SpindleDbContext spindleContext ? spindleContext.Schema : null, + designTime); + } +} diff --git a/src/Spindle.Persistence.EFCore/SpindleServiceCollectionExtensions.cs b/src/Spindle.Persistence.EFCore/SpindleServiceCollectionExtensions.cs index 4b8f16a..36d881e 100644 --- a/src/Spindle.Persistence.EFCore/SpindleServiceCollectionExtensions.cs +++ b/src/Spindle.Persistence.EFCore/SpindleServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -13,7 +14,11 @@ public static IServiceCollection AddSpindleEntityFramework( ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configure); - services.AddPooledDbContextFactory(configure); + services.AddPooledDbContextFactory(options => + { + options.ReplaceService(); + configure(options); + }); services.TryAddScoped(serviceProvider => serviceProvider .GetRequiredService>() .CreateDbContext()); diff --git a/tests/Spindle.Persistence.EFCore.Tests/ProviderConfigurationTests.cs b/tests/Spindle.Persistence.EFCore.Tests/ProviderConfigurationTests.cs new file mode 100644 index 0000000..d3c6838 --- /dev/null +++ b/tests/Spindle.Persistence.EFCore.Tests/ProviderConfigurationTests.cs @@ -0,0 +1,145 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Spindle.Persistence.EFCore; +using Spindle.Persistence.EFCore.MySql; +using Spindle.Persistence.EFCore.PostgreSQL; +using Spindle.Persistence.EFCore.Sqlite; +using Spindle.Persistence.EFCore.SqlServer; +using Xunit; + +namespace Spindle.Persistence.EFCore.Tests; + +public sealed class ProviderConfigurationTests +{ + [Fact] + public void ProviderConfigureActions_AreInvokedForEveryProvider() + { + var invoked = new bool[4]; + + using (var provider = new ServiceCollection() + .AddSpindleSqlite("Data Source=spindle-options.db", _ => invoked[0] = true) + .BuildServiceProvider()) + { + _ = provider.GetRequiredService(); + } + + using (var provider = new ServiceCollection() + .AddSpindlePostgreSql("Host=localhost;Database=spindle", _ => invoked[1] = true) + .BuildServiceProvider()) + { + _ = provider.GetRequiredService(); + } + + using (var provider = new ServiceCollection() + .AddSpindleMySql("Server=localhost;Database=spindle", _ => invoked[2] = true) + .BuildServiceProvider()) + { + _ = provider.GetRequiredService(); + } + + using (var provider = BuildSqlServerProvider(configure: _ => invoked[3] = true)) + { + _ = provider.GetRequiredService(); + } + + Assert.All(invoked, Assert.True); + } + + [Fact] + public void SqlServerSensitiveDataLogging_IsOptInAndDetailedErrorsAreNotEnabledByDefault() + { + using var defaultProvider = BuildSqlServerProvider(); + using var defaultContext = defaultProvider.GetRequiredService(); + var defaultOptions = defaultContext.GetService(); + var defaultCoreOptions = defaultOptions.FindExtension()!; + + Assert.False(defaultCoreOptions.IsSensitiveDataLoggingEnabled); + Assert.False(defaultCoreOptions.DetailedErrorsEnabled); + + using var configuredProvider = BuildSqlServerProvider(configure: options => + options.EnableSensitiveDataLogging()); + using var configuredContext = configuredProvider.GetRequiredService(); + var configuredCoreOptions = configuredContext + .GetService() + .FindExtension()!; + + Assert.True(configuredCoreOptions.IsSensitiveDataLoggingEnabled); + } + + [Fact] + public void RelationalProviders_EnableRetryOnFailure() + { + using var postgreSqlProvider = new ServiceCollection() + .AddSpindlePostgreSql("Host=localhost;Database=spindle") + .BuildServiceProvider(); + using var postgreSqlContext = postgreSqlProvider.GetRequiredService(); + + using var mySqlProvider = new ServiceCollection() + .AddSpindleMySql("Server=localhost;Database=spindle") + .BuildServiceProvider(); + using var mySqlContext = mySqlProvider.GetRequiredService(); + + using var sqlServerProvider = BuildSqlServerProvider(); + using var sqlServerContext = sqlServerProvider.GetRequiredService(); + + using var sqliteProvider = new ServiceCollection() + .AddSpindleSqlite("Data Source=spindle-options.db") + .BuildServiceProvider(); + using var sqliteContext = sqliteProvider.GetRequiredService(); + + Assert.True(postgreSqlContext.Database.CreateExecutionStrategy().RetriesOnFailure); + Assert.True(mySqlContext.Database.CreateExecutionStrategy().RetriesOnFailure); + Assert.True(sqlServerContext.Database.CreateExecutionStrategy().RetriesOnFailure); + Assert.True(sqliteContext.Database.CreateExecutionStrategy().RetriesOnFailure); + } + + [Fact] + public void SqlServerSchema_IsAppliedToModelAndMigrationsHistory() + { + using var provider = BuildSqlServerProvider(schema: "spindle"); + using var context = provider.GetRequiredService(); + + AssertSchema(context, "spindle"); + } + + [Fact] + public void PostgreSqlSchema_IsAppliedToModelAndMigrationsHistory() + { + using var provider = new ServiceCollection() + .AddSpindlePostgreSql("Host=localhost;Database=spindle", schema: "spindle") + .BuildServiceProvider(); + using var context = provider.GetRequiredService(); + + AssertSchema(context, "spindle"); + } + + private static ServiceProvider BuildSqlServerProvider( + string? schema = null, + Action? configure = null) + { + return new ServiceCollection() + .AddSpindleSqlServer( + "Server=localhost;Database=spindle", + schema, + configure) + .BuildServiceProvider(); + } + + private static void AssertSchema(SpindleDbContext context, string schema) + { + Assert.Equal(schema, context.Model.GetDefaultSchema()); + + var tableEntityTypes = context.Model + .GetEntityTypes() + .Where(entityType => entityType.GetTableName() is not null) + .ToList(); + Assert.NotEmpty(tableEntityTypes); + Assert.All(tableEntityTypes, entityType => Assert.Equal(schema, entityType.GetSchema())); + + var relationalOptions = RelationalOptionsExtension.Extract( + context.GetService()); + Assert.Equal(schema, relationalOptions.MigrationsHistoryTableSchema); + } +} diff --git a/tests/Spindle.Persistence.EFCore.Tests/Spindle.Persistence.EFCore.Tests.csproj b/tests/Spindle.Persistence.EFCore.Tests/Spindle.Persistence.EFCore.Tests.csproj index c52518d..acd5be6 100644 --- a/tests/Spindle.Persistence.EFCore.Tests/Spindle.Persistence.EFCore.Tests.csproj +++ b/tests/Spindle.Persistence.EFCore.Tests/Spindle.Persistence.EFCore.Tests.csproj @@ -8,6 +8,19 @@ + + + + + + + + + + + + +