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 new file mode 100644 index 00000000..11350894 --- /dev/null +++ b/src/Migrator.Tests/FluentOperationsTests.cs @@ -0,0 +1,189 @@ +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 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, 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); + 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", 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")); + 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[])); + } + [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 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(); + 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 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(); + 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.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.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); - } -} - +} diff --git a/src/Migrator/Framework/Fluent/FluentMigration.cs b/src/Migrator/Framework/Fluent/FluentMigration.cs new file mode 100644 index 00000000..9bb7a5b5 --- /dev/null +++ b/src/Migrator/Framework/Fluent/FluentMigration.cs @@ -0,0 +1,74 @@ +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() => MigrationBuilder.ApplyOperations(Database, GetOperations(true)); + public override void Down() => MigrationBuilder.ApplyOperations(Database, GetOperations(false)); + 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.ValidateReverse(Database); // Validate before the first change. + MigrationBuilder.ApplyOperations(Database, operations); + } + 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 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); + 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..ae1a2023 --- /dev/null +++ b/src/Migrator/Framework/Fluent/MigrationBuilder.cs @@ -0,0 +1,165 @@ +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) => 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) + { + 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) => 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 +{ + 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 (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; } + 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) +{ + 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) => 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))); + 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) + { builder.Add(new CopyDataOperation(source, sourceColumns.ToArray(), target, targetColumns.ToArray(), orderBy?.ToArray())); } + public void UpdateFrom(string source, string target, ColumnPair[] copy, ColumnPair[] match) + { 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 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 new file mode 100644 index 00000000..6232abc6 --- /dev/null +++ b/src/Migrator/Framework/Fluent/Operations.cs @@ -0,0 +1,251 @@ +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 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}."); +} + +public sealed record CreateTableOperation(string Table, string Engine, IDbField[] Fields) : MigrationOperation +{ + 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) + { + 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: + 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; + } + } + 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."); + c.InvalidateSchema(); + 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); +} +public sealed record ReversibleOperation(MigrationOperation Forward, MigrationOperation Backward) : MigrationOperation +{ + public override void Validate(ITransformationProvider p) => 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); + 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 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) : ""; +} +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 + { + 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..645d8ef4 --- /dev/null +++ b/src/Migrator/Framework/Fluent/SqlGenerationContext.cs @@ -0,0 +1,56 @@ +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; + 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) + { Provider = provider; Dialect = ProviderFactory.DialectForProvider(provider) ?? throw new ArgumentException("Unknown provider."); this.existingTable = existingTable; } + 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 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 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) + }; + 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) { 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)); + 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..ac3a39bf 100644 --- a/src/Migrator/Framework/SchemaBuilder/AddTableExpression.cs +++ b/src/Migrator/Framework/SchemaBuilder/AddTableExpression.cs @@ -1,29 +1,36 @@ -#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); - } -} \ No newline at end of file +#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(); + 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); + } +} 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