From 02da4bd4f84df5bc55245e08343a436d90f85b23 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 14:42:33 +0200 Subject: [PATCH 1/7] Introduce structured fluent migration operations and SQL previews Add Create/Alter/Delete/Rename/data/Execute/administration roots backed by operation records, immutable input snapshots, schema inspection and explicit provider callbacks. Build complete table definitions before execution, including the legacy builder adapter. Add automatic reversal for supported operations and reject unsupported preview/reversal rather than running callbacks. Track created and renamed tables during offline SQL generation. Validation: fluent snapshot, preview rejection, SQLite execution/preview equivalence and legacy-builder tests pass; unit and SQLite suites pass. --- src/Migrator.Tests/FluentOperationsTests.cs | 68 ++++ .../Framework/Fluent/FluentMigration.cs | 64 +++ .../Framework/Fluent/MigrationBuilder.cs | 158 ++++++++ src/Migrator/Framework/Fluent/Operations.cs | 175 ++++++++ .../Framework/Fluent/SqlGenerationContext.cs | 49 +++ .../SchemaBuilder/AddTableExpression.cs | 64 +-- .../Framework/SchemaBuilder/SchemaBuilder.cs | 372 +++++++++--------- 7 files changed, 739 insertions(+), 211 deletions(-) create mode 100644 src/Migrator.Tests/FluentOperationsTests.cs create mode 100644 src/Migrator/Framework/Fluent/FluentMigration.cs create mode 100644 src/Migrator/Framework/Fluent/MigrationBuilder.cs create mode 100644 src/Migrator/Framework/Fluent/Operations.cs create mode 100644 src/Migrator/Framework/Fluent/SqlGenerationContext.cs diff --git a/src/Migrator.Tests/FluentOperationsTests.cs b/src/Migrator.Tests/FluentOperationsTests.cs new file mode 100644 index 00000000..3013b029 --- /dev/null +++ b/src/Migrator.Tests/FluentOperationsTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using DotNetProjects.Migrator.Providers; +using Microsoft.Data.Sqlite; +using NSubstitute; +using NUnit.Framework; +namespace Migrator.Tests; +public class FluentOperationsTests +{ + [Test] public void TableIsOneCompleteOperationAndDoesNotMutateInput() + { + var column = new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey); + var builder = new MigrationBuilder(); builder.Create.Table("Example").WithFields(column); + column.Name = "Changed"; + var operation = (CreateTableOperation)builder.Build().Single(); + Assert.That(((Column)operation.Fields.Single()).Name, Is.EqualTo("Id")); + var provider = Substitute.For(); + builder.Apply(provider); + provider.Received(1).AddTable("Example", (string)null, Arg.Is(x => x.Length == 1)); + provider.DidNotReceiveWithAnyArgs().AddColumn(default, default(Column)); + } + [Test] public void OfflinePreviewTracksCreatedThenRenamedTable() + { + var builder = new MigrationBuilder(); + builder.Create.Table("First").WithColumn("Id").AsInt32(); + builder.Rename.Table("First", "Second"); + builder.Create.Column("Name", "Second").AsString(); + var sql = builder.Preview(new SqlGenerationContext(ProviderTypes.SQLite)); + Assert.That(sql.Count, Is.EqualTo(3)); + Assert.That(sql[2], Does.Contain("Second")); + var unknown = new MigrationBuilder(); unknown.Create.Column("Id", "Missing"); + Assert.Throws(() => unknown.Preview(new SqlGenerationContext(ProviderTypes.SQLite))); + } + [Test] public void CallbacksCannotBePreviewedOrAutomaticallyReversed() + { + var called = false; var builder = new MigrationBuilder(); builder.Execute.WithProvider(_ => called = true); + Assert.Throws(() => builder.Preview(new SqlGenerationContext(ProviderTypes.SQLite))); + Assert.Throws(() => builder.Build().Single().Reverse()); + Assert.That(called, Is.False); + } + [Test, Category("SQLite")] public void FluentAndPreviewProduceEquivalentDataAndAutomaticDownRemovesTable() + { + var builder = new MigrationBuilder(); builder.Create.Table("Example").WithColumn("Id").AsInt32().PrimaryKey().WithColumn("Name").AsString(); + builder.Insert.IntoTable("Example").Row(new[] { "Id", "Name" }, new object[] { 1, "O'Brien" }); + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + builder.Apply(provider); + Assert.That(provider.ExecuteScalar("SELECT Name FROM Example"), Is.EqualTo("O'Brien")); + provider.RemoveTable("Example"); + foreach (var sql in builder.Preview(new SqlGenerationContext(ProviderTypes.SQLite))) provider.ExecuteNonQuery(sql); + Assert.That(provider.ExecuteScalar("SELECT Name FROM Example"), Is.EqualTo("O'Brien")); + builder.Build()[0].Reverse().Apply(provider); + Assert.That(provider.TableExists("Example"), Is.False); + } + [Test, Category("SQLite")] public void LegacyBuilderCreatesCompleteTable() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + var builder = new DotNetProjects.Migrator.Framework.SchemaBuilder.SchemaBuilder(); + builder.AddTable("Example").AddColumn("Id").OfType(DbType.Int32); + provider.ExecuteSchemaBuilder(builder); + Assert.That(provider.ColumnExists("Example", "Id"), Is.True); + } +} diff --git a/src/Migrator/Framework/Fluent/FluentMigration.cs b/src/Migrator/Framework/Fluent/FluentMigration.cs new file mode 100644 index 00000000..94714ee6 --- /dev/null +++ b/src/Migrator/Framework/Fluent/FluentMigration.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Linq; +namespace DotNetProjects.Migrator.Framework.Fluent; + +public abstract class FluentMigration : Migration +{ + public abstract void BuildUp(MigrationBuilder migration); + public virtual void BuildDown(MigrationBuilder migration) => throw new IrreversibleMigrationException(); + public IReadOnlyList GetOperations(bool up) + { + var builder = new MigrationBuilder(); + if (up) BuildUp(builder); else BuildDown(builder); + return builder.Build(); + } + public override void Up() { foreach (var op in GetOperations(true)) op.Apply(Database); } + public override void Down() { foreach (var op in GetOperations(false)) op.Apply(Database); } + protected SchemaInspector Schema => new(Database); + protected ITransformationProvider Context => Database; +} +public abstract class AutoReversingMigration : FluentMigration +{ + public override void Up() + { + var operations = GetOperations(true); + foreach (var op in operations) _ = op.Reverse(); // Validate before the first change. + foreach (var op in operations) op.Apply(Database); + } + public override void BuildDown(MigrationBuilder migration) + { foreach (var op in GetOperations(true).Reverse()) migration.Add(op.Reverse()); } +} +public sealed class SchemaInspector(ITransformationProvider provider) +{ + public TableInspector Table(string name) => new(provider, name); + public bool ViewExists(string name) => provider.ViewExists(name); + public bool DatabaseExists(string name) => provider.DatabaseExists(name); + public IEnumerable Databases() => provider.GetDatabases(); + public IEnumerable Tables() => provider.GetTables(); + public IEnumerable Tables(string schema) => provider.GetTables(schema); + public object Scalar(string sql) => provider.ExecuteScalar(sql); + public T? NullableScalar(string sql) where T : struct => provider.ExecuteNullableScalar(sql); + public List Strings(string sql, params object[] args) => provider.ExecuteStringQuery(sql, args); + public void Query(string sql, Action read) + { using var command = provider.CreateCommand(); using var reader = provider.ExecuteQuery(command, sql); read(reader); } + public string QuoteColumn(string name) => provider.QuoteColumnNameIfRequired(name); + public string QuoteTable(string name) => provider.QuoteTableNameIfRequired(name); + public string Encode(Guid guid) => provider.Encode(guid); + public string Concatenate(params string[] strings) => provider.Concatenate(strings); +} +public sealed class TableInspector(ITransformationProvider provider, string table) +{ + public bool Exists() => provider.TableExists(table); + public bool ColumnExists(string column) => provider.ColumnExists(table, column); + public bool ConstraintExists(string name) => provider.ConstraintExists(table, name); + public bool IndexExists(string name) => provider.IndexExists(table, name); + public bool PrimaryKeyExists(string name) => provider.PrimaryKeyExists(table, name); + public Column[] Columns() => provider.GetColumns(table); + public Column Column(string name) => provider.GetColumnByName(table, name); + public Index[] Indexes() => provider.GetIndexes(table); + public ForeignKeyConstraint[] ForeignKeys() => provider.GetForeignKeyConstraints(table); + public string[] Constraints() => (provider as DotNetProjects.Migrator.Providers.TransformationProvider)?.GetConstraints(table) ?? throw new NotSupportedException("Provider does not expose constraint enumeration."); + public int ContentSize(string column) => provider.GetColumnContentSize(table, column); + public int? NullableContentSize(string column) => provider.GetNullableColumnContentSize(table, column); +} diff --git a/src/Migrator/Framework/Fluent/MigrationBuilder.cs b/src/Migrator/Framework/Fluent/MigrationBuilder.cs new file mode 100644 index 00000000..078e29bb --- /dev/null +++ b/src/Migrator/Framework/Fluent/MigrationBuilder.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using System.Linq; +using System.Reflection; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Models.Indexes; +using DotNetProjects.Migrator.Providers.Models; +using DotNetProjects.Migrator.Framework.Models; +using Index = DotNetProjects.Migrator.Framework.Index; +namespace DotNetProjects.Migrator.Framework.Fluent; + +public sealed class MigrationBuilder +{ + private readonly List> operations = new(); + public CreateRoot Create => new(this); + public AlterRoot Alter => new(this); + public DeleteRoot Delete => new(this); + public RenameRoot Rename => new(this); + public DataRoot Insert => new(this, DataKind.Insert); + public DataRoot Update => new(this, DataKind.Update); + public ExecuteRoot Execute => new(this); + public AdministrationRoot Administration => new(this); + public void Add(MigrationOperation operation) => operations.Add(() => operation); + internal void Add(Func operation) => operations.Add(operation); + public IReadOnlyList Build() => operations.Select(x => x()).ToArray(); + public void Apply(ITransformationProvider provider) { foreach (var op in Build()) op.Apply(provider); } + public IReadOnlyList Preview(SqlGenerationContext context) => Build().Select(op => op.ToSql(context)).Where(sql => sql.Length != 0).ToArray(); + public void IfDatabase(string name, Action configure) + { + var nested = new MigrationBuilder(); configure(nested); + foreach (var op in nested.Build()) Add(new ConditionalOperation(name, op)); + } + public void WithReverse(MigrationOperation forward, MigrationOperation backward) => Add(new ReversibleOperation(forward, backward)); +} +public sealed class CreateRoot(MigrationBuilder builder) +{ + public TableBuilder Table(string name) => new(builder, name); + public ColumnBuilder Column(string name, string table) => new(builder, table, name, false); + public void Index(string table, Index index) => builder.Add(new IndexOperation(table, (Index)Definitions.Copy(index))); + public void PrimaryKey(string name, string table, params string[] columns) => Constraint(ConstraintKind.PrimaryKey, name, table, columns); + public void NonClusteredPrimaryKey(string name, string table, params string[] columns) => Constraint(ConstraintKind.NonClusteredPrimaryKey, name, table, columns); + public void Unique(string name, string table, params string[] columns) => Constraint(ConstraintKind.Unique, name, table, columns); + private void Constraint(ConstraintKind kind, string name, string table, string[] columns) => builder.Add(new ConstraintOperation(kind, table, name, (string[])columns.Clone())); + public void Check(string name, string table, string sql) => builder.Add(new ConstraintOperation(ConstraintKind.Check, table, name, Array.Empty(), Check: sql)); + public void ForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType onDelete = ForeignKeyConstraintType.NoAction, ForeignKeyConstraintType onUpdate = ForeignKeyConstraintType.NoAction) + => builder.Add(new ConstraintOperation(ConstraintKind.ForeignKey, childTable, name, (string[])childColumns.Clone(), parentTable, (string[])parentColumns.Clone(), OnDelete: onDelete, OnUpdate: onUpdate)); + public void View(string name, string table, params IViewElement[] elements) { var copy = elements.ToArray(); builder.Add(new CallbackOperation("Create view", p => p.AddView(name, table, copy))); } + public void View(string name, string table, params IViewField[] fields) { var copy = fields.ToArray(); builder.Add(new CallbackOperation("Create view", p => p.AddView(name, table, copy))); } +} +public sealed class TableBuilder +{ + private readonly List fields = new(); + private Column current; + private string engine; + internal TableBuilder(MigrationBuilder builder, string name) => builder.Add(() => new CreateTableOperation(name, engine, fields.Select(Definitions.Copy).ToArray())); + public TableBuilder WithColumn(string name) { current = new Column(name, DbType.String, ColumnProperty.Null); fields.Add(current); return this; } + public TableBuilder WithFields(params IDbField[] values) { fields.AddRange(values.Select(Definitions.Copy)); return this; } + public TableBuilder WithEngine(string value) { engine = value; return this; } + private Column Current => current ?? throw new InvalidOperationException("Call WithColumn first."); + public TableBuilder OfType(DbType type) { Current.Type = type; return this; } + public TableBuilder OfType(MigratorDbType type) { Current.MigratorDbType = type; return this; } + public TableBuilder AsInt32() => OfType(DbType.Int32); + public TableBuilder AsInt64() => OfType(DbType.Int64); + public TableBuilder AsString(int size = 255) { OfType(DbType.String); Current.Size = size; return this; } + public TableBuilder AsGuid() => OfType(DbType.Guid); + public TableBuilder AsBoolean() => OfType(DbType.Boolean); + public TableBuilder AsDateTime() => OfType(DbType.DateTime2); + public TableBuilder WithSize(int size) { Current.Size = size; return this; } + public TableBuilder WithPrecision(int precision, int scale) { Current.Precision = precision; Current.Scale = scale; return this; } + public TableBuilder WithDefaultValue(object value) { Current.DefaultValue = value; return this; } + public TableBuilder WithProperty(ColumnProperty value) { Current.ColumnProperty = value; return this; } + public TableBuilder NotNullable() { Current.ColumnProperty = (Current.ColumnProperty & ~ColumnProperty.Null) | ColumnProperty.NotNull; return this; } + public TableBuilder Nullable() { Current.ColumnProperty = (Current.ColumnProperty & ~ColumnProperty.NotNull) | ColumnProperty.Null; return this; } + public TableBuilder PrimaryKey() { Current.ColumnProperty |= ColumnProperty.PrimaryKey; return NotNullable(); } + public TableBuilder Identity() { Current.ColumnProperty |= ColumnProperty.Identity; return this; } + public TableBuilder Unique() { Current.ColumnProperty |= ColumnProperty.Unique; return this; } +} +public sealed class ColumnBuilder +{ + private readonly Column column; + internal ColumnBuilder(MigrationBuilder builder, string table, string name, bool alter) { column = new Column(name, DbType.String, ColumnProperty.Null); builder.Add(() => new ColumnOperation(table, Definitions.CopyColumn(column), alter)); } + public ColumnBuilder OfType(DbType value) { column.Type = value; return this; } + public ColumnBuilder OfType(MigratorDbType value) { column.MigratorDbType = value; return this; } + public ColumnBuilder AsInt32() => OfType(DbType.Int32); + public ColumnBuilder AsInt64() => OfType(DbType.Int64); + public ColumnBuilder AsString(int size = 255) { column.Size = size; return OfType(DbType.String); } + public ColumnBuilder WithSize(int value) { column.Size = value; return this; } + public ColumnBuilder WithPrecision(int precision, int scale) { column.Precision = precision; column.Scale = scale; return this; } + public ColumnBuilder WithDefaultValue(object value) { column.DefaultValue = value; return this; } + public ColumnBuilder WithProperty(ColumnProperty value) { column.ColumnProperty = value; return this; } + public ColumnBuilder NotNullable() { column.ColumnProperty = (column.ColumnProperty & ~ColumnProperty.Null) | ColumnProperty.NotNull; return this; } + public ColumnBuilder Nullable() { column.ColumnProperty = (column.ColumnProperty & ~ColumnProperty.NotNull) | ColumnProperty.Null; return this; } + public ColumnBuilder Identity() { column.ColumnProperty |= ColumnProperty.Identity; return this; } + public ColumnBuilder PrimaryKey() { column.ColumnProperty |= ColumnProperty.PrimaryKey; return NotNullable(); } +} +public sealed class AlterRoot(MigrationBuilder builder) +{ + public ColumnBuilder Column(string name, string table) => new(builder, table, name, true); + public void Column(string table, Column column) => builder.Add(new ColumnOperation(table, Definitions.CopyColumn(column), true)); +} +public sealed class DeleteRoot(MigrationBuilder builder) +{ + public void Table(string table) => builder.Add(new RemoveOperation(RemoveKind.Table, table)); + public void Column(string name, string table) => builder.Add(new RemoveOperation(RemoveKind.Column, table, name)); + public void ForeignKey(string name, string table) => builder.Add(new RemoveOperation(RemoveKind.ForeignKey, table, name)); + public void Constraint(string name, string table) => builder.Add(new RemoveOperation(RemoveKind.Constraint, table, name)); + public void PrimaryKey(string table) => builder.Add(new RemoveOperation(RemoveKind.PrimaryKey, table)); + public void Default(string column, string table) => builder.Add(new RemoveOperation(RemoveKind.Default, table, column)); + public void Index(string name, string table) => builder.Add(new RemoveOperation(RemoveKind.Index, table, name)); + public void AllIndexes(string table) => builder.Add(new RemoveOperation(RemoveKind.AllIndexes, table)); + public void AllConstraints(string table) => builder.Add(new RemoveOperation(RemoveKind.AllConstraints, table)); + public void ForeignKeysForColumn(string table, string column) => builder.Add(new RemoveOperation(RemoveKind.ForeignKeysForColumn, table, column)); + public DataBuilder FromTable(string table) => new(builder, DataKind.Delete, table); +} +public sealed class RenameRoot(MigrationBuilder builder) +{ + public void Table(string oldName, string newName) => builder.Add(new RenameOperation(oldName, newName)); + public void Column(string table, string oldName, string newName) => builder.Add(new RenameOperation(table, newName, oldName)); +} +public sealed class DataRoot(MigrationBuilder builder, DataKind kind) +{ + public DataBuilder IntoTable(string table) => new(builder, kind, table); + public DataBuilder Table(string table) => new(builder, kind, table); +} +public sealed class DataBuilder +{ + private DataKind kind; + private string[] columns = Array.Empty(), whereColumns; + private object[] values = Array.Empty(), whereValues; + private string whereSql; + internal DataBuilder(MigrationBuilder builder, DataKind kind, string table) { this.kind = kind; builder.Add(() => new DataOperation(this.kind, table, columns.ToArray(), values.ToArray(), whereColumns?.ToArray(), whereValues?.ToArray(), whereSql)); } + public DataBuilder Row(string[] names, object[] data) { if (names.Length != data.Length) throw new ArgumentException("Columns and values must have equal lengths."); columns = names.ToArray(); values = data.ToArray(); return this; } + public DataBuilder Set(string[] names, object[] data) => Row(names, data); + public DataBuilder Where(string[] names, object[] data) { if (names.Length != data.Length) throw new ArgumentException("Where columns and values must have equal lengths."); whereColumns = names.ToArray(); whereValues = data.ToArray(); return this; } + public DataBuilder WhereSql(string sql) { whereSql = sql; return this; } + public DataBuilder IfNotExists(string[] names, object[] data) { kind = DataKind.InsertIfMissing; return Where(names, data); } +} +public sealed class ExecuteRoot(MigrationBuilder builder) +{ + public void Sql(string sql, int? timeout = null, params object[] parameters) => builder.Add(new SqlOperation(sql, timeout, parameters.Length == 0 ? null : parameters.ToArray())); + public void Script(string path) => Sql(File.ReadAllText(path)); + public void EmbeddedScript(Assembly assembly, string name) { using var stream = assembly.GetManifestResourceStream(name) ?? throw new FileNotFoundException("Resource not found", name); using var reader = new StreamReader(stream); Sql(reader.ReadToEnd()); } + public void WithProvider(Action action) => builder.Add(new CallbackOperation("Provider callback (not previewable)", action)); + public void Truncate(string table) => builder.Add(new RemoveOperation(RemoveKind.Truncate, table)); + public void CopyData(string source, IEnumerable sourceColumns, string target, IEnumerable targetColumns, IEnumerable orderBy = null) + { var sc = sourceColumns.ToList(); var tc = targetColumns.ToList(); var order = orderBy?.ToList(); builder.Add(new CallbackOperation("Copy data", p => p.CopyDataFromTableToTable(source, sc.ToList(), target, tc.ToList(), order?.ToList()))); } + public void UpdateFrom(string source, string target, ColumnPair[] copy, ColumnPair[] match) + { var c = copy.ToArray(); var m = match.ToArray(); builder.Add(new CallbackOperation("Update from table", p => p.UpdateTargetFromSource(source, target, c, m))); } +} +public sealed class AdministrationRoot(MigrationBuilder builder) +{ + public void CreateDatabase(string name) => builder.Add(new CallbackOperation("Create database", p => p.CreateDatabases(name))); + public void DropDatabase(string name) => builder.Add(new CallbackOperation("Drop database", p => p.DropDatabases(name))); + public void SwitchDatabase(string name) => builder.Add(new CallbackOperation("Switch database", p => p.SwitchDatabase(name))); + public void KillConnections(string name) => builder.Add(new CallbackOperation("Kill connections", p => p.KillDatabaseConnections(name))); +} diff --git a/src/Migrator/Framework/Fluent/Operations.cs b/src/Migrator/Framework/Fluent/Operations.cs new file mode 100644 index 00000000..1510c3da --- /dev/null +++ b/src/Migrator/Framework/Fluent/Operations.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Providers; +using Index = DotNetProjects.Migrator.Framework.Index; +namespace DotNetProjects.Migrator.Framework.Fluent; + +public abstract record MigrationOperation +{ + public abstract void Apply(ITransformationProvider provider); + public virtual MigrationOperation Reverse() => throw new IrreversibleMigrationException(); + public virtual string ToSql(SqlGenerationContext context) => throw new NotSupportedException($"SQL preview is not supported for {GetType().Name}."); +} + +public sealed record CreateTableOperation(string Table, string Engine, IDbField[] Fields) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) => p.AddTable(Table, Engine, Fields.Select(Definitions.Copy).ToArray()); + public override MigrationOperation Reverse() => new RemoveOperation(RemoveKind.Table, Table); + public override string ToSql(SqlGenerationContext c) + { + if (Engine != null || Fields.Any(f => f is not Column)) throw new NotSupportedException("Preview this table's constraints as separate operations."); + var columns = Fields.Cast().Select(Definitions.CopyColumn).ToArray(); + var pks = columns.Where(x => x.IsPrimaryKey).ToArray(); + if (pks.Length > 1) foreach (var column in pks) column.ColumnProperty &= ~ColumnProperty.PrimaryKey; + var definitions = columns.Select(c.Column).ToList(); + if (pks.Length > 1) definitions.Add($"PRIMARY KEY ({string.Join(", ", pks.Select(x => c.Quote(x.Name)))})"); + c.AddTable(Table, columns); + return $"CREATE TABLE {c.Table(Table)} ({string.Join(", ", definitions)});"; + } +} +public sealed record ColumnOperation(string Table, Column Column, bool Alter = false) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) { if (Alter) p.ChangeColumn(Table, Definitions.CopyColumn(Column)); else p.AddColumn(Table, Definitions.CopyColumn(Column)); } + public override MigrationOperation Reverse() => Alter ? base.Reverse() : new RemoveOperation(RemoveKind.Column, Table, Column.Name); + public override string ToSql(SqlGenerationContext c) + { + c.RequireTable(Table); + if (Alter) throw new NotSupportedException("Altering columns needs provider-specific schema inspection; use explicit SQL preview."); + c.AddColumn(Table, Column); + return $"ALTER TABLE {c.Table(Table)} ADD {c.Column(Column)};"; + } +} +public enum RemoveKind { Table, Column, ForeignKey, Constraint, PrimaryKey, Default, Index, AllIndexes, AllConstraints, ForeignKeysForColumn, Truncate } +public sealed record RemoveOperation(RemoveKind Kind, string Table, string Name = null) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) + { + switch (Kind) + { + case RemoveKind.Table: p.RemoveTable(Table); break; + case RemoveKind.Column: p.RemoveColumn(Table, Name); break; + case RemoveKind.ForeignKey: p.RemoveForeignKey(Table, Name); break; + case RemoveKind.Constraint: p.RemoveConstraint(Table, Name); break; + case RemoveKind.PrimaryKey: p.RemovePrimaryKey(Table); break; + case RemoveKind.Default: p.RemoveColumnDefaultValue(Table, Name); break; + case RemoveKind.Index: p.RemoveIndex(Table, Name); break; + case RemoveKind.AllIndexes: p.RemoveAllIndexes(Table); break; + case RemoveKind.AllConstraints: p.RemoveAllConstraints(Table); break; + case RemoveKind.ForeignKeysForColumn: p.RemoveAllForeignKeys(Table, Name); break; + case RemoveKind.Truncate: p.TruncateTable(Table); break; + default: throw new ArgumentOutOfRangeException(); + } + } + public override string ToSql(SqlGenerationContext c) + { + c.RequireTable(Table); + if (Kind == RemoveKind.Table) { c.RemoveTable(Table); return $"DROP TABLE {c.Table(Table)};"; } + throw new NotSupportedException($"{Kind} requires provider-specific preview support."); + } +} +public sealed record RenameOperation(string Table, string NewName, string Column = null) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) { if (Column == null) p.RenameTable(Table, NewName); else p.RenameColumn(Table, Column, NewName); } + public override MigrationOperation Reverse() => Column == null ? new RenameOperation(NewName, Table) : new RenameOperation(Table, Column, NewName); + public override string ToSql(SqlGenerationContext c) + { + c.RequireTable(Table); + if (c.Provider is not (ProviderTypes.SQLite or ProviderTypes.PostgreSQL)) return base.ToSql(c); + if (Column == null) { c.RenameTable(Table, NewName); return $"ALTER TABLE {c.Table(Table)} RENAME TO {c.Quote(NewName)};"; } + c.RenameColumn(Table, Column, NewName); + return $"ALTER TABLE {c.Table(Table)} RENAME COLUMN {c.Quote(Column)} TO {c.Quote(NewName)};"; + } +} +public enum ConstraintKind { PrimaryKey, NonClusteredPrimaryKey, Unique, Check, ForeignKey } +public sealed record ConstraintOperation(ConstraintKind Kind, string Table, string Name, string[] Columns, string ParentTable = null, + string[] ParentColumns = null, string Check = null, ForeignKeyConstraintType OnDelete = ForeignKeyConstraintType.NoAction, + ForeignKeyConstraintType OnUpdate = ForeignKeyConstraintType.NoAction) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) + { + switch (Kind) + { + case ConstraintKind.PrimaryKey: p.AddPrimaryKey(Name, Table, Columns); break; + case ConstraintKind.NonClusteredPrimaryKey: p.AddPrimaryKeyNonClustered(Name, Table, Columns); break; + case ConstraintKind.Unique: p.AddUniqueConstraint(Name, Table, Columns); break; + case ConstraintKind.Check: p.AddCheckConstraint(Name, Table, Check); break; + case ConstraintKind.ForeignKey: + if (p is not IForeignKeyActions actions) throw new NotSupportedException("Provider must implement IForeignKeyActions."); + actions.AddForeignKey(Name, Table, Columns, ParentTable, ParentColumns, OnDelete, OnUpdate); break; + } + } + public override MigrationOperation Reverse() => new RemoveOperation(Kind is ConstraintKind.PrimaryKey or ConstraintKind.NonClusteredPrimaryKey ? RemoveKind.PrimaryKey : Kind == ConstraintKind.ForeignKey ? RemoveKind.ForeignKey : RemoveKind.Constraint, Table, Name); +} +public sealed record IndexOperation(string Table, Index Index) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) => p.AddIndex(Table, (Index)Definitions.Copy(Index)); + public override MigrationOperation Reverse() => new RemoveOperation(RemoveKind.Index, Table, Index.Name); + public override string ToSql(SqlGenerationContext c) + { + c.RequireTable(Table); + if (Index.FilterItems.Count != 0 || Index.IncludeColumns.Length != 0 || Index.Clustered) return base.ToSql(c); + return $"CREATE {(Index.Unique ? "UNIQUE " : "")}INDEX {c.Quote(Index.Name)} ON {c.Table(Table)} ({string.Join(", ", Index.KeyColumns.Select(c.Quote))});"; + } +} +public enum DataKind { Insert, InsertIfMissing, Update, Delete } +public sealed record DataOperation(DataKind Kind, string Table, string[] Columns, object[] Values, string[] WhereColumns = null, object[] WhereValues = null, string WhereSql = null) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) + { + switch (Kind) + { + case DataKind.Insert: p.Insert(Table, Columns, Values); break; + case DataKind.InsertIfMissing: p.InsertIfNotExists(Table, Columns, Values, WhereColumns, WhereValues); break; + case DataKind.Update: + if (WhereSql != null) p.Update(Table, Columns, Values, WhereSql); + else p.Update(Table, Columns, Values, WhereColumns ?? Array.Empty(), WhereValues ?? Array.Empty()); break; + case DataKind.Delete: p.Delete(Table, WhereColumns, WhereValues); break; + } + } + public override string ToSql(SqlGenerationContext c) + { + c.RequireTable(Table); + if (Kind == DataKind.Insert) return $"INSERT INTO {c.Table(Table)} ({string.Join(", ", Columns.Select(c.Quote))}) VALUES ({string.Join(", ", Values.Select(c.Literal))});"; + return base.ToSql(c); + } +} +public sealed record SqlOperation(string Sql, int? Timeout = null, object[] Parameters = null) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) => p.ExecuteNonQuery(Sql, Timeout ?? p.CommandTimeout ?? 30, Parameters); + public override string ToSql(SqlGenerationContext c) + { + if (Parameters != null && Parameters.Length != 0) throw new NotSupportedException("Parameterized raw SQL cannot be exported as an executable script."); + return Sql.TrimEnd().TrimEnd(';') + ";"; + } +} +public sealed record CallbackOperation(string Description, Action Action) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) => Action(p); +} +public sealed record ReversibleOperation(MigrationOperation Forward, MigrationOperation Backward) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) => Forward.Apply(p); + public override MigrationOperation Reverse() => new ReversibleOperation(Backward, Forward); + public override string ToSql(SqlGenerationContext c) => Forward.ToSql(c); +} +public sealed record ConditionalOperation(string Provider, MigrationOperation Operation) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) { if (p.IsThisProvider(Provider)) Operation.Apply(p); } + public override MigrationOperation Reverse() => new ConditionalOperation(Provider, Operation.Reverse()); + public override string ToSql(SqlGenerationContext c) => c.Dialect.GetType().Name.StartsWith(Provider, StringComparison.OrdinalIgnoreCase) ? Operation.ToSql(c) : ""; +} +public static class Definitions +{ + public static Column CopyColumn(Column c) => new(c.Name, c.Type, c.Size, c.ColumnProperty, c.DefaultValue is byte[] b ? b.Clone() : c.DefaultValue) { Precision = c.Precision, Scale = c.Scale, MigratorDbType = c.MigratorDbType }; + public static IDbField Copy(IDbField field) => field switch + { + Column c => CopyColumn(c), + Index i => new Index { Name = i.Name, Unique = i.Unique, Clustered = i.Clustered, KeyColumns = (string[])i.KeyColumns.Clone(), IncludeColumns = (string[])i.IncludeColumns.Clone(), FilterItems = i.FilterItems.Select(f => new DotNetProjects.Migrator.Providers.Models.Indexes.FilterItem { ColumnName = f.ColumnName, Filter = f.Filter, Value = f.Value }).ToList() }, + ForeignKeyConstraint f => new ForeignKeyConstraint(f.Name, f.ParentTable, (string[])f.ParentColumns.Clone(), f.ChildTable, (string[])f.ChildColumns.Clone()) { OnDelete = f.OnDelete, OnUpdate = f.OnUpdate, Match = f.Match, Id = f.Id }, + Unique u => new Unique { Name = u.Name, KeyColumns = (string[])u.KeyColumns.Clone() }, + CheckConstraint c => new CheckConstraint(c.Name, c.CheckConstraintString), + _ => throw new NotSupportedException($"Cannot snapshot {field.GetType().Name}.") + }; +} diff --git a/src/Migrator/Framework/Fluent/SqlGenerationContext.cs b/src/Migrator/Framework/Fluent/SqlGenerationContext.cs new file mode 100644 index 00000000..04274352 --- /dev/null +++ b/src/Migrator/Framework/Fluent/SqlGenerationContext.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using DotNetProjects.Migrator.Providers; +namespace DotNetProjects.Migrator.Framework.Fluent; + +/// Tracks schema produced earlier in a preview without modifying a database. +public sealed class SqlGenerationContext +{ + private readonly Dictionary> tables = new(StringComparer.Ordinal); + private readonly HashSet removed = new(StringComparer.Ordinal); + private readonly Func existingTable; + public ProviderTypes Provider { get; } + public Dialect Dialect { get; } + public SqlGenerationContext(ProviderTypes provider, Func existingTable = null) + { Provider = provider; Dialect = ProviderFactory.DialectForProvider(provider) ?? throw new ArgumentException("Unknown provider."); this.existingTable = existingTable; } + public string Quote(string name) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Identifier cannot be empty."); + var template = Dialect.QuoteTemplate; + var closing = template[^1].ToString(); + return string.Format(CultureInfo.InvariantCulture, template, name.Replace(closing, closing + closing)); + } + public string Table(string name) => string.Join(".", name.Split('.').Select(Quote)); + public string Column(Column column) => Dialect.GetAndMapColumnProperties(Definitions.CopyColumn(column)).ColumnSql; + public string Literal(object value) => value switch + { + null or DBNull => "NULL", + string s => "'" + s.Replace("'", "''") + "'", + bool b => Provider == ProviderTypes.PostgreSQL ? (b ? "TRUE" : "FALSE") : (b ? "1" : "0"), + DateTime d => "'" + d.ToString("yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture) + "'", + Guid g => "'" + g + "'", + byte or sbyte or short or ushort or int or uint or long or ulong or decimal or float or double => Convert.ToString(value, CultureInfo.InvariantCulture), + _ => throw new NotSupportedException("No portable SQL literal for " + value.GetType().Name) + }; + public void RequireTable(string table) + { + if (tables.ContainsKey(table)) return; + if (removed.Contains(table)) throw new MigrationException("Table was removed earlier in the plan: " + table); + var columns = existingTable?.Invoke(table) ?? throw new MigrationException("Offline preview needs a definition for existing table: " + table); + tables.Add(table, columns.ToDictionary(c => c.Name, Definitions.CopyColumn)); + } + public void AddTable(string table, IEnumerable columns) { if (tables.ContainsKey(table)) throw new MigrationException("Duplicate table: " + table); removed.Remove(table); tables.Add(table, columns.ToDictionary(c => c.Name, Definitions.CopyColumn)); } + public void RemoveTable(string table) { tables.Remove(table); removed.Add(table); } + public void RenameTable(string table, string name) { var columns = tables[table].Values.ToArray(); RemoveTable(table); AddTable(name, columns); } + public void AddColumn(string table, Column column) => tables[table].Add(column.Name, Definitions.CopyColumn(column)); + public void RenameColumn(string table, string oldName, string newName) { var column = tables[table][oldName]; tables[table].Remove(oldName); column.Name = newName; tables[table].Add(newName, column); } +} diff --git a/src/Migrator/Framework/SchemaBuilder/AddTableExpression.cs b/src/Migrator/Framework/SchemaBuilder/AddTableExpression.cs index c86ec384..258d7882 100644 --- a/src/Migrator/Framework/SchemaBuilder/AddTableExpression.cs +++ b/src/Migrator/Framework/SchemaBuilder/AddTableExpression.cs @@ -1,29 +1,37 @@ -#region License - -//The contents of this file are subject to the Mozilla Public License -//Version 1.1 (the "License"); you may not use this file except in -//compliance with the License. You may obtain a copy of the License at -//http://www.mozilla.org/MPL/ -//Software distributed under the License is distributed on an "AS IS" -//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the -//License for the specific language governing rights and limitations -//under the License. - -#endregion - -namespace DotNetProjects.Migrator.Framework.SchemaBuilder; - -public class AddTableExpression : ISchemaBuilderExpression -{ - private readonly string _newTable; - - public AddTableExpression(string newTable) - { - _newTable = newTable; - } - - public void Create(ITransformationProvider provider) - { - provider.AddTable(_newTable); - } +#region License + +//The contents of this file are subject to the Mozilla Public License +//Version 1.1 (the "License"); you may not use this file except in +//compliance with the License. You may obtain a copy of the License at +//http://www.mozilla.org/MPL/ +//Software distributed under the License is distributed on an "AS IS" +//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the +//License for the specific language governing rights and limitations +//under the License. + +#endregion + +using System.Collections.Generic; +using System.Linq; +namespace DotNetProjects.Migrator.Framework.SchemaBuilder; + +public class AddTableExpression : ISchemaBuilderExpression +{ + private readonly string _newTable; + public List Columns { get; } = new(); + + public AddTableExpression(string newTable) + { + _newTable = newTable; + } + + public void Create(ITransformationProvider provider) + { + var fields = Columns.Select(c => (IDbField)new Column(c.Name, c.Type, c.Size, c.ColumnProperty, c.DefaultValue)).ToList(); + foreach (var c in Columns.Where(c => c.ForeignKey != null)) + fields.Add(new ForeignKeyConstraint("FK_" + _newTable + "_" + c.Name + "_" + c.ForeignKey.PrimaryTable + "_" + c.ForeignKey.PrimaryKey, + c.ForeignKey.PrimaryTable, new[] { c.ForeignKey.PrimaryKey }, _newTable, new[] { c.Name }) + { OnDelete = new DotNetProjects.Migrator.Providers.ForeignKeyConstraintMapper().SqlForConstraint(c.Constraint) }); + provider.AddTable(_newTable, fields.ToArray()); + } } \ No newline at end of file diff --git a/src/Migrator/Framework/SchemaBuilder/SchemaBuilder.cs b/src/Migrator/Framework/SchemaBuilder/SchemaBuilder.cs index 675e446b..9aae0607 100644 --- a/src/Migrator/Framework/SchemaBuilder/SchemaBuilder.cs +++ b/src/Migrator/Framework/SchemaBuilder/SchemaBuilder.cs @@ -1,184 +1,190 @@ -#region License - -//The contents of this file are subject to the Mozilla Public License -//Version 1.1 (the "License"); you may not use this file except in -//compliance with the License. You may obtain a copy of the License at -//http://www.mozilla.org/MPL/ -//Software distributed under the License is distributed on an "AS IS" -//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the -//License for the specific language governing rights and limitations -//under the License. - -#endregion - -using System; -using System.Collections.Generic; -using System.Data; - -namespace DotNetProjects.Migrator.Framework.SchemaBuilder; - -public class SchemaBuilder : IColumnOptions, IForeignKeyOptions, IDeleteTableOptions -{ - private readonly IList _exprs; - private IFluentColumn _currentColumn; - private string _currentTable; - - public SchemaBuilder() - { - _exprs = new List(); - } - - public IEnumerable Expressions - { - get { return _exprs; } - } - - public SchemaBuilder OfType(DbType columnType) - { - _currentColumn.Type = columnType; - - return this; - } - - public SchemaBuilder WithSize(int size) - { - if (size == 0) - { - throw new ArgumentNullException("size", "Size must be greater than zero"); - } - - _currentColumn.Size = size; - - return this; - } - - public IForeignKeyOptions AsForeignKey() - { - return this; - } - - /// - /// Adds a Table to be created to the Schema - /// - /// Table name to be created - /// SchemaBuilder for chaining - public SchemaBuilder AddTable(string name) - { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException("name"); - } - - _exprs.Add(new AddTableExpression(name)); - _currentTable = name; - - return this; - } - - public IDeleteTableOptions DeleteTable(string name) - { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException("name"); - } - - _currentTable = ""; - _currentColumn = null; - - _exprs.Add(new DeleteTableExpression(name)); - - return this; - } - - /// - /// Reference an existing table. - /// - /// Table to reference - /// SchemaBuilder for chaining - public SchemaBuilder WithTable(string name) - { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException("name"); - } - - _currentTable = name; - - return this; - } - - public SchemaBuilder ReferencedTo(string primaryKeyTable, string primaryKeyColumn) - { - _currentColumn.Constraint = ForeignKeyConstraintType.NoAction; - _currentColumn.ForeignKey = new ForeignKey(primaryKeyTable, primaryKeyColumn); - return this; - } - - /// - /// Reference an existing table. - /// - /// Table to reference - /// SchemaBuilder for chaining - public SchemaBuilder RenameTable(string newName) - { - if (string.IsNullOrEmpty(newName)) - { - throw new ArgumentNullException("newName"); - } - - _exprs.Add(new RenameTableExpression(_currentTable, newName)); - _currentTable = newName; - - return this; - } - - /// - /// Adds a Column to be created - /// - /// Column name to be added - /// IColumnOptions to restrict chaining - public IColumnOptions AddColumn(string name) - { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException("name"); - } - - if (string.IsNullOrEmpty(_currentTable)) - { - throw new ArgumentException("missing referenced table"); - } - - IFluentColumn column = new FluentColumn(name); - _currentColumn = column; - - _exprs.Add(new AddColumnExpression(_currentTable, column)); - return this; - } - - public SchemaBuilder WithProperty(ColumnProperty columnProperty) - { - _currentColumn.ColumnProperty = columnProperty; - - return this; - } - - public SchemaBuilder WithDefaultValue(object defaultValue) - { - if (defaultValue == null) - { - throw new ArgumentNullException("defaultValue", "DefaultValue cannot be null or empty"); - } - - _currentColumn.DefaultValue = defaultValue; - - return this; - } - - public SchemaBuilder WithConstraint(ForeignKeyConstraintType action) - { - _currentColumn.Constraint = action; - - return this; - } +#region License + +//The contents of this file are subject to the Mozilla Public License +//Version 1.1 (the "License"); you may not use this file except in +//compliance with the License. You may obtain a copy of the License at +//http://www.mozilla.org/MPL/ +//Software distributed under the License is distributed on an "AS IS" +//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the +//License for the specific language governing rights and limitations +//under the License. + +#endregion + +using System; +using System.Collections.Generic; +using System.Data; + +namespace DotNetProjects.Migrator.Framework.SchemaBuilder; + +public class SchemaBuilder : IColumnOptions, IForeignKeyOptions, IDeleteTableOptions +{ + private readonly IList _exprs; + private IFluentColumn _currentColumn; + private string _currentTable; + private AddTableExpression _creatingTable; + + public SchemaBuilder() + { + _exprs = new List(); + } + + public IEnumerable Expressions + { + get { return _exprs; } + } + + public SchemaBuilder OfType(DbType columnType) + { + _currentColumn.Type = columnType; + + return this; + } + + public SchemaBuilder WithSize(int size) + { + if (size == 0) + { + throw new ArgumentNullException("size", "Size must be greater than zero"); + } + + _currentColumn.Size = size; + + return this; + } + + public IForeignKeyOptions AsForeignKey() + { + return this; + } + + /// + /// Adds a Table to be created to the Schema + /// + /// Table name to be created + /// SchemaBuilder for chaining + public SchemaBuilder AddTable(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException("name"); + } + + _creatingTable = new AddTableExpression(name); + _exprs.Add(_creatingTable); + _currentTable = name; + + return this; + } + + public IDeleteTableOptions DeleteTable(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException("name"); + } + + _creatingTable = null; + _currentTable = ""; + _currentColumn = null; + + _exprs.Add(new DeleteTableExpression(name)); + + return this; + } + + /// + /// Reference an existing table. + /// + /// Table to reference + /// SchemaBuilder for chaining + public SchemaBuilder WithTable(string name) + { + _creatingTable = null; + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException("name"); + } + + _currentTable = name; + + return this; + } + + public SchemaBuilder ReferencedTo(string primaryKeyTable, string primaryKeyColumn) + { + _currentColumn.Constraint = ForeignKeyConstraintType.NoAction; + _currentColumn.ForeignKey = new ForeignKey(primaryKeyTable, primaryKeyColumn); + return this; + } + + /// + /// Reference an existing table. + /// + /// Table to reference + /// SchemaBuilder for chaining + public SchemaBuilder RenameTable(string newName) + { + if (string.IsNullOrEmpty(newName)) + { + throw new ArgumentNullException("newName"); + } + + _creatingTable = null; + _exprs.Add(new RenameTableExpression(_currentTable, newName)); + _currentTable = newName; + + return this; + } + + /// + /// Adds a Column to be created + /// + /// Column name to be added + /// IColumnOptions to restrict chaining + public IColumnOptions AddColumn(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException("name"); + } + + if (string.IsNullOrEmpty(_currentTable)) + { + throw new ArgumentException("missing referenced table"); + } + + IFluentColumn column = new FluentColumn(name); + _currentColumn = column; + + if (_creatingTable != null) _creatingTable.Columns.Add(column); + else _exprs.Add(new AddColumnExpression(_currentTable, column)); + return this; + } + + public SchemaBuilder WithProperty(ColumnProperty columnProperty) + { + _currentColumn.ColumnProperty = columnProperty; + + return this; + } + + public SchemaBuilder WithDefaultValue(object defaultValue) + { + if (defaultValue == null) + { + throw new ArgumentNullException("defaultValue", "DefaultValue cannot be null or empty"); + } + + _currentColumn.DefaultValue = defaultValue; + + return this; + } + + public SchemaBuilder WithConstraint(ForeignKeyConstraintType action) + { + _currentColumn.Constraint = action; + + return this; + } } \ No newline at end of file From 63842c7f2f1b88370ca18535ce3e9f6517775208 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:04:21 +0200 Subject: [PATCH 2/7] Fix fluent data safety and provider-specific execution semantics Reject insert-only modifiers on updates/deletes and reject raw delete predicates instead of silently deleting all rows. Dispatch complete table definitions through the provider's normal overload when no engine is specified. Retain legacy FK actions and defer conditional reversal until provider matching is known. Align preview identifier quoting with provider rules, render PostgreSQL82 booleans correctly and use Oracle RAW GUID conversion. Validation: rebuilt solution; Unit 79 and SQLite 149 passed. Added regressions for invalid modifiers, inactive irreversible branches, preview identifiers/literals and actual legacy cascade-delete behavior. Addresses eight PR 175 review findings. --- src/Migrator.Tests/FluentOperationsTests.cs | 49 ++++++++++++++++++- .../Framework/Fluent/FluentMigration.cs | 2 +- .../Framework/Fluent/MigrationBuilder.cs | 4 +- src/Migrator/Framework/Fluent/Operations.cs | 20 ++++++-- .../Framework/Fluent/SqlGenerationContext.cs | 8 +-- .../SchemaBuilder/AddTableExpression.cs | 9 ++-- 6 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src/Migrator.Tests/FluentOperationsTests.cs b/src/Migrator.Tests/FluentOperationsTests.cs index 3013b029..1a80034c 100644 --- a/src/Migrator.Tests/FluentOperationsTests.cs +++ b/src/Migrator.Tests/FluentOperationsTests.cs @@ -20,9 +20,45 @@ [Test] public void TableIsOneCompleteOperationAndDoesNotMutateInput() Assert.That(((Column)operation.Fields.Single()).Name, Is.EqualTo("Id")); var provider = Substitute.For(); builder.Apply(provider); - provider.Received(1).AddTable("Example", (string)null, Arg.Is(x => x.Length == 1)); + provider.Received(1).AddTable("Example", Arg.Is(x => x.Length == 1)); provider.DidNotReceiveWithAnyArgs().AddColumn(default, default(Column)); } + [Test] public void InvalidDataModifiersFailBeforeExecution() + { + var b = new MigrationBuilder(); + Assert.Throws(() => b.Update.Table("Example").IfNotExists(new[] { "Id" }, new object[] { 1 })); + Assert.Throws(() => b.Delete.FromTable("Example").IfNotExists(new[] { "Id" }, new object[] { 1 })); + Assert.Throws(() => b.Delete.FromTable("Example").WhereSql("Id = 1")); + var p = Substitute.For(); + Assert.Throws(() => new DataOperation(DataKind.Delete, "Example", null, null, WhereSql: "Id=1").Apply(p)); + p.DidNotReceiveWithAnyArgs().Delete(default, default(string[]), default(object[])); + } + [TestCase(ProviderTypes.PostgreSQL)] + [TestCase(ProviderTypes.PostgreSQL82)] + public void PostgreSqlPreviewUsesProviderIdentifierAndBooleanSemantics(ProviderTypes provider) + { + var c = new SqlGenerationContext(provider); + Assert.That(c.Table("Example"), Is.EqualTo("Example")); + Assert.That(c.Quote("Id"), Is.EqualTo("Id")); + Assert.That(c.Literal(true), Is.EqualTo("TRUE")); + } + [TestCase(ProviderTypes.Oracle)] + [TestCase(ProviderTypes.MsOracle)] + public void OracleGuidPreviewUsesRawConversion(ProviderTypes provider) + { + var c = new SqlGenerationContext(provider); + Assert.That(c.Literal(Guid.Parse("00112233-4455-6677-8899-aabbccddeeff")), Is.EqualTo("HEXTORAW('00112233445566778899AABBCCDDEEFF')")); + } + [Test] public void InactiveProviderBranchCanBeAutomaticallyReversed() + { + var p = Substitute.For(); + p.IsThisProvider("oracle").Returns(false); + var operation = new ConditionalOperation("oracle", new CallbackOperation("irreversible", _ => throw new Exception())); + Assert.DoesNotThrow(() => operation.ValidateReverse(p)); + Assert.DoesNotThrow(() => operation.Reverse().Apply(p)); + p.IsThisProvider("oracle").Returns(true); + Assert.Throws(() => operation.ValidateReverse(p)); + } [Test] public void OfflinePreviewTracksCreatedThenRenamedTable() { var builder = new MigrationBuilder(); @@ -56,6 +92,17 @@ [Test] public void CallbacksCannotBePreviewedOrAutomaticallyReversed() builder.Build()[0].Reverse().Apply(provider); Assert.That(provider.TableExists("Example"), Is.False); } + [Test, Category("SQLite")] public void LegacyBuilderRetainsForeignKeyAction() + { + using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.AddTable("Parent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); + var builder = new DotNetProjects.Migrator.Framework.SchemaBuilder.SchemaBuilder(); + builder.AddTable("Child").AddColumn("ParentId").OfType(DbType.Int32).AsForeignKey().ReferencedTo("Parent", "Id").WithConstraint(ForeignKeyConstraintType.Cascade); + provider.ExecuteSchemaBuilder(builder); + provider.ExecuteNonQuery("INSERT INTO Parent VALUES (1); INSERT INTO Child VALUES (1); DELETE FROM Parent WHERE Id=1"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child")), Is.Zero); + } [Test, Category("SQLite")] public void LegacyBuilderCreatesCompleteTable() { using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); diff --git a/src/Migrator/Framework/Fluent/FluentMigration.cs b/src/Migrator/Framework/Fluent/FluentMigration.cs index 94714ee6..1207cf7d 100644 --- a/src/Migrator/Framework/Fluent/FluentMigration.cs +++ b/src/Migrator/Framework/Fluent/FluentMigration.cs @@ -23,7 +23,7 @@ public abstract class AutoReversingMigration : FluentMigration public override void Up() { var operations = GetOperations(true); - foreach (var op in operations) _ = op.Reverse(); // Validate before the first change. + foreach (var op in operations) op.ValidateReverse(Database); // Validate before the first change. foreach (var op in operations) op.Apply(Database); } public override void BuildDown(MigrationBuilder migration) diff --git a/src/Migrator/Framework/Fluent/MigrationBuilder.cs b/src/Migrator/Framework/Fluent/MigrationBuilder.cs index 078e29bb..5a33b2d6 100644 --- a/src/Migrator/Framework/Fluent/MigrationBuilder.cs +++ b/src/Migrator/Framework/Fluent/MigrationBuilder.cs @@ -134,8 +134,8 @@ public sealed class DataBuilder public DataBuilder Row(string[] names, object[] data) { if (names.Length != data.Length) throw new ArgumentException("Columns and values must have equal lengths."); columns = names.ToArray(); values = data.ToArray(); return this; } public DataBuilder Set(string[] names, object[] data) => Row(names, data); public DataBuilder Where(string[] names, object[] data) { if (names.Length != data.Length) throw new ArgumentException("Where columns and values must have equal lengths."); whereColumns = names.ToArray(); whereValues = data.ToArray(); return this; } - public DataBuilder WhereSql(string sql) { whereSql = sql; return this; } - public DataBuilder IfNotExists(string[] names, object[] data) { kind = DataKind.InsertIfMissing; return Where(names, data); } + public DataBuilder WhereSql(string sql) { if (kind != DataKind.Update) throw new NotSupportedException("Raw predicates are supported only for Update; use Where columns/values for Delete."); whereSql = sql; return this; } + public DataBuilder IfNotExists(string[] names, object[] data) { if (kind is not (DataKind.Insert or DataKind.InsertIfMissing)) throw new InvalidOperationException("IfNotExists is only valid for Insert."); kind = DataKind.InsertIfMissing; return Where(names, data); } } public sealed class ExecuteRoot(MigrationBuilder builder) { diff --git a/src/Migrator/Framework/Fluent/Operations.cs b/src/Migrator/Framework/Fluent/Operations.cs index 1510c3da..1405f245 100644 --- a/src/Migrator/Framework/Fluent/Operations.cs +++ b/src/Migrator/Framework/Fluent/Operations.cs @@ -10,12 +10,17 @@ public abstract record MigrationOperation { public abstract void Apply(ITransformationProvider provider); public virtual MigrationOperation Reverse() => throw new IrreversibleMigrationException(); + public virtual void ValidateReverse(ITransformationProvider provider) => _ = Reverse(); public virtual string ToSql(SqlGenerationContext context) => throw new NotSupportedException($"SQL preview is not supported for {GetType().Name}."); } public sealed record CreateTableOperation(string Table, string Engine, IDbField[] Fields) : MigrationOperation { - public override void Apply(ITransformationProvider p) => p.AddTable(Table, Engine, Fields.Select(Definitions.Copy).ToArray()); + public override void Apply(ITransformationProvider p) + { + var fields = Fields.Select(Definitions.Copy).ToArray(); + if (Engine == null) p.AddTable(Table, fields); else p.AddTable(Table, Engine, fields); + } public override MigrationOperation Reverse() => new RemoveOperation(RemoveKind.Table, Table); public override string ToSql(SqlGenerationContext c) { @@ -125,7 +130,9 @@ public override void Apply(ITransformationProvider p) case DataKind.Update: if (WhereSql != null) p.Update(Table, Columns, Values, WhereSql); else p.Update(Table, Columns, Values, WhereColumns ?? Array.Empty(), WhereValues ?? Array.Empty()); break; - case DataKind.Delete: p.Delete(Table, WhereColumns, WhereValues); break; + case DataKind.Delete: + if (WhereSql != null) throw new NotSupportedException("Delete requires structured Where columns/values."); + p.Delete(Table, WhereColumns, WhereValues); break; } } public override string ToSql(SqlGenerationContext c) @@ -157,9 +164,16 @@ public sealed record ReversibleOperation(MigrationOperation Forward, MigrationOp public sealed record ConditionalOperation(string Provider, MigrationOperation Operation) : MigrationOperation { public override void Apply(ITransformationProvider p) { if (p.IsThisProvider(Provider)) Operation.Apply(p); } - public override MigrationOperation Reverse() => new ConditionalOperation(Provider, Operation.Reverse()); + public override void ValidateReverse(ITransformationProvider p) { if (p.IsThisProvider(Provider)) Operation.ValidateReverse(p); } + public override MigrationOperation Reverse() => new ConditionalReverseOperation(Provider, Operation); public override string ToSql(SqlGenerationContext c) => c.Dialect.GetType().Name.StartsWith(Provider, StringComparison.OrdinalIgnoreCase) ? Operation.ToSql(c) : ""; } +public sealed record ConditionalReverseOperation(string Provider, MigrationOperation Forward) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) { if (p.IsThisProvider(Provider)) Forward.Reverse().Apply(p); } + public override MigrationOperation Reverse() => new ConditionalOperation(Provider, Forward); + public override string ToSql(SqlGenerationContext c) => c.Dialect.GetType().Name.StartsWith(Provider, StringComparison.OrdinalIgnoreCase) ? Forward.Reverse().ToSql(c) : ""; +} public static class Definitions { public static Column CopyColumn(Column c) => new(c.Name, c.Type, c.Size, c.ColumnProperty, c.DefaultValue is byte[] b ? b.Clone() : c.DefaultValue) { Precision = c.Precision, Scale = c.Scale, MigratorDbType = c.MigratorDbType }; diff --git a/src/Migrator/Framework/Fluent/SqlGenerationContext.cs b/src/Migrator/Framework/Fluent/SqlGenerationContext.cs index 04274352..2aa6240f 100644 --- a/src/Migrator/Framework/Fluent/SqlGenerationContext.cs +++ b/src/Migrator/Framework/Fluent/SqlGenerationContext.cs @@ -15,21 +15,23 @@ public sealed class SqlGenerationContext public Dialect Dialect { get; } public SqlGenerationContext(ProviderTypes provider, Func existingTable = null) { Provider = provider; Dialect = ProviderFactory.DialectForProvider(provider) ?? throw new ArgumentException("Unknown provider."); this.existingTable = existingTable; } - public string Quote(string name) + private string AlwaysQuote(string name) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Identifier cannot be empty."); var template = Dialect.QuoteTemplate; var closing = template[^1].ToString(); return string.Format(CultureInfo.InvariantCulture, template, name.Replace(closing, closing + closing)); } - public string Table(string name) => string.Join(".", name.Split('.').Select(Quote)); + public string Quote(string name) => Dialect.ColumnNameNeedsQuote || Dialect.IsReservedWord(name) ? AlwaysQuote(name) : name; + public string Table(string name) => string.Join(".", name.Split('.').Select(part => Dialect.TableNameNeedsQuote || Dialect.IsReservedWord(part) ? AlwaysQuote(part) : part)); public string Column(Column column) => Dialect.GetAndMapColumnProperties(Definitions.CopyColumn(column)).ColumnSql; public string Literal(object value) => value switch { null or DBNull => "NULL", string s => "'" + s.Replace("'", "''") + "'", - bool b => Provider == ProviderTypes.PostgreSQL ? (b ? "TRUE" : "FALSE") : (b ? "1" : "0"), + bool b => Provider is ProviderTypes.PostgreSQL or ProviderTypes.PostgreSQL82 ? (b ? "TRUE" : "FALSE") : (b ? "1" : "0"), DateTime d => "'" + d.ToString("yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture) + "'", + Guid g when Dialect is DotNetProjects.Migrator.Providers.Impl.Oracle.OracleDialect => Dialect.Default(g)[8..], Guid g => "'" + g + "'", byte or sbyte or short or ushort or int or uint or long or ulong or decimal or float or double => Convert.ToString(value, CultureInfo.InvariantCulture), _ => throw new NotSupportedException("No portable SQL literal for " + value.GetType().Name) diff --git a/src/Migrator/Framework/SchemaBuilder/AddTableExpression.cs b/src/Migrator/Framework/SchemaBuilder/AddTableExpression.cs index 258d7882..ac3a39bf 100644 --- a/src/Migrator/Framework/SchemaBuilder/AddTableExpression.cs +++ b/src/Migrator/Framework/SchemaBuilder/AddTableExpression.cs @@ -28,10 +28,9 @@ public AddTableExpression(string newTable) public void Create(ITransformationProvider provider) { var fields = Columns.Select(c => (IDbField)new Column(c.Name, c.Type, c.Size, c.ColumnProperty, c.DefaultValue)).ToList(); - foreach (var c in Columns.Where(c => c.ForeignKey != null)) - fields.Add(new ForeignKeyConstraint("FK_" + _newTable + "_" + c.Name + "_" + c.ForeignKey.PrimaryTable + "_" + c.ForeignKey.PrimaryKey, - c.ForeignKey.PrimaryTable, new[] { c.ForeignKey.PrimaryKey }, _newTable, new[] { c.Name }) - { OnDelete = new DotNetProjects.Migrator.Providers.ForeignKeyConstraintMapper().SqlForConstraint(c.Constraint) }); provider.AddTable(_newTable, fields.ToArray()); + foreach (var c in Columns.Where(c => c.ForeignKey != null)) + provider.AddForeignKey("FK_" + _newTable + "_" + c.Name + "_" + c.ForeignKey.PrimaryTable + "_" + c.ForeignKey.PrimaryKey, + _newTable, new[] { c.Name }, c.ForeignKey.PrimaryTable, new[] { c.ForeignKey.PrimaryKey }, c.Constraint); } -} \ No newline at end of file +} From fb9cbf4bfaae7592d471a1b37da5753ab557e835 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:34:55 +0200 Subject: [PATCH 3/7] Complete fluent capability inventory and type additional operations Map every normal API method family to fluent authoring, schema inspection or explicit context APIs, enforced by reflection coverage. Replace view/data-copy/update-from/database callbacks with typed operations and snapshot mutable definitions. Add schema query helpers and controlled command/connection callbacks. Reject structured SQL preview after raw SQL invalidates known schema and block administrative operations inside provider transactions. Validation: rebuilt solution; Unit 84 and SQLite 154 passed before the final transaction guard. New tests cover persisted conditional inserts/updates/deletes, reader queries, pair snapshots and preview dependency rejection. Final guard and expanded provider coverage are checked on the next full build. --- docs/fluent-operation-coverage.json | 73 +++++++++++++++++++ docs/fluent-operation-coverage.md | 20 +++++ src/Migrator.Tests/FluentCoverageTests.cs | 17 +++++ src/Migrator.Tests/FluentOperationsTests.cs | 36 +++++++++ src/Migrator.Tests/Migrator.Tests.csproj | 1 + .../Framework/Fluent/FluentMigration.cs | 10 +++ .../Framework/Fluent/MigrationBuilder.cs | 18 +++-- src/Migrator/Framework/Fluent/Operations.cs | 43 +++++++++++ .../Framework/Fluent/SqlGenerationContext.cs | 7 +- 9 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 docs/fluent-operation-coverage.json create mode 100644 docs/fluent-operation-coverage.md create mode 100644 src/Migrator.Tests/FluentCoverageTests.cs diff --git a/docs/fluent-operation-coverage.json b/docs/fluent-operation-coverage.json new file mode 100644 index 00000000..b57cf8c9 --- /dev/null +++ b/docs/fluent-operation-coverage.json @@ -0,0 +1,73 @@ +{ + "AddTable": "Create.Table(...).WithFields(...) / WithEngine(...)", + "AddColumn": "Create.Column(...).OfType(...).WithPrecision(...)", + "ChangeColumn": "Alter.Column(...).OfType(...).WithPrecision(...)", + "AddPrimaryKey": "Create.PrimaryKey(...) / Create.NonClusteredPrimaryKey(...)", + "AddPrimaryKeyNonClustered": "Create.PrimaryKey(...) / Create.NonClusteredPrimaryKey(...)", + "AddForeignKey": "Create.ForeignKey(...); supply a generated name for GenerateForeignKey convenience overloads", + "GenerateForeignKey": "Create.ForeignKey(...); supply a generated name for GenerateForeignKey convenience overloads", + "AddUniqueConstraint": "Create.Unique(...) / Create.Check(...)", + "AddCheckConstraint": "Create.Unique(...) / Create.Check(...)", + "AddIndex": "Create.Index(table, Index)", + "AddView": "Create.View(...), both view-definition forms", + "RemoveTable": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", + "RemoveColumn": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", + "RemoveForeignKey": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", + "RemoveConstraint": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", + "RemovePrimaryKey": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", + "RemoveColumnDefaultValue": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", + "RemoveIndex": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", + "RemoveAllIndexes": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", + "RemoveAllConstraints": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", + "RemoveAllForeignKeys": "Delete.Table/Column/ForeignKey/Constraint/PrimaryKey/Default/Index/AllIndexes/AllConstraints/ForeignKeysForColumn", + "RenameTable": "Rename.Table(...) / Rename.Column(...)", + "RenameColumn": "Rename.Table(...) / Rename.Column(...)", + "Insert": "Insert.IntoTable(...).Row(...)[.IfNotExists(...)]", + "InsertIfNotExists": "Insert.IntoTable(...).Row(...)[.IfNotExists(...)]", + "Update": "Update.Table(...).Set(...).Where(...) / WhereSql(...)", + "Delete": "Delete.FromTable(...).Where(...)", + "TruncateTable": "Execute.Truncate(...)", + "CopyDataFromTableToTable": "Execute.CopyData(...) / Execute.UpdateFrom(...)", + "UpdateTargetFromSource": "Execute.CopyData(...) / Execute.UpdateFrom(...)", + "ExecuteNonQuery": "Execute.Sql(...) / Script(...) / EmbeddedScript(...)", + "ExecuteQuery": "Schema.Query(...) / Strings(...) / Scalar(...) / NullableScalar(...)", + "ExecuteStringQuery": "Schema.Query(...) / Strings(...) / Scalar(...) / NullableScalar(...)", + "ExecuteScalar": "Schema.Query(...) / Strings(...) / Scalar(...) / NullableScalar(...)", + "Select": "Schema.Select(...) / SelectScalar(...); raw selection overloads through explicit Context", + "SelectComplex": "Schema.Select(...) / SelectScalar(...); raw selection overloads through explicit Context", + "SelectScalar": "Schema.Select(...) / SelectScalar(...); raw selection overloads through explicit Context", + "GetColumns": "Schema.Table(...).Columns/Column/Indexes/ForeignKeys/ContentSize/NullableContentSize", + "GetColumnByName": "Schema.Table(...).Columns/Column/Indexes/ForeignKeys/ContentSize/NullableContentSize", + "GetIndexes": "Schema.Table(...).Columns/Column/Indexes/ForeignKeys/ContentSize/NullableContentSize", + "GetForeignKeyConstraints": "Schema.Table(...).Columns/Column/Indexes/ForeignKeys/ContentSize/NullableContentSize", + "GetColumnContentSize": "Schema.Table(...).Columns/Column/Indexes/ForeignKeys/ContentSize/NullableContentSize", + "TableExists": "Schema.Table(...).Exists/ColumnExists/ConstraintExists/IndexExists/PrimaryKeyExists", + "ColumnExists": "Schema.Table(...).Exists/ColumnExists/ConstraintExists/IndexExists/PrimaryKeyExists", + "ConstraintExists": "Schema.Table(...).Exists/ColumnExists/ConstraintExists/IndexExists/PrimaryKeyExists", + "IndexExists": "Schema.Table(...).Exists/ColumnExists/ConstraintExists/IndexExists/PrimaryKeyExists", + "PrimaryKeyExists": "Schema.Table(...).Exists/ColumnExists/ConstraintExists/IndexExists/PrimaryKeyExists", + "ViewExists": "Schema.ViewExists/DatabaseExists/Databases/Tables", + "DatabaseExists": "Schema.ViewExists/DatabaseExists/Databases/Tables", + "GetDatabases": "Schema.ViewExists/DatabaseExists/Databases/Tables", + "GetTables": "Schema.ViewExists/DatabaseExists/Databases/Tables", + "QuoteColumnNameIfRequired": "Schema.QuoteColumn/QuoteColumns/QuoteTable/ParameterName/Encode/Concatenate", + "QuoteColumnNamesIfRequired": "Schema.QuoteColumn/QuoteColumns/QuoteTable/ParameterName/Encode/Concatenate", + "QuoteTableNameIfRequired": "Schema.QuoteColumn/QuoteColumns/QuoteTable/ParameterName/Encode/Concatenate", + "GenerateParameterName": "Schema.QuoteColumn/QuoteColumns/QuoteTable/ParameterName/Encode/Concatenate", + "Encode": "Schema.QuoteColumn/QuoteColumns/QuoteTable/ParameterName/Encode/Concatenate", + "Concatenate": "Schema.QuoteColumn/QuoteColumns/QuoteTable/ParameterName/Encode/Concatenate", + "CreateDatabases": "Administration.CreateDatabase/DropDatabase/SwitchDatabase/KillConnections", + "DropDatabases": "Administration.CreateDatabase/DropDatabase/SwitchDatabase/KillConnections", + "SwitchDatabase": "Administration.CreateDatabase/DropDatabase/SwitchDatabase/KillConnections", + "KillDatabaseConnections": "Administration.CreateDatabase/DropDatabase/SwitchDatabase/KillConnections", + "IsThisProvider": "IfDatabase(...); Context.IsThisProvider(...) for imperative branching", + "CreateCommand": "Execute.WithCommand(...); explicit Context for advanced command lifetime", + "GetCommand": "Execute.WithCommand(...); explicit Context for advanced command lifetime", + "BeginTransaction": "Explicit Context history/transaction APIs; not schema expressions", + "Commit": "Explicit Context history/transaction APIs; not schema expressions", + "Rollback": "Explicit Context history/transaction APIs; not schema expressions", + "MigrationApplied": "Explicit Context history/transaction APIs; not schema expressions", + "MigrationUnApplied": "Explicit Context history/transaction APIs; not schema expressions", + "IsMigrationApplied": "Explicit Context history/transaction APIs; not schema expressions", + "ExecuteSchemaBuilder": "Execute.WithProvider(p => p.ExecuteSchemaBuilder(legacyBuilder))" +} diff --git a/docs/fluent-operation-coverage.md b/docs/fluent-operation-coverage.md new file mode 100644 index 00000000..ebc87b33 --- /dev/null +++ b/docs/fluent-operation-coverage.md @@ -0,0 +1,20 @@ +# Imperative/fluent capability inventory + +The machine-readable [inventory](fluent-operation-coverage.json) maps every named method family on `ITransformationProvider` to fluent authoring, schema inspection or an explicit context API. `FluentCoverageTests` checks this inventory against reflection so new normal-API methods require an entry. Overload convenience is consolidated into typed definitions; this does not promise FluentMigrator source compatibility. + +| Capability | Authoring / inspection | Validation evidence | +| --- | --- | --- | +| Complete tables, columns, types, defaults, identity, nullability and precision | `Create.Table`, `Create.Column`, `Alter.Column` | Complete table snapshot, SQLite schema/data, provider precision tests | +| Keys, foreign-key actions, unique/check constraints | `Create.PrimaryKey`, `ForeignKey`, `Unique`, `Check` | Provider constraint/action suites; unsupported providers throw | +| Index keys, include/filter/cluster options | `Create.Index` with the existing `Index` model | Provider index suites; preview rejects unsupported options | +| Views and joins | `Create.View` with either normal definition model | Definitions snapshot caller input; provider implementations retain their own limitations | +| Drop/rename operations | `Delete` / `Rename` | SQLite schema tests, reversal tests; destructive changes need explicit reverse definitions | +| Inserts, conditional insert, update, delete | `Insert`, `Update`, `Delete.FromTable` | FluentDataChangesAndSchemaReadsPersistExpectedRows | +| Truncate, data copy, update from another table | `Execute.Truncate/CopyData/UpdateFrom` | Normal provider tests; mutable pair snapshot regression | +| SQL, files, resources | `Execute.Sql/Script/EmbeddedScript` | SQL execution/preview tests; provider-specific batch splitting is separate work | +| Scalars/readers/existence/metadata | `Schema`, `Schema.Table`, `Select`, `SelectScalar` | Reader disposal and persisted-data assertions; nullable helpers are additive | +| Database administration | `Administration` | Typed operations flag transaction incompatibility; backend capabilities still apply | +| Provider conditions, commands/connections | `IfDatabase`, `Execute.WithCommand/WithConnection/WithProvider` | Inactive reversal, explicit preview rejection; callbacks execute trusted code | +| History and transaction administration | Explicit `Context` | Runner transaction/scope/history regressions; not schema expressions | + +Execution coverage is broader than SQL-generation and automatic-reversal coverage. Unsupported preview/reversal operations fail explicitly. The inventory is an API coverage check, not a claim that every overload has an independent live test on every engine. Continue adding behavioral provider tests when changing an operation's semantics. diff --git a/src/Migrator.Tests/FluentCoverageTests.cs b/src/Migrator.Tests/FluentCoverageTests.cs new file mode 100644 index 00000000..063080ca --- /dev/null +++ b/src/Migrator.Tests/FluentCoverageTests.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using DotNetProjects.Migrator.Framework; +using NUnit.Framework; +namespace Migrator.Tests; +public class FluentCoverageTests +{ + [Test] public void EveryNormalApiMethodFamilyHasAnExplicitFluentOrContextMapping() + { + var inventory = JsonSerializer.Deserialize>(File.ReadAllText(Path.Combine(System.AppContext.BaseDirectory, "fluent-operation-coverage.json"))); + var methods = typeof(ITransformationProvider).GetMethods().Where(m => !m.IsSpecialName).Select(m => m.Name).Distinct().ToArray(); + Assert.That(inventory.Keys, Is.EquivalentTo(methods)); + Assert.That(inventory.Values.All(v => !string.IsNullOrWhiteSpace(v)), Is.True); + } +} diff --git a/src/Migrator.Tests/FluentOperationsTests.cs b/src/Migrator.Tests/FluentOperationsTests.cs index 1a80034c..5268dcf6 100644 --- a/src/Migrator.Tests/FluentOperationsTests.cs +++ b/src/Migrator.Tests/FluentOperationsTests.cs @@ -59,6 +59,42 @@ [Test] public void InactiveProviderBranchCanBeAutomaticallyReversed() p.IsThisProvider("oracle").Returns(true); Assert.Throws(() => operation.ValidateReverse(p)); } + [Test] public void PreviewRejectsStructuredDependenciesAfterRawSql() + { + var builder = new MigrationBuilder(); + builder.Create.Table("Example").WithColumn("Id").AsInt32(); + builder.Execute.Sql("DROP TABLE Example"); + builder.Insert.IntoTable("Example").Row(new[] { "Id" }, new object[] { 1 }); + Assert.Throws(() => builder.Preview(new SqlGenerationContext(ProviderTypes.SQLite))); + } + [Test] public void CopyOperationsSnapshotMutableDefinitions() + { + var pairs = new[] { new DotNetProjects.Migrator.Framework.Models.ColumnPair { ColumnNameSource = "Old", ColumnNameTarget = "New" } }; + var builder = new MigrationBuilder(); builder.Execute.UpdateFrom("Source", "Target", pairs, pairs); + pairs[0].ColumnNameSource = "Mutated"; + var operation = (UpdateFromOperation)builder.Build().Single(); + Assert.That(operation.Copy[0].ColumnNameSource, Is.EqualTo("Old")); + Assert.That(operation.Match[0], Is.Not.SameAs(pairs[0])); + } + [Test, Category("SQLite")] public void FluentDataChangesAndSchemaReadsPersistExpectedRows() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + var builder = new MigrationBuilder(); + builder.Create.Table("ValuesTable").WithColumn("Id").AsInt32().WithColumn("Name").AsString(); + builder.Insert.IntoTable("ValuesTable").Row(new[] { "Id", "Name" }, new object[] { 1, "first" }); + builder.Insert.IntoTable("ValuesTable").Row(new[] { "Id", "Name" }, new object[] { 1, "duplicate" }).IfNotExists(new[] { "Id" }, new object[] { 1 }); + builder.Insert.IntoTable("ValuesTable").Row(new[] { "Id", "Name" }, new object[] { 2, "remove" }); + builder.Update.Table("ValuesTable").Set(new[] { "Name" }, new object[] { "updated" }).Where(new[] { "Id" }, new object[] { 1 }); + builder.Delete.FromTable("ValuesTable").Where(new[] { "Id" }, new object[] { 2 }); + builder.Apply(provider); + var schema = new SchemaInspector(provider); + Assert.That(schema.Table("ValuesTable").ColumnExists("Name"), Is.True); + schema.Select("ValuesTable", new[] { "Name" }, reader => + { + Assert.That(reader.Read(), Is.True); Assert.That(reader.GetString(0), Is.EqualTo("updated")); Assert.That(reader.Read(), Is.False); + }); + } [Test] public void OfflinePreviewTracksCreatedThenRenamedTable() { var builder = new MigrationBuilder(); diff --git a/src/Migrator.Tests/Migrator.Tests.csproj b/src/Migrator.Tests/Migrator.Tests.csproj index 20a707f2..b1cc09e6 100644 --- a/src/Migrator.Tests/Migrator.Tests.csproj +++ b/src/Migrator.Tests/Migrator.Tests.csproj @@ -49,6 +49,7 @@ + diff --git a/src/Migrator/Framework/Fluent/FluentMigration.cs b/src/Migrator/Framework/Fluent/FluentMigration.cs index 1207cf7d..1e2ae941 100644 --- a/src/Migrator/Framework/Fluent/FluentMigration.cs +++ b/src/Migrator/Framework/Fluent/FluentMigration.cs @@ -42,6 +42,16 @@ public sealed class SchemaInspector(ITransformationProvider provider) public List Strings(string sql, params object[] args) => provider.ExecuteStringQuery(sql, args); public void Query(string sql, Action read) { using var command = provider.CreateCommand(); using var reader = provider.ExecuteQuery(command, sql); read(reader); } + public object SelectScalar(string columns, string table, string where = null) => provider.SelectScalar(columns, table, where); + public void Select(string table, string[] columns, Action read, string[] whereColumns = null, object[] whereValues = null, + string[] nullColumns = null, string[] notNullColumns = null) + { + using var command = provider.CreateCommand(); + using var reader = provider.SelectComplex(command, table, columns, whereColumns, whereValues, nullColumns, notNullColumns); + read(reader); + } + public string[] QuoteColumns(params string[] names) => provider.QuoteColumnNamesIfRequired(names); + public string ParameterName(int index) => provider.GenerateParameterName(index); public string QuoteColumn(string name) => provider.QuoteColumnNameIfRequired(name); public string QuoteTable(string name) => provider.QuoteTableNameIfRequired(name); public string Encode(Guid guid) => provider.Encode(guid); diff --git a/src/Migrator/Framework/Fluent/MigrationBuilder.cs b/src/Migrator/Framework/Fluent/MigrationBuilder.cs index 5a33b2d6..3ca05b77 100644 --- a/src/Migrator/Framework/Fluent/MigrationBuilder.cs +++ b/src/Migrator/Framework/Fluent/MigrationBuilder.cs @@ -46,8 +46,8 @@ public sealed class CreateRoot(MigrationBuilder builder) public void Check(string name, string table, string sql) => builder.Add(new ConstraintOperation(ConstraintKind.Check, table, name, Array.Empty(), Check: sql)); public void ForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType onDelete = ForeignKeyConstraintType.NoAction, ForeignKeyConstraintType onUpdate = ForeignKeyConstraintType.NoAction) => builder.Add(new ConstraintOperation(ConstraintKind.ForeignKey, childTable, name, (string[])childColumns.Clone(), parentTable, (string[])parentColumns.Clone(), OnDelete: onDelete, OnUpdate: onUpdate)); - public void View(string name, string table, params IViewElement[] elements) { var copy = elements.ToArray(); builder.Add(new CallbackOperation("Create view", p => p.AddView(name, table, copy))); } - public void View(string name, string table, params IViewField[] fields) { var copy = fields.ToArray(); builder.Add(new CallbackOperation("Create view", p => p.AddView(name, table, copy))); } + public void View(string name, string table, params IViewElement[] elements) => builder.Add(new ViewOperation(name, table, null, elements.Select(Definitions.CopyViewElement).ToArray())); + public void View(string name, string table, params IViewField[] fields) => builder.Add(new ViewOperation(name, table, fields.Select(Definitions.CopyViewField).ToArray())); } public sealed class TableBuilder { @@ -143,16 +143,18 @@ public sealed class ExecuteRoot(MigrationBuilder builder) public void Script(string path) => Sql(File.ReadAllText(path)); public void EmbeddedScript(Assembly assembly, string name) { using var stream = assembly.GetManifestResourceStream(name) ?? throw new FileNotFoundException("Resource not found", name); using var reader = new StreamReader(stream); Sql(reader.ReadToEnd()); } public void WithProvider(Action action) => builder.Add(new CallbackOperation("Provider callback (not previewable)", action)); + public void WithCommand(Action action) => builder.Add(new CallbackOperation("Command callback", p => { using var command = p.CreateCommand(); action(command); })); + public void WithConnection(Action action) => builder.Add(new CallbackOperation("Connection callback", p => action(p.Connection))); public void Truncate(string table) => builder.Add(new RemoveOperation(RemoveKind.Truncate, table)); public void CopyData(string source, IEnumerable sourceColumns, string target, IEnumerable targetColumns, IEnumerable orderBy = null) - { var sc = sourceColumns.ToList(); var tc = targetColumns.ToList(); var order = orderBy?.ToList(); builder.Add(new CallbackOperation("Copy data", p => p.CopyDataFromTableToTable(source, sc.ToList(), target, tc.ToList(), order?.ToList()))); } + { builder.Add(new CopyDataOperation(source, sourceColumns.ToArray(), target, targetColumns.ToArray(), orderBy?.ToArray())); } public void UpdateFrom(string source, string target, ColumnPair[] copy, ColumnPair[] match) - { var c = copy.ToArray(); var m = match.ToArray(); builder.Add(new CallbackOperation("Update from table", p => p.UpdateTargetFromSource(source, target, c, m))); } + { builder.Add(new UpdateFromOperation(source, target, copy.Select(Definitions.CopyPair).ToArray(), match.Select(Definitions.CopyPair).ToArray())); } } public sealed class AdministrationRoot(MigrationBuilder builder) { - public void CreateDatabase(string name) => builder.Add(new CallbackOperation("Create database", p => p.CreateDatabases(name))); - public void DropDatabase(string name) => builder.Add(new CallbackOperation("Drop database", p => p.DropDatabases(name))); - public void SwitchDatabase(string name) => builder.Add(new CallbackOperation("Switch database", p => p.SwitchDatabase(name))); - public void KillConnections(string name) => builder.Add(new CallbackOperation("Kill connections", p => p.KillDatabaseConnections(name))); + public void CreateDatabase(string name) => builder.Add(new DatabaseOperation(DatabaseOperationKind.Create, name)); + public void DropDatabase(string name) => builder.Add(new DatabaseOperation(DatabaseOperationKind.Drop, name)); + public void SwitchDatabase(string name) => builder.Add(new DatabaseOperation(DatabaseOperationKind.Switch, name)); + public void KillConnections(string name) => builder.Add(new DatabaseOperation(DatabaseOperationKind.KillConnections, name)); } diff --git a/src/Migrator/Framework/Fluent/Operations.cs b/src/Migrator/Framework/Fluent/Operations.cs index 1405f245..440f367b 100644 --- a/src/Migrator/Framework/Fluent/Operations.cs +++ b/src/Migrator/Framework/Fluent/Operations.cs @@ -9,6 +9,7 @@ namespace DotNetProjects.Migrator.Framework.Fluent; public abstract record MigrationOperation { public abstract void Apply(ITransformationProvider provider); + public virtual bool RequiresNoTransaction => false; public virtual MigrationOperation Reverse() => throw new IrreversibleMigrationException(); public virtual void ValidateReverse(ITransformationProvider provider) => _ = Reverse(); public virtual string ToSql(SqlGenerationContext context) => throw new NotSupportedException($"SQL preview is not supported for {GetType().Name}."); @@ -148,6 +149,7 @@ public sealed record SqlOperation(string Sql, int? Timeout = null, object[] Para public override string ToSql(SqlGenerationContext c) { if (Parameters != null && Parameters.Length != 0) throw new NotSupportedException("Parameterized raw SQL cannot be exported as an executable script."); + c.InvalidateSchema(); return Sql.TrimEnd().TrimEnd(';') + ";"; } } @@ -174,8 +176,49 @@ public sealed record ConditionalReverseOperation(string Provider, MigrationOpera public override MigrationOperation Reverse() => new ConditionalOperation(Provider, Forward); public override string ToSql(SqlGenerationContext c) => c.Dialect.GetType().Name.StartsWith(Provider, StringComparison.OrdinalIgnoreCase) ? Forward.Reverse().ToSql(c) : ""; } +public enum DatabaseOperationKind { Create, Drop, Switch, KillConnections } +public sealed record DatabaseOperation(DatabaseOperationKind Kind, string Name) : MigrationOperation +{ + public override bool RequiresNoTransaction => true; + public override void Apply(ITransformationProvider p) + { + if (p is TransformationProvider { HasActiveTransaction: true }) + throw new MigrationException("Database administration requires an explicit no-transaction run."); + switch (Kind) + { + case DatabaseOperationKind.Create: p.CreateDatabases(Name); break; + case DatabaseOperationKind.Drop: p.DropDatabases(Name); break; + case DatabaseOperationKind.Switch: p.SwitchDatabase(Name); break; + case DatabaseOperationKind.KillConnections: p.KillDatabaseConnections(Name); break; + } + } +} +public sealed record CopyDataOperation(string Source, string[] SourceColumns, string Target, string[] TargetColumns, string[] OrderBy) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) => p.CopyDataFromTableToTable(Source, SourceColumns.ToList(), Target, TargetColumns.ToList(), OrderBy?.ToList()); +} +public sealed record UpdateFromOperation(string Source, string Target, DotNetProjects.Migrator.Framework.Models.ColumnPair[] Copy, DotNetProjects.Migrator.Framework.Models.ColumnPair[] Match) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) => p.UpdateTargetFromSource(Source, Target, Copy.Select(Definitions.CopyPair).ToArray(), Match.Select(Definitions.CopyPair).ToArray()); +} +public sealed record ViewOperation(string Name, string Table, IViewField[] Fields, IViewElement[] Elements = null) : MigrationOperation +{ + public override void Apply(ITransformationProvider p) + { + if (Elements != null) p.AddView(Name, Table, Elements.Select(Definitions.CopyViewElement).ToArray()); + else p.AddView(Name, Table, Fields.Select(Definitions.CopyViewField).ToArray()); + } +} public static class Definitions { + public static DotNetProjects.Migrator.Framework.Models.ColumnPair CopyPair(DotNetProjects.Migrator.Framework.Models.ColumnPair p) => new() { ColumnNameSource = p.ColumnNameSource, ColumnNameTarget = p.ColumnNameTarget }; + public static IViewField CopyViewField(IViewField f) => new ViewField(f.ColumnName, f.TableName, f.KeyColumnName, f.ParentTableName, f.ParentKeyColumnName); + public static IViewElement CopyViewElement(IViewElement e) => e switch + { + ViewColumn c => new ViewColumn(c.Prefix, c.ColumnName), + ViewJoin j => new ViewJoin(j.TableName, j.TableAlias, j.ColumnName, j.ParentTableName, j.ParentTableAlias, j.ParentColumnName, j.JoinType), + _ => throw new NotSupportedException("Unknown view element.") + }; public static Column CopyColumn(Column c) => new(c.Name, c.Type, c.Size, c.ColumnProperty, c.DefaultValue is byte[] b ? b.Clone() : c.DefaultValue) { Precision = c.Precision, Scale = c.Scale, MigratorDbType = c.MigratorDbType }; public static IDbField Copy(IDbField field) => field switch { diff --git a/src/Migrator/Framework/Fluent/SqlGenerationContext.cs b/src/Migrator/Framework/Fluent/SqlGenerationContext.cs index 2aa6240f..645d8ef4 100644 --- a/src/Migrator/Framework/Fluent/SqlGenerationContext.cs +++ b/src/Migrator/Framework/Fluent/SqlGenerationContext.cs @@ -11,6 +11,10 @@ public sealed class SqlGenerationContext private readonly Dictionary> tables = new(StringComparer.Ordinal); private readonly HashSet removed = new(StringComparer.Ordinal); private readonly Func existingTable; + private bool schemaUnknown; + public void InvalidateSchema() => schemaUnknown = true; + private void RequireKnownSchema() + { if (schemaUnknown) throw new NotSupportedException("Raw SQL may change the schema; dependent structured preview cannot be verified."); } public ProviderTypes Provider { get; } public Dialect Dialect { get; } public SqlGenerationContext(ProviderTypes provider, Func existingTable = null) @@ -38,12 +42,13 @@ private string AlwaysQuote(string name) }; public void RequireTable(string table) { + RequireKnownSchema(); if (tables.ContainsKey(table)) return; if (removed.Contains(table)) throw new MigrationException("Table was removed earlier in the plan: " + table); var columns = existingTable?.Invoke(table) ?? throw new MigrationException("Offline preview needs a definition for existing table: " + table); tables.Add(table, columns.ToDictionary(c => c.Name, Definitions.CopyColumn)); } - public void AddTable(string table, IEnumerable columns) { if (tables.ContainsKey(table)) throw new MigrationException("Duplicate table: " + table); removed.Remove(table); tables.Add(table, columns.ToDictionary(c => c.Name, Definitions.CopyColumn)); } + public void AddTable(string table, IEnumerable columns) { RequireKnownSchema(); if (tables.ContainsKey(table)) throw new MigrationException("Duplicate table: " + table); removed.Remove(table); tables.Add(table, columns.ToDictionary(c => c.Name, Definitions.CopyColumn)); } public void RemoveTable(string table) { tables.Remove(table); removed.Add(table); } public void RenameTable(string table, string name) { var columns = tables[table].Values.ToArray(); RemoveTable(table); AddTable(name, columns); } public void AddColumn(string table, Column column) => tables[table].Add(column.Name, Definitions.CopyColumn(column)); From b238c08119324464cc94d7de56c767097a6e1e04 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:46:56 +0200 Subject: [PATCH 4/7] Reject row assignments on fluent and directly authored delete operations Delete.Row and Delete.Set now fail during authoring instead of silently ignoring row data and potentially deleting an entire table. DataOperation also rejects nonempty row fields when callers bypass the builder. Keep structured Where predicates as the delete input. Validation: build, Unit 85 passed, SQLite 154 passed. Regression asserts no provider Delete call occurs for invalid modifiers. Addresses reviews 4072049125 and 4072049185. --- src/Migrator.Tests/FluentOperationsTests.cs | 3 +++ src/Migrator/Framework/Fluent/MigrationBuilder.cs | 2 +- src/Migrator/Framework/Fluent/Operations.cs | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Migrator.Tests/FluentOperationsTests.cs b/src/Migrator.Tests/FluentOperationsTests.cs index 5268dcf6..dcb6f74b 100644 --- a/src/Migrator.Tests/FluentOperationsTests.cs +++ b/src/Migrator.Tests/FluentOperationsTests.cs @@ -29,7 +29,10 @@ [Test] public void InvalidDataModifiersFailBeforeExecution() Assert.Throws(() => b.Update.Table("Example").IfNotExists(new[] { "Id" }, new object[] { 1 })); Assert.Throws(() => b.Delete.FromTable("Example").IfNotExists(new[] { "Id" }, new object[] { 1 })); Assert.Throws(() => b.Delete.FromTable("Example").WhereSql("Id = 1")); + Assert.Throws(() => b.Delete.FromTable("Example").Row(new[] { "Id" }, new object[] { 1 })); + Assert.Throws(() => b.Delete.FromTable("Example").Set(new[] { "Id" }, new object[] { 1 })); var p = Substitute.For(); + Assert.Throws(() => new DataOperation(DataKind.Delete, "Example", new[] { "Id" }, new object[] { 1 }).Apply(p)); Assert.Throws(() => new DataOperation(DataKind.Delete, "Example", null, null, WhereSql: "Id=1").Apply(p)); p.DidNotReceiveWithAnyArgs().Delete(default, default(string[]), default(object[])); } diff --git a/src/Migrator/Framework/Fluent/MigrationBuilder.cs b/src/Migrator/Framework/Fluent/MigrationBuilder.cs index 3ca05b77..64fcbfee 100644 --- a/src/Migrator/Framework/Fluent/MigrationBuilder.cs +++ b/src/Migrator/Framework/Fluent/MigrationBuilder.cs @@ -131,7 +131,7 @@ public sealed class DataBuilder private object[] values = Array.Empty(), whereValues; private string whereSql; internal DataBuilder(MigrationBuilder builder, DataKind kind, string table) { this.kind = kind; builder.Add(() => new DataOperation(this.kind, table, columns.ToArray(), values.ToArray(), whereColumns?.ToArray(), whereValues?.ToArray(), whereSql)); } - public DataBuilder Row(string[] names, object[] data) { if (names.Length != data.Length) throw new ArgumentException("Columns and values must have equal lengths."); columns = names.ToArray(); values = data.ToArray(); return this; } + public DataBuilder Row(string[] names, object[] data) { if (kind == DataKind.Delete) throw new InvalidOperationException("Delete accepts predicates through Where, not row values."); if (names.Length != data.Length) throw new ArgumentException("Columns and values must have equal lengths."); columns = names.ToArray(); values = data.ToArray(); return this; } public DataBuilder Set(string[] names, object[] data) => Row(names, data); public DataBuilder Where(string[] names, object[] data) { if (names.Length != data.Length) throw new ArgumentException("Where columns and values must have equal lengths."); whereColumns = names.ToArray(); whereValues = data.ToArray(); return this; } public DataBuilder WhereSql(string sql) { if (kind != DataKind.Update) throw new NotSupportedException("Raw predicates are supported only for Update; use Where columns/values for Delete."); whereSql = sql; return this; } diff --git a/src/Migrator/Framework/Fluent/Operations.cs b/src/Migrator/Framework/Fluent/Operations.cs index 440f367b..6b167942 100644 --- a/src/Migrator/Framework/Fluent/Operations.cs +++ b/src/Migrator/Framework/Fluent/Operations.cs @@ -132,6 +132,7 @@ public override void Apply(ITransformationProvider p) if (WhereSql != null) p.Update(Table, Columns, Values, WhereSql); else p.Update(Table, Columns, Values, WhereColumns ?? Array.Empty(), WhereValues ?? Array.Empty()); break; case DataKind.Delete: + if ((Columns?.Length ?? 0) != 0 || (Values?.Length ?? 0) != 0) throw new InvalidOperationException("Delete does not accept row values; use WhereColumns and WhereValues."); if (WhereSql != null) throw new NotSupportedException("Delete requires structured Where columns/values."); p.Delete(Table, WhereColumns, WhereValues); break; } From 55d5358380f1225656756ce49a2659e78a8efe6a Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:52:43 +0200 Subject: [PATCH 5/7] Preserve script batch semantics in fluent file and resource operations Represent loaded scripts separately from raw SQL so execution uses provider batch handling and preview preserves client separators. Validate SQL Server GO syntax before preview output and invalidate schema knowledge after arbitrary scripts. Add a regression for file/resource operation capture and unchanged batch boundaries. Validation: build, Unit 90 passed and SQLite 155 passed. --- src/Migrator.Tests/FluentOperationsTests.cs | 16 ++++++++++++++++ .../Framework/Fluent/MigrationBuilder.cs | 4 ++-- src/Migrator/Framework/Fluent/Operations.cs | 10 ++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Migrator.Tests/FluentOperationsTests.cs b/src/Migrator.Tests/FluentOperationsTests.cs index dcb6f74b..f7f8f332 100644 --- a/src/Migrator.Tests/FluentOperationsTests.cs +++ b/src/Migrator.Tests/FluentOperationsTests.cs @@ -11,6 +11,22 @@ namespace Migrator.Tests; public class FluentOperationsTests { + [Test] public void FileAndEmbeddedScriptsUseScriptOperations() + { + var path = System.IO.Path.GetTempFileName(); + try + { + System.IO.File.WriteAllText(path, "SELECT 1;\nGO\nSELECT 2;"); + var builder = new MigrationBuilder(); + builder.Execute.Script(path); + builder.Execute.EmbeddedScript(typeof(ScriptTests).Assembly, "Migrator.Tests.ScriptResource.sql"); + Assert.That(builder.Build().All(x => x is ScriptOperation), Is.True); + var script = builder.Build().First().ToSql(new SqlGenerationContext(ProviderTypes.SqlServer)); + Assert.That(script, Does.Contain("GO")); + Assert.That(script.TrimEnd(), Does.EndWith("SELECT 2;")); + } + finally { System.IO.File.Delete(path); } + } [Test] public void TableIsOneCompleteOperationAndDoesNotMutateInput() { var column = new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey); diff --git a/src/Migrator/Framework/Fluent/MigrationBuilder.cs b/src/Migrator/Framework/Fluent/MigrationBuilder.cs index 64fcbfee..5eece221 100644 --- a/src/Migrator/Framework/Fluent/MigrationBuilder.cs +++ b/src/Migrator/Framework/Fluent/MigrationBuilder.cs @@ -140,8 +140,8 @@ public sealed class DataBuilder public sealed class ExecuteRoot(MigrationBuilder builder) { public void Sql(string sql, int? timeout = null, params object[] parameters) => builder.Add(new SqlOperation(sql, timeout, parameters.Length == 0 ? null : parameters.ToArray())); - public void Script(string path) => Sql(File.ReadAllText(path)); - public void EmbeddedScript(Assembly assembly, string name) { using var stream = assembly.GetManifestResourceStream(name) ?? throw new FileNotFoundException("Resource not found", name); using var reader = new StreamReader(stream); Sql(reader.ReadToEnd()); } + public void Script(string path) => builder.Add(new ScriptOperation(File.ReadAllText(path))); + public void EmbeddedScript(Assembly assembly, string name) { using var stream = assembly.GetManifestResourceStream(name) ?? throw new FileNotFoundException("Resource not found", name); using var reader = new StreamReader(stream); builder.Add(new ScriptOperation(reader.ReadToEnd())); } public void WithProvider(Action action) => builder.Add(new CallbackOperation("Provider callback (not previewable)", action)); public void WithCommand(Action action) => builder.Add(new CallbackOperation("Command callback", p => { using var command = p.CreateCommand(); action(command); })); public void WithConnection(Action action) => builder.Add(new CallbackOperation("Connection callback", p => action(p.Connection))); diff --git a/src/Migrator/Framework/Fluent/Operations.cs b/src/Migrator/Framework/Fluent/Operations.cs index 6b167942..cd4e1503 100644 --- a/src/Migrator/Framework/Fluent/Operations.cs +++ b/src/Migrator/Framework/Fluent/Operations.cs @@ -154,6 +154,16 @@ public override string ToSql(SqlGenerationContext c) return Sql.TrimEnd().TrimEnd(';') + ";"; } } +public sealed record ScriptOperation(string Sql) : MigrationOperation +{ + public override void Apply(ITransformationProvider provider) => provider.ExecuteSqlScript(Sql); + public override string ToSql(SqlGenerationContext context) + { + if (context.Dialect is Providers.Impl.SqlServer.SqlServerDialect) _ = SqlScriptBatches.SplitSqlServer(Sql); + context.InvalidateSchema(); + return Sql.TrimEnd() + Environment.NewLine; + } +} public sealed record CallbackOperation(string Description, Action Action) : MigrationOperation { public override void Apply(ITransformationProvider p) => Action(p); From d3347402f045c00dd17d59345ef680e33550fd7a Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 16:13:05 +0200 Subject: [PATCH 6/7] Preflight structured transaction requirements before applying fluent operations Validate the complete authored operation list before executing its first operation. Reversible and conditional wrappers propagate validation only for their active operations, so a database-administration expression cannot be discovered after earlier schema changes. Apply the same path to builders, fluent Up/Down and automatic reversal. Validation: solution build, Unit 91 passed, SQLite 158 passed. Regression checks no earlier table is created and an inactive provider-specific operation does not block execution. --- src/Migrator.Tests/FluentOperationsTests.cs | 19 +++++++++++++++++++ .../Framework/Fluent/FluentMigration.cs | 6 +++--- .../Framework/Fluent/MigrationBuilder.cs | 7 ++++++- src/Migrator/Framework/Fluent/Operations.cs | 8 ++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/Migrator.Tests/FluentOperationsTests.cs b/src/Migrator.Tests/FluentOperationsTests.cs index f7f8f332..11350894 100644 --- a/src/Migrator.Tests/FluentOperationsTests.cs +++ b/src/Migrator.Tests/FluentOperationsTests.cs @@ -27,6 +27,25 @@ [Test] public void FileAndEmbeddedScriptsUseScriptOperations() } finally { System.IO.File.Delete(path); } } + [Test, Category("SQLite")] + public void TransactionIncompatibilityIsRejectedBeforeEarlierOperationsRun() + { + using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.BeginTransaction(); + try + { + var builder = new MigrationBuilder(); + builder.Create.Table("ShouldNotExist").WithColumn("Id").AsInt32(); + builder.WithReverse(new DatabaseOperation(DatabaseOperationKind.Create, "Other"), new SqlOperation("SELECT 1")); + Assert.Throws(() => builder.Apply(provider)); + Assert.That(provider.TableExists("ShouldNotExist"), Is.False); + var conditional = new MigrationBuilder(); + conditional.IfDatabase("PostgreSQL", nested => nested.Administration.CreateDatabase("Other")); + Assert.DoesNotThrow(() => conditional.Apply(provider)); + } + finally { provider.Rollback(); } + } [Test] public void TableIsOneCompleteOperationAndDoesNotMutateInput() { var column = new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey); diff --git a/src/Migrator/Framework/Fluent/FluentMigration.cs b/src/Migrator/Framework/Fluent/FluentMigration.cs index 1e2ae941..9bb7a5b5 100644 --- a/src/Migrator/Framework/Fluent/FluentMigration.cs +++ b/src/Migrator/Framework/Fluent/FluentMigration.cs @@ -13,8 +13,8 @@ public IReadOnlyList GetOperations(bool up) if (up) BuildUp(builder); else BuildDown(builder); return builder.Build(); } - public override void Up() { foreach (var op in GetOperations(true)) op.Apply(Database); } - public override void Down() { foreach (var op in GetOperations(false)) op.Apply(Database); } + public override void Up() => MigrationBuilder.ApplyOperations(Database, GetOperations(true)); + public override void Down() => MigrationBuilder.ApplyOperations(Database, GetOperations(false)); protected SchemaInspector Schema => new(Database); protected ITransformationProvider Context => Database; } @@ -24,7 +24,7 @@ public override void Up() { var operations = GetOperations(true); foreach (var op in operations) op.ValidateReverse(Database); // Validate before the first change. - foreach (var op in operations) op.Apply(Database); + MigrationBuilder.ApplyOperations(Database, operations); } public override void BuildDown(MigrationBuilder migration) { foreach (var op in GetOperations(true).Reverse()) migration.Add(op.Reverse()); } diff --git a/src/Migrator/Framework/Fluent/MigrationBuilder.cs b/src/Migrator/Framework/Fluent/MigrationBuilder.cs index 5eece221..ae1a2023 100644 --- a/src/Migrator/Framework/Fluent/MigrationBuilder.cs +++ b/src/Migrator/Framework/Fluent/MigrationBuilder.cs @@ -25,7 +25,12 @@ public sealed class MigrationBuilder public void Add(MigrationOperation operation) => operations.Add(() => operation); internal void Add(Func operation) => operations.Add(operation); public IReadOnlyList Build() => operations.Select(x => x()).ToArray(); - public void Apply(ITransformationProvider provider) { foreach (var op in Build()) op.Apply(provider); } + public void Apply(ITransformationProvider provider) => ApplyOperations(provider, Build()); + internal static void ApplyOperations(ITransformationProvider provider, IReadOnlyList operations) + { + foreach (var operation in operations) operation.Validate(provider); + foreach (var operation in operations) operation.Apply(provider); + } public IReadOnlyList Preview(SqlGenerationContext context) => Build().Select(op => op.ToSql(context)).Where(sql => sql.Length != 0).ToArray(); public void IfDatabase(string name, Action configure) { diff --git a/src/Migrator/Framework/Fluent/Operations.cs b/src/Migrator/Framework/Fluent/Operations.cs index cd4e1503..6232abc6 100644 --- a/src/Migrator/Framework/Fluent/Operations.cs +++ b/src/Migrator/Framework/Fluent/Operations.cs @@ -10,6 +10,11 @@ public abstract record MigrationOperation { public abstract void Apply(ITransformationProvider provider); public virtual bool RequiresNoTransaction => false; + public virtual void Validate(ITransformationProvider provider) + { + if (RequiresNoTransaction && provider is TransformationProvider { HasActiveTransaction: true }) + throw new MigrationException("Database administration requires an explicit no-transaction run."); + } public virtual MigrationOperation Reverse() => throw new IrreversibleMigrationException(); public virtual void ValidateReverse(ITransformationProvider provider) => _ = Reverse(); public virtual string ToSql(SqlGenerationContext context) => throw new NotSupportedException($"SQL preview is not supported for {GetType().Name}."); @@ -170,12 +175,14 @@ public sealed record CallbackOperation(string Description, Action Forward.Validate(p); public override void Apply(ITransformationProvider p) => Forward.Apply(p); public override MigrationOperation Reverse() => new ReversibleOperation(Backward, Forward); public override string ToSql(SqlGenerationContext c) => Forward.ToSql(c); } public sealed record ConditionalOperation(string Provider, MigrationOperation Operation) : MigrationOperation { + public override void Validate(ITransformationProvider p) { if (p.IsThisProvider(Provider)) Operation.Validate(p); } public override void Apply(ITransformationProvider p) { if (p.IsThisProvider(Provider)) Operation.Apply(p); } public override void ValidateReverse(ITransformationProvider p) { if (p.IsThisProvider(Provider)) Operation.ValidateReverse(p); } public override MigrationOperation Reverse() => new ConditionalReverseOperation(Provider, Operation); @@ -183,6 +190,7 @@ public sealed record ConditionalOperation(string Provider, MigrationOperation Op } public sealed record ConditionalReverseOperation(string Provider, MigrationOperation Forward) : MigrationOperation { + public override void Validate(ITransformationProvider p) { if (p.IsThisProvider(Provider)) Forward.Reverse().Validate(p); } public override void Apply(ITransformationProvider p) { if (p.IsThisProvider(Provider)) Forward.Reverse().Apply(p); } public override MigrationOperation Reverse() => new ConditionalOperation(Provider, Forward); public override string ToSql(SqlGenerationContext c) => c.Dialect.GetType().Name.StartsWith(Provider, StringComparison.OrdinalIgnoreCase) ? Forward.Reverse().ToSql(c) : ""; From 1c10816ea01e13f07f9b1755f5f6918ec569975b Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 16:25:24 +0200 Subject: [PATCH 7/7] Replace assertion-free legacy fluent tests with execution assertions Verify one complete table definition reaches the provider, including type, primary key, size and default. Assert foreign-key ordering, direction and action, and verify existing-table columns use AddColumn without creating a table. Remove seven tests whose assertions were commented out. Validation: solution build and Unit 88 passed; fewer test cases now cover meaningful observable behavior. --- src/Migrator.Tests/SchemaBuilderTests.cs | 108 ++++++++--------------- 1 file changed, 36 insertions(+), 72 deletions(-) diff --git a/src/Migrator.Tests/SchemaBuilderTests.cs b/src/Migrator.Tests/SchemaBuilderTests.cs index f0adc61c..ab17758f 100644 --- a/src/Migrator.Tests/SchemaBuilderTests.cs +++ b/src/Migrator.Tests/SchemaBuilderTests.cs @@ -1,91 +1,55 @@ using System.Data; +using System.Linq; using DotNetProjects.Migrator.Framework; using DotNetProjects.Migrator.Framework.SchemaBuilder; +using NSubstitute; using NUnit.Framework; - namespace Migrator.Tests; [TestFixture] public class SchemaBuilderTests { - private SchemaBuilder _schemaBuilder; - - [SetUp] - public void Setup() - { - _schemaBuilder = new SchemaBuilder(); - _schemaBuilder.AddTable("SomeTable"); - } - - [Test] - public void Can_AddTable() - { - _schemaBuilder.AddTable("MyUserTable"); - //Assert.That("MyUserTable", _schemaBuilder.Expressions.ElementAt(0)); - } - - [Test] - public void Can_AddColumn() - { - var columnName = "MyUserId"; - - _schemaBuilder - .AddColumn(columnName); - - //Assert.IsTrue(_schemaBuilder.Columns.Count == 1); - //Assert.That(columnName, _schemaBuilder.Columns[0].Name); - } - [Test] - public void Can_chain_AddColumn_OfType() - { - _schemaBuilder - .AddColumn("SomeColumn") - .OfType(DbType.Int32); - - //Assert.That(DbType.Int32, _schemaBuilder.Columns[0].Type, "Column.Type was not as expected"); + public void TableExecutesOnceWithCompletedColumnDefinitions() + { + var builder = new SchemaBuilder(); + builder.AddTable("Users").AddColumn("Id").OfType(DbType.Int32).WithProperty(ColumnProperty.PrimaryKey); + builder.AddColumn("Name").OfType(DbType.String).WithSize(100).WithDefaultValue("guest"); + var provider = Substitute.For(); + foreach (var expression in builder.Expressions) expression.Create(provider); + provider.Received(1).AddTable("Users", Arg.Is(fields => fields.Length == 2 + && ((Column)fields[0]).Name == "Id" && ((Column)fields[0]).Type == DbType.Int32 + && ((Column)fields[0]).IsPrimaryKey && ((Column)fields[1]).Name == "Name" + && ((Column)fields[1]).Type == DbType.String && ((Column)fields[1]).Size == 100 + && (string)((Column)fields[1]).DefaultValue == "guest")); + Assert.That(provider.ReceivedCalls().Count(call => call.GetMethodInfo().Name == "AddTable"), Is.EqualTo(1)); + Assert.That(provider.ReceivedCalls().Any(call => call.GetMethodInfo().Name == "AddColumn"), Is.False); } [Test] - public void Can_chain_AddColumn_WithProperty() + public void ForeignKeyExecutesAfterCompletedChildTableWithCorrectDirectionAndAction() { - _schemaBuilder - .AddColumn("MyColumn") - .OfType(DbType.Int32) - .WithProperty(ColumnProperty.PrimaryKey); - - //Assert.IsTrue(_schemaBuilder.Columns[0].IsPrimaryKey); + var builder = new SchemaBuilder(); + builder.AddTable("Child").AddColumn("ParentId").OfType(DbType.Int32) + .AsForeignKey().ReferencedTo("Parent", "Id").WithConstraint(ForeignKeyConstraintType.Cascade); + var provider = Substitute.For(); + foreach (var expression in builder.Expressions) expression.Create(provider); + Received.InOrder(() => + { + provider.AddTable("Child", Arg.Is(fields => fields.Length == 1 && ((Column)fields[0]).Name == "ParentId")); + provider.AddForeignKey("FK_Child_ParentId_Parent_Id", "Child", Arg.Is(names => names.SequenceEqual(new[] { "ParentId" })), + "Parent", Arg.Is(names => names.SequenceEqual(new[] { "Id" })), ForeignKeyConstraintType.Cascade); + }); } [Test] - public void Can_chain_AddColumn_WithSize() + public void ExistingTableColumnUsesAddColumnWithAuthoredOptions() { - _schemaBuilder - .AddColumn("column") - .WithSize(100); - - //Assert.That(100, _schemaBuilder.Columns[0].Size); + var builder = new SchemaBuilder(); + builder.WithTable("Existing").AddColumn("Name").OfType(DbType.String).WithSize(80).WithDefaultValue("guest"); + var provider = Substitute.For(); + foreach (var expression in builder.Expressions) expression.Create(provider); + provider.Received(1).AddColumn("Existing", "Name", DbType.String, 80, ColumnProperty.None, "guest"); + Assert.That(provider.ReceivedCalls().Any(call => call.GetMethodInfo().Name == "AddTable"), Is.False); } - - [Test] - public void Can_chain_AddColumn_WithDefaultValue() - { - _schemaBuilder - .AddColumn("something") - .OfType(DbType.Int32) - .WithDefaultValue("default value"); - - //Assert.That("default value", _schemaBuilder.Columns[0].DefaultValue); - } - - [Test] - public void Can_chain_AddTable_WithForeignKey() - { - _schemaBuilder - .AddColumn("MyColumnThatIsForeignKey") - .AsForeignKey().ReferencedTo("PrimaryKeyTable", "PrimaryKeyColumn").WithConstraint(ForeignKeyConstraintType.NoAction); - - //Assert.IsTrue(_schemaBuilder.Columns[0].ColumnProperty == ColumnProperty.ForeignKey); - } -} - +}