From 4cf70c2257368f9f0eb5895b687dd1c720f2a5d5 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sat, 18 Jul 2026 15:09:17 -0500 Subject: [PATCH] =?UTF-8?q?feat(efcore):=20follow-up=20round=20=E2=80=94?= =?UTF-8?q?=20sequences,=20check=20constraints,=20computed=20columns,=20in?= =?UTF-8?q?dex=20methods,=20drift=20detection,=20Oracle/MySql=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups to the EF Core schema-parity sweep (#361): - Sequences: SequenceBase.IncrementBy (HiLo block size), Migrator.CreateSequence seam (PG/SS/Oracle), GetSchemaObjectsForMigration maps model sequences (UseHiLo/UseSequence/HasSequence) ahead of tables; CreateMigrationAsync uses it - Check constraints: TableCheckConstraint in Weasel.Core, ITable.AddCheckConstraint, CREATE emission + catalog reads + conservative delta comparison on PG and SS (only declared checks compared; unknown constraints never dropped) - Computed columns: ITableColumn.ComputedExpression/ComputedColumnIsStored with emission on PG (STORED), SS (AS ... PERSISTED), MySQL, SQLite (mapped onto its existing generated-column model); Oracle throws rather than emitting wrong DDL - Index methods: ITableIndex.Method maps Npgsql HasMethod("gin") automatically - Opt-in column drift detection: ITable.DetectColumnDrift compares canonicalized defaults + nullability of matching columns and emits ALTER corrections on PG/SS; off by default (datetime literal canonicalization is not stable enough to be safe) - Oracle casing parity: preserve-case columns/PK/FK are quoted (folded convention unchanged), covered by DB-free DDL tests - MySQL: EF CI service added, EnsureDeleted hazard removed from its e2e tests - SQL Server FetchExisting reads column defaults/nullability; ItemDelta matching case-insensitive; EF docs updated (casing, sequences, drift, owned types) Co-Authored-By: Claude Fable 5 --- .github/workflows/ci-build-efcore.yml | 21 ++- docs/core/command-builders.md | 4 +- docs/core/multi-tenancy.md | 16 +-- docs/core/schema-migrations.md | 12 +- docs/core/schema-objects.md | 16 +-- docs/efcore/json-columns.md | 4 +- docs/efcore/migrations.md | 6 +- docs/efcore/table-mapping.md | 64 +++++++-- docs/mysql/tables.md | 2 +- docs/sqlserver/tables.md | 4 +- src/DocSamples/EfCoreSamples.cs | 12 ++ src/Weasel.Core.AotSmoke/Program.cs | 3 + src/Weasel.Core/ITableColumn.cs | 36 +++++ src/Weasel.Core/ITableIndex.cs | 9 ++ src/Weasel.Core/Migrator.cs | 8 ++ src/Weasel.Core/SequenceBase.cs | 7 + src/Weasel.Core/TableBase.cs | 21 +++ src/Weasel.Core/TableCheckConstraint.cs | 68 +++++++++ .../MySql/end_to_end.cs | 14 +- .../SchemaComparison/check_constraints.cs | 72 ++++++++-- .../Postgresql/SchemaComparison/indexes.cs | 11 +- .../Postgresql/SchemaComparison/sequences.cs | 64 +++++++++ .../PostgresqlSchemaIntrospector.cs | 29 +++- .../SchemaComparison/SchemaComparer.cs | 35 ++++- .../SchemaComparisonHarness.cs | 41 +++--- .../SchemaComparison/SchemaSnapshot.cs | 15 +- .../SqlServerSchemaIntrospector.cs | 29 +++- .../sqlserver_schema_comparison.cs | 129 ++++++++++++++++++ .../DbContextExtensions.cs | 80 ++++++++++- src/Weasel.MySql/Tables/IndexDefinition.cs | 20 +++ src/Weasel.MySql/Tables/TableColumn.cs | 13 ++ .../Tables/preserve_identifier_case_ddl.cs | 83 +++++++++++ src/Weasel.Oracle/OracleMigrator.cs | 5 + src/Weasel.Oracle/Sequence.cs | 2 +- src/Weasel.Oracle/Tables/ForeignKey.cs | 10 +- src/Weasel.Oracle/Tables/IndexDefinition.cs | 20 +++ src/Weasel.Oracle/Tables/Table.cs | 10 +- src/Weasel.Oracle/Tables/TableColumn.cs | 39 +++++- .../Tables/column_drift_detection.cs | 111 +++++++++++++++ src/Weasel.Postgresql/PostgresqlMigrator.cs | 5 + src/Weasel.Postgresql/Sequence.cs | 2 +- .../Tables/IndexDefinition.cs | 20 +++ .../Tables/Table.FetchExisting.cs | 26 +++- src/Weasel.Postgresql/Tables/Table.cs | 7 + src/Weasel.Postgresql/Tables/TableColumn.cs | 49 +++++++ src/Weasel.Postgresql/Tables/TableDelta.cs | 68 ++++++++- .../Tables/column_drift_detection.cs | 91 ++++++++++++ src/Weasel.SqlServer/Sequence.cs | 2 +- src/Weasel.SqlServer/SqlServerMigrator.cs | 5 + .../Tables/IndexDefinition.cs | 12 ++ src/Weasel.SqlServer/Tables/ItemDelta.cs | 5 +- .../Tables/Table.FetchExisting.cs | 31 ++++- src/Weasel.SqlServer/Tables/Table.cs | 11 +- src/Weasel.SqlServer/Tables/TableColumn.cs | 60 ++++++++ src/Weasel.SqlServer/Tables/TableDelta.cs | 58 +++++++- src/Weasel.Sqlite/Tables/IndexDefinition.cs | 12 ++ src/Weasel.Sqlite/Tables/TableColumn.cs | 16 +++ 57 files changed, 1500 insertions(+), 125 deletions(-) create mode 100644 src/Weasel.Core/TableCheckConstraint.cs create mode 100644 src/Weasel.EntityFrameworkCore.Tests/Postgresql/SchemaComparison/sequences.cs create mode 100644 src/Weasel.Oracle.Tests/Tables/preserve_identifier_case_ddl.cs create mode 100644 src/Weasel.Postgresql.Tests/Tables/column_drift_detection.cs create mode 100644 src/Weasel.SqlServer.Tests/Tables/column_drift_detection.cs diff --git a/.github/workflows/ci-build-efcore.yml b/.github/workflows/ci-build-efcore.yml index 0332ba49..5ecebb3a 100644 --- a/.github/workflows/ci-build-efcore.yml +++ b/.github/workflows/ci-build-efcore.yml @@ -43,6 +43,20 @@ jobs: env: ACCEPT_EULA: Y SA_PASSWORD: ${{ env.db_pwd }} + mysql: + image: mysql:8.0 + ports: + - 3306:3306 + env: + MYSQL_ROOT_PASSWORD: ${{ env.db_pwd }} + MYSQL_DATABASE: weasel_testing + MYSQL_USER: weasel + MYSQL_PASSWORD: ${{ env.db_pwd }} + options: >- + --health-cmd="mysqladmin ping -h localhost" + --health-interval=10s + --health-timeout=5s + --health-retries=5 steps: - uses: actions/checkout@v6 @@ -60,7 +74,8 @@ jobs: - name: Build run: dotnet build src/Weasel.EntityFrameworkCore.Tests/Weasel.EntityFrameworkCore.Tests.csproj --no-restore --framework ${{ matrix.framework }} - # MySql and Oracle EF providers have no database services in this workflow; - # their end-to-end suites only run locally against docker-compose + # The Oracle EF provider has no database service in this workflow; its + # end-to-end suite only runs locally against docker-compose. MySql tests + # exist on net9.0 only (the provider does not yet support net10.0). - name: Test - run: dotnet test src/Weasel.EntityFrameworkCore.Tests/Weasel.EntityFrameworkCore.Tests.csproj --no-build --verbosity normal --framework ${{ matrix.framework }} --filter "FullyQualifiedName!~MySql&FullyQualifiedName!~Oracle" + run: dotnet test src/Weasel.EntityFrameworkCore.Tests/Weasel.EntityFrameworkCore.Tests.csproj --no-build --verbosity normal --framework ${{ matrix.framework }} --filter "FullyQualifiedName!~Oracle" diff --git a/docs/core/command-builders.md b/docs/core/command-builders.md index 8c36747d..c9284de9 100644 --- a/docs/core/command-builders.md +++ b/docs/core/command-builders.md @@ -16,7 +16,7 @@ Each database provider has its own `ICommandBuilder` interface and `BatchBuilder The provider-specific `ICommandBuilder` interface (defined in both `Weasel.Postgresql` and `Weasel.SqlServer`) exposes methods for building SQL incrementally: - + ```cs public interface ICommandBuilder_Sample { @@ -31,7 +31,7 @@ public interface ICommandBuilder_Sample // ... additional members } ``` -snippet source | anchor +snippet source | anchor Key methods: diff --git a/docs/core/multi-tenancy.md b/docs/core/multi-tenancy.md index 28c6fb0a..3d3ea9e4 100644 --- a/docs/core/multi-tenancy.md +++ b/docs/core/multi-tenancy.md @@ -18,7 +18,7 @@ In a sharded multi-tenant architecture, each tenant's data lives in one of sever Manages the registry of databases and tenant assignments: - + ```cs public interface ITenantDatabasePool_Sample { @@ -30,7 +30,7 @@ public interface ITenantDatabasePool_Sample ValueTask RemoveTenantAsync(string tenantId, CancellationToken ct); } ``` -snippet source | anchor +snippet source | anchor Each `PooledDatabase` tracks a database's identifier, connection string, and whether it is full (accepting no more tenants). @@ -40,7 +40,7 @@ Each `PooledDatabase` tracks a database's identifier, connection string, and whe Determines which database a new tenant should be assigned to: - + ```cs public interface ITenantAssignmentStrategy_Sample { @@ -49,7 +49,7 @@ public interface ITenantAssignmentStrategy_Sample IReadOnlyList availableDatabases); } ``` -snippet source | anchor +snippet source | anchor Weasel ships with several built-in strategies: @@ -65,14 +65,14 @@ Weasel ships with several built-in strategies: Controls when a database is considered "full" and should stop accepting new tenants: - + ```cs public interface IDatabaseSizingStrategy_Sample { ValueTask FindSmallestDatabaseAsync(IReadOnlyList databases); } ``` -snippet source | anchor +snippet source | anchor ## IDatabase.TenantIds @@ -80,7 +80,7 @@ public interface IDatabaseSizingStrategy_Sample The `IDatabase` interface includes a `TenantIds` property that lists which tenants are assigned to that database instance: - + ```cs public interface IDatabase_TenantIds_Sample { @@ -88,7 +88,7 @@ public interface IDatabase_TenantIds_Sample // ... other members } ``` -snippet source | anchor +snippet source | anchor This is used by the migration infrastructure to apply schema changes to the correct databases when running in a multi-tenant configuration. diff --git a/docs/core/schema-migrations.md b/docs/core/schema-migrations.md index a085acd1..d12cd23d 100644 --- a/docs/core/schema-migrations.md +++ b/docs/core/schema-migrations.md @@ -23,7 +23,7 @@ flowchart TD `IDatabase` (in `Weasel.Core.Migrations`) is the central interface for managing a database's schema lifecycle: - + ```cs public interface IDatabase_Sample { @@ -48,7 +48,7 @@ public interface IDatabase_Sample string ToDatabaseScript(); } ``` -snippet source | anchor +snippet source | anchor Key methods: @@ -66,7 +66,7 @@ Key methods: An `IFeatureSchema` groups related schema objects together (for example, all the tables and indexes for a document storage feature): - + ```cs public interface IFeatureSchema_Sample { @@ -76,7 +76,7 @@ public interface IFeatureSchema_Sample Type StorageType { get; } } ``` -snippet source | anchor +snippet source | anchor Weasel processes features in the order returned by `BuildFeatureSchemas()`, so dependency relationships between features should be reflected by their position in the array. @@ -186,7 +186,7 @@ var script = database.ToDatabaseScript(); Implement `IMigrationLogger` to capture the SQL that Weasel generates: - + ```cs public interface IMigrationLogger_Sample { @@ -194,7 +194,7 @@ public interface IMigrationLogger_Sample void OnFailure(DbCommand command, Exception ex); } ``` -snippet source | anchor +snippet source | anchor The default logger writes SQL to `Console.WriteLine` and rethrows exceptions. diff --git a/docs/core/schema-objects.md b/docs/core/schema-objects.md index dc197c25..b6f83c68 100644 --- a/docs/core/schema-objects.md +++ b/docs/core/schema-objects.md @@ -113,7 +113,7 @@ The diagram above shows the core type system. `ISchemaObject` implementations (t Defined in `Weasel.Core`, the interface looks like this: - + ```cs public interface ISchemaObject_Sample { @@ -126,7 +126,7 @@ public interface ISchemaObject_Sample IEnumerable AllNames(); } ``` -snippet source | anchor +snippet source | anchor | Member | Purpose | @@ -143,7 +143,7 @@ public interface ISchemaObject_Sample When Weasel compares what you *configured* against what *actually exists* in the database, the result is an `ISchemaObjectDelta`: - + ```cs public interface ISchemaObjectDelta_Sample { @@ -154,7 +154,7 @@ public interface ISchemaObjectDelta_Sample void WriteRestorationOfPreviousState(Migrator rules, TextWriter writer); } ``` -snippet source | anchor +snippet source | anchor The `Difference` property tells you the outcome of the comparison using `SchemaPatchDifference`: @@ -183,7 +183,7 @@ Each database provider supplies its own concrete implementations of `ISchemaObje Weasel provides a generic base class `SchemaObjectDelta` that simplifies building deltas for a specific schema object type: - + ```cs public abstract class SchemaObjectDelta_Sample : ISchemaObjectDelta where T : ISchemaObject { @@ -199,7 +199,7 @@ public abstract class SchemaObjectDelta_Sample : ISchemaObjectDelta where T : public abstract void WriteRestorationOfPreviousState(Migrator rules, TextWriter writer); } ``` -snippet source | anchor +snippet source | anchor The constructor calls `compare()` to determine the `Difference` between the expected and actual objects. If `Actual` is null, the object does not exist yet and the difference is `Create`. @@ -209,14 +209,14 @@ The constructor calls `compare()` to determine the `Difference` between the expe Some schema objects need to examine other objects before they can finalize their own configuration. For example, PostgreSQL partitioned tables may need to adjust foreign key definitions based on the partition strategy of related tables. - + ```cs public interface ISchemaObjectWithPostProcessing_Sample : ISchemaObject { void PostProcess(ISchemaObject[] allObjects); } ``` -snippet source | anchor +snippet source | anchor The migration infrastructure calls `PostProcess()` after all objects have been loaded, passing the full array of schema objects so the implementing object can make any cross-object adjustments. diff --git a/docs/efcore/json-columns.md b/docs/efcore/json-columns.md index ff980f0e..b7909a23 100644 --- a/docs/efcore/json-columns.md +++ b/docs/efcore/json-columns.md @@ -41,7 +41,7 @@ public class OrderDbContext : DbContext } } ``` -snippet source | anchor +snippet source | anchor The resulting Weasel table will include three columns: @@ -80,5 +80,5 @@ if (migration.Migration.Difference != SchemaPatchDifference.None) await migration.ExecuteAsync(AutoCreate.CreateOrUpdate, ct); } ``` -snippet source | anchor +snippet source | anchor diff --git a/docs/efcore/migrations.md b/docs/efcore/migrations.md index 22d7bb9d..ad18db75 100644 --- a/docs/efcore/migrations.md +++ b/docs/efcore/migrations.md @@ -19,7 +19,7 @@ if (migration.Migration.Difference != SchemaPatchDifference.None) await migration.ExecuteAsync(AutoCreate.CreateOrUpdate, ct); } ``` -snippet source | anchor +snippet source | anchor The `DbContextMigration` record wraps three components: @@ -44,7 +44,7 @@ var database = serviceProvider.CreateDatabase(dbContext); // You can also provide a custom identifier: var customDatabase = serviceProvider.CreateDatabase(dbContext, "my-read-models"); ``` -snippet source | anchor +snippet source | anchor This is useful when composing multiple schema sources (e.g., Marten documents plus EF Core tables) into a single migration pipeline. @@ -64,7 +64,7 @@ services.AddSingleton(new PostgresqlMigrator()); // Later, resolve automatically var (connection, migrator) = serviceProvider.FindMigratorForDbContext(dbContext); ``` -snippet source | anchor +snippet source | anchor If no registered `Migrator` matches the connection type, an `InvalidOperationException` is thrown listing the available migrators. diff --git a/docs/efcore/table-mapping.md b/docs/efcore/table-mapping.md index c48b0430..e36b86b7 100644 --- a/docs/efcore/table-mapping.md +++ b/docs/efcore/table-mapping.md @@ -2,17 +2,26 @@ The `MapToTable()` extension method on `Migrator` converts an EF Core `IEntityType` into a Weasel `ITable`. This is the core of the EF Core integration -- it reads EF Core's metadata and produces a fully defined Weasel table object that participates in delta detection and DDL generation. +The guiding principle: the schema Weasel creates for a `DbContext` is the schema EF Core's own migration system would create, verified at the database-catalog level by a schema-parity test suite for PostgreSQL and SQL Server. + ## What Gets Mapped | EF Core Metadata | Weasel Table Property | |---|---| | Table name and schema | `ITable.Identifier` | -| Column name, type, nullability | `ITableColumn` with type, `AllowNulls` | -| Max length, column type annotations | Column type string | -| Default value SQL | `ITableColumn.DefaultExpression` | -| Primary key and constraint name | Primary key columns + `PrimaryKeyName` | -| Foreign keys with delete behavior | `ITable.AddForeignKey()` with `CascadeAction` | -| Indexes (unique, filtered, composite) | Index definitions | +| Column name, type, nullability | `ITableColumn` with type, `AllowNulls` (TPH-aware via `IsColumnNullable`) | +| Max length, precision, column type annotations | Column type string | +| `HasDefaultValueSql(...)` | `ITableColumn.DefaultExpression` | +| `HasDefaultValue(literal)` | `DefaultExpression` rendered with the provider's own SQL literal generator | +| Identity / serial value generation | `ITableColumn.IsAutoNumber` (`IDENTITY`, `GENERATED BY DEFAULT AS IDENTITY`, ...) | +| Computed columns (`HasComputedColumnSql`) | `ITableColumn.ComputedExpression` + `ComputedColumnIsStored` | +| Primary key and constraint name | Primary key columns + `PrimaryKeyName`, exact casing preserved | +| Foreign keys with delete behavior | `ITable.AddForeignKey()` with `CascadeAction` (`Client*` behaviors emit no `ON DELETE` clause, matching EF) | +| Indexes: `HasIndex` (unique, filtered, composite, INCLUDE), EF's conventional `IX_*` FK indexes | `ITable.AddIndex()` / `ITableIndex` | +| Npgsql `HasMethod("gin")` and friends | `ITableIndex.Method` | +| Alternate keys (`HasAlternateKey`) | Unique indexes | +| Check constraints (`ToTable(t => t.HasCheckConstraint(...))`) | `ITable.AddCheckConstraint()` | +| Model sequences (`HasSequence`, `UseHiLo`, `UseSequence`) | Weasel sequences via `GetSchemaObjectsForMigration` | | JSON columns via `OwnsOne().ToJson()` | Column with `jsonb` type (see [JSON Columns](./json-columns)) | ## Basic Usage @@ -32,25 +41,47 @@ foreach (var entityType in DbContextExtensions.GetEntityTypesForMigration(contex snippet source | anchor +To include the sequences declared on the model (from `UseHiLo`, `UseSequence`, or `HasSequence`) along with the tables, use `GetSchemaObjectsForMigration`. Sequences are returned first so column defaults that reference them (`NEXT VALUE FOR ...`) are valid when the tables are created: + + + +```cs +var migrator = new PostgresqlMigrator(); // or SqlServerMigrator +using var context = dbContext; + +// Sequences declared on the model (HasSequence, UseHiLo, UseSequence) +// followed by the mapped tables, in dependency order +var schemaObjects = DbContextExtensions.GetSchemaObjectsForMigration(context, migrator); +``` +snippet source | anchor + + +`CreateMigrationAsync()` uses `GetSchemaObjectsForMigration` internally, so HiLo and sequence-keyed models migrate correctly out of the box. Providers without sequences (MySQL, SQLite) simply contribute tables. + +## Identifier Casing + +EF Core migrations emit quoted, case-sensitive identifiers (`"BlogId"`, `PK_Blogs`, `IX_Posts_BlogId`). The mapper preserves that exact casing by setting `ITable.PreserveIdentifierCase`, and the case-folding providers (PostgreSQL, Oracle) quote identifiers in generated DDL so a Weasel-created schema is byte-for-byte usable by the EF Core runtime. Delta detection compares identifiers case-insensitively in both directions, so it makes no difference whether a schema was created by EF Core (quoted PascalCase) or by an older Weasel version (folded lowercase). + ## Entity Type Filtering `GetEntityTypesForMigration()` applies several filters before returning entity types: - **Excluded from migrations** -- Entity types marked with `ExcludeFromMigrations()` are skipped. - **No table name** -- Entity types without a mapped table (e.g., keyless query types) are skipped. -- **Owned types** -- Entity types configured via `OwnsOne()` or `OwnsMany()` are excluded since they do not have their own tables. Their JSON-mapped properties are handled separately (see [JSON Columns](./json-columns)). +- **Owned types sharing the owner's table** -- Entity types configured via `OwnsOne()` without `ToTable()` (table splitting) or with `.ToJson()` do not get their own table; their columns (`Nav_Prop` style) or JSON container column are folded into the owner's table definition instead. +- **Owned types with their own table** -- `OwnsOne(...).ToTable(...)` and `OwnsMany(...)` entity types *are* included: they map to real tables (with the PK-as-FK / composite-key shapes EF creates for them). ## TPH (Table Per Hierarchy) Handling -When multiple entity types in a TPH hierarchy share the same table, only the root entity type produces a Weasel table. However, columns from all derived types in the hierarchy are included in that table definition. This prevents duplicate table definitions while ensuring all columns (including discriminator-driven columns from derived types) are present. +When multiple entity types in a TPH hierarchy share the same table, only the root entity type produces a Weasel table. However, columns, foreign keys, indexes, and check constraints from all derived types in the hierarchy are included in that table definition. Required properties of derived types map to nullable columns, exactly as EF Core's relational model dictates. -For example, if `Animal` is the root and `Dog`/`Cat` are derived types sharing the `"animals"` table, `GetEntityTypesForMigration` returns only `Animal`, but the resulting `ITable` includes columns from `Dog` and `Cat` as well. +TPT hierarchies map each type to its own table, with the derived tables keyed by a primary key that is also a foreign key to the base table -- and no identity on the derived keys, since their values come from the base row. ## Foreign Key Dependencies and Topological Sorting Entity types are topologically sorted by foreign key relationships using Kahn's algorithm. This ensures that when DDL is generated, referenced tables are created before the tables that reference them. If a circular dependency is detected, the original order is preserved as a fallback. -Foreign keys from TPH derived types are also considered -- if a derived entity in a TPH hierarchy has a foreign key to another table, that dependency is attributed to the root entity type that owns the table. +Row-internal linking foreign keys -- an owned type sharing its owner's table and key -- are skipped, exactly as EF Core migrations skip them. ## Schema Resolution @@ -59,6 +90,15 @@ The table schema is resolved as follows: 1. If the entity type has an explicit schema via `.ToTable("name", "schema")`, that schema is used. 2. Otherwise, the `Migrator.DefaultSchemaName` is used (e.g., `public` for PostgreSQL, `dbo` for SQL Server). -## Constraint Name Normalization +## Column Drift Detection + +By default, Weasel's delta detection treats column defaults and nullability as write-once: they are applied when a table is created but changing them later does not produce a migration. The opt-in `ITable.DetectColumnDrift` flag adds default-expression and nullability comparison for otherwise-matching columns, emitting `ALTER COLUMN` corrections. + +The flag is deliberately **not** enabled by the EF Core mapper: default expressions are compared textually after canonicalization, and literal formats the database rewrites non-trivially (notably datetime literals) can produce perpetual false-positive migrations. Enable it per table -- e.g. through the mapped `ITable` instances -- when your defaults are simple numeric / string / boolean literals or stable function calls like `now()`. + +## Known Limitations -Primary key and foreign key constraint names are normalized to lowercase. This prevents spurious migration diffs when EF Core generates PascalCase names (e.g., `PK_items`) but the database stores them as lowercase (common with PostgreSQL's identifier folding). +- Per-column descending sort in indexes is not expressible through the provider-neutral seam; set the provider's `SortOrder` on the concrete `IndexDefinition` for whole-index descending. +- Npgsql identity-`ALWAYS` columns are created as `GENERATED BY DEFAULT` (more permissive; EF inserts behave identically). +- Alternate keys are created as unique **indexes** rather than unique **constraints** -- functionally equivalent, including as foreign key targets. +- Check constraints participate in delta detection conservatively: only the constraints the model declares are compared, and constraints Weasel doesn't know about are never dropped. diff --git a/docs/mysql/tables.md b/docs/mysql/tables.md index 5219933a..b6d3e166 100644 --- a/docs/mysql/tables.md +++ b/docs/mysql/tables.md @@ -9,7 +9,7 @@ The `Table` class in `Weasel.MySql.Tables` provides a fluent API for defining My ```cs var table = new Table("users"); -table.AddColumn("id").AsPrimaryKey().AutoNumber(); +table.AddColumn("id").AsPrimaryKey().AutoIncrement(); table.AddColumn("name").NotNull(); table.AddColumn("email").NotNull().AddIndex(idx => idx.IsUnique = true); table.AddColumn("created_at"); diff --git a/docs/sqlserver/tables.md b/docs/sqlserver/tables.md index ae23afea..b1c34d6a 100644 --- a/docs/sqlserver/tables.md +++ b/docs/sqlserver/tables.md @@ -9,7 +9,7 @@ The `Table` class in `Weasel.SqlServer.Tables` provides a fluent API for definin ```cs var table = new Table("dbo.users"); -table.AddColumn("id").AsPrimaryKey().AutoNumber(); +table.AddColumn("id").AsPrimaryKey().AutoIncrement(); table.AddColumn("name").NotNull(); table.AddColumn("email").NotNull().AddIndex(idx => idx.IsUnique = true); table.AddColumn("created_at").DefaultValueByExpression("GETUTCDATE()"); @@ -36,7 +36,7 @@ The `AddColumn` method returns a `ColumnExpression` with a fluent API: ```cs var orders = new Table("dbo.orders"); -orders.AddColumn("id").AsPrimaryKey().AutoNumber(); +orders.AddColumn("id").AsPrimaryKey().AutoIncrement(); orders.AddColumn("user_id").NotNull() .ForeignKeyTo("dbo.users", "id", onDelete: Weasel.SqlServer.CascadeAction.Cascade); orders.AddColumn("total").NotNull(); diff --git a/src/DocSamples/EfCoreSamples.cs b/src/DocSamples/EfCoreSamples.cs index 222f3d68..a184b9ba 100644 --- a/src/DocSamples/EfCoreSamples.cs +++ b/src/DocSamples/EfCoreSamples.cs @@ -65,6 +65,18 @@ public void efcore_table_mapping_basic() #endregion } + public void efcore_schema_objects_with_sequences() + { + #region sample_efcore_schema_objects_for_migration + var migrator = new PostgresqlMigrator(); // or SqlServerMigrator + using var context = dbContext; + + // Sequences declared on the model (HasSequence, UseHiLo, UseSequence) + // followed by the mapped tables, in dependency order + var schemaObjects = DbContextExtensions.GetSchemaObjectsForMigration(context, migrator); + #endregion + } + // === migrations.md samples === public async Task efcore_create_migration() diff --git a/src/Weasel.Core.AotSmoke/Program.cs b/src/Weasel.Core.AotSmoke/Program.cs index 0891b4de..a4739eac 100644 --- a/src/Weasel.Core.AotSmoke/Program.cs +++ b/src/Weasel.Core.AotSmoke/Program.cs @@ -171,6 +171,8 @@ internal sealed class SmokeColumn(string name, string type): ITableColumn public string Type { get; set; } = type; public bool IsPrimaryKey { get; set; } public bool IsAutoNumber { get; set; } + public string? ComputedExpression { get; set; } + public bool ComputedColumnIsStored { get; set; } } internal sealed class SmokeIndex(string name): ITableIndex @@ -180,6 +182,7 @@ internal sealed class SmokeIndex(string name): ITableIndex public bool IsUnique { get; set; } public string? Predicate { get; set; } public string[]? IncludeColumns { get; set; } + public string? Method { get; set; } } internal sealed class SmokeForeignKey(string name): ForeignKeyBase(name) diff --git a/src/Weasel.Core/ITableColumn.cs b/src/Weasel.Core/ITableColumn.cs index 9736e6ba..e8f72fa0 100644 --- a/src/Weasel.Core/ITableColumn.cs +++ b/src/Weasel.Core/ITableColumn.cs @@ -19,6 +19,20 @@ public interface ITableColumn : INamed /// SQLite. /// bool IsAutoNumber { get; set; } + + /// + /// When set, this is a computed / generated column defined by the given + /// SQL expression (without wrapping parentheses). Providers without + /// computed-column emission throw when generating DDL for it. + /// + string? ComputedExpression { get; set; } + + /// + /// Whether the computed column is physically stored (PERSISTED / + /// STORED) rather than evaluated on read. PostgreSQL only supports + /// stored generated columns. + /// + bool ComputedColumnIsStored { get; set; } } public interface ITable : ISchemaObject @@ -26,6 +40,18 @@ public interface ITable : ISchemaObject IReadOnlyList PrimaryKeyColumns { get; } string PrimaryKeyName { get; set; } + /// + /// Opt-in detection of column metadata drift: when true, delta detection + /// also compares column default expressions and nullability for columns + /// that otherwise match, emitting ALTER statements to correct drift. + /// Off by default because default expressions are compared textually + /// after canonicalization — literal formats that the database rewrites + /// non-trivially (notably datetime literals) can produce perpetual + /// false-positive migrations. Enable it for tables whose defaults are + /// simple numeric / string / boolean literals or stable function calls. + /// + bool DetectColumnDrift { get; set; } + /// /// When true, column names added to this table keep their exact casing /// instead of being folded to the provider's conventional casing @@ -64,4 +90,14 @@ public interface ITable : ISchemaObject /// Add an index over the given columns to this table /// ITableIndex AddIndex(string name, string[] columnNames, bool isUnique = false); + + /// + /// The named table-level CHECK constraints declared for this table + /// + IReadOnlyList CheckConstraints { get; } + + /// + /// Declare a named table-level CHECK constraint on this table + /// + TableCheckConstraint AddCheckConstraint(string name, string expression); } diff --git a/src/Weasel.Core/ITableIndex.cs b/src/Weasel.Core/ITableIndex.cs index 5374b342..b6a84607 100644 --- a/src/Weasel.Core/ITableIndex.cs +++ b/src/Weasel.Core/ITableIndex.cs @@ -25,4 +25,13 @@ public interface ITableIndex : INamed /// support throw from the setter. /// string[]? IncludeColumns { get; set; } + + /// + /// Optional index access method, e.g. PostgreSQL's gin/gist/hash/brin + /// (CREATE INDEX ... USING method) or MySQL's btree/hash. Null means the + /// provider default (btree). Providers without pluggable index methods + /// throw when a non-null method is + /// assigned. + /// + string? Method { get; set; } } diff --git a/src/Weasel.Core/Migrator.cs b/src/Weasel.Core/Migrator.cs index d9a2cf75..122ef1d9 100644 --- a/src/Weasel.Core/Migrator.cs +++ b/src/Weasel.Core/Migrator.cs @@ -44,6 +44,14 @@ public virtual IDatabaseWithTables CreateDatabase(DbDataSource dataSource, strin public abstract ITable CreateTable(DbObjectName identifier); + /// + /// Create an empty, provider-specific sequence definition for the given + /// identifier, or null when the database engine has no sequence support + /// (MySQL, SQLite). Callers building migrations from an external model + /// (e.g. EF Core's HiLo / HasSequence) use this to include sequences. + /// + public virtual SequenceBase? CreateSequence(DbObjectName identifier) => null; + /// /// Should all generated DDL files be written with transactional semantics /// so that everything succeeds or everything fails together diff --git a/src/Weasel.Core/SequenceBase.cs b/src/Weasel.Core/SequenceBase.cs index f547a4e0..ed04b342 100644 --- a/src/Weasel.Core/SequenceBase.cs +++ b/src/Weasel.Core/SequenceBase.cs @@ -23,6 +23,13 @@ protected SequenceBase(DbObjectName identifier, long startWith) : base(identifie /// public long? StartWith { get; set; } + /// + /// Optional increment for the sequence. When null, the provider's default is used + /// (typically 1). EF Core's HiLo value generation depends on this being the + /// configured block size (10 by default). + /// + public long? IncrementBy { get; set; } + /// /// Optional table that "owns" this sequence (PostgreSQL's /// ALTER SEQUENCE … OWNED BY tbl.col). On providers that do not support sequence diff --git a/src/Weasel.Core/TableBase.cs b/src/Weasel.Core/TableBase.cs index 01df8be7..7650559b 100644 --- a/src/Weasel.Core/TableBase.cs +++ b/src/Weasel.Core/TableBase.cs @@ -75,6 +75,14 @@ protected TableBase(DbObjectName identifier) : base(identifier) public IList ForeignKeys { get; } = new List(); public IList Indexes { get; } = new List(); + /// + /// Named table-level CHECK constraints. Emitted in CREATE TABLE and + /// compared during delta detection by the providers that support it + /// (PostgreSQL, SQL Server); see for + /// the conservative comparison semantics. + /// + public IList CheckConstraints { get; } = new List(); + /// /// Names of indexes that this table intentionally ignores during delta /// comparison — useful when a third party (e.g. pg_partman on @@ -114,6 +122,9 @@ public string PrimaryKeyName /// public bool PreserveIdentifierCase { get; set; } + /// + public bool DetectColumnDrift { get; set; } + /// /// Provider-specific default for the auto-generated primary-key /// constraint name. PG / SS use pkey_{name}_{cols}, Oracle / @@ -219,6 +230,16 @@ ITableColumn ITable.AddPrimaryKeyColumn(string name, Type dotnetType) IReadOnlyList ITable.Indexes => Indexes.OfType().ToList(); + IReadOnlyList ITable.CheckConstraints + => CheckConstraints.ToList(); + + TableCheckConstraint ITable.AddCheckConstraint(string name, string expression) + { + var constraint = new TableCheckConstraint(name, expression); + CheckConstraints.Add(constraint); + return constraint; + } + ITableIndex ITable.AddIndex(string name, string[] columnNames, bool isUnique) { var index = CreateIndexFor(name, columnNames); diff --git a/src/Weasel.Core/TableCheckConstraint.cs b/src/Weasel.Core/TableCheckConstraint.cs new file mode 100644 index 00000000..f3c259a8 --- /dev/null +++ b/src/Weasel.Core/TableCheckConstraint.cs @@ -0,0 +1,68 @@ +using System.Text.RegularExpressions; + +namespace Weasel.Core; + +/// +/// A named table-level CHECK constraint. Weasel compares check constraints +/// conservatively: only tables that declare at least one expected check +/// participate in check-constraint delta detection, and actual constraints +/// unknown to the expected table are never dropped (inline column checks +/// and third-party constraints stay untouched). +/// +public class TableCheckConstraint: INamed +{ + public TableCheckConstraint(string name, string expression) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentOutOfRangeException(nameof(name)); + } + + if (string.IsNullOrWhiteSpace(expression)) + { + throw new ArgumentOutOfRangeException(nameof(expression)); + } + + Name = name; + Expression = expression; + } + + public string Name { get; set; } + + /// The boolean SQL expression, without the CHECK (...) wrapper + public string Expression { get; set; } + + /// + /// Normalize a check expression for comparison against the database + /// catalog's canonicalized rendering: strips CHECK wrappers, quoting, + /// parens, ::casts and whitespace differences, then lowercases. + /// + public static string Canonicalize(string expression) + { + var normalized = expression.Trim(); + + if (normalized.StartsWith("CHECK", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized[5..].Trim(); + } + + // strip ::type casts (PostgreSQL canonical output) + normalized = Regex.Replace(normalized, "::\"?[a-zA-Z_][a-zA-Z_ 0-9]*\"?(\\([0-9, ]*\\))?(\\[\\])?", ""); + // strip identifier quoting, grouping noise, and all whitespace — the + // canonical form is only ever compared for equality, and the catalogs + // rewrite operator spacing freely ("[Price] > 0" vs "([Price]>(0))") + normalized = normalized + .Replace("\"", "") + .Replace("[", "") + .Replace("]", "") + .Replace("(", "") + .Replace(")", ""); + normalized = Regex.Replace(normalized, @"\s+", ""); + + return normalized.ToLowerInvariant(); + } + + public bool Matches(TableCheckConstraint other) + => Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) + && Canonicalize(Expression) == Canonicalize(other.Expression); +} diff --git a/src/Weasel.EntityFrameworkCore.Tests/MySql/end_to_end.cs b/src/Weasel.EntityFrameworkCore.Tests/MySql/end_to_end.cs index 7906aba1..76e238a4 100644 --- a/src/Weasel.EntityFrameworkCore.Tests/MySql/end_to_end.cs +++ b/src/Weasel.EntityFrameworkCore.Tests/MySql/end_to_end.cs @@ -84,9 +84,13 @@ public async Task can_create_table_and_verify_schema() using var scope = _host.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); - // Ensure database is created and schema is applied - await context.Database.EnsureDeletedAsync(); + // Ensure the database exists, then recreate just this test's table. + // Never EnsureDeleted here — that drops the whole weasel_testing + // database out from under any concurrently-running tests (and the CI + // MySQL user only has rights on the pre-created database). await context.Database.EnsureCreatedAsync(); + await context.Database.ExecuteSqlRawAsync("DROP TABLE IF EXISTS my_entities"); + await context.Database.ExecuteSqlRawAsync(context.Database.GenerateCreateScript()); // Verify table exists by inserting and reading data var entity = new MyEntity @@ -130,12 +134,8 @@ public async Task can_create_migration_and_apply() using var scope = _host.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); - // Ensure database exists then delete tables for a clean schema state - await context.Database.EnsureCreatedAsync(); - await context.Database.EnsureDeletedAsync(); + // Ensure the database exists, then drop the table to simulate needing a migration await context.Database.EnsureCreatedAsync(); - - // Drop the table to simulate needing a migration await context.Database.ExecuteSqlRawAsync("DROP TABLE IF EXISTS my_entities"); // Use Weasel to create migration diff --git a/src/Weasel.EntityFrameworkCore.Tests/Postgresql/SchemaComparison/check_constraints.cs b/src/Weasel.EntityFrameworkCore.Tests/Postgresql/SchemaComparison/check_constraints.cs index a1ebc497..ad559411 100644 --- a/src/Weasel.EntityFrameworkCore.Tests/Postgresql/SchemaComparison/check_constraints.cs +++ b/src/Weasel.EntityFrameworkCore.Tests/Postgresql/SchemaComparison/check_constraints.cs @@ -7,37 +7,56 @@ namespace Weasel.EntityFrameworkCore.Tests.Postgresql.SchemaComparison; /// -/// KNOWN GAP, documented by this test: EF Core check constraints -/// (ToTable(t => t.HasCheckConstraint(...))) are not modeled by Weasel — -/// ITable has no check-constraint surface and neither provider's -/// FetchExisting reads them. The Weasel-created schema therefore lacks -/// them (tolerated MissingCheckConstraint), while Weasel's delta remains -/// None because check constraints are invisible to its comparison. +/// EF Core check constraints (ToTable(t => t.HasCheckConstraint(...))) +/// map into Weasel table definitions, are emitted in CREATE TABLE, and +/// participate in delta detection — conservatively: only declared checks +/// are compared, and unknown constraints in the database are never dropped. /// public class check_constraints { public const string SchemaName = "efcmp_checks"; [Fact] - public async Task check_constraints_are_a_documented_gap() + public async Task weasel_schema_matches_ef_schema() { await using var context = new CheckConstraintDbContext(); var result = await SchemaComparisonHarness.RunPostgresqlAsync(context, SchemaName); - result.AssertParity(DifferenceCategory.MissingCheckConstraint); + result.AssertParity(); - // the EF side really has it... result.EfSchema.TableFor("PricedItems")!.CheckConstraints .ShouldContain(c => c.Name == "CK_PricedItems_Price"); - // ...and the Weasel side really lacks it - result.WeaselSchema.TableFor("PricedItems")!.CheckConstraints.ShouldBeEmpty(); + result.WeaselSchema.TableFor("PricedItems")!.CheckConstraints + .ShouldContain(c => c.Name == "CK_PricedItems_Price"); - // Weasel cannot see check constraints, so its delta stays None either way result.DeltaAgainstEfSchema.ShouldBe(SchemaPatchDifference.None); } } +/// +/// Computed / generated columns: HasComputedColumnSql maps into Weasel and +/// is emitted as GENERATED ALWAYS AS (...) STORED on PostgreSQL (the only +/// kind PostgreSQL supports). +/// +public class computed_columns +{ + public const string SchemaName = "efcmp_computed"; + + [Fact] + public async Task weasel_schema_matches_ef_schema() + { + await using var context = new ComputedColumnDbContext(); + + var result = await SchemaComparisonHarness.RunPostgresqlAsync(context, SchemaName); + + result.AssertParity(); + + result.EfSchema.TableFor("People")!.ColumnFor("FullName")!.IsComputed.ShouldBeTrue(); + result.WeaselSchema.TableFor("People")!.ColumnFor("FullName")!.IsComputed.ShouldBeTrue(); + } +} + public class PricedItem { public int Id { get; set; } @@ -61,3 +80,32 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) }); } } + +public class ComputedPerson +{ + public int Id { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; +} + +public class ComputedColumnDbContext : DbContext +{ + public DbSet People => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(PostgresqlDbContext.ConnectionString); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(computed_columns.SchemaName); + + modelBuilder.Entity(entity => + { + entity.ToTable("People"); + // PostgreSQL only supports stored generated columns + entity.Property(e => e.FullName) + .HasComputedColumnSql("\"FirstName\" || ' ' || \"LastName\"", stored: true); + }); + } +} diff --git a/src/Weasel.EntityFrameworkCore.Tests/Postgresql/SchemaComparison/indexes.cs b/src/Weasel.EntityFrameworkCore.Tests/Postgresql/SchemaComparison/indexes.cs index 977448f8..10f396d8 100644 --- a/src/Weasel.EntityFrameworkCore.Tests/Postgresql/SchemaComparison/indexes.cs +++ b/src/Weasel.EntityFrameworkCore.Tests/Postgresql/SchemaComparison/indexes.cs @@ -40,13 +40,13 @@ public async Task weasel_schema_matches_ef_schema() } /// - /// Provider-specific index features (Npgsql HasMethod, descending sort) - /// are not expressible through the provider-neutral ITableIndex seam. - /// The harness's customizeTables hook is the supported escape hatch: - /// downcast to the concrete provider table and enrich the definition. + /// Npgsql HasMethod("gin") maps automatically through ITableIndex.Method. + /// Descending sort remains provider-specific; the harness's + /// customizeTables hook is the supported escape hatch — downcast to the + /// concrete provider table and enrich the definition. /// [Fact] - public async Task gin_and_descending_indexes_via_customize_escape_hatch() + public async Task gin_method_maps_automatically_descending_via_escape_hatch() { await using var context = new ProviderIndexesDbContext(); @@ -54,7 +54,6 @@ public async Task gin_and_descending_indexes_via_customize_escape_hatch() tables => { var docs = tables.OfType().Single(t => t.Identifier.Name == "TaggedDocs"); - docs.IndexFor("IX_TaggedDocs_Tags")!.Method = IndexMethod.gin; docs.IndexFor("IX_TaggedDocs_Rank")!.SortOrder = SortOrder.Desc; }); diff --git a/src/Weasel.EntityFrameworkCore.Tests/Postgresql/SchemaComparison/sequences.cs b/src/Weasel.EntityFrameworkCore.Tests/Postgresql/SchemaComparison/sequences.cs new file mode 100644 index 00000000..e6e88ddb --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/Postgresql/SchemaComparison/sequences.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Weasel.EntityFrameworkCore.Tests.SchemaComparison; +using Xunit; + +namespace Weasel.EntityFrameworkCore.Tests.Postgresql.SchemaComparison; + +/// +/// EF Core model sequences: UseHiLo declares a sequence (increment = block +/// size, 10 by default) and leaves the key column plain — no identity, no +/// default. Weasel must create the sequence with the same increment or +/// HiLo key generation would hand out colliding blocks. +/// +public class sequences +{ + public const string SchemaName = "efcmp_sequences"; + + [Fact] + public async Task weasel_schema_matches_ef_schema() + { + await using var context = new HiLoDbContext(); + + var result = await SchemaComparisonHarness.RunPostgresqlAsync(context, SchemaName); + + result.AssertParity(); + + var efSequence = result.EfSchema.SequenceFor("cmp_hilo")!; + efSequence.IncrementBy.ShouldBe(10); + + var weaselSequence = result.WeaselSchema.SequenceFor("cmp_hilo")!; + weaselSequence.IncrementBy.ShouldBe(10); + weaselSequence.StartValue.ShouldBe(efSequence.StartValue); + + // HiLo keys are client-generated from the sequence: plain column + var id = result.WeaselSchema.TableFor("HiLoItems")!.ColumnFor("Id")!; + id.IsIdentity.ShouldBeFalse(); + id.DefaultExpression.ShouldBeNull(); + } +} + +public class HiLoItem +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; +} + +public class HiLoDbContext : DbContext +{ + public DbSet Items => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(PostgresqlDbContext.ConnectionString); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(sequences.SchemaName); + + modelBuilder.Entity(entity => + { + entity.ToTable("HiLoItems"); + NpgsqlPropertyBuilderExtensions.UseHiLo(entity.Property(e => e.Id), "cmp_hilo"); + }); + } +} diff --git a/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/PostgresqlSchemaIntrospector.cs b/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/PostgresqlSchemaIntrospector.cs index edc2872f..4bf6241f 100644 --- a/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/PostgresqlSchemaIntrospector.cs +++ b/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/PostgresqlSchemaIntrospector.cs @@ -17,6 +17,7 @@ public static async Task SnapshotAsync(NpgsqlConnection conn, st var foreignKeys = await readForeignKeysAsync(conn, schemaName); var indexes = await readIndexesAsync(conn, schemaName); var checks = await readCheckConstraintsAsync(conn, schemaName); + var sequences = await readSequencesAsync(conn, schemaName); var tableNames = columns.Keys .Union(keyConstraints.Keys) @@ -43,7 +44,33 @@ public static async Task SnapshotAsync(NpgsqlConnection conn, st }; }).ToList(); - return new SchemaSnapshot(schemaName, tables); + return new SchemaSnapshot(schemaName, tables, sequences); + } + + private static async Task> readSequencesAsync(NpgsqlConnection conn, string schemaName) + { + const string sql = """ + select sequence_name, start_value::bigint, increment::bigint + from information_schema.sequences + where sequence_schema = :schema + """; + + var results = new List(); + + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("schema", schemaName); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + results.Add(new SequenceSnapshot + { + Name = reader.GetString(0), + StartValue = reader.GetInt64(1), + IncrementBy = reader.GetInt64(2) + }); + } + + return results; } private static async Task>> readColumnsAsync( diff --git a/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SchemaComparer.cs b/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SchemaComparer.cs index e5129b3a..8f9959fd 100644 --- a/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SchemaComparer.cs +++ b/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SchemaComparer.cs @@ -36,7 +36,10 @@ public enum DifferenceCategory UniqueConstraintVsUniqueIndex, MissingCheckConstraint, ExtraCheckConstraint, - CheckConstraintExpression + CheckConstraintExpression, + MissingSequence, + ExtraSequence, + SequenceDefinition } public record SchemaDifference(DifferenceCategory Category, string Table, string Detail) @@ -78,9 +81,39 @@ public static IReadOnlyList Compare(SchemaSnapshot efSchema, S } } + compareSequences(efSchema, weaselSchema, differences); + return differences; } + private static void compareSequences(SchemaSnapshot ef, SchemaSnapshot weasel, List differences) + { + foreach (var efSequence in ef.Sequences) + { + var weaselSequence = weasel.SequenceFor(efSequence.Name); + if (weaselSequence == null) + { + differences.Add(new(DifferenceCategory.MissingSequence, efSequence.Name, + $"sequence (start {efSequence.StartValue}, increment {efSequence.IncrementBy}) missing from Weasel-created schema")); + } + else if (efSequence.StartValue != weaselSequence.StartValue + || efSequence.IncrementBy != weaselSequence.IncrementBy) + { + differences.Add(new(DifferenceCategory.SequenceDefinition, efSequence.Name, + $"EF start {efSequence.StartValue} / increment {efSequence.IncrementBy}, Weasel start {weaselSequence.StartValue} / increment {weaselSequence.IncrementBy}")); + } + } + + foreach (var weaselSequence in weasel.Sequences) + { + if (ef.SequenceFor(weaselSequence.Name) == null) + { + differences.Add(new(DifferenceCategory.ExtraSequence, weaselSequence.Name, + "sequence created by Weasel does not exist in the EF Core-created schema")); + } + } + } + private static void compareTables(TableSnapshot ef, TableSnapshot weasel, List differences) { compareColumns(ef, weasel, differences); diff --git a/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SchemaComparisonHarness.cs b/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SchemaComparisonHarness.cs index e1099212..2b6879c3 100644 --- a/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SchemaComparisonHarness.cs +++ b/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SchemaComparisonHarness.cs @@ -100,15 +100,12 @@ public static async Task RunPostgresqlAsync( var connectionString = context.Database.GetConnectionString() ?? throw new InvalidOperationException("DbContext has no connection string"); - var tables = DbContextExtensions.GetEntityTypesForMigration(context) - .Select(migrator.MapToTable) - .ToArray(); + var schemaObjects = DbContextExtensions.GetSchemaObjectsForMigration(context, migrator).ToArray(); + var tables = schemaObjects.OfType().ToArray(); - guardSchemaIsolation(tables, schemaName); + guardSchemaIsolation(schemaObjects, schemaName); customizeTables?.Invoke(tables); - var schemaObjects = tables.OfType().ToArray(); - await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(); @@ -168,15 +165,12 @@ public static async Task RunSqlServerAsync( var connectionString = context.Database.GetConnectionString() ?? throw new InvalidOperationException("DbContext has no connection string"); - var tables = DbContextExtensions.GetEntityTypesForMigration(context) - .Select(migrator.MapToTable) - .ToArray(); + var schemaObjects = DbContextExtensions.GetSchemaObjectsForMigration(context, migrator).ToArray(); + var tables = schemaObjects.OfType().ToArray(); - guardSchemaIsolation(tables, schemaName); + guardSchemaIsolation(schemaObjects, schemaName); customizeTables?.Invoke(tables); - var schemaObjects = tables.OfType().ToArray(); - await SqlServer.SqlServerDatabaseBootstrap.EnsureDatabaseExistsAsync(connectionString); await using var conn = new SqlConnection(connectionString); @@ -255,6 +249,13 @@ await executeSqlServerAsync(conn, $""" from sys.tables where SCHEMA_NAME(schema_id) = '{schemaName}'; exec sp_executesql @sql; """); + + await executeSqlServerAsync(conn, $""" + declare @sql nvarchar(max) = N''; + select @sql += N'DROP SEQUENCE ' + QUOTENAME('{schemaName}') + N'.' + QUOTENAME(name) + N';' + from sys.sequences where SCHEMA_NAME(schema_id) = '{schemaName}'; + exec sp_executesql @sql; + """); } private static IEnumerable splitSqlServerBatches(string script) @@ -269,21 +270,21 @@ private static async Task executeSqlServerAsync(SqlConnection conn, string sql) } /// - /// Every mapped table must live in the dedicated test schema — this - /// harness drops that schema with CASCADE, and must never be able to - /// touch shared tables in other schemas. + /// Every mapped schema object must live in the dedicated test schema — + /// this harness drops that schema with CASCADE, and must never be able + /// to touch shared objects in other schemas. /// - private static void guardSchemaIsolation(ITable[] tables, string schemaName) + private static void guardSchemaIsolation(ISchemaObject[] schemaObjects, string schemaName) { - var strays = tables - .Where(t => !t.Identifier.Schema.Equals(schemaName, StringComparison.OrdinalIgnoreCase)) - .Select(t => t.Identifier.QualifiedName) + var strays = schemaObjects + .Where(o => !o.Identifier.Schema.Equals(schemaName, StringComparison.OrdinalIgnoreCase)) + .Select(o => o.Identifier.QualifiedName) .ToList(); if (strays.Any()) { throw new InvalidOperationException( - $"All entity types must be mapped into the dedicated schema '{schemaName}' " + + $"All entity types and sequences must be mapped into the dedicated schema '{schemaName}' " + $"(use modelBuilder.HasDefaultSchema(...)), but found: {string.Join(", ", strays)}"); } } diff --git a/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SchemaSnapshot.cs b/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SchemaSnapshot.cs index 15f46493..288d4777 100644 --- a/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SchemaSnapshot.cs +++ b/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SchemaSnapshot.cs @@ -8,17 +8,30 @@ namespace Weasel.EntityFrameworkCore.Tests.SchemaComparison; /// public class SchemaSnapshot { - public SchemaSnapshot(string schemaName, IReadOnlyList tables) + public SchemaSnapshot(string schemaName, IReadOnlyList tables, + IReadOnlyList? sequences = null) { SchemaName = schemaName; Tables = tables; + Sequences = sequences ?? []; } public string SchemaName { get; } public IReadOnlyList Tables { get; } + public IReadOnlyList Sequences { get; } public TableSnapshot? TableFor(string tableName) => Tables.FirstOrDefault(t => t.Name.Equals(tableName, StringComparison.OrdinalIgnoreCase)); + + public SequenceSnapshot? SequenceFor(string sequenceName) + => Sequences.FirstOrDefault(s => s.Name.Equals(sequenceName, StringComparison.OrdinalIgnoreCase)); +} + +public class SequenceSnapshot +{ + public required string Name { get; init; } + public long StartValue { get; init; } + public long IncrementBy { get; init; } } public class TableSnapshot diff --git a/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SqlServerSchemaIntrospector.cs b/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SqlServerSchemaIntrospector.cs index 06596cd9..13a7c756 100644 --- a/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SqlServerSchemaIntrospector.cs +++ b/src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/SqlServerSchemaIntrospector.cs @@ -15,6 +15,7 @@ public static async Task SnapshotAsync(SqlConnection conn, strin var indexes = await readIndexesAsync(conn, schemaName); var foreignKeys = await readForeignKeysAsync(conn, schemaName); var checks = await readCheckConstraintsAsync(conn, schemaName); + var sequences = await readSequencesAsync(conn, schemaName); var tableNames = columns.Keys.OrderBy(x => x).ToList(); @@ -35,7 +36,33 @@ public static async Task SnapshotAsync(SqlConnection conn, strin }; }).ToList(); - return new SchemaSnapshot(schemaName, tables); + return new SchemaSnapshot(schemaName, tables, sequences); + } + + private static async Task> readSequencesAsync(SqlConnection conn, string schemaName) + { + const string sql = """ + select name, cast(start_value as bigint), cast(increment as bigint) + from sys.sequences + where schema_id = SCHEMA_ID(@schema) + """; + + var results = new List(); + + await using var cmd = new SqlCommand(sql, conn); + cmd.Parameters.AddWithValue("schema", schemaName); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + results.Add(new SequenceSnapshot + { + Name = reader.GetString(0), + StartValue = reader.GetInt64(1), + IncrementBy = reader.GetInt64(2) + }); + } + + return results; } private static async Task>> readColumnsAsync( diff --git a/src/Weasel.EntityFrameworkCore.Tests/SqlServer/SchemaComparison/sqlserver_schema_comparison.cs b/src/Weasel.EntityFrameworkCore.Tests/SqlServer/SchemaComparison/sqlserver_schema_comparison.cs index 6dd0f682..76c409ea 100644 --- a/src/Weasel.EntityFrameworkCore.Tests/SqlServer/SchemaComparison/sqlserver_schema_comparison.cs +++ b/src/Weasel.EntityFrameworkCore.Tests/SqlServer/SchemaComparison/sqlserver_schema_comparison.cs @@ -150,8 +150,137 @@ private static string actionFor(TableSnapshot table, string column) => table.ForeignKeys.Single(fk => fk.Columns.SequenceEqual([column])).OnDelete; } +/// +/// SQL Server sequence strategies: UseHiLo (sequence + plain client-driven +/// key column) and UseSequence (sequence + NEXT VALUE FOR column default). +/// +[Collection("sqlserver-schema-comparison")] +public class sqlserver_sequences +{ + public const string SchemaName = "efcmp_sequences"; + + [Fact] + public async Task weasel_schema_matches_ef_schema() + { + await using var context = new SqlSequencesDbContext(); + + var result = await SchemaComparisonHarness.RunSqlServerAsync(context, SchemaName); + + result.AssertParity(); + + result.WeaselSchema.SequenceFor("cmp_hilo")!.IncrementBy.ShouldBe(10); + + // HiLo: plain column, client-generated values + var hiloId = result.WeaselSchema.TableFor("CmpHiLoItems")!.ColumnFor("Id")!; + hiloId.IsIdentity.ShouldBeFalse(); + hiloId.DefaultExpression.ShouldBeNull(); + + // UseSequence: NEXT VALUE FOR default on the column + var seqId = result.WeaselSchema.TableFor("CmpSeqItems")!.ColumnFor("Id")!; + seqId.IsIdentity.ShouldBeFalse(); + seqId.DefaultExpression.ShouldNotBeNull(); + seqId.DefaultExpression!.ToUpperInvariant().ShouldContain("NEXT VALUE FOR"); + } +} + +/// +/// SQL Server check constraints and computed columns (virtual AS (...) and +/// PERSISTED variants). +/// +[Collection("sqlserver-schema-comparison")] +public class sqlserver_checks_and_computed +{ + public const string SchemaName = "efcmp_checkcomp"; + + [Fact] + public async Task weasel_schema_matches_ef_schema() + { + await using var context = new SqlChecksComputedDbContext(); + + var result = await SchemaComparisonHarness.RunSqlServerAsync(context, SchemaName); + + result.AssertParity(); + + result.WeaselSchema.TableFor("CmpChecked")!.CheckConstraints + .ShouldContain(c => c.Name == "CK_CmpChecked_Price"); + result.WeaselSchema.TableFor("CmpChecked")!.ColumnFor("FullName")!.IsComputed.ShouldBeTrue(); + result.WeaselSchema.TableFor("CmpChecked")!.ColumnFor("PersistedTotal")!.IsComputed.ShouldBeTrue(); + } +} + // ---- entities & contexts -------------------------------------------------- +public class CmpCheckedItem +{ + public int Id { get; set; } + public decimal Price { get; set; } + public int Quantity { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public decimal PersistedTotal { get; set; } +} + +public class SqlChecksComputedDbContext : DbContext +{ + public DbSet Checked => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseSqlServer(SqlServerDbContext.ConnectionString); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(sqlserver_checks_and_computed.SchemaName); + + modelBuilder.Entity(entity => + { + entity.ToTable("CmpChecked", t => t.HasCheckConstraint("CK_CmpChecked_Price", "[Price] > 0")); + entity.Property(e => e.FullName) + .HasComputedColumnSql("[FirstName] + ' ' + [LastName]"); + entity.Property(e => e.PersistedTotal) + .HasComputedColumnSql("[Price] * [Quantity]", stored: true); + }); + } +} + +public class CmpHiLoItem +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; +} + +public class CmpSeqItem +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; +} + +public class SqlSequencesDbContext : DbContext +{ + public DbSet HiLoItems => Set(); + public DbSet SeqItems => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseSqlServer(SqlServerDbContext.ConnectionString); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(sqlserver_sequences.SchemaName); + + modelBuilder.Entity(entity => + { + entity.ToTable("CmpHiLoItems"); + SqlServerPropertyBuilderExtensions.UseHiLo(entity.Property(e => e.Id), "cmp_hilo"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("CmpSeqItems"); + SqlServerPropertyBuilderExtensions.UseSequence(entity.Property(e => e.Id), "cmp_item_seq"); + }); + } +} + public class CmpBlog { public int Id { get; set; } diff --git a/src/Weasel.EntityFrameworkCore/DbContextExtensions.cs b/src/Weasel.EntityFrameworkCore/DbContextExtensions.cs index 50998e4d..004d1137 100644 --- a/src/Weasel.EntityFrameworkCore/DbContextExtensions.cs +++ b/src/Weasel.EntityFrameworkCore/DbContextExtensions.cs @@ -92,16 +92,13 @@ public static async Task CreateMigrationAsync( var (originalConn, migrator) = services.FindMigratorForDbContext(context); var conn = GetConnectionWithCredentials(context, originalConn); - var tables = GetEntityTypesForMigration(context) - .Select(x => migrator!.MapToTable(x)) - .OfType() - .ToArray(); + var schemaObjects = GetSchemaObjectsForMigration(context, migrator!).ToArray(); await conn.OpenAsync(cancellation).ConfigureAwait(false); try { - var migration = await SchemaMigration.DetermineAsync(conn, cancellation, tables).ConfigureAwait(false); + var migration = await SchemaMigration.DetermineAsync(conn, cancellation, schemaObjects).ConfigureAwait(false); return new DbContextMigration(conn, migrator, migration); } finally @@ -110,6 +107,35 @@ public static async Task CreateMigrationAsync( } } + /// + /// All Weasel schema objects for the DbContext's model: the sequences + /// declared on the model (HasSequence, UseHiLo, UseSequence) followed by + /// the mapped tables. Sequences come first so that column defaults + /// referencing them (NEXT VALUE FOR ...) are valid when tables are + /// created. Providers without sequence support simply contribute tables. + /// + public static IReadOnlyList GetSchemaObjectsForMigration(DbContext context, Migrator migrator) + { + var objects = new List(); + + foreach (var sequence in context.Model.GetSequences()) + { + var identifier = migrator.Provider.Parse(sequence.Schema ?? migrator.DefaultSchemaName, sequence.Name); + var mapped = migrator.CreateSequence(identifier); + if (mapped == null) continue; + + mapped.StartWith = sequence.StartValue; + mapped.IncrementBy = sequence.IncrementBy; + objects.Add(mapped); + } + + objects.AddRange(GetEntityTypesForMigration(context) + .Select(x => migrator.MapToTable(x)) + .OfType()); + + return objects; + } + public static (DbConnection conn, Migrator? migrator) FindMigratorForDbContext(this IServiceProvider services, DbContext context) { var migrators = services.GetServices().ToList(); @@ -499,6 +525,32 @@ public static ITable MapToTable(this Migrator migrator, IEntityType entityType) mapIndexes(et, storeObjectIdentifier, addedIndexes, table); } + // Add check constraints from all entity types in the hierarchy + var addedChecks = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var et in allEntityTypes) + { + IEnumerable checkConstraints; + try + { + checkConstraints = et.GetCheckConstraints(); + } + catch (InvalidOperationException) + { + // Check-constraint metadata only exists on the design-time model + // (GetEntityTypesForMigration supplies it); entity types from the + // read-optimized runtime model simply contribute none + continue; + } + + foreach (var check in checkConstraints) + { + var name = check.GetName(storeObjectIdentifier); + if (name == null || check.Sql == null || !addedChecks.Add(name)) continue; + + table.AddCheckConstraint(name, check.Sql); + } + } + return table; } @@ -530,6 +582,17 @@ private static void mapIndexes(IEntityType entityType, StoreObjectIdentifier sto { tableIndex.IncludeColumns = includeColumns; } + + // Provider index method (Npgsql HasMethod("gin") etc. — annotation + // "Npgsql:IndexMethod") + foreach (var annotation in index.GetAnnotations()) + { + if (annotation.Name.EndsWith(":IndexMethod", StringComparison.Ordinal) + && annotation.Value is string method) + { + tableIndex.Method = method; + } + } } // Alternate keys (HasAlternateKey) become UNIQUE constraints in EF Core @@ -621,6 +684,13 @@ private static void mapColumn(IProperty property, StoreObjectIdentifier storeObj column.IsAutoNumber = isDatabaseGeneratedIdentity(property, storeObjectIdentifier); + var computedColumnSql = property.GetComputedColumnSql(storeObjectIdentifier); + if (computedColumnSql != null) + { + column.ComputedExpression = computedColumnSql; + column.ComputedColumnIsStored = property.GetIsStored(storeObjectIdentifier) ?? false; + } + var defaultValueSql = property.GetDefaultValueSql(storeObjectIdentifier); if (defaultValueSql != null) { diff --git a/src/Weasel.MySql/Tables/IndexDefinition.cs b/src/Weasel.MySql/Tables/IndexDefinition.cs index cb04c7f7..9858d351 100644 --- a/src/Weasel.MySql/Tables/IndexDefinition.cs +++ b/src/Weasel.MySql/Tables/IndexDefinition.cs @@ -44,6 +44,26 @@ public string[] Columns set => throw new NotSupportedException("Covering (INCLUDE) indexes are not supported by this database provider"); } + string? Weasel.Core.ITableIndex.Method + { + get => IndexType.ToString(); + set + { + if (value == null) + { + IndexType = MySqlIndexType.BTree; + } + else if (Enum.TryParse(value, ignoreCase: true, out var known)) + { + IndexType = known; + } + else + { + throw new NotSupportedException($"MySQL does not support index method '{value}'"); + } + } + } + /// /// For FULLTEXT indexes, specify the parser to use (e.g., "ngram" or "mecab"). /// diff --git a/src/Weasel.MySql/Tables/TableColumn.cs b/src/Weasel.MySql/Tables/TableColumn.cs index 47485cab..b431cfd6 100644 --- a/src/Weasel.MySql/Tables/TableColumn.cs +++ b/src/Weasel.MySql/Tables/TableColumn.cs @@ -40,10 +40,23 @@ public string ToDeclaration() return $"{QuotedName} {Type} {Declaration()}".TrimEnd(); } + /// + /// Computed column expression: emitted as + /// GENERATED ALWAYS AS (expr) STORED|VIRTUAL. + /// + public string? ComputedExpression { get; set; } + + public bool ComputedColumnIsStored { get; set; } + public string Declaration() { var parts = new List(); + if (ComputedExpression != null) + { + parts.Add($"GENERATED ALWAYS AS ({ComputedExpression}) {(ComputedColumnIsStored ? "STORED" : "VIRTUAL")}"); + } + if (!AllowNulls || IsPrimaryKey) { parts.Add("NOT NULL"); diff --git a/src/Weasel.Oracle.Tests/Tables/preserve_identifier_case_ddl.cs b/src/Weasel.Oracle.Tests/Tables/preserve_identifier_case_ddl.cs new file mode 100644 index 00000000..533dd5af --- /dev/null +++ b/src/Weasel.Oracle.Tests/Tables/preserve_identifier_case_ddl.cs @@ -0,0 +1,83 @@ +using Shouldly; +using Weasel.Core; +using Weasel.Oracle.Tables; +using Xunit; + +namespace Weasel.Oracle.Tests.Tables; + +/// +/// Pure DDL-generation tests (no database) for +/// on Oracle: case-preserved +/// tables must emit quoted identifiers everywhere or Oracle folds them to +/// uppercase, breaking consumers (EF Core) that emit quoted PascalCase SQL. +/// +public class preserve_identifier_case_ddl +{ + [Fact] + public void folded_convention_is_unchanged_by_default() + { + var table = new Table(new DbObjectName("weasel", "blogs")); + table.AddColumn("Id", "NUMBER(10)").AsPrimaryKey(); + table.AddColumn("Name", "VARCHAR2(200)"); + + // default behavior: lowercased names, unquoted DDL + table.Columns.Select(x => x.Name).ShouldBe(["id", "name"]); + var ddl = table.ToBasicCreateTableSql(); + ddl.ShouldContain("id"); + ddl.ShouldNotContain("\"Id\""); + } + + [Fact] + public void preserved_columns_keep_case_and_are_quoted() + { + var table = new Table(new DbObjectName("weasel", "Blogs")); + ((ITable)table).PreserveIdentifierCase = true; + table.AddColumn("Id", "NUMBER(10)").AsPrimaryKey(); + table.AddColumn("Name", "VARCHAR2(200)"); + table.PrimaryKeyName = "PK_Blogs"; + + table.Columns.Select(x => x.Name).ShouldBe(["Id", "Name"]); + + var ddl = table.ToBasicCreateTableSql(); + ddl.ShouldContain("\"Id\""); + ddl.ShouldContain("\"Name\""); + ddl.ShouldContain("CONSTRAINT \"PK_Blogs\" PRIMARY KEY (\"Id\")"); + } + + [Fact] + public void preserved_foreign_keys_are_quoted() + { + var table = new Table(new DbObjectName("weasel", "Posts")); + ((ITable)table).PreserveIdentifierCase = true; + table.AddColumn("Id", "NUMBER(10)").AsPrimaryKey(); + table.AddColumn("BlogId", "NUMBER(10)"); + + var fk = ((ITable)table).AddForeignKey( + "FK_Posts_Blogs_BlogId", + new DbObjectName("weasel", "Blogs"), + ["BlogId"], + ["Id"]); + fk.DeleteAction = CascadeAction.Cascade; + + var writer = new StringWriter(); + table.ForeignKeys.Single().WriteAddStatement(table, writer); + var ddl = writer.ToString(); + + ddl.ShouldContain("ADD CONSTRAINT \"FK_Posts_Blogs_BlogId\" FOREIGN KEY(\"BlogId\")"); + ddl.ShouldContain("(\"Id\")"); + } + + [Fact] + public void preserved_indexes_flow_through_the_core_seam() + { + var table = new Table(new DbObjectName("weasel", "Posts")); + var asTable = (ITable)table; + asTable.PreserveIdentifierCase = true; + table.AddColumn("BlogId", "NUMBER(10)"); + + var index = asTable.AddIndex("IX_Posts_BlogId", ["BlogId"]); + + index.Name.ShouldBe("IX_Posts_BlogId"); + table.Indexes.Single().Columns.ShouldBe(["BlogId"]); + } +} diff --git a/src/Weasel.Oracle/OracleMigrator.cs b/src/Weasel.Oracle/OracleMigrator.cs index 8a4e840a..623f1840 100644 --- a/src/Weasel.Oracle/OracleMigrator.cs +++ b/src/Weasel.Oracle/OracleMigrator.cs @@ -237,6 +237,11 @@ public override ITable CreateTable(DbObjectName identifier) return new Tables.Table(identifier); } + public override SequenceBase CreateSequence(DbObjectName identifier) + { + return new Sequence(identifier); + } + public override string GenerateDeleteAllSql(IReadOnlyList tables, bool resetIdentity = true) { if (tables.Count == 0) diff --git a/src/Weasel.Oracle/Sequence.cs b/src/Weasel.Oracle/Sequence.cs index 7cf2b404..30c53b69 100644 --- a/src/Weasel.Oracle/Sequence.cs +++ b/src/Weasel.Oracle/Sequence.cs @@ -29,7 +29,7 @@ public override void WriteCreateStatement(Migrator migrator, TextWriter writer) // between our check and create writer.WriteLine($@" BEGIN - EXECUTE IMMEDIATE 'CREATE SEQUENCE {Identifier} START WITH {startsWith}'; + EXECUTE IMMEDIATE 'CREATE SEQUENCE {Identifier} START WITH {startsWith}{(IncrementBy.HasValue ? $" INCREMENT BY {IncrementBy.Value}" : string.Empty)}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE = -955 THEN diff --git a/src/Weasel.Oracle/Tables/ForeignKey.cs b/src/Weasel.Oracle/Tables/ForeignKey.cs index 8f5f356a..d1853f79 100644 --- a/src/Weasel.Oracle/Tables/ForeignKey.cs +++ b/src/Weasel.Oracle/Tables/ForeignKey.cs @@ -89,9 +89,15 @@ public string ToDDL(Table parent) public void WriteAddStatement(Table parent, TextWriter writer) { + // Case-preserved identifiers must be quoted or Oracle folds them to + // uppercase; the conventional (folded) path stays unquoted as before + var quote = parent.PreserveIdentifierCase + ? (Func)(x => $"\"{x}\"") + : x => x; + writer.WriteLine($"ALTER TABLE {parent.Identifier}"); - writer.WriteLine($"ADD CONSTRAINT {Name} FOREIGN KEY({ColumnNames.Join(", ")})"); - writer.Write($" REFERENCES {LinkedTable}({LinkedNames.Join(", ")})"); + writer.WriteLine($"ADD CONSTRAINT {quote(Name)} FOREIGN KEY({ColumnNames.Select(quote).Join(", ")})"); + writer.Write($" REFERENCES {LinkedTable}({LinkedNames.Select(quote).Join(", ")})"); writer.WriteCascadeAction("ON DELETE", OnDelete); writer.WriteLine(); } diff --git a/src/Weasel.Oracle/Tables/IndexDefinition.cs b/src/Weasel.Oracle/Tables/IndexDefinition.cs index affd5d71..11f81b7d 100644 --- a/src/Weasel.Oracle/Tables/IndexDefinition.cs +++ b/src/Weasel.Oracle/Tables/IndexDefinition.cs @@ -59,6 +59,26 @@ public string[] Columns set => throw new NotSupportedException("Covering (INCLUDE) indexes are not supported by this database provider"); } + string? Weasel.Core.ITableIndex.Method + { + get => IndexType.ToString(); + set + { + if (value == null) + { + IndexType = OracleIndexType.BTree; + } + else if (Enum.TryParse(value, ignoreCase: true, out var known)) + { + IndexType = known; + } + else + { + throw new NotSupportedException($"Oracle does not support index method '{value}'"); + } + } + } + public string Name { get diff --git a/src/Weasel.Oracle/Tables/Table.cs b/src/Weasel.Oracle/Tables/Table.cs index 95f570d1..30ac097c 100644 --- a/src/Weasel.Oracle/Tables/Table.cs +++ b/src/Weasel.Oracle/Tables/Table.cs @@ -220,6 +220,14 @@ public override IEnumerable AllNames() internal string PrimaryKeyDeclaration() { + // Case-preserved identifiers must be quoted or Oracle folds them to + // uppercase; the conventional (folded) path stays unquoted as before + if (PreserveIdentifierCase) + { + var columns = PrimaryKeyColumns.Select(x => $"\"{x}\"").Join(", "); + return $"CONSTRAINT \"{PrimaryKeyName}\" PRIMARY KEY ({columns})"; + } + return $"CONSTRAINT {PrimaryKeyName} PRIMARY KEY ({PrimaryKeyColumns.Join(", ")})"; } @@ -233,7 +241,7 @@ public ColumnExpression AddColumn(TableColumn column) public ColumnExpression AddColumn(string columnName, string columnType) { - var column = new TableColumn(columnName, columnType) { Parent = this }; + var column = new TableColumn(columnName, columnType, PreserveIdentifierCase) { Parent = this }; return AddColumn(column); } diff --git a/src/Weasel.Oracle/Tables/TableColumn.cs b/src/Weasel.Oracle/Tables/TableColumn.cs index 87617704..7201d2a3 100644 --- a/src/Weasel.Oracle/Tables/TableColumn.cs +++ b/src/Weasel.Oracle/Tables/TableColumn.cs @@ -6,7 +6,21 @@ namespace Weasel.Oracle.Tables; public class TableColumn: ITableColumn { - public TableColumn(string name, string type) + private readonly bool _preserveCase; + + public TableColumn(string name, string type): this(name, type, false) + { + } + + /// + /// Oracle folds unquoted identifiers to UPPERCASE; by default the column + /// name is lowercased and written unquoted (the historical Weasel + /// convention, stored by Oracle as uppercase). Pass + /// = true to keep the exact name and + /// emit it quoted, for tables that must reproduce a schema created with + /// quoted, case-sensitive identifiers (see ). + /// + public TableColumn(string name, string type, bool preserveCase) { if (string.IsNullOrEmpty(name)) { @@ -18,7 +32,10 @@ public TableColumn(string name, string type) throw new ArgumentOutOfRangeException(nameof(type)); } - Name = name.ToLowerInvariant().Trim().Replace(' ', '_'); + _preserveCase = preserveCase; + Name = preserveCase + ? name.Trim() + : name.ToLowerInvariant().Trim().Replace(' ', '_'); Type = type.ToUpperInvariant(); } @@ -34,8 +51,18 @@ public TableColumn(string name, string type) public bool IsPrimaryKey { get; internal set; } public bool IsAutoNumber { get; set; } + /// + /// Computed (virtual) column expression. DDL emission for Oracle virtual + /// columns is not implemented yet — generating DDL for a column with + /// this set throws rather than silently creating a plain column. + /// + public string? ComputedExpression { get; set; } + + public bool ComputedColumnIsStored { get; set; } + public string Name { get; } - public string QuotedName => SchemaUtils.QuoteName(Name); + + public string QuotedName => _preserveCase ? $"\"{Name}\"" : SchemaUtils.QuoteName(Name); public string RawType() { @@ -44,6 +71,12 @@ public string RawType() public string Declaration() { + if (ComputedExpression != null) + { + throw new NotSupportedException( + "Computed (virtual) column DDL emission is not implemented for Oracle yet"); + } + var parts = new List(); // In Oracle, the order is: DEFAULT, NULL/NOT NULL, IDENTITY, CHECK constraints diff --git a/src/Weasel.Postgresql.Tests/Tables/column_drift_detection.cs b/src/Weasel.Postgresql.Tests/Tables/column_drift_detection.cs new file mode 100644 index 00000000..11609217 --- /dev/null +++ b/src/Weasel.Postgresql.Tests/Tables/column_drift_detection.cs @@ -0,0 +1,111 @@ +using Shouldly; +using Weasel.Core; +using Weasel.Postgresql.Tables; +using Xunit; + +namespace Weasel.Postgresql.Tests.Tables; + +/// +/// Opt-in column drift detection (ITable.DetectColumnDrift): default +/// expressions and nullability of otherwise-matching columns participate in +/// delta detection and are corrected with ALTER COLUMN statements. +/// +[Collection("column_drift")] +public class column_drift_detection: IntegrationContext +{ + public column_drift_detection(): base("column_drift") + { + } + + public override Task InitializeAsync() => ResetSchema(); + + private async Task AssertNoDeltasAfterPatching(Table table) + { + await table.ApplyChangesAsync(theConnection); + + var delta = await table.FindDeltaAsync(theConnection); + delta.HasChanges().ShouldBeFalse(); + } + + private Table theTable() + { + var table = new Table("column_drift.people"); + table.AddColumn("id").AsPrimaryKey(); + table.AddColumn("status"); + table.ColumnFor("status")!.DefaultExpression = "'pending'"; + table.ColumnFor("status")!.AllowNulls = false; + table.AddColumn("score"); + table.ColumnFor("score")!.DefaultExpression = "42"; + table.DetectColumnDrift = true; + return table; + } + + [Fact] + public async Task no_drift_is_no_delta() + { + var table = theTable(); + await CreateSchemaObjectInDatabase(table); + + var delta = await table.FindDeltaAsync(theConnection); + delta.Difference.ShouldBe(SchemaPatchDifference.None); + } + + [Fact] + public async Task off_by_default_ignores_drift() + { + var table = theTable(); + await CreateSchemaObjectInDatabase(table); + + await theConnection.CreateCommand("alter table column_drift.people alter column score set default 99;") + .ExecuteNonQueryAsync(); + + table.DetectColumnDrift = false; + var delta = await table.FindDeltaAsync(theConnection); + delta.Difference.ShouldBe(SchemaPatchDifference.None); + } + + [Fact] + public async Task detects_and_corrects_changed_default() + { + var table = theTable(); + await CreateSchemaObjectInDatabase(table); + + await theConnection.CreateCommand("alter table column_drift.people alter column score set default 99;") + .ExecuteNonQueryAsync(); + + var delta = await table.FindDeltaAsync(theConnection); + delta.Difference.ShouldBe(SchemaPatchDifference.Update); + + await AssertNoDeltasAfterPatching(table); + } + + [Fact] + public async Task detects_and_corrects_dropped_default() + { + var table = theTable(); + await CreateSchemaObjectInDatabase(table); + + await theConnection.CreateCommand("alter table column_drift.people alter column status drop default;") + .ExecuteNonQueryAsync(); + + var delta = await table.FindDeltaAsync(theConnection); + delta.Difference.ShouldBe(SchemaPatchDifference.Update); + + await AssertNoDeltasAfterPatching(table); + } + + [Fact] + public async Task detects_and_corrects_nullability_drift() + { + var table = theTable(); + await CreateSchemaObjectInDatabase(table); + + await theConnection.CreateCommand("alter table column_drift.people alter column status drop not null;") + .ExecuteNonQueryAsync(); + + var delta = await table.FindDeltaAsync(theConnection); + delta.Difference.ShouldBe(SchemaPatchDifference.Update); + + await AssertNoDeltasAfterPatching(table); + } +} diff --git a/src/Weasel.Postgresql/PostgresqlMigrator.cs b/src/Weasel.Postgresql/PostgresqlMigrator.cs index 24a3611a..e4a3a069 100644 --- a/src/Weasel.Postgresql/PostgresqlMigrator.cs +++ b/src/Weasel.Postgresql/PostgresqlMigrator.cs @@ -379,6 +379,11 @@ public override ITable CreateTable(DbObjectName identifier) return new Tables.Table(identifier); } + public override SequenceBase CreateSequence(DbObjectName identifier) + { + return new Sequence(identifier); + } + public DatabaseWithTables CreateDatabase(NpgsqlDataSource dataSource) { var builder = new NpgsqlConnectionStringBuilder(dataSource.ConnectionString); diff --git a/src/Weasel.Postgresql/Sequence.cs b/src/Weasel.Postgresql/Sequence.cs index 125d0ff2..06ffacdd 100644 --- a/src/Weasel.Postgresql/Sequence.cs +++ b/src/Weasel.Postgresql/Sequence.cs @@ -24,7 +24,7 @@ public Sequence(DbObjectName identifier, long startWith) public override void WriteCreateStatement(Migrator migrator, TextWriter writer) { writer.WriteLine( - $"CREATE SEQUENCE {Identifier}{(StartWith.HasValue ? $" START {StartWith.Value}" : string.Empty)};"); + $"CREATE SEQUENCE {Identifier}{(StartWith.HasValue ? $" START {StartWith.Value}" : string.Empty)}{(IncrementBy.HasValue ? $" INCREMENT BY {IncrementBy.Value}" : string.Empty)};"); if (Owner != null) { diff --git a/src/Weasel.Postgresql/Tables/IndexDefinition.cs b/src/Weasel.Postgresql/Tables/IndexDefinition.cs index 459420d2..257030ac 100644 --- a/src/Weasel.Postgresql/Tables/IndexDefinition.cs +++ b/src/Weasel.Postgresql/Tables/IndexDefinition.cs @@ -52,6 +52,26 @@ public string? CustomMethod } } + string? ITableIndex.Method + { + get => Method == IndexMethod.custom ? CustomMethod : Method.ToString(); + set + { + if (value == null) + { + Method = IndexMethod.btree; + } + else if (Enum.TryParse(value, ignoreCase: true, out var known) && known != IndexMethod.custom) + { + Method = known; + } + else + { + CustomMethod = value; + } + } + } + /// /// Set sort order for a btree index column/expression /// diff --git a/src/Weasel.Postgresql/Tables/Table.FetchExisting.cs b/src/Weasel.Postgresql/Tables/Table.FetchExisting.cs index c71ddaab..a2ff6905 100644 --- a/src/Weasel.Postgresql/Tables/Table.FetchExisting.cs +++ b/src/Weasel.Postgresql/Tables/Table.FetchExisting.cs @@ -22,7 +22,7 @@ public override void ConfigureQueryCommand(DbCommandBuilder builder) var nameWithSchemaQuotedParam = builder.AddParameter($"{Identifier.Schema}.{quotedName}").ParameterName; builder.Append($@" -select column_name, data_type, character_maximum_length, udt_name +select column_name, data_type, character_maximum_length, udt_name, column_default, is_nullable from information_schema.columns where table_schema = :{schemaParam} and table_name = :{nameParam} order by ordinal_position; @@ -84,7 +84,7 @@ JOIN LATERAL UNNEST(c.conkey) WITH ORDINALITY AS u(attnum, attposition) ON TRUE JOIN pg_namespace sch ON sch.oid = tbl.relnamespace JOIN pg_attribute col ON (col.attrelid = tbl.oid AND col.attnum = u.attnum) WHERE - c.contype = 'f' and + c.contype in ('f', 'c') and sch.nspname = :{schemaParam} and tbl.relname = :{nameParam} GROUP BY constraint_name, constraint_type, schema_name, table_name, definition; @@ -302,6 +302,13 @@ private static async Task readColumnAsync(DbDataReader reader, Canc column.Type = $"{column.Type}({length})"; } + if (!await reader.IsDBNullAsync(4, ct).ConfigureAwait(false)) + { + column.DefaultExpression = await reader.GetFieldValueAsync(4, ct).ConfigureAwait(false); + } + + column.AllowNulls = await reader.GetFieldValueAsync(5, ct).ConfigureAwait(false) == "YES"; + return column; } @@ -314,9 +321,24 @@ private async Task readConstraintsAsync(DbDataReader reader, Table existing, Can while (await reader.ReadAsync(ct).ConfigureAwait(false)) { var name = await reader.GetFieldValueAsync(0, ct).ConfigureAwait(false); + var type = await reader.GetFieldValueAsync(1, ct).ConfigureAwait(false); var schema = await reader.GetFieldValueAsync(2, ct).ConfigureAwait(false); var definition = await reader.GetFieldValueAsync(5, ct).ConfigureAwait(false); + if (type == 'c') + { + // pg_get_constraintdef renders "CHECK ((expr))"; store the raw + // expression — canonicalization happens at comparison time + var expression = definition.Trim(); + if (expression.StartsWith("CHECK", StringComparison.OrdinalIgnoreCase)) + { + expression = expression[5..].Trim(); + } + + existing.CheckConstraints.Add(new TableCheckConstraint(name, expression)); + continue; + } + var fk = new ForeignKey(name); fk.Parse(definition, schema); diff --git a/src/Weasel.Postgresql/Tables/Table.cs b/src/Weasel.Postgresql/Tables/Table.cs index e75b5ad0..55190e4d 100644 --- a/src/Weasel.Postgresql/Tables/Table.cs +++ b/src/Weasel.Postgresql/Tables/Table.cs @@ -94,6 +94,8 @@ public override void WriteCreateStatement(Migrator migrator, TextWriter writer) lines.Add(PrimaryKeyDeclaration()); } + lines.AddRange(CheckConstraints.Select(CheckConstraintDeclaration)); + for (var i = 0; i < lines.Count - 1; i++) { writer.WriteLine(lines[i] + ","); @@ -112,6 +114,8 @@ public override void WriteCreateStatement(Migrator migrator, TextWriter writer) lines.Add(PrimaryKeyDeclaration()); } + lines.AddRange(CheckConstraints.Select(CheckConstraintDeclaration)); + for (var i = 0; i < lines.Count - 1; i++) { writer.WriteLine(lines[i] + ","); @@ -208,6 +212,9 @@ public void MoveToSchema(string schemaName) } } + internal static string CheckConstraintDeclaration(TableCheckConstraint constraint) + => $"CONSTRAINT {SchemaUtils.QuoteName(constraint.Name)} CHECK ({constraint.Expression})"; + internal string PrimaryKeyDeclaration() { // QuoteName only quotes identifiers that need it (uppercase characters diff --git a/src/Weasel.Postgresql/Tables/TableColumn.cs b/src/Weasel.Postgresql/Tables/TableColumn.cs index 2f1232e4..7df131f9 100644 --- a/src/Weasel.Postgresql/Tables/TableColumn.cs +++ b/src/Weasel.Postgresql/Tables/TableColumn.cs @@ -50,6 +50,16 @@ public TableColumn(string name, string type, bool preserveCase) /// public bool IsAutoNumber { get; set; } + /// + /// Computed / generated column expression. PostgreSQL only supports + /// STORED generated columns, so this always emits + /// GENERATED ALWAYS AS (expr) STORED regardless of + /// . + /// + public string? ComputedExpression { get; set; } + + public bool ComputedColumnIsStored { get; set; } = true; + public string Type { get; set; } public Table Parent { get; internal set; } = null!; @@ -77,6 +87,11 @@ public string Declaration() declaration += " GENERATED BY DEFAULT AS IDENTITY"; } + if (ComputedExpression.IsNotEmpty()) + { + declaration += $" GENERATED ALWAYS AS ({ComputedExpression}) STORED"; + } + if (DefaultExpression.IsNotEmpty()) { declaration += " DEFAULT " + DefaultExpression; @@ -85,6 +100,40 @@ public string Declaration() return $"{declaration} {ColumnChecks.Select(x => x.FullDeclaration()).Join(" ")}".TrimEnd(); } + /// + /// Drift comparison for : + /// nullability (primary key columns excluded — they are implicitly NOT + /// NULL) and canonicalized default expressions. + /// + internal bool HasSameDefaultAndNullability(TableColumn actual) + { + if (!IsPrimaryKey && !actual.IsPrimaryKey && AllowNulls != actual.AllowNulls) + { + return false; + } + + return canonicalDefault(DefaultExpression) == canonicalDefault(actual.DefaultExpression); + } + + private static string? canonicalDefault(string? expression) + => expression == null ? null : TableCheckConstraint.Canonicalize(expression); + + internal void WriteDriftCorrections(Table parent, TableColumn actual, TextWriter writer) + { + if (!IsPrimaryKey && !actual.IsPrimaryKey && AllowNulls != actual.AllowNulls) + { + writer.WriteLine( + $"alter table {parent.Identifier} alter column {QuotedName} {(AllowNulls ? "drop not null" : "set not null")};"); + } + + if (canonicalDefault(DefaultExpression) != canonicalDefault(actual.DefaultExpression)) + { + writer.WriteLine(DefaultExpression.IsNotEmpty() + ? $"alter table {parent.Identifier} alter column {QuotedName} set default {DefaultExpression};" + : $"alter table {parent.Identifier} alter column {QuotedName} drop default;"); + } + } + protected bool Equals(TableColumn other) { // Name comparison is case-insensitive: an expected case-preserved column diff --git a/src/Weasel.Postgresql/Tables/TableDelta.cs b/src/Weasel.Postgresql/Tables/TableDelta.cs index 03592a41..44096c8d 100644 --- a/src/Weasel.Postgresql/Tables/TableDelta.cs +++ b/src/Weasel.Postgresql/Tables/TableDelta.cs @@ -23,6 +23,8 @@ public TableDelta(Table expected, Table? actual): base(expected, actual) internal ItemDelta ForeignKeys { get; private set; } = null!; + internal ItemDelta CheckConstraints { get; private set; } = null!; + /// /// Foreign keys from OTHER tables that reference this table's primary key. /// When the PK changes, these must be dropped and recreated. @@ -39,13 +41,25 @@ protected override SchemaPatchDifference compare(Table expected, Table? actual) return SchemaPatchDifference.Create; } - Columns = new ItemDelta(expected.Columns, actual.Columns); + Columns = new ItemDelta(expected.Columns, actual.Columns, + expected.DetectColumnDrift + ? (e, a) => e.Equals(a) && e.HasSameDefaultAndNullability(a) + : null); Indexes = new ItemDelta(expected.Indexes.Where(x => !expected.HasIgnoredIndex(x.Name)), actual.Indexes.Where(x => !expected.HasIgnoredIndex(x.Name)), (e, a) => e.Matches(a, Expected)); ForeignKeys = new ItemDelta(expected.ForeignKeys, actual.ForeignKeys); + // Conservative check-constraint comparison: only the checks the expected + // table declares participate, and actual constraints the expected table + // doesn't know about (inline column checks, third-party constraints) + // are never treated as extras to drop. + var relevantActualChecks = actual.CheckConstraints + .Where(a => expected.CheckConstraints.Any(e => e.Name.Equals(a.Name, StringComparison.OrdinalIgnoreCase))); + CheckConstraints = new ItemDelta(expected.CheckConstraints, relevantActualChecks, + (e, a) => e.Matches(a)); + PrimaryKeyDifference = SchemaPatchDifference.None; if (expected.PrimaryKeyName.IsEmpty()) { @@ -123,9 +137,20 @@ public override void WriteUpdate(Migrator rules, TextWriter writer) // Different columns foreach (var change1 in Columns.Different) - writer.WriteLine(change1.Expected.AlterColumnTypeSql(Expected, change1.Actual)); + { + if (change1.Expected.Equals(change1.Actual)) + { + // same name/type — the difference is default/nullability drift + change1.Expected.WriteDriftCorrections(Expected, change1.Actual, writer); + } + else + { + writer.WriteLine(change1.Expected.AlterColumnTypeSql(Expected, change1.Actual)); + } + } writeForeignKeyUpdates(writer); + writeCheckConstraintUpdates(writer); // Missing indexes foreach (var indexDefinition in Indexes.Missing) writer.WriteLine(indexDefinition.ToDDL(Expected)); @@ -212,6 +237,20 @@ private void writeForeignKeyUpdates(TextWriter writer) } } + private void writeCheckConstraintUpdates(TextWriter writer) + { + // Extras never appear here — unknown actual checks are filtered out of + // the comparison entirely (see the delta construction) + foreach (var check in CheckConstraints.Missing) + writer.WriteLine($"alter table {Expected.Identifier} add {Table.CheckConstraintDeclaration(check)};"); + + foreach (var change in CheckConstraints.Different) + { + writer.WriteLine($"alter table {Expected.Identifier} drop constraint {SchemaUtils.QuoteName(change.Actual.Name)};"); + writer.WriteLine($"alter table {Expected.Identifier} add {Table.CheckConstraintDeclaration(change.Expected)};"); + } + } + public override void WriteRollback(Migrator rules, TextWriter writer) { if (Actual == null) @@ -224,12 +263,31 @@ public override void WriteRollback(Migrator rules, TextWriter writer) foreach (var change in ForeignKeys.Different) change.Expected.WriteDropStatement(Expected, writer); + // roll back check-constraint changes: drop what was added, restore what was replaced + foreach (var check in CheckConstraints.Missing) + writer.WriteLine($"alter table {Expected.Identifier} drop constraint if exists {SchemaUtils.QuoteName(check.Name)};"); + + foreach (var change in CheckConstraints.Different) + { + writer.WriteLine($"alter table {Expected.Identifier} drop constraint if exists {SchemaUtils.QuoteName(change.Expected.Name)};"); + writer.WriteLine($"alter table {Expected.Identifier} add {Table.CheckConstraintDeclaration(change.Actual)};"); + } + // Extra columns foreach (var column in Columns.Extras) writer.WriteLine(column.AddColumnSql(Expected)); // Different columns foreach (var change1 in Columns.Different) - writer.WriteLine(change1.Actual.AlterColumnTypeSql(Actual, change1.Expected)); + { + if (change1.Expected.Equals(change1.Actual)) + { + change1.Actual.WriteDriftCorrections(Expected, change1.Expected, writer); + } + else + { + writer.WriteLine(change1.Actual.AlterColumnTypeSql(Actual, change1.Expected)); + } + } foreach (var change in ForeignKeys.Different) change.Actual.WriteAddStatement(Expected, writer); @@ -332,7 +390,8 @@ private SchemaPatchDifference determinePatchDifference() var differences = new[] { - Columns.Difference(), ForeignKeys.Difference(), Indexes.Difference(), PrimaryKeyDifference, partitionDifference() + Columns.Difference(), ForeignKeys.Difference(), Indexes.Difference(), CheckConstraints.Difference(), + PrimaryKeyDifference, partitionDifference() }; return differences.Min(); @@ -356,6 +415,7 @@ private SchemaPatchDifference partitionDifference() public bool HasChanges() { return Columns.HasChanges() || Indexes.HasChanges() || ForeignKeys.HasChanges() || + CheckConstraints.HasChanges() || PrimaryKeyDifference != SchemaPatchDifference.None || PartitionDelta != PartitionDelta.None; } diff --git a/src/Weasel.SqlServer.Tests/Tables/column_drift_detection.cs b/src/Weasel.SqlServer.Tests/Tables/column_drift_detection.cs new file mode 100644 index 00000000..5cc11500 --- /dev/null +++ b/src/Weasel.SqlServer.Tests/Tables/column_drift_detection.cs @@ -0,0 +1,91 @@ +using Shouldly; +using Weasel.Core; +using Weasel.SqlServer.Tables; +using Xunit; + +namespace Weasel.SqlServer.Tests.Tables; + +/// +/// Opt-in column drift detection (ITable.DetectColumnDrift) on SQL Server: +/// default expressions and nullability of otherwise-matching columns +/// participate in delta detection and are corrected — including dropping +/// the server-named default constraint before re-adding the default. +/// +public class column_drift_detection: IntegrationContext +{ + public column_drift_detection(): base("column_drift") + { + } + + public override Task InitializeAsync() => ResetSchema(); + + private async Task AssertNoDeltasAfterPatching(Table table) + { + var delta = await table.FindDeltaAsync(theConnection); + var migration = new SchemaMigration(delta); + var migrator = new SqlServerMigrator(); + await migrator.ApplyAllAsync(theConnection, migration, JasperFx.AutoCreate.CreateOrUpdate); + + var after = await table.FindDeltaAsync(theConnection); + after.HasChanges().ShouldBeFalse(); + } + + private Table theTable() + { + var table = new Table("column_drift.people"); + table.AddColumn("id").AsPrimaryKey(); + table.AddColumn("status"); + table.ColumnFor("status")!.DefaultExpression = "'pending'"; + table.ColumnFor("status")!.AllowNulls = false; + table.AddColumn("score"); + table.ColumnFor("score")!.DefaultExpression = "42"; + table.DetectColumnDrift = true; + return table; + } + + [Fact] + public async Task no_drift_is_no_delta() + { + var table = theTable(); + await CreateSchemaObjectInDatabase(table); + + var delta = await table.FindDeltaAsync(theConnection); + delta.Difference.ShouldBe(SchemaPatchDifference.None); + } + + [Fact] + public async Task detects_and_corrects_changed_default() + { + var table = theTable(); + await CreateSchemaObjectInDatabase(table); + + await theConnection.CreateCommand(""" + declare @dc nvarchar(max); + select @dc = dc.name from sys.default_constraints dc + inner join sys.columns c on c.default_object_id = dc.object_id + where dc.parent_object_id = OBJECT_ID('column_drift.people') and c.name = 'score'; + if @dc is not null exec('alter table column_drift.people drop constraint ' + @dc); + alter table column_drift.people add default 99 for score; + """).ExecuteNonQueryAsync(); + + var delta = await table.FindDeltaAsync(theConnection); + delta.Difference.ShouldBe(SchemaPatchDifference.Update); + + await AssertNoDeltasAfterPatching(table); + } + + [Fact] + public async Task detects_and_corrects_nullability_drift() + { + var table = theTable(); + await CreateSchemaObjectInDatabase(table); + + await theConnection.CreateCommand("alter table column_drift.people alter column status varchar(100) null;") + .ExecuteNonQueryAsync(); + + var delta = await table.FindDeltaAsync(theConnection); + delta.Difference.ShouldBe(SchemaPatchDifference.Update); + + await AssertNoDeltasAfterPatching(table); + } +} diff --git a/src/Weasel.SqlServer/Sequence.cs b/src/Weasel.SqlServer/Sequence.cs index 12a73def..5ab04ed7 100644 --- a/src/Weasel.SqlServer/Sequence.cs +++ b/src/Weasel.SqlServer/Sequence.cs @@ -24,7 +24,7 @@ public override void WriteCreateStatement(Migrator migrator, TextWriter writer) var startsWith = StartWith ?? 1; writer.WriteLine( - $"CREATE SEQUENCE {Identifier} START WITH {startsWith};"); + $"CREATE SEQUENCE {Identifier} START WITH {startsWith}{(IncrementBy.HasValue ? $" INCREMENT BY {IncrementBy.Value}" : string.Empty)};"); if (Owner != null) { diff --git a/src/Weasel.SqlServer/SqlServerMigrator.cs b/src/Weasel.SqlServer/SqlServerMigrator.cs index 85d94fb5..5798db74 100644 --- a/src/Weasel.SqlServer/SqlServerMigrator.cs +++ b/src/Weasel.SqlServer/SqlServerMigrator.cs @@ -228,6 +228,11 @@ public override ITable CreateTable(DbObjectName identifier) return new Tables.Table(identifier); } + public override SequenceBase CreateSequence(DbObjectName identifier) + { + return new Sequence(identifier); + } + public override string GenerateDeleteAllSql(IReadOnlyList tables, bool resetIdentity = true) { if (tables.Count == 0) diff --git a/src/Weasel.SqlServer/Tables/IndexDefinition.cs b/src/Weasel.SqlServer/Tables/IndexDefinition.cs index 7dc28ef8..4b830c05 100644 --- a/src/Weasel.SqlServer/Tables/IndexDefinition.cs +++ b/src/Weasel.SqlServer/Tables/IndexDefinition.cs @@ -50,6 +50,18 @@ public string[] IncludedColumns set => IncludedColumns = value ?? []; } + string? ITableIndex.Method + { + get => null; + set + { + if (value != null) + { + throw new NotSupportedException("SQL Server indexes do not have pluggable access methods"); + } + } + } + /// /// The constraint expression for a partial index. /// diff --git a/src/Weasel.SqlServer/Tables/ItemDelta.cs b/src/Weasel.SqlServer/Tables/ItemDelta.cs index b80df1ce..026c6d3d 100644 --- a/src/Weasel.SqlServer/Tables/ItemDelta.cs +++ b/src/Weasel.SqlServer/Tables/ItemDelta.cs @@ -12,7 +12,8 @@ internal class ItemDelta where T : INamed public ItemDelta(IEnumerable expectedItems, IEnumerable actualItems, Func? comparison = null) { comparison ??= (expected, actual) => expected.Equals(actual); - var expecteds = expectedItems.ToDictionary(x => x.Name); + // SQL Server identifiers are case-insensitive under the default collation + var expecteds = expectedItems.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); foreach (var actual in actualItems) { @@ -33,7 +34,7 @@ public ItemDelta(IEnumerable expectedItems, IEnumerable actualItems, Func< } } - var actuals = actualItems.ToDictionary(x => x.Name); + var actuals = actualItems.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); _missing.AddRange(expectedItems.Where(x => !actuals.ContainsKey(x.Name))); } diff --git a/src/Weasel.SqlServer/Tables/Table.FetchExisting.cs b/src/Weasel.SqlServer/Tables/Table.FetchExisting.cs index 83350ecb..a6a81ade 100644 --- a/src/Weasel.SqlServer/Tables/Table.FetchExisting.cs +++ b/src/Weasel.SqlServer/Tables/Table.FetchExisting.cs @@ -13,7 +13,7 @@ public override void ConfigureQueryCommand(DbCommandBuilder builder) var nameParam = builder.AddParameter(Identifier.Name).ParameterName; builder.Append($@" -select column_name, data_type, character_maximum_length +select column_name, data_type, character_maximum_length, null as udt_name, column_default, is_nullable from information_schema.columns where table_schema = @{schemaParam} and table_name = @{nameParam} order by ordinal_position; @@ -108,6 +108,12 @@ from sys.tables tbl where sch.name = @{schemaParam} and tbl.name = @{nameParam} order by prv.boundary_id; +select cc.name, cc.definition +from sys.check_constraints cc + inner join sys.tables t on t.object_id = cc.parent_object_id + inner join sys.schemas s on s.schema_id = t.schema_id +where s.name = @{schemaParam} and t.name = @{nameParam}; + "); } @@ -140,11 +146,27 @@ from sys.tables tbl await readPartitioningAsync(reader, existing, ct).ConfigureAwait(false); + await readCheckConstraintsAsync(reader, existing, ct).ConfigureAwait(false); + return !existing.Columns.Any() ? null : existing; } + private static async Task readCheckConstraintsAsync(DbDataReader reader, Table existing, CancellationToken ct = default) + { + await reader.NextResultAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + var name = await reader.GetFieldValueAsync(0, ct).ConfigureAwait(false); + var definition = await reader.GetFieldValueAsync(1, ct).ConfigureAwait(false); + + // sys.check_constraints.definition renders "([Price]>(0))"; stored + // raw — canonicalization happens at comparison time + existing.CheckConstraints.Add(new TableCheckConstraint(name, definition)); + } + } + private async Task readPartitioningAsync(DbDataReader reader, Table existing, CancellationToken ct = default) { var hasResults = await reader.NextResultAsync(ct).ConfigureAwait(false); @@ -230,6 +252,13 @@ await reader.GetFieldValueAsync(1, ct).ConfigureAwait(false) column.Type = length == -1 ? $"{column.Type}(max)" : $"{column.Type}({length})"; } + if (!await reader.IsDBNullAsync(4, ct).ConfigureAwait(false)) + { + column.DefaultExpression = await reader.GetFieldValueAsync(4, ct).ConfigureAwait(false); + } + + column.AllowNulls = await reader.GetFieldValueAsync(5, ct).ConfigureAwait(false) == "YES"; + return column; } diff --git a/src/Weasel.SqlServer/Tables/Table.cs b/src/Weasel.SqlServer/Tables/Table.cs index 713b92b6..1327c966 100644 --- a/src/Weasel.SqlServer/Tables/Table.cs +++ b/src/Weasel.SqlServer/Tables/Table.cs @@ -160,7 +160,9 @@ public override void WriteCreateStatement(Migrator migrator, TextWriter writer) var typeLength = Columns.Max(x => x.Type.Length) + 4; var lines = Columns.Select(column => - $" {column.QuotedName.PadRight(columnLength)}{column.Type.PadRight(typeLength)}{column.Declaration()}") + column.ComputedExpression != null + ? $" {column.ToDeclaration()}" + : $" {column.QuotedName.PadRight(columnLength)}{column.Type.PadRight(typeLength)}{column.Declaration()}") .ToList(); if (PrimaryKeyColumns.Any()) @@ -168,6 +170,8 @@ public override void WriteCreateStatement(Migrator migrator, TextWriter writer) lines.Add(PrimaryKeyDeclaration()); } + lines.AddRange(CheckConstraints.Select(CheckConstraintDeclaration)); + for (var i = 0; i < lines.Count - 1; i++) { writer.WriteLine(lines[i] + ","); @@ -186,6 +190,8 @@ public override void WriteCreateStatement(Migrator migrator, TextWriter writer) lines.Add(PrimaryKeyDeclaration()); } + lines.AddRange(CheckConstraints.Select(CheckConstraintDeclaration)); + for (var i = 0; i < lines.Count - 1; i++) { writer.WriteLine(lines[i] + ","); @@ -255,6 +261,9 @@ internal string PrimaryKeyDeclaration() return $"CONSTRAINT {PrimaryKeyName} PRIMARY KEY ({PrimaryKeyColumns.Join(", ")})"; } + internal static string CheckConstraintDeclaration(TableCheckConstraint constraint) + => $"CONSTRAINT [{constraint.Name}] CHECK ({constraint.Expression})"; + public ColumnExpression AddColumn(TableColumn column) { _columns.Add(column); diff --git a/src/Weasel.SqlServer/Tables/TableColumn.cs b/src/Weasel.SqlServer/Tables/TableColumn.cs index eeedecf6..28d52670 100644 --- a/src/Weasel.SqlServer/Tables/TableColumn.cs +++ b/src/Weasel.SqlServer/Tables/TableColumn.cs @@ -40,6 +40,15 @@ public TableColumn(string name, string type) public bool IsPrimaryKey { get; internal set; } public bool IsAutoNumber { get; set; } + /// + /// Computed column expression: emitted as [name] AS (expr) [PERSISTED], + /// replacing the data type in the column declaration (SQL Server derives + /// the type from the expression). + /// + public string? ComputedExpression { get; set; } + + public bool ComputedColumnIsStored { get; set; } + public string Name { get; } public string QuotedName => SchemaUtils.QuoteName(Name); @@ -64,6 +73,52 @@ public string Declaration() return $"{declaration} {ColumnChecks.Select(x => x.FullDeclaration()).Join(" ")}".TrimEnd(); } + /// + /// Drift comparison for : + /// nullability (primary key columns excluded — they are implicitly NOT + /// NULL) and canonicalized default expressions. + /// + internal bool HasSameDefaultAndNullability(TableColumn actual) + { + if (!IsPrimaryKey && !actual.IsPrimaryKey && AllowNulls != actual.AllowNulls) + { + return false; + } + + return canonicalDefault(DefaultExpression) == canonicalDefault(actual.DefaultExpression); + } + + private static string? canonicalDefault(string? expression) + => expression == null ? null : TableCheckConstraint.Canonicalize(expression); + + internal void WriteDriftCorrections(Table parent, TableColumn actual, TextWriter writer) + { + if (!IsPrimaryKey && !actual.IsPrimaryKey && AllowNulls != actual.AllowNulls) + { + writer.WriteLine( + $"alter table {parent.Identifier} alter column {QuotedName} {Type} {(AllowNulls ? "NULL" : "NOT NULL")};"); + } + + if (canonicalDefault(DefaultExpression) != canonicalDefault(actual.DefaultExpression)) + { + // SQL Server default constraints have (often server-generated) names; + // drop whatever default currently exists before adding the new one + var variable = $"@dc_{Guid.NewGuid().ToString("N")[..8]}"; + writer.WriteLine($"declare {variable} nvarchar(max);"); + writer.WriteLine( + $"select {variable} = dc.name from sys.default_constraints dc " + + $"inner join sys.columns c on c.default_object_id = dc.object_id " + + $"where dc.parent_object_id = OBJECT_ID('{parent.Identifier}') and c.name = '{Name}';"); + writer.WriteLine( + $"if {variable} is not null exec('alter table {parent.Identifier} drop constraint ' + {variable});"); + + if (DefaultExpression.IsNotEmpty()) + { + writer.WriteLine($"alter table {parent.Identifier} add default {DefaultExpression} for {QuotedName};"); + } + } + } + protected bool Equals(TableColumn other) { return string.Equals(QuotedName, other.QuotedName, StringComparison.OrdinalIgnoreCase) && @@ -101,6 +156,11 @@ public override int GetHashCode() public string ToDeclaration() { + if (ComputedExpression.IsNotEmpty()) + { + return $"{QuotedName} AS ({ComputedExpression}){(ComputedColumnIsStored ? " PERSISTED" : string.Empty)}"; + } + var declaration = Declaration(); return declaration.IsEmpty() diff --git a/src/Weasel.SqlServer/Tables/TableDelta.cs b/src/Weasel.SqlServer/Tables/TableDelta.cs index 4b7285fb..29952361 100644 --- a/src/Weasel.SqlServer/Tables/TableDelta.cs +++ b/src/Weasel.SqlServer/Tables/TableDelta.cs @@ -14,6 +14,8 @@ public TableDelta(Table expected, Table? actual): base(expected, actual) internal ItemDelta ForeignKeys { get; private set; } = null!; + internal ItemDelta CheckConstraints { get; private set; } = null!; + public SchemaPatchDifference PrimaryKeyDifference { get; private set; } /// @@ -31,12 +33,23 @@ protected override SchemaPatchDifference compare(Table expected, Table? actual) return SchemaPatchDifference.Create; } - Columns = new ItemDelta(expected.Columns, actual.Columns); + Columns = new ItemDelta(expected.Columns, actual.Columns, + expected.DetectColumnDrift + ? (e, a) => e.Equals(a) && e.HasSameDefaultAndNullability(a) + : null); Indexes = new ItemDelta(expected.Indexes, actual.Indexes, (e, a) => e.Matches(a, Expected)); ForeignKeys = new ItemDelta(expected.ForeignKeys, actual.ForeignKeys); + // Conservative check-constraint comparison: only the checks the expected + // table declares participate, and actual constraints the expected table + // doesn't know about are never treated as extras to drop. + var relevantActualChecks = actual.CheckConstraints + .Where(a => expected.CheckConstraints.Any(e => e.Name.Equals(a.Name, StringComparison.OrdinalIgnoreCase))); + CheckConstraints = new ItemDelta(expected.CheckConstraints, relevantActualChecks, + (e, a) => e.Matches(a)); + PrimaryKeyDifference = SchemaPatchDifference.None; if (expected.PrimaryKeyName.IsEmpty()) { @@ -101,9 +114,20 @@ public override void WriteUpdate(Migrator rules, TextWriter writer) // Different columns foreach (var change1 in Columns.Different) - writer.WriteLine(change1.Expected.AlterColumnTypeSql(Expected, change1.Actual)); + { + if (change1.Expected.Equals(change1.Actual)) + { + // same name/type — the difference is default/nullability drift + change1.Expected.WriteDriftCorrections(Expected, change1.Actual, writer); + } + else + { + writer.WriteLine(change1.Expected.AlterColumnTypeSql(Expected, change1.Actual)); + } + } writeForeignKeyUpdates(writer); + writeCheckConstraintUpdates(writer); // Missing indexes foreach (var indexDefinition in Indexes.Missing) writer.WriteLine(indexDefinition.ToDDL(Expected)); @@ -161,6 +185,20 @@ private void writeForeignKeyUpdates(TextWriter writer) } } + private void writeCheckConstraintUpdates(TextWriter writer) + { + // Extras never appear here — unknown actual checks are filtered out of + // the comparison entirely (see the delta construction) + foreach (var check in CheckConstraints.Missing) + writer.WriteLine($"alter table {Expected.Identifier} add {Table.CheckConstraintDeclaration(check)};"); + + foreach (var change in CheckConstraints.Different) + { + writer.WriteLine($"alter table {Expected.Identifier} drop constraint [{change.Actual.Name}];"); + writer.WriteLine($"alter table {Expected.Identifier} add {Table.CheckConstraintDeclaration(change.Expected)};"); + } + } + public override void WriteRollback(Migrator rules, TextWriter writer) { if (Actual == null) @@ -185,7 +223,16 @@ public override void WriteRollback(Migrator rules, TextWriter writer) // Different columns foreach (var change1 in Columns.Different) - writer.WriteLine(change1.Actual.AlterColumnTypeSql(Actual, change1.Expected)); + { + if (change1.Expected.Equals(change1.Actual)) + { + change1.Actual.WriteDriftCorrections(Expected, change1.Expected, writer); + } + else + { + writer.WriteLine(change1.Actual.AlterColumnTypeSql(Actual, change1.Expected)); + } + } foreach (var change in ForeignKeys.Different) change.Actual.WriteAddStatement(Expected, writer); @@ -283,8 +330,8 @@ private SchemaPatchDifference determinePatchDifference() var differences = new[] { - Columns.Difference(), ForeignKeys.Difference(), Indexes.Difference(), PrimaryKeyDifference, - PartitioningDifference + Columns.Difference(), ForeignKeys.Difference(), Indexes.Difference(), CheckConstraints.Difference(), + PrimaryKeyDifference, PartitioningDifference }; return differences.Min(); @@ -305,6 +352,7 @@ private bool requiresPrimaryKeyDropBeforeRollback() public bool HasChanges() { return Columns.HasChanges() || Indexes.HasChanges() || ForeignKeys.HasChanges() || + CheckConstraints.HasChanges() || PrimaryKeyDifference != SchemaPatchDifference.None || PartitioningDifference != SchemaPatchDifference.None; } diff --git a/src/Weasel.Sqlite/Tables/IndexDefinition.cs b/src/Weasel.Sqlite/Tables/IndexDefinition.cs index d3813cf8..e09c6f4e 100644 --- a/src/Weasel.Sqlite/Tables/IndexDefinition.cs +++ b/src/Weasel.Sqlite/Tables/IndexDefinition.cs @@ -47,6 +47,18 @@ protected IndexDefinition() set => throw new NotSupportedException("Covering (INCLUDE) indexes are not supported by this database provider"); } + string? Weasel.Core.ITableIndex.Method + { + get => null; + set + { + if (value != null) + { + throw new NotSupportedException("SQLite indexes do not have pluggable access methods"); + } + } + } + /// /// The collation sequence to use for text columns /// diff --git a/src/Weasel.Sqlite/Tables/TableColumn.cs b/src/Weasel.Sqlite/Tables/TableColumn.cs index e069cfde..fa07d3db 100644 --- a/src/Weasel.Sqlite/Tables/TableColumn.cs +++ b/src/Weasel.Sqlite/Tables/TableColumn.cs @@ -46,6 +46,22 @@ public TableColumn(string name, string type) /// public string? GeneratedExpression { get; set; } + string? ITableColumn.ComputedExpression + { + get => GeneratedExpression; + set + { + GeneratedExpression = value; + GeneratedType ??= GeneratedColumnType.Virtual; + } + } + + bool ITableColumn.ComputedColumnIsStored + { + get => GeneratedType == GeneratedColumnType.Stored; + set => GeneratedType = value ? GeneratedColumnType.Stored : GeneratedColumnType.Virtual; + } + /// /// For generated columns: STORED (materialized) or VIRTUAL (computed on read) ///