diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml
index 7dba5873..52020f5b 100644
--- a/.github/workflows/dotnetpull.yml
+++ b/.github/workflows/dotnetpull.yml
@@ -62,6 +62,7 @@ jobs:
if: always()
with:
name: test-results-${{ matrix.database }}
+ overwrite: true
path: TestResults/
if-no-files-found: error
- name: Remove test container
diff --git a/.gitignore b/.gitignore
index c92f3f9b..921f9fe6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,4 @@ packages/
**/appsettings.Development.json
TestResults/
+/artifacts/
diff --git a/Migrator.slnx b/Migrator.slnx
index 4cddff02..0fad1ce6 100644
--- a/Migrator.slnx
+++ b/Migrator.slnx
@@ -2,6 +2,9 @@
+
+
+
@@ -13,6 +16,14 @@
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index e8f8b420..ac0fa223 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ DotNetProjects.Migrator is a fork of [Migrator.NET](https://github.com/migratord
- **Bring your database driver.** The library does not directly reference database-driver packages; supply an ADO.NET connection or configure the driver factory.
- **SQLite schema handling.** This fork includes schema inspection and table-recreation logic for operations SQLite cannot perform directly.
-Migrator is a library you embed in a migration host. It does not provide EF-style model-difference scaffolding, a packaged command-line runner, or built-in migration-content checksum validation.
+The source upgrade adds a structured fluent API, runner filtering/lifecycle options, SQL-preview subset, native locking, a CLI project and optional Microsoft DI/logging integration. These changes are under review and **are not a released NuGet feature claim**. See the [runner and fluent guide](docs/runner-guide.md) and [detailed framework comparison](docs/migration-framework-comparison.md). EF-style model scaffolding and migration-content checksums remain outside the implementation.
## Installation and requirements
@@ -187,6 +187,16 @@ Important details:
See [ProviderFactory](src/Migrator/ProviderFactory.cs), [MigrationLoader](src/Migrator/MigrationLoader.cs) and [history implementation](src/Migrator/Providers/TransformationProvider.cs).
+## Fluent API and deployment tooling
+
+Run the [compiled fluent example](examples/FluentQuickStart/Program.cs):
+
+```sh
+dotnet run --project examples/FluentQuickStart
+```
+
+The example creates a complete table definition, previews it without changing history, runs a whole-session migration, then verifies automatic reversal. The [runner guide](docs/runner-guide.md) covers CLI commands, tags/profiles, maintenance, transactions, optional DI/logging, locks and preview limitations. Build the source packages locally to try the new tooling; no NuGet publication accompanies these PRs.
+
## Schema and data operations
Inside a migration, `Database` implements [`ITransformationProvider`](src/Migrator/Framework/ITransformationProvider.cs). It includes:
diff --git a/docs/index.html b/docs/index.html
index 768b513a..09171875 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -341,6 +341,11 @@
Choose by how you work.
>
+ Source upgrade under review, not a NuGet release:
+ fluent operations, SQL-preview subset, runner options, native locks and source CLI.
+ Read the runner guide and limitations.
+ Follow the PR stack.
+
Migrator fits applications that want explicit C# migrations and
scoped history without coupling schema changes to an ORM. Other
@@ -397,7 +402,7 @@
Choose by how you work.
| Authoring style |
- Handwritten C# Transformation API |
+ Imperative C# + structured fluent API |
Handwritten C# Fluent DSL |
C# generated from model changes; editable |
SQL scripts; C# scripts also supported |
@@ -432,7 +437,7 @@ Choose by how you work.
| Downgrade an applied version |
- Authored Down()
MigrateTo
+ Authored Down() or supported automatic reversal
|
Down(); auto-reverse for supported expressions
@@ -444,7 +449,7 @@ Choose by how you work.
|
| History / module separation |
- Scope in history table + selected assembly/types
+ Scope-filtered discovery + history
|
Custom version tables + migration filtering |
@@ -455,7 +460,7 @@ Choose by how you work.
|
| Transactions |
- Per migration |
+ Per migration; none or verified whole-session modes |
Per migration by default; configurable |
Most migrations wrapped automatically |
Opt-in per script or whole run; none by default |
@@ -463,7 +468,7 @@ Choose by how you work.
| Execution / deployment |
- Library; write your own host |
+ Library + source CLI (unreleased) |
In-process runner + CLI |
CLI, SQL scripts, bundles, runtime API |
Library; host in a console app or application |
@@ -481,7 +486,7 @@ Choose by how you work.
| Repeatable / recurring work |
- Custom application code |
+ Ordered maintenance + named profiles; no checksum repeatables |
Maintenance migrations / profiles |
Seeding APIs (EF 9+); custom code |
RunAlways scripts |
diff --git a/docs/issue-audit.md b/docs/issue-audit.md
new file mode 100644
index 00000000..477cfd4b
--- /dev/null
+++ b/docs/issue-audit.md
@@ -0,0 +1,99 @@
+# GitHub issue audit inventory
+
+Reviewed issue set: 81 issues (23 open and 58 closed at the start). Baseline: master `b7ae95c`; upgrade work is in PRs #173, #174, #175 and #177. Updated 2026-09-22.
+
+This inventory separates verified closures, fixes awaiting merge, partial fixes, and historical reports. A historical closed state is not proof of a fresh reproduction. The historical rows below identify named passing baseline tests or explicit source evidence. They have **not all been independently reproduced from their original reports**; related coverage is labeled and must not be treated as complete behavioral proof. No newly implemented fix is closed before its PR merges.
+
+Evidence used so far: clean master build and SQLite run (139 passed, one unrelated skipped default-removal test); master live matrix [35715528132](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35715528132); independent FK actions [35735648261](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35735648261); reproduced metadata/time failures [35737057890](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35737057890). The integrated source `bc35e0e` passed all eleven database/unit jobs and the coverage gate in [run 35743265022](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35743265022). Later fixes require their own green checks.
+
+| Issue | Disposition | Reproduction / relevant evidence / remaining work |
+| --- | --- | --- |
+| [#15](https://github.com/dotnetprojects/Migrator.NET/issues/15) Feature to use update method for copying columns | Historically closed; relevant baseline test verified | `UpdateFromTableToTable_Success` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#30](https://github.com/dotnetprojects/Migrator.NET/issues/30) Updates are not respecting command timeout | Historical report rechecked; retain closed state | Master Update explicitly assigns CommandTimeout when configured and attaches the provider transaction before execution. No fresh wall-clock timeout reproduction was run; this is source evidence. |
+| [#31](https://github.com/dotnetprojects/Migrator.NET/issues/31) Parameter names (and meaning) differ in ITransformationProvider and Implementation Class TransformationProvider | Historical report rechecked; retain closed state | The current interface names FK arguments childTable/childColumns and parentTable/parentColumns; PR #174 corrects the independent action path and definitions. Original report supplies screenshots only; no blanket claim for every parameter name. |
+| [#32](https://github.com/dotnetprojects/Migrator.NET/issues/32) Implementation of GetForeignKeyConstraints is wrong in TransformationProvider | Historically closed; relevant baseline test verified | `GetForeignKeyConstraints_MultiColumnColumn_Success` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#33](https://github.com/dotnetprojects/Migrator.NET/issues/33) SQLite Foreign Keys: OnDelete, OnUpdate, Match is not implemented (ignored in SQLite) | Partial; keep open | SQLite independent DELETE/UPDATE actions now execute; MATCH semantics still need an explicit supported-policy decision. |
+| [#34](https://github.com/dotnetprojects/Migrator.NET/issues/34) SQLite Foreign Keys: FKs added by AddTable are removed when using other methods | Historically closed; relevant baseline test verified | `AddForeignKey_RenameParentColumWithForeignKeyAndData_ForeignKeyPointsToRenamedColumn` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#35](https://github.com/dotnetprojects/Migrator.NET/issues/35) SQLite: UNIQUEs are removed when using some other methods after AddTable | Historically closed; relevant baseline test verified | `ChangeColumn_HavingColumnPropertyUniqueAndIndex_RebuildSucceeds` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#37](https://github.com/dotnetprojects/Migrator.NET/issues/37) Override in SQLite for AddForeignKey silently does nothing | Historically closed; relevant baseline test verified | `AddForeignKey_Cascade_DeletingParentDeletesReferencingChildren` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#38](https://github.com/dotnetprojects/Migrator.NET/issues/38) SQLite: Using AddTable with ColumnProperty.Unique silently does nothing | Historically closed; relevant baseline test verified | `AddUniqueColumn` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#39](https://github.com/dotnetprojects/Migrator.NET/issues/39) SQLite: Indexes are dropped if certain methods are called which internally call changeColumnInternal | Historically closed; relevant baseline test verified | `AddColumn_HavingColumnPropertyUniqueAndIndex_RebuildSucceeds` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#40](https://github.com/dotnetprojects/Migrator.NET/issues/40) Replace changeColumnInternal and implement different approach | Historically closed; relevant baseline test verified | `RecreateTable_HavingACompoundPrimaryKey_Success` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#41](https://github.com/dotnetprojects/Migrator.NET/issues/41) GetIndexes should distinguish between unique constraints and unique indexes | Historically closed; relevant baseline test verified | `GetSQLiteTableInfo_GetIndexesAndColumnsWithIndex_NoUniqueOnTheColumnsAndIndexExists` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#42](https://github.com/dotnetprojects/Migrator.NET/issues/42) Add GetUniques method for SQLiteTableInfo. This is utterly missing. | Historically closed; relevant baseline test verified | `GetUniques_Success` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#43](https://github.com/dotnetprojects/Migrator.NET/issues/43) T | Historically closed; retain state | Report title is only “T”; no reproducible requirement in the retrieved issue body. No new fix or closure claimed. |
+| [#44](https://github.com/dotnetprojects/Migrator.NET/issues/44) If ColumnProperty.PrimaryKey is removed, NotNull is removed as well | Historically closed; relevant baseline test verified | `AddPrimaryKey_AddPrimaryKey_ShouldStillBeNotNull` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#45](https://github.com/dotnetprojects/Migrator.NET/issues/45) ColumnProperty.ForeignKey has no own value but is combined using Unsigned and Null which is wrong | Historical report rechecked; retain closed state | Master ColumnProperty has no active ForeignKey enum member; the obsolete commented declaration is not a combined flag. The reported bit-mask implementation is absent. |
+| [#46](https://github.com/dotnetprojects/Migrator.NET/issues/46) SQLite: ConstraintExists returns false in any case (hard-coded). | Verified on master; closed | ConstraintExists reads SQLite metadata; clean master SQLite suite passed. |
+| [#47](https://github.com/dotnetprojects/Migrator.NET/issues/47) SQLite: GetConstraints returns empty array in any case (hard-coded). | Verified on master; closed | GetConstraints no longer returns an unconditional empty array; generic constraint tests cover metadata. |
+| [#48](https://github.com/dotnetprojects/Migrator.NET/issues/48) Schema is not supported in almost any case e.g. in AddTable | Partial; keep open | SQL Server schema-qualified column metadata corrected. PostgreSQL relation-based, parameterized column/constraint/existence lookup now has cross-schema and quoted-name regressions in PR #174; passed live PostgreSQL CI in run 35742976746. Cross-provider schema qualification is not complete. |
+| [#52](https://github.com/dotnetprojects/Migrator.NET/issues/52) AddForeignKey in TransformationProvider uses the same for OnUpdate and OnDelete which is wrong | Fixed in PR #174; await merge | Independent-action overload and provider guards; SQL Server update cascade/delete set-null regression passed live CI at b8b075e. |
+| [#53](https://github.com/dotnetprojects/Migrator.NET/issues/53) QuoteColumnNames should return a new list instead of changing the given list | Fixed in PR #174; await merge | QuoteColumnNamesIfRequired returns a fresh array; FK inputs are copied. |
+| [#54](https://github.com/dotnetprojects/Migrator.NET/issues/54) Constraint names are not quoted in many cases. Probably in all cases? | Partial; keep open | Generic removal and FK paths quote constraints. All provider-specific inline constraint paths still need review. |
+| [#56](https://github.com/dotnetprojects/Migrator.NET/issues/56) public virtual bool ViewExists(string view) implementation is wrong | Historical report rechecked; retain closed state | Master live Oracle ViewExists_ViewExists_Returns and ViewExists_ViewDoesNotExist_ReturnsFalse both passed. Provider-specific overrides remain important; this does not certify arbitrary custom-provider implementations. |
+| [#57](https://github.com/dotnetprojects/Migrator.NET/issues/57) public virtual bool TableExists(string view) implementation is wrong | Historical report rechecked; retain closed state | Master live SQL Server TableExists_WithSchemaNameTableExists_Returns and TableExists_TableDoesNotExist_ReturnsFalse passed. Qualified lookup gaps in other providers remain tracked in #48. |
+| [#59](https://github.com/dotnetprojects/Migrator.NET/issues/59) If NOT NULL or NULL is not explicitly given in the create script, notnull in PRAGMA table_info is wrong | Historical report rechecked; retain closed state | Master SQLite AddTable_NoNotNullColumn_NotNullIsFalse and AddTable_NotNullColumn_NotNullIsTrue passed, distinguishing implicit nullable columns from explicit NOT NULL. |
+| [#60](https://github.com/dotnetprojects/Migrator.NET/issues/60) We cannot use NULL in columns of a composite PK | Verified on master; closed | SQLite composite Guid PK regression inserts NULL members and rejects non-null duplicates; single-column PK regression rejects NULL. |
+| [#62](https://github.com/dotnetprojects/Migrator.NET/issues/62) PostgreSQL: '42883: function length(integer) does not exist | Historical report rechecked; retain closed state | Master PostgreSQL GetColumnContentSize_UseOnNonStringColumn_ThrowsSpeakingException passed. Non-string input is explicitly rejected rather than sent to length(integer). |
+| [#63](https://github.com/dotnetprojects/Migrator.NET/issues/63) T | Historically closed; retain state | Report title is only “T”; no reproducible requirement in the retrieved issue body. No new fix or closure claimed. |
+| [#64](https://github.com/dotnetprojects/Migrator.NET/issues/64) CHECK Constraints are not implemented in SQLiteTransformationProvider | Verified on master; closed | SQLite CHECK support and valid/invalid data regressions exist. |
+| [#65](https://github.com/dotnetprojects/Migrator.NET/issues/65) public override string[] GetConstraints(string table) returns an empty array in SQLite | Historically closed; relevant baseline test verified | `ConstraintExist` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#66](https://github.com/dotnetprojects/Migrator.NET/issues/66) RemoveAllConstraints should be implemented in SQLite | Historical report rechecked; retain closed state | Master RemoveAllConstraints cleared PK/UNIQUE but retained a CHECK TODO and foreign keys. PR #174 adds FK/CHECK removal; historical closure alone did not establish completeness. |
+| [#68](https://github.com/dotnetprojects/Migrator.NET/issues/68) Match child properties and parent properties with data of PRAGMA foreign_key_list | Historically closed; relevant baseline test verified | `GetForeignKeyConstraints_SingleColumn_Success` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#72](https://github.com/dotnetprojects/Migrator.NET/issues/72) TableExistsShouldWorkWithBracketsAndSchemaNameAndTableName Test fails | Historically closed; relevant baseline test verified | `TableExistsShouldWorkWithBracketsAndSchemaNameAndTableName` passed in the SQLServer artifact of master run 35715528132 (2 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#73](https://github.com/dotnetprojects/Migrator.NET/issues/73) SqlServerDialect has incorrect boundaries defined for NVARCHAR(n). Should be 4000 | Historically closed; relevant baseline test verified | `AddTableWithFixedLengthEqualTo4000Characters_ShouldCreateNVARCHAR4000` passed in the SQLServer artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#74](https://github.com/dotnetprojects/Migrator.NET/issues/74) Fix RemoveUnexistingColumn test for SQL Server | Historically closed; relevant baseline test verified | `RemoveUnexistingColumn` passed in the SQLServer artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#75](https://github.com/dotnetprojects/Migrator.NET/issues/75) Reactivate SQL Server Tests | Historical report rechecked; retain closed state | Master workflow run 35715528132 contains a successful live SQL Server job and its NUnit artifact; the provider is enabled in the required CI matrix. |
+| [#82](https://github.com/dotnetprojects/Migrator.NET/issues/82) AddTable/AddForeignKey does not quote names - important for Postgre SQL | Historical report rechecked; retain closed state | Master quotes reserved identifiers on several paths, with AddIndex_TableNameIsReservedWord_Succeeds passing. PR #174 expands constraint-name quoting. Full table/FK identifier coverage remains a limitation shared with #54. |
+| [#85](https://github.com/dotnetprojects/Migrator.NET/issues/85) Reactiveate MySQL tests | Verified on master; closed | PR #171 restored live MySQL/MariaDB tests; master CI run 35715528132 passed. |
+| [#89](https://github.com/dotnetprojects/Migrator.NET/issues/89) GetColumns() in Postgre does not even read the type nor does it convert it to DBType! | Historically closed; relevant baseline test verified | `GetColumns_DataTypeResolveSucceeds` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#90](https://github.com/dotnetprojects/Migrator.NET/issues/90) Default Values are not read correctly in Postgre using GetColumns() | Historically closed; relevant baseline test verified | `GetColumns_Postgres_DefaultValues_Succeeds` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#92](https://github.com/dotnetprojects/Migrator.NET/issues/92) Add boolean default value tests for Postgre | Historically closed; relevant baseline test verified | `GetColumns_DefaultValueBooleanValues_Succeeds` passed in the PostgreSQL artifact of master run 35715528132 (20 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#95](https://github.com/dotnetprojects/Migrator.NET/issues/95) Postgre SQL interval default value is not implemented | Historically closed; relevant baseline test verified | `GetColumns_Postgres_DefaultValues_Succeeds` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#97](https://github.com/dotnetprojects/Migrator.NET/issues/97) Postgres: GetColumnContentSize throws No function matches the given name and argument types. You might need to add explicit type casts. | Historically closed; relevant baseline test verified | `GetColumnContentSize_UseOnNonStringColumn_ThrowsSpeakingException` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#98](https://github.com/dotnetprojects/Migrator.NET/issues/98) GetColumnContentSize should return int? instead of int for empty tables or NULL columns | Additive fix in PR #174; await merge | GetNullableColumnContentSize distinguishes empty/all-NULL input while keeping the existing int contract. |
+| [#101](https://github.com/dotnetprojects/Migrator.NET/issues/101) GetColumns in SqlServerTransformationProvider swallows exceptions | Fixed in PR #174; await merge | SQL Server GetColumns propagates metadata errors rather than returning an empty schema. |
+| [#102](https://github.com/dotnetprojects/Migrator.NET/issues/102) GetColumns_UniqueButNotPrimaryKey_ReturnsFalse should be moved to generic GetColumns tests | Reproduced and verified in PR #174; await merge | Moving the uniqueness test to generic fixtures reproduced missing UNIQUE flags on SQL Server, Oracle and PostgreSQL (run 35737057890). Added catalog queries and a composite-constraint counterexample; all provider jobs passed run 35737814671. |
+| [#103](https://github.com/dotnetprojects/Migrator.NET/issues/103) SQL Server: GetColumns parses datetime as DbType.Date instead of DbType.DateTime/DateTime2 - Major bug | Historically closed; relevant baseline test verified | `AddTableDateTime2` passed in the SQLServer artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#104](https://github.com/dotnetprojects/Migrator.NET/issues/104) SQL Server: Default value of type DateTime/DateTime2 is not parsed | Historically closed; relevant baseline test verified | `GetColumns_DefaultValues_Succeeds` passed in the SQLServer artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#105](https://github.com/dotnetprojects/Migrator.NET/issues/105) Oracle: Only bool, Guid and DateTime are implemented in Default in OracleDialect | Historically closed; relevant baseline test verified | `GetColumns_Oracle_DefaultValues_Succeeds` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#106](https://github.com/dotnetprojects/Migrator.NET/issues/106) SQL Server type detection should be completely overhauled - does not work correctly | Historically closed; relevant baseline test verified | `GetColumns_DefaultValues_Succeeds` passed in the SQLServer artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#107](https://github.com/dotnetprojects/Migrator.NET/issues/107) SQL Server parser of default values does not work correctly and implements only a few data types. Should be fixed and extended. | Historically closed; relevant baseline test verified | `GetColumns_DefaultValues_Succeeds` passed in the SQLServer artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#108](https://github.com/dotnetprojects/Migrator.NET/issues/108) Oracle: Dialect for byte array byte[] fails => OracleException (0x80004005): ORA-03062: Ein Komma oder eine rechte Klammer fehlen | Historically closed; relevant baseline test verified | `GetColumns_Oracle_DefaultValues_Succeeds` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#109](https://github.com/dotnetprojects/Migrator.NET/issues/109) SQLite: RemoveForeignKey does nothing - silently! It is overridden but just returns - nothing else. | Historically closed; relevant baseline test verified | `RemoveForeignKey` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#110](https://github.com/dotnetprojects/Migrator.NET/issues/110) Implement GetCheckConstraints() - at least for generic tests | Historically closed; relevant baseline test verified | `GetCheckConstraints_AddCheckConstraintsViaAddTable_CreatesTableCorrectly` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#112](https://github.com/dotnetprojects/Migrator.NET/issues/112) No feedback if table or constraint does not exist in RemoveConstraint in TransformationProvider | Historically closed; relevant baseline test verified | `RemoveUnexistingForeignKey` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#113](https://github.com/dotnetprojects/Migrator.NET/issues/113) PrimaryKeyExists should be overridden and should throw in SQLite since it does not support named primary keys. | Historical report rechecked; retain closed state | Master overrides PrimaryKeyExists and reports whether any primary key exists, deliberately ignoring the supplied name. This differs from the issue suggestion to throw; preserve compatibility and document the actual semantics. |
+| [#114](https://github.com/dotnetprojects/Migrator.NET/issues/114) AddCheckConstraint is not overridden in SQLiteTransformationProvider | Historically closed; relevant baseline test verified | `CanAddCheckConstraint` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#115](https://github.com/dotnetprojects/Migrator.NET/issues/115) Some AddColumn virtual methods are not overridden in SQLite resulting in cascading failure. | Historically closed; relevant baseline test verified | `AddColumnWithDefaultButNoSize` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#118](https://github.com/dotnetprojects/Migrator.NET/issues/118) ColumnExists returns false in a catch! | Historical report rechecked; retain closed state | Master ColumnExists(table, column, ignoreCase) directly queries GetColumns without a catch. The exception-swallowing code in the report is absent. |
+| [#120](https://github.com/dotnetprojects/Migrator.NET/issues/120) Extend Oracle restrictions from 30bytes to 128bytes supporting Oracle versions greater than 12.1 | Historical report rechecked; retain closed state | Master Oracle validation uses Encoding.UTF8.GetBytes(name).Length with a 128-byte limit. PR #174 repairs column-name validation to validate each actual column. Older Oracle versions have different limits. |
+| [#122](https://github.com/dotnetprojects/Migrator.NET/issues/122) Oracle: AddIndex does not add a unique index if used in Index instance | Historically closed; relevant baseline test verified | `AddIndex_Unique_Success` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#123](https://github.com/dotnetprojects/Migrator.NET/issues/123) Indexes should be filterable | Historically closed; relevant baseline test verified | `AddIndex_FilteredIndexMiscellaneousFilterTypesAndDataTypes_Success` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#124](https://github.com/dotnetprojects/Migrator.NET/issues/124) PostgreSQL: AddIndex UNIQUE is not supported silently although available via Index class which is misleading | Historically closed; relevant baseline test verified | `AddIndex_Unique_Success` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#125](https://github.com/dotnetprojects/Migrator.NET/issues/125) Postgre: IncludeColumns in AddIndex is not used at all | Historically closed; relevant baseline test verified | `AddIndex_IncludeColumnsMultiple_Success` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#126](https://github.com/dotnetprojects/Migrator.NET/issues/126) Postgre does neither extract included columns nor does it retrieve the partial filters | Historically closed; relevant baseline test verified | `AddIndex_FilteredIndexSingle_Success` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#132](https://github.com/dotnetprojects/Migrator.NET/issues/132) SQL Server does not remove the unique index on ChangeColumn() with no ColumnProperty.Unique | Partial; keep open | New SQL Server column-owned uniqueness has an explicit extended-property marker. AdoptColumnUniqueConstraint now validates and marks an explicitly selected historical single-column UNIQUE constraint. Names never infer ownership; adoption and composite-rejection regressions passed live SQL Server in run 35741656276. |
+| [#134](https://github.com/dotnetprojects/Migrator.NET/issues/134) Remove hacks for some SQlite features | Partial; keep open | SQLite native rename/drop selected when eligible; guarded reconstruction retained. Unsupported table properties remain explicit failures. |
+| [#135](https://github.com/dotnetprojects/Migrator.NET/issues/135) Feature CopyDataFromTableToTable | Historically closed; relevant baseline test verified | `CopyDataFromTableToTable_UsingOrderBy_Success` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#139](https://github.com/dotnetprojects/Migrator.NET/issues/139) Default value is not reset on ChangeColumn | Fix in PR #174; await merge | Default removal regressions enabled; SQL Server default lookup and Oracle in-place reset corrected. |
+| [#140](https://github.com/dotnetprojects/Migrator.NET/issues/140) Remove table in Oracle does not cleanup sequences | Verified fix in PR #174; await merge | Default Oracle RemoveTable no longer guesses sequence ownership. RemoveTableWithOwnedSequences validates explicit legacy names and propagates cleanup errors. Passed live Oracle CI in run 35737814671. |
+| [#141](https://github.com/dotnetprojects/Migrator.NET/issues/141) RemoveTable in Oracle does not cleanup => TRIGGERs | Verified in PR #174; await merge | Oracle table-owned trigger cleanup is exercised by the legacy sequence/trigger regression; no guessed trigger-name cleanup. Passed live Oracle CI in run 35737814671. |
+| [#143](https://github.com/dotnetprojects/Migrator.NET/issues/143) Replace Identity trigger to "GENERATED...." | Historically closed; relevant baseline test verified | `GetColumns_GetIdentity_Succeeds` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#145](https://github.com/dotnetprojects/Migrator.NET/issues/145) ExecuteScalar("SELECT MAX(Id) FROM MyTable") should return null (C#) if table is empty | Additive fix in PR #174; await merge | ExecuteNullableScalar returns null for null/DBNull and preserves typed structs; existing ExecuteScalar contract stays compatible. |
+| [#146](https://github.com/dotnetprojects/Migrator.NET/issues/146) Oracle: Handle default value "NULL" | Historically closed; relevant baseline test verified | `DefaultValue_Null_Success` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#152](https://github.com/dotnetprojects/Migrator.NET/issues/152) Exception "This is currently not supported by the migrator see issue #44. You need to use NOT NULL for a PK column." occurs | Verified on master; closed | The old issue-44 exception is absent; SQLite supplies single-PK NOT NULL and supports nullable composite members in existing regressions. |
+| [#161](https://github.com/dotnetprojects/Migrator.NET/issues/161) Microsoft SQLite: FK integrity issue when using AddTable (by e.g. using AddColumn) | Fix in PRs #173/#174; await merge | SQLite FK settings restored after success/failure; integrity checked before commit; rebuild dependencies guarded. Regression coverage uses both driver paths. |
+| [#162](https://github.com/dotnetprojects/Migrator.NET/issues/162) DbType.Time is not implemented | Partial; keep open | SQL Server native TIME metadata/defaults and TimeSpan binding verified live. PostgreSQL native TIME metadata/default parsing now has a regression in PR #174; passed live PostgreSQL CI in run 35742976746. Oracle and SqlServer2005 retain documented historical representations; no universal native time claim. |
+| [#164](https://github.com/dotnetprojects/Migrator.NET/issues/164) PostgreTransform Provider does not quote IncludeColumns for reserved names | Historically closed; relevant baseline test verified | `AddIndex_IncludeColumnsWithReservedWord_Succeeds` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#165](https://github.com/dotnetprojects/Migrator.NET/issues/165) Included columns not quoted in Postgre | Duplicate verified; closed | Duplicates #164; PostgreSQL included-column quoting is covered by the live metadata regression. |
+| [#167](https://github.com/dotnetprojects/Migrator.NET/issues/167) Get columns in postgre should not quote table name | Historically closed; relevant baseline test verified | `AddIndex_TableNameIsReservedWord_Succeeds` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+| [#169](https://github.com/dotnetprojects/Migrator.NET/issues/169) Support ON DELETE CASCADE (and other FK actions) in SQLite migrator | Historically closed; relevant baseline test verified | `AddForeignKey_Cascade_DeletingParentDeletesReferencingChildren` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. |
+
+## Remaining audit work
+
+- Finish individual source/test evidence for historical closed reports; do not interpret a broad green suite as proof of every original report.
+- Recheck partial schema qualification, inline identifier quoting, historical uniqueness ownership and provider-specific time representations.
+- Keep partial ownership/scope reports open; new complete fixes close only after the referenced PR merges.
+- Keep issue comments and this inventory synchronized as evidence changes.
+
diff --git a/docs/migration-framework-comparison.md b/docs/migration-framework-comparison.md
index 1894c4ae..817c70c9 100644
--- a/docs/migration-framework-comparison.md
+++ b/docs/migration-framework-comparison.md
@@ -4,7 +4,7 @@
The main matrices cover **DotNetProjects.Migrator, FluentMigrator, EF Core migrations, DbUp and Evolve**—all five frameworks on the homepage. Additional sections cover **EF6, grate and RoundhousE**, with a short boundary comparison for **Flyway and Liquibase**. This is a defined shortlist, not a claim to catalogue every migration package ever published.
-Migrator findings are pinned to repository commit [`ab3aa9f`][m-revision], before the parallel refactoring. FluentMigrator's SQLite implementation is pinned to [`2e0acdb`][f-sqlite-generator]. Other findings describe the linked official documentation as reviewed, not guaranteed behavior of every historical release. EF Core features introduced in version 9 are labeled. Check provider and release compatibility separately.
+Migrator findings are pinned to upgrade-stack commit [`bc35e0e`][m-revision]. These are source capabilities under review in PRs [#173](https://github.com/dotnetprojects/Migrator.NET/pull/173), [#174](https://github.com/dotnetprojects/Migrator.NET/pull/174), [#175](https://github.com/dotnetprojects/Migrator.NET/pull/175) and [#177](https://github.com/dotnetprojects/Migrator.NET/pull/177), **not a claim that these features have shipped on NuGet**. FluentMigrator's SQLite implementation is pinned to [`2e0acdb`][f-sqlite-generator]. Other findings describe the linked official documentation as reviewed, not guaranteed behavior of every historical release. EF Core features introduced in version 9 are labeled. Check provider and release compatibility separately.
[Homepage](https://dotnetprojects.github.io/Migrator.NET/) · [Project README](../README.md) · [SQLite emulation comparison](#sqlite-emulation-comparison) · [Source index](#source-index)
@@ -47,13 +47,13 @@ Evidence: [Migrator runner][m-runner], [loader][m-loader], [migration contract][
| Requires an ORM model | No | No | Yes, for normal scaffolding | No | No |
| Generates changes from model differences | No built-in | No built-in model differ in core workflow | Yes | No; author scripts | No; author scripts |
| Migration without a model change | Yes | Yes | Empty migration, then custom operations | Yes | Yes |
-| Schema DSL / transformation API | `Database` operations; optional `SchemaBuilder` | Fluent create/alter/delete expressions | `MigrationBuilder` operations | No schema DSL; SQL / commands | No schema DSL; SQL |
+| Schema DSL / transformation API | Imperative API and structured `MigrationBuilder`; provider limits apply | Fluent create/alter/delete expressions | `MigrationBuilder` operations | No schema DSL; SQL / commands | No schema DSL; SQL |
| Custom C# logic | `Up` / `Down`; open provider | Migration code / connection operations | SQL/custom operations for database work | `IScript` and command factory | Surrounding host logic; migrations are SQL |
| Raw SQL | Command, query and scalar APIs | Inline, file and embedded SQL | `migrationBuilder.Sql` | Primary workflow | Primary workflow |
| Migration discovery | Assembly scan or explicit `Type[]` | Assembly scanning / filters | Context's migration assembly | Configurable script providers | Locations or embedded resources |
-| Constructor dependency injection | Default loader uses `Activator.CreateInstance`; customize loader | Runner/DI integration | Context services; migration customization is separate | Custom script provider/host if needed | No C# migration constructors |
+| Constructor dependency injection | Optional Microsoft DI/options package; custom activator supported | Runner/DI integration | Context services; migration customization is separate | Custom script provider/host if needed | No C# migration constructors |
| Embedded execution | Yes | Yes | Yes | Yes | Library mode |
-| Dedicated execution host | Write your own | Library or packaged runner | Tooling, bundles or custom host | Write your own | CLI, .NET tool or library |
+| Dedicated execution host | Library or source-built packaged .NET tool (unreleased) | Library or packaged runner | Tooling, bundles or custom host | Write your own | CLI, .NET tool or library |
EF Core's model snapshot comparison is not a live-database schema comparison. DbUp's C# support is more than static SQL file loading, but it does not supply a cross-database schema-operation layer.
@@ -90,16 +90,16 @@ Evidence: [Migrator loader][m-loader], [execution][m-execution] and [history sto
| Default history | `SchemaInfo` | Version table | `__EFMigrationsHistory` | E.g. `SchemaVersions` | `changelog` |
| History customization | Table name; scope column | Version-table metadata | Table/schema; custom services | Custom journal / table | Metadata table/schema |
| Independent modules | Scope + selected migrations | Separate history + filters | Contexts/assemblies + separate history | Filters + separate journals | Locations + separate metadata |
-| Environment selection | Host selection; ignore attribute for assembly discovery | Tags / profiles / configuration | Context/deployment configuration | Filters / host | Locations / placeholders / host |
+| Environment selection | Tags with explicit Any/All matching; scopes and named profiles | Tags / profiles / configuration | Context/deployment configuration | Filters / host | Locations / placeholders / host |
| Skip applied work | Version history | Version history | Migration history | Journal | Metadata |
| Applied-source checksum | No built-in | Not a core version-table guarantee | No script checksum journal | Standard journal tracks names; custom validation | Script checksums |
| Late lower-numbered change | Revisits missing versions up to target | Check runner policy | Do not assume IDs make diverging branches safe | Unrecorded scripts eligible; ordering matters | `OutOfOrder` |
| Repeat on content change | Custom | Not equivalent to maintenance/profiles | Not equivalent to seeding | Custom checksum-aware runner | Repeatable SQL |
-| Always-run work | Host code; hooks are per executed migration | Maintenance / selected profiles | Seeding APIs, EF 9+ | `RunAlways` / `NullJournal` | Not identical to RunAlways |
+| Always-run work | Ordered before/after-run and before/after-migration stages; selected profiles | Maintenance / selected profiles | Seeding APIs, EF 9+ | `RunAlways` / `NullJournal` | Not identical to RunAlways |
| Existing-schema baseline | Custom verified history initialization | Custom baseline/runner strategy | Existing-schema workflow | `MarkAsExecuted` | `StartVersion` / skip options |
| Repair checksums | Not applicable | Not established by version history | Not applicable | Custom journal concern | `repair` |
-**Migrator scope detail:** `MigrationAttribute.Scope` changes where a history record is written; it does not filter assembly discovery. A runner reads its provider scope and checks duplicate versions across its entire loaded set. Use separate assemblies or explicit types, normally leaving the attribute scope unset. History isolation is not table isolation. [Loader][m-loader], [execution][m-execution], [provider][m-provider].
+**Migrator scope detail:** unscoped migrations inherit the runner scope. Explicitly scoped migrations are selected only for that scope; duplicate validation and history access use the same effective scope. Custom legacy providers without `IMigrationHistory` retain their prior behavior. History isolation is not table isolation. [Loader][m-loader], [execution][m-execution], [provider][m-provider].
## Transactions, rollback and coordination
@@ -108,14 +108,14 @@ Evidence: [Migrator execution][m-execution] and [runner][m-runner]; [FluentMigra
| Capability | Migrator | FluentMigrator | EF Core | DbUp | Evolve |
| ------------------------------ | -------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------ |
| Default transaction unit | Per migration | Per migration; configurable | Version-sensitive: EF 9 grouped pending migrations, reverted in EF 10 | None | Per migration |
-| Whole-run transaction | Not a runner option | Configure/orchestrate; check runner | Depends on version/operations | `WithTransaction()` | `CommitAll` |
-| Per-change transaction opt-out | No migration attribute | Transaction behavior | Raw SQL suppression | Choose strategy / separate runs | Script opt-out |
+| Whole-run transaction | `WholeSession` for verified SQLite, PostgreSQL and SQL Server dialects | Configure/orchestrate; check runner | Depends on version/operations | `WithTransaction()` | `CommitAll` |
+| Per-change transaction opt-out | Run-level `None`; no per-migration transaction attribute | Transaction behavior | Raw SQL suppression | Choose strategy / separate runs | Script opt-out |
| Failed DDL rollback | Engine-dependent | Engine-dependent | Engine-dependent | When enabled and supported | Engine-dependent |
| Reverse committed migration | Authored `Down()` | `Down()` | Generated/editable `Down()` | Custom undo / forward fix | Forward fix; no Down command |
-| Generate reverse operations | No | Supported auto-reverse expressions | Scaffolding; review output | No schema reverse generator | No |
+| Generate reverse operations | Supported create/rename operations; explicit reverse required for destructive/data/SQL operations | Supported auto-reverse expressions | Scaffolding; review output | No schema reverse generator | No |
| Target earlier version | `MigrateTo` | Down/rollback APIs | Earlier target / reverse script | Custom | Target limits forward work, not undo |
| Restore deleted data | Backup / reconstruction | Same | Same | Same | Same |
-| Cross-process coordination | No built-in migration lock found | Serialize deployment / application-lock pattern | Migration locking, EF 9+; execution-path dependent | Host/provider concern; journal is not a lock | Cluster setting; provider-dependent |
+| Cross-process coordination | Opt-in native session locks for SQL Server, PostgreSQL and MySQL/MariaDB; custom abstraction | Serialize deployment / application-lock pattern | Migration locking, EF 9+; execution-path dependent | Host/provider concern; journal is not a lock | Cluster setting; provider-dependent |
| Post-commit hooks | `AfterUp` / `AfterDown` | Maintenance stages | Host/seeding lifecycle; not direct equivalent | Host / ordered scripts | Host / ordered scripts |
A scope, checksum, history primary key or ordinary database write lock does not prove that two deployments can safely run the entire sequence concurrently. Evolve's cluster setting must be checked for the selected provider; it is not a blanket SQLite session-lock guarantee.
@@ -126,14 +126,14 @@ Evidence: [Migrator runner][m-runner] and [execution][m-execution]; [FluentMigra
| Capability | Migrator | FluentMigrator | EF Core | DbUp | Evolve |
| ------------------------------------ | ------------------------------------------------- | ------------------------------------------ | --------------------------------- | -------------------------------- | ------------------------------------------------ |
-| Packaged CLI | No | Yes | `dotnet ef` | Core library; custom host | Yes |
+| Packaged CLI | Source project `DotNetProjects.Migrator.Tool`; not published by this upgrade | Yes | `dotnet ef` | Core library; custom host | Yes |
| Dedicated migration bundle generator | No; publish host | Package runner/migrations | Yes | Publish host | CLI distribution, not EF-style bundle generation |
-| Review SQL without applying | No equivalent runner SQL generator | Preview/output | Scripts | Authored SQL / pending scripts | Authored SQL |
-| Dry-run qualification | Skips bodies; still touches provider/transactions | Processor preview; user code needs care | Not a full side-effect simulation | Pending list / custom simulation | `RollbackAll` actually executes |
+| Review SQL without applying | Connected/offline structured subset; unsupported operations fail explicitly | Preview/output | Scripts | Authored SQL / pending scripts | Authored SQL |
+| Dry-run qualification | `DryRun` plans versions without migration bodies, callbacks, transactions or history creation | Processor preview; user code needs care | Not a full side-effect simulation | Pending list / custom simulation | `RollbackAll` actually executes |
| Idempotent deployment SQL | Custom | Preview is not idempotent history guarding | Provider-dependent; not SQLite | Author SQL / use journal | Author SQL / use metadata |
| Status | Versions / loaded types | Runner/tool info | CLI / history APIs | Pending/executed APIs | `info` |
| Command timeout | Provider setting | Processor setting | Database/provider setting | Runner/provider setting | `CommandTimeout` |
-| Logging | `ILogger` / writers | Logging integration | EF logging | `IUpgradeLog` / integrations | Host/CLI |
+| Logging | Legacy logger plus optional Microsoft logging adapter (SQL/exception details omitted) | Logging integration | EF logging | `IUpgradeLog` / integrations | Host/CLI |
| SQL substitution | Custom | Script tokens | Custom logic | `$variable$` | `${placeholder}` |
| Deployment identity | Host connection | Runner connection | Migration connection | Host connection | Tool connection |
@@ -170,8 +170,8 @@ Evidence: [EF Core SQLite operation table][ef-sqlite], [FluentMigrator SQLite ge
| Existing-table operation | Migrator | FluentMigrator | EF Core | DbUp / Evolve |
| ---------------------------- | --------------------------------- | ------------------------------- | ----------- | ---------------------------------------------- |
| Add ordinary column | R | N | N | Manual SQL |
-| Remove column | R | N; engine restrictions | R | Manual SQL/rebuild |
-| Rename column | R | N; engine restrictions | N | Manual SQL/rebuild |
+| Remove column | N on SQLite 3.35+ when eligible; R fallback | N; engine restrictions | R | Manual SQL/rebuild |
+| Rename column | N on SQLite 3.26+; R fallback | N; engine restrictions | N | Manual SQL/rebuild |
| Change declared type | R | Manual | R | Manual rebuild |
| Change nullability | R | Manual | R | Manual SQL on 3.53+ / rebuild on older engines |
| Change default | R via full `Column` | Manual | R via alter | Manual rebuild |
@@ -201,16 +201,16 @@ Methods refer to the pinned [SQLite provider][m-sqlite]. Tests illustrate eviden
| ------------------------------------ | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AddColumn` | Adds a column and mapping without an old source column; rebuilds. | Existing rows receive SQLite default/NULL behavior; incompatible NOT NULL requirements can fail. [Tests][t-add-column]. |
| `ChangeColumn` | Replaces the entire matching `Column` definition; rebuilds. | Specify properties to retain. Type affinity during copying is not arbitrary data conversion. [Tests][t-change-column]. |
-| `RemoveColumnDefaultValue` | Clears parsed default; rebuilds. | Dedicated API is exercised, but generic `ChangeColumn_RemoveDefaultValue_Success` is skipped under issue #139. Not every default-removal path is verified. [Tests][t-sqlite-general]. |
-| `RemoveColumn` | Removes column/mapping and matching single-column indexes/uniques/FKs; rebuilds affected tables. | Rejects detected CHECK references and composite dependencies until adjusted. Can remove inbound single-column FKs from other tables. [Tests][t-remove-column]. |
-| `RenameColumn` | Changes copy mapping, column and represented key/index references; adjusts referencing tables. | Requires FK enforcement off; not an arbitrary SQL-expression rewriter. [Tests][t-rename-column]. |
+| `RemoveColumnDefaultValue` | Clears parsed default; rebuilds. Generic default-removal regression is enabled and passes. | Dedicated and generic default-removal regressions run; provider CI is required for changes. [Tests][t-sqlite-general]. |
+| `RemoveColumn` | Uses native DROP COLUMN on SQLite 3.35+ for eligible columns; otherwise removes represented dependencies and rebuilds. | Rejects detected CHECK references and composite dependencies until adjusted. Can remove inbound single-column FKs from other tables. [Tests][t-remove-column]. |
+| `RenameColumn` | Native on SQLite 3.26+; reconstruction fallback for older engines. | Native rename delegates dependency rewriting to SQLite; reconstruction is not an arbitrary SQL-expression rewriter. [Tests][t-rename-column]. |
| `AddPrimaryKey` | Sets membership, orders selected columns, rebuilds. | Composite keys supported; `PrimaryKeyExists` checks for any PK rather than matching its name. [Tests][t-pk]. |
| `RemovePrimaryKey` | Clears PK/PK-identity flags; rebuilds. | Changes identity-related semantics; review referencing tables. [Source][m-sqlite]. |
| `AddForeignKey` / `RemoveForeignKey` | Adds/removes represented FK; rebuilds child table. | Validate existing rows and enforcement. [FK tests][t-fk], [integrity tests][t-integrity]. |
| `AddUniqueConstraint` | Adds named unique definition; rebuilds. | Duplicate data can reject the copy. [Metadata tests][t-uniques]. |
| `AddCheckConstraint` | Adds named CHECK SQL; rebuilds. | Predicate must accept existing rows and be understood by the reader. [Tests][t-check]. |
| `RemoveConstraint` | Removes matching unique and check definitions; rebuilds. | Does not remove FKs/PKs; use dedicated APIs. [Source][m-sqlite]. |
-| `RemoveAllConstraints` | Removes PK/unique definitions via rebuilds. | Retains FKs and leaves CHECK handling incomplete; not an all-constraint eraser. [Tests][t-remove-constraints], [source][m-sqlite]. |
+| `RemoveAllConstraints` | Clears PK, unique, FK and CHECK definitions before rebuilding. | Constraint removal can fail when dependent schemas/data require a coordinated migration. [Tests][t-remove-constraints], [source][m-sqlite]. |
| `RemoveAllIndexes` | Clears indexes **and unique constraints**; rebuilds. | Broader than dropping non-unique indexes. [Source][m-sqlite]. |
| `RecreateTable` | Public low-level schema/mapping reconstruction. | Requires a consistent supported representation. [Composite-key round-trip test][t-recreate]. |
| `TruncateTable` | Emits `DELETE FROM`. | Not native TRUNCATE and not an identity-sequence reset. [Source][m-sqlite]. |
@@ -225,15 +225,15 @@ Methods refer to the pinned [SQLite provider][m-sqlite]. Tests illustrate eviden
| FKs and delete actions | Read from schema/PRAGMA; emitted into replacement DDL. | Not a promise about every clause, e.g. arbitrary deferrability. |
| Unique / CHECK definitions | Included in `SQLiteTableInfo`. | Reader restrictions apply; rename does not rewrite arbitrary CHECK expressions. |
| Indexes / represented filters | Recreated after replacement. | Complex predicates, expressions, collations and sort details require separate verification. |
-| Triggers | No trigger collection/replay in schema model or rebuild. | Do not assume preservation; a table drop removes its triggers. Recreate as needed. |
+| Triggers | Collected and replayed for supported rebuilds without renames; unsafe rename fallback rejected. | Trigger SQL is replayed only where the rebuild does not require rewriting its identifiers. |
| Views / dependent SQL | No general dependency-SQL rewrite. | Validate/recreate dependencies after renames/drops. |
-| `WITHOUT ROWID`, `STRICT`, generated columns | Not modeled as a complete round-trip contract. | No blanket preservation claim for external schemas. |
-| Hidden `rowid` / AUTOINCREMENT high-water mark | Only mapped columns copied; no explicit sequence-state restoration. | Historical rowid/sequence metadata may change. |
+| `WITHOUT ROWID`, `STRICT`, generated columns | Unsupported reconstruction is rejected before dropping the original. | No preservation claim for unsupported external table properties. |
+| Hidden `rowid` / AUTOINCREMENT high-water mark | Mapped columns and retained AUTOINCREMENT high-water state are preserved; hidden rowid is not mapped. | Deleted historical identity values are not reused after a rebuild; hidden rowid values may change. |
| Type / length enforcement | Changes declarations, not SQLite typing rules. | Declared size is not SQL Server-like length enforcement. |
-| FK enforcement state | Runner disables before migration and restores after successful execution. | Direct provider calls differ; exception restoration is not proven by success-path tests. |
-| Whole-database FK validation | Integrity helper exists; runner does not automatically invoke it. | Enabling enforcement alone does not validate existing rows. |
+| FK enforcement state | Runner and owned rebuild transactions restore the prior setting after success/failure. | Caller-owned active SQLite transactions require FK settings to be configured before beginning the transaction. |
+| Whole-database FK validation | Runner and owned rebuild transactions validate integrity before commit. | Enabling enforcement alone does not validate existing rows. |
-Evidence: [SQLite provider][m-sqlite], [schema model][m-sqlite-model], [execution][m-execution], [SQLite reconstruction procedure][sqlite-alter]. Re-evaluate these limitations after the parallel refactoring.
+Evidence: [SQLite provider][m-sqlite], [schema model][m-sqlite-model], [execution][m-execution], [SQLite reconstruction procedure][sqlite-alter]. Native drop-column selection and AUTOINCREMENT high-water preservation have regressions. Arbitrary dependency rewriting remains unsupported.
### How the other frameworks compare on preservation
@@ -306,25 +306,19 @@ These interpretations are grounded in the preceding evidence, rather than univer
Potential Migrator improvements, **not implemented-feature claims**:
-1. A packaged CLI and dedicated SQL-preview/export workflow.
+1. Broader structured SQL-preview coverage, more client-script dialects and CLI deployment validation. SQL Server GO scripts now use an explicit batch path. The source CLI and preview subset already exist.
2. Validation of edits to already applied migration content.
-3. Cross-process migration locking and explicit failure recovery.
+3. More native lock backends and recovery/concurrency validation; three database families now have opt-in locks.
4. Repeatable migrations distinct from execution hooks.
-5. Stronger SQLite preservation of triggers, generated columns, table options and complex indexes.
-6. Clearer bulk-removal semantics and FK-state restoration after exceptions.
+5. SQLite generated columns, table options, hidden rowid and complex-index preservation beyond the currently guarded subset.
+6. Broader behavioral parity tests beyond the [fluent method-family inventory](fluent-operation-coverage.md), and provider coverage for explicit adoption of historical uniqueness objects without ownership markers.
7. Continued operation-level provider documentation and live test coverage.
## Validation and maintenance
-Reviewed in a separate Git worktree based on `ab3aa9f`. No migration implementation files or parallel-refactoring checkout were changed.
+The master baseline (`b7ae95c`) passed 139 SQLite tests with one skipped default-removal test. The pinned upgrade revision passed **93 unit tests and 184 SQLite tests, with no skips**, after rebuilding the solution. Test counts reflect replacement of assertion-free tests with behavioral checks. The packed/installed tool previously passed offline SQL, migration, status and rollback smoke checks. The provider fixes at `bdc8ac3` passed all eleven database/unit jobs and the coverage gate in [run 35737814671](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35737814671). Concurrent-runner tests passed on SQL Server, PostgreSQL, MySQL and MariaDB at `bb88165` in [run 35741656276](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35741656276). The pinned revision `bc35e0e`, including PostgreSQL metadata/time changes, passed all eleven database/unit jobs and the coverage gate in [run 35743265022](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35743265022). A green earlier revision is not evidence for a later revision.
-The existing SQLite category was executed on Windows:
-
-```sh
-dotnet test src/Migrator.Tests/Migrator.Tests.csproj --configuration Release --filter "TestCategory=SQLite"
-```
-
-**139 passed, 1 skipped, 0 failed.** The skipped test is `ChangeColumn_RemoveDefaultValue_Success`, documented by [issue #139](https://github.com/dotnetprojects/Migrator.NET/issues/139). Existing compiler warnings were present. This validates existing scenarios, not the complete preservation matrix. Competitors were reviewed through documentation/source, **not executed in a comparative test harness**.
+This is not a complete implementation of the upgrade plan: SQL preview supports a structured subset; offline CLI rejects profiles/maintenance; full client-script dialects, remaining metadata/legacy ownership cases and broader deployment regressions remain work in progress. SQL Server GO splitting and explicit Oracle legacy sequence cleanup are implemented. See the [81-issue inventory](issue-audit.md) for verified closures and incomplete audit items. The operation inventory maps normal API method families to fluent/context entry points, but does not establish every overload/provider combination through execution. Competitors were reviewed through documentation/source, **not executed in a comparative harness**.
When updating:
@@ -345,29 +339,29 @@ When updating:
- **grate / RoundhousE:** [grate][g-home], [options][g-config], [script types][g-types], [migration guide][g-migrate], [RoundhousE][r-home].
- **SQLite engine:** [ALTER TABLE and reconstruction procedure][sqlite-alter].
-[m-runner]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/Migrator.cs
-[m-loader]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/MigrationLoader.cs
-[m-execution]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/MigrateAnywhere.cs
-[m-migration]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/Framework/Migration.cs
-[m-api]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/Framework/ITransformationProvider.cs
-[m-provider]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/Providers/TransformationProvider.cs
-[m-factory]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/ProviderFactory.cs
-[m-live]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/docs/live-database-tests.md
-[m-sqlite]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs
-[m-sqlite-model]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/Providers/Impl/SQLite/Models/SQLiteTableInfo.cs
-[t-add-column]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddColumnTests.cs
-[t-change-column]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_ChangeColumnTests.cs
-[t-remove-column]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveColumnTests.cs
-[t-rename-column]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RenameColumnTests.cs
-[t-pk]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddPrimaryKeyTests.cs
-[t-fk]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs
-[t-integrity]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_CheckForeignKeyIntegrityTests.cs
-[t-uniques]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetUniques.cs
-[t-check]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetCheckConstraintsTests.cs
-[t-remove-constraints]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveAllConstraintsTests.cs
-[t-recreate]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RecreateTable.cs
-[t-sqlite-general]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProviderTests.cs
-[m-revision]: https://github.com/dotnetprojects/Migrator.NET/tree/ab3aa9f488196139334ae4b2ea335e803a280533/
+[m-runner]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator/Migrator.cs
+[m-loader]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator/MigrationLoader.cs
+[m-execution]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator/MigrationExecution.cs
+[m-migration]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator/Framework/Migration.cs
+[m-api]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator/Framework/ITransformationProvider.cs
+[m-provider]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator/Providers/TransformationProvider.cs
+[m-factory]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator/ProviderFactory.cs
+[m-live]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/docs/live-database-tests.md
+[m-sqlite]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs
+[m-sqlite-model]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator/Providers/Impl/SQLite/Models/SQLiteTableInfo.cs
+[t-add-column]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddColumnTests.cs
+[t-change-column]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_ChangeColumnTests.cs
+[t-remove-column]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveColumnTests.cs
+[t-rename-column]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RenameColumnTests.cs
+[t-pk]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddPrimaryKeyTests.cs
+[t-fk]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs
+[t-integrity]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_CheckForeignKeyIntegrityTests.cs
+[t-uniques]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetUniques.cs
+[t-check]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetCheckConstraintsTests.cs
+[t-remove-constraints]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveAllConstraintsTests.cs
+[t-recreate]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RecreateTable.cs
+[t-sqlite-general]: https://github.com/dotnetprojects/Migrator.NET/blob/bc35e0e626545785faa2fdd8df0affa6d16f1349/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProviderTests.cs
+[m-revision]: https://github.com/dotnetprojects/Migrator.NET/tree/bc35e0e626545785faa2fdd8df0affa6d16f1349/
[f-start]: https://fluentmigrator.github.io/intro/quick-start.html
[f-config]: https://fluentmigrator.github.io/intro/configuration.html
[f-sql]: https://fluentmigrator.github.io/operations/execute-sql.html
diff --git a/docs/runner-guide.md b/docs/runner-guide.md
new file mode 100644
index 00000000..8689ef43
--- /dev/null
+++ b/docs/runner-guide.md
@@ -0,0 +1,112 @@
+# Runner and fluent API upgrade
+
+These APIs describe the source upgrade under review in PRs #173, #174, #175 and #177. They are not a statement about the currently released NuGet packages. Build the repository to try them; no package publication is part of this change.
+
+## Fluent quick start
+
+The [compiled quick-start project](../examples/FluentQuickStart/Program.cs) executes preview, migration and automatic reversal against SQLite:
+
+```sh
+dotnet run --project examples/FluentQuickStart
+```
+
+```csharp
+[Migration(1, Scope = "demo"), Tags("core")]
+public class CreateUsers : AutoReversingMigration
+{
+ public override void BuildUp(MigrationBuilder migration)
+ {
+ migration.Create.Table("Users")
+ .WithColumn("Id").AsInt32().PrimaryKey()
+ .WithColumn("Name").AsString(255).NotNullable();
+ }
+}
+```
+
+Use `DotNetProjects.Migrator`, `.Framework` and `.Framework.Fluent`. A table definition is completed before execution. Existing imperative `Migration.Up/Down` classes keep working. `FluentMigration` supports authored `BuildDown`; `AutoReversingMigration` reverses supported create/rename operations in reverse order. Destructive changes, data, SQL and callbacks need explicit reverse operations. Automatic reversal never restores deleted data.
+
+The builder has `Create`, `Alter`, `Delete`, `Rename`, `Insert`, `Update`, `Execute` and `Administration`. Schema inspection is exposed through `FluentMigration.Schema`, and the provider through `Context`. History and transaction methods remain explicit context operations. Administrative operations, views, data copying and updates from another table have typed operations; their SQL preview is currently unsupported. See the [operation coverage inventory](fluent-operation-coverage.md) for the normal API mappings and test limits.
+
+## Scripts and provider-specific cleanup
+
+`Execute.Script(path)` and `Execute.EmbeddedScript(assembly, resourceName)` capture script text as dedicated operations. Imperative callers can use `ExecuteScript(path)`, `ExecuteResourceScript(assembly, name)` and `ExecuteSqlScript(text)`. SQL Server splits standalone `GO` lines, including an optional `--` comment, while respecting strings, quoted identifiers and nested comments. GO repetition and SQLCMD directives fail explicitly before executing batches. Ordinary `ExecuteNonQuery` and fluent `Execute.Sql` never split client separators. Other providers receive the script as one command unless they implement `IScriptBatchProvider`; this is not a complete SQL*Plus, mysql-client or isql interpreter.
+
+Oracle `RemoveTable` leaves unrelated sequences intact and relies on Oracle to remove table-owned triggers and native identity objects. For legacy sequences you explicitly own, use `OracleTransformationProvider.RemoveTableWithOwnedSequences(table, sequenceNames)` through an explicit provider context/callback. It accepts simple unquoted sequence names, validates existence before dropping the table, and propagates cleanup failures. Oracle DDL is not atomic. SQL Server removes only column-unique constraints carrying its ownership marker; historical unmarked objects can be adopted explicitly with `SqlServerTransformationProvider.AdoptColumnUniqueConstraint(table, column, constraint)`. Adoption verifies a single-column UNIQUE constraint before marking it and rejects composite constraints. Names alone never establish ownership.
+
+## Runner options
+
+`runner.Options` supports:
+
+| Option | Semantics |
+| --- | --- |
+| `Tags` / `TagMatch` | Ordinal names; explicit `Any` or `All`. No filter selects all versioned migrations. Filtered applied versions remain applied on downgrade. |
+| `Profiles` | Explicit names of `[Profile("name")]` classes. Run after versioned migrations without recording versions; run again when selected again. |
+| `TransactionMode` | `PerMigration` by default; `None` or `WholeSession` available. |
+| `Activator` | Optional constructor activation delegate. |
+| `Lock` / `LockTimeout` | Optional `IMigrationLock` lease; acquire before reading history and release on completion/failure. |
+
+Unscoped migrations inherit the provider scope; explicitly scoped migrations run only in that scope. Discovery, duplicate validation and history reads use the effective scope. Scopes separate history, not tables. Legacy custom providers can adopt the additive `IMigrationHistory` interface for read-only planning and effective-scope selection.
+
+Maintenance classes use `[Maintenance(MaintenanceStage.BeforeRun)]`, `BeforeMigration`, `AfterMigration` or `AfterRun`. Profiles and maintenance accept `Order` and `Scope`. Ordering uses `Order` then ordinal full type name. Hooks stop on failure; later hooks are not cleanup guarantees. Connection/transaction restoration and lock release do not depend on hooks running. Profiles and maintenance use `Up`; they do not acquire version records.
+
+## Transactions and locks
+
+`PerMigration` commits each successful migration. `None` leaves transaction behavior to the provider/operations. `WholeSession` is accepted for SQLite, PostgreSQL and SQL Server dialects; history-table initialization occurs before that transaction. Other dialects fail explicitly because transactional DDL has not been verified. Arbitrary imperative SQL can still violate transaction assumptions; database administration and implicit-commit statements require separate runs.
+
+`AfterUp`/`AfterDown` run after commit. In whole-session mode they are deferred until the complete session commits. Their failure reports an error after durable changes; it cannot undo a successful commit. Caller-owned connections remain caller-owned.
+
+`new DatabaseMigrationLock()` uses SQL Server application locks, PostgreSQL advisory locks or MySQL/MariaDB named locks. Locks are session-owned, keyed by database/history table/scope, and remain held across migration commits. Do not switch databases, replace/close the connection or manipulate the native lock inside a migration. Unsupported providers, including SQLite, reject this lock implementation. Supply a custom `IMigrationLock` where another coordination mechanism is required. MySQL named locks coordinate one server, not an entire distributed cluster.
+
+## Planning and SQL preview
+
+`runner.Plan(target)` and `DryRun` inspect history without creating/upgrading it and do not invoke migration bodies, callbacks, transactions or SQLite PRAGMA changes. Custom providers must implement `IMigrationHistory` for these paths.
+
+`runner.PreviewSql(target, providerType)` connects for history/schema reads. `MigrationSqlPreview.Generate(providerType, migrations)` can generate SQL offline. Earlier structured operations update a planned schema so later operations can refer to newly created/renamed tables. SQL preview currently supports a subset: basic tables/columns, supported renames, simple indexes, inserts and raw SQL. Unsupported alterations, constraints, filters, callbacks and schema dependencies fail explicitly. Output is operation SQL, not an idempotent history-managed deployment bundle.
+
+Imperative bodies require `allowLegacyBodies: true`. Provider calls are captured through a rejecting proxy: direct connections, commands and unsupported reads/callbacks are blocked. **Arbitrary C# cannot be sandboxed**: constructors, fluent authoring and opted-in imperative bodies can still access files, networks or external state. Use trusted migration code. Migrations overriding `InitializeOnce` are rejected before their body runs, because skipping initialization could produce misleading SQL. Post-commit callbacks do not run during preview. Raw SQL invalidates planned schema knowledge, so later structured schema dependencies fail explicitly.
+
+## CLI from source
+
+```sh
+dotnet pack src/Migrator.Tool -o artifacts/packages
+dotnet tool install DotNetProjects.Migrator.Tool --add-source artifacts/packages --tool-path artifacts/tools
+```
+
+On Windows, use a short tool installation directory (or the default global-tool directory): the bundled SQLite native library failed to load from this review workspace's deeply nested tool path, while the same package passed from a short temporary path.
+
+Set `MIGRATOR_CONNECTION` in your environment; the tool does not print its value. Common commands:
+
+```sh
+migrator list --assembly MyMigrations.dll --provider SQLite
+migrator status --assembly MyMigrations.dll --provider SQLite
+migrator validate --assembly MyMigrations.dll --provider SQLite
+migrator plan --assembly MyMigrations.dll --provider SQLite --target 10
+migrator sql --assembly MyMigrations.dll --provider SQLite --output migration.sql
+migrator sql --assembly MyMigrations.dll --provider SQLite --offline --output migration.sql
+migrator migrate --assembly MyMigrations.dll --provider SQLite --scope billing --transaction WholeSession
+migrator rollback --assembly MyMigrations.dll --provider SQLite --target 0
+```
+
+Use `--connection-env NAME`, `--schema`, `--tags a,b`, `--tag-match Any|All`, `--profiles a,b`, `--timeout SECONDS`, `--lock` and `--lock-timeout SECONDS` where applicable. `rollback` requires an explicit lower target and rejects any plan containing upward steps. Target validation runs after acquiring the configured lock and refreshing history. Offline SQL assumes empty history and currently rejects profiles/maintenance. `validate` validates version planning, not arbitrary migration-body behavior. The packaged drivers cover SQLite, SQL Server, PostgreSQL, MySQL/MariaDB, Oracle and Firebird. Other library providers need a custom host.
+
+Exit codes: `0` success, `1` execution/load failure, `2` invalid arguments, `3` unsupported operation/provider, `4` lock timeout. SQL output may contain migration data; exception and provider trace details are omitted from CLI diagnostics.
+
+## Optional DI and logging
+
+The source package `DotNetProjects.Migrator.Extensions.DependencyInjection` provides `services.AddMigrator(providerFactory, migrationAssembly, configureOptions)`. Resolve `Migrator` inside a service scope; migration constructors use that scope's services. Options are scoped snapshots. Provider disposal follows the DI scope. Microsoft logging records lifecycle events while omitting SQL text and raw exception messages; the core retains its lightweight logger API.
+
+## Validation
+
+Build before using the test scripts (they intentionally use `--no-build`):
+
+```sh
+dotnet build Migrator.slnx
+pwsh .github/scripts/test.ps1 -Database Unit
+pwsh .github/scripts/test.ps1 -Database SQLite
+```
+
+See [live database tests](live-database-tests.md) for the full matrix. Provider-specific changes need live provider evidence. Check PR CI and review threads after every push; reply with implementation/test evidence and resolve fixed findings. Keep commits descriptive and merge the PR stack in dependency order only after review.
+
+An auxiliary-only `MigrateToLastVersion()` run preserves existing version history while executing selected profiles and maintenance. A completely empty run does not create a history table. Post-commit callbacks receive their migration context in both per-migration and whole-session modes; callback failure cannot undo a committed migration.
+
+PostgreSQL column and constraint metadata resolves the requested relation through the database, including schema-qualified or explicitly quoted names and the connection search path. The lookup is parameterized and distinguishes same-named tables in different schemas. This does not imply complete schema qualification for every provider operation. Native `time without time zone` metadata and literal defaults map to `TimeSpan`.
diff --git a/examples/FluentQuickStart/FluentQuickStart.csproj b/examples/FluentQuickStart/FluentQuickStart.csproj
new file mode 100644
index 00000000..d0d7285c
--- /dev/null
+++ b/examples/FluentQuickStart/FluentQuickStart.csproj
@@ -0,0 +1,4 @@
+
+ Exenet9.0enable
+
+
diff --git a/examples/FluentQuickStart/Program.cs b/examples/FluentQuickStart/Program.cs
new file mode 100644
index 00000000..b9e2ba48
--- /dev/null
+++ b/examples/FluentQuickStart/Program.cs
@@ -0,0 +1,30 @@
+using DotNetProjects.Migrator;
+using DotNetProjects.Migrator.Framework;
+using DotNetProjects.Migrator.Framework.Fluent;
+using DotNetProjects.Migrator.Providers;
+using Microsoft.Data.Sqlite;
+
+using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
+connection.Open();
+using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null, "demo");
+var runner = new Migrator(provider, false, typeof(CreateUsers));
+runner.Options.Tags.Add("core");
+runner.Options.TransactionMode = MigrationTransactionMode.WholeSession;
+Console.WriteLine(runner.PreviewSql(1, ProviderTypes.SQLite));
+if (provider.TableExists("Users") || provider.TableExists(provider.SchemaInfoTable)) throw new Exception("Preview wrote to the database.");
+runner.MigrateToLastVersion();
+if (!provider.ColumnExists("Users", "Name")) throw new Exception("Migration failed.");
+runner.MigrateTo(0);
+if (provider.TableExists("Users")) throw new Exception("Automatic reversal failed.");
+Console.WriteLine("Quick-start migration, preview and reversal passed.");
+
+[Migration(1, Scope = "demo"), Tags("core")]
+public class CreateUsers : AutoReversingMigration
+{
+ public override void BuildUp(MigrationBuilder migration)
+ {
+ migration.Create.Table("Users")
+ .WithColumn("Id").AsInt32().PrimaryKey()
+ .WithColumn("Name").AsString(255).NotNullable();
+ }
+}
diff --git a/src/Migrator.Extensions.DependencyInjection/DotNetProjects.Migrator.Extensions.DependencyInjection.csproj b/src/Migrator.Extensions.DependencyInjection/DotNetProjects.Migrator.Extensions.DependencyInjection.csproj
new file mode 100644
index 00000000..e8f1027b
--- /dev/null
+++ b/src/Migrator.Extensions.DependencyInjection/DotNetProjects.Migrator.Extensions.DependencyInjection.csproj
@@ -0,0 +1,9 @@
+
+ net9.09.0.0MPL-1.1Optional dependency injection, options and logging integration for Migrator.NET.
+
+
+
+
+
+
+
diff --git a/src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs b/src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs
new file mode 100644
index 00000000..cdc63c2d
--- /dev/null
+++ b/src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs
@@ -0,0 +1,22 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+namespace DotNetProjects.Migrator.Extensions.DependencyInjection;
+
+/// Logs lifecycle events. SQL and exception messages may contain secrets and are omitted.
+public sealed class MigrationLogger(Microsoft.Extensions.Logging.ILogger logger) : Framework.ILogger
+{
+ public void Started(List currentVersion, long finalVersion) => logger.LogInformation("Migration run started; target {Version}", finalVersion);
+ public void Finished(List currentVersion, long finalVersion) => logger.LogInformation("Migration run completed; target {Version}", finalVersion);
+ public void MigrateUp(long version, string migrationName) => logger.LogInformation("Applying migration {Version} ({Name})", version, migrationName);
+ public void MigrateDown(long version, string migrationName) => logger.LogInformation("Reverting migration {Version} ({Name})", version, migrationName);
+ public void Skipping(long version) => logger.LogWarning("Skipping migration {Version}", version);
+ public void RollingBack(long originalVersion) => logger.LogWarning("Rolling back migration {Version}", originalVersion);
+ public void ApplyingDBChange(string sql) => logger.LogDebug("Executing a database change");
+ public void Exception(long version, string migrationName, Exception ex) => logger.LogError("Migration {Version} failed: {ExceptionType}", version, ex.GetType().Name);
+ public void Exception(string message, Exception ex) => logger.LogError("Migration operation failed: {ExceptionType}", ex.GetType().Name);
+ public void Log(string format, params object[] args) => logger.LogInformation("Provider informational event");
+ public void Warn(string format, params object[] args) => logger.LogWarning("Provider warning event");
+ public void Trace(string format, params object[] args) { } // Provider traces commonly contain SQL values.
+}
diff --git a/src/Migrator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/src/Migrator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs
new file mode 100644
index 00000000..0ae41213
--- /dev/null
+++ b/src/Migrator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Linq;
+using System.Reflection;
+using DotNetProjects.Migrator.Framework;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+namespace DotNetProjects.Migrator.Extensions.DependencyInjection;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddMigrator(this IServiceCollection services,
+ Func providerFactory, Assembly migrations, Action configure = null)
+ {
+ services.AddOptions();
+ if (configure != null) services.Configure(configure);
+ services.AddScoped(providerFactory);
+ foreach (var type in MigrationLoader.GetMigrationTypes(migrations)) services.TryAddTransient(type);
+ services.AddScoped(sp =>
+ {
+ var provider = sp.GetRequiredService();
+ var loader = new MigrationLoader(provider, migrations, false);
+ var options = sp.GetRequiredService>().Value;
+ options.Activator ??= type => (IMigration)sp.GetRequiredService(type);
+ return new Migrator(provider, new MigrationLogger(sp.GetService()?.CreateLogger("Migrator.NET") ?? NullLogger.Instance), loader) { Options = options };
+ });
+ return services;
+ }
+}
diff --git a/src/Migrator.Tests/DatabaseLockTests.cs b/src/Migrator.Tests/DatabaseLockTests.cs
new file mode 100644
index 00000000..5a72fc55
--- /dev/null
+++ b/src/Migrator.Tests/DatabaseLockTests.cs
@@ -0,0 +1,106 @@
+using System;
+using System.Data.Common;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using DotNetProjects.Migrator.Framework;
+using DotNetProjects.Migrator;
+using DotNetProjects.Migrator.Providers;
+using Migrator.Tests.Settings;
+using NUnit.Framework;
+namespace Migrator.Tests;
+
+[TestFixture(ProviderTypes.SqlServer, Category = "SQLServer")]
+[TestFixture(ProviderTypes.PostgreSQL, Category = "PostgreSQL")]
+[TestFixture(ProviderTypes.Mysql, Category = "MySQL")]
+[TestFixture(ProviderTypes.MariaDB, Category = "MariaDB")]
+public class DatabaseLockTests(ProviderTypes type)
+{
+ private DbConnection Open()
+ {
+ DbConnection connection;
+ if (type == ProviderTypes.SqlServer)
+ {
+ var config = new ConfigurationReader().GetDatabaseConnectionConfigById("SQLServer");
+ var builder = new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(config.ConnectionString) { InitialCatalog = "master" };
+ connection = new Microsoft.Data.SqlClient.SqlConnection(builder.ConnectionString);
+ }
+ else if (type == ProviderTypes.PostgreSQL)
+ connection = new Npgsql.NpgsqlConnection(new ConfigurationReader().GetDatabaseConnectionConfigById("PostgreSQL").ConnectionString);
+ else
+ connection = new MySql.Data.MySqlClient.MySqlConnection(Environment.GetEnvironmentVariable(type == ProviderTypes.Mysql ? "MIGRATOR_MYSQL" : "MIGRATOR_MARIADB")
+ ?? "Server=127.0.0.1;Database=testdb;User ID=root;Password=rootpass;Pooling=false");
+ connection.Open(); return connection;
+ }
+ private sealed class RunState : IDisposable
+ {
+ public int Calls;
+ public readonly ManualResetEventSlim Entered = new();
+ public readonly ManualResetEventSlim Release = new();
+ public void Dispose() { Entered.Dispose(); Release.Dispose(); }
+ }
+ [Migration(1)]
+ private sealed class CountMigration(RunState state) : Migration
+ {
+ public override void Up()
+ {
+ Interlocked.Increment(ref state.Calls);
+ state.Entered.Set();
+ if (!state.Release.Wait(TimeSpan.FromSeconds(20))) throw new TimeoutException("Test migration gate timed out.");
+ }
+ public override void Down() { }
+ }
+ private sealed class SignallingLock(ManualResetEventSlim attempted) : IMigrationLock
+ {
+ public IDisposable Acquire(ITransformationProvider provider, string scope, TimeSpan timeout)
+ { attempted.Set(); return new DatabaseMigrationLock().Acquire(provider, scope, timeout); }
+ }
+ [Test]
+ public async Task ConcurrentRunnersReloadStaleHistoryAfterAcquiringNativeLock()
+ {
+ using var connection1 = Open(); using var connection2 = Open();
+ using var p1 = ProviderFactory.Create(type, connection1, null);
+ using var p2 = ProviderFactory.Create(type, connection2, null);
+ p1.SchemaInfoTable = p2.SchemaInfoTable = "lockhistory_" + Guid.NewGuid().ToString("N")[..12];
+ using var state = new RunState(); using var attempted = new ManualResetEventSlim();
+ Assert.That(p2.AppliedMigrations, Is.Empty); // Deliberately seed a stale empty cache.
+ var first = new DotNetProjects.Migrator.Migrator(p1, false, typeof(CountMigration));
+ var second = new DotNetProjects.Migrator.Migrator(p2, false, typeof(CountMigration));
+ first.Options.Activator = second.Options.Activator = _ => new CountMigration(state);
+ first.Options.Lock = new DatabaseMigrationLock(); second.Options.Lock = new SignallingLock(attempted);
+ first.Options.LockTimeout = second.Options.LockTimeout = TimeSpan.FromSeconds(15);
+ Task one = null, two = null;
+ try
+ {
+ one = Task.Run(first.MigrateToLastVersion);
+ Assert.That(state.Entered.Wait(TimeSpan.FromSeconds(10)), Is.True);
+ two = Task.Run(second.MigrateToLastVersion);
+ Assert.That(attempted.Wait(TimeSpan.FromSeconds(10)), Is.True);
+ state.Release.Set();
+ await Task.WhenAll(one, two);
+ Assert.That(state.Calls, Is.EqualTo(1));
+ Assert.That(((IMigrationHistory)p2).ReadAppliedMigrations(), Is.EqualTo(new long[] { 1 }));
+ using var released = new DatabaseMigrationLock().Acquire(p2, ((IMigrationHistory)p2).Scope, TimeSpan.Zero);
+ }
+ finally
+ {
+ state.Release.Set();
+ try { await Task.WhenAll(new[] { one, two }.Where(task => task != null)); }
+ finally { p1.RemoveTable(p1.SchemaInfoTable); }
+ }
+ }
+
+ [Test] public void IndependentSessionsContendAndCanAcquireAfterRelease()
+ {
+ using var connection1 = Open(); using var connection2 = Open();
+ using var p1 = ProviderFactory.Create(type, connection1, null);
+ using var p2 = ProviderFactory.Create(type, connection2, null);
+ var migrationLock = new DatabaseMigrationLock(); var scope = Guid.NewGuid().ToString("N");
+ using (migrationLock.Acquire(p1, scope, TimeSpan.FromSeconds(1)))
+ {
+ Assert.Throws(() => migrationLock.Acquire(p2, scope, TimeSpan.FromMilliseconds(100)));
+ using var independentScope = migrationLock.Acquire(p2, scope + "other", TimeSpan.Zero);
+ }
+ using var acquiredAfterRelease = migrationLock.Acquire(p2, scope, TimeSpan.FromSeconds(1));
+ }
+}
diff --git a/src/Migrator.Tests/Migrator.Tests.csproj b/src/Migrator.Tests/Migrator.Tests.csproj
index b1cc09e6..061f4d0f 100644
--- a/src/Migrator.Tests/Migrator.Tests.csproj
+++ b/src/Migrator.Tests/Migrator.Tests.csproj
@@ -45,11 +45,13 @@
+
+
-
+
diff --git a/src/Migrator.Tests/RunnerFeatureTests.cs b/src/Migrator.Tests/RunnerFeatureTests.cs
new file mode 100644
index 00000000..66bff1cd
--- /dev/null
+++ b/src/Migrator.Tests/RunnerFeatureTests.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Collections.Generic;
+using System.Data;
+using DotNetProjects.Migrator;
+using DotNetProjects.Migrator.Framework;
+using DotNetProjects.Migrator.Providers;
+using Microsoft.Data.Sqlite;
+using NUnit.Framework;
+namespace Migrator.Tests;
+
+[Category("SQLite")]
+public class RunnerFeatureTests
+{
+ private static readonly List Events = new();
+ [Migration(1), Tags("blue", "shared")]
+ internal class First : Migration
+ {
+ public override void Up() { Events.Add("first"); Database.AddTable("First", new Column("Id", DbType.Int32)); }
+ public override void Down() => Database.RemoveTable("First");
+ public override void AfterUp()
+ {
+ Assert.That(((TransformationProvider)Database).CurrentMigration, Is.SameAs(this));
+ Assert.That(((TransformationProvider)Database).HasActiveTransaction, Is.False);
+ Events.Add("committed");
+ }
+ }
+ [Migration(2), Tags("red", "shared")]
+ internal class Second : Migration
+ {
+ public override void Up() { Events.Add("second"); Database.AddTable("Second", new Column("Id", DbType.Int32)); }
+ public override void Down() => Database.RemoveTable("Second");
+ }
+ [Migration(3)] internal class Failure : Migration
+ {
+ public override void Up() => throw new InvalidOperationException("migration failed");
+ public override void Down() => throw new NotSupportedException();
+ }
+ [Profile("seed")] internal class Seed : Migration
+ {
+ public override void Up() { Events.Add("profile"); Database.Insert("First", new[] { "Id" }, new object[] { 7 }); }
+ public override void Down() => throw new NotSupportedException();
+ }
+ [Maintenance(MaintenanceStage.BeforeRun)] internal class Before : Migration
+ {
+ public override void Up() => Events.Add("before");
+ public override void Down() => throw new NotSupportedException();
+ }
+ [Maintenance(MaintenanceStage.AfterRun)] internal class After : Migration
+ {
+ public override void Up() => Events.Add("after");
+ public override void Down() => throw new NotSupportedException();
+ }
+ [SetUp] public void Reset() => Events.Clear();
+ private static ITransformationProvider Provider()
+ {
+ // Provider owns this connection, so disposal also closes the in-memory database.
+ return ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null);
+ }
+ [Test] public void ProfilesAndMaintenanceHaveDeterministicOrderAndNoHistory()
+ {
+ using var p = Provider();
+ var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(After), typeof(Seed), typeof(First), typeof(Before));
+ runner.Options.Profiles.Add("seed");
+ runner.MigrateToLastVersion();
+ Assert.That(Events, Is.EqualTo(new[] { "before", "first", "committed", "profile", "after" }));
+ Assert.That(p.AppliedMigrations, Is.EqualTo(new long[] { 1 }));
+ Assert.That(Convert.ToInt64(p.ExecuteScalar("SELECT Id FROM First")), Is.EqualTo(7));
+ }
+ [Test] public void AuxiliaryOnlyLatestRunPreservesExistingVersions()
+ {
+ using var p = Provider();
+ new DotNetProjects.Migrator.Migrator(p, false, typeof(First)).MigrateToLastVersion();
+ Events.Clear();
+ var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(Before), typeof(Seed), typeof(After));
+ runner.Options.Profiles.Add("seed");
+ runner.MigrateToLastVersion();
+ Assert.That(Events, Is.EqualTo(new[] { "before", "profile", "after" }));
+ Assert.That(p.AppliedMigrations, Is.EqualTo(new long[] { 1 }));
+ Assert.That(Convert.ToInt64(p.ExecuteScalar("SELECT Id FROM First")), Is.EqualTo(7));
+ }
+ [TestCase(TagMatchMode.Any, 2)]
+ [TestCase(TagMatchMode.All, 1)]
+ public void TagsUseExplicitAnyOrAll(TagMatchMode mode, int expected)
+ {
+ using var p = Provider();
+ var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(Second), typeof(First));
+ runner.Options.TagMatch = mode;
+ runner.Options.Tags.Add("blue"); runner.Options.Tags.Add("shared");
+ runner.MigrateTo(2);
+ Assert.That(p.AppliedMigrations.Count, Is.EqualTo(expected));
+ }
+ [TestCase(MigrationTransactionMode.WholeSession, false)]
+ [TestCase(MigrationTransactionMode.PerMigration, true)]
+ [TestCase(MigrationTransactionMode.None, true)]
+ public void TransactionModeDefinesFailureBoundary(MigrationTransactionMode mode, bool firstRemains)
+ {
+ using var p = Provider();
+ var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(First), typeof(Failure));
+ runner.Options.TransactionMode = mode;
+ Assert.Throws(() => runner.MigrateToLastVersion());
+ Assert.That(p.TableExists("First"), Is.EqualTo(firstRemains));
+ Assert.That(p.AppliedMigrations.Contains(1), Is.EqualTo(firstRemains));
+ Assert.That(Events.Contains("committed"), Is.EqualTo(firstRemains));
+ }
+ [Test] public void SessionCallbacksRunAfterAllMigrationsAndCommit()
+ {
+ using var p = Provider();
+ var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(First), typeof(Second));
+ runner.Options.TransactionMode = MigrationTransactionMode.WholeSession;
+ runner.MigrateToLastVersion();
+ Assert.That(Events, Is.EqualTo(new[] { "first", "second", "committed" }));
+ }
+ [Test] public void LockPrecedesHistoryAndReleasesOnFailure()
+ {
+ using var p = Provider(); var migrationLock = new ProbeLock();
+ var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(Failure)); runner.Options.Lock = migrationLock;
+ Assert.Throws(() => runner.MigrateToLastVersion());
+ Assert.That(migrationLock.Disposed, Is.True);
+ }
+ [Test] public void LegacyPreviewRequiresOptInAndNeverCreatesHistory()
+ {
+ using var p = Provider();
+ var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(First));
+ Assert.Catch(() => runner.PreviewSql(1, ProviderTypes.SQLite));
+ Assert.That(Events, Is.Empty);
+ var sql = runner.PreviewSql(1, ProviderTypes.SQLite, allowLegacyBodies: true);
+ Assert.That(sql, Does.Contain("CREATE TABLE"));
+ Assert.That(p.TableExists("First"), Is.False);
+ Assert.That(p.TableExists(p.SchemaInfoTable), Is.False);
+ Assert.That(Events, Is.EqualTo(new[] { "first" })); // Opt-in still executes arbitrary C#.
+ }
+ [Migration(1)] internal class DirectConnection : Migration
+ {
+ public override void Up() => _ = Database.Connection;
+ public override void Down() => throw new NotSupportedException();
+ }
+ [Test] public void LegacyPreviewRejectsDirectConnectionsAndUnsupportedLocks()
+ {
+ using var p = Provider();
+ var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(DirectConnection));
+ Assert.Catch(() => runner.PreviewSql(1, ProviderTypes.SQLite, true));
+ runner.Options.Lock = new DatabaseMigrationLock();
+ Assert.Catch(() => runner.MigrateTo(1));
+ Assert.That(p.TableExists(p.SchemaInfoTable), Is.False);
+ }
+ [Migration(4)] internal class RequiresInitialization : First
+ {
+ public override void InitializeOnce(string[] args) => throw new Exception("must not execute");
+ }
+ [Test] public void PreviewRejectsInitializationDependentMigrationsBeforeBody()
+ {
+ using var p = Provider();
+ var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(RequiresInitialization));
+ Assert.Throws(() => runner.PreviewSql(4, ProviderTypes.SQLite, true));
+ Assert.That(Events, Is.Empty);
+ Assert.That(p.TableExists(p.SchemaInfoTable), Is.False);
+ }
+ [Test] public void LifecycleLogArgumentsRemainInitialHistorySnapshots()
+ {
+ using var p = Provider();
+ var logger = NSubstitute.Substitute.For();
+ List started = null, finished = null;
+ logger.Started(NSubstitute.Arg.Do>(h => started = h), NSubstitute.Arg.Any());
+ logger.Finished(NSubstitute.Arg.Do>(h => finished = h), NSubstitute.Arg.Any());
+ var runner = new DotNetProjects.Migrator.Migrator(p, false, logger, typeof(First));
+ runner.MigrateTo(1);
+ Assert.That(started, Is.Empty); Assert.That(finished, Is.Empty);
+ Assert.That(p.AppliedMigrations, Is.EqualTo(new long[] { 1 }));
+ }
+ [Test] public void LockReleaseFailureDoesNotMaskMigrationFailure()
+ {
+ using var p = Provider();
+ var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(Failure));
+ runner.Options.Lock = new FailingReleaseLock();
+ var error = Assert.Throws(() => runner.MigrateToLastVersion());
+ Assert.That(error.Message, Is.EqualTo("migration failed"));
+ Assert.That(error.Data["LockReleaseException"], Is.TypeOf());
+ }
+ private sealed class FailingReleaseLock : IMigrationLock, IDisposable
+ {
+ public IDisposable Acquire(ITransformationProvider p, string scope, TimeSpan timeout) => this;
+ public void Dispose() => throw new ApplicationException("release failed");
+ }
+ private sealed class ProbeLock : IMigrationLock, IDisposable
+ {
+ public bool Disposed { get; private set; }
+ public IDisposable Acquire(ITransformationProvider p, string scope, TimeSpan timeout)
+ { Assert.That(p.TableExists(p.SchemaInfoTable), Is.False); return this; }
+ public void Dispose() => Disposed = true;
+ }
+}
diff --git a/src/Migrator.Tests/ToolingTests.cs b/src/Migrator.Tests/ToolingTests.cs
new file mode 100644
index 00000000..7a81d65d
--- /dev/null
+++ b/src/Migrator.Tests/ToolingTests.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Data;
+using System.IO;
+using System.Linq;
+using DotNetProjects.Migrator;
+using DotNetProjects.Migrator.Framework;
+using DotNetProjects.Migrator.Extensions.DependencyInjection;
+using DotNetProjects.Migrator.Providers;
+using Microsoft.Extensions.DependencyInjection;
+using NUnit.Framework;
+using NSubstitute;
+namespace Migrator.Tests;
+
+public class ToolingTests
+{
+ public sealed class Dependency { public bool Activated { get; set; } }
+ [Migration(900001, Scope = "tooling-spec")]
+ public class InjectedMigration(Dependency dependency) : Migration
+ {
+ public override void Up() { dependency.Activated = true; Database.AddTable("Injected", new Column("Id", DbType.Int32)); }
+ public override void Down() => Database.RemoveTable("Injected");
+ }
+ [Test, Category("SQLite")]
+ public void DependencyInjectionResolvesConstructorAndOptions()
+ {
+ var services = new ServiceCollection(); var dependency = new Dependency();
+ services.AddSingleton(dependency);
+ services.AddMigrator(_ => ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null, "tooling-spec"), typeof(ToolingTests).Assembly,
+ options => options.TransactionMode = MigrationTransactionMode.WholeSession);
+ using var container = services.BuildServiceProvider(); using var scope = container.CreateScope();
+ var runner = scope.ServiceProvider.GetRequiredService();
+ runner.MigrateToLastVersion();
+ Assert.That(dependency.Activated, Is.True);
+ Assert.That(scope.ServiceProvider.GetRequiredService().TableExists("Injected"), Is.True);
+ }
+ [TestCase(new[] { "bad-command" }, 2)]
+ [TestCase(new[] { "--help" }, 0)]
+ [TestCase(new[] { "rollback", "--provider", "SQLite" }, 2)]
+ public void CliReturnsMeaningfulArgumentExitCodes(string[] args, int exit)
+ {
+ using var output = new StringWriter(); using var error = new StringWriter();
+ Assert.That(MigratorCommand.Run(args, output, error), Is.EqualTo(exit));
+ }
+ [Migration(900002, Scope = "cli-spec")]
+ public class CliMigration : DotNetProjects.Migrator.Framework.Fluent.AutoReversingMigration
+ {
+ public override void BuildUp(DotNetProjects.Migrator.Framework.Fluent.MigrationBuilder migration)
+ => migration.Create.Table("CliExample").WithColumn("Id").AsInt32();
+ }
+ [Test, Category("SQLite")]
+ public void CliMigratesReadsStatusAndRollsBackWithPackagedDriver()
+ {
+ var file = Path.Combine(Path.GetTempPath(), "migrator-cli-" + Guid.NewGuid().ToString("N") + ".db");
+ var environmentName = "MIGRATOR_TEST_" + Guid.NewGuid().ToString("N");
+ Environment.SetEnvironmentVariable(environmentName, "Data Source=" + file + ";Pooling=False");
+ try
+ {
+ foreach (var command in new[] { "migrate", "status", "rollback" })
+ {
+ using var output = new StringWriter(); using var error = new StringWriter();
+ var args = new System.Collections.Generic.List { command, "--assembly", typeof(ToolingTests).Assembly.Location, "--provider", "SQLite", "--scope", "cli-spec", "--connection-env", environmentName };
+ if (command == "rollback") args.AddRange(new[] { "--target", "0" });
+ Assert.That(MigratorCommand.Run(args.ToArray(), output, error), Is.Zero, error.ToString());
+ if (command == "status") Assert.That(output.ToString(), Does.Contain("900002 applied"));
+ }
+ using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=" + file + ";Pooling=False"); connection.Open();
+ using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null, "cli-spec");
+ Assert.That(provider.TableExists("CliExample"), Is.False);
+ Assert.That(((IMigrationHistory)provider).ReadAppliedMigrations(), Is.Empty);
+ }
+ finally { Environment.SetEnvironmentVariable(environmentName, null); File.Delete(file); }
+ }
+ [Test, Category("SQLite")]
+ public void RollbackCommandRejectsAnUpwardTargetWithoutCreatingUserTables()
+ {
+ var file = Path.Combine(Path.GetTempPath(), "migrator-rollback-" + Guid.NewGuid().ToString("N") + ".db");
+ var variable = "MIGRATOR_TEST_" + Guid.NewGuid().ToString("N");
+ Environment.SetEnvironmentVariable(variable, "Data Source=" + file + ";Pooling=False");
+ try
+ {
+ using var output = new StringWriter(); using var error = new StringWriter();
+ Assert.That(MigratorCommand.Run(new[] { "rollback", "--assembly", typeof(ToolingTests).Assembly.Location,
+ "--provider", "SQLite", "--scope", "cli-spec", "--connection-env", variable,
+ "--target", "900002" }, output, error), Is.EqualTo(1));
+ using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=" + file + ";Pooling=False"); connection.Open();
+ using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null, "cli-spec");
+ Assert.That(provider.TableExists("CliExample"), Is.False);
+ Assert.That(((IMigrationHistory)provider).ReadAppliedMigrations(), Is.Empty);
+ }
+ finally { Environment.SetEnvironmentVariable(variable, null); File.Delete(file); }
+ }
+ [Migration(900003, Scope = "cli-errors")]
+ public class FailingCliMigration : Migration
+ {
+ internal static int Kind;
+ public override void Up() => throw Kind switch
+ {
+ 1 => new ArgumentException("SECRET_VALUE"),
+ 2 => new TimeoutException("SECRET_VALUE"),
+ _ => new NotSupportedException("SECRET_VALUE")
+ };
+ public override void Down() => throw new NotSupportedException();
+ }
+ [TestCase(1), TestCase(2), TestCase(3), Category("SQLite"), NonParallelizable]
+ public void CliClassifiesMigrationBodyExceptionsAsExecutionFailure(int kind)
+ {
+ var environmentName = "MIGRATOR_TEST_" + Guid.NewGuid().ToString("N");
+ Environment.SetEnvironmentVariable(environmentName, "Data Source=:memory:");
+ FailingCliMigration.Kind = kind;
+ try
+ {
+ using var output = new StringWriter(); using var error = new StringWriter();
+ var exit = MigratorCommand.Run(new[] { "migrate", "--assembly", typeof(ToolingTests).Assembly.Location, "--provider", "SQLite", "--scope", "cli-errors", "--connection-env", environmentName }, output, error);
+ Assert.That(exit, Is.EqualTo(1));
+ Assert.That(error.ToString(), Does.Not.Contain("SECRET_VALUE"));
+ }
+ finally { Environment.SetEnvironmentVariable(environmentName, null); }
+ }
+ [Test] public void LoggingAdapterOmitsProviderMessagesAndDoesNotFormatSqlBraces()
+ {
+ var sink = NSubstitute.Substitute.For();
+ var logger = new MigrationLogger(sink);
+ Assert.DoesNotThrow(() => logger.Log("SECRET_VALUE {"));
+ logger.Warn("SECRET_VALUE"); logger.Trace("SECRET_VALUE"); logger.ApplyingDBChange("SECRET_VALUE");
+ logger.Exception("SECRET_VALUE", new Exception("SECRET_VALUE"));
+ foreach (var call in sink.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "Log"))
+ Assert.That(call.GetArguments()[2].ToString(), Does.Not.Contain("SECRET_VALUE"));
+ }
+ [Test] public void CliCanListWithoutOpeningDatabase()
+ {
+ using var output = new StringWriter(); using var error = new StringWriter();
+ var exit = MigratorCommand.Run(new[] { "list", "--assembly", typeof(ToolingTests).Assembly.Location, "--provider", "SQLite", "--scope", "tooling-spec" }, output, error);
+ Assert.That(exit, Is.Zero, error.ToString());
+ Assert.That(output.ToString(), Does.Contain("900001"));
+ }
+}
diff --git a/src/Migrator.Tool/DotNetProjects.Migrator.Tool.csproj b/src/Migrator.Tool/DotNetProjects.Migrator.Tool.csproj
new file mode 100644
index 00000000..0a0f2501
--- /dev/null
+++ b/src/Migrator.Tool/DotNetProjects.Migrator.Tool.csproj
@@ -0,0 +1,12 @@
+
+ Exenet9.0enabletruemigratorDotNetProjects.Migrator.Tool9.0.0MPL-1.1
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Migrator.Tool/Program.cs b/src/Migrator.Tool/Program.cs
new file mode 100644
index 00000000..93701fe1
--- /dev/null
+++ b/src/Migrator.Tool/Program.cs
@@ -0,0 +1,127 @@
+using System.Reflection;
+using System.Runtime.Loader;
+using DotNetProjects.Migrator;
+using DotNetProjects.Migrator.Framework;
+using DotNetProjects.Migrator.Framework.Loggers;
+using DotNetProjects.Migrator.Providers;
+
+return MigratorCommand.Run(args, Console.Out, Console.Error);
+
+public static class MigratorCommand
+{
+ public static int Run(string[] args, TextWriter output, TextWriter error)
+ {
+ try { return Execute(args, output); }
+ catch (CliUsageException ex) { error.WriteLine("Invalid arguments: " + ex.Option + ". Use --help."); return 2; }
+ catch (UnsupportedMigrationFeatureException) { error.WriteLine("The requested operation is unsupported by this provider or preview mode."); return 3; }
+ catch (MigrationLockTimeoutException) { error.WriteLine("Migration lock acquisition timed out."); return 4; }
+ catch (Exception ex) { error.WriteLine("Migration command failed (" + ex.GetType().Name + "). Exception details are omitted because they may contain credentials or SQL values."); return 1; }
+ }
+ private sealed class CliUsageException(string option) : Exception { public string Option { get; } = option; }
+ private static int Execute(string[] args, TextWriter output)
+ {
+ if (args.Length == 0 || args.Contains("--help"))
+ {
+ output.WriteLine("migrator --assembly PATH --provider NAME");
+ output.WriteLine("--connection-env NAME (default MIGRATOR_CONNECTION), --scope NAME, --schema NAME, --target VERSION");
+ output.WriteLine("--tags a,b --tag-match Any|All --profiles a,b --transaction PerMigration|None|WholeSession");
+ output.WriteLine("--timeout SECONDS --lock --lock-timeout SECONDS --output PATH --offline --allow-legacy-preview");
+ output.WriteLine("rollback requires --target. Offline SQL assumes empty history. Legacy preview executes trusted arbitrary C#.");
+ return 0;
+ }
+ var command = args[0];
+ if (!new[] { "list", "status", "validate", "migrate", "rollback", "plan", "sql" }.Contains(command)) throw new CliUsageException("command");
+ var values = new Dictionary(StringComparer.Ordinal);
+ var flags = new HashSet { "--lock", "--offline", "--allow-legacy-preview" };
+ var allowed = new HashSet { "--assembly", "--provider", "--connection-env", "--scope", "--schema", "--target", "--tags", "--tag-match", "--profiles", "--transaction", "--timeout", "--lock-timeout", "--output" };
+ for (var i = 1; i < args.Length; i++)
+ {
+ var key = args[i];
+ if (values.ContainsKey(key)) throw new CliUsageException(key);
+ if (flags.Contains(key)) values.Add(key, "true");
+ else if (allowed.Contains(key) && i + 1 < args.Length && !args[i + 1].StartsWith("--")) values.Add(key, args[++i]);
+ else throw new CliUsageException(key);
+ }
+ string Value(string key, string fallback = null) => values.GetValueOrDefault(key, fallback);
+ T EnumValue(string key, string fallback) where T : struct, Enum => Enum.TryParse(Value(key, fallback), true, out var result) && Enum.IsDefined(result) ? result : throw new CliUsageException(key);
+ var providerType = EnumValue("--provider", "none");
+ if (providerType == ProviderTypes.none) throw new CliUsageException("--provider");
+ var assemblyPath = Path.GetFullPath(Value("--assembly") ?? throw new CliUsageException("--assembly"));
+ var resolver = new AssemblyDependencyResolver(assemblyPath);
+ Assembly Resolving(AssemblyLoadContext context, AssemblyName name)
+ {
+ var path = resolver.ResolveAssemblyToPath(name);
+ return path == null ? null : context.LoadFromAssemblyPath(path);
+ }
+ AssemblyLoadContext.Default.Resolving += Resolving;
+ try
+ {
+ var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath);
+ var scope = Value("--scope", "default");
+ var types = MigrationLoader.GetMigrationTypes(assembly).Where(t =>
+ (t.GetCustomAttribute()?.Scope ?? t.GetCustomAttribute()?.Scope ?? t.GetCustomAttribute()?.Scope) is not string ownScope || ownScope == scope).ToArray();
+ var tags = Value("--tags", "").Split(',', StringSplitOptions.RemoveEmptyEntries);
+ var tagMatch = EnumValue("--tag-match", "Any");
+ bool Selected(Type t)
+ {
+ var own = t.GetCustomAttribute()?.Tags ?? Array.Empty();
+ return tags.Length == 0 || (tagMatch == TagMatchMode.All ? tags.All(own.Contains) : tags.Any(own.Contains));
+ }
+ var versioned = types.Where(t => t.GetCustomAttribute() != null && Selected(t)).OrderBy(MigrationLoader.GetMigrationVersion).ToArray();
+ var target = Value("--target") is { } targetString ? long.TryParse(targetString, out var parsed) && parsed >= 0 ? parsed : throw new CliUsageException("--target") : versioned.Select(MigrationLoader.GetMigrationVersion).DefaultIfEmpty(0).Max();
+ if (command == "rollback" && !values.ContainsKey("--target")) throw new CliUsageException("--target");
+ if (command == "list")
+ {
+ foreach (var type in versioned) output.WriteLine(MigrationLoader.GetMigrationVersion(type) + " " + type.FullName);
+ return 0;
+ }
+ if (values.ContainsKey("--offline"))
+ {
+ if (command != "sql" || values.ContainsKey("--profiles") || types.Any(t => t.GetCustomAttribute() != null)) throw new UnsupportedMigrationFeatureException("CLI operation is unsupported.");
+ var plan = MigrationPlanner.Create(versioned.Select(MigrationLoader.GetMigrationVersion), Array.Empty(), target);
+ var migrations = plan.Select(step => ((IMigration)Activator.CreateInstance(versioned.Single(t => MigrationLoader.GetMigrationVersion(t) == step.Version)), step.IsUp));
+ Write(MigrationSqlPreview.Generate(providerType, migrations, values.ContainsKey("--allow-legacy-preview")));
+ return 0;
+ }
+ var connectionString = Environment.GetEnvironmentVariable(Value("--connection-env", "MIGRATOR_CONNECTION")) ?? throw new CliUsageException("--connection-env");
+ var providerName = providerType switch
+ {
+ ProviderTypes.SQLite => "Microsoft.Data.Sqlite", ProviderTypes.SqlServer or ProviderTypes.SqlServer2005 => "Microsoft.Data.SqlClient",
+ ProviderTypes.PostgreSQL or ProviderTypes.PostgreSQL82 => "Npgsql", ProviderTypes.Mysql or ProviderTypes.MariaDB => "MySql.Data.MySqlClient",
+ ProviderTypes.Oracle => "Oracle.ManagedDataAccess.Client", ProviderTypes.Firebird => "FirebirdSql.Data.FirebirdClient",
+ _ => throw new UnsupportedMigrationFeatureException("CLI operation is unsupported.")
+ };
+ System.Data.Common.DbProviderFactories.RegisterFactory(providerName, providerType switch
+ {
+ ProviderTypes.SQLite => Microsoft.Data.Sqlite.SqliteFactory.Instance,
+ ProviderTypes.SqlServer or ProviderTypes.SqlServer2005 => Microsoft.Data.SqlClient.SqlClientFactory.Instance,
+ ProviderTypes.PostgreSQL or ProviderTypes.PostgreSQL82 => Npgsql.NpgsqlFactory.Instance,
+ ProviderTypes.Mysql or ProviderTypes.MariaDB => MySql.Data.MySqlClient.MySqlClientFactory.Instance,
+ ProviderTypes.Oracle => Oracle.ManagedDataAccess.Client.OracleClientFactory.Instance,
+ ProviderTypes.Firebird => FirebirdSql.Data.FirebirdClient.FirebirdClientFactory.Instance,
+ _ => throw new UnsupportedMigrationFeatureException("CLI operation is unsupported.")
+ });
+ using var provider = ProviderFactory.Create(providerType, connectionString, Value("--schema"), scope, providerName);
+ if (values.ContainsKey("--timeout")) provider.CommandTimeout = Seconds("--timeout", "30");
+ var runner = new Migrator(provider, false, new Logger(false), types);
+ runner.Options.Tags.UnionWith(tags); runner.Options.TagMatch = tagMatch;
+ runner.Options.Profiles.UnionWith(Value("--profiles", "").Split(',', StringSplitOptions.RemoveEmptyEntries));
+ runner.Options.TransactionMode = EnumValue("--transaction", "PerMigration");
+ if (values.ContainsKey("--lock")) runner.Options.Lock = new DatabaseMigrationLock();
+ runner.Options.LockTimeout = TimeSpan.FromSeconds(Seconds("--lock-timeout", "30"));
+ switch (command)
+ {
+ case "status": foreach (var applied in ((IMigrationHistory)provider).ReadAppliedMigrations()) output.WriteLine(applied + " applied"); break;
+ case "validate": _ = runner.Plan(target); output.WriteLine("Migration plan is valid."); break;
+ case "plan": foreach (var step in runner.Plan(target)) output.WriteLine(step.Version + (step.IsUp ? " up" : " down")); break;
+ case "sql": Write(runner.PreviewSql(target, providerType, values.ContainsKey("--allow-legacy-preview"))); break;
+ case "rollback": runner.RollbackTo(target); output.WriteLine("Rollback completed."); break;
+ default: runner.MigrateTo(target); output.WriteLine("Migration completed."); break;
+ }
+ return 0;
+ int Seconds(string key, string fallback) => int.TryParse(Value(key, fallback), out var seconds) && seconds >= 0 ? seconds : throw new CliUsageException(key);
+ void Write(string sql) { if (Value("--output") is { } path) File.WriteAllText(path, sql); else output.WriteLine(sql); }
+ }
+ finally { AssemblyLoadContext.Default.Resolving -= Resolving; }
+ }
+}
diff --git a/src/Migrator/DatabaseMigrationLock.cs b/src/Migrator/DatabaseMigrationLock.cs
new file mode 100644
index 00000000..d6e35d90
--- /dev/null
+++ b/src/Migrator/DatabaseMigrationLock.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Buffers.Binary;
+using System.Data;
+using System.Diagnostics;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using DotNetProjects.Migrator.Framework;
+using DotNetProjects.Migrator.Providers.Impl.Mysql;
+using DotNetProjects.Migrator.Providers.Impl.PostgreSQL;
+using DotNetProjects.Migrator.Providers.Impl.SqlServer;
+namespace DotNetProjects.Migrator;
+
+/// Session-owned database locks for SQL Server, PostgreSQL and MySQL/MariaDB.
+public sealed class DatabaseMigrationLock : IMigrationLock
+{
+ public IDisposable Acquire(ITransformationProvider provider, string scope, TimeSpan timeout)
+ {
+ if (timeout < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(timeout));
+ var kind = provider.Dialect switch
+ {
+ SqlServerDialect => 0, PostgreSQLDialect => 1, MysqlDialect => 2,
+ _ => throw new UnsupportedMigrationFeatureException("Database migration locking is supported on SQL Server, PostgreSQL and MySQL/MariaDB.")
+ };
+ var connection = provider.Connection;
+ if (connection.State != ConnectionState.Open) throw new MigrationException("Migration locking requires an open connection.");
+ var resource = "Migrator.NET:" + connection.Database + ":" + provider.SchemaInfoTable + ":" + scope;
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(resource));
+ object key = kind == 1 ? BinaryPrimitives.ReadInt64BigEndian(hash) : Convert.ToHexString(hash);
+ var acquire = kind switch
+ {
+ 0 => "DECLARE @result int; EXEC @result=sys.sp_getapplock @Resource=@key, @LockMode='Exclusive', @LockOwner='Session', @LockTimeout=0; SELECT @result",
+ 1 => "SELECT pg_try_advisory_lock(@key)",
+ _ => "SELECT GET_LOCK(@key, 0)"
+ };
+ var release = kind switch
+ {
+ 0 => "DECLARE @result int; EXEC @result=sys.sp_releaseapplock @Resource=@key, @LockOwner='Session'; SELECT @result",
+ 1 => "SELECT pg_advisory_unlock(@key)",
+ _ => "SELECT RELEASE_LOCK(@key)"
+ };
+ var watch = Stopwatch.StartNew();
+ while (true)
+ {
+ var value = Scalar(connection, acquire, key);
+ if (value == null || value == DBNull.Value) throw new MigrationException("Database lock acquisition returned no result.");
+ var code = Convert.ToInt32(value, CultureInfo.InvariantCulture);
+ if (kind == 0 ? code >= 0 : code == 1) return new Lease(connection, release, key, kind);
+ if (kind == 0 && code != -1) throw new MigrationException("Database lock acquisition failed with code " + code);
+ if (watch.Elapsed >= timeout) throw new TimeoutException("Timed out acquiring the migration lock.");
+ Thread.Sleep((int)Math.Min(50, Math.Max(1, (timeout - watch.Elapsed).TotalMilliseconds)));
+ }
+ }
+ private static object Scalar(IDbConnection connection, string sql, object key)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = sql; command.CommandTimeout = 30;
+ var parameter = command.CreateParameter(); parameter.ParameterName = "@key"; parameter.Value = key;
+ parameter.DbType = key is long ? DbType.Int64 : DbType.String;
+ command.Parameters.Add(parameter);
+ return command.ExecuteScalar();
+ }
+ private sealed class Lease(IDbConnection connection, string release, object key, int kind) : IDisposable
+ {
+ private bool disposed;
+ public void Dispose()
+ {
+ if (disposed) return;
+ var value = Scalar(connection, release, key);
+ if (value == null || value == DBNull.Value || (kind == 0 ? Convert.ToInt32(value) < 0 : Convert.ToInt32(value) != 1))
+ throw new MigrationException("The database did not confirm migration lock release.");
+ disposed = true;
+ }
+ }
+}
diff --git a/src/Migrator/MigrationExecution.cs b/src/Migrator/MigrationExecution.cs
index eb7c148b..9a50aca3 100644
--- a/src/Migrator/MigrationExecution.cs
+++ b/src/Migrator/MigrationExecution.cs
@@ -7,29 +7,48 @@ namespace DotNetProjects.Migrator;
internal static class MigrationExecution
{
- internal static void Execute(ITransformationProvider provider, IMigration migration, MigrationStep step, ILogger logger)
+ internal static void Execute(ITransformationProvider provider, IMigration migration, MigrationStep step, ILogger logger,
+ bool transaction = true, bool inSession = false, bool recordHistory = true, bool callbacks = true)
{
var concrete = provider as TransformationProvider;
- if (concrete?.HasActiveTransaction == true)
+ try
+ {
+ void Body()
+ {
+ if (concrete != null) concrete.CurrentMigration = migration;
+ if (step.IsUp) { logger.MigrateUp(step.Version, migration.Name); migration.Up(); }
+ else { logger.MigrateDown(step.Version, migration.Name); migration.Down(); }
+ if (provider is SQLiteTransformationProvider sqlite && !sqlite.CheckForeignKeyIntegrity())
+ throw new MigrationException("Migration would leave invalid SQLite foreign keys.");
+ if (recordHistory)
+ {
+ var scope = migration.GetType().GetCustomAttribute()?.Scope ?? (provider as IMigrationHistory)?.Scope;
+ if (step.IsUp) provider.MigrationApplied(step.Version, scope);
+ else provider.MigrationUnApplied(step.Version, scope);
+ }
+ }
+ if (inSession) Body(); else InTransaction(provider, transaction, Body);
+ }
+ catch (Exception ex) { logger.Exception(step.Version, migration.Name, ex); throw; }
+ finally { if (concrete != null) concrete.CurrentMigration = null; }
+ // Session callbacks are deferred until the outer transaction commits.
+ if (callbacks) After(provider, migration, step.IsUp);
+ }
+
+ internal static void InTransaction(ITransformationProvider provider, bool transaction, Action body)
+ {
+ if ((provider as TransformationProvider)?.HasActiveTransaction == true)
throw new MigrationException("The runner cannot take ownership of an existing provider transaction.");
var sqlite = provider as SQLiteTransformationProvider;
- var foreignKeys = sqlite?.IsPragmaForeignKeysOn() == true;
+ var foreignKeys = transaction && sqlite?.IsPragmaForeignKeysOn() == true;
Exception failure = null;
var began = false;
try
{
if (foreignKeys) sqlite.SetPragmaForeignKeys(false);
- provider.BeginTransaction();
- began = true;
- if (concrete != null) concrete.CurrentMigration = migration;
- if (step.IsUp) { logger.MigrateUp(step.Version, migration.Name); migration.Up(); }
- else { logger.MigrateDown(step.Version, migration.Name); migration.Down(); }
- if (sqlite != null && !sqlite.CheckForeignKeyIntegrity())
- throw new MigrationException("Migration would leave invalid SQLite foreign keys.");
- if (step.IsUp) provider.MigrationApplied(step.Version, migration.GetType().GetCustomAttribute()?.Scope ?? (provider as IMigrationHistory)?.Scope);
- else provider.MigrationUnApplied(step.Version, migration.GetType().GetCustomAttribute()?.Scope ?? (provider as IMigrationHistory)?.Scope);
- provider.Commit();
- began = false;
+ if (transaction) { provider.BeginTransaction(); began = true; }
+ body();
+ if (transaction) { provider.Commit(); began = false; }
}
catch (Exception ex)
{
@@ -39,21 +58,18 @@ internal static void Execute(ITransformationProvider provider, IMigration migrat
try { provider.Rollback(); }
catch (Exception rollback) { ex.Data["RollbackException"] = rollback; }
}
- logger.Exception(step.Version, migration.Name, ex);
throw;
}
finally
{
- if (concrete != null) concrete.CurrentMigration = null;
try { if (foreignKeys) sqlite.SetPragmaForeignKeys(true); }
catch (Exception restore)
{
if (failure == null) throw;
failure.Data["ConnectionRestoreException"] = restore;
}
+ (provider as IMigrationHistory)?.InvalidateHistory();
}
- // These callbacks intentionally run after commit; failure cannot be rolled back.
- After(provider, migration, step.IsUp);
}
internal static void After(ITransformationProvider provider, IMigration migration, bool up)
{
diff --git a/src/Migrator/MigrationLoader.cs b/src/Migrator/MigrationLoader.cs
index 4be11204..932164b3 100644
--- a/src/Migrator/MigrationLoader.cs
+++ b/src/Migrator/MigrationLoader.cs
@@ -26,7 +26,7 @@ public MigrationLoader(ITransformationProvider provider, Assembly migrationAssem
provider.Logger.Trace("Loaded migrations:");
foreach (var t in _migrationsTypes)
{
- provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name));
+ provider.Logger.Trace("{0} {1}", (t.GetCustomAttribute()?.Version.ToString() ?? "aux").PadLeft(5), StringUtils.ToHumanName(t.Name));
}
}
}
@@ -41,7 +41,7 @@ public MigrationLoader(ITransformationProvider provider, bool trace, params Type
provider.Logger.Trace("Loaded migrations:");
foreach (var t in _migrationsTypes)
{
- provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name));
+ provider.Logger.Trace("{0} {1}", (t.GetCustomAttribute()?.Version.ToString() ?? "aux").PadLeft(5), StringUtils.ToHumanName(t.Name));
}
}
}
@@ -70,9 +70,14 @@ public virtual long LastVersion
}
}
+ public Func Activator { get; set; }
+
public IEnumerable SelectedTypes => _migrationsTypes.Where(t =>
- _provider is not IMigrationHistory history ||
- t.GetCustomAttribute()?.Scope is not string scope || scope == history.Scope);
+ t.GetCustomAttribute() != null && InScope(t.GetCustomAttribute().Scope));
+
+ internal bool InScope(string scope) => scope == null || _provider is not IMigrationHistory history || scope == history.Scope;
+ internal IEnumerable AuxiliaryTypes => _migrationsTypes.Where(t => t.GetCustomAttribute() == null);
+
public virtual void AddMigrations(Assembly migrationAssembly)
{
@@ -112,26 +117,13 @@ public static List GetMigrationTypes(Assembly asm)
var migrations = new List();
foreach (var t in asm.GetExportedTypes())
{
-
-
-#if NETSTANDARD
- var attrib = t.GetTypeInfo().GetCustomAttribute();
- if (attrib != null && typeof(IMigration).GetTypeInfo().IsAssignableFrom(t) && !attrib.Ignore)
- {
+ if (t.IsAbstract || !typeof(IMigration).IsAssignableFrom(t)) continue;
+ var versioned = t.GetCustomAttribute();
+ if (versioned != null ? !versioned.Ignore :
+ t.GetCustomAttribute() != null || t.GetCustomAttribute() != null)
migrations.Add(t);
- }
-#else
- var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute));
- if (attrib != null && typeof(IMigration).IsAssignableFrom(t) && !attrib.Ignore)
- {
- migrations.Add(t);
- }
-#endif
-
-
}
-
- migrations.Sort(new MigrationTypeComparer(true));
+ migrations = migrations.OrderBy(t => t.GetCustomAttribute()?.Version ?? 0).ThenBy(t => t.FullName, StringComparer.Ordinal).ToList();
return migrations;
}
@@ -149,8 +141,7 @@ public static long GetMigrationVersion(Type t)
public List GetAvailableMigrations()
{
- _migrationsTypes.Sort(new MigrationTypeComparer(true));
- return SelectedTypes.Select(GetMigrationVersion).ToList();
+ return SelectedTypes.Select(GetMigrationVersion).OrderBy(v => v).ToList();
}
public virtual IMigration GetMigration(long version)
@@ -170,6 +161,6 @@ public virtual IMigration GetMigration(long version)
public virtual IMigration CreateInstance(Type migrationType)
{
- return (IMigration)Activator.CreateInstance(migrationType);
+ return Activator != null ? Activator(migrationType) ?? throw new MigrationException("Migration activator returned null.") : (IMigration)System.Activator.CreateInstance(migrationType);
}
}
diff --git a/src/Migrator/MigrationSqlPreview.cs b/src/Migrator/MigrationSqlPreview.cs
new file mode 100644
index 00000000..46cfbc37
--- /dev/null
+++ b/src/Migrator/MigrationSqlPreview.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using DotNetProjects.Migrator.Framework;
+using DotNetProjects.Migrator.Framework.Fluent;
+using DotNetProjects.Migrator.Providers;
+namespace DotNetProjects.Migrator;
+
+public static class MigrationSqlPreview
+{
+ /// Generates SQL without a database. C# authoring code still executes and must be trusted.
+ public static string Generate(ProviderTypes provider, IEnumerable<(IMigration Migration, bool Up)> migrations,
+ bool allowLegacyBodies = false, Func existingTables = null)
+ {
+ var context = new SqlGenerationContext(provider, existingTables);
+ var sql = new List();
+ foreach (var (migration, up) in migrations)
+ {
+ var initialization = migration.GetType().GetInterfaceMap(typeof(IMigration));
+ var initializeIndex = Array.FindIndex(initialization.InterfaceMethods, m => m.Name == nameof(IMigration.InitializeOnce));
+ if (initialization.TargetMethods[initializeIndex].DeclaringType != typeof(Migration))
+ throw new UnsupportedMigrationFeatureException("Preview rejects migrations with an InitializeOnce hook because executing initialization would violate read-only preview semantics.");
+ var original = migration.Database;
+ var proxy = DispatchProxy.Create();
+ var recorder = (PreviewProvider)(object)proxy;
+ try
+ {
+ migration.Database = proxy;
+ IReadOnlyList operations;
+ if (migration is FluentMigration fluent) operations = fluent.GetOperations(up);
+ else
+ {
+ if (!allowLegacyBodies) throw new UnsupportedMigrationFeatureException("Imperative SQL preview requires explicit allowLegacyBodies opt-in. Arbitrary C# cannot be sandboxed.");
+ if (up) migration.Up(); else migration.Down();
+ operations = recorder.Operations;
+ }
+ foreach (var operation in operations)
+ {
+ try { sql.Add(operation.ToSql(context)); }
+ catch (NotSupportedException ex) { throw new UnsupportedMigrationFeatureException("This operation cannot be previewed.", ex); }
+ }
+ }
+ finally { migration.Database = original; }
+ }
+ return string.Join(Environment.NewLine, sql.Where(s => !string.IsNullOrWhiteSpace(s)));
+ }
+
+ // Every method is denied unless explicitly mapped to a captured operation. No connection is exposed.
+ public class PreviewProvider : DispatchProxy
+ {
+ internal readonly List Operations = new();
+ protected override object Invoke(MethodInfo method, object[] args)
+ {
+ MigrationOperation operation = method.Name switch
+ {
+ "AddTable" when args.Length == 2 && args[1] is IDbField[] fields => new CreateTableOperation((string)args[0], null, fields.Select(Definitions.Copy).ToArray()),
+ "AddColumn" when args.Length == 2 && args[1] is Column column => new ColumnOperation((string)args[0], Definitions.CopyColumn(column)),
+ "RemoveTable" => new RemoveOperation(RemoveKind.Table, (string)args[0]),
+ "RenameTable" => new RenameOperation((string)args[0], (string)args[1]),
+ "RenameColumn" => new RenameOperation((string)args[0], (string)args[2], (string)args[1]),
+ "Insert" when args.Length == 3 && args[1] is string[] columns && args[2] is object[] values => new DataOperation(DataKind.Insert, (string)args[0], (string[])columns.Clone(), (object[])values.Clone()),
+ "ExecuteNonQuery" when args.Length == 1 => new SqlOperation((string)args[0]),
+ _ => throw new UnsupportedMigrationFeatureException("SQL preview blocks provider member " + method.Name + ". Use a structured operation or an explicit SQL script.")
+ };
+ Operations.Add(operation);
+ return method.ReturnType == typeof(int) ? 0 : null;
+ }
+ }
+}
diff --git a/src/Migrator/Migrator.cs b/src/Migrator/Migrator.cs
index f900dd5c..9d94f03c 100644
--- a/src/Migrator/Migrator.cs
+++ b/src/Migrator/Migrator.cs
@@ -26,6 +26,7 @@ namespace DotNetProjects.Migrator;
///
public class Migrator
{
+ public RunnerOptions Options { get; init; } = new();
private readonly MigrationLoader _migrationLoader;
private readonly ITransformationProvider _provider;
@@ -182,12 +183,14 @@ public long? LastAppliedMigrationVersion
///
public void MigrateToLastVersion()
{
- if (_migrationLoader.GetAvailableMigrations().Count == 0)
+ var versions = SelectedMigrationTypes.Select(MigrationLoader.GetMigrationVersion).ToArray();
+ if (versions.Length == 0 && Options.Profiles.Count == 0 &&
+ !_migrationLoader.AuxiliaryTypes.Any(t => t.GetCustomAttribute() is { } a && _migrationLoader.InScope(a.Scope)))
{
Logger.Warn("No migrations found for the effective scope.");
return;
}
- MigrateTo(_migrationLoader.LastVersion);
+ MigrateTo(versions.DefaultIfEmpty(0).Max(), false, versions.Length == 0);
}
///
@@ -201,43 +204,143 @@ public void MigrateToLastVersion()
/// If dryrun is set, don't write any changes to the database.
///
/// The version that must became the current one
- public IReadOnlyList Plan(long version)
+ private IEnumerable SelectedMigrationTypes => _migrationLoader.SelectedTypes.Where(t =>
+ {
+ if (Options.Tags.Count == 0) return true;
+ var tags = t.GetCustomAttribute()?.Tags ?? Array.Empty();
+ return Options.TagMatch == TagMatchMode.All ? Options.Tags.All(tags.Contains) : Options.Tags.Any(tags.Contains);
+ });
+
+ private IReadOnlyList CreatePlan(IEnumerable applied, long version)
{
_migrationLoader.CheckForDuplicatedVersion();
+ var selected = SelectedMigrationTypes.Select(MigrationLoader.GetMigrationVersion).ToHashSet();
+ var known = _migrationLoader.GetAvailableMigrations().ToHashSet();
+ // Filtered migrations stay applied; unknown history must still fail a downgrade.
+ return MigrationPlanner.Create(selected, applied.Where(v => selected.Contains(v) || !known.Contains(v)), version);
+ }
+
+ public IReadOnlyList Plan(long version)
+ {
if (_provider is not IMigrationHistory history)
throw new NotSupportedException("Read-only planning requires IMigrationHistory on custom providers.");
- return MigrationPlanner.Create(_migrationLoader.GetAvailableMigrations(), history.ReadAppliedMigrations(), version);
+ return CreatePlan(history.ReadAppliedMigrations(), version);
}
- public void MigrateTo(long version)
+ public string PreviewSql(long version, ProviderTypes provider, bool allowLegacyBodies = false)
{
- _migrationLoader.CheckForDuplicatedVersion();
- var history = DryRun
- ? _provider is IMigrationHistory reader ? reader.ReadAppliedMigrations().ToList()
- : throw new NotSupportedException("DryRun requires IMigrationHistory on custom providers.")
- : new List(_provider.AppliedMigrations);
- var plan = MigrationPlanner.Create(_migrationLoader.GetAvailableMigrations(), history, version);
- Logger.Started(history, version);
- var firstRun = true;
+ _migrationLoader.Activator = Options.Activator;
+ var plan = Plan(version);
+ var migrations = new List<(IMigration, bool)>();
+ void AddMaintenance(MaintenanceStage stage)
+ {
+ foreach (var type in _migrationLoader.AuxiliaryTypes.Where(t => t.GetCustomAttribute() is { } a && a.Stage == stage && _migrationLoader.InScope(a.Scope))
+ .OrderBy(t => t.GetCustomAttribute().Order).ThenBy(t => t.FullName, StringComparer.Ordinal))
+ migrations.Add((_migrationLoader.CreateInstance(type), true));
+ }
+ AddMaintenance(MaintenanceStage.BeforeRun);
foreach (var step in plan)
{
- if (DryRun)
+ AddMaintenance(MaintenanceStage.BeforeMigration);
+ migrations.Add((_migrationLoader.GetMigration(step.Version), step.IsUp));
+ AddMaintenance(MaintenanceStage.AfterMigration);
+ }
+ foreach (var name in Options.Profiles)
+ if (!_migrationLoader.AuxiliaryTypes.Any(t => t.GetCustomAttribute() is { } a && a.Name == name && _migrationLoader.InScope(a.Scope)))
+ throw new MigrationException("Unknown profile: " + name);
+ foreach (var type in _migrationLoader.AuxiliaryTypes.Where(t => t.GetCustomAttribute() is { } a && Options.Profiles.Contains(a.Name) && _migrationLoader.InScope(a.Scope))
+ .OrderBy(t => t.GetCustomAttribute().Order).ThenBy(t => t.FullName, StringComparer.Ordinal))
+ migrations.Add((_migrationLoader.CreateInstance(type), true));
+ AddMaintenance(MaintenanceStage.AfterRun);
+ return MigrationSqlPreview.Generate(provider, migrations, allowLegacyBodies,
+ table => _provider.TableExists(table) ? _provider.GetColumns(table) : throw new MigrationException("Preview table does not exist: " + table));
+ }
+
+ public void MigrateTo(long version) => MigrateTo(version, false);
+
+ /// Run only downward steps; validate the target after acquiring the configured lock.
+ public void RollbackTo(long version) => MigrateTo(version, true);
+
+ private void MigrateTo(long version, bool downOnly, bool preserveVersion = false)
+ {
+ if (DryRun)
+ {
+ var preview = preserveVersion ? Array.Empty() : Plan(version);
+ if (downOnly && preview.Any(step => step.IsUp)) throw new MigrationException("Rollback cannot apply upward migrations.");
+ foreach (var step in preview)
+ if (step.IsUp) Logger.MigrateUp(step.Version, "Preview"); else Logger.MigrateDown(step.Version, "Preview");
+ return;
+ }
+ if (Options.LockTimeout < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(Options.LockTimeout));
+ var session = Options.TransactionMode == MigrationTransactionMode.WholeSession;
+ if (session && _provider.Dialect is not (Providers.Impl.SQLite.SQLiteDialect or Providers.Impl.PostgreSQL.PostgreSQLDialect or Providers.Impl.SqlServer.SqlServerDialect))
+ throw new UnsupportedMigrationFeatureException("Whole-session transactions require a verified transactional DDL provider (SQLite, PostgreSQL or SQL Server).");
+ _migrationLoader.Activator = Options.Activator;
+ IDisposable AcquireLock()
+ {
+ try { return Options.Lock?.Acquire(_provider, (_provider as IMigrationHistory)?.Scope, Options.LockTimeout); }
+ catch (TimeoutException ex) { throw new MigrationLockTimeoutException(ex); }
+ }
+ var lease = AcquireLock();
+ Exception failure = null;
+ try
+ {
+ (_provider as IMigrationHistory)?.InvalidateHistory();
+ var history = new List(_provider.AppliedMigrations);
+ var initialHistory = new List(history);
+ if (preserveVersion) version = history.DefaultIfEmpty(0).Max();
+ var plan = CreatePlan(history, version);
+ if (downOnly && (version >= history.DefaultIfEmpty(0).Max() || plan.Any(step => step.IsUp)))
+ throw new MigrationException("Rollback requires a lower target and cannot apply upward migrations.");
+ var profiles = _migrationLoader.AuxiliaryTypes.Where(t => t.GetCustomAttribute() is { } p && Options.Profiles.Contains(p.Name) && _migrationLoader.InScope(p.Scope))
+ .OrderBy(t => t.GetCustomAttribute().Order).ThenBy(t => t.FullName, StringComparer.Ordinal).ToArray();
+ foreach (var name in Options.Profiles)
+ if (!profiles.Any(t => t.GetCustomAttribute().Name == name)) throw new MigrationException("Unknown profile: " + name);
+ var afterCommit = new List();
+ var firstRun = true;
+ void Execute(IMigration migration, MigrationStep step, bool record)
{
- if (step.IsUp) Logger.MigrateUp(step.Version, "Preview");
- else Logger.MigrateDown(step.Version, "Preview");
- continue;
+ migration.Database = _provider;
+ if (firstRun) { migration.InitializeOnce(_args); firstRun = false; }
+ MigrationExecution.Execute(_provider, migration, step, Logger,
+ Options.TransactionMode == MigrationTransactionMode.PerMigration, session, record, !session);
+ if (session) afterCommit.Add(() => MigrationExecution.After(_provider, migration, step.IsUp));
}
- var migration = _migrationLoader.GetMigration(step.Version);
- if (firstRun)
+ void Maintenance(MaintenanceStage stage)
{
- migration.InitializeOnce(_args);
- firstRun = false;
+ foreach (var type in _migrationLoader.AuxiliaryTypes.Where(t => t.GetCustomAttribute() is { } a && a.Stage == stage && _migrationLoader.InScope(a.Scope))
+ .OrderBy(t => t.GetCustomAttribute().Order).ThenBy(t => t.FullName, StringComparer.Ordinal))
+ Execute(_migrationLoader.CreateInstance(type), new MigrationStep(0, true), false);
+ }
+ void Run()
+ {
+ Maintenance(MaintenanceStage.BeforeRun);
+ foreach (var step in plan)
+ {
+ Maintenance(MaintenanceStage.BeforeMigration);
+ Execute(_migrationLoader.GetMigration(step.Version), step, true);
+ if (step.IsUp) history.Add(step.Version); else history.Remove(step.Version);
+ Maintenance(MaintenanceStage.AfterMigration);
+ }
+ foreach (var type in profiles) Execute(_migrationLoader.CreateInstance(type), new MigrationStep(0, true), false);
+ Maintenance(MaintenanceStage.AfterRun);
+ }
+ Logger.Started(new List(initialHistory), version);
+ if (session) MigrationExecution.InTransaction(_provider, true, Run); else Run();
+ foreach (var callback in afterCommit) callback();
+ history.Sort();
+ Logger.Finished(new List(initialHistory), version);
+ }
+ catch (Exception ex) { failure = ex; throw; }
+ finally
+ {
+ try { lease?.Dispose(); }
+ catch (Exception release)
+ {
+ if (failure == null) throw;
+ failure.Data["LockReleaseException"] = release;
}
- MigrationExecution.Execute(_provider, migration, step, Logger);
- if (step.IsUp) history.Add(step.Version);
- else history.Remove(step.Version);
}
- history.Sort();
- Logger.Finished(history, version);
}
}
+
diff --git a/src/Migrator/RunnerOptions.cs b/src/Migrator/RunnerOptions.cs
new file mode 100644
index 00000000..0666f37d
--- /dev/null
+++ b/src/Migrator/RunnerOptions.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using DotNetProjects.Migrator.Framework;
+namespace DotNetProjects.Migrator;
+
+public enum TagMatchMode { Any, All }
+public enum MigrationTransactionMode { PerMigration, None, WholeSession }
+public enum MaintenanceStage { BeforeRun, BeforeMigration, AfterMigration, AfterRun }
+
+[AttributeUsage(AttributeTargets.Class, Inherited = true)]
+public sealed class TagsAttribute(params string[] tags) : Attribute
+{ public IReadOnlyList Tags { get; } = Array.AsReadOnly((string[])tags.Clone()); }
+[AttributeUsage(AttributeTargets.Class, Inherited = false)]
+public sealed class ProfileAttribute(string name) : Attribute
+{ public string Name { get; } = name; public int Order { get; set; } public string Scope { get; set; } }
+[AttributeUsage(AttributeTargets.Class, Inherited = false)]
+public sealed class MaintenanceAttribute(MaintenanceStage stage) : Attribute
+{ public MaintenanceStage Stage { get; } = stage; public int Order { get; set; } public string Scope { get; set; } }
+
+public sealed class RunnerOptions
+{
+ public ISet Tags { get; } = new HashSet(StringComparer.Ordinal);
+ public TagMatchMode TagMatch { get; set; } = TagMatchMode.Any;
+ public ISet Profiles { get; } = new HashSet(StringComparer.Ordinal);
+ public MigrationTransactionMode TransactionMode { get; set; } = MigrationTransactionMode.PerMigration;
+ public Func Activator { get; set; }
+ public IMigrationLock Lock { get; set; }
+ public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(30);
+}
+
+/// Acquire before any history read. The lease must release its lock in Dispose.
+public interface IMigrationLock
+{
+ IDisposable Acquire(ITransformationProvider provider, string scope, TimeSpan timeout);
+}
+
+public sealed class UnsupportedMigrationFeatureException : NotSupportedException
+{
+ public UnsupportedMigrationFeatureException(string message, Exception inner = null) : base(message, inner) { }
+}
+public sealed class MigrationLockTimeoutException : TimeoutException
+{
+ public MigrationLockTimeoutException(Exception inner) : base("Timed out acquiring the migration lock.", inner) { }
+}