From 480638f3248f9e87c5b11ab5b097549accbe5eab Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Thu, 8 Jan 2026 05:50:54 -0800 Subject: [PATCH 01/11] Remove CoreEx dependencies. --- .github/workflows/CI.yml | 2 +- CHANGELOG.md | 15 + Common.targets | 2 +- DbEx.sln | 12 +- docker-compose.yml | 24 ++ .../Console/MySqlMigrationConsole.cs | 1 - src/DbEx.MySql/DbEx.MySql.csproj | 26 +- src/DbEx.MySql/Migration/MySqlDatabase.cs | 12 + src/DbEx.MySql/Migration/MySqlMigration.cs | 2 - src/DbEx.MySql/MySqlSchemaConfig.cs | 29 +- src/DbEx.MySql/Resources/ScriptCreate_sql.hbs | 4 +- .../Resources/ScriptRefData_sql.hbs | 4 +- .../Console/PostgresMigrationConsole.cs | 1 - src/DbEx.Postgres/DbEx.Postgres.csproj | 39 +-- .../Migration/PostgresDatabase.cs | 12 + .../Migration/PostgresMigration.cs | 2 - src/DbEx.Postgres/PostgresSchemaConfig.cs | 34 +-- .../Functions/fn_get_tenant_id.sql | 2 +- .../Functions/fn_get_timestamp.sql | 2 +- .../Functions/fn_get_user_id.sql | 2 +- .../Functions/fn_get_username.sql | 2 +- .../Resources/ScriptCreate_sql.hbs | 4 +- .../Resources/ScriptRefData_sql.hbs | 4 +- .../Console/SqlServerMigrationConsole.cs | 1 - src/DbEx.SqlServer/DbEx.SqlServer.csproj | 29 +- .../Migration/SqlServerDatabase.cs | 12 + .../Migration/SqlServerMigration.cs | 4 +- .../Functions/fnGetTenantId.sql | 4 +- .../Functions/fnGetTimestamp.sql | 12 +- .../ExtendedSchema/Functions/fnGetUserId.sql | 4 +- .../Functions/fnGetUsername.sql | 4 +- .../Stored Procedures/spSetSessionContext.sql | 8 +- .../spThrowBusinessException.sql | 2 +- .../spThrowConcurrencyException.sql | 2 +- .../spThrowConflictException.sql | 2 +- .../spThrowDuplicateException.sql | 2 +- .../spThrowNotFoundException.sql | 2 +- .../spThrowValidationException.sql | 2 +- .../Resources/ScriptCreate_sql.hbs | 4 +- .../Resources/ScriptRefData_sql.hbs | 4 +- src/DbEx.SqlServer/SqlServerSchemaConfig.cs | 24 +- src/DbEx/Console/AssemblyValidator.cs | 1 - src/DbEx/Console/MigrationConsoleBase.cs | 12 +- src/DbEx/Console/MigrationConsoleBaseT.cs | 10 +- src/DbEx/Console/ParametersValidator.cs | 3 +- src/DbEx/DatabaseExtensions.cs | 249 +++++---------- src/DbEx/DatabaseSchemaConfig.cs | 63 ++-- src/DbEx/DbEx.csproj | 5 +- src/DbEx/DbSchema/DbColumnSchema.cs | 1 - src/DbEx/DbSchema/DbTableSchema.cs | 11 +- src/DbEx/Migration/Data/DataColumn.cs | 1 - src/DbEx/Migration/Data/DataParser.cs | 1 - src/DbEx/Migration/Data/DataParserArgs.cs | 18 +- .../Migration/Data/DataParserColumnDefault.cs | 1 - .../Data/DataParserColumnDefaultCollection.cs | 1 - .../Data/DataParserTableNameMappings.cs | 1 - src/DbEx/Migration/Data/DataRow.cs | 9 +- src/DbEx/Migration/Data/DataTable.cs | 15 +- src/DbEx/Migration/Database.cs | 284 ++++++++++++++++++ src/DbEx/Migration/DatabaseCommand.cs | 132 ++++++++ src/DbEx/Migration/DatabaseJournal.cs | 12 +- src/DbEx/Migration/DatabaseMigrationBase.cs | 18 +- src/DbEx/Migration/DatabaseMigrationScript.cs | 1 - src/DbEx/Migration/DatabaseRecord.cs | 84 ++++++ .../Migration/DatabaseSchemaScriptBase.cs | 1 - src/DbEx/Migration/IDatabase.cs | 54 ++++ src/DbEx/Migration/MigrationArgsBase.cs | 33 +- src/DbEx/Migration/MigrationAssemblyArgs.cs | 1 - tests/DbEx.Test.Console/Data/Other.sql | 2 +- .../DbEx.Test.Console.csproj | 3 +- .../003-create-test-gender-table.sql | 8 +- .../006-create-test-person-table.sql | 8 +- .../007-create-test-status-table.sql | 8 +- tests/DbEx.Test.Console/Program.cs | 4 +- .../DbEx.Test.Console/Resources/Table_sql.hb | 4 +- tests/DbEx.Test.Empty/DbEx.Test.Empty.csproj | 2 +- tests/DbEx.Test.Error/DbEx.Test.Error.csproj | 2 +- .../DbEx.Test.MySqlConsole.csproj | 2 +- .../003-create-test-gender-table.sql | 4 +- .../004-create-test-contact-table.sql | 4 +- .../DbEx.Test.OutboxConsole.csproj | 2 +- .../DbEx.Test.PostgresConsole.csproj | 2 +- .../003-create-test-gender-table.sql | 4 +- .../004-create-test-contact-table.sql | 4 +- tests/DbEx.Test/ContextOutLogger.cs | 28 +- tests/DbEx.Test/DatabaseSchemaTest.cs | 11 +- tests/DbEx.Test/DbEx.Test.csproj | 15 +- tests/DbEx.Test/PostgresMigrationTest.cs | 42 +-- tests/DbEx.Test/SqlServerMigrationTest.cs | 67 +++-- tests/DbEx.Test/appsettings.json | 19 +- 90 files changed, 1025 insertions(+), 601 deletions(-) create mode 100644 docker-compose.yml create mode 100644 src/DbEx.MySql/Migration/MySqlDatabase.cs create mode 100644 src/DbEx.Postgres/Migration/PostgresDatabase.cs create mode 100644 src/DbEx.SqlServer/Migration/SqlServerDatabase.cs create mode 100644 src/DbEx/Migration/Database.cs create mode 100644 src/DbEx/Migration/DatabaseCommand.cs create mode 100644 src/DbEx/Migration/DatabaseRecord.cs create mode 100644 src/DbEx/Migration/IDatabase.cs diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 26d8939..a32985a 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -18,9 +18,9 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: | - 6.0.x 8.0.x 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore diff --git a/CHANGELOG.md b/CHANGELOG.md index 150370f..6a0163e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ Represents the **NuGet** versions. +## v3.0.0 +All internal dependecies to [`CoreEx`](https://github.com/avanade/coreex) have been removed. This is intended to further generalize the capabilities of `DbEx`; but more importantly, break the circular dependency reference between the two repositories. +- *Enhancement:* Added `net10.0` support and updated all related package dependencies to latest. Removed `net6.0` support. +- *Enhancement:* List of key **breaking changes** as follows: + - `DatabaseSchemaConfig.CreatedDate` renamed to `DatabaseSchemaConfig.CreatedOn`. + - `DatabaseSchemaConfig.UpdatedDate` renamed to `DatabaseSchemaConfig.UpdatedOn`. + - `MigrationArgsBase.CreatedDateColumnName` renamed to `MigrationArgsBase.CreatedOnColumnName`. + - `MigrationArgsBase.UpdatedDateColumnName` renamed to `MigrationArgsBase.UpdatedOnColumnName`. + - `DateTimeOffset` is the preferred .NET type for date/time auditing/timestamping. +- *Enhancement:* Absorbing the [`Beef`](https://github.com/avanade/beef) database code-generation capabilities into `DbEx` to enable greater usage and consistency. + - The code-generation templates have been updated to reflect the latest patterns and practices (where applicable). + - The code-generation configuration file has been renamed to `dbex.yaml` to avoid conflicts; schema remains largely the same. + +The enhancements have been made in a manner to maximize backwards compatibility with previous versions of `DbEx` where possible; however, some breaking changes were unfortunately unavoidable (and made to improve overall). + ## v2.8.1 - *Fixed:* All related package dependencies updated to latest. diff --git a/Common.targets b/Common.targets index 335965d..c341773 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@ - 2.8.1 + 3.0.0 preview Avanade Avanade diff --git a/DbEx.sln b/DbEx.sln index b3a85ea..0ade183 100644 --- a/DbEx.sln +++ b/DbEx.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32228.430 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11312.210 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DbEx", "src\DbEx\DbEx.csproj", "{1C02E315-1886-4F77-976F-7081764A4FCB}" EndProject @@ -20,6 +20,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution CODE_OF_CONDUCT.md = CODE_OF_CONDUCT.md Common.targets = Common.targets CONTRIBUTING.md = CONTRIBUTING.md + docker-compose.yml = docker-compose.yml LICENSE = LICENSE nuget-publish.ps1 = nuget-publish.ps1 README.md = README.md @@ -28,8 +29,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DbEx.Test.Console", "tests\DbEx.Test.Console\DbEx.Test.Console.csproj", "{117B6E86-2C88-446E-AC3A-AE1A2E84E2D8}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DbEx.Test.OutboxConsole", "tests\DbEx.Test.OutboxConsole\DbEx.Test.OutboxConsole.csproj", "{959DD5E1-530A-42BA-82B8-F17A657AC351}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DbEx.Test.Error", "tests\DbEx.Test.Error\DbEx.Test.Error.csproj", "{2069346C-9769-48DF-B71F-A58ED6A2192B}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{BB01353C-4FB3-4D61-899C-1A1D8F0AA268}" @@ -74,10 +73,6 @@ Global {117B6E86-2C88-446E-AC3A-AE1A2E84E2D8}.Debug|Any CPU.Build.0 = Debug|Any CPU {117B6E86-2C88-446E-AC3A-AE1A2E84E2D8}.Release|Any CPU.ActiveCfg = Release|Any CPU {117B6E86-2C88-446E-AC3A-AE1A2E84E2D8}.Release|Any CPU.Build.0 = Release|Any CPU - {959DD5E1-530A-42BA-82B8-F17A657AC351}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {959DD5E1-530A-42BA-82B8-F17A657AC351}.Debug|Any CPU.Build.0 = Debug|Any CPU - {959DD5E1-530A-42BA-82B8-F17A657AC351}.Release|Any CPU.ActiveCfg = Release|Any CPU - {959DD5E1-530A-42BA-82B8-F17A657AC351}.Release|Any CPU.Build.0 = Release|Any CPU {2069346C-9769-48DF-B71F-A58ED6A2192B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2069346C-9769-48DF-B71F-A58ED6A2192B}.Debug|Any CPU.Build.0 = Debug|Any CPU {2069346C-9769-48DF-B71F-A58ED6A2192B}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -111,7 +106,6 @@ Global {06385968-DFF7-4470-B87E-55D98CC4661C} = {97C99299-8645-46CC-A851-6F3E79112221} {7B499943-7183-446F-92C0-D9DAD6237303} = {06385968-DFF7-4470-B87E-55D98CC4661C} {117B6E86-2C88-446E-AC3A-AE1A2E84E2D8} = {06385968-DFF7-4470-B87E-55D98CC4661C} - {959DD5E1-530A-42BA-82B8-F17A657AC351} = {06385968-DFF7-4470-B87E-55D98CC4661C} {2069346C-9769-48DF-B71F-A58ED6A2192B} = {06385968-DFF7-4470-B87E-55D98CC4661C} {80EF0604-F641-4D01-9922-0162D0C69E02} = {06385968-DFF7-4470-B87E-55D98CC4661C} {9DBC4E5E-6321-4F56-82CC-7ECEE1020EE0} = {06385968-DFF7-4470-B87E-55D98CC4661C} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6974ad3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +name: DbEx + +services: + db-sql-server: + image: mcr.microsoft.com/mssql/server:2019-latest + environment: + SA_PASSWORD: "yourStrong(!)Password" + ACCEPT_EULA: "Y" + ports: + - "1433:1433" + + db-mysql: + image: mysql + environment: + MYSQL_ROOT_PASSWORD: "yourStrong#!Password" + ports: + - "3306:3306" + + db-postgres: + image: postgres + environment: + POSTGRES_PASSWORD: "yourStrong#!Password" + ports: + - "5432:5432" \ No newline at end of file diff --git a/src/DbEx.MySql/Console/MySqlMigrationConsole.cs b/src/DbEx.MySql/Console/MySqlMigrationConsole.cs index 20111ab..2ef199a 100644 --- a/src/DbEx.MySql/Console/MySqlMigrationConsole.cs +++ b/src/DbEx.MySql/Console/MySqlMigrationConsole.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using DbEx.Console; using DbEx.Migration; using DbEx.MySql.Migration; diff --git a/src/DbEx.MySql/DbEx.MySql.csproj b/src/DbEx.MySql/DbEx.MySql.csproj index aa61462..b8805da 100644 --- a/src/DbEx.MySql/DbEx.MySql.csproj +++ b/src/DbEx.MySql/DbEx.MySql.csproj @@ -1,12 +1,13 @@  - net6.0;net8.0;net9.0 + net8.0;net9.0;net10.0 DbEx.MySql DbEx DbEx MySQL Migration Tool. DbEX Database Migration tool for MySQL. dbex database dbup db-up mysql sql + $(NoWarn);CA1873 @@ -22,27 +23,8 @@ - - - - - - - - - - - - - - - - - - - - - + + diff --git a/src/DbEx.MySql/Migration/MySqlDatabase.cs b/src/DbEx.MySql/Migration/MySqlDatabase.cs new file mode 100644 index 0000000..7d62766 --- /dev/null +++ b/src/DbEx.MySql/Migration/MySqlDatabase.cs @@ -0,0 +1,12 @@ +using DbEx.Migration; +using MySql.Data.MySqlClient; +using System; + +namespace DbEx.MySql.Migration +{ + /// + /// Provides MySQL database access functionality. + /// + /// + public class MySqlDatabase(Func create) : Database(create, MySqlClientFactory.Instance) { } +} \ No newline at end of file diff --git a/src/DbEx.MySql/Migration/MySqlMigration.cs b/src/DbEx.MySql/Migration/MySqlMigration.cs index 6ad78e6..f9cc44b 100644 --- a/src/DbEx.MySql/Migration/MySqlMigration.cs +++ b/src/DbEx.MySql/Migration/MySqlMigration.cs @@ -1,7 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx.Database; -using CoreEx.Database.MySql; using DbEx.DbSchema; using DbEx.Migration; using DbUp.Support; diff --git a/src/DbEx.MySql/MySqlSchemaConfig.cs b/src/DbEx.MySql/MySqlSchemaConfig.cs index 4c8e057..8f51d58 100644 --- a/src/DbEx.MySql/MySqlSchemaConfig.cs +++ b/src/DbEx.MySql/MySqlSchemaConfig.cs @@ -1,7 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; -using CoreEx.Database; using DbEx.DbSchema; using DbEx.Migration; using DbEx.MySql.Migration; @@ -34,16 +32,16 @@ public class MySqlSchemaConfig(MySqlMigration migration) : DatabaseSchemaConfig( public override string JsonColumnNameSuffix => "_json"; /// - /// Value is 'created_date'. - public override string CreatedDateColumnName => "created_date"; + /// Value is 'created_on'. + public override string CreatedOnColumnName => "created_on"; /// /// Value is 'created_by'. public override string CreatedByColumnName => "created_by"; /// - /// Value is 'updated_date'. - public override string UpdatedDateColumnName => "updated_date"; + /// Value is 'updated_on'. + public override string UpdatedOnColumnName => "updated_on"; /// /// Value is 'updated_by'. @@ -84,13 +82,13 @@ public override void PrepareMigrationArgs() /// public override DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema table, DatabaseRecord dr) { - var dt = dr.GetValue("DATA_TYPE"); - if (string.Compare(dt, "TINYINT", StringComparison.OrdinalIgnoreCase) == 0 && dr.GetValue("COLUMN_TYPE").Equals("TINYINT(1)", StringComparison.OrdinalIgnoreCase)) + var dt = dr.GetValue("DATA_TYPE")!; + if (string.Compare(dt, "TINYINT", StringComparison.OrdinalIgnoreCase) == 0 && dr.GetValue("COLUMN_TYPE")!.Equals("TINYINT(1)", StringComparison.OrdinalIgnoreCase)) dt = "BOOL"; - var c = new DbColumnSchema(table, dr.GetValue("COLUMN_NAME"), dt) + var c = new DbColumnSchema(table, dr.GetValue("COLUMN_NAME")!, dt) { - IsNullable = dr.GetValue("IS_NULLABLE").Equals("YES", StringComparison.OrdinalIgnoreCase), + IsNullable = dr.GetValue("IS_NULLABLE")!.Equals("YES", StringComparison.OrdinalIgnoreCase), Length = (ulong?)dr.GetValue("CHARACTER_MAXIMUM_LENGTH"), Precision = dr.GetValue("NUMERIC_PRECISION") ?? dr.GetValue("DATETIME_PRECISION"), Scale = dr.GetValue("NUMERIC_SCALE"), @@ -99,7 +97,7 @@ public override DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema t IsDotNetTimeOnly = RemovePrecisionFromDataType(dt).Equals("TIME", StringComparison.OrdinalIgnoreCase) }; - c.IsJsonContent = c.Type.ToUpper() == "JSON" || (c.DotNetName == "string" && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)); + c.IsJsonContent = c.Type.Equals("JSON", StringComparison.OrdinalIgnoreCase) || (c.DotNetName == "string" && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)); if (c.IsJsonContent && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)) c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[..^JsonColumnNameSuffix.Length]); @@ -179,10 +177,9 @@ public override string ToDotNetTypeName(DbColumnSchema schema) { "CHAR" or "VARCHAR" or "TINYTEXT" or "TEXT" or "MEDIUMTEXT" or "LONGTEXT" or "SET" or "ENUM" or "NCHAR" or "NVARCHAR" or "JSON" => "string", "DECIMAL" => "decimal", - "DATE" or "DATETIME" or "TIMESTAMP" => "DateTime", - "DATETIMEOFFSET" => "DateTimeOffset", - "Date" => "DateTime", // Date only - "TIME" => "TimeSpan", // Time only + "DATETIME" or "TIMESTAMP" => "DateTime", + "DATE" => "DateOnly", + "TIME" => "TimeOnly", "BINARY" or "VARBINARY" or "TINYBLOB" or "BLOB" or "MEDIUMBLOB" or "LONGBLOB" => "byte[]", "BIT" or "BOOL" or "BOOLEAN" => "bool", "DOUBLE" => "double", @@ -224,7 +221,7 @@ public override string ToFormattedSqlType(DbColumnSchema schema, bool includeNul bool b => b ? "true" : "false", Guid => $"'{value}'", DateTime dt => $"'{dt.ToString(Migration.Args.DataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", - DateTimeOffset dto => $"'{dto.ToString(Migration.Args.DataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + DateTimeOffset dto => $"'{dto.ToString(Migration.Args.DataParserArgs.DateTimeOffsetFormat, System.Globalization.CultureInfo.InvariantCulture)}'", #if NET7_0_OR_GREATER DateOnly d => $"'{d.ToString(Migration.Args.DataParserArgs.DateOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", TimeOnly t => $"'{t.ToString(Migration.Args.DataParserArgs.TimeOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", diff --git a/src/DbEx.MySql/Resources/ScriptCreate_sql.hbs b/src/DbEx.MySql/Resources/ScriptCreate_sql.hbs index e3babb5..1433f00 100644 --- a/src/DbEx.MySql/Resources/ScriptCreate_sql.hbs +++ b/src/DbEx.MySql/Resources/ScriptCreate_sql.hbs @@ -13,9 +13,9 @@ CREATE TABLE `{{lookup Parameters 'Param1'}}` ( -- `date` DATE NULL, `row_version` TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), `created_by` VARCHAR(250) NULL, - `created_date` DATETIME(6) NULL, + `created_on` DATETIME(6) NULL, `updated_by` VARCHAR(250) NULL, - `updated_date` DATETIME(6) NULL + `updated_on` DATETIME(6) NULL ); COMMIT WORK; \ No newline at end of file diff --git a/src/DbEx.MySql/Resources/ScriptRefData_sql.hbs b/src/DbEx.MySql/Resources/ScriptRefData_sql.hbs index 6499c65..c79c069 100644 --- a/src/DbEx.MySql/Resources/ScriptRefData_sql.hbs +++ b/src/DbEx.MySql/Resources/ScriptRefData_sql.hbs @@ -13,9 +13,9 @@ CREATE TABLE `{{lookup Parameters 'Param1'}}` ( `sort_order` INT NULL, `row_version` TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), `created_by` VARCHAR(250) NULL, - `created_date` DATETIME(6) NULL, + `created_on` DATETIME(6) NULL, `updated_by` VARCHAR(250) NULL, - `updated_date` DATETIME(6) NULL + `updated_on` DATETIME(6) NULL ); COMMIT WORK; \ No newline at end of file diff --git a/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs b/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs index 0b4d751..f9f8fa6 100644 --- a/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs +++ b/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using DbEx.Console; using DbEx.Migration; using DbEx.Postgres.Migration; diff --git a/src/DbEx.Postgres/DbEx.Postgres.csproj b/src/DbEx.Postgres/DbEx.Postgres.csproj index eeb8b43..7c076f1 100644 --- a/src/DbEx.Postgres/DbEx.Postgres.csproj +++ b/src/DbEx.Postgres/DbEx.Postgres.csproj @@ -1,12 +1,13 @@  - net6.0;net8.0;net9.0 + net8.0;net9.0;net10.0 DbEx.Postgres DbEx DbEx PostgreSQL Migration Tool. DbEX Database Migration tool for PostgreSQL. dbex database dbup db-up postgres postgresql sql + $(NoWarn);CA1873 @@ -22,40 +23,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + diff --git a/src/DbEx.Postgres/Migration/PostgresDatabase.cs b/src/DbEx.Postgres/Migration/PostgresDatabase.cs new file mode 100644 index 0000000..e0b5463 --- /dev/null +++ b/src/DbEx.Postgres/Migration/PostgresDatabase.cs @@ -0,0 +1,12 @@ +using DbEx.Migration; +using Npgsql; +using System; + +namespace DbEx.Postgres.Migration +{ + /// + /// Provides Npgsql (PostgreSQL) database access functionality. + /// + /// + public class PostgresDatabase(Func create) : Database(create, NpgsqlFactory.Instance) { } +} \ No newline at end of file diff --git a/src/DbEx.Postgres/Migration/PostgresMigration.cs b/src/DbEx.Postgres/Migration/PostgresMigration.cs index 0117a7d..c2a2b11 100644 --- a/src/DbEx.Postgres/Migration/PostgresMigration.cs +++ b/src/DbEx.Postgres/Migration/PostgresMigration.cs @@ -1,7 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx.Database; -using CoreEx.Database.Postgres; using DbEx.DbSchema; using DbEx.Migration; using DbUp.Support; diff --git a/src/DbEx.Postgres/PostgresSchemaConfig.cs b/src/DbEx.Postgres/PostgresSchemaConfig.cs index a1aed83..a3e26db 100644 --- a/src/DbEx.Postgres/PostgresSchemaConfig.cs +++ b/src/DbEx.Postgres/PostgresSchemaConfig.cs @@ -1,7 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; -using CoreEx.Database; using DbEx.DbSchema; using DbEx.Migration; using DbEx.Postgres.Migration; @@ -33,16 +31,16 @@ public class PostgresSchemaConfig(PostgresMigration migration) : DatabaseSchemaC public override string JsonColumnNameSuffix => "_json"; /// - /// Value is 'created_date'. - public override string CreatedDateColumnName => "created_date"; + /// Value is 'created_on'. + public override string CreatedOnColumnName => "created_on"; /// /// Value is 'created_by'. public override string CreatedByColumnName => "created_by"; /// - /// Value is 'updated_date'. - public override string UpdatedDateColumnName => "updated_date"; + /// Value is 'updated_on'. + public override string UpdatedOnColumnName => "updated_on"; /// /// Value is 'updated_by'. @@ -84,20 +82,20 @@ public override void PrepareMigrationArgs() /// public override DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema table, DatabaseRecord dr) { - var c = new DbColumnSchema(table, dr.GetValue("COLUMN_NAME"), dr.GetValue("DATA_TYPE")) + var c = new DbColumnSchema(table, dr.GetValue("COLUMN_NAME")!, dr.GetValue("DATA_TYPE")!) { - IsNullable = dr.GetValue("IS_NULLABLE").Equals("YES", StringComparison.OrdinalIgnoreCase), + IsNullable = dr.GetValue("IS_NULLABLE")!.Equals("YES", StringComparison.OrdinalIgnoreCase), Length = (ulong?)dr.GetValue("CHARACTER_MAXIMUM_LENGTH"), Precision = (ulong?)(dr.GetValue("NUMERIC_PRECISION") ?? dr.GetValue("DATETIME_PRECISION")), Scale = (ulong?)dr.GetValue("NUMERIC_SCALE"), - DefaultValue = dr.GetValue("COLUMN_DEFAULT") is not null && dr.GetValue("COLUMN_DEFAULT").StartsWith("nextval(", StringComparison.OrdinalIgnoreCase) ? null : dr.GetValue("COLUMN_DEFAULT"), + DefaultValue = dr.GetValue("COLUMN_DEFAULT") is not null && dr.GetValue("COLUMN_DEFAULT")!.StartsWith("nextval(", StringComparison.OrdinalIgnoreCase) ? null : dr.GetValue("COLUMN_DEFAULT"), IsComputed = dr.GetValue("IS_GENERATED") != "NEVER", IsIdentity = dr.GetValue("COLUMN_DEFAULT")?.StartsWith("nextval(", StringComparison.OrdinalIgnoreCase) ?? false, - IsDotNetDateOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")).Equals("DATE", StringComparison.OrdinalIgnoreCase), - IsDotNetTimeOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")).Equals("TIME WITHOUT TIME ZONE", StringComparison.OrdinalIgnoreCase) + IsDotNetDateOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")!).Equals("DATE", StringComparison.OrdinalIgnoreCase), + IsDotNetTimeOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")!).Equals("TIME WITHOUT TIME ZONE", StringComparison.OrdinalIgnoreCase) }; - c.IsJsonContent = c.Type.ToUpper() == "JSON" || (c.DotNetName == "string" && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)); + c.IsJsonContent = c.Type.Equals("JSON", StringComparison.OrdinalIgnoreCase) || (c.DotNetName == "string" && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)); if (c.IsJsonContent && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)) c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[..^JsonColumnNameSuffix.Length]); @@ -127,7 +125,7 @@ public override async Task LoadAdditionalInformationSchema(IDatabase database, L // Configure all the single column foreign keys. using var sr3 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableForeignKeys.sql", [typeof(PostgresSchemaConfig).Assembly]); - var fks = await database.SqlStatement(await sr3.ReadToEndAsync().ConfigureAwait(false)).SelectQueryAsync(dr => new + var fks = await database.SqlStatement(await sr3.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new { ConstraintName = dr.GetValue("constraint_name"), TableSchema = dr.GetValue("table_schema"), @@ -173,11 +171,11 @@ public override string ToDotNetTypeName(DbColumnSchema schema) { "TEXT" or "CHARACTER VARYING" or "CHARACTER" or "CITEXT" or "JSON" or "JSONB" or "XML" or "NAME" => "string", "NUMERIC" or "MONEY" => "decimal", - "TIMESTAMP WITHOUT TIME ZONE" or "TIMESTAMP WITH TIME ZONE" => "DateTime", - "TIME WITH TIME ZONE" => "DateTimeOffset", + "TIMESTAMP WITHOUT TIME ZONE" => "DateTime", + "TIME WITH TIME ZONE" or "TIMESTAMP WITH TIME ZONE" => "DateTimeOffset", "INTERVAL" => "TimeSpan", - "TIME WITHOUT TIME ZONE" => "TimeSpan", // TimeOnly - "DATE" => "DateTime", // DateOnly + "TIME WITHOUT TIME ZONE" => "TimeOnly", + "DATE" => "DateOnly", "BYTEA" => "byte[]", "BOOLEAN" or "BIT(1)" => "bool", "DOUBLE PRECISION" => "double", @@ -218,7 +216,7 @@ public override string ToFormattedSqlType(DbColumnSchema schema, bool includeNul bool b => b ? "true" : "false", Guid => $"uuid('{value}')", DateTime dt => $"'{dt.ToString(Migration.Args.DataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", - DateTimeOffset dto => $"'{dto.ToString(Migration.Args.DataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + DateTimeOffset dto => $"'{dto.ToString(Migration.Args.DataParserArgs.DateTimeOffsetFormat, System.Globalization.CultureInfo.InvariantCulture)}'", #if NET7_0_OR_GREATER DateOnly d => $"'{d.ToString(Migration.Args.DataParserArgs.DateOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", TimeOnly t => $"'{t.ToString(Migration.Args.DataParserArgs.TimeOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_tenant_id.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_tenant_id.sql index b48d29c..7772899 100644 --- a/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_tenant_id.sql +++ b/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_tenant_id.sql @@ -1,4 +1,4 @@ --- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/Beef +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR REPLACE FUNCTION fn_get_tenant_id( "Override" TEXT = NULL diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_timestamp.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_timestamp.sql index 008bdad..d6539c0 100644 --- a/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_timestamp.sql +++ b/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_timestamp.sql @@ -1,4 +1,4 @@ --- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/Beef +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR REPLACE FUNCTION fn_get_timestamp( "Override" TIMESTAMP WITH TIME ZONE = NULL diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_user_id.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_user_id.sql index cd1eacf..0608c63 100644 --- a/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_user_id.sql +++ b/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_user_id.sql @@ -1,4 +1,4 @@ --- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/Beef +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR REPLACE FUNCTION fn_get_user_id( "Override" TEXT = NULL diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_username.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_username.sql index 27b3d2c..46991df 100644 --- a/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_username.sql +++ b/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_username.sql @@ -1,4 +1,4 @@ --- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/Beef +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR REPLACE FUNCTION fn_get_username( "Override" TEXT = NULL diff --git a/src/DbEx.Postgres/Resources/ScriptCreate_sql.hbs b/src/DbEx.Postgres/Resources/ScriptCreate_sql.hbs index 5c1c424..0de8d98 100644 --- a/src/DbEx.Postgres/Resources/ScriptCreate_sql.hbs +++ b/src/DbEx.Postgres/Resources/ScriptCreate_sql.hbs @@ -11,7 +11,7 @@ CREATE TABLE "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" ( -- "bool" BOOLEAN NULL, -- "date" DATE NULL, "created_by" VARCHAR(250) NULL, - "created_date" TIMESTAMPTZ NULL, + "created_on" TIMESTAMPTZ NULL, "updated_by" VARCHAR(250) NULL, - "updated_date" TIMESTAMPTZ NULL + "updated_on" TIMESTAMPTZ NULL ); \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/ScriptRefData_sql.hbs b/src/DbEx.Postgres/Resources/ScriptRefData_sql.hbs index 2df69ec..8751e7e 100644 --- a/src/DbEx.Postgres/Resources/ScriptRefData_sql.hbs +++ b/src/DbEx.Postgres/Resources/ScriptRefData_sql.hbs @@ -11,7 +11,7 @@ CREATE TABLE "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" ( "is_active" BOOLEAN NULL, "sort_order" INT NULL, "created_by" VARCHAR(250) NULL, - "created_date" TIMESTAMPTZ NULL, + "created_on" TIMESTAMPTZ NULL, "updated_by" VARCHAR(250) NULL, - "updated_date" TIMESTAMPTZ NULL + "updated_on" TIMESTAMPTZ NULL ); \ No newline at end of file diff --git a/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs b/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs index abecd87..77dbdaa 100644 --- a/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs +++ b/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using DbEx.Console; using DbEx.Migration; using DbEx.SqlServer.Migration; diff --git a/src/DbEx.SqlServer/DbEx.SqlServer.csproj b/src/DbEx.SqlServer/DbEx.SqlServer.csproj index d1900d8..1cda3c6 100644 --- a/src/DbEx.SqlServer/DbEx.SqlServer.csproj +++ b/src/DbEx.SqlServer/DbEx.SqlServer.csproj @@ -1,12 +1,13 @@  - net6.0;net8.0;net9.0;netstandard2.1 + net8.0;net9.0;net10.0;netstandard2.1 DbEx.SqlServer DbEx DbEx SQL Server Migration Tool. DbEX Database Migration tool for SQL Server. dbex database dbup db-up sqlserver sql + $(NoWarn);CA1873 @@ -22,30 +23,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + diff --git a/src/DbEx.SqlServer/Migration/SqlServerDatabase.cs b/src/DbEx.SqlServer/Migration/SqlServerDatabase.cs new file mode 100644 index 0000000..b22e341 --- /dev/null +++ b/src/DbEx.SqlServer/Migration/SqlServerDatabase.cs @@ -0,0 +1,12 @@ +using DbEx.Migration; +using Microsoft.Data.SqlClient; +using System; + +namespace DbEx.SqlServer.Migration +{ + /// + /// Provides the SQL Server functionality. + /// + /// + public class SqlServerDatabase(Func create) : Database(create, SqlClientFactory.Instance) { } +} \ No newline at end of file diff --git a/src/DbEx.SqlServer/Migration/SqlServerMigration.cs b/src/DbEx.SqlServer/Migration/SqlServerMigration.cs index b301103..f9a53b0 100644 --- a/src/DbEx.SqlServer/Migration/SqlServerMigration.cs +++ b/src/DbEx.SqlServer/Migration/SqlServerMigration.cs @@ -1,7 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx.Database; -using CoreEx.Database.SqlServer; using DbEx.DbSchema; using DbEx.Migration; using DbUp.Support; @@ -91,7 +89,7 @@ protected override async Task DatabaseResetAsync(CancellationToken cancell { // Filter out temporal tables. Logger.LogInformation(" Querying database to find and filter all temporal table(s)..."); - using var sr = GetRequiredResourcesStreamReader($"DatabaseTemporal.sql", ArtefactResourceAssemblies.ToArray()); + using var sr = GetRequiredResourcesStreamReader($"DatabaseTemporal.sql", [.. ArtefactResourceAssemblies]); await Database.SqlStatement(sr.ReadToEnd()).SelectQueryAsync(dr => { _resetBypass.Add($"[{dr.GetValue("schema")}].[{dr.GetValue("table")}]"); diff --git a/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetTenantId.sql b/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetTenantId.sql index c1759b3..3324d69 100644 --- a/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetTenantId.sql +++ b/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetTenantId.sql @@ -1,8 +1,8 @@ --- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/Beef +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR ALTER FUNCTION [dbo].[fnGetTenantId] ( - @Override as NVARCHAR(1024) = null + @Override as NVARCHAR(1024) = NULL ) RETURNS NVARCHAR(1024) AS diff --git a/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetTimestamp.sql b/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetTimestamp.sql index 2441c69..61830f3 100644 --- a/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetTimestamp.sql +++ b/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetTimestamp.sql @@ -1,19 +1,19 @@ --- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/Beef +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR ALTER FUNCTION [dbo].[fnGetTimestamp] ( - @Override as datetime = null + @Override AS DATETIMEOFFSET = NULL ) -RETURNS datetime +RETURNS DATETIMEOFFSET AS BEGIN - DECLARE @Timestamp datetime + DECLARE @Timestamp DATETIMEOFFSET IF @Override IS NULL BEGIN - SET @Timestamp = CONVERT(datetime, SESSION_CONTEXT(N'Timestamp')); + SET @Timestamp = CONVERT(DATETIMEOFFSET, SESSION_CONTEXT(N'Timestamp')); IF @Timestamp IS NULL BEGIN - SET @Timestamp = SYSUTCDATETIME() + SET @Timestamp = SYSDATETIMEOFFSET() END END ELSE diff --git a/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetUserId.sql b/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetUserId.sql index 6a8e2c7..45fb5e7 100644 --- a/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetUserId.sql +++ b/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetUserId.sql @@ -1,8 +1,8 @@ --- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/Beef +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR ALTER FUNCTION [dbo].[fnGetUserId] ( - @Override as NVARCHAR(1024) = null + @Override as NVARCHAR(1024) = NULL ) RETURNS NVARCHAR(1024) AS diff --git a/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetUsername.sql b/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetUsername.sql index f231c87..1fea59a 100644 --- a/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetUsername.sql +++ b/src/DbEx.SqlServer/Resources/ExtendedSchema/Functions/fnGetUsername.sql @@ -1,8 +1,8 @@ --- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/Beef +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR ALTER FUNCTION [dbo].[fnGetUsername] ( - @Override AS NVARCHAR(1024) = null + @Override AS NVARCHAR(1024) = NULL ) RETURNS NVARCHAR(1024) AS diff --git a/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spSetSessionContext.sql b/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spSetSessionContext.sql index 318c215..b4fd08a 100644 --- a/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spSetSessionContext.sql +++ b/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spSetSessionContext.sql @@ -1,10 +1,10 @@ -- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR ALTER PROCEDURE [dbo].[spSetSessionContext] - @Timestamp DATETIME2 = null, - @Username NVARCHAR(1024) = null, - @TenantId NVARCHAR(1024) = null, - @UserId NVARCHAR(1024) = null + @Timestamp DATETIMEOFFSET = NULL, + @Username NVARCHAR(1024) = NULL, + @TenantId NVARCHAR(1024) = NULL, + @UserId NVARCHAR(1024) = NULL AS BEGIN IF @Timestamp IS NOT NULL diff --git a/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowBusinessException.sql b/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowBusinessException.sql index 71d80ab..b531ff4 100644 --- a/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowBusinessException.sql +++ b/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowBusinessException.sql @@ -1,4 +1,4 @@ --- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/Beef +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR ALTER PROCEDURE [dbo].[spThrowBusinessException] @Message NVARCHAR(2048) = NULL diff --git a/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowConcurrencyException.sql b/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowConcurrencyException.sql index 3f7c880..2e83f04 100644 --- a/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowConcurrencyException.sql +++ b/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowConcurrencyException.sql @@ -1,4 +1,4 @@ --- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/Beef +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR ALTER PROCEDURE [dbo].[spThrowConcurrencyException] @Message NVARCHAR(2048) = NULL diff --git a/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowConflictException.sql b/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowConflictException.sql index 57477a2..40d7aa3 100644 --- a/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowConflictException.sql +++ b/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowConflictException.sql @@ -1,4 +1,4 @@ --- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/Beef +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR ALTER PROCEDURE [dbo].[spThrowConflictException] @Message NVARCHAR(2048) = NULL diff --git a/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowDuplicateException.sql b/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowDuplicateException.sql index 3fa7852..26d4c9c 100644 --- a/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowDuplicateException.sql +++ b/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowDuplicateException.sql @@ -1,4 +1,4 @@ --- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/Beef +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR ALTER PROCEDURE [dbo].[spThrowDuplicateException] @Message NVARCHAR(2048) = NULL diff --git a/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowNotFoundException.sql b/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowNotFoundException.sql index 3cf33e1..7d73db2 100644 --- a/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowNotFoundException.sql +++ b/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowNotFoundException.sql @@ -1,4 +1,4 @@ --- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/Beef +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR ALTER PROCEDURE [dbo].[spThrowNotFoundException] @Message NVARCHAR(2048) = NULL diff --git a/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowValidationException.sql b/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowValidationException.sql index 7c29d7a..c8a4ad6 100644 --- a/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowValidationException.sql +++ b/src/DbEx.SqlServer/Resources/ExtendedSchema/Stored Procedures/spThrowValidationException.sql @@ -1,4 +1,4 @@ --- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/Beef +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx CREATE OR ALTER PROCEDURE [dbo].[spThrowValidationException] @Message NVARCHAR(2048) = NULL diff --git a/src/DbEx.SqlServer/Resources/ScriptCreate_sql.hbs b/src/DbEx.SqlServer/Resources/ScriptCreate_sql.hbs index a4973c0..43f8d1e 100644 --- a/src/DbEx.SqlServer/Resources/ScriptCreate_sql.hbs +++ b/src/DbEx.SqlServer/Resources/ScriptCreate_sql.hbs @@ -14,9 +14,9 @@ CREATE TABLE [{{lookup Parameters 'Param1'}}].[{{lookup Parameters 'Param2'}}] ( -- [Date] DATE NULL, [RowVersion] TIMESTAMP NOT NULL, [CreatedBy] NVARCHAR(250) NULL, - [CreatedDate] DATETIME2 NULL, + [CreatedOn] DATETIMEOFFSET NULL, [UpdatedBy] NVARCHAR(250) NULL, - [UpdatedDate] DATETIME2 NULL + [UpdatedOn] DATETIMEOFFSET NULL ); COMMIT TRANSACTION \ No newline at end of file diff --git a/src/DbEx.SqlServer/Resources/ScriptRefData_sql.hbs b/src/DbEx.SqlServer/Resources/ScriptRefData_sql.hbs index dd264e1..0b7ef86 100644 --- a/src/DbEx.SqlServer/Resources/ScriptRefData_sql.hbs +++ b/src/DbEx.SqlServer/Resources/ScriptRefData_sql.hbs @@ -14,9 +14,9 @@ CREATE TABLE [{{lookup Parameters 'Param1'}}].[{{lookup Parameters 'Param2'}}] ( [SortOrder] INT NULL, [RowVersion] TIMESTAMP NOT NULL, [CreatedBy] NVARCHAR(250) NULL, - [CreatedDate] DATETIME2 NULL, + [CreatedOn] DATETIMEOFFSET NULL, [UpdatedBy] NVARCHAR(250) NULL, - [UpdatedDate] DATETIME2 NULL + [UpdatedOn] DATETIMEOFFSET NULL ); COMMIT TRANSACTION \ No newline at end of file diff --git a/src/DbEx.SqlServer/SqlServerSchemaConfig.cs b/src/DbEx.SqlServer/SqlServerSchemaConfig.cs index 8f0d2e9..b7d16de 100644 --- a/src/DbEx.SqlServer/SqlServerSchemaConfig.cs +++ b/src/DbEx.SqlServer/SqlServerSchemaConfig.cs @@ -1,7 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; -using CoreEx.Database; using DbEx.DbSchema; using DbEx.Migration; using DbEx.SqlServer.Migration; @@ -33,16 +31,16 @@ public class SqlServerSchemaConfig(SqlServerMigration migration) : DatabaseSchem public override string JsonColumnNameSuffix => "Json"; /// - /// Value is 'CreatedDate'. - public override string CreatedDateColumnName => "CreatedDate"; + /// Value is 'CreatedOn'. + public override string CreatedOnColumnName => "CreatedOn"; /// /// Value is 'CreatedBy'. public override string CreatedByColumnName => "CreatedBy"; /// - /// Value is 'UpdatedDate'. - public override string UpdatedDateColumnName => "UpdatedDate"; + /// Value is 'UpdatedOn'. + public override string UpdatedOnColumnName => "UpdatedOn"; /// /// Value is 'UpdatedBy'. @@ -83,15 +81,15 @@ public override void PrepareMigrationArgs() /// public override DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema table, DatabaseRecord dr) { - var c = new DbColumnSchema(table, dr.GetValue("COLUMN_NAME"), dr.GetValue("DATA_TYPE")) + var c = new DbColumnSchema(table, dr.GetValue("COLUMN_NAME")!, dr.GetValue("DATA_TYPE")!) { - IsNullable = dr.GetValue("IS_NULLABLE").Equals("YES", StringComparison.OrdinalIgnoreCase), + IsNullable = dr.GetValue("IS_NULLABLE")!.Equals("YES", StringComparison.OrdinalIgnoreCase), Length = (ulong?)(dr.GetValue("CHARACTER_MAXIMUM_LENGTH") <= 0 ? null : dr.GetValue("CHARACTER_MAXIMUM_LENGTH")), Precision = (ulong?)(dr.GetValue("NUMERIC_PRECISION") ?? dr.GetValue("DATETIME_PRECISION")), Scale = (ulong?)dr.GetValue("NUMERIC_SCALE"), DefaultValue = dr.GetValue("COLUMN_DEFAULT"), - IsDotNetDateOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")).Equals("DATE", StringComparison.OrdinalIgnoreCase), - IsDotNetTimeOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")).Equals("TIME", StringComparison.OrdinalIgnoreCase), + IsDotNetDateOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")!).Equals("DATE", StringComparison.OrdinalIgnoreCase), + IsDotNetTimeOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")!).Equals("TIME", StringComparison.OrdinalIgnoreCase), }; if (c.IsJsonContent = c.DotNetName == "string" && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)) @@ -214,8 +212,8 @@ public override string ToDotNetTypeName(DbColumnSchema schema) "DECIMAL" or "MONEY" or "NUMERIC" or "SMALLMONEY" => "decimal", "DATETIME" or "DATETIME2" or "SMALLDATETIME" => "DateTime", "DATETIMEOFFSET" => "DateTimeOffset", - "DATE" => "DateTime", // Date only - "TIME" => "TimeSpan", // Time only + "DATE" => "DateOnly", + "TIME" => "TimeOnly", "ROWVERSION" or "TIMESTAMP" or "BINARY" or "VARBINARY" or "IMAGE" => "byte[]", "BIT" => "bool", "FLOAT" => "double", @@ -258,7 +256,7 @@ public override string ToFormattedSqlType(DbColumnSchema schema, bool includeNul bool b => b ? "1" : "0", Guid => $"CONVERT(UNIQUEIDENTIFIER, '{value}')", DateTime dt => $"'{dt.ToString(Migration.Args.DataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", - DateTimeOffset dto => $"'{dto.ToString(Migration.Args.DataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + DateTimeOffset dto => $"'{dto.ToString(Migration.Args.DataParserArgs.DateTimeOffsetFormat, System.Globalization.CultureInfo.InvariantCulture)}'", #if NET7_0_OR_GREATER DateOnly d => $"'{d.ToString(Migration.Args.DataParserArgs.DateOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", TimeOnly t => $"'{t.ToString(Migration.Args.DataParserArgs.TimeOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", diff --git a/src/DbEx/Console/AssemblyValidator.cs b/src/DbEx/Console/AssemblyValidator.cs index ffdaaf5..a9faf42 100644 --- a/src/DbEx/Console/AssemblyValidator.cs +++ b/src/DbEx/Console/AssemblyValidator.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using DbEx.Migration; using McMaster.Extensions.CommandLineUtils; using McMaster.Extensions.CommandLineUtils.Validation; diff --git a/src/DbEx/Console/MigrationConsoleBase.cs b/src/DbEx/Console/MigrationConsoleBase.cs index fc121ef..bbe269e 100644 --- a/src/DbEx/Console/MigrationConsoleBase.cs +++ b/src/DbEx/Console/MigrationConsoleBase.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using DbEx.Migration; using McMaster.Extensions.CommandLineUtils; using Microsoft.Extensions.Logging; @@ -26,7 +25,8 @@ namespace DbEx.Console /// , and . Changes to the console output can be achieved by overriding /// , , and . /// The underlying command line parsing is provided by . - public abstract class MigrationConsoleBase + /// The default that will be overridden/updated by the command-line argument values. + public abstract class MigrationConsoleBase(MigrationArgsBase args) { private static readonly string[] memberNames = ["args"]; private const string EntryAssemblyOnlyOptionName = "entry-assembly-only"; @@ -36,16 +36,10 @@ public abstract class MigrationConsoleBase private CommandArgument? _additionalArgs; private CommandOption? _helpOption; - /// - /// Initializes a new instance of the class. - /// - /// The default that will be overridden/updated by the command-line argument values. - protected MigrationConsoleBase(MigrationArgsBase args) => Args = args.ThrowIfNull(nameof(args)); - /// /// Gets the . /// - public MigrationArgsBase Args { get; } + public MigrationArgsBase Args { get; } = args.ThrowIfNull(nameof(args)); /// /// Gets the application/command name. diff --git a/src/DbEx/Console/MigrationConsoleBaseT.cs b/src/DbEx/Console/MigrationConsoleBaseT.cs index 9e88619..920a5ba 100644 --- a/src/DbEx/Console/MigrationConsoleBaseT.cs +++ b/src/DbEx/Console/MigrationConsoleBaseT.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using DbEx.Migration; using System; using System.Collections.Generic; @@ -13,14 +12,9 @@ namespace DbEx.Console /// Base console that facilitates the by managing the standard console command-line arguments/options. /// /// The itself being implemented. - public abstract class MigrationConsoleBase : MigrationConsoleBase where TSelf : MigrationConsoleBase + /// The default that will be overridden/updated by the command-line argument values. + public abstract class MigrationConsoleBase(MigrationArgsBase args) : MigrationConsoleBase(args) where TSelf : MigrationConsoleBase { - /// - /// Initializes a new instance of the class. - /// - /// The default that will be overridden/updated by the command-line argument values. - protected MigrationConsoleBase(MigrationArgsBase args) : base(args) { } - /// /// Enables fluent-style method-chaining configuration of /// diff --git a/src/DbEx/Console/ParametersValidator.cs b/src/DbEx/Console/ParametersValidator.cs index f527b91..ec0e7dc 100644 --- a/src/DbEx/Console/ParametersValidator.cs +++ b/src/DbEx/Console/ParametersValidator.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using DbEx.Migration; using McMaster.Extensions.CommandLineUtils; using McMaster.Extensions.CommandLineUtils.Validation; @@ -31,7 +30,7 @@ public ValidationResult GetValidationResult(CommandOption option, ValidationCont foreach (var p in option.Values.Where(x => !string.IsNullOrEmpty(x))) { - var pos = p!.IndexOf("=", StringComparison.Ordinal); + var pos = p!.IndexOf('=', StringComparison.Ordinal); if (pos <= 0) AddParameter(p, null); else diff --git a/src/DbEx/DatabaseExtensions.cs b/src/DbEx/DatabaseExtensions.cs index 4029f5b..db3a18e 100644 --- a/src/DbEx/DatabaseExtensions.cs +++ b/src/DbEx/DatabaseExtensions.cs @@ -1,15 +1,14 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; -using CoreEx.Database; -using DbEx.DbSchema; using DbEx.Migration; using System; using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DbEx.SqlServer, PublicKey=00240000048000009400000006020000002400005253413100040000010001007dee530af6d801902d40685e9cd0a3d8991ddbf545be3ef6147c9f79bacd7464d92fbd94fee34e885c37e3dff4ea15a4f9978f1f614798e0f48e3a3d5bf15e8b2fba9c19b6966838f97444bc247bc101454946d70ac93207cf2c611956aed59c316f81f1bf8c8486f8f0b3f9adf93c2f07e06a86745f6dc4b819c2bc2f3fdad5")] +[assembly: InternalsVisibleTo("DbEx.Postgres, PublicKey=00240000048000009400000006020000002400005253413100040000010001007dee530af6d801902d40685e9cd0a3d8991ddbf545be3ef6147c9f79bacd7464d92fbd94fee34e885c37e3dff4ea15a4f9978f1f614798e0f48e3a3d5bf15e8b2fba9c19b6966838f97444bc247bc101454946d70ac93207cf2c611956aed59c316f81f1bf8c8486f8f0b3f9adf93c2f07e06a86745f6dc4b819c2bc2f3fdad5")] +[assembly: InternalsVisibleTo("DbEx.MySql, PublicKey=00240000048000009400000006020000002400005253413100040000010001007dee530af6d801902d40685e9cd0a3d8991ddbf545be3ef6147c9f79bacd7464d92fbd94fee34e885c37e3dff4ea15a4f9978f1f614798e0f48e3a3d5bf15e8b2fba9c19b6966838f97444bc247bc101454946d70ac93207cf2c611956aed59c316f81f1bf8c8486f8f0b3f9adf93c2f07e06a86745f6dc4b819c2bc2f3fdad5")] namespace DbEx { @@ -20,186 +19,84 @@ public static class DatabaseExtensions { private static readonly char[] _snakeCamelCaseSeparatorChars = ['_', '-']; +#if NET6_0_OR_GREATER /// - /// Selects all the table and column schema details from the database. + /// Throws an if the is null. /// - /// The . - /// The . - /// The . - /// A list of all the table and column schema details. - public static async Task> SelectSchemaAsync(this IDatabase database, DatabaseMigrationBase migration, CancellationToken cancellationToken = default) + /// The . + /// The value to validate as non-null. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + internal static T ThrowIfNull([NotNull] this T? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) { - database.ThrowIfNull(nameof(database)); - migration.ThrowIfNull(nameof(migration)); - - migration.PreExecutionInitialization(); - - var tables = new List(); - DbTableSchema? table = null; - - var refDataPredicate = new Func(t => t.Columns.Any(c => c.Name == migration.Args.RefDataCodeColumnName! && !c.IsPrimaryKey && c.DotNetType == "string") && t.Columns.Any(c => c.Name == migration.Args.RefDataTextColumnName && !c.IsPrimaryKey && c.DotNetType == "string")); - - // Get all the tables and their columns. - var probeAssemblies = new[] { migration.SchemaConfig.GetType().Assembly, typeof(DatabaseExtensions).Assembly }; - using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableAndColumns.sql", probeAssemblies); - await database.SqlStatement(await migration.ReadSqlAsync(sr, cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => - { - if (!migration.SchemaConfig.SupportsSchema && dr.GetValue("TABLE_SCHEMA") != migration.DatabaseName) - return 0; - - var dt = new DbTableSchema(migration, dr.GetValue("TABLE_SCHEMA"), dr.GetValue("TABLE_NAME")) - { - IsAView = dr.GetValue("TABLE_TYPE") == "VIEW" - }; - - if (table == null || table.Schema != dt.Schema || table.Name != dt.Name) - tables.Add(table = dt); - - var dc = migration.SchemaConfig.CreateColumnFromInformationSchema(table, dr); - dc.IsCreatedAudit = dc.Name == migration.Args?.CreatedByColumnName || dc.Name == migration.Args?.CreatedDateColumnName; - dc.IsUpdatedAudit = dc.Name == migration.Args?.UpdatedByColumnName || dc.Name == migration.Args?.UpdatedDateColumnName; - dc.IsTenantId = dc.Name == migration.Args?.TenantIdColumnName; - dc.IsRowVersion = dc.Name == migration.Args?.RowVersionColumnName; - dc.IsIsDeleted = dc.Name == migration.Args?.IsDeletedColumnName; - - table.Columns.Add(dc); - return 0; - }, cancellationToken).ConfigureAwait(false); - - // Exit where no tables initially found. - if (tables.Count == 0) - return tables; - - // Determine whether a table is considered reference data. - foreach (var t in tables) - { - t.IsRefData = refDataPredicate(t); - if (t.IsRefData) - t.RefDataCodeColumn = t.Columns.Where(x => x.Name == migration.Args.RefDataCodeColumnName).SingleOrDefault(); - } - - // Configure all the single column primary and unique constraints. - using var sr2 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTablePrimaryKey.sql", probeAssemblies); - var pks = await database.SqlStatement(await migration.ReadSqlAsync(sr2, cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new - { - ConstraintName = dr.GetValue("CONSTRAINT_NAME"), - TableSchema = dr.GetValue("TABLE_SCHEMA"), - TableName = dr.GetValue("TABLE_NAME"), - TableColumnName = dr.GetValue("COLUMN_NAME"), - IsPrimaryKey = dr.GetValue("CONSTRAINT_TYPE").StartsWith("PRIMARY", StringComparison.OrdinalIgnoreCase) - }, cancellationToken).ConfigureAwait(false); - - if (!migration.SchemaConfig.SupportsSchema) - pks = pks.Where(x => x.TableSchema == migration.DatabaseName).ToArray(); - - foreach (var grp in pks.GroupBy(x => new { x.ConstraintName, x.TableSchema, x.TableName })) - { - // Only single column unique columns are supported. - if (grp.Count() > 1 && !grp.First().IsPrimaryKey) - continue; - - // Set the column flags as appropriate. - foreach (var pk in grp) - { - var col = (from t in tables - from c in t.Columns - where (!migration.SchemaConfig.SupportsSchema || t.Schema == pk.TableSchema) && t.Name == pk.TableName && c.Name == pk.TableColumnName - select c).SingleOrDefault(); - - if (col == null) - continue; - - if (pk.IsPrimaryKey) - { - col.IsPrimaryKey = true; - if (!col.IsIdentity) - col.IsIdentity = col.DefaultValue != null; - } - else - col.IsUnique = true; - } - } - - // Load any additional configuration specific to the database provider. - await migration.SchemaConfig.LoadAdditionalInformationSchema(database, tables, cancellationToken).ConfigureAwait(false); - - // Attempt to infer foreign key reference data relationship where not explicitly specified. - foreach (var t in tables) - { - foreach (var c in t.Columns.Where(x => !x.IsPrimaryKey)) - { - if (c.ForeignTable != null) - { - if (c.IsForeignRefData) - { - c.ForeignRefDataCodeColumn = migration.Args.RefDataCodeColumnName; - if (c.Name.EndsWith(migration.Args.IdColumnNameSuffix!, StringComparison.Ordinal)) - c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[0..^migration.Args.IdColumnNameSuffix!.Length]); - } + ArgumentNullException.ThrowIfNull(value, paramName); + return value; + } +#else + /// + /// Throws an if the is null. + /// + /// The . + /// The value to validate as non-null. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + internal static T ThrowIfNull([NotNull] this T? value, string? paramName = "value") + { + if (value is null) + throw new ArgumentNullException(paramName); + + return value; + } +#endif - continue; - } +#if NET7_0_OR_GREATER + /// + /// Throws an if the is null or . + /// + /// The value to validate as non-null. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + internal static string ThrowIfNullOrEmpty([NotNull] this string? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) + { + ArgumentException.ThrowIfNullOrEmpty(value, paramName); + return value; + } +#else + /// + /// Throws an if the is null or . + /// + /// The value to validate as non-null. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + internal static string ThrowIfNullOrEmpty([NotNull] this string? value, string? paramName = "value") + { + if (string.IsNullOrEmpty(value)) + throw new ArgumentNullException(paramName); - if (!c.Name.EndsWith(migration.Args.IdColumnNameSuffix!, StringComparison.Ordinal)) - continue; + return value; + } +#endif - // Find table with same name as column in any schema that is considered reference data and has a single primary key. - var fk = tables.Where(x => x != t && x.Name == c.Name[0..^migration.Args.IdColumnNameSuffix!.Length] && x.IsRefData && x.PrimaryKeyColumns.Count == 1).FirstOrDefault(); - if (fk == null) - continue; + /// + /// Performs the specified on each element in the sequence. + /// + /// The item . + /// The sequence to iterate. + /// The action to perform on each element. + /// The sequence. + internal static IEnumerable ForEach(this IEnumerable sequence, Action action) + { + if (sequence == null) + return sequence!; - c.ForeignSchema = fk.Schema; - c.ForeignTable = fk.Name; - c.ForeignColumn = fk.PrimaryKeyColumns[0].Name; - c.IsForeignRefData = true; - c.ForeignRefDataCodeColumn = migration.Args.RefDataCodeColumnName; - c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[0..^migration.Args.IdColumnNameSuffix!.Length]); - } - } + action.ThrowIfNull(nameof(action)); - // Attempt to infer if a reference data column where not explicitly specified. - foreach (var t in tables) + foreach (TItem element in sequence.ThrowIfNull(nameof(sequence))) { - foreach (var c in t.Columns.Where(x => !x.IsPrimaryKey)) - { - if (c.IsForeignRefData) - { - c.IsRefData = true; - continue; - } - - // Find possible name by removing suffix by-convention. - string name; - if (c.Name.EndsWith(migration.Args.IdColumnNameSuffix!, StringComparison.Ordinal)) - name = c.Name[0..^migration.Args.IdColumnNameSuffix!.Length]; - else if (c.Name.EndsWith(migration.Args.CodeColumnNameSuffix!, StringComparison.Ordinal)) - name = c.Name[0..^migration.Args.CodeColumnNameSuffix!.Length]; - else - continue; - - // Is there a table match of same name that is considered reference data; if so, consider ref data. - if (tables.Any(x => x.Name == name && x.Schema == t.Schema && x.IsRefData)) - { - c.IsRefData = true; - c.DotNetCleanedName = DbTableSchema.CreateDotNetName(name); - } - } + action(element); } - return tables; - } - - /// - /// Gets the SQL statement from the embedded resource stream - /// - private async static Task ReadSqlAsync(this DatabaseMigrationBase migration, StreamReader sr, CancellationToken cancellationToken) - { -#if NET7_0_OR_GREATER - var sql = await sr.ThrowIfNull(nameof(sr)).ReadToEndAsync(cancellationToken).ConfigureAwait(false); -#else - var sql = await sr.ThrowIfNull(nameof(sr)).ReadToEndAsync().ConfigureAwait(false); -#endif - return sql.Replace("{{DatabaseName}}", migration.DatabaseName); + return sequence; } } } \ No newline at end of file diff --git a/src/DbEx/DatabaseSchemaConfig.cs b/src/DbEx/DatabaseSchemaConfig.cs index 50b1e69..f8a1381 100644 --- a/src/DbEx/DatabaseSchemaConfig.cs +++ b/src/DbEx/DatabaseSchemaConfig.cs @@ -1,13 +1,11 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; -using CoreEx.Database; -using CoreEx.Entities; -using CoreEx.RefData; using DbEx.DbSchema; using DbEx.Migration; +using DbEx.Migration.Data; using System; using System.Collections.Generic; +using System.Runtime.Serialization; using System.Threading; using System.Threading.Tasks; @@ -59,47 +57,47 @@ public abstract class DatabaseSchemaConfig(DatabaseMigrationBase migration, bool public abstract string JsonColumnNameSuffix { get; } /// - /// Gets the name of the column (where it exists). + /// Gets the name of the CreatedOn audit column (where it exists). /// - public abstract string CreatedDateColumnName { get; } + public abstract string CreatedOnColumnName { get; } /// - /// Gets the name of the column (where it exists). + /// Gets the name of the CreatedBy audit column (where it exists). /// public abstract string CreatedByColumnName { get; } /// - /// Gets the name of the column (where it exists). + /// Gets the name of the UpdatedOn audit column (where it exists). /// - public abstract string UpdatedDateColumnName { get; } + public abstract string UpdatedOnColumnName { get; } /// - /// Gets the name of the column (where it exists). + /// Gets the name of the UpdatedBy audit column (where it exists). /// public abstract string UpdatedByColumnName { get; } /// - /// Gets the name of the column (where it exists). + /// Gets the name of the TenantId column (where it exists). /// public abstract string TenantIdColumnName { get; } /// - /// Gets the name of the row-version ( equivalent) column (where it exists). + /// Gets the name of the row-version (ETag) column (where it exists). /// public abstract string RowVersionColumnName { get; } /// - /// Gets the default column. + /// Gets the name of the logically IsDeleted column (where it exists). /// public abstract string IsDeletedColumnName { get; } /// - /// Gets the default column. + /// Gets the name of the reference-data code column (where it exists); /// public abstract string RefDataCodeColumnName { get; } /// - /// Gets the default column. + /// Gets the name of the reference-data text column (where it exists); /// public abstract string RefDataTextColumnName { get; } @@ -114,9 +112,9 @@ public virtual void PrepareMigrationArgs() Migration.Args.CodeColumnNameSuffix ??= CodeColumnNameSuffix; Migration.Args.JsonColumnNameSuffix ??= JsonColumnNameSuffix; Migration.Args.CreatedByColumnName ??= CreatedByColumnName; - Migration.Args.CreatedDateColumnName ??= CreatedDateColumnName; + Migration.Args.CreatedOnColumnName ??= CreatedOnColumnName; Migration.Args.UpdatedByColumnName ??= UpdatedByColumnName; - Migration.Args.UpdatedDateColumnName ??= UpdatedDateColumnName; + Migration.Args.UpdatedOnColumnName ??= UpdatedOnColumnName; Migration.Args.TenantIdColumnName ??= TenantIdColumnName; Migration.Args.RowVersionColumnName ??= RowVersionColumnName; Migration.Args.IsDeletedColumnName ??= IsDeletedColumnName; @@ -165,14 +163,39 @@ public virtual void PrepareMigrationArgs() /// The . /// Indicates whether to include the nullability within the formatted value. /// The long-form formatted SQL type. + /// This resulting text is intended for usage within SQL statements. public abstract string ToFormattedSqlType(DbColumnSchema schema, bool includeNullability = true); /// - /// Gets the formatted SQL statement representation of the . + /// Gets the formatted text representation of the used for parsing (see ). /// - /// The . + /// The . /// The value. /// The formatted SQL statement representation. - public abstract string ToFormattedSqlStatementValue(DbColumnSchema dbColumnSchema, object? value); + /// This resulting text is intended for usage within JSON/YAML data formatting/parsing. + public virtual string ToFormattedDataParserValue(DataParserArgs args, object? value) + { + args.ThrowIfNull(nameof(args)); + + return value switch + { + DateTime dt => dt.ToString(args.DateTimeFormat), + DateTimeOffset dto => dto.ToString(args.DateTimeOffsetFormat), +#if NET7_0_OR_GREATER + DateOnly d => d.ToString(args.DateOnlyFormat), + TimeOnly t => t.ToString(args.TimeOnlyFormat), +#endif + _ => value?.ToString() ?? string.Empty, + }; + } + + /// + /// Gets the formatted SQL statement representation (where required) of the . + /// + /// The . + /// The value. + /// The formatted SQL statement representation. + /// This resulting text is intended for usage when generating/outputting SQL statements. + public abstract string ToFormattedSqlStatementValue(DbColumnSchema schema, object? value); } } \ No newline at end of file diff --git a/src/DbEx/DbEx.csproj b/src/DbEx/DbEx.csproj index 18dc7ab..55a31fc 100644 --- a/src/DbEx/DbEx.csproj +++ b/src/DbEx/DbEx.csproj @@ -1,12 +1,13 @@  - net6.0;net8.0;net9.0;netstandard2.1 + net8.0;net9.0;net10.0;netstandard2.1 DbEx DbEx DbEx Database Migration Tool. DbEX Database Migration tool base capabilities. dbex database dbup db-up data-migration + $(NoWarn);CA1873 @@ -20,7 +21,7 @@ - + diff --git a/src/DbEx/DbSchema/DbColumnSchema.cs b/src/DbEx/DbSchema/DbColumnSchema.cs index cff331b..01aa4ae 100644 --- a/src/DbEx/DbSchema/DbColumnSchema.cs +++ b/src/DbEx/DbSchema/DbColumnSchema.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using System; using System.Diagnostics; diff --git a/src/DbEx/DbSchema/DbTableSchema.cs b/src/DbEx/DbSchema/DbTableSchema.cs index f18218f..d946c82 100644 --- a/src/DbEx/DbSchema/DbTableSchema.cs +++ b/src/DbEx/DbSchema/DbTableSchema.cs @@ -1,8 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; -using CoreEx.RefData; -using CoreEx.Text; using DbEx.Migration; using OnRamp.Utility; using System; @@ -36,7 +33,7 @@ public static string CreateAlias(string name) { name.ThrowIfNullOrEmpty(nameof(name)); var s = StringConverter.ToSentenceCase(name)!; - return new string(s.Replace(" ", " ").Replace("_", " ").Replace("-", " ").Split(' ').Where(x => !string.IsNullOrEmpty(x)).Select(x => x[..1].ToLower(System.Globalization.CultureInfo.InvariantCulture).ToCharArray()[0]).ToArray()); + return new string([.. s.Replace(" ", " ").Replace("_", " ").Replace("-", " ").Split(' ').Where(x => !string.IsNullOrEmpty(x)).Select(x => x[..1].ToLower(System.Globalization.CultureInfo.InvariantCulture).ToCharArray()[0])]); } /// @@ -61,7 +58,7 @@ public static string CreateDotNetName(string name) public static string CreatePluralName(string name) { name.ThrowIfNullOrEmpty(nameof(name)); - var words = SentenceCase.SplitIntoWords(name).Where(x => !string.IsNullOrEmpty(x)).ToList(); + var words = StringConverter.ToSentenceCase(name)!.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(x => !string.IsNullOrEmpty(x)).ToList(); words[^1] = StringConverter.ToPlural(words[^1]); return string.Join(string.Empty, words); } @@ -74,7 +71,7 @@ public static string CreatePluralName(string name) public static string CreateSingularName(string name) { name.ThrowIfNullOrEmpty(nameof(name)); - var words = SentenceCase.SplitIntoWords(name).Where(x => !string.IsNullOrEmpty(x)).ToList(); + var words = StringConverter.ToSentenceCase(name)!.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(x => !string.IsNullOrEmpty(x)).ToList(); words[^1] = StringConverter.ToSingle(words[^1]); return string.Join(string.Empty, words); } @@ -194,7 +191,7 @@ public DbTableSchema(DbTableSchema table) public bool HasAuditColumns => Columns?.Any(x => x.IsCreatedAudit || x.IsUpdatedAudit) ?? false; /// - /// Gets or sets the . + /// Gets or sets the reference-data code . /// public DbColumnSchema? RefDataCodeColumn { get; set; } diff --git a/src/DbEx/Migration/Data/DataColumn.cs b/src/DbEx/Migration/Data/DataColumn.cs index 894b0fc..bb94737 100644 --- a/src/DbEx/Migration/Data/DataColumn.cs +++ b/src/DbEx/Migration/Data/DataColumn.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using DbEx.DbSchema; using System; diff --git a/src/DbEx/Migration/Data/DataParser.cs b/src/DbEx/Migration/Data/DataParser.cs index 7fc489c..39aee63 100644 --- a/src/DbEx/Migration/Data/DataParser.cs +++ b/src/DbEx/Migration/Data/DataParser.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using DbEx.DbSchema; using HandlebarsDotNet; using System; diff --git a/src/DbEx/Migration/Data/DataParserArgs.cs b/src/DbEx/Migration/Data/DataParserArgs.cs index ff9b287..ecf73e7 100644 --- a/src/DbEx/Migration/Data/DataParserArgs.cs +++ b/src/DbEx/Migration/Data/DataParserArgs.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using DbEx.DbSchema; using System; using System.Collections.Generic; @@ -33,10 +32,10 @@ public DataParserArgs() { } public string UserName { get; set; } = Environment.UserDomainName == null ? Environment.UserName : $"{Environment.UserDomainName}\\{Environment.UserName}"; /// - /// Gets or sets the current . + /// Gets or sets the current . /// - /// Defaults to . - public DateTime DateTimeNow { get; set; } = DateTime.UtcNow; + /// Defaults to . + public DateTimeOffset DateTimeNow { get; set; } = DateTimeOffset.UtcNow; /// /// Gets or sets the . @@ -50,6 +49,12 @@ public DataParserArgs() { } /// Defaults to 'yyyy-MM-ddTHH:mm:ss.FFFFFFF'. public string DateTimeFormat { get; set; } = "yyyy-MM-ddTHH:mm:ss.FFFFFFF"; + /// + /// Gets or sets the format. + /// + /// Defaults to 'yyyy-MM-ddTHH:mm:ss.FFFFFFFZ'. + public string DateTimeOffsetFormat { get; set; } = "yyyy-MM-ddTHH:mm:ss.FFFFFFFZ"; + /// /// Gets or sets the format. /// @@ -123,7 +128,7 @@ public DataParserArgs Parameter(string key, object? value, bool overrideExisting /// /// Gets or sets the updater. /// - /// This is invoked offering an opportunity to further update (manipulate) the selected from the database using the . + /// This is invoked offering an opportunity to further update (manipulate) the selected from the database using the . public Func, CancellationToken, Task>>? DbSchemaUpdaterAsync { get; set; } /// @@ -148,6 +153,9 @@ public void CopyFrom(DataParserArgs args) DateTimeNow = args.DateTimeNow; IdentifierGenerator = args.IdentifierGenerator; DateTimeFormat = args.DateTimeFormat; + DateTimeOffsetFormat = args.DateTimeOffsetFormat; + DateOnlyFormat = args.DateOnlyFormat; + TimeOnlyFormat = args.TimeOnlyFormat; DbSchemaUpdaterAsync = args.DbSchemaUpdaterAsync; RefDataColumnDefaults.Clear(); args.RefDataColumnDefaults.ForEach(x => RefDataColumnDefaults.Add(x.Key, x.Value)); diff --git a/src/DbEx/Migration/Data/DataParserColumnDefault.cs b/src/DbEx/Migration/Data/DataParserColumnDefault.cs index b085572..d9c2826 100644 --- a/src/DbEx/Migration/Data/DataParserColumnDefault.cs +++ b/src/DbEx/Migration/Data/DataParserColumnDefault.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using System; namespace DbEx.Migration.Data diff --git a/src/DbEx/Migration/Data/DataParserColumnDefaultCollection.cs b/src/DbEx/Migration/Data/DataParserColumnDefaultCollection.cs index 0fde106..b631d0b 100644 --- a/src/DbEx/Migration/Data/DataParserColumnDefaultCollection.cs +++ b/src/DbEx/Migration/Data/DataParserColumnDefaultCollection.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using DbEx.DbSchema; using System; using System.Collections.ObjectModel; diff --git a/src/DbEx/Migration/Data/DataParserTableNameMappings.cs b/src/DbEx/Migration/Data/DataParserTableNameMappings.cs index 5c0ac61..82aae56 100644 --- a/src/DbEx/Migration/Data/DataParserTableNameMappings.cs +++ b/src/DbEx/Migration/Data/DataParserTableNameMappings.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using System; using System.Collections; using System.Collections.Generic; diff --git a/src/DbEx/Migration/Data/DataRow.cs b/src/DbEx/Migration/Data/DataRow.cs index 6a0e3e3..a6b364f 100644 --- a/src/DbEx/Migration/Data/DataRow.cs +++ b/src/DbEx/Migration/Data/DataRow.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using System; using System.Collections.Generic; using System.Linq; @@ -31,17 +30,17 @@ public class DataRow /// /// Gets the insert columns. /// - public List InsertColumns => Columns.Where(c => Table.InsertColumns.Any(x => x.Name == c.Name)).ToList(); + public List InsertColumns => [.. Columns.Where(c => Table.InsertColumns.Any(x => x.Name == c.Name))]; /// /// Gets the columns that are used for the merge insert. /// - public List MergeInsertColumns => Columns.Where(c => Table.MergeInsertColumns.Any(x => x.Name == c.Name)).ToList(); + public List MergeInsertColumns => [.. Columns.Where(c => Table.MergeInsertColumns.Any(x => x.Name == c.Name))]; /// /// Gets the columns that are used for the merge update. /// - public List MergeUpdateColumns => Columns.Where(c => Table.MergeUpdateColumns.Any(x => x.Name == c.Name)).ToList(); + public List MergeUpdateColumns => [.. Columns.Where(c => Table.MergeUpdateColumns.Any(x => x.Name == c.Name))]; /// /// Adds a to the row using the specified name and value. @@ -83,7 +82,7 @@ public void AddColumn(DataColumn column) string? str = null; try { - str = column.Value is DateTime time ? time.ToString(Table.Parser.ParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture) : column.Value.ToString()!; + str = column.Table.DbTable.Migration.SchemaConfig.ToFormattedDataParserValue(column.Table.ParserArgs, column.Value); switch (col.DotNetType) { diff --git a/src/DbEx/Migration/Data/DataTable.cs b/src/DbEx/Migration/Data/DataTable.cs index cd382bd..fc5cd34 100644 --- a/src/DbEx/Migration/Data/DataTable.cs +++ b/src/DbEx/Migration/Data/DataTable.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using DbEx.DbSchema; using System; using System.Collections.Generic; @@ -118,27 +117,27 @@ internal DataTable(DataParser parser, string schema, string name) /// /// Gets the insert columns. /// - public List InsertColumns => Columns.Where(x => !x.IsUpdatedAudit).ToList(); + public List InsertColumns => [.. Columns.Where(x => !x.IsUpdatedAudit)]; /// /// Gets the merge match columns. /// - public List MergeMatchColumns => Columns.Where(x => !x.IsCreatedAudit && !x.IsUpdatedAudit && !(UseIdentifierGenerator && x.IsPrimaryKey)).ToList(); + public List MergeMatchColumns => [.. Columns.Where(x => !x.IsCreatedAudit && !x.IsUpdatedAudit && !(UseIdentifierGenerator && x.IsPrimaryKey))]; /// /// Gets the merge insert columns. /// - public List MergeInsertColumns => Columns.Where(x => !x.IsUpdatedAudit).ToList(); + public List MergeInsertColumns => [.. Columns.Where(x => !x.IsUpdatedAudit)]; /// /// Gets the merge update columns. /// - public List MergeUpdateColumns => Columns.Where(x => !x.IsCreatedAudit).ToList(); + public List MergeUpdateColumns => [.. Columns.Where(x => !x.IsCreatedAudit)]; /// /// Gets the primary key columns. /// - public List PrimaryKeyColumns => Columns.Where(x => x.IsPrimaryKey).ToList(); + public List PrimaryKeyColumns => [.. Columns.Where(x => x.IsPrimaryKey)]; /// /// Gets the rows. @@ -206,9 +205,9 @@ internal async Task PrepareAsync(CancellationToken cancellationToken) var row = Rows[i]; // Apply the configured auditing defaults. - await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.CreatedDateColumnName!, () => Task.FromResult(ParserArgs.DateTimeNow)).ConfigureAwait(false); + await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.CreatedOnColumnName!, () => Task.FromResult(ParserArgs.DateTimeNow)).ConfigureAwait(false); await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.CreatedByColumnName!, () => Task.FromResult(ParserArgs.UserName)).ConfigureAwait(false); - await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.UpdatedDateColumnName!, () => Task.FromResult(ParserArgs.DateTimeNow)).ConfigureAwait(false); + await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.UpdatedOnColumnName!, () => Task.FromResult(ParserArgs.DateTimeNow)).ConfigureAwait(false); await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.UpdatedByColumnName!, () => Task.FromResult(ParserArgs.UserName)).ConfigureAwait(false); // Apply an reference data defaults. diff --git a/src/DbEx/Migration/Database.cs b/src/DbEx/Migration/Database.cs new file mode 100644 index 0000000..6ce80ff --- /dev/null +++ b/src/DbEx/Migration/Database.cs @@ -0,0 +1,284 @@ +using DbEx.DbSchema; +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace DbEx.Migration +{ + /// + /// Provides the common/base database access functionality. + /// + /// The . + /// The function to create the . + /// The underlying . + public class Database(Func create, DbProviderFactory provider) : IDatabase where TConnection : DbConnection + { + private readonly Func _dbConnCreate = create.ThrowIfNull(nameof(create)); + private TConnection? _dbConn; + private readonly SemaphoreSlim _semaphore = new(1, 1); + + /// + public DbProviderFactory Provider { get; } = provider.ThrowIfNull(nameof(provider)); + + /// + public DbConnection GetConnection() => _dbConn is not null ? _dbConn : GetConnectionAsync().GetAwaiter().GetResult(); + + /// + async Task IDatabase.GetConnectionAsync(CancellationToken cancellationToken) => await GetConnectionAsync(cancellationToken).ConfigureAwait(false); + + /// + public async Task GetConnectionAsync(CancellationToken cancellationToken = default) + { + if (_dbConn == null) + { + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_dbConn != null) + return _dbConn; + + _dbConn = _dbConnCreate() ?? throw new InvalidOperationException($"The create function must create a valid {nameof(TConnection)} instance."); + await _dbConn.OpenAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception) + { + _dbConn?.Dispose(); + _dbConn = null; + throw; + } + finally + { + _semaphore.Release(); + } + } + + return _dbConn; + } + + /// + public DatabaseCommand StoredProcedure(string storedProcedure) => new(this, CommandType.StoredProcedure, storedProcedure.ThrowIfNull(nameof(storedProcedure))); + + /// + public DatabaseCommand SqlStatement(string sqlStatement) => new(this, CommandType.Text, sqlStatement.ThrowIfNull(nameof(sqlStatement))); + + /// + public async Task> SelectSchemaAsync(DatabaseMigrationBase migration, CancellationToken cancellationToken = default) + { + migration.ThrowIfNull(nameof(migration)); + migration.PreExecutionInitialization(); + + var tables = new List(); + DbTableSchema? table = null; + + var refDataPredicate = new Func(t => t.Columns.Any(c => c.Name == migration.Args.RefDataCodeColumnName! && !c.IsPrimaryKey && c.DotNetType == "string") && t.Columns.Any(c => c.Name == migration.Args.RefDataTextColumnName && !c.IsPrimaryKey && c.DotNetType == "string")); + + // Get all the tables and their columns. + var probeAssemblies = new[] { migration.SchemaConfig.GetType().Assembly, typeof(DatabaseExtensions).Assembly }; + using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableAndColumns.sql", probeAssemblies); + await SqlStatement(await ReadSqlAsync(migration, sr, cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => + { + if (!migration.SchemaConfig.SupportsSchema && dr.GetValue("TABLE_SCHEMA") != migration.DatabaseName) + return 0; + + var dt = new DbTableSchema(migration, dr.GetValue("TABLE_SCHEMA"), dr.GetValue("TABLE_NAME")!) + { + IsAView = dr.GetValue("TABLE_TYPE") == "VIEW" + }; + + if (table == null || table.Schema != dt.Schema || table.Name != dt.Name) + tables.Add(table = dt); + + var dc = migration.SchemaConfig.CreateColumnFromInformationSchema(table, dr); + dc.IsCreatedAudit = dc.Name == migration.Args?.CreatedByColumnName || dc.Name == migration.Args?.CreatedOnColumnName; + dc.IsUpdatedAudit = dc.Name == migration.Args?.UpdatedByColumnName || dc.Name == migration.Args?.UpdatedOnColumnName; + dc.IsTenantId = dc.Name == migration.Args?.TenantIdColumnName; + dc.IsRowVersion = dc.Name == migration.Args?.RowVersionColumnName; + dc.IsIsDeleted = dc.Name == migration.Args?.IsDeletedColumnName; + + table.Columns.Add(dc); + return 0; + }, cancellationToken).ConfigureAwait(false); + + // Exit where no tables initially found. + if (tables.Count == 0) + return tables; + + // Determine whether a table is considered reference data. + foreach (var t in tables) + { + t.IsRefData = refDataPredicate(t); + if (t.IsRefData) + t.RefDataCodeColumn = t.Columns.Where(x => x.Name == migration.Args.RefDataCodeColumnName).SingleOrDefault(); + } + + // Configure all the single column primary and unique constraints. + using var sr2 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTablePrimaryKey.sql", probeAssemblies); + var pks = await SqlStatement(await ReadSqlAsync(migration, sr2, cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new + { + ConstraintName = dr.GetValue("CONSTRAINT_NAME"), + TableSchema = dr.GetValue("TABLE_SCHEMA"), + TableName = dr.GetValue("TABLE_NAME"), + TableColumnName = dr.GetValue("COLUMN_NAME"), + IsPrimaryKey = dr.GetValue("CONSTRAINT_TYPE")?.StartsWith("PRIMARY", StringComparison.OrdinalIgnoreCase) ?? false + }, cancellationToken).ConfigureAwait(false); + + if (!migration.SchemaConfig.SupportsSchema) + pks = [.. pks.Where(x => x.TableSchema == migration.DatabaseName)]; + + foreach (var grp in pks.GroupBy(x => new { x.ConstraintName, x.TableSchema, x.TableName })) + { + // Only single column unique columns are supported. + if (grp.Count() > 1 && !grp.First().IsPrimaryKey) + continue; + + // Set the column flags as appropriate. + foreach (var pk in grp) + { + var col = (from t in tables + from c in t.Columns + where (!migration.SchemaConfig.SupportsSchema || t.Schema == pk.TableSchema) && t.Name == pk.TableName && c.Name == pk.TableColumnName + select c).SingleOrDefault(); + + if (col == null) + continue; + + if (pk.IsPrimaryKey) + { + col.IsPrimaryKey = true; + if (!col.IsIdentity) + col.IsIdentity = col.DefaultValue != null; + } + else + col.IsUnique = true; + } + } + + // Load any additional configuration specific to the database provider. + await migration.SchemaConfig.LoadAdditionalInformationSchema(this, tables, cancellationToken).ConfigureAwait(false); + + // Attempt to infer foreign key reference data relationship where not explicitly specified. + foreach (var t in tables) + { + foreach (var c in t.Columns.Where(x => !x.IsPrimaryKey)) + { + if (c.ForeignTable != null) + { + if (c.IsForeignRefData) + { + c.ForeignRefDataCodeColumn = migration.Args.RefDataCodeColumnName; + if (c.Name.EndsWith(migration.Args.IdColumnNameSuffix!, StringComparison.Ordinal)) + c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[0..^migration.Args.IdColumnNameSuffix!.Length]); + } + + continue; + } + + if (!c.Name.EndsWith(migration.Args.IdColumnNameSuffix!, StringComparison.Ordinal)) + continue; + + // Find table with same name as column in any schema that is considered reference data and has a single primary key. + var fk = tables.Where(x => x != t && x.Name == c.Name[0..^migration.Args.IdColumnNameSuffix!.Length] && x.IsRefData && x.PrimaryKeyColumns.Count == 1).FirstOrDefault(); + if (fk == null) + continue; + + c.ForeignSchema = fk.Schema; + c.ForeignTable = fk.Name; + c.ForeignColumn = fk.PrimaryKeyColumns[0].Name; + c.IsForeignRefData = true; + c.ForeignRefDataCodeColumn = migration.Args.RefDataCodeColumnName; + c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[0..^migration.Args.IdColumnNameSuffix!.Length]); + } + } + + // Attempt to infer if a reference data column where not explicitly specified. + foreach (var t in tables) + { + foreach (var c in t.Columns.Where(x => !x.IsPrimaryKey)) + { + if (c.IsForeignRefData) + { + c.IsRefData = true; + continue; + } + + // Find possible name by removing suffix by-convention. + string name; + if (c.Name.EndsWith(migration.Args.IdColumnNameSuffix!, StringComparison.Ordinal)) + name = c.Name[0..^migration.Args.IdColumnNameSuffix!.Length]; + else if (c.Name.EndsWith(migration.Args.CodeColumnNameSuffix!, StringComparison.Ordinal)) + name = c.Name[0..^migration.Args.CodeColumnNameSuffix!.Length]; + else + continue; + + // Is there a table match of same name that is considered reference data; if so, consider ref data. + if (tables.Any(x => x.Name == name && x.Schema == t.Schema && x.IsRefData)) + { + c.IsRefData = true; + c.DotNetCleanedName = DbTableSchema.CreateDotNetName(name); + } + } + } + + return tables; + } + + /// + /// Gets the SQL statement from the embedded resource stream + /// + private async static Task ReadSqlAsync(DatabaseMigrationBase migration, StreamReader sr, CancellationToken cancellationToken) + { +#if NET7_0_OR_GREATER + var sql = await sr.ThrowIfNull(nameof(sr)).ReadToEndAsync(cancellationToken).ConfigureAwait(false); +#else + var sql = await sr.ThrowIfNull(nameof(sr)).ReadToEndAsync().ConfigureAwait(false); +#endif + return sql.Replace("{{DatabaseName}}", migration.DatabaseName); + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose of the resources. + /// + /// Indicates whether to dispose. + protected virtual void Dispose(bool disposing) + { + if (disposing && _dbConn != null) + { + _dbConn.Dispose(); + _dbConn = null; + } + } + + /// + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore().ConfigureAwait(false); + GC.SuppressFinalize(this); + } + + /// + /// Dispose of the resources asynchronously. + /// + public virtual async ValueTask DisposeAsyncCore() + { + if (_dbConn != null) + { + await _dbConn.DisposeAsync().ConfigureAwait(false); + _dbConn = null; + } + + Dispose(); + } + } +} \ No newline at end of file diff --git a/src/DbEx/Migration/DatabaseCommand.cs b/src/DbEx/Migration/DatabaseCommand.cs new file mode 100644 index 0000000..7a6bdf2 --- /dev/null +++ b/src/DbEx/Migration/DatabaseCommand.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace DbEx.Migration +{ + /// + /// Provides extended database command capabilities. + /// + /// The . + /// The . + /// The command text. + /// As the underlying implements this is only created (and automatically disposed) where executing the command proper. + public class DatabaseCommand(IDatabase db, CommandType commandType, string commandText) + { + private readonly List _parameters = []; + + /// + /// Gets the underlying . + /// + public IDatabase Database { get; } = db.ThrowIfNull(); + + /// + /// Gets the . + /// + public CommandType CommandType { get; } = commandType; + + /// + /// Gets the command text. + /// + public string CommandText { get; } = commandText.ThrowIfNullOrEmpty(); + + /// + /// Adds a parameter to the command. + /// + /// The parameter name. + /// The parameter value. + /// The . + public DatabaseCommand Param(string name, T? value = default) + { + var param = Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null."); + param.ParameterName = name; + + // Convert to UTC. https://www.tinybird.co/blog/database-timestamps-timezone + param.Value = value is null + ? DBNull.Value + : (value is DateTimeOffset dto ? dto.ToUniversalTime() : value); + + _parameters.Add(param); + return this; + } + + /// + /// Selects none or more items from the first result set. + /// + /// The item . + /// The mapping function. + /// The . + /// The resulting set. + public async Task> SelectQueryAsync(Func func, CancellationToken cancellationToken = default) + { + func.ThrowIfNull(nameof(func)); + + var list = new List(); + + using var cmd = await CreateDbCommandAsync(cancellationToken).ConfigureAwait(false); + using var dr = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + + while (await dr.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + list.Add(func(new DatabaseRecord(Database, dr))); + } + + return list; + } + + /// + /// Executes the query and returns the first column of the first row in the result set returned by the query. + /// + /// The result . + /// The . + /// The value of the first column of the first row in the result set. + public async Task ScalarAsync(CancellationToken cancellationToken = default) + { + using var cmd = await CreateDbCommandAsync(cancellationToken).ConfigureAwait(false); + var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + + if (typeof(T) == typeof(DateTimeOffset) && result is DateTime dt) + { + // https://www.tinybird.co/blog/database-timestamps-timezone + var dto = dt.Kind switch + { + DateTimeKind.Utc => new DateTimeOffset(dt, TimeSpan.Zero), + DateTimeKind.Local => new DateTimeOffset(dt), + _ =>throw new InvalidOperationException($"{nameof(DateTime)} with {nameof(DateTime.Kind)} of {dt.Kind} cannot be safely converted to a {nameof(DateTimeOffset)}.") + }; + + return (T)(object)dto; + } + else + return result is null ? default! : result is DBNull ? default! : (T)result; + } + + /// + /// Executes a non-query command. + /// + /// The . + /// The number of rows affected. + public async Task NonQueryAsync(CancellationToken cancellationToken = default) + { + using var cmd = await CreateDbCommandAsync(cancellationToken).ConfigureAwait(false); + return await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates the corresponding . + /// + /// The . + /// The . + private async Task CreateDbCommandAsync(CancellationToken cancellationToken = default) + { + var cmd = (await Database.GetConnectionAsync(cancellationToken).ConfigureAwait(false)).CreateCommand(); + cmd.CommandType = CommandType; + cmd.CommandText = CommandText; + cmd.Parameters.AddRange(_parameters.ToArray()); + return cmd; + } + } +} \ No newline at end of file diff --git a/src/DbEx/Migration/DatabaseJournal.cs b/src/DbEx/Migration/DatabaseJournal.cs index b3823e1..0fd3d09 100644 --- a/src/DbEx/Migration/DatabaseJournal.cs +++ b/src/DbEx/Migration/DatabaseJournal.cs @@ -1,13 +1,11 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx.Database; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using System.Linq; -using CoreEx; namespace DbEx.Migration { @@ -39,7 +37,7 @@ public async Task EnsureExistsAsync(CancellationToken cancellationToken = defaul if (_journalExists) return; - using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalExists.sql", Migrator.ArtefactResourceAssemblies.ToArray())!; + using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalExists.sql", [.. Migrator.ArtefactResourceAssemblies])!; var exists = await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr.ReadToEnd())).ScalarAsync(cancellationToken).ConfigureAwait(false); if (exists != null) { @@ -47,7 +45,7 @@ public async Task EnsureExistsAsync(CancellationToken cancellationToken = defaul return; } - using var sr2 = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalCreate.sql", Migrator.ArtefactResourceAssemblies.ToArray())!; + using var sr2 = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalCreate.sql", [.. Migrator.ArtefactResourceAssemblies])!; await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr2.ReadToEnd())).NonQueryAsync(cancellationToken).ConfigureAwait(false); Migrator.Logger.LogInformation(" *Journal table did not exist within the database and was automatically created."); @@ -60,7 +58,7 @@ public async Task AuditScriptExecutionAsync(DatabaseMigrationScript script, Canc { await EnsureExistsAsync(cancellationToken).ConfigureAwait(false); - using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalAudit.sql", Migrator.ArtefactResourceAssemblies.ToArray())!; + using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalAudit.sql", [.. Migrator.ArtefactResourceAssemblies])!; await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr.ReadToEnd())) .Param("@scriptname", script.Name) .Param("@applied", DateTime.UtcNow) @@ -72,8 +70,8 @@ public async Task> GetExecutedScriptsAsync(CancellationToken { await EnsureExistsAsync(cancellationToken).ConfigureAwait(false); - using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalPrevious.sql", Migrator.ArtefactResourceAssemblies.ToArray())!; - return await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr.ReadToEnd())).SelectQueryAsync(dr => dr.GetValue("scriptname"), cancellationToken).ConfigureAwait(false); + using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalPrevious.sql", [.. Migrator.ArtefactResourceAssemblies])!; + return await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr.ReadToEnd())).SelectQueryAsync(dr => dr.GetValue("scriptname")!, cancellationToken).ConfigureAwait(false); } } } \ No newline at end of file diff --git a/src/DbEx/Migration/DatabaseMigrationBase.cs b/src/DbEx/Migration/DatabaseMigrationBase.cs index fd09f24..8739041 100644 --- a/src/DbEx/Migration/DatabaseMigrationBase.cs +++ b/src/DbEx/Migration/DatabaseMigrationBase.cs @@ -1,7 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; -using CoreEx.Database; using DbEx.Migration.Data; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -342,7 +340,7 @@ protected virtual async Task ExecuteScriptsAsync(IEnumerable ExecuteScriptsAsync(IEnumerableThe @DatabaseName literal within the resulting (embedded resource) command is replaced by the using a (i.e. not database parameterized as not all databases support). protected virtual async Task DatabaseExistsAsync(CancellationToken cancellationToken = default) { - using var sr = GetRequiredResourcesStreamReader($"DatabaseExists.sql", ArtefactResourceAssemblies.ToArray()); + using var sr = GetRequiredResourcesStreamReader($"DatabaseExists.sql", [.. ArtefactResourceAssemblies]); var name = await MasterDatabase.SqlStatement(ReplaceSqlRuntimeParameters(sr.ReadToEnd())).ScalarAsync(cancellationToken); return name != null; } @@ -409,7 +407,7 @@ protected virtual async Task DatabaseDropAsync(CancellationToken cancellat return true; } - using var sr = GetRequiredResourcesStreamReader($"DatabaseDrop.sql", ArtefactResourceAssemblies.ToArray()); + using var sr = GetRequiredResourcesStreamReader($"DatabaseDrop.sql", [.. ArtefactResourceAssemblies]); await MasterDatabase.SqlStatement(ReplaceSqlRuntimeParameters(sr.ReadToEnd())).NonQueryAsync(cancellationToken); Logger.LogInformation("{Content}", $" Database '{DatabaseName}' dropped."); @@ -434,7 +432,7 @@ protected virtual async Task DatabaseCreateAsync(CancellationToken cancell return true; } - using var sr = GetRequiredResourcesStreamReader($"DatabaseCreate.sql", ArtefactResourceAssemblies.ToArray()); + using var sr = GetRequiredResourcesStreamReader($"DatabaseCreate.sql", [.. ArtefactResourceAssemblies]); await MasterDatabase.SqlStatement(ReplaceSqlRuntimeParameters(sr.ReadToEnd())).NonQueryAsync(cancellationToken); Logger.LogInformation("{Content}", $" Database '{DatabaseName}' did not exist and was created."); @@ -708,7 +706,7 @@ protected virtual async Task DatabaseResetAsync(CancellationToken cancella return true; } - using var sr = GetRequiredResourcesStreamReader($"DatabaseReset_sql", ArtefactResourceAssemblies.ToArray(), StreamLocator.HandlebarsExtensions); + using var sr = GetRequiredResourcesStreamReader($"DatabaseReset_sql", [.. ArtefactResourceAssemblies], StreamLocator.HandlebarsExtensions); var cg = new HandlebarsCodeGenerator(sr); var sql = cg.Generate(delete); @@ -830,7 +828,7 @@ protected virtual async Task DatabaseDataAsync(List dataTables, // Cache the compiled code-gen template. if (_dataCodeGen == null) { - using var sr = GetRequiredResourcesStreamReader($"DatabaseData_sql", ArtefactResourceAssemblies.ToArray(), StreamLocator.HandlebarsExtensions); + using var sr = GetRequiredResourcesStreamReader($"DatabaseData_sql", [.. ArtefactResourceAssemblies], StreamLocator.HandlebarsExtensions); #if NET7_0_OR_GREATER _dataCodeGen = new HandlebarsCodeGenerator(await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false)); #else @@ -909,7 +907,7 @@ private async Task CreateScriptInternalAsync(string? name, IDictionary ExecuteSqlStatementsInternalAsync(string[]? statements, /// The SQL command. /// The resulting SQL command with runtime replacements make. public string ReplaceSqlRuntimeParameters(string sql) => Args.Parameters.Count == 0 - ? sql : Regex.Replace(sql, "(" + string.Join("|", Args.Parameters.Select(x => $"{{{{{x.Key}}}}}").ToArray()) + ")", + ? sql : Regex.Replace(sql, "(" + string.Join("|", [.. Args.Parameters.Select(x => $"{{{{{x.Key}}}}}")]) + ")", m => Args.Parameters.TryGetValue(m.Value[2..^2], out var pv) ? pv?.ToString()! : throw new InvalidOperationException($"Runtime Parameter '{m.Value}' found within SQL command; a corresponding Parameter value has not been configured.")); /// diff --git a/src/DbEx/Migration/DatabaseMigrationScript.cs b/src/DbEx/Migration/DatabaseMigrationScript.cs index 7a40448..c548203 100644 --- a/src/DbEx/Migration/DatabaseMigrationScript.cs +++ b/src/DbEx/Migration/DatabaseMigrationScript.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using System.IO; using System.Reflection; using System.Text; diff --git a/src/DbEx/Migration/DatabaseRecord.cs b/src/DbEx/Migration/DatabaseRecord.cs new file mode 100644 index 0000000..3350684 --- /dev/null +++ b/src/DbEx/Migration/DatabaseRecord.cs @@ -0,0 +1,84 @@ +using System; +using System.Data.Common; + +namespace DbEx.Migration +{ + /// + /// Encapsulates the to provide requisite column value capabilities. + /// + /// The owning . + /// The underlying . + public class DatabaseRecord(IDatabase database, DbDataReader dataReader) + { + /// + /// Gets the owning . + /// + public IDatabase Database { get; } = database.ThrowIfNull(); + + /// + /// Gets the underlying . + /// + public DbDataReader DataReader { get; } = dataReader.ThrowIfNull(); + + /// + /// Gets the named column value. + /// + /// The column name. + /// The value. + public object? GetValue(string columnName) => GetValue(DataReader.GetOrdinal(columnName.ThrowIfNull())); + + /// + /// Gets the specified column value. + /// + /// The ordinal index. + /// The value. + public object? GetValue(int ordinal) + { + if (DataReader.IsDBNull(ordinal)) + return default; + + return DataReader.GetValue(ordinal); + } + + /// + /// Gets the named column value. + /// + /// The value . + /// The column name. + /// The value. + public T? GetValue(string columnName) => GetValue(DataReader.GetOrdinal(columnName.ThrowIfNull())); + + /// + /// Gets the specified column value. + /// + /// The value . + /// The ordinal index. + /// The value. + public T? GetValue(int ordinal) + { + if (DataReader.IsDBNull(ordinal)) + return default!; + +#if NET7_0_OR_GREATER + if (typeof(T) == typeof(Nullable)) + return (T?)(object)DataReader.GetFieldValue(ordinal); + else if (typeof(T) == typeof(Nullable)) + return (T?)(object)DataReader.GetFieldValue(ordinal); +#endif + + return DataReader.GetFieldValue(ordinal); + } + + /// + /// Indicates whether the named column is . + /// + /// The column name. + /// The corresponding ordinal for the column name. + /// indicates that the column value has a value; otherwise, . + public bool IsDBNull(string columnName, out int ordinal) + { + ordinal = DataReader.GetOrdinal(columnName.ThrowIfNull()); + return DataReader.IsDBNull(ordinal); + } + } +} \ No newline at end of file diff --git a/src/DbEx/Migration/DatabaseSchemaScriptBase.cs b/src/DbEx/Migration/DatabaseSchemaScriptBase.cs index 01ace38..031e297 100644 --- a/src/DbEx/Migration/DatabaseSchemaScriptBase.cs +++ b/src/DbEx/Migration/DatabaseSchemaScriptBase.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using System; namespace DbEx.Migration diff --git a/src/DbEx/Migration/IDatabase.cs b/src/DbEx/Migration/IDatabase.cs new file mode 100644 index 0000000..c2e8841 --- /dev/null +++ b/src/DbEx/Migration/IDatabase.cs @@ -0,0 +1,54 @@ +using DbEx.DbSchema; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace DbEx.Migration +{ + /// + /// Enables database (relational) access. + /// + public interface IDatabase : IAsyncDisposable, IDisposable + { + /// + /// Gets the . + /// + DbProviderFactory Provider { get; } + + /// + /// Gets the . + /// + /// The connection is created and opened on first use, and closed on or . + DbConnection GetConnection(); + + /// + /// Gets the . + /// + /// The connection is created and opened on first use, and closed on or . + Task GetConnectionAsync(CancellationToken cancellationToken = default); + + /// + /// Creates a stored procedure . + /// + /// The stored procedure name. + /// The . + DatabaseCommand StoredProcedure(string storedProcedure); + + /// + /// Creates a SQL statement . + /// + /// The SQL statement. + /// The . + DatabaseCommand SqlStatement(string sqlStatement); + + /// + /// Selects all the table and column schema details from the database. + /// + /// The . + /// The . + /// A list of all the table and column schema details. + Task> SelectSchemaAsync(DatabaseMigrationBase migration, CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/src/DbEx/Migration/MigrationArgsBase.cs b/src/DbEx/Migration/MigrationArgsBase.cs index 0f0d644..566bb97 100644 --- a/src/DbEx/Migration/MigrationArgsBase.cs +++ b/src/DbEx/Migration/MigrationArgsBase.cs @@ -2,9 +2,6 @@ global using ExplicitMigrationScript = (DbEx.MigrationCommand Command, System.Reflection.Assembly Assembly, string Name); -using CoreEx; -using CoreEx.Entities; -using CoreEx.RefData; using DbEx.Migration.Data; using Microsoft.Extensions.Logging; using System; @@ -112,55 +109,55 @@ public abstract class MigrationArgsBase : OnRamp.CodeGeneratorDbArgsBase public string? JsonColumnNameSuffix { get; set; } /// - /// Gets or sets the name of the column (where it exists). + /// Gets or sets the name of the CreatedOn column (where it exists). /// - /// Defaults to where not specified (i.e. null). - public string? CreatedDateColumnName { get; set; } + /// Defaults to where not specified (i.e. null). + public string? CreatedOnColumnName { get; set; } /// - /// Gets or sets the name of the column (where it exists). + /// Gets or sets the name of the CreatedBy column (where it exists). /// /// Defaults to where not specified (i.e. null). public string? CreatedByColumnName { get; set; } /// - /// Gets or sets the name of the column (where it exists). + /// Gets or sets the name of the UpdatedOn column (where it exists). /// - /// Defaults to where not specified (i.e. null). - public string? UpdatedDateColumnName { get; set; } + /// Defaults to where not specified (i.e. null). + public string? UpdatedOnColumnName { get; set; } /// - /// Gets or sets the name of the column (where it exists). + /// Gets or sets the name of the UpdatedBy column (where it exists). /// /// Defaults to where not specified (i.e. null). public string? UpdatedByColumnName { get; set; } /// - /// Gets or sets the name of the column (where it exists). + /// Gets or sets the name of the TenantId column (where it exists). /// /// Defaults to where not specified (i.e. null). public string? TenantIdColumnName { get; set; } /// - /// Gets or sets the name of the row-version ( equivalent) column (where it exists). + /// Gets or sets the name of the row-version (ETag equivalent) column (where it exists). /// /// Defaults to where not specified (i.e. null). public string? RowVersionColumnName { get; set; } /// - /// Gets or sets the name of the column (where it exists). + /// Gets or sets the name of the logically IsDeleted column (where it exists). /// /// Defaults to where not specified (i.e. null). public string? IsDeletedColumnName { get; set; } /// - /// Gets or sets the name of the column. + /// Gets or sets the name of the reference-data code column. /// /// Defaults to where not specified (i.e. null). public string? RefDataCodeColumnName { get; set; } /// - /// Gets or sets the name of the column. + /// Gets or sets the name of the reference-data text column. /// /// Defaults to where not specified (i.e. null). public string? RefDataTextColumnName { get; set; } @@ -302,9 +299,9 @@ protected void CopyFrom(MigrationArgsBase args) DataParserArgs.CopyFrom(args.DataParserArgs); IdColumnNameSuffix = args.IdColumnNameSuffix; CodeColumnNameSuffix = args.CodeColumnNameSuffix; - CreatedDateColumnName = args.CreatedDateColumnName; + CreatedOnColumnName = args.CreatedOnColumnName; CreatedByColumnName = args.CreatedByColumnName; - UpdatedDateColumnName = args.UpdatedDateColumnName; + UpdatedOnColumnName = args.UpdatedOnColumnName; UpdatedByColumnName = args.UpdatedByColumnName; RowVersionColumnName = args.RowVersionColumnName; TenantIdColumnName = args.TenantIdColumnName; diff --git a/src/DbEx/Migration/MigrationAssemblyArgs.cs b/src/DbEx/Migration/MigrationAssemblyArgs.cs index 7a15f61..b7bfcbf 100644 --- a/src/DbEx/Migration/MigrationAssemblyArgs.cs +++ b/src/DbEx/Migration/MigrationAssemblyArgs.cs @@ -1,6 +1,5 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx; using System.Reflection; namespace DbEx.Migration diff --git a/tests/DbEx.Test.Console/Data/Other.sql b/tests/DbEx.Test.Console/Data/Other.sql index 71cb419..98f6cb5 100644 --- a/tests/DbEx.Test.Console/Data/Other.sql +++ b/tests/DbEx.Test.Console/Data/Other.sql @@ -1 +1 @@ -INSERT INTO [Test].[Contact] ([ContactId], [ContactTypeId], [GenderId], [Name], [DateOfBirth]) VALUES (3, (SELECT TOP 1 [ContactTypeId] FROM [Test].[ContactType] WHERE [Code] = 'E'), (SELECT TOP 1 [GenderId] FROM [Test].[Gender] WHERE [Code] = 'M'), 'Barry', '2001-10-22T00:00:00.0000000') \ No newline at end of file +INSERT INTO [Test].[Contact] ([ContactId], [ContactTypeId], [GenderId], [Name], [DateOfBirth]) VALUES (3, (SELECT TOP 1 [ContactTypeId] FROM [Test].[ContactType] WHERE [Code] = 'E'), (SELECT TOP 1 [GenderId] FROM [Test].[Gender] WHERE [Code] = 'M'), 'Barry', '2001-10-22') \ No newline at end of file diff --git a/tests/DbEx.Test.Console/DbEx.Test.Console.csproj b/tests/DbEx.Test.Console/DbEx.Test.Console.csproj index a6e5c67..2d6ffc6 100644 --- a/tests/DbEx.Test.Console/DbEx.Test.Console.csproj +++ b/tests/DbEx.Test.Console/DbEx.Test.Console.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0;net9.0;net10.0 @@ -21,7 +21,6 @@ - diff --git a/tests/DbEx.Test.Console/Migrations/003-create-test-gender-table.sql b/tests/DbEx.Test.Console/Migrations/003-create-test-gender-table.sql index ff64743..46ab8b7 100644 --- a/tests/DbEx.Test.Console/Migrations/003-create-test-gender-table.sql +++ b/tests/DbEx.Test.Console/Migrations/003-create-test-gender-table.sql @@ -2,8 +2,8 @@ [GenderId] INT NOT NULL IDENTITY(1, 1) PRIMARY KEY, [Code] NVARCHAR (50) NOT NULL UNIQUE, [Text] VARCHAR (256) NOT NULL, - [CreatedBy] NVARCHAR (50) NULL, - [CreatedDate] DATETIME2 NULL, - [UpdatedBy] NVARCHAR (50) NULL, - [UpdatedDate] DATETIME2 NULL + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL ) \ No newline at end of file diff --git a/tests/DbEx.Test.Console/Migrations/006-create-test-person-table.sql b/tests/DbEx.Test.Console/Migrations/006-create-test-person-table.sql index cd9e6c6..16f7de6 100644 --- a/tests/DbEx.Test.Console/Migrations/006-create-test-person-table.sql +++ b/tests/DbEx.Test.Console/Migrations/006-create-test-person-table.sql @@ -3,8 +3,8 @@ [Name] NVARCHAR (200) NOT NULL, [NicknamesJson] NVARCHAR (500) NULL, [AddressJson] NVARCHAR (500) NULL, - [CreatedBy] NVARCHAR (200) NULL, - [CreatedDate] DATETIME2 NULL, - [UpdatedBy] NVARCHAR (200) NULL, - [UpdatedDate] DATETIME2 NULL + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL ) \ No newline at end of file diff --git a/tests/DbEx.Test.Console/Migrations/007-create-test-status-table.sql b/tests/DbEx.Test.Console/Migrations/007-create-test-status-table.sql index e455aec..81bd36e 100644 --- a/tests/DbEx.Test.Console/Migrations/007-create-test-status-table.sql +++ b/tests/DbEx.Test.Console/Migrations/007-create-test-status-table.sql @@ -2,10 +2,10 @@ [StatusId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY, [Code] NVARCHAR (50) NOT NULL, [Text] VARCHAR (256) NOT NULL, - [CreatedBy] NVARCHAR (50) NULL, - [CreatedDate] DATETIME2 NULL, - [UpdatedBy] NVARCHAR (50) NULL, - [UpdatedDate] DATETIME2 NULL, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL, [TenantId] NVARCHAR(50) NOT NULL, UNIQUE ([TenantId], [Code]) ) \ No newline at end of file diff --git a/tests/DbEx.Test.Console/Program.cs b/tests/DbEx.Test.Console/Program.cs index 67fcc71..6611891 100644 --- a/tests/DbEx.Test.Console/Program.cs +++ b/tests/DbEx.Test.Console/Program.cs @@ -6,10 +6,10 @@ namespace DbEx.Test.Console public class Program { internal static Task Main(string[] args) => SqlServerMigrationConsole - .Create("Data Source=.;Initial Catalog=DbEx.Console;Integrated Security=True;TrustServerCertificate=true") + .Create("Data Source=127.0.0.1,1433;Initial Catalog=DbEx.Console;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true") .Configure(c => { - c.Args.AddAssembly("Data", "Data2"); + //c.Args.AddAssembly("Data", "Data2"); c.Args.AddSchemaOrder("Test", "Outbox"); c.Args.IncludeExtendedSchemaScripts(); c.Args.DataParserArgs.Parameter("DefaultName", "Bazza") diff --git a/tests/DbEx.Test.Console/Resources/Table_sql.hb b/tests/DbEx.Test.Console/Resources/Table_sql.hb index cd861aa..2f1553a 100644 --- a/tests/DbEx.Test.Console/Resources/Table_sql.hb +++ b/tests/DbEx.Test.Console/Resources/Table_sql.hb @@ -14,9 +14,9 @@ CREATE TABLE [{{lookup Parameters 'Param1'}}].[{{lookup Parameters 'Param2'}}] ( -- [Date] DATE NULL, [RowVersion] TIMESTAMP NOT NULL, [CreatedBy] NVARCHAR(250) NULL, - [CreatedDate] DATETIME2 NULL, + [CreatedOn] DATETIMEOFFSET NULL, [UpdatedBy] NVARCHAR(250) NULL, - [UpdatedDate] DATETIME2 NULL + [UpdatedOn] DATETIMEOFFSET NULL ); COMMIT TRANSACTION \ No newline at end of file diff --git a/tests/DbEx.Test.Empty/DbEx.Test.Empty.csproj b/tests/DbEx.Test.Empty/DbEx.Test.Empty.csproj index dbc1517..f7424b9 100644 --- a/tests/DbEx.Test.Empty/DbEx.Test.Empty.csproj +++ b/tests/DbEx.Test.Empty/DbEx.Test.Empty.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0;net9.0;net10.0 diff --git a/tests/DbEx.Test.Error/DbEx.Test.Error.csproj b/tests/DbEx.Test.Error/DbEx.Test.Error.csproj index 27d59fd..c230ccc 100644 --- a/tests/DbEx.Test.Error/DbEx.Test.Error.csproj +++ b/tests/DbEx.Test.Error/DbEx.Test.Error.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0;net9.0;net10.0 diff --git a/tests/DbEx.Test.MySqlConsole/DbEx.Test.MySqlConsole.csproj b/tests/DbEx.Test.MySqlConsole/DbEx.Test.MySqlConsole.csproj index ad88bc2..66b6973 100644 --- a/tests/DbEx.Test.MySqlConsole/DbEx.Test.MySqlConsole.csproj +++ b/tests/DbEx.Test.MySqlConsole/DbEx.Test.MySqlConsole.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0;net9.0;net10.0 enable enable diff --git a/tests/DbEx.Test.MySqlConsole/Migrations/003-create-test-gender-table.sql b/tests/DbEx.Test.MySqlConsole/Migrations/003-create-test-gender-table.sql index 5752605..3ab7be4 100644 --- a/tests/DbEx.Test.MySqlConsole/Migrations/003-create-test-gender-table.sql +++ b/tests/DbEx.Test.MySqlConsole/Migrations/003-create-test-gender-table.sql @@ -3,7 +3,7 @@ `code` VARCHAR (50) NOT NULL UNIQUE, `text` VARCHAR (256) NOT NULL, `created_by` VARCHAR (50) NULL, - `created_date` DATETIME NULL, + `created_on` DATETIME(6) NULL, `updated_by` VARCHAR (50) NULL, - `updated_date` DATETIME NULL + `updated_on` DATETIME(6) NULL ) \ No newline at end of file diff --git a/tests/DbEx.Test.MySqlConsole/Migrations/004-create-test-contact-table.sql b/tests/DbEx.Test.MySqlConsole/Migrations/004-create-test-contact-table.sql index e2aa1a7..2b5204a 100644 --- a/tests/DbEx.Test.MySqlConsole/Migrations/004-create-test-contact-table.sql +++ b/tests/DbEx.Test.MySqlConsole/Migrations/004-create-test-contact-table.sql @@ -7,9 +7,9 @@ `gender_id` INT NULL, `notes` TEXT NULL, `created_by` VARCHAR (50) NULL, - `created_date` DATETIME NULL, + `created_on` DATETIME NULL, `updated_by` VARCHAR (50) NULL, - `updated_date` DATETIME NULL, + `updated_on` DATETIME NULL, `contact_type_code` VARCHAR(50) NULL, CONSTRAINT `FK_Test_Contact_ContactType` FOREIGN KEY (`contact_type_id`) REFERENCES `contact_type` (`contact_type_id`) ) \ No newline at end of file diff --git a/tests/DbEx.Test.OutboxConsole/DbEx.Test.OutboxConsole.csproj b/tests/DbEx.Test.OutboxConsole/DbEx.Test.OutboxConsole.csproj index 300f585..5ba8bcb 100644 --- a/tests/DbEx.Test.OutboxConsole/DbEx.Test.OutboxConsole.csproj +++ b/tests/DbEx.Test.OutboxConsole/DbEx.Test.OutboxConsole.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0;net9.0;net10.0 enable preview diff --git a/tests/DbEx.Test.PostgresConsole/DbEx.Test.PostgresConsole.csproj b/tests/DbEx.Test.PostgresConsole/DbEx.Test.PostgresConsole.csproj index cdf5283..4984900 100644 --- a/tests/DbEx.Test.PostgresConsole/DbEx.Test.PostgresConsole.csproj +++ b/tests/DbEx.Test.PostgresConsole/DbEx.Test.PostgresConsole.csproj @@ -1,7 +1,7 @@  - net6.0 + net8.0;net9.0;net10.0 enable enable diff --git a/tests/DbEx.Test.PostgresConsole/Migrations/003-create-test-gender-table.sql b/tests/DbEx.Test.PostgresConsole/Migrations/003-create-test-gender-table.sql index 53835a9..6b89433 100644 --- a/tests/DbEx.Test.PostgresConsole/Migrations/003-create-test-gender-table.sql +++ b/tests/DbEx.Test.PostgresConsole/Migrations/003-create-test-gender-table.sql @@ -3,7 +3,7 @@ "code" VARCHAR (50) NOT NULL UNIQUE, "text" VARCHAR (256) NOT NULL, "created_by" VARCHAR (50) NULL, - "created_date" TIMESTAMPTZ NULL, + "created_on" TIMESTAMPTZ NULL, "updated_by" VARCHAR (50) NULL, - "updated_date" TIMESTAMPTZ NULL + "updated_on" TIMESTAMPTZ NULL ) \ No newline at end of file diff --git a/tests/DbEx.Test.PostgresConsole/Migrations/004-create-test-contact-table.sql b/tests/DbEx.Test.PostgresConsole/Migrations/004-create-test-contact-table.sql index d56029d..7e1c6a7 100644 --- a/tests/DbEx.Test.PostgresConsole/Migrations/004-create-test-contact-table.sql +++ b/tests/DbEx.Test.PostgresConsole/Migrations/004-create-test-contact-table.sql @@ -7,9 +7,9 @@ "gender_id" INT NULL, "notes" TEXT NULL, "created_by" VARCHAR (50) NULL, - "created_date" TIMESTAMPTZ NULL, + "created_on" TIMESTAMPTZ NULL, "updated_by" VARCHAR (50) NULL, - "updated_date" TIMESTAMPTZ NULL, + "updated_on" TIMESTAMPTZ NULL, "contact_type_code" VARCHAR(50) NULL, CONSTRAINT "FK_Test_Contact_ContactType" FOREIGN KEY ("contact_type_id") REFERENCES "contact_type" ("contact_type_id") ) \ No newline at end of file diff --git a/tests/DbEx.Test/ContextOutLogger.cs b/tests/DbEx.Test/ContextOutLogger.cs index 7f662cf..c3bd171 100644 --- a/tests/DbEx.Test/ContextOutLogger.cs +++ b/tests/DbEx.Test/ContextOutLogger.cs @@ -8,7 +8,7 @@ namespace DbEx.Test /// /// Represents the provider. /// - [ProviderAlias("")] + [Microsoft.Extensions.Logging.ProviderAlias("")] [DebuggerStepThrough] public sealed class TestContextLoggerProvider : ILoggerProvider { @@ -38,24 +38,15 @@ public void Dispose() { } /// /// Represents a logger where all messages are written directly to . /// + /// The name of the logger. + /// Indicates whether to include the log level in the message. + /// Indicates whether to include the logger name in the message. [DebuggerStepThrough] - public sealed class TestContextLogger : ILogger + public sealed class TestContextLogger(string name, bool includeLevel, bool includeName) : ILogger { - private readonly string _name; - private readonly bool _includeName; - private readonly bool _includeLevel; - - /// - /// Initializes a new instance of the class. - /// - /// The name of the logger. - /// Indicates whether to include the logger name in the message. - public TestContextLogger(string name, bool includeLevel, bool includeName) - { - _name = name ?? throw new ArgumentNullException(nameof(name)); - _includeLevel = includeLevel; - _includeName = includeName; - } + private readonly string _name = name ?? throw new ArgumentNullException(nameof(name)); + private readonly bool _includeName = includeName; + private readonly bool _includeLevel = includeLevel; /// public IDisposable BeginScope(TState state) => NullScope.Default; @@ -69,8 +60,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except if (!IsEnabled(logLevel)) return; - if (formatter == null) - throw new ArgumentNullException(nameof(formatter)); + ArgumentNullException.ThrowIfNull(formatter); var message = formatter(state, exception); if (_includeLevel) diff --git a/tests/DbEx.Test/DatabaseSchemaTest.cs b/tests/DbEx.Test/DatabaseSchemaTest.cs index 8e20208..de1c70d 100644 --- a/tests/DbEx.Test/DatabaseSchemaTest.cs +++ b/tests/DbEx.Test/DatabaseSchemaTest.cs @@ -1,7 +1,4 @@ -using CoreEx.Database.MySql; -using CoreEx.Database.Postgres; -using CoreEx.Database.SqlServer; -using DbEx.Migration; +using DbEx.Migration; using DbEx.MySql.Migration; using DbEx.Postgres.Migration; using DbEx.SqlServer.Migration; @@ -138,7 +135,7 @@ public async Task SqlServerSelectSchema() Assert.IsNull(col.Length); Assert.IsNull(col.Scale); Assert.AreEqual(0, col.Precision); - Assert.AreEqual("DateTime", col.DotNetType); + Assert.AreEqual("DateOnly", col.DotNetType); Assert.IsTrue(col.IsNullable); Assert.IsFalse(col.IsPrimaryKey); Assert.IsFalse(col.IsIdentity); @@ -481,7 +478,7 @@ public async Task MySqlSelectSchema() Assert.IsNull(col.Length); Assert.IsNull(col.Scale); Assert.IsNull(col.Precision); - Assert.AreEqual("DateTime", col.DotNetType); + Assert.AreEqual("DateOnly", col.DotNetType); Assert.IsTrue(col.IsNullable); Assert.IsFalse(col.IsPrimaryKey); Assert.IsFalse(col.IsIdentity); @@ -807,7 +804,7 @@ public async Task PostgresSelectSchema() Assert.IsNull(col.Length); Assert.IsNull(col.Scale); Assert.AreEqual(0, col.Precision); - Assert.AreEqual("DateTime", col.DotNetType); + Assert.AreEqual("DateOnly", col.DotNetType); Assert.IsTrue(col.IsNullable); Assert.IsFalse(col.IsPrimaryKey); Assert.IsFalse(col.IsIdentity); diff --git a/tests/DbEx.Test/DbEx.Test.csproj b/tests/DbEx.Test/DbEx.Test.csproj index 904a75a..b6679ad 100644 --- a/tests/DbEx.Test/DbEx.Test.csproj +++ b/tests/DbEx.Test/DbEx.Test.csproj @@ -1,15 +1,19 @@  - net8.0 + net8.0;net9.0;net10.0 false - - - - + + + + + + + + @@ -28,7 +32,6 @@ - diff --git a/tests/DbEx.Test/PostgresMigrationTest.cs b/tests/DbEx.Test/PostgresMigrationTest.cs index f1cc6ef..d0db490 100644 --- a/tests/DbEx.Test/PostgresMigrationTest.cs +++ b/tests/DbEx.Test/PostgresMigrationTest.cs @@ -1,13 +1,12 @@ -using CoreEx; -using CoreEx.Database; -using CoreEx.Database.Postgres; -using DbEx.Migration; +using DbEx.Migration; using DbEx.Postgres.Console; using DbEx.Postgres.Migration; using DbEx.Test.PostgresConsole; using Microsoft.Extensions.Configuration; +using Npgsql; using NUnit.Framework; using System; +using System.Data.Common; using System.Threading.Tasks; using Assert = NUnit.Framework.Legacy.ClassicAssert; @@ -55,15 +54,16 @@ public async Task B110_Throw_Exceptions() var cs = UnitTest.GetConfig("DbEx_").GetConnectionString("PostgresDb"); using var db = new PostgresDatabase(() => new Npgsql.NpgsqlConnection(cs)); - Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_authorization_exception").Param("message", null).NonQueryAsync()); - Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_business_exception").Param("message", null).NonQueryAsync()); - Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_concurrency_exception").Param("message", null).NonQueryAsync()); - Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_conflict_exception").Param("message", null).NonQueryAsync()); - Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_duplicate_exception").Param("message", null).NonQueryAsync()); - Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_not_found_exception").Param("message", null).NonQueryAsync()); - Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_validation_exception").Param("message", null).NonQueryAsync()); - var vex = Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_validation_exception").Param("message", "On no!").NonQueryAsync()); - Assert.AreEqual("On no!", vex.Message); + Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_authorization_exception").Param("@message", (string)null).NonQueryAsync()).SqlState, "56003"); + Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_business_exception").Param("@message", (string)null).NonQueryAsync()).SqlState, "56002"); + Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_concurrency_exception").Param("@message", (string)null).NonQueryAsync()).SqlState, "56004"); + Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_conflict_exception").Param("@message", (string)null).NonQueryAsync()).SqlState, "56006"); + Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_duplicate_exception").Param("@message", (string)null).NonQueryAsync()).SqlState, "56007"); + Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_not_found_exception").Param("@message", (string)null).NonQueryAsync()).SqlState, "56005"); + Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_validation_exception").Param("@message", (string)null).NonQueryAsync()).SqlState, "56001"); + + var vex = Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_validation_exception").Param("@message", "On no!").NonQueryAsync()); + Assert.AreEqual("On no!", vex.MessageText.TrimEnd()); } [Test] @@ -74,19 +74,25 @@ public async Task B120_Set_Session_Context() var cs = UnitTest.GetConfig("DbEx_").GetConnectionString("PostgresDb"); using var db = new PostgresDatabase(() => new Npgsql.NpgsqlConnection(cs)); - var now = DateTime.UtcNow; - var ts = new DateTime(2024, 09, 30, 23, 45, 08, 123, DateTimeKind.Utc); + var now = DateTimeOffset.UtcNow; + var ts = new DateTimeOffset(2024, 09, 30, 23, 45, 08, 123, TimeSpan.FromHours(8)); + var tsUtc = ts.ToUniversalTime(); - await db.SetPostgresSessionContextAsync("bob@gmail.com", ts, "banana", "bob2"); + await db.StoredProcedure("\"public\".\"sp_set_session_context\"") + .Param("@Username", "bob@gmail.com") + .Param("@Timestamp", ts) + .Param("@TenantId", "banana") + .Param("@UserId", "bob2") + .NonQueryAsync().ConfigureAwait(false); - Assert.That(await db.SqlStatement("select fn_get_timestamp()").ScalarAsync(), Is.EqualTo(ts)); + Assert.That(await db.SqlStatement("select fn_get_timestamp()").ScalarAsync(), Is.EqualTo(tsUtc)); Assert.That(await db.SqlStatement("select fn_get_username()").ScalarAsync(), Is.EqualTo("bob@gmail.com")); Assert.That(await db.SqlStatement("select fn_get_tenant_id()").ScalarAsync(), Is.EqualTo("banana")); Assert.That(await db.SqlStatement("select fn_get_user_id()").ScalarAsync(), Is.EqualTo("bob2")); // Make sure the session context doesn't leak between connections. using var db2 = new PostgresDatabase(() => new Npgsql.NpgsqlConnection(cs)); - Assert.That(await db2.SqlStatement("select fn_get_timestamp()").ScalarAsync(), Is.GreaterThanOrEqualTo(now)); + Assert.That(await db2.SqlStatement("select fn_get_timestamp()").ScalarAsync(), Is.GreaterThanOrEqualTo(now)); Assert.That(await db2.SqlStatement("select fn_get_username()").ScalarAsync(), Is.Not.Null.And.Not.EqualTo("bob@gmail.com")); Assert.That(await db2.SqlStatement("select fn_get_tenant_id()").ScalarAsync(), Is.Null); Assert.That(await db2.SqlStatement("select fn_get_user_id()").ScalarAsync(), Is.Null); diff --git a/tests/DbEx.Test/SqlServerMigrationTest.cs b/tests/DbEx.Test/SqlServerMigrationTest.cs index 3024c17..6712aec 100644 --- a/tests/DbEx.Test/SqlServerMigrationTest.cs +++ b/tests/DbEx.Test/SqlServerMigrationTest.cs @@ -1,8 +1,4 @@ -using CoreEx; -using CoreEx.Database; -using CoreEx.Database.Postgres; -using CoreEx.Database.SqlServer; -using DbEx.Migration; +using DbEx.Migration; using DbEx.Migration.Data; using DbEx.SqlServer.Console; using DbEx.SqlServer.Migration; @@ -86,7 +82,7 @@ public async Task A130_MigrateAll_Console() ContactId = dr.GetValue("ContactId"), Name = dr.GetValue("Name"), Phone = dr.GetValue("Phone"), - DateOfBirth = dr.GetValue("DateOfBirth"), + DateOfBirth = dr.GetValue("DateOfBirth"), ContactTypeId = dr.GetValue("ContactTypeId"), GenderId = dr.GetValue("GenderId"), TenantId = dr.GetValue("TenantId") @@ -98,7 +94,7 @@ public async Task A130_MigrateAll_Console() Assert.AreEqual(1, row.ContactId); Assert.AreEqual("Bob", row.Name); Assert.AreEqual(null, row.Phone); - Assert.AreEqual(new DateTime(2001, 10, 22), row.DateOfBirth); + Assert.AreEqual(new DateOnly(2001, 10, 22), row.DateOfBirth); Assert.AreEqual(1, row.ContactTypeId); Assert.AreEqual(2, row.GenderId); Assert.AreEqual("test-tenant", row.TenantId); @@ -116,7 +112,7 @@ public async Task A130_MigrateAll_Console() Assert.AreEqual(3, row.ContactId); Assert.AreEqual("Barry", row.Name); Assert.AreEqual(null, row.Phone); - Assert.AreEqual(new DateTime(2001, 10, 22), row.DateOfBirth); + Assert.AreEqual(new DateOnly(2001, 10, 22), row.DateOfBirth); Assert.AreEqual(1, row.ContactTypeId); Assert.AreEqual(2, row.GenderId); Assert.IsNull(row.TenantId); // Must be set within SQL script itself; the column default does not extend to SQL scripts themselves. @@ -127,7 +123,7 @@ public async Task A130_MigrateAll_Console() PersonId = dr.GetValue("PersonId"), Name = dr.GetValue("Name"), CreatedBy = dr.GetValue("CreatedBy"), - CreatedDate = dr.GetValue("CreatedDate"), + CreatedOn = dr.GetValue("CreatedOn"), AddressJson = dr.GetValue("AddressJson"), NicknamesJson = dr.GetValue("NicknamesJson") }).ConfigureAwait(false)).ToList(); @@ -137,27 +133,27 @@ public async Task A130_MigrateAll_Console() Assert.AreEqual(DataValueConverter.IntToGuid(88), row2.PersonId); Assert.AreEqual("RUNTIME", row2.Name); Assert.AreEqual(m.Args.DataParserArgs.UserName, row2.CreatedBy); - Assert.AreEqual(m.Args.DataParserArgs.DateTimeNow, row2.CreatedDate); + Assert.AreEqual(m.Args.DataParserArgs.DateTimeNow, row2.CreatedOn); row2 = res2[1]; Assert.AreNotEqual(Guid.Empty, row2.PersonId); Assert.AreEqual("Bazza", row2.Name); Assert.AreEqual(m.Args.DataParserArgs.UserName, row2.CreatedBy); - Assert.AreEqual(m.Args.DataParserArgs.DateTimeNow, row2.CreatedDate); + Assert.AreEqual(m.Args.DataParserArgs.DateTimeNow, row2.CreatedOn); Assert.AreEqual("{\"Street\": \"Main St\", \"City\": \"Maine\"}", row2.AddressJson); Assert.AreEqual("[\"Gaz\", \"Baz\"]", row2.NicknamesJson); // Check that the stored procedure script was migrated and works! - res = (await db.StoredProcedure("[Test].[spGetContact]").Param("@ContactId", 2).SelectQueryAsync(dr => new + res = [.. (await db.StoredProcedure("[Test].[spGetContact]").Param("@ContactId", 2).SelectQueryAsync(dr => new { ContactId = dr.GetValue("ContactId"), Name = dr.GetValue("Name"), Phone = dr.GetValue("Phone"), - DateOfBirth = dr.GetValue("DateOfBirth"), + DateOfBirth = dr.GetValue("DateOfBirth"), ContactTypeId = dr.GetValue("ContactTypeId"), GenderId = dr.GetValue("GenderId"), TenantId = dr.GetValue("TenantId") - }).ConfigureAwait(false)).ToList(); + }).ConfigureAwait(false))]; Assert.AreEqual(1, res.Count); row = res[0]; @@ -195,14 +191,15 @@ public async Task C110_Throw_Exceptions() var (cs, l, m) = await CreateConsoleDb().ConfigureAwait(false); using var db = new SqlServerDatabase(() => new SqlConnection(cs)); - Assert.ThrowsAsync(() => db.StoredProcedure("spThrowAuthorizationException").Param("message", null).NonQueryAsync()); - Assert.ThrowsAsync(() => db.StoredProcedure("spThrowBusinessException").Param("message", null).NonQueryAsync()); - Assert.ThrowsAsync(() => db.StoredProcedure("spThrowConcurrencyException").Param("message", null).NonQueryAsync()); - Assert.ThrowsAsync(() => db.StoredProcedure("spThrowConflictException").Param("message", null).NonQueryAsync()); - Assert.ThrowsAsync(() => db.StoredProcedure("spThrowDuplicateException").Param("message", null).NonQueryAsync()); - Assert.ThrowsAsync(() => db.StoredProcedure("spThrowNotFoundException").Param("message", null).NonQueryAsync()); - Assert.ThrowsAsync(() => db.StoredProcedure("spThrowValidationException").Param("message", null).NonQueryAsync()); - var vex = Assert.ThrowsAsync(() => db.StoredProcedure("spThrowValidationException").Param("message", "On no!").NonQueryAsync()); + Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("spThrowAuthorizationException").Param("message", (string)null).NonQueryAsync()).Number, 56003); + Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("spThrowBusinessException").Param("message", (string)null).NonQueryAsync()).Number, 56002); + Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("spThrowConcurrencyException").Param("message", (string)null).NonQueryAsync()).Number, 56004); + Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("spThrowConflictException").Param("message", (string)null).NonQueryAsync()).Number, 56006); + Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("spThrowDuplicateException").Param("message", (string)null).NonQueryAsync()).Number, 56007); + Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("spThrowNotFoundException").Param("message", (string)null).NonQueryAsync()).Number, 56005); + Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("spThrowValidationException").Param("message", (string)null).NonQueryAsync()).Number, 56001); + + var vex = Assert.ThrowsAsync(() => db.StoredProcedure("spThrowValidationException").Param("message", "On no!").NonQueryAsync()); Assert.AreEqual("On no!", vex.Message); } @@ -212,19 +209,25 @@ public async Task C120_Set_Session_Context() var (cs, l, m) = await CreateConsoleDb().ConfigureAwait(false); using var db = new SqlServerDatabase(() => new SqlConnection(cs)); - var now = DateTime.UtcNow; - var ts = new DateTime(2024, 09, 30, 23, 45, 08, 123, DateTimeKind.Utc); + var now = DateTimeOffset.UtcNow; + var ts = new DateTimeOffset(2024, 09, 30, 23, 45, 08, 123, TimeSpan.FromHours(8)); + var tsUtc = ts.ToUniversalTime(); - await db.SetSqlSessionContextAsync("bob@gmail.com", ts, "banana", "bob2"); + await db.StoredProcedure("[dbo].[spSetSessionContext]") + .Param("@Username", "bob@gmail.com") + .Param("@Timestamp", ts) + .Param("@TenantId", "banana") + .Param("@UserId", "bob2") + .NonQueryAsync().ConfigureAwait(false); - Assert.That(await db.SqlStatement("select dbo.fnGetTimestamp(null)").ScalarAsync(), Is.EqualTo(ts)); + Assert.That(await db.SqlStatement("select dbo.fnGetTimestamp(null)").ScalarAsync(), Is.EqualTo(tsUtc)); Assert.That(await db.SqlStatement("select dbo.fnGetUsername(null)").ScalarAsync(), Is.EqualTo("bob@gmail.com")); Assert.That(await db.SqlStatement("select dbo.fnGetTenantId(null)").ScalarAsync(), Is.EqualTo("banana")); Assert.That(await db.SqlStatement("select dbo.fnGetUserId(null)").ScalarAsync(), Is.EqualTo("bob2")); // Make sure the session context doesn't leak between connections. using var db2 = new SqlServerDatabase(() => new SqlConnection(cs)); - Assert.That(await db2.SqlStatement("select dbo.fnGetTimestamp(null)").ScalarAsync(), Is.GreaterThanOrEqualTo(now)); + Assert.That(await db2.SqlStatement("select dbo.fnGetTimestamp(null)").ScalarAsync(), Is.GreaterThanOrEqualTo(now)); Assert.That(await db2.SqlStatement("select dbo.fnGetUsername(null)").ScalarAsync(), Is.Not.Null.And.Not.EqualTo("bob@gmail.com")); Assert.That(await db2.SqlStatement("select dbo.fnGetTenantId(null)").ScalarAsync(), Is.Null); Assert.That(await db2.SqlStatement("select dbo.fnGetUserId(null)").ScalarAsync(), Is.Null); @@ -276,7 +279,7 @@ public async Task B100_Execute_Console_Success() var a = new MigrationArgs(MigrationCommand.Execute, c.cs) { Logger = c.l }.AddAssembly(typeof(Console.Program).Assembly); using var m = new SqlServerMigration(a); - var r = await m.ExecuteSqlStatementsAsync(new string[] { "SELECT * FROM Test.Contact" }).ConfigureAwait(false); + var r = await m.ExecuteSqlStatementsAsync(["SELECT * FROM Test.Contact"]).ConfigureAwait(false); Assert.IsTrue(r); } @@ -287,7 +290,7 @@ public async Task B110_Execute_Console_Error() var a = new MigrationArgs(MigrationCommand.Execute, c.cs) { Logger = c.l }.AddAssembly(typeof(Console.Program).Assembly); using var m = new SqlServerMigration(a); - var r = await m.ExecuteSqlStatementsAsync(new string[] { "SELECT * FROM Test.Contact", "SELECT BANANAS" }).ConfigureAwait(false); + var r = await m.ExecuteSqlStatementsAsync(["SELECT * FROM Test.Contact", "SELECT BANANAS"]).ConfigureAwait(false); Assert.IsFalse(r); } @@ -298,7 +301,7 @@ public async Task B120_Execute_Console_Batch_Error() var a = new MigrationArgs(MigrationCommand.Execute, c.cs) { Logger = c.l }.AddAssembly(typeof(Console.Program).Assembly); using var m = new SqlServerMigration(a); - var r = await m.ExecuteSqlStatementsAsync(new string[] { @"SELECT * FROM Test.ContactBad; /* end */ GO; SELECT * FROM Test.Contact -- comment" }).ConfigureAwait(false); + var r = await m.ExecuteSqlStatementsAsync([@"SELECT * FROM Test.ContactBad; /* end */ GO; SELECT * FROM Test.Contact -- comment"]).ConfigureAwait(false); Assert.IsFalse(r); } @@ -309,10 +312,10 @@ public async Task B130_Execute_Console_Batch_Success() var a = new MigrationArgs(MigrationCommand.Execute, c.cs) { Logger = c.l }.AddAssembly(typeof(Console.Program).Assembly); using var m = new SqlServerMigration(a); - var r = await m.ExecuteSqlStatementsAsync(new string[] { @"SELECT * FROM Test.Contact; + var r = await m.ExecuteSqlStatementsAsync([ @"SELECT * FROM Test.Contact; /* end */ GO -SELECT * FROM Test.Contact -- comment" }).ConfigureAwait(false); +SELECT * FROM Test.Contact -- comment" ]).ConfigureAwait(false); Assert.IsTrue(r); } diff --git a/tests/DbEx.Test/appsettings.json b/tests/DbEx.Test/appsettings.json index 07aa1e7..ad293ec 100644 --- a/tests/DbEx.Test/appsettings.json +++ b/tests/DbEx.Test/appsettings.json @@ -1,16 +1,11 @@ { "ConnectionStrings": { - // Local machine... - "NoneDb": "Data Source=.;Initial Catalog=DbEx.None;Integrated Security=True;TrustServerCertificate=true", - "EmptyDb": "Data Source=.;Initial Catalog=DbEx.Empty;Integrated Security=True;TrustServerCertificate=true", - "ErrorDb": "Data Source=.;Initial Catalog=DbEx.Error;Integrated Security=True;TrustServerCertificate=true", - "ConsoleDb": "Data Source=.;Initial Catalog=DbEx.Console;Integrated Security=True;TrustServerCertificate=true", - "MySqlDb": "Server=localhost; Port=3306; Database=dbex_test; Uid=dbuser; Pwd=dbpassword;", - "PostgresDb": "Server=localhost;Port=5432;Username=postgres;Password=dbpassword;Database=dbex_test;Pooling=false" - - // WSL ... - //"NoneDb": "Data Source=localhost,1433;Initial Catalog=DbEx.None;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true", - //"EmptyDb": "Data Source=localhost,1433;Initial Catalog=DbEx.Empty;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true", - //"ConsoleDb": "Data Source=localhost,1433;Initial Catalog=DbEx.Console;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true" + // Assumes running in WSL ... + "NoneDb": "Data Source=127.0.0.1,1433;Initial Catalog=DbEx.None;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true", + "EmptyDb": "Data Source=127.0.0.1,1433;Initial Catalog=DbEx.Empty;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true", + "ErrorDb": "Data Source=127.0.0.1,1433;Initial Catalog=DbEx.Error;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true", + "ConsoleDb": "Data Source=127.0.0.1,1433;Initial Catalog=DbEx.Console;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true", + "MySqlDb": "Server=127.0.0.1; Port=3306; Database=dbex_test; Uid=root; Pwd=yourStrong#!Password;", + "PostgresDb": "Server=127.0.0.1;Port=5432;Database=dbex_test;Username=postgres;Password=yourStrong#!Password;Pooling=false" } } \ No newline at end of file From 3d10b2583b5d78a633a64b87497fb308104188aa Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Thu, 8 Jan 2026 07:00:03 -0800 Subject: [PATCH 02/11] Global usings and namespace reformat. --- .../Console/MySqlMigrationConsole.cs | 100 +- src/DbEx.MySql/GlobalUsing.cs | 14 + src/DbEx.MySql/Migration/MySqlDatabase.cs | 17 +- src/DbEx.MySql/Migration/MySqlMigration.cs | 144 +- src/DbEx.MySql/Migration/MySqlSchemaScript.cs | 180 +- src/DbEx.MySql/MySqlSchemaConfig.cs | 372 ++-- .../Console/MigrationArgsExtensions.cs | 48 +- .../Console/PostgresMigrationConsole.cs | 102 +- src/DbEx.Postgres/GlobalUsing.cs | 14 + .../Migration/PostgresDatabase.cs | 17 +- .../Migration/PostgresMigration.cs | 130 +- .../Migration/PostgresSchemaScript.cs | 206 +- src/DbEx.Postgres/PostgresSchemaConfig.cs | 373 ++-- .../Console/MigrationArgsExtensions.cs | 49 +- .../Console/SqlServerMigrationConsole.cs | 106 +- src/DbEx.SqlServer/GlobalUsing.cs | 14 + .../Migration/SqlServerDatabase.cs | 17 +- .../Migration/SqlServerMigration.cs | 176 +- .../Migration/SqlServerSchemaScript.cs | 206 +- src/DbEx.SqlServer/SqlServerSchemaConfig.cs | 433 +++-- src/DbEx/CodeGen/Config/CodeGenConfig.cs | 1 + src/DbEx/CodeGen/Config/IColumnConfig.cs | 162 ++ src/DbEx/CodeGen/Config/ISpecialColumns.cs | 103 + src/DbEx/Console/AssemblyValidator.cs | 65 +- src/DbEx/Console/MigrationConsoleBase.cs | 733 ++++---- src/DbEx/Console/MigrationConsoleBaseT.cs | 207 +-- src/DbEx/Console/ParametersValidator.cs | 72 +- src/DbEx/DatabaseExtensions.cs | 155 +- src/DbEx/DatabaseSchemaConfig.cs | 372 ++-- src/DbEx/DbSchema/DbColumnSchema.cs | 480 +++-- src/DbEx/DbSchema/DbTableSchema.cs | 382 ++-- src/DbEx/GlobalUsing.cs | 36 + src/DbEx/Migration/Data/DataColumn.cs | 98 +- src/DbEx/Migration/Data/DataConfig.cs | 41 +- src/DbEx/Migration/Data/DataParser.cs | 621 +++---- src/DbEx/Migration/Data/DataParserArgs.cs | 318 ++-- .../Migration/Data/DataParserColumnDefault.cs | 51 +- .../Data/DataParserColumnDefaultCollection.cs | 104 +- .../Migration/Data/DataParserException.cs | 41 +- .../Data/DataParserTableNameMappings.cs | 109 +- src/DbEx/Migration/Data/DataRow.cs | 281 ++- src/DbEx/Migration/Data/DataTable.cs | 476 +++-- .../Migration/Data/DataTableIdentifierType.cs | 51 +- src/DbEx/Migration/Data/DataValueConverter.cs | 28 +- .../Migration/Data/GuidIdentifierGenerator.cs | 29 +- .../Migration/Data/IIdentifierGenerator.cs | 53 +- src/DbEx/Migration/Database.cs | 431 +++-- src/DbEx/Migration/DatabaseCommand.cs | 218 ++- src/DbEx/Migration/DatabaseJournal.cs | 112 +- src/DbEx/Migration/DatabaseMigrationBase.cs | 1652 ++++++++--------- src/DbEx/Migration/DatabaseMigrationScript.cs | 153 +- src/DbEx/Migration/DatabaseRecord.cs | 130 +- .../Migration/DatabaseSchemaScriptBase.cs | 185 +- src/DbEx/Migration/IDatabase.cs | 80 +- src/DbEx/Migration/IDatabaseJournal.cs | 63 +- src/DbEx/Migration/MigrationArgs.cs | 47 +- src/DbEx/Migration/MigrationArgsBase.cs | 639 ++++--- src/DbEx/Migration/MigrationArgsBaseT.cs | 177 +- src/DbEx/Migration/MigrationAssemblyArgs.cs | 55 +- src/DbEx/Migration/StringLogger.cs | 41 +- src/DbEx/MigrationCommand.cs | 226 ++- 61 files changed, 5929 insertions(+), 6067 deletions(-) create mode 100644 src/DbEx.MySql/GlobalUsing.cs create mode 100644 src/DbEx.Postgres/GlobalUsing.cs create mode 100644 src/DbEx.SqlServer/GlobalUsing.cs create mode 100644 src/DbEx/CodeGen/Config/CodeGenConfig.cs create mode 100644 src/DbEx/CodeGen/Config/IColumnConfig.cs create mode 100644 src/DbEx/CodeGen/Config/ISpecialColumns.cs create mode 100644 src/DbEx/GlobalUsing.cs diff --git a/src/DbEx.MySql/Console/MySqlMigrationConsole.cs b/src/DbEx.MySql/Console/MySqlMigrationConsole.cs index 2ef199a..5649d1f 100644 --- a/src/DbEx.MySql/Console/MySqlMigrationConsole.cs +++ b/src/DbEx.MySql/Console/MySqlMigrationConsole.cs @@ -1,68 +1,58 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.MySql.Console; -using DbEx.Console; -using DbEx.Migration; -using DbEx.MySql.Migration; -using Microsoft.Extensions.Logging; -using System; -using System.Reflection; - -namespace DbEx.MySql.Console +/// +/// Console that facilitates the by managing the standard console command-line arguments/options. +/// +public sealed class MySqlMigrationConsole : MigrationConsoleBase { /// - /// Console that facilitates the by managing the standard console command-line arguments/options. + /// Creates a new using to default the probing . /// - public sealed class MySqlMigrationConsole : MigrationConsoleBase - { - /// - /// Creates a new using to default the probing . - /// - /// The . - /// The database connection string. - /// A new . - public static MySqlMigrationConsole Create(string connectionString) => new(new MigrationArgs { ConnectionString = connectionString }.AddAssembly(typeof(T).Assembly)); + /// The . + /// The database connection string. + /// A new . + public static MySqlMigrationConsole Create(string connectionString) => new(new MigrationArgs { ConnectionString = connectionString }.AddAssembly(typeof(T).Assembly)); - /// - /// Initializes a new instance of the class. - /// - /// The default that will be overridden/updated by the command-line argument values. - public MySqlMigrationConsole(MigrationArgs? args = null) : base(args ?? new MigrationArgs()) { } + /// + /// Initializes a new instance of the class. + /// + /// The default that will be overridden/updated by the command-line argument values. + public MySqlMigrationConsole(MigrationArgs? args = null) : base(args ?? new MigrationArgs()) { } - /// - /// Initializes a new instance of the class that provides a default for the . - /// - /// The database connection string. - public MySqlMigrationConsole(string connectionString) : base(new MigrationArgs { ConnectionString = connectionString.ThrowIfNull(nameof(connectionString)) }) { } + /// + /// Initializes a new instance of the class that provides a default for the . + /// + /// The database connection string. + public MySqlMigrationConsole(string connectionString) : base(new MigrationArgs { ConnectionString = connectionString.ThrowIfNull(nameof(connectionString)) }) { } - /// - /// Gets the . - /// - public new MigrationArgs Args => (MigrationArgs)base.Args; + /// + /// Gets the . + /// + public new MigrationArgs Args => (MigrationArgs)base.Args; - /// - protected override DatabaseMigrationBase CreateMigrator() => new MySqlMigration(Args); + /// + protected override DatabaseMigrationBase CreateMigrator() => new MySqlMigration(Args); - /// - public override string AppTitle => base.AppTitle + " [MySQL]"; + /// + public override string AppTitle => base.AppTitle + " [MySQL]"; - /// - protected override void OnWriteHelp() - { - base.OnWriteHelp(); - WriteScriptHelp(); - Logger?.LogInformation("{help}", string.Empty); - } + /// + protected override void OnWriteHelp() + { + base.OnWriteHelp(); + WriteScriptHelp(); + Logger?.LogInformation("{help}", string.Empty); + } - /// - /// Writes the supported help content. - /// - public void WriteScriptHelp() - { - Logger?.LogInformation("{help}", "Script command and argument(s):"); - Logger?.LogInformation("{help}", " script [default] Creates a default (empty) SQL script."); - Logger?.LogInformation("{help}", " script alter Creates a SQL script to perform an ALTER TABLE."); - Logger?.LogInformation("{help}", " script create
Creates a SQL script to perform a CREATE TABLE."); - Logger?.LogInformation("{help}", " script refdata
Creates a SQL script to perform a CREATE TABLE as reference data."); - } + /// + /// Writes the supported help content. + /// + public void WriteScriptHelp() + { + Logger?.LogInformation("{help}", "Script command and argument(s):"); + Logger?.LogInformation("{help}", " script [default] Creates a default (empty) SQL script."); + Logger?.LogInformation("{help}", " script alter
Creates a SQL script to perform an ALTER TABLE."); + Logger?.LogInformation("{help}", " script create
Creates a SQL script to perform a CREATE TABLE."); + Logger?.LogInformation("{help}", " script refdata
Creates a SQL script to perform a CREATE TABLE as reference data."); } } \ No newline at end of file diff --git a/src/DbEx.MySql/GlobalUsing.cs b/src/DbEx.MySql/GlobalUsing.cs new file mode 100644 index 0000000..51e693d --- /dev/null +++ b/src/DbEx.MySql/GlobalUsing.cs @@ -0,0 +1,14 @@ +global using DbEx.Console; +global using DbEx.DbSchema; +global using DbEx.Migration; +global using DbEx.MySql.Migration; +global using DbUp.Support; +global using Microsoft.Extensions.Logging; +global using MySql.Data.MySqlClient; +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Reflection; +global using System.Text; +global using System.Threading; +global using System.Threading.Tasks; \ No newline at end of file diff --git a/src/DbEx.MySql/Migration/MySqlDatabase.cs b/src/DbEx.MySql/Migration/MySqlDatabase.cs index 7d62766..b66464e 100644 --- a/src/DbEx.MySql/Migration/MySqlDatabase.cs +++ b/src/DbEx.MySql/Migration/MySqlDatabase.cs @@ -1,12 +1,7 @@ -using DbEx.Migration; -using MySql.Data.MySqlClient; -using System; +namespace DbEx.MySql.Migration; -namespace DbEx.MySql.Migration -{ - /// - /// Provides MySQL database access functionality. - /// - /// - public class MySqlDatabase(Func create) : Database(create, MySqlClientFactory.Instance) { } -} \ No newline at end of file +/// +/// Provides MySQL database access functionality. +/// +/// +public class MySqlDatabase(Func create) : Database(create, MySqlClientFactory.Instance) { } \ No newline at end of file diff --git a/src/DbEx.MySql/Migration/MySqlMigration.cs b/src/DbEx.MySql/Migration/MySqlMigration.cs index f9cc44b..e4c71af 100644 --- a/src/DbEx.MySql/Migration/MySqlMigration.cs +++ b/src/DbEx.MySql/Migration/MySqlMigration.cs @@ -1,103 +1,91 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx - -using DbEx.DbSchema; -using DbEx.Migration; -using DbUp.Support; -using MySql.Data.MySqlClient; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.MySql.Migration +namespace DbEx.MySql.Migration; + +/// +/// Provides the MySQL migration orchestration. +/// +/// The following are supported by default: ''FUNCTION', 'VIEW', 'PROCEDURE'. +/// The base instance is updated; the and properties are set to `null` and `schemaversions` respectively. +public class MySqlMigration : DatabaseMigrationBase { + private readonly string _databaseName; + private readonly IDatabase _database; + private readonly IDatabase _masterDatabase; + private readonly List _resetBypass = []; + /// - /// Provides the MySQL migration orchestration. + /// Initializes an instance of the class. /// - /// The following are supported by default: ''FUNCTION', 'VIEW', 'PROCEDURE'. - /// The base instance is updated; the and properties are set to `null` and `schemaversions` respectively. - public class MySqlMigration : DatabaseMigrationBase + /// The . + public MySqlMigration(MigrationArgsBase args) : base(args) { - private readonly string _databaseName; - private readonly IDatabase _database; - private readonly IDatabase _masterDatabase; - private readonly List _resetBypass = []; - - /// - /// Initializes an instance of the class. - /// - /// The . - public MySqlMigration(MigrationArgsBase args) : base(args) - { - SchemaConfig = new MySqlSchemaConfig(this); + SchemaConfig = new MySqlSchemaConfig(this); - var csb = new MySqlConnectionStringBuilder(Args.ConnectionString); - _databaseName = csb.Database; - if (string.IsNullOrEmpty(_databaseName)) - throw new ArgumentException($"The {nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionString)} property must contain a database name.", nameof(args)); + var csb = new MySqlConnectionStringBuilder(Args.ConnectionString); + _databaseName = csb.Database; + if (string.IsNullOrEmpty(_databaseName)) + throw new ArgumentException($"The {nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionString)} property must contain a database name.", nameof(args)); - _database = new MySqlDatabase(() => new MySqlConnection(Args.ConnectionString)); + _database = new MySqlDatabase(() => new MySqlConnection(Args.ConnectionString)); - csb.Database = null; - _masterDatabase = new MySqlDatabase(() => new MySqlConnection(csb.ConnectionString)); + csb.Database = null; + _masterDatabase = new MySqlDatabase(() => new MySqlConnection(csb.ConnectionString)); - // Add this assembly for probing. - Args.AddAssemblyAfter(typeof(DatabaseMigrationBase).Assembly, typeof(MySqlMigration).Assembly); + // Add this assembly for probing. + Args.AddAssemblyAfter(typeof(DatabaseMigrationBase).Assembly, typeof(MySqlMigration).Assembly); - // Defaults the schema object types unless already specified. - if (SchemaObjectTypes.Length == 0) - SchemaObjectTypes = ["FUNCTION", "VIEW", "PROCEDURE"]; + // Defaults the schema object types unless already specified. + if (SchemaObjectTypes.Length == 0) + SchemaObjectTypes = ["FUNCTION", "VIEW", "PROCEDURE"]; - // MySql will require all schema objects to be dropped as replacements are not currently supported. - if (MustDropSchemaObjectTypes.Length == 0) - MustDropSchemaObjectTypes = ["FUNCTION", "VIEW", "PROCEDURE"]; + // MySql will require all schema objects to be dropped as replacements are not currently supported. + if (MustDropSchemaObjectTypes.Length == 0) + MustDropSchemaObjectTypes = ["FUNCTION", "VIEW", "PROCEDURE"]; - // Add/set standard parameters. - Args.AddParameter(MigrationArgsBase.DatabaseNameParamName, _databaseName, true); - Args.AddParameter(MigrationArgsBase.JournalSchemaParamName, null, true); - Args.AddParameter(MigrationArgsBase.JournalTableParamName, "schemaversions"); - } + // Add/set standard parameters. + Args.AddParameter(MigrationArgsBase.DatabaseNameParamName, _databaseName, true); + Args.AddParameter(MigrationArgsBase.JournalSchemaParamName, null, true); + Args.AddParameter(MigrationArgsBase.JournalTableParamName, "schemaversions"); + } - /// - public override string Provider => "MySQL"; + /// + public override string Provider => "MySQL"; - /// - public override string DatabaseName => _databaseName; + /// + public override string DatabaseName => _databaseName; - /// - public override IDatabase Database => _database; + /// + public override IDatabase Database => _database; - /// - public override IDatabase MasterDatabase => _masterDatabase; + /// + public override IDatabase MasterDatabase => _masterDatabase; - /// - public override DatabaseSchemaConfig SchemaConfig { get; } + /// + public override DatabaseSchemaConfig SchemaConfig { get; } - /// - protected override DatabaseSchemaScriptBase CreateSchemaScript(DatabaseMigrationScript migrationScript) => MySqlSchemaScript.Create(migrationScript); + /// + protected override DatabaseSchemaScriptBase CreateSchemaScript(DatabaseMigrationScript migrationScript) => MySqlSchemaScript.Create(migrationScript); - /// - protected override async Task DatabaseResetAsync(CancellationToken cancellationToken = default) - { - // Filter out the versioning table. - _resetBypass.Add(SchemaConfig.ToFullyQualifiedTableName(Journal.Schema!, Journal.Table!)); + /// + protected override async Task DatabaseResetAsync(CancellationToken cancellationToken = default) + { + // Filter out the versioning table. + _resetBypass.Add(SchemaConfig.ToFullyQualifiedTableName(Journal.Schema!, Journal.Table!)); - // Carry on as they say ;-) - return await base.DatabaseResetAsync(cancellationToken).ConfigureAwait(false); - } + // Carry on as they say ;-) + return await base.DatabaseResetAsync(cancellationToken).ConfigureAwait(false); + } - /// - protected override Func DataResetFilterPredicate => schema => !_resetBypass.Contains(schema.QualifiedName!); + /// + protected override Func DataResetFilterPredicate => schema => !_resetBypass.Contains(schema.QualifiedName!); - /// - protected override async Task ExecuteScriptAsync(DatabaseMigrationScript script, CancellationToken cancellationToken = default) - { - using var sr = script.GetStreamReader(); + /// + protected override async Task ExecuteScriptAsync(DatabaseMigrationScript script, CancellationToken cancellationToken = default) + { + using var sr = script.GetStreamReader(); - foreach (var sql in new SqlCommandSplitter().SplitScriptIntoCommands(sr.ReadToEnd())) - { - await Database.SqlStatement(ReplaceSqlRuntimeParameters(sql)).NonQueryAsync(cancellationToken).ConfigureAwait(false); - } + foreach (var sql in new SqlCommandSplitter().SplitScriptIntoCommands(sr.ReadToEnd())) + { + await Database.SqlStatement(ReplaceSqlRuntimeParameters(sql)).NonQueryAsync(cancellationToken).ConfigureAwait(false); } } } \ No newline at end of file diff --git a/src/DbEx.MySql/Migration/MySqlSchemaScript.cs b/src/DbEx.MySql/Migration/MySqlSchemaScript.cs index 8150039..dc59fb6 100644 --- a/src/DbEx.MySql/Migration/MySqlSchemaScript.cs +++ b/src/DbEx.MySql/Migration/MySqlSchemaScript.cs @@ -1,121 +1,111 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.MySql.Migration; -using DbEx.Migration; -using DbUp.Support; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - -namespace DbEx.MySql.Migration +/// +/// Provides the MySQL database schema script functionality. +/// +public class MySqlSchemaScript : DatabaseSchemaScriptBase { /// - /// Provides the MySQL database schema script functionality. + /// Creates the from the . /// - public class MySqlSchemaScript : DatabaseSchemaScriptBase + /// The . + /// The . + public static MySqlSchemaScript Create(DatabaseMigrationScript migrationScript) { - /// - /// Creates the from the . - /// - /// The . - /// The . - public static MySqlSchemaScript Create(DatabaseMigrationScript migrationScript) - { - var script = new MySqlSchemaScript(migrationScript); + var script = new MySqlSchemaScript(migrationScript); - using var sr = script.MigrationScript.GetStreamReader(); - var sql = sr.ReadToEnd(); - var tokens = new SqlCommandTokenizer(sql).ReadAllTokens(); + using var sr = script.MigrationScript.GetStreamReader(); + var sql = sr.ReadToEnd(); + var tokens = new SqlCommandTokenizer(sql).ReadAllTokens(); - for (int i = 0; i < tokens.Length; i++) - { - if (string.Compare(tokens[i], "create", StringComparison.OrdinalIgnoreCase) != 0) - continue; + for (int i = 0; i < tokens.Length; i++) + { + if (string.Compare(tokens[i], "create", StringComparison.OrdinalIgnoreCase) != 0) + continue; - if (i + 2 < tokens.Length) - { - script.Type = tokens[i + 1]; - script.FullyQualifiedName = tokens[i + 2]; - script.Name = script.FullyQualifiedName; - return script; - } + if (i + 2 < tokens.Length) + { + script.Type = tokens[i + 1]; + script.FullyQualifiedName = tokens[i + 2]; + script.Name = script.FullyQualifiedName; + return script; } - - script.ErrorMessage = "The SQL statement must be a valid 'CREATE' statement."; - return script; } - /// - /// Initializes a new instance of the class. - /// - /// The parent . - private MySqlSchemaScript(DatabaseMigrationScript migrationScript) : base(migrationScript, "`", "`") { } + script.ErrorMessage = "The SQL statement must be a valid 'CREATE' statement."; + return script; + } + + /// + /// Initializes a new instance of the class. + /// + /// The parent . + private MySqlSchemaScript(DatabaseMigrationScript migrationScript) : base(migrationScript, "`", "`") { } - /// - public override string SqlDropStatement => $"DROP {Type.ToUpperInvariant()} IF EXISTS `{Name}`"; + /// + public override string SqlDropStatement => $"DROP {Type.ToUpperInvariant()} IF EXISTS `{Name}`"; - /// - public override string SqlCreateStatement => $"CREATE {Type.ToUpperInvariant()} `{Name}`"; + /// + public override string SqlCreateStatement => $"CREATE {Type.ToUpperInvariant()} `{Name}`"; - private class SqlCommandTokenizer(string sqlText) : SqlCommandReader(sqlText) + private class SqlCommandTokenizer(string sqlText) : SqlCommandReader(sqlText) + { + private readonly char[] delimiters = ['(', ')', ';', ',', '=']; + + public string[] ReadAllTokens() { - private readonly char[] delimiters = ['(', ')', ';', ',', '=']; + var words = new List(); + var sb = new StringBuilder(); - public string[] ReadAllTokens() + while (!HasReachedEnd) { - var words = new List(); - var sb = new StringBuilder(); - - while (!HasReachedEnd) + ReadCharacter += (type, c) => { - ReadCharacter += (type, c) => + switch (type) { - switch (type) - { - case CharacterType.Command: - if (char.IsWhiteSpace(c)) - { - if (sb.Length > 0) - words.Add(sb.ToString()); - - sb.Clear(); - break; - } - else if (delimiters.Contains(c)) - { - if (sb.Length > 0) - words.Add(sb.ToString()); - - sb.Clear(); - } - - sb.Append(c); - break; - - case CharacterType.BracketedText: - case CharacterType.QuotedString: - sb.Append(c); - break; + case CharacterType.Command: + if (char.IsWhiteSpace(c)) + { + if (sb.Length > 0) + words.Add(sb.ToString()); - case CharacterType.SlashStarComment: - case CharacterType.DashComment: - case CharacterType.CustomStatement: - case CharacterType.Delimiter: + sb.Clear(); break; + } + else if (delimiters.Contains(c)) + { + if (sb.Length > 0) + words.Add(sb.ToString()); + + sb.Clear(); + } + + sb.Append(c); + break; + + case CharacterType.BracketedText: + case CharacterType.QuotedString: + sb.Append(c); + break; + + case CharacterType.SlashStarComment: + case CharacterType.DashComment: + case CharacterType.CustomStatement: + case CharacterType.Delimiter: + break; + + default: + throw new ArgumentOutOfRangeException(nameof(type), type, null); + } + }; + + Parse(); + } - default: - throw new ArgumentOutOfRangeException(nameof(type), type, null); - } - }; - - Parse(); - } - - if (sb.Length > 0) - words.Add(sb.ToString()); + if (sb.Length > 0) + words.Add(sb.ToString()); - return [.. words]; - } + return [.. words]; } } } \ No newline at end of file diff --git a/src/DbEx.MySql/MySqlSchemaConfig.cs b/src/DbEx.MySql/MySqlSchemaConfig.cs index 8f51d58..8207598 100644 --- a/src/DbEx.MySql/MySqlSchemaConfig.cs +++ b/src/DbEx.MySql/MySqlSchemaConfig.cs @@ -1,232 +1,218 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx - -using DbEx.DbSchema; -using DbEx.Migration; -using DbEx.MySql.Migration; -using System; -using System.Collections.Generic; -using System.Data; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.MySql +namespace DbEx.MySql; + +/// +/// Provides MySQL specific configuration and capabilities. +/// +/// The owning . +public class MySqlSchemaConfig(MySqlMigration migration) : DatabaseSchemaConfig(migration) { - /// - /// Provides MySQL specific configuration and capabilities. - /// - /// The owning . - public class MySqlSchemaConfig(MySqlMigration migration) : DatabaseSchemaConfig(migration) - { - /// - /// Value is '_id'. - public override string IdColumnNameSuffix => "_id"; + /// + /// Value is '_id'. + public override string IdColumnNameSuffix => "_id"; - /// - /// Value is '_code'. - public override string CodeColumnNameSuffix => "_code"; + /// + /// Value is '_code'. + public override string CodeColumnNameSuffix => "_code"; - /// - /// Value is '_json'. - public override string JsonColumnNameSuffix => "_json"; + /// + /// Value is '_json'. + public override string JsonColumnNameSuffix => "_json"; - /// - /// Value is 'created_on'. - public override string CreatedOnColumnName => "created_on"; + /// + /// Value is 'created_on'. + public override string CreatedOnColumnName => "created_on"; - /// - /// Value is 'created_by'. - public override string CreatedByColumnName => "created_by"; + /// + /// Value is 'created_by'. + public override string CreatedByColumnName => "created_by"; - /// - /// Value is 'updated_on'. - public override string UpdatedOnColumnName => "updated_on"; + /// + /// Value is 'updated_on'. + public override string UpdatedOnColumnName => "updated_on"; - /// - /// Value is 'updated_by'. - public override string UpdatedByColumnName => "updated_by"; + /// + /// Value is 'updated_by'. + public override string UpdatedByColumnName => "updated_by"; - /// - /// Value is 'tenant_id'. - public override string TenantIdColumnName => "tenant_id"; + /// + /// Value is 'tenant_id'. + public override string TenantIdColumnName => "tenant_id"; - /// - /// Value is 'row_version'. - public override string RowVersionColumnName => "row_version"; + /// + /// Value is 'row_version'. + public override string RowVersionColumnName => "row_version"; - /// - /// Value is 'is_deleted'. - public override string IsDeletedColumnName => "is_deleted"; + /// + /// Value is 'is_deleted'. + public override string IsDeletedColumnName => "is_deleted"; - /// - /// Value is 'code'. - public override string RefDataCodeColumnName => "code"; + /// + /// Value is 'code'. + public override string RefDataCodeColumnName => "code"; - /// - /// Value is 'text'. - public override string RefDataTextColumnName => "text"; + /// + /// Value is 'text'. + public override string RefDataTextColumnName => "text"; - /// - public override string ToFullyQualifiedTableName(string? schema, string table) => $"`{table}`"; + /// + public override string ToFullyQualifiedTableName(string? schema, string table) => $"`{table}`"; - /// - public override void PrepareMigrationArgs() - { - base.PrepareMigrationArgs(); + /// + public override void PrepareMigrationArgs() + { + base.PrepareMigrationArgs(); - Migration.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("is_active", _ => true); - Migration.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("sort_order", i => i); - } + Migration.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("is_active", _ => true); + Migration.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("sort_order", i => i); + } - /// - public override DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema table, DatabaseRecord dr) + /// + public override DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema table, DatabaseRecord dr) + { + var dt = dr.GetValue("DATA_TYPE")!; + if (string.Compare(dt, "TINYINT", StringComparison.OrdinalIgnoreCase) == 0 && dr.GetValue("COLUMN_TYPE")!.Equals("TINYINT(1)", StringComparison.OrdinalIgnoreCase)) + dt = "BOOL"; + + var c = new DbColumnSchema(table, dr.GetValue("COLUMN_NAME")!, dt) { - var dt = dr.GetValue("DATA_TYPE")!; - if (string.Compare(dt, "TINYINT", StringComparison.OrdinalIgnoreCase) == 0 && dr.GetValue("COLUMN_TYPE")!.Equals("TINYINT(1)", StringComparison.OrdinalIgnoreCase)) - dt = "BOOL"; + IsNullable = dr.GetValue("IS_NULLABLE")!.Equals("YES", StringComparison.OrdinalIgnoreCase), + Length = (ulong?)dr.GetValue("CHARACTER_MAXIMUM_LENGTH"), + Precision = dr.GetValue("NUMERIC_PRECISION") ?? dr.GetValue("DATETIME_PRECISION"), + Scale = dr.GetValue("NUMERIC_SCALE"), + DefaultValue = dr.GetValue("COLUMN_DEFAULT"), + IsDotNetDateOnly = RemovePrecisionFromDataType(dt).Equals("DATE", StringComparison.OrdinalIgnoreCase), + IsDotNetTimeOnly = RemovePrecisionFromDataType(dt).Equals("TIME", StringComparison.OrdinalIgnoreCase) + }; - var c = new DbColumnSchema(table, dr.GetValue("COLUMN_NAME")!, dt) - { - IsNullable = dr.GetValue("IS_NULLABLE")!.Equals("YES", StringComparison.OrdinalIgnoreCase), - Length = (ulong?)dr.GetValue("CHARACTER_MAXIMUM_LENGTH"), - Precision = dr.GetValue("NUMERIC_PRECISION") ?? dr.GetValue("DATETIME_PRECISION"), - Scale = dr.GetValue("NUMERIC_SCALE"), - DefaultValue = dr.GetValue("COLUMN_DEFAULT"), - IsDotNetDateOnly = RemovePrecisionFromDataType(dt).Equals("DATE", StringComparison.OrdinalIgnoreCase), - IsDotNetTimeOnly = RemovePrecisionFromDataType(dt).Equals("TIME", StringComparison.OrdinalIgnoreCase) - }; - - c.IsJsonContent = c.Type.Equals("JSON", StringComparison.OrdinalIgnoreCase) || (c.DotNetName == "string" && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)); - if (c.IsJsonContent && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)) - c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[..^JsonColumnNameSuffix.Length]); - - // https://dev.mysql.com/doc/refman/5.7/en/show-columns.html - var extra = dr.GetValue("EXTRA"); - if (extra is not null) + c.IsJsonContent = c.Type.Equals("JSON", StringComparison.OrdinalIgnoreCase) || (c.DotNetName == "string" && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)); + if (c.IsJsonContent && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)) + c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[..^JsonColumnNameSuffix.Length]); + + // https://dev.mysql.com/doc/refman/5.7/en/show-columns.html + var extra = dr.GetValue("EXTRA"); + if (extra is not null) + { + if (extra.Contains("AUTO_INCREMENT", StringComparison.OrdinalIgnoreCase)) { - if (extra.Contains("AUTO_INCREMENT", StringComparison.OrdinalIgnoreCase)) - { - c.IsIdentity = true; - c.IdentitySeed = 1; - c.IdentityIncrement = 1; - } - - if (extra.Contains("GENERATED", StringComparison.OrdinalIgnoreCase)) - c.IsComputed = true; + c.IsIdentity = true; + c.IdentitySeed = 1; + c.IdentityIncrement = 1; } - return c; + if (extra.Contains("GENERATED", StringComparison.OrdinalIgnoreCase)) + c.IsComputed = true; } - /// - /// Removes any precision from the data type. - /// - private static string RemovePrecisionFromDataType(string type) => type.Contains('(') ? type[..type.IndexOf('(')] : type; + return c; + } - /// - public override async Task LoadAdditionalInformationSchema(IDatabase database, List tables, CancellationToken cancellationToken) - { - // Configure all the single column foreign keys. - using var sr3 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableForeignKeys.sql", [typeof(MySqlSchemaConfig).Assembly]); + /// + /// Removes any precision from the data type. + /// + private static string RemovePrecisionFromDataType(string type) => type.Contains('(') ? type[..type.IndexOf('(')] : type; + + /// + public override async Task LoadAdditionalInformationSchema(IDatabase database, List tables, CancellationToken cancellationToken) + { + // Configure all the single column foreign keys. + using var sr3 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableForeignKeys.sql", [typeof(MySqlSchemaConfig).Assembly]); #if NET7_0_OR_GREATER - var fks = await database.SqlStatement(await sr3.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new + var fks = await database.SqlStatement(await sr3.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new #else - var fks = await database.SqlStatement(await sr3.ReadToEndAsync().ConfigureAwait(false)).SelectQueryAsync(dr => new + var fks = await database.SqlStatement(await sr3.ReadToEndAsync().ConfigureAwait(false)).SelectQueryAsync(dr => new #endif - { - ConstraintName = dr.GetValue("fk_constraint_name"), - TableSchema = dr.GetValue("CONSTRAINT_SCHEMA"), - TableName = dr.GetValue("TABLE_NAME"), - TableColumnName = dr.GetValue("FK_COLUMN_NAME"), - ForeignTable = dr.GetValue("REFERENCED_TABLE_NAME"), - ForiegnColumn = dr.GetValue("pk_column_name") - }, cancellationToken).ConfigureAwait(false); - - foreach (var grp in fks.Where(x => x.TableSchema == Migration.DatabaseName).GroupBy(x => new { x.ConstraintName, x.TableSchema, x.TableName }).Where(x => x.Count() == 1)) - { - var fk = grp.Single(); - var r = (from t in tables - from c in t.Columns - where (t.Name == fk.TableName && c.Name == fk.TableColumnName) - select (t, c)).SingleOrDefault(); - - if (r == default) - continue; - - r.c.ForeignSchema = string.Empty; - r.c.ForeignTable = fk.ForeignTable; - r.c.ForeignColumn = fk.ForiegnColumn; - r.c.IsForeignRefData = (from t in tables where (t.Schema == string.Empty && t.Name == fk.ForeignTable) select t.IsRefData).FirstOrDefault(); - } + { + ConstraintName = dr.GetValue("fk_constraint_name"), + TableSchema = dr.GetValue("CONSTRAINT_SCHEMA"), + TableName = dr.GetValue("TABLE_NAME"), + TableColumnName = dr.GetValue("FK_COLUMN_NAME"), + ForeignTable = dr.GetValue("REFERENCED_TABLE_NAME"), + ForiegnColumn = dr.GetValue("pk_column_name") + }, cancellationToken).ConfigureAwait(false); + + foreach (var grp in fks.Where(x => x.TableSchema == Migration.DatabaseName).GroupBy(x => new { x.ConstraintName, x.TableSchema, x.TableName }).Where(x => x.Count() == 1)) + { + var fk = grp.Single(); + var r = (from t in tables + from c in t.Columns + where (t.Name == fk.TableName && c.Name == fk.TableColumnName) + select (t, c)).SingleOrDefault(); + + if (r == default) + continue; + + r.c.ForeignSchema = string.Empty; + r.c.ForeignTable = fk.ForeignTable; + r.c.ForeignColumn = fk.ForiegnColumn; + r.c.IsForeignRefData = (from t in tables where (t.Schema == string.Empty && t.Name == fk.ForeignTable) select t.IsRefData).FirstOrDefault(); } + } - /// - public override string ToDotNetTypeName(DbColumnSchema schema) - { - var dbType = RemovePrecisionFromDataType(schema.ThrowIfNull(nameof(schema)).Type); - if (string.IsNullOrEmpty(dbType)) - return "string"; + /// + public override string ToDotNetTypeName(DbColumnSchema schema) + { + var dbType = RemovePrecisionFromDataType(schema.ThrowIfNull(nameof(schema)).Type); + if (string.IsNullOrEmpty(dbType)) + return "string"; - if (Migration.Args.EmitDotNetDateOnly && schema.IsDotNetDateOnly) - return "DateOnly"; - else if (Migration.Args.EmitDotNetTimeOnly && schema.IsDotNetTimeOnly) - return "TimeOnly"; + if (Migration.Args.EmitDotNetDateOnly && schema.IsDotNetDateOnly) + return "DateOnly"; + else if (Migration.Args.EmitDotNetTimeOnly && schema.IsDotNetTimeOnly) + return "TimeOnly"; - return dbType.ToUpperInvariant() switch - { - "CHAR" or "VARCHAR" or "TINYTEXT" or "TEXT" or "MEDIUMTEXT" or "LONGTEXT" or "SET" or "ENUM" or "NCHAR" or "NVARCHAR" or "JSON" => "string", - "DECIMAL" => "decimal", - "DATETIME" or "TIMESTAMP" => "DateTime", - "DATE" => "DateOnly", - "TIME" => "TimeOnly", - "BINARY" or "VARBINARY" or "TINYBLOB" or "BLOB" or "MEDIUMBLOB" or "LONGBLOB" => "byte[]", - "BIT" or "BOOL" or "BOOLEAN" => "bool", - "DOUBLE" => "double", - "INT" => "int", - "BIGINT" => "long", - "SMALLINT" => "short", - "TINYINT" => "byte", - "FLOAT" => "float", - "UNIQUEIDENTIFIER" => "Guid", - _ => throw new InvalidOperationException($"Database data type '{dbType}' does not have corresponding .NET type mapping defined."), - }; - } + return dbType.ToUpperInvariant() switch + { + "CHAR" or "VARCHAR" or "TINYTEXT" or "TEXT" or "MEDIUMTEXT" or "LONGTEXT" or "SET" or "ENUM" or "NCHAR" or "NVARCHAR" or "JSON" => "string", + "DECIMAL" => "decimal", + "DATETIME" or "TIMESTAMP" => "DateTime", + "DATE" => "DateOnly", + "TIME" => "TimeOnly", + "BINARY" or "VARBINARY" or "TINYBLOB" or "BLOB" or "MEDIUMBLOB" or "LONGBLOB" => "byte[]", + "BIT" or "BOOL" or "BOOLEAN" => "bool", + "DOUBLE" => "double", + "INT" => "int", + "BIGINT" => "long", + "SMALLINT" => "short", + "TINYINT" => "byte", + "FLOAT" => "float", + "UNIQUEIDENTIFIER" => "Guid", + _ => throw new InvalidOperationException($"Database data type '{dbType}' does not have corresponding .NET type mapping defined."), + }; + } - /// - public override string ToFormattedSqlType(DbColumnSchema schema, bool includeNullability = true) + /// + public override string ToFormattedSqlType(DbColumnSchema schema, bool includeNullability = true) + { + var sb = new StringBuilder(schema.Type!.ToUpperInvariant()); + + sb.Append(schema.Type.ToUpperInvariant() switch { - var sb = new StringBuilder(schema.Type!.ToUpperInvariant()); - - sb.Append(schema.Type.ToUpperInvariant() switch - { - "CHAR" or "VARCHAR" or "NCHAR" or "NVARCHAR" => schema.Length.HasValue && schema.Length.Value > 0 ? $"({schema.Length.Value})" : "(MAX)", - "DECIMAL" => $"({schema.Precision}, {schema.Scale})", - "NUMERIC" => $"({schema.Precision}, {schema.Scale})", - "TIME" or "DATETIME" or "TIMESTAMP" => schema.Scale.HasValue && schema.Scale.Value > 0 ? $"({schema.Scale})" : string.Empty, - _ => string.Empty - }); + "CHAR" or "VARCHAR" or "NCHAR" or "NVARCHAR" => schema.Length.HasValue && schema.Length.Value > 0 ? $"({schema.Length.Value})" : "(MAX)", + "DECIMAL" => $"({schema.Precision}, {schema.Scale})", + "NUMERIC" => $"({schema.Precision}, {schema.Scale})", + "TIME" or "DATETIME" or "TIMESTAMP" => schema.Scale.HasValue && schema.Scale.Value > 0 ? $"({schema.Scale})" : string.Empty, + _ => string.Empty + }); - if (includeNullability && schema.IsNullable) - sb.Append(" NULL"); + if (includeNullability && schema.IsNullable) + sb.Append(" NULL"); - return sb.ToString(); - } + return sb.ToString(); + } - /// - public override string ToFormattedSqlStatementValue(DbColumnSchema dbColumnSchema, object? value) => value switch - { - null => "NULL", - string str => $"'{str.Replace("'", "''", StringComparison.Ordinal)}'", - bool b => b ? "true" : "false", - Guid => $"'{value}'", - DateTime dt => $"'{dt.ToString(Migration.Args.DataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", - DateTimeOffset dto => $"'{dto.ToString(Migration.Args.DataParserArgs.DateTimeOffsetFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + /// + public override string ToFormattedSqlStatementValue(DbColumnSchema dbColumnSchema, object? value) => value switch + { + null => "NULL", + string str => $"'{str.Replace("'", "''", StringComparison.Ordinal)}'", + bool b => b ? "true" : "false", + Guid => $"'{value}'", + DateTime dt => $"'{dt.ToString(Migration.Args.DataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + DateTimeOffset dto => $"'{dto.ToString(Migration.Args.DataParserArgs.DateTimeOffsetFormat, System.Globalization.CultureInfo.InvariantCulture)}'", #if NET7_0_OR_GREATER - DateOnly d => $"'{d.ToString(Migration.Args.DataParserArgs.DateOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", - TimeOnly t => $"'{t.ToString(Migration.Args.DataParserArgs.TimeOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + DateOnly d => $"'{d.ToString(Migration.Args.DataParserArgs.DateOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + TimeOnly t => $"'{t.ToString(Migration.Args.DataParserArgs.TimeOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", #endif - _ => value.ToString()! - }; - } + _ => value.ToString()! + }; } \ No newline at end of file diff --git a/src/DbEx.Postgres/Console/MigrationArgsExtensions.cs b/src/DbEx.Postgres/Console/MigrationArgsExtensions.cs index 5be52c0..c5d7797 100644 --- a/src/DbEx.Postgres/Console/MigrationArgsExtensions.cs +++ b/src/DbEx.Postgres/Console/MigrationArgsExtensions.cs @@ -1,37 +1,31 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Postgres.Console; -using DbEx.Migration; -using System.Linq; - -namespace DbEx.Postgres.Console +/// +/// Provides extension methods for . +/// +public static class MigrationArgsExtensions { /// - /// Provides extension methods for . + /// Include the Postgres extended Schema scripts (stored procedures and functions) from . /// - public static class MigrationArgsExtensions + /// The . + /// The to support fluent-style method-chaining. + public static MigrationArgs IncludeExtendedSchemaScripts(this MigrationArgs args) { - /// - /// Include the Postgres extended Schema scripts (stored procedures and functions) from . - /// - /// The . - /// The to support fluent-style method-chaining. - public static MigrationArgs IncludeExtendedSchemaScripts(this MigrationArgs args) - { - AddExtendedSchemaScripts(args); - return args; - } + AddExtendedSchemaScripts(args); + return args; + } - /// - /// Include the Postgres extended Schema scripts (stored procedures and functions) from . - /// - /// The . - /// The to support fluent-style method-chaining. - public static void AddExtendedSchemaScripts(TArgs args) where TArgs : MigrationArgsBase + /// + /// Include the Postgres extended Schema scripts (stored procedures and functions) from . + /// + /// The . + /// The to support fluent-style method-chaining. + public static void AddExtendedSchemaScripts(TArgs args) where TArgs : MigrationArgsBase + { + foreach (var rn in typeof(MigrationArgsExtensions).Assembly.GetManifestResourceNames().Where(x => x.StartsWith("DbEx.Postgres.Resources.ExtendedSchema.") && x.EndsWith(".sql"))) { - foreach (var rn in typeof(MigrationArgsExtensions).Assembly.GetManifestResourceNames().Where(x => x.StartsWith("DbEx.Postgres.Resources.ExtendedSchema.") && x.EndsWith(".sql"))) - { - args.AddScript(MigrationCommand.Schema, typeof(MigrationArgsExtensions).Assembly, rn); - } + args.AddScript(MigrationCommand.Schema, typeof(MigrationArgsExtensions).Assembly, rn); } } } \ No newline at end of file diff --git a/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs b/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs index f9f8fa6..efd96aa 100644 --- a/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs +++ b/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs @@ -1,69 +1,59 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Postgres.Console; -using DbEx.Console; -using DbEx.Migration; -using DbEx.Postgres.Migration; -using Microsoft.Extensions.Logging; -using System; -using System.Reflection; - -namespace DbEx.Postgres.Console +/// +/// Console that facilitates the by managing the standard console command-line arguments/options. +/// +public sealed class PostgresMigrationConsole : MigrationConsoleBase { /// - /// Console that facilitates the by managing the standard console command-line arguments/options. + /// Creates a new using to default the probing . /// - public sealed class PostgresMigrationConsole : MigrationConsoleBase - { - /// - /// Creates a new using to default the probing . - /// - /// The . - /// The database connection string. - /// A new . - public static PostgresMigrationConsole Create(string connectionString) => new(new MigrationArgs { ConnectionString = connectionString }.AddAssembly(typeof(T).Assembly)); + /// The . + /// The database connection string. + /// A new . + public static PostgresMigrationConsole Create(string connectionString) => new(new MigrationArgs { ConnectionString = connectionString }.AddAssembly(typeof(T).Assembly)); - /// - /// Initializes a new instance of the class. - /// - /// The default that will be overridden/updated by the command-line argument values. - public PostgresMigrationConsole(MigrationArgs? args = null) : base(args ?? new MigrationArgs()) { } + /// + /// Initializes a new instance of the class. + /// + /// The default that will be overridden/updated by the command-line argument values. + public PostgresMigrationConsole(MigrationArgs? args = null) : base(args ?? new MigrationArgs()) { } - /// - /// Initializes a new instance of the class that provides a default for the . - /// - /// The database connection string. - public PostgresMigrationConsole(string connectionString) : base(new MigrationArgs { ConnectionString = connectionString.ThrowIfNull(nameof(connectionString)) }) { } + /// + /// Initializes a new instance of the class that provides a default for the . + /// + /// The database connection string. + public PostgresMigrationConsole(string connectionString) : base(new MigrationArgs { ConnectionString = connectionString.ThrowIfNull(nameof(connectionString)) }) { } - /// - /// Gets the . - /// - public new MigrationArgs Args => (MigrationArgs)base.Args; + /// + /// Gets the . + /// + public new MigrationArgs Args => (MigrationArgs)base.Args; - /// - protected override DatabaseMigrationBase CreateMigrator() => new PostgresMigration(Args); + /// + protected override DatabaseMigrationBase CreateMigrator() => new PostgresMigration(Args); - /// - public override string AppTitle => base.AppTitle + " [PostgreSQL]"; + /// + public override string AppTitle => base.AppTitle + " [PostgreSQL]"; - /// - protected override void OnWriteHelp() - { - base.OnWriteHelp(); - WriteScriptHelp(); - Logger?.LogInformation("{help}", string.Empty); - } + /// + protected override void OnWriteHelp() + { + base.OnWriteHelp(); + WriteScriptHelp(); + Logger?.LogInformation("{help}", string.Empty); + } - /// - /// Writes the supported help content. - /// - public void WriteScriptHelp() - { - Logger?.LogInformation("{help}", "Script command and argument(s):"); - Logger?.LogInformation("{help}", " script [default] Creates a default (empty) SQL script."); - Logger?.LogInformation("{help}", " script alter
Creates a SQL script to perform an ALTER TABLE."); - Logger?.LogInformation("{help}", " script create
Creates a SQL script to perform a CREATE TABLE."); - Logger?.LogInformation("{help}", " script refdata
Creates a SQL script to perform a CREATE TABLE as reference data."); - Logger?.LogInformation("{help}", " script schema Creates a SQL script to perform a CREATE SCHEMA."); - } + /// + /// Writes the supported help content. + /// + public void WriteScriptHelp() + { + Logger?.LogInformation("{help}", "Script command and argument(s):"); + Logger?.LogInformation("{help}", " script [default] Creates a default (empty) SQL script."); + Logger?.LogInformation("{help}", " script alter
Creates a SQL script to perform an ALTER TABLE."); + Logger?.LogInformation("{help}", " script create
Creates a SQL script to perform a CREATE TABLE."); + Logger?.LogInformation("{help}", " script refdata
Creates a SQL script to perform a CREATE TABLE as reference data."); + Logger?.LogInformation("{help}", " script schema Creates a SQL script to perform a CREATE SCHEMA."); } } \ No newline at end of file diff --git a/src/DbEx.Postgres/GlobalUsing.cs b/src/DbEx.Postgres/GlobalUsing.cs new file mode 100644 index 0000000..ed26cb6 --- /dev/null +++ b/src/DbEx.Postgres/GlobalUsing.cs @@ -0,0 +1,14 @@ +global using DbEx.Console; +global using DbEx.DbSchema; +global using DbEx.Migration; +global using DbEx.Postgres.Migration; +global using DbUp.Support; +global using Microsoft.Extensions.Logging; +global using Npgsql; +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Reflection; +global using System.Text; +global using System.Threading; +global using System.Threading.Tasks; \ No newline at end of file diff --git a/src/DbEx.Postgres/Migration/PostgresDatabase.cs b/src/DbEx.Postgres/Migration/PostgresDatabase.cs index e0b5463..14a3c48 100644 --- a/src/DbEx.Postgres/Migration/PostgresDatabase.cs +++ b/src/DbEx.Postgres/Migration/PostgresDatabase.cs @@ -1,12 +1,7 @@ -using DbEx.Migration; -using Npgsql; -using System; +namespace DbEx.Postgres.Migration; -namespace DbEx.Postgres.Migration -{ - /// - /// Provides Npgsql (PostgreSQL) database access functionality. - /// - /// - public class PostgresDatabase(Func create) : Database(create, NpgsqlFactory.Instance) { } -} \ No newline at end of file +/// +/// Provides Npgsql (PostgreSQL) database access functionality. +/// +/// +public class PostgresDatabase(Func create) : Database(create, NpgsqlFactory.Instance) { } \ No newline at end of file diff --git a/src/DbEx.Postgres/Migration/PostgresMigration.cs b/src/DbEx.Postgres/Migration/PostgresMigration.cs index c2a2b11..24c5870 100644 --- a/src/DbEx.Postgres/Migration/PostgresMigration.cs +++ b/src/DbEx.Postgres/Migration/PostgresMigration.cs @@ -1,95 +1,83 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx - -using DbEx.DbSchema; -using DbEx.Migration; -using DbUp.Support; -using Npgsql; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.Postgres.Migration +namespace DbEx.Postgres.Migration; + +/// +/// Provides the PostgreSQL migration orchestration. +/// +public class PostgresMigration : DatabaseMigrationBase { + private readonly string _databaseName; + private readonly IDatabase _database; + private readonly IDatabase _masterDatabase; + private readonly List _resetBypass = []; + /// - /// Provides the PostgreSQL migration orchestration. + /// Initializes an instance of the class. /// - public class PostgresMigration : DatabaseMigrationBase + /// The . + public PostgresMigration(MigrationArgsBase args) : base(args) { - private readonly string _databaseName; - private readonly IDatabase _database; - private readonly IDatabase _masterDatabase; - private readonly List _resetBypass = []; - - /// - /// Initializes an instance of the class. - /// - /// The . - public PostgresMigration(MigrationArgsBase args) : base(args) - { - SchemaConfig = new PostgresSchemaConfig(this); + SchemaConfig = new PostgresSchemaConfig(this); - var csb = new NpgsqlConnectionStringBuilder(Args.ConnectionString); - if (string.IsNullOrEmpty(csb.Database)) - throw new ArgumentException($"The {nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionString)} property must contain a database name.", nameof(args)); + var csb = new NpgsqlConnectionStringBuilder(Args.ConnectionString); + if (string.IsNullOrEmpty(csb.Database)) + throw new ArgumentException($"The {nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionString)} property must contain a database name.", nameof(args)); - _databaseName = csb.Database; - _database = new PostgresDatabase(() => new NpgsqlConnection(Args.ConnectionString)); + _databaseName = csb.Database; + _database = new PostgresDatabase(() => new NpgsqlConnection(Args.ConnectionString)); - csb.Database = null; - _masterDatabase = new PostgresDatabase(() => new NpgsqlConnection(csb.ConnectionString)); + csb.Database = null; + _masterDatabase = new PostgresDatabase(() => new NpgsqlConnection(csb.ConnectionString)); - Args.AddAssemblyAfter(typeof(DatabaseMigrationBase).Assembly, typeof(PostgresMigration).Assembly); + Args.AddAssemblyAfter(typeof(DatabaseMigrationBase).Assembly, typeof(PostgresMigration).Assembly); - if (SchemaObjectTypes.Length == 0) - SchemaObjectTypes = ["FUNCTION", "VIEW", "PROCEDURE"]; + if (SchemaObjectTypes.Length == 0) + SchemaObjectTypes = ["FUNCTION", "VIEW", "PROCEDURE"]; - Args.AddParameter(MigrationArgsBase.DatabaseNameParamName, _databaseName, true); - Args.AddParameter(MigrationArgsBase.JournalSchemaParamName, SchemaConfig.DefaultSchema, true); - Args.AddParameter(MigrationArgsBase.JournalTableParamName, "schemaversions"); - } + Args.AddParameter(MigrationArgsBase.DatabaseNameParamName, _databaseName, true); + Args.AddParameter(MigrationArgsBase.JournalSchemaParamName, SchemaConfig.DefaultSchema, true); + Args.AddParameter(MigrationArgsBase.JournalTableParamName, "schemaversions"); + } - /// - public override string Provider => "Postgres"; + /// + public override string Provider => "Postgres"; - /// - public override string DatabaseName => _databaseName; + /// + public override string DatabaseName => _databaseName; - /// - public override IDatabase Database => _database; + /// + public override IDatabase Database => _database; - /// - public override IDatabase MasterDatabase => _masterDatabase; + /// + public override IDatabase MasterDatabase => _masterDatabase; - /// - public override DatabaseSchemaConfig SchemaConfig { get; } + /// + public override DatabaseSchemaConfig SchemaConfig { get; } - /// - protected override DatabaseSchemaScriptBase CreateSchemaScript(DatabaseMigrationScript migrationScript) => PostgresSchemaScript.Create(migrationScript); + /// + protected override DatabaseSchemaScriptBase CreateSchemaScript(DatabaseMigrationScript migrationScript) => PostgresSchemaScript.Create(migrationScript); - /// - protected override async Task DatabaseResetAsync(CancellationToken cancellationToken = default) - { - // Filter out the versioning table. - _resetBypass.Add(SchemaConfig.ToFullyQualifiedTableName(Journal.Schema!, Journal.Table!)); + /// + protected override async Task DatabaseResetAsync(CancellationToken cancellationToken = default) + { + // Filter out the versioning table. + _resetBypass.Add(SchemaConfig.ToFullyQualifiedTableName(Journal.Schema!, Journal.Table!)); - // Carry on as they say ;-) - return await base.DatabaseResetAsync(cancellationToken).ConfigureAwait(false); - } + // Carry on as they say ;-) + return await base.DatabaseResetAsync(cancellationToken).ConfigureAwait(false); + } - /// - protected override Func DataResetFilterPredicate => - schema => !_resetBypass.Contains(schema.QualifiedName!) && !schema.Name.StartsWith("pg_"); + /// + protected override Func DataResetFilterPredicate => + schema => !_resetBypass.Contains(schema.QualifiedName!) && !schema.Name.StartsWith("pg_"); - /// - protected override async Task ExecuteScriptAsync(DatabaseMigrationScript script, CancellationToken cancellationToken = default) - { - using var sr = script.GetStreamReader(); + /// + protected override async Task ExecuteScriptAsync(DatabaseMigrationScript script, CancellationToken cancellationToken = default) + { + using var sr = script.GetStreamReader(); - foreach (var sql in new SqlCommandSplitter().SplitScriptIntoCommands(sr.ReadToEnd())) - { - await Database.SqlStatement(ReplaceSqlRuntimeParameters(sql)).NonQueryAsync(cancellationToken).ConfigureAwait(false); - } + foreach (var sql in new SqlCommandSplitter().SplitScriptIntoCommands(sr.ReadToEnd())) + { + await Database.SqlStatement(ReplaceSqlRuntimeParameters(sql)).NonQueryAsync(cancellationToken).ConfigureAwait(false); } } } \ No newline at end of file diff --git a/src/DbEx.Postgres/Migration/PostgresSchemaScript.cs b/src/DbEx.Postgres/Migration/PostgresSchemaScript.cs index aeba061..20bbf97 100644 --- a/src/DbEx.Postgres/Migration/PostgresSchemaScript.cs +++ b/src/DbEx.Postgres/Migration/PostgresSchemaScript.cs @@ -1,139 +1,129 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Postgres.Migration; -using DbEx.Migration; -using DbUp.Support; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - -namespace DbEx.Postgres.Migration +/// +/// Provides the PostgreSQL database schema script functionality. +/// +public class PostgresSchemaScript : DatabaseSchemaScriptBase { /// - /// Provides the PostgreSQL database schema script functionality. + /// Creates the from the . /// - public class PostgresSchemaScript : DatabaseSchemaScriptBase + /// The . + /// The . + public static PostgresSchemaScript Create(DatabaseMigrationScript migrationScript) { - /// - /// Creates the from the . - /// - /// The . - /// The . - public static PostgresSchemaScript Create(DatabaseMigrationScript migrationScript) - { - var script = new PostgresSchemaScript(migrationScript); + var script = new PostgresSchemaScript(migrationScript); - using var sr = script.MigrationScript.GetStreamReader(); - var sql = sr.ReadToEnd(); - var tokens = new SqlCommandTokenizer(sql).ReadAllTokens(); + using var sr = script.MigrationScript.GetStreamReader(); + var sql = sr.ReadToEnd(); + var tokens = new SqlCommandTokenizer(sql).ReadAllTokens(); - for (int i = 0; i < tokens.Length; i++) - { - if (string.Compare(tokens[i], "create", StringComparison.OrdinalIgnoreCase) != 0) - continue; + for (int i = 0; i < tokens.Length; i++) + { + if (string.Compare(tokens[i], "create", StringComparison.OrdinalIgnoreCase) != 0) + continue; - if (i + 4 < tokens.Length) + if (i + 4 < tokens.Length) + { + if (string.Compare(tokens[i + 1], "or", StringComparison.OrdinalIgnoreCase) == 0 && string.Compare(tokens[i + 2], "replace", StringComparison.OrdinalIgnoreCase) == 0) { - if (string.Compare(tokens[i + 1], "or", StringComparison.OrdinalIgnoreCase) == 0 && string.Compare(tokens[i + 2], "replace", StringComparison.OrdinalIgnoreCase) == 0) - { - i =+ 2; - script.SupportsReplace = true; - } + i =+ 2; + script.SupportsReplace = true; + } - script.Type = tokens[i + 1]; - script.FullyQualifiedName = tokens[i + 2]; + script.Type = tokens[i + 1]; + script.FullyQualifiedName = tokens[i + 2]; - var index = script.FullyQualifiedName.IndexOf('.'); - if (index < 0) - { - script.Schema = migrationScript.DatabaseMigration.SchemaConfig.DefaultSchema; - script.Name = script.FullyQualifiedName; - } - else - { - script.Schema = script.FullyQualifiedName[..index]; - script.Name = script.FullyQualifiedName[(index + 1)..]; - } - - return script; + var index = script.FullyQualifiedName.IndexOf('.'); + if (index < 0) + { + script.Schema = migrationScript.DatabaseMigration.SchemaConfig.DefaultSchema; + script.Name = script.FullyQualifiedName; + } + else + { + script.Schema = script.FullyQualifiedName[..index]; + script.Name = script.FullyQualifiedName[(index + 1)..]; } - } - script.ErrorMessage = "The SQL statement must be a valid 'CREATE' statement."; - return script; + return script; + } } - /// - /// Initializes a new instance of the class. - /// - /// The parent . - private PostgresSchemaScript(DatabaseMigrationScript migrationScript) : base(migrationScript, "\"", "\"") { } + script.ErrorMessage = "The SQL statement must be a valid 'CREATE' statement."; + return script; + } + + /// + /// Initializes a new instance of the class. + /// + /// The parent . + private PostgresSchemaScript(DatabaseMigrationScript migrationScript) : base(migrationScript, "\"", "\"") { } + + /// + public override string SqlDropStatement => $"DROP {Type.ToUpperInvariant()} IF EXISTS \"{Schema}\".\"{Name}\""; - /// - public override string SqlDropStatement => $"DROP {Type.ToUpperInvariant()} IF EXISTS \"{Schema}\".\"{Name}\""; + /// + public override string SqlCreateStatement => $"CREATE {(SupportsReplace ? "OR REPLACE " : "")}{Type.ToUpperInvariant()} \"{Schema}\".\"{Name}\""; - /// - public override string SqlCreateStatement => $"CREATE {(SupportsReplace ? "OR REPLACE " : "")}{Type.ToUpperInvariant()} \"{Schema}\".\"{Name}\""; + private class SqlCommandTokenizer(string sqlText) : SqlCommandReader(sqlText) + { + private readonly char[] delimiters = ['(', ')', ';', ',', '=']; - private class SqlCommandTokenizer(string sqlText) : SqlCommandReader(sqlText) + public string[] ReadAllTokens() { - private readonly char[] delimiters = ['(', ')', ';', ',', '=']; + var words = new List(); + var sb = new StringBuilder(); - public string[] ReadAllTokens() + while (!HasReachedEnd) { - var words = new List(); - var sb = new StringBuilder(); - - while (!HasReachedEnd) + ReadCharacter += (type, c) => { - ReadCharacter += (type, c) => + switch (type) { - switch (type) - { - case CharacterType.Command: - if (char.IsWhiteSpace(c)) - { - if (sb.Length > 0) - words.Add(sb.ToString()); - - sb.Clear(); - break; - } - else if (delimiters.Contains(c)) - { - if (sb.Length > 0) - words.Add(sb.ToString()); - - sb.Clear(); - } - - sb.Append(c); - break; - - case CharacterType.BracketedText: - case CharacterType.QuotedString: - sb.Append(c); - break; + case CharacterType.Command: + if (char.IsWhiteSpace(c)) + { + if (sb.Length > 0) + words.Add(sb.ToString()); - case CharacterType.SlashStarComment: - case CharacterType.DashComment: - case CharacterType.CustomStatement: - case CharacterType.Delimiter: + sb.Clear(); break; + } + else if (delimiters.Contains(c)) + { + if (sb.Length > 0) + words.Add(sb.ToString()); + + sb.Clear(); + } + + sb.Append(c); + break; + + case CharacterType.BracketedText: + case CharacterType.QuotedString: + sb.Append(c); + break; + + case CharacterType.SlashStarComment: + case CharacterType.DashComment: + case CharacterType.CustomStatement: + case CharacterType.Delimiter: + break; + + default: + throw new ArgumentOutOfRangeException(nameof(type), type, null); + } + }; - default: - throw new ArgumentOutOfRangeException(nameof(type), type, null); - } - }; - - Parse(); - } + Parse(); + } - if (sb.Length > 0) - words.Add(sb.ToString()); + if (sb.Length > 0) + words.Add(sb.ToString()); - return [.. words]; - } + return [.. words]; } } } \ No newline at end of file diff --git a/src/DbEx.Postgres/PostgresSchemaConfig.cs b/src/DbEx.Postgres/PostgresSchemaConfig.cs index a3e26db..02858cf 100644 --- a/src/DbEx.Postgres/PostgresSchemaConfig.cs +++ b/src/DbEx.Postgres/PostgresSchemaConfig.cs @@ -1,227 +1,214 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx - -using DbEx.DbSchema; -using DbEx.Migration; -using DbEx.Postgres.Migration; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.Postgres +namespace DbEx.Postgres; + +/// +/// Provides PostgreSQL specific configuration and capabilities. +/// +/// The owning . +public class PostgresSchemaConfig(PostgresMigration migration) : DatabaseSchemaConfig(migration, true, "public") { - /// - /// Provides PostgreSQL specific configuration and capabilities. - /// - /// The owning . - public class PostgresSchemaConfig(PostgresMigration migration) : DatabaseSchemaConfig(migration, true, "public") - { - /// - /// Value is '_id'. - public override string IdColumnNameSuffix => "_id"; + /// + /// Value is '_id'. + public override string IdColumnNameSuffix => "_id"; - /// - /// Value is '_code'. - public override string CodeColumnNameSuffix => "_code"; + /// + /// Value is '_code'. + public override string CodeColumnNameSuffix => "_code"; - /// - /// Value is '_json'. - public override string JsonColumnNameSuffix => "_json"; + /// + /// Value is '_json'. + public override string JsonColumnNameSuffix => "_json"; - /// - /// Value is 'created_on'. - public override string CreatedOnColumnName => "created_on"; + /// + /// Value is 'created_on'. + public override string CreatedOnColumnName => "created_on"; - /// - /// Value is 'created_by'. - public override string CreatedByColumnName => "created_by"; + /// + /// Value is 'created_by'. + public override string CreatedByColumnName => "created_by"; - /// - /// Value is 'updated_on'. - public override string UpdatedOnColumnName => "updated_on"; + /// + /// Value is 'updated_on'. + public override string UpdatedOnColumnName => "updated_on"; - /// - /// Value is 'updated_by'. - public override string UpdatedByColumnName => "updated_by"; + /// + /// Value is 'updated_by'. + public override string UpdatedByColumnName => "updated_by"; - /// - /// Value is 'tenant_id'. - public override string TenantIdColumnName => "tenant_id"; + /// + /// Value is 'tenant_id'. + public override string TenantIdColumnName => "tenant_id"; - /// - /// Value is 'xmin'. This is a PostgreSQL system column (hidden); see - /// and for more information. - public override string RowVersionColumnName => "xmin"; + /// + /// Value is 'xmin'. This is a PostgreSQL system column (hidden); see + /// and for more information. + public override string RowVersionColumnName => "xmin"; - /// - /// Value is 'is_deleted'. - public override string IsDeletedColumnName => "is_deleted"; + /// + /// Value is 'is_deleted'. + public override string IsDeletedColumnName => "is_deleted"; - /// - /// Value is 'code'. - public override string RefDataCodeColumnName => "code"; + /// + /// Value is 'code'. + public override string RefDataCodeColumnName => "code"; - /// - /// Value is 'text'. - public override string RefDataTextColumnName => "text"; + /// + /// Value is 'text'. + public override string RefDataTextColumnName => "text"; - /// - public override string ToFullyQualifiedTableName(string? schema, string table) => string.IsNullOrEmpty(schema) ? $"\"{table}\"" : $"\"{schema}\".\"{table}\""; + /// + public override string ToFullyQualifiedTableName(string? schema, string table) => string.IsNullOrEmpty(schema) ? $"\"{table}\"" : $"\"{schema}\".\"{table}\""; - /// - public override void PrepareMigrationArgs() - { - base.PrepareMigrationArgs(); + /// + public override void PrepareMigrationArgs() + { + base.PrepareMigrationArgs(); - Migration.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("is_active", _ => true); - Migration.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("sort_order", i => i); - } + Migration.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("is_active", _ => true); + Migration.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("sort_order", i => i); + } - /// - public override DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema table, DatabaseRecord dr) + /// + public override DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema table, DatabaseRecord dr) + { + var c = new DbColumnSchema(table, dr.GetValue("COLUMN_NAME")!, dr.GetValue("DATA_TYPE")!) { - var c = new DbColumnSchema(table, dr.GetValue("COLUMN_NAME")!, dr.GetValue("DATA_TYPE")!) - { - IsNullable = dr.GetValue("IS_NULLABLE")!.Equals("YES", StringComparison.OrdinalIgnoreCase), - Length = (ulong?)dr.GetValue("CHARACTER_MAXIMUM_LENGTH"), - Precision = (ulong?)(dr.GetValue("NUMERIC_PRECISION") ?? dr.GetValue("DATETIME_PRECISION")), - Scale = (ulong?)dr.GetValue("NUMERIC_SCALE"), - DefaultValue = dr.GetValue("COLUMN_DEFAULT") is not null && dr.GetValue("COLUMN_DEFAULT")!.StartsWith("nextval(", StringComparison.OrdinalIgnoreCase) ? null : dr.GetValue("COLUMN_DEFAULT"), - IsComputed = dr.GetValue("IS_GENERATED") != "NEVER", - IsIdentity = dr.GetValue("COLUMN_DEFAULT")?.StartsWith("nextval(", StringComparison.OrdinalIgnoreCase) ?? false, - IsDotNetDateOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")!).Equals("DATE", StringComparison.OrdinalIgnoreCase), - IsDotNetTimeOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")!).Equals("TIME WITHOUT TIME ZONE", StringComparison.OrdinalIgnoreCase) - }; - - c.IsJsonContent = c.Type.Equals("JSON", StringComparison.OrdinalIgnoreCase) || (c.DotNetName == "string" && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)); - if (c.IsJsonContent && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)) - c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[..^JsonColumnNameSuffix.Length]); - - return c; - } + IsNullable = dr.GetValue("IS_NULLABLE")!.Equals("YES", StringComparison.OrdinalIgnoreCase), + Length = (ulong?)dr.GetValue("CHARACTER_MAXIMUM_LENGTH"), + Precision = (ulong?)(dr.GetValue("NUMERIC_PRECISION") ?? dr.GetValue("DATETIME_PRECISION")), + Scale = (ulong?)dr.GetValue("NUMERIC_SCALE"), + DefaultValue = dr.GetValue("COLUMN_DEFAULT") is not null && dr.GetValue("COLUMN_DEFAULT")!.StartsWith("nextval(", StringComparison.OrdinalIgnoreCase) ? null : dr.GetValue("COLUMN_DEFAULT"), + IsComputed = dr.GetValue("IS_GENERATED") != "NEVER", + IsIdentity = dr.GetValue("COLUMN_DEFAULT")?.StartsWith("nextval(", StringComparison.OrdinalIgnoreCase) ?? false, + IsDotNetDateOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")!).Equals("DATE", StringComparison.OrdinalIgnoreCase), + IsDotNetTimeOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")!).Equals("TIME WITHOUT TIME ZONE", StringComparison.OrdinalIgnoreCase) + }; + + c.IsJsonContent = c.Type.Equals("JSON", StringComparison.OrdinalIgnoreCase) || (c.DotNetName == "string" && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)); + if (c.IsJsonContent && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)) + c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[..^JsonColumnNameSuffix.Length]); + + return c; + } - /// - /// Removes any precision from the data type. - /// - private static string RemovePrecisionFromDataType(string type) => type.Contains('(') ? type[..type.IndexOf('(')] : type; + /// + /// Removes any precision from the data type. + /// + private static string RemovePrecisionFromDataType(string type) => type.Contains('(') ? type[..type.IndexOf('(')] : type; - /// - public override async Task LoadAdditionalInformationSchema(IDatabase database, List tables, CancellationToken cancellationToken) + /// + public override async Task LoadAdditionalInformationSchema(IDatabase database, List tables, CancellationToken cancellationToken) + { + // Add the row version 'xmin' column to the table schema. + foreach (var table in tables) { - // Add the row version 'xmin' column to the table schema. - foreach (var table in tables) - { - table.Columns.Add(new DbColumnSchema(table, migration.Args.RowVersionColumnName!, "xid", "RowVersion") - { - IsNullable = false, - Scale = 0, - Precision = 32, - IsComputed = true, - IsRowVersion = true - }); - } - - // Configure all the single column foreign keys. - using var sr3 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableForeignKeys.sql", [typeof(PostgresSchemaConfig).Assembly]); - var fks = await database.SqlStatement(await sr3.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new - { - ConstraintName = dr.GetValue("constraint_name"), - TableSchema = dr.GetValue("table_schema"), - TableName = dr.GetValue("table_name"), - TableColumnName = dr.GetValue("column_name"), - ForeignSchema = dr.GetValue("foreign_schema_name"), - ForeignTable = dr.GetValue("foreign_table_name"), - ForiegnColumn = dr.GetValue("foreign_column_name") - }, cancellationToken).ConfigureAwait(false); - - foreach (var grp in fks.GroupBy(x => new { x.ConstraintName, x.TableSchema, x.TableName }).Where(x => x.Count() == 1)) + table.Columns.Add(new DbColumnSchema(table, migration.Args.RowVersionColumnName!, "xid", "RowVersion") { - var fk = grp.Single(); - var r = (from t in tables - from c in t.Columns - where (t.Schema == fk.TableSchema && t.Name == fk.TableName && c.Name == fk.TableColumnName) - select (t, c)).SingleOrDefault(); - - if (r == default) - continue; - - r.c.ForeignSchema = fk.ForeignSchema; - r.c.ForeignTable = fk.ForeignTable; - r.c.ForeignColumn = fk.ForiegnColumn; - r.c.IsForeignRefData = (from t in tables where (t.Schema == fk.ForeignSchema && t.Name == fk.ForeignTable) select t.IsRefData).FirstOrDefault(); - } + IsNullable = false, + Scale = 0, + Precision = 32, + IsComputed = true, + IsRowVersion = true + }); } - /// - public override string ToDotNetTypeName(DbColumnSchema schema) + // Configure all the single column foreign keys. + using var sr3 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableForeignKeys.sql", [typeof(PostgresSchemaConfig).Assembly]); + var fks = await database.SqlStatement(await sr3.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new + { + ConstraintName = dr.GetValue("constraint_name"), + TableSchema = dr.GetValue("table_schema"), + TableName = dr.GetValue("table_name"), + TableColumnName = dr.GetValue("column_name"), + ForeignSchema = dr.GetValue("foreign_schema_name"), + ForeignTable = dr.GetValue("foreign_table_name"), + ForiegnColumn = dr.GetValue("foreign_column_name") + }, cancellationToken).ConfigureAwait(false); + + foreach (var grp in fks.GroupBy(x => new { x.ConstraintName, x.TableSchema, x.TableName }).Where(x => x.Count() == 1)) { - var dbType = RemovePrecisionFromDataType(schema.ThrowIfNull(nameof(schema)).Type); - if (string.IsNullOrEmpty(dbType)) - return "string"; + var fk = grp.Single(); + var r = (from t in tables + from c in t.Columns + where (t.Schema == fk.TableSchema && t.Name == fk.TableName && c.Name == fk.TableColumnName) + select (t, c)).SingleOrDefault(); + + if (r == default) + continue; + + r.c.ForeignSchema = fk.ForeignSchema; + r.c.ForeignTable = fk.ForeignTable; + r.c.ForeignColumn = fk.ForiegnColumn; + r.c.IsForeignRefData = (from t in tables where (t.Schema == fk.ForeignSchema && t.Name == fk.ForeignTable) select t.IsRefData).FirstOrDefault(); + } + } - if (Migration.Args.EmitDotNetDateOnly && schema.IsDotNetDateOnly) - return "DateOnly"; - else if (Migration.Args.EmitDotNetTimeOnly && schema.IsDotNetTimeOnly) - return "TimeOnly"; + /// + public override string ToDotNetTypeName(DbColumnSchema schema) + { + var dbType = RemovePrecisionFromDataType(schema.ThrowIfNull(nameof(schema)).Type); + if (string.IsNullOrEmpty(dbType)) + return "string"; - // Source of truth: https://www.npgsql.org/doc/types/basic.html - return dbType.ToUpperInvariant() switch - { - "TEXT" or "CHARACTER VARYING" or "CHARACTER" or "CITEXT" or "JSON" or "JSONB" or "XML" or "NAME" => "string", - "NUMERIC" or "MONEY" => "decimal", - "TIMESTAMP WITHOUT TIME ZONE" => "DateTime", - "TIME WITH TIME ZONE" or "TIMESTAMP WITH TIME ZONE" => "DateTimeOffset", - "INTERVAL" => "TimeSpan", - "TIME WITHOUT TIME ZONE" => "TimeOnly", - "DATE" => "DateOnly", - "BYTEA" => "byte[]", - "BOOLEAN" or "BIT(1)" => "bool", - "DOUBLE PRECISION" => "double", - "INTEGER" => "int", - "BIGINT" => "long", - "SMALLINT" => "short", - "REAL" => "float", - "UUID" => "Guid", - "XID" => "uint", - _ => throw new InvalidOperationException($"Database data type '{dbType}' does not have corresponding .NET type mapping defined."), - }; - } + if (Migration.Args.EmitDotNetDateOnly && schema.IsDotNetDateOnly) + return "DateOnly"; + else if (Migration.Args.EmitDotNetTimeOnly && schema.IsDotNetTimeOnly) + return "TimeOnly"; - /// - public override string ToFormattedSqlType(DbColumnSchema schema, bool includeNullability = true) + // Source of truth: https://www.npgsql.org/doc/types/basic.html + return dbType.ToUpperInvariant() switch { - var sb = new StringBuilder(schema.Type!.ToUpperInvariant()); - - sb.Append(schema.Type.ToUpperInvariant() switch - { - "CHARACTER VARYING" or "CHARACTER" => schema.Length.HasValue && schema.Length.Value > 0 ? $"({schema.Length.Value})" : "(MAX)", - "NUMERIC" => $"({schema.Precision}, {schema.Scale})", - "TIMESTAMP WITHOUT TIME ZONE" or "TIMESTAMP WITH TIME ZONE" or "TIME WITH TIME ZONE" or "TIME WITHOUT TIME ZONE" => schema.Scale.HasValue && schema.Scale.Value > 0 ? $"({schema.Scale})" : string.Empty, - _ => string.Empty - }); + "TEXT" or "CHARACTER VARYING" or "CHARACTER" or "CITEXT" or "JSON" or "JSONB" or "XML" or "NAME" => "string", + "NUMERIC" or "MONEY" => "decimal", + "TIMESTAMP WITHOUT TIME ZONE" => "DateTime", + "TIME WITH TIME ZONE" or "TIMESTAMP WITH TIME ZONE" => "DateTimeOffset", + "INTERVAL" => "TimeSpan", + "TIME WITHOUT TIME ZONE" => "TimeOnly", + "DATE" => "DateOnly", + "BYTEA" => "byte[]", + "BOOLEAN" or "BIT(1)" => "bool", + "DOUBLE PRECISION" => "double", + "INTEGER" => "int", + "BIGINT" => "long", + "SMALLINT" => "short", + "REAL" => "float", + "UUID" => "Guid", + "XID" => "uint", + _ => throw new InvalidOperationException($"Database data type '{dbType}' does not have corresponding .NET type mapping defined."), + }; + } - if (includeNullability && schema.IsNullable) - sb.Append(" NULL"); + /// + public override string ToFormattedSqlType(DbColumnSchema schema, bool includeNullability = true) + { + var sb = new StringBuilder(schema.Type!.ToUpperInvariant()); + + sb.Append(schema.Type.ToUpperInvariant() switch + { + "CHARACTER VARYING" or "CHARACTER" => schema.Length.HasValue && schema.Length.Value > 0 ? $"({schema.Length.Value})" : "(MAX)", + "NUMERIC" => $"({schema.Precision}, {schema.Scale})", + "TIMESTAMP WITHOUT TIME ZONE" or "TIMESTAMP WITH TIME ZONE" or "TIME WITH TIME ZONE" or "TIME WITHOUT TIME ZONE" => schema.Scale.HasValue && schema.Scale.Value > 0 ? $"({schema.Scale})" : string.Empty, + _ => string.Empty + }); - return sb.ToString(); - } + if (includeNullability && schema.IsNullable) + sb.Append(" NULL"); - /// - public override string ToFormattedSqlStatementValue(DbColumnSchema dbColumnSchema, object? value) => value switch - { - null => "NULL", - string str => $"'{str.Replace("'", "''", StringComparison.Ordinal)}'", - bool b => b ? "true" : "false", - Guid => $"uuid('{value}')", - DateTime dt => $"'{dt.ToString(Migration.Args.DataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", - DateTimeOffset dto => $"'{dto.ToString(Migration.Args.DataParserArgs.DateTimeOffsetFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + return sb.ToString(); + } + + /// + public override string ToFormattedSqlStatementValue(DbColumnSchema dbColumnSchema, object? value) => value switch + { + null => "NULL", + string str => $"'{str.Replace("'", "''", StringComparison.Ordinal)}'", + bool b => b ? "true" : "false", + Guid => $"uuid('{value}')", + DateTime dt => $"'{dt.ToString(Migration.Args.DataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + DateTimeOffset dto => $"'{dto.ToString(Migration.Args.DataParserArgs.DateTimeOffsetFormat, System.Globalization.CultureInfo.InvariantCulture)}'", #if NET7_0_OR_GREATER - DateOnly d => $"'{d.ToString(Migration.Args.DataParserArgs.DateOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", - TimeOnly t => $"'{t.ToString(Migration.Args.DataParserArgs.TimeOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + DateOnly d => $"'{d.ToString(Migration.Args.DataParserArgs.DateOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + TimeOnly t => $"'{t.ToString(Migration.Args.DataParserArgs.TimeOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", #endif - _ => value.ToString()! - }; - } + _ => value.ToString()! + }; } \ No newline at end of file diff --git a/src/DbEx.SqlServer/Console/MigrationArgsExtensions.cs b/src/DbEx.SqlServer/Console/MigrationArgsExtensions.cs index 32a6515..6497797 100644 --- a/src/DbEx.SqlServer/Console/MigrationArgsExtensions.cs +++ b/src/DbEx.SqlServer/Console/MigrationArgsExtensions.cs @@ -1,38 +1,31 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.SqlServer.Console; -using DbEx.Migration; -using System; -using System.Linq; - -namespace DbEx.SqlServer.Console +/// +/// Provides extension methods for . +/// +public static class MigrationArgsExtensions { /// - /// Provides extension methods for . + /// Include the SQL Server extended Schema scripts (stored procedures and functions) from . /// - public static class MigrationArgsExtensions + /// The . + /// The to support fluent-style method-chaining. + public static MigrationArgs IncludeExtendedSchemaScripts(this MigrationArgs args) { - /// - /// Include the SQL Server extended Schema scripts (stored procedures and functions) from . - /// - /// The . - /// The to support fluent-style method-chaining. - public static MigrationArgs IncludeExtendedSchemaScripts(this MigrationArgs args) - { - AddExtendedSchemaScripts(args); - return args; - } + AddExtendedSchemaScripts(args); + return args; + } - /// - /// Adds the SQL Server extended Schema scripts (stored procedures and functions) from . - /// - /// The . - /// The . - public static void AddExtendedSchemaScripts(TArgs args) where TArgs : MigrationArgsBase + /// + /// Adds the SQL Server extended Schema scripts (stored procedures and functions) from . + /// + /// The . + /// The . + public static void AddExtendedSchemaScripts(TArgs args) where TArgs : MigrationArgsBase + { + foreach (var rn in typeof(MigrationArgsExtensions).Assembly.GetManifestResourceNames().Where(x => x.StartsWith("DbEx.SqlServer.Resources.ExtendedSchema.") && x.EndsWith(".sql"))) { - foreach (var rn in typeof(MigrationArgsExtensions).Assembly.GetManifestResourceNames().Where(x => x.StartsWith("DbEx.SqlServer.Resources.ExtendedSchema.") && x.EndsWith(".sql"))) - { - args.AddScript(MigrationCommand.Schema, typeof(MigrationArgsExtensions).Assembly, rn); - } + args.AddScript(MigrationCommand.Schema, typeof(MigrationArgsExtensions).Assembly, rn); } } } \ No newline at end of file diff --git a/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs b/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs index 77dbdaa..654bab8 100644 --- a/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs +++ b/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs @@ -1,71 +1,61 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.SqlServer.Console; -using DbEx.Console; -using DbEx.Migration; -using DbEx.SqlServer.Migration; -using Microsoft.Extensions.Logging; -using System; -using System.Reflection; - -namespace DbEx.SqlServer.Console +/// +/// Console that facilitates the by managing the standard console command-line arguments/options. +/// +public sealed class SqlServerMigrationConsole : MigrationConsoleBase { /// - /// Console that facilitates the by managing the standard console command-line arguments/options. + /// Creates a new using to default the probing . /// - public sealed class SqlServerMigrationConsole : MigrationConsoleBase - { - /// - /// Creates a new using to default the probing . - /// - /// The . - /// The database connection string. - /// A new . - public static SqlServerMigrationConsole Create(string connectionString) => new(new MigrationArgs { ConnectionString = connectionString }.AddAssembly(typeof(T).Assembly)); + /// The . + /// The database connection string. + /// A new . + public static SqlServerMigrationConsole Create(string connectionString) => new(new MigrationArgs { ConnectionString = connectionString }.AddAssembly(typeof(T).Assembly)); - /// - /// Initializes a new instance of the class. - /// - /// The default that will be overridden/updated by the command-line argument values. - public SqlServerMigrationConsole(MigrationArgs? args = null) : base(args ?? new MigrationArgs()) { } + /// + /// Initializes a new instance of the class. + /// + /// The default that will be overridden/updated by the command-line argument values. + public SqlServerMigrationConsole(MigrationArgs? args = null) : base(args ?? new MigrationArgs()) { } - /// - /// Initializes a new instance of the class that provides a default for the . - /// - /// The database connection string. - public SqlServerMigrationConsole(string connectionString) : base(new MigrationArgs { ConnectionString = connectionString.ThrowIfNull(nameof(connectionString)) }) { } + /// + /// Initializes a new instance of the class that provides a default for the . + /// + /// The database connection string. + public SqlServerMigrationConsole(string connectionString) : base(new MigrationArgs { ConnectionString = connectionString.ThrowIfNull(nameof(connectionString)) }) { } - /// - /// Gets the . - /// - public new MigrationArgs Args => (MigrationArgs)base.Args; + /// + /// Gets the . + /// + public new MigrationArgs Args => (MigrationArgs)base.Args; - /// - protected override DatabaseMigrationBase CreateMigrator() => new SqlServerMigration(Args); + /// + protected override DatabaseMigrationBase CreateMigrator() => new SqlServerMigration(Args); - /// - public override string AppTitle => base.AppTitle + " [SQL Server]"; + /// + public override string AppTitle => base.AppTitle + " [SQL Server]"; - /// - protected override void OnWriteHelp() - { - base.OnWriteHelp(); - WriteScriptHelp(); - Logger?.LogInformation("{help}", string.Empty); - } + /// + protected override void OnWriteHelp() + { + base.OnWriteHelp(); + WriteScriptHelp(); + Logger?.LogInformation("{help}", string.Empty); + } - /// - /// Writes the supported help content. - /// - public void WriteScriptHelp() - { - Logger?.LogInformation("{help}", "Script command and argument(s):"); - Logger?.LogInformation("{help}", " script [default] Creates a default (empty) SQL script."); - Logger?.LogInformation("{help}", " script alter
Creates a SQL script to perform an ALTER TABLE."); - Logger?.LogInformation("{help}", " script cdc
Creates a SQL script to turn on CDC for the specified table."); - Logger?.LogInformation("{help}", " script cdcdb Creates a SQL script to turn on CDC for the database."); - Logger?.LogInformation("{help}", " script create
Creates a SQL script to perform a CREATE TABLE."); - Logger?.LogInformation("{help}", " script refdata
Creates a SQL script to perform a CREATE TABLE as reference data."); - Logger?.LogInformation("{help}", " script schema Creates a SQL script to perform a CREATE SCHEMA."); - } + /// + /// Writes the supported help content. + /// + public void WriteScriptHelp() + { + Logger?.LogInformation("{help}", "Script command and argument(s):"); + Logger?.LogInformation("{help}", " script [default] Creates a default (empty) SQL script."); + Logger?.LogInformation("{help}", " script alter
Creates a SQL script to perform an ALTER TABLE."); + Logger?.LogInformation("{help}", " script cdc
Creates a SQL script to turn on CDC for the specified table."); + Logger?.LogInformation("{help}", " script cdcdb Creates a SQL script to turn on CDC for the database."); + Logger?.LogInformation("{help}", " script create
Creates a SQL script to perform a CREATE TABLE."); + Logger?.LogInformation("{help}", " script refdata
Creates a SQL script to perform a CREATE TABLE as reference data."); + Logger?.LogInformation("{help}", " script schema Creates a SQL script to perform a CREATE SCHEMA."); } } \ No newline at end of file diff --git a/src/DbEx.SqlServer/GlobalUsing.cs b/src/DbEx.SqlServer/GlobalUsing.cs new file mode 100644 index 0000000..7257baf --- /dev/null +++ b/src/DbEx.SqlServer/GlobalUsing.cs @@ -0,0 +1,14 @@ +global using DbEx.Console; +global using DbEx.DbSchema; +global using DbEx.Migration; +global using DbEx.SqlServer.Migration; +global using DbUp.Support; +global using Microsoft.Data.SqlClient; +global using Microsoft.Extensions.Logging; +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Reflection; +global using System.Text; +global using System.Threading; +global using System.Threading.Tasks; \ No newline at end of file diff --git a/src/DbEx.SqlServer/Migration/SqlServerDatabase.cs b/src/DbEx.SqlServer/Migration/SqlServerDatabase.cs index b22e341..fecf361 100644 --- a/src/DbEx.SqlServer/Migration/SqlServerDatabase.cs +++ b/src/DbEx.SqlServer/Migration/SqlServerDatabase.cs @@ -1,12 +1,7 @@ -using DbEx.Migration; -using Microsoft.Data.SqlClient; -using System; +namespace DbEx.SqlServer.Migration; -namespace DbEx.SqlServer.Migration -{ - /// - /// Provides the SQL Server functionality. - /// - /// - public class SqlServerDatabase(Func create) : Database(create, SqlClientFactory.Instance) { } -} \ No newline at end of file +/// +/// Provides the SQL Server functionality. +/// +/// +public class SqlServerDatabase(Func create) : Database(create, SqlClientFactory.Instance) { } \ No newline at end of file diff --git a/src/DbEx.SqlServer/Migration/SqlServerMigration.cs b/src/DbEx.SqlServer/Migration/SqlServerMigration.cs index f9a53b0..f143c08 100644 --- a/src/DbEx.SqlServer/Migration/SqlServerMigration.cs +++ b/src/DbEx.SqlServer/Migration/SqlServerMigration.cs @@ -1,121 +1,107 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx - -using DbEx.DbSchema; -using DbEx.Migration; -using DbUp.Support; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.SqlServer.Migration +namespace DbEx.SqlServer.Migration; + +/// +/// Provides the SQL Server migration orchestration. +/// +/// The following are supported by default: 'TYPE', 'FUNCTION', 'VIEW', 'PROCEDURE' and 'PROC'. +/// Where the is not specified it will default to 'schema => schema.Schema != "dbo" || schema.Schema != "cdc"' which will +/// filter out a data reset where a table is in the 'dbo' and 'cdc' schemas. +/// The base instance is updated; the and properties are set to `` and `SchemaVersions` respectively. +public class SqlServerMigration : DatabaseMigrationBase { + private readonly string _databaseName; + private readonly IDatabase _database; + private readonly IDatabase _masterDatabase; + private readonly List _resetBypass = []; + /// - /// Provides the SQL Server migration orchestration. + /// Initializes an instance of the class. /// - /// The following are supported by default: 'TYPE', 'FUNCTION', 'VIEW', 'PROCEDURE' and 'PROC'. - /// Where the is not specified it will default to 'schema => schema.Schema != "dbo" || schema.Schema != "cdc"' which will - /// filter out a data reset where a table is in the 'dbo' and 'cdc' schemas. - /// The base instance is updated; the and properties are set to `` and `SchemaVersions` respectively. - public class SqlServerMigration : DatabaseMigrationBase + /// The . + public SqlServerMigration(MigrationArgsBase args) : base(args) { - private readonly string _databaseName; - private readonly IDatabase _database; - private readonly IDatabase _masterDatabase; - private readonly List _resetBypass = []; - - /// - /// Initializes an instance of the class. - /// - /// The . - public SqlServerMigration(MigrationArgsBase args) : base(args) - { - SchemaConfig = new SqlServerSchemaConfig(this); + SchemaConfig = new SqlServerSchemaConfig(this); - var csb = new SqlConnectionStringBuilder(Args.ConnectionString); - _databaseName = csb.InitialCatalog; - if (string.IsNullOrEmpty(_databaseName)) - throw new ArgumentException($"The {nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionString)} property must contain an initial catalog (i.e. database name).", nameof(args)); + var csb = new SqlConnectionStringBuilder(Args.ConnectionString); + _databaseName = csb.InitialCatalog; + if (string.IsNullOrEmpty(_databaseName)) + throw new ArgumentException($"The {nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionString)} property must contain an initial catalog (i.e. database name).", nameof(args)); - _database = new SqlServerDatabase(() => new SqlConnection(Args.ConnectionString)); + _database = new SqlServerDatabase(() => new SqlConnection(Args.ConnectionString)); - csb.InitialCatalog = "master"; - _masterDatabase = new SqlServerDatabase(() => new SqlConnection(csb.ConnectionString)); + csb.InitialCatalog = "master"; + _masterDatabase = new SqlServerDatabase(() => new SqlConnection(csb.ConnectionString)); - // Add this assembly for probing. - Args.AddAssemblyAfter(typeof(DatabaseMigrationBase).Assembly, typeof(SqlServerMigration).Assembly); + // Add this assembly for probing. + Args.AddAssemblyAfter(typeof(DatabaseMigrationBase).Assembly, typeof(SqlServerMigration).Assembly); - // Defaults the schema object types unless already specified. - if (SchemaObjectTypes.Length == 0) - SchemaObjectTypes = ["TYPE", "FUNCTION", "VIEW", "PROCEDURE", "PROC"]; + // Defaults the schema object types unless already specified. + if (SchemaObjectTypes.Length == 0) + SchemaObjectTypes = ["TYPE", "FUNCTION", "VIEW", "PROCEDURE", "PROC"]; - // A schema object type that is a user-defined type will require all schema objects to be dropped (as it may be referenced). - if (MustDropSchemaObjectTypes.Length == 0) - MustDropSchemaObjectTypes = ["TYPE"]; + // A schema object type that is a user-defined type will require all schema objects to be dropped (as it may be referenced). + if (MustDropSchemaObjectTypes.Length == 0) + MustDropSchemaObjectTypes = ["TYPE"]; - // Always add the dbo schema _first_ unless already specified. - if (!Args.SchemaOrder.Contains(SchemaConfig.DefaultSchema)) - Args.SchemaOrder.Insert(0, SchemaConfig.DefaultSchema); + // Always add the dbo schema _first_ unless already specified. + if (!Args.SchemaOrder.Contains(SchemaConfig.DefaultSchema)) + Args.SchemaOrder.Insert(0, SchemaConfig.DefaultSchema); - // Add/set standard parameters. - Args.AddParameter(MigrationArgsBase.DatabaseNameParamName, _databaseName, true); - Args.AddParameter(MigrationArgsBase.JournalSchemaParamName, SchemaConfig.DefaultSchema); - Args.AddParameter(MigrationArgsBase.JournalTableParamName, "SchemaVersions"); - } + // Add/set standard parameters. + Args.AddParameter(MigrationArgsBase.DatabaseNameParamName, _databaseName, true); + Args.AddParameter(MigrationArgsBase.JournalSchemaParamName, SchemaConfig.DefaultSchema); + Args.AddParameter(MigrationArgsBase.JournalTableParamName, "SchemaVersions"); + } - /// - public override string Provider => "SqlServer"; + /// + public override string Provider => "SqlServer"; - /// - public override string DatabaseName => _databaseName; + /// + public override string DatabaseName => _databaseName; - /// - public override IDatabase Database => _database; + /// + public override IDatabase Database => _database; - /// - public override IDatabase MasterDatabase => _masterDatabase; + /// + public override IDatabase MasterDatabase => _masterDatabase; - /// - public override DatabaseSchemaConfig SchemaConfig { get; } + /// + public override DatabaseSchemaConfig SchemaConfig { get; } - /// - protected override DatabaseSchemaScriptBase CreateSchemaScript(DatabaseMigrationScript migrationScript) => SqlServerSchemaScript.Create(migrationScript); + /// + protected override DatabaseSchemaScriptBase CreateSchemaScript(DatabaseMigrationScript migrationScript) => SqlServerSchemaScript.Create(migrationScript); - /// - protected override async Task DatabaseResetAsync(CancellationToken cancellationToken = default) + /// + protected override async Task DatabaseResetAsync(CancellationToken cancellationToken = default) + { + // Filter out temporal tables. + Logger.LogInformation(" Querying database to find and filter all temporal table(s)..."); + using var sr = GetRequiredResourcesStreamReader($"DatabaseTemporal.sql", [.. ArtefactResourceAssemblies]); + await Database.SqlStatement(sr.ReadToEnd()).SelectQueryAsync(dr => { - // Filter out temporal tables. - Logger.LogInformation(" Querying database to find and filter all temporal table(s)..."); - using var sr = GetRequiredResourcesStreamReader($"DatabaseTemporal.sql", [.. ArtefactResourceAssemblies]); - await Database.SqlStatement(sr.ReadToEnd()).SelectQueryAsync(dr => - { - _resetBypass.Add($"[{dr.GetValue("schema")}].[{dr.GetValue("table")}]"); - return 0; - }, cancellationToken).ConfigureAwait(false); - - // Filter out the versioning table. - _resetBypass.Add($"[{Journal.Schema}].[{Journal.Table}]"); - - // Carry on as they say ;-) - return await base.DatabaseResetAsync(cancellationToken).ConfigureAwait(false); - } + _resetBypass.Add($"[{dr.GetValue("schema")}].[{dr.GetValue("table")}]"); + return 0; + }, cancellationToken).ConfigureAwait(false); - /// - protected override Func DataResetFilterPredicate => - schema => !_resetBypass.Contains(schema.QualifiedName!) && schema.Schema != "sys" && schema.Schema != "cdc" && !(schema.Schema == "dbo" && schema.Name.StartsWith("sys")); + // Filter out the versioning table. + _resetBypass.Add($"[{Journal.Schema}].[{Journal.Table}]"); - /// - protected override async Task ExecuteScriptAsync(DatabaseMigrationScript script, CancellationToken cancellationToken = default) - { - using var sr = script.GetStreamReader(); + // Carry on as they say ;-) + return await base.DatabaseResetAsync(cancellationToken).ConfigureAwait(false); + } + + /// + protected override Func DataResetFilterPredicate => + schema => !_resetBypass.Contains(schema.QualifiedName!) && schema.Schema != "sys" && schema.Schema != "cdc" && !(schema.Schema == "dbo" && schema.Name.StartsWith("sys")); - foreach (var sql in new SqlCommandSplitter().SplitScriptIntoCommands(sr.ReadToEnd())) - { - await Database.SqlStatement(ReplaceSqlRuntimeParameters(sql)).NonQueryAsync(cancellationToken).ConfigureAwait(false); - } + /// + protected override async Task ExecuteScriptAsync(DatabaseMigrationScript script, CancellationToken cancellationToken = default) + { + using var sr = script.GetStreamReader(); + + foreach (var sql in new SqlCommandSplitter().SplitScriptIntoCommands(sr.ReadToEnd())) + { + await Database.SqlStatement(ReplaceSqlRuntimeParameters(sql)).NonQueryAsync(cancellationToken).ConfigureAwait(false); } } } \ No newline at end of file diff --git a/src/DbEx.SqlServer/Migration/SqlServerSchemaScript.cs b/src/DbEx.SqlServer/Migration/SqlServerSchemaScript.cs index fcbeb98..35b852f 100644 --- a/src/DbEx.SqlServer/Migration/SqlServerSchemaScript.cs +++ b/src/DbEx.SqlServer/Migration/SqlServerSchemaScript.cs @@ -1,139 +1,129 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.SqlServer.Migration; -using DbEx.Migration; -using DbUp.Support; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - -namespace DbEx.SqlServer.Migration +/// +/// Provides the SQL Server database schema script functionality. +/// +public class SqlServerSchemaScript : DatabaseSchemaScriptBase { /// - /// Provides the SQL Server database schema script functionality. + /// Creates the from the . /// - public class SqlServerSchemaScript : DatabaseSchemaScriptBase + /// The . + /// The . + public static SqlServerSchemaScript Create(DatabaseMigrationScript migrationScript) { - /// - /// Creates the from the . - /// - /// The . - /// The . - public static SqlServerSchemaScript Create(DatabaseMigrationScript migrationScript) - { - var script = new SqlServerSchemaScript(migrationScript); + var script = new SqlServerSchemaScript(migrationScript); - using var sr = script.MigrationScript.GetStreamReader(); - var sql = sr.ReadToEnd(); - var tokens = new SqlCommandTokenizer(sql).ReadAllTokens(); + using var sr = script.MigrationScript.GetStreamReader(); + var sql = sr.ReadToEnd(); + var tokens = new SqlCommandTokenizer(sql).ReadAllTokens(); - for (int i = 0; i < tokens.Length; i++) - { - if (string.Compare(tokens[i], "create", StringComparison.OrdinalIgnoreCase) != 0) - continue; + for (int i = 0; i < tokens.Length; i++) + { + if (string.Compare(tokens[i], "create", StringComparison.OrdinalIgnoreCase) != 0) + continue; - if (i + 4 < tokens.Length) + if (i + 4 < tokens.Length) + { + if (string.Compare(tokens[i + 1], "or", StringComparison.OrdinalIgnoreCase) == 0 && string.Compare(tokens[i + 2], "alter", StringComparison.OrdinalIgnoreCase) == 0) { - if (string.Compare(tokens[i + 1], "or", StringComparison.OrdinalIgnoreCase) == 0 && string.Compare(tokens[i + 2], "alter", StringComparison.OrdinalIgnoreCase) == 0) - { - i = +2; - script.SupportsReplace = true; - } + i = +2; + script.SupportsReplace = true; + } - script.Type = tokens[i + 1]; - script.FullyQualifiedName = tokens[i + 2]; + script.Type = tokens[i + 1]; + script.FullyQualifiedName = tokens[i + 2]; - var index = script.FullyQualifiedName.IndexOf('.'); - if (index < 0) - { - script.Schema = migrationScript.DatabaseMigration.SchemaConfig.DefaultSchema; - script.Name = script.FullyQualifiedName; - } - else - { - script.Schema = script.FullyQualifiedName[..index]; - script.Name = script.FullyQualifiedName[(index + 1)..]; - } - - return script; + var index = script.FullyQualifiedName.IndexOf('.'); + if (index < 0) + { + script.Schema = migrationScript.DatabaseMigration.SchemaConfig.DefaultSchema; + script.Name = script.FullyQualifiedName; + } + else + { + script.Schema = script.FullyQualifiedName[..index]; + script.Name = script.FullyQualifiedName[(index + 1)..]; } - } - script.ErrorMessage = "The SQL statement must be a valid 'CREATE' statement."; - return script; + return script; + } } - /// - /// Initializes a new instance of the class. - /// - /// The parent . - private SqlServerSchemaScript(DatabaseMigrationScript migrationScript) : base(migrationScript, "[", "]") { } + script.ErrorMessage = "The SQL statement must be a valid 'CREATE' statement."; + return script; + } + + /// + /// Initializes a new instance of the class. + /// + /// The parent . + private SqlServerSchemaScript(DatabaseMigrationScript migrationScript) : base(migrationScript, "[", "]") { } + + /// + public override string SqlDropStatement => $"DROP {Type.ToUpperInvariant()} IF EXISTS [{Schema}].[{Name}]"; - /// - public override string SqlDropStatement => $"DROP {Type.ToUpperInvariant()} IF EXISTS [{Schema}].[{Name}]"; + /// + public override string SqlCreateStatement => $"CREATE {(SupportsReplace ? "OR ALTER " : "")}{Type.ToUpperInvariant()} [{Schema}].[{Name}]"; - /// - public override string SqlCreateStatement => $"CREATE {(SupportsReplace ? "OR ALTER " : "")}{Type.ToUpperInvariant()} [{Schema}].[{Name}]"; + private class SqlCommandTokenizer(string sqlText) : SqlCommandReader(sqlText) + { + private readonly char[] delimiters = ['(', ')', ';', ',', '=']; - private class SqlCommandTokenizer(string sqlText) : SqlCommandReader(sqlText) + public string[] ReadAllTokens() { - private readonly char[] delimiters = ['(', ')', ';', ',', '=']; + var words = new List(); + var sb = new StringBuilder(); - public string[] ReadAllTokens() + while (!HasReachedEnd) { - var words = new List(); - var sb = new StringBuilder(); - - while (!HasReachedEnd) + ReadCharacter += (type, c) => { - ReadCharacter += (type, c) => + switch (type) { - switch (type) - { - case CharacterType.Command: - if (char.IsWhiteSpace(c)) - { - if (sb.Length > 0) - words.Add(sb.ToString()); - - sb.Clear(); - break; - } - else if (delimiters.Contains(c)) - { - if (sb.Length > 0) - words.Add(sb.ToString()); - - sb.Clear(); - } - - sb.Append(c); - break; - - case CharacterType.BracketedText: - case CharacterType.QuotedString: - sb.Append(c); - break; + case CharacterType.Command: + if (char.IsWhiteSpace(c)) + { + if (sb.Length > 0) + words.Add(sb.ToString()); - case CharacterType.SlashStarComment: - case CharacterType.DashComment: - case CharacterType.CustomStatement: - case CharacterType.Delimiter: + sb.Clear(); break; + } + else if (delimiters.Contains(c)) + { + if (sb.Length > 0) + words.Add(sb.ToString()); + + sb.Clear(); + } + + sb.Append(c); + break; + + case CharacterType.BracketedText: + case CharacterType.QuotedString: + sb.Append(c); + break; + + case CharacterType.SlashStarComment: + case CharacterType.DashComment: + case CharacterType.CustomStatement: + case CharacterType.Delimiter: + break; + + default: + throw new ArgumentOutOfRangeException(nameof(type), type, null); + } + }; - default: - throw new ArgumentOutOfRangeException(nameof(type), type, null); - } - }; - - Parse(); - } + Parse(); + } - if (sb.Length > 0) - words.Add(sb.ToString()); + if (sb.Length > 0) + words.Add(sb.ToString()); - return [.. words]; - } + return [.. words]; } } } \ No newline at end of file diff --git a/src/DbEx.SqlServer/SqlServerSchemaConfig.cs b/src/DbEx.SqlServer/SqlServerSchemaConfig.cs index b7d16de..36636d5 100644 --- a/src/DbEx.SqlServer/SqlServerSchemaConfig.cs +++ b/src/DbEx.SqlServer/SqlServerSchemaConfig.cs @@ -1,267 +1,254 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx - -using DbEx.DbSchema; -using DbEx.Migration; -using DbEx.SqlServer.Migration; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.SqlServer +namespace DbEx.SqlServer; + +/// +/// Provides SQL Server specific configuration and capabilities. +/// +/// The owning . +public class SqlServerSchemaConfig(SqlServerMigration migration) : DatabaseSchemaConfig(migration, true, "dbo") { - /// - /// Provides SQL Server specific configuration and capabilities. - /// - /// The owning . - public class SqlServerSchemaConfig(SqlServerMigration migration) : DatabaseSchemaConfig(migration, true, "dbo") - { - /// - /// Value is 'Id'. - public override string IdColumnNameSuffix => "Id"; + /// + /// Value is 'Id'. + public override string IdColumnNameSuffix => "Id"; - /// - /// Value is 'Code'. - public override string CodeColumnNameSuffix => "Code"; + /// + /// Value is 'Code'. + public override string CodeColumnNameSuffix => "Code"; - /// - /// Value is 'Json'. - public override string JsonColumnNameSuffix => "Json"; + /// + /// Value is 'Json'. + public override string JsonColumnNameSuffix => "Json"; - /// - /// Value is 'CreatedOn'. - public override string CreatedOnColumnName => "CreatedOn"; + /// + /// Value is 'CreatedOn'. + public override string CreatedOnColumnName => "CreatedOn"; - /// - /// Value is 'CreatedBy'. - public override string CreatedByColumnName => "CreatedBy"; + /// + /// Value is 'CreatedBy'. + public override string CreatedByColumnName => "CreatedBy"; - /// - /// Value is 'UpdatedOn'. - public override string UpdatedOnColumnName => "UpdatedOn"; + /// + /// Value is 'UpdatedOn'. + public override string UpdatedOnColumnName => "UpdatedOn"; - /// - /// Value is 'UpdatedBy'. - public override string UpdatedByColumnName => "UpdatedBy"; + /// + /// Value is 'UpdatedBy'. + public override string UpdatedByColumnName => "UpdatedBy"; - /// - /// Value is 'TenantId'. - public override string TenantIdColumnName => "TenantId"; + /// + /// Value is 'TenantId'. + public override string TenantIdColumnName => "TenantId"; - /// - /// Value is 'RowVersion'. - public override string RowVersionColumnName => "RowVersion"; + /// + /// Value is 'RowVersion'. + public override string RowVersionColumnName => "RowVersion"; - /// - /// Value is 'IsDeleted'. - public override string IsDeletedColumnName => "IsDeleted"; + /// + /// Value is 'IsDeleted'. + public override string IsDeletedColumnName => "IsDeleted"; - /// - /// Value is 'Code'. - public override string RefDataCodeColumnName => "Code"; + /// + /// Value is 'Code'. + public override string RefDataCodeColumnName => "Code"; - /// - /// Value is 'Text'. - public override string RefDataTextColumnName => "Text"; + /// + /// Value is 'Text'. + public override string RefDataTextColumnName => "Text"; - /// - public override string ToFullyQualifiedTableName(string? schema, string table) => $"[{schema}].[{table}]"; + /// + public override string ToFullyQualifiedTableName(string? schema, string table) => $"[{schema}].[{table}]"; - /// - public override void PrepareMigrationArgs() - { - base.PrepareMigrationArgs(); + /// + public override void PrepareMigrationArgs() + { + base.PrepareMigrationArgs(); - Migration.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("IsActive", _ => true); - Migration.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("SortOrder", i => i); - } + Migration.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("IsActive", _ => true); + Migration.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("SortOrder", i => i); + } - /// - public override DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema table, DatabaseRecord dr) + /// + public override DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema table, DatabaseRecord dr) + { + var c = new DbColumnSchema(table, dr.GetValue("COLUMN_NAME")!, dr.GetValue("DATA_TYPE")!) { - var c = new DbColumnSchema(table, dr.GetValue("COLUMN_NAME")!, dr.GetValue("DATA_TYPE")!) - { - IsNullable = dr.GetValue("IS_NULLABLE")!.Equals("YES", StringComparison.OrdinalIgnoreCase), - Length = (ulong?)(dr.GetValue("CHARACTER_MAXIMUM_LENGTH") <= 0 ? null : dr.GetValue("CHARACTER_MAXIMUM_LENGTH")), - Precision = (ulong?)(dr.GetValue("NUMERIC_PRECISION") ?? dr.GetValue("DATETIME_PRECISION")), - Scale = (ulong?)dr.GetValue("NUMERIC_SCALE"), - DefaultValue = dr.GetValue("COLUMN_DEFAULT"), - IsDotNetDateOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")!).Equals("DATE", StringComparison.OrdinalIgnoreCase), - IsDotNetTimeOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")!).Equals("TIME", StringComparison.OrdinalIgnoreCase), - }; - - if (c.IsJsonContent = c.DotNetName == "string" && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)) - c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[..^JsonColumnNameSuffix.Length]); - - return c; - } + IsNullable = dr.GetValue("IS_NULLABLE")!.Equals("YES", StringComparison.OrdinalIgnoreCase), + Length = (ulong?)(dr.GetValue("CHARACTER_MAXIMUM_LENGTH") <= 0 ? null : dr.GetValue("CHARACTER_MAXIMUM_LENGTH")), + Precision = (ulong?)(dr.GetValue("NUMERIC_PRECISION") ?? dr.GetValue("DATETIME_PRECISION")), + Scale = (ulong?)dr.GetValue("NUMERIC_SCALE"), + DefaultValue = dr.GetValue("COLUMN_DEFAULT"), + IsDotNetDateOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")!).Equals("DATE", StringComparison.OrdinalIgnoreCase), + IsDotNetTimeOnly = RemovePrecisionFromDataType(dr.GetValue("DATA_TYPE")!).Equals("TIME", StringComparison.OrdinalIgnoreCase), + }; - /// - /// Removes any precision from the data type. - /// - private static string RemovePrecisionFromDataType(string type) => type.Contains('(') ? type[..type.IndexOf('(')] : type; + if (c.IsJsonContent = c.DotNetName == "string" && c.Name.EndsWith(JsonColumnNameSuffix, StringComparison.Ordinal)) + c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[..^JsonColumnNameSuffix.Length]); - /// - public override async Task LoadAdditionalInformationSchema(IDatabase database, List tables, CancellationToken cancellationToken) - { - // Configure all the single column foreign keys. - using var sr3 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableForeignKeys.sql", [typeof(SqlServerSchemaConfig).Assembly]); + return c; + } + + /// + /// Removes any precision from the data type. + /// + private static string RemovePrecisionFromDataType(string type) => type.Contains('(') ? type[..type.IndexOf('(')] : type; + + /// + public override async Task LoadAdditionalInformationSchema(IDatabase database, List tables, CancellationToken cancellationToken) + { + // Configure all the single column foreign keys. + using var sr3 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableForeignKeys.sql", [typeof(SqlServerSchemaConfig).Assembly]); #if NET7_0_OR_GREATER - var fks = await database.SqlStatement(await sr3.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new + var fks = await database.SqlStatement(await sr3.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new #else - var fks = await database.SqlStatement(await sr3.ReadToEndAsync().ConfigureAwait(false)).SelectQueryAsync(dr => new + var fks = await database.SqlStatement(await sr3.ReadToEndAsync().ConfigureAwait(false)).SelectQueryAsync(dr => new #endif - { - ConstraintName = dr.GetValue("FK_CONSTRAINT_NAME"), - TableSchema = dr.GetValue("FK_SCHEMA_NAME"), - TableName = dr.GetValue("FK_TABLE_NAME"), - TableColumnName = dr.GetValue("FK_COLUMN_NAME"), - ForeignSchema = dr.GetValue("UQ_SCHEMA_NAME"), - ForeignTable = dr.GetValue("UQ_TABLE_NAME"), - ForiegnColumn = dr.GetValue("UQ_COLUMN_NAME") - }, cancellationToken).ConfigureAwait(false); - - foreach (var grp in fks.GroupBy(x => new { x.ConstraintName, x.TableSchema, x.TableName }).Where(x => x.Count() == 1)) - { - var fk = grp.Single(); - var r = (from t in tables - from c in t.Columns - where (t.Schema == fk.TableSchema && t.Name == fk.TableName && c.Name == fk.TableColumnName) - select (t, c)).SingleOrDefault(); - - if (r == default) - continue; - - r.c.ForeignSchema = fk.ForeignSchema; - r.c.ForeignTable = fk.ForeignTable; - r.c.ForeignColumn = fk.ForiegnColumn; - r.c.IsForeignRefData = (from t in tables where (t.Schema == fk.ForeignSchema && t.Name == fk.ForeignTable) select t.IsRefData).FirstOrDefault(); - } - - // Select the table identity columns. - using var sr4 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableIdentityColumns.sql", [typeof(SqlServerSchemaConfig).Assembly]); + { + ConstraintName = dr.GetValue("FK_CONSTRAINT_NAME"), + TableSchema = dr.GetValue("FK_SCHEMA_NAME"), + TableName = dr.GetValue("FK_TABLE_NAME"), + TableColumnName = dr.GetValue("FK_COLUMN_NAME"), + ForeignSchema = dr.GetValue("UQ_SCHEMA_NAME"), + ForeignTable = dr.GetValue("UQ_TABLE_NAME"), + ForiegnColumn = dr.GetValue("UQ_COLUMN_NAME") + }, cancellationToken).ConfigureAwait(false); + + foreach (var grp in fks.GroupBy(x => new { x.ConstraintName, x.TableSchema, x.TableName }).Where(x => x.Count() == 1)) + { + var fk = grp.Single(); + var r = (from t in tables + from c in t.Columns + where (t.Schema == fk.TableSchema && t.Name == fk.TableName && c.Name == fk.TableColumnName) + select (t, c)).SingleOrDefault(); + + if (r == default) + continue; + + r.c.ForeignSchema = fk.ForeignSchema; + r.c.ForeignTable = fk.ForeignTable; + r.c.ForeignColumn = fk.ForiegnColumn; + r.c.IsForeignRefData = (from t in tables where (t.Schema == fk.ForeignSchema && t.Name == fk.ForeignTable) select t.IsRefData).FirstOrDefault(); + } + + // Select the table identity columns. + using var sr4 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableIdentityColumns.sql", [typeof(SqlServerSchemaConfig).Assembly]); #if NET7_0_OR_GREATER - await database.SqlStatement(await sr4.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => + await database.SqlStatement(await sr4.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => #else - await database.SqlStatement(await sr4.ReadToEndAsync().ConfigureAwait(false)).SelectQueryAsync(dr => + await database.SqlStatement(await sr4.ReadToEndAsync().ConfigureAwait(false)).SelectQueryAsync(dr => #endif - { - var t = tables.SingleOrDefault(x => x.Schema == dr.GetValue("TABLE_SCHEMA") && x.Name == dr.GetValue("TABLE_NAME")); - if (t == null) - return 0; - - var c = t.Columns.Single(x => x.Name == dr.GetValue("COLUMN_NAME")); - c.IsIdentity = true; - c.IdentitySeed = 1; - c.IdentityIncrement = 1; + { + var t = tables.SingleOrDefault(x => x.Schema == dr.GetValue("TABLE_SCHEMA") && x.Name == dr.GetValue("TABLE_NAME")); + if (t == null) return 0; - }, cancellationToken).ConfigureAwait(false); - // Select the "always" generated columns. - using var sr5 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableAlwaysGeneratedColumns.sql", [typeof(SqlServerSchemaConfig).Assembly]); + var c = t.Columns.Single(x => x.Name == dr.GetValue("COLUMN_NAME")); + c.IsIdentity = true; + c.IdentitySeed = 1; + c.IdentityIncrement = 1; + return 0; + }, cancellationToken).ConfigureAwait(false); + + // Select the "always" generated columns. + using var sr5 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableAlwaysGeneratedColumns.sql", [typeof(SqlServerSchemaConfig).Assembly]); #if NET7_0_OR_GREATER - await database.SqlStatement(await sr5.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => + await database.SqlStatement(await sr5.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => #else - await database.SqlStatement(await sr5.ReadToEndAsync().ConfigureAwait(false)).SelectQueryAsync(dr => + await database.SqlStatement(await sr5.ReadToEndAsync().ConfigureAwait(false)).SelectQueryAsync(dr => #endif - { - var t = tables.SingleOrDefault(x => x.Schema == dr.GetValue("TABLE_SCHEMA") && x.Name == dr.GetValue("TABLE_NAME")); - if (t == null) - return 0; - - var c = t.Columns.Single(x => x.Name == dr.GetValue("COLUMN_NAME")); - t.Columns.Remove(c); + { + var t = tables.SingleOrDefault(x => x.Schema == dr.GetValue("TABLE_SCHEMA") && x.Name == dr.GetValue("TABLE_NAME")); + if (t == null) return 0; - }, cancellationToken).ConfigureAwait(false); - // Select the generated columns. - using var sr6 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableGeneratedColumns.sql", [typeof(SqlServerSchemaConfig).Assembly]); + var c = t.Columns.Single(x => x.Name == dr.GetValue("COLUMN_NAME")); + t.Columns.Remove(c); + return 0; + }, cancellationToken).ConfigureAwait(false); + + // Select the generated columns. + using var sr6 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableGeneratedColumns.sql", [typeof(SqlServerSchemaConfig).Assembly]); #if NET7_0_OR_GREATER - await database.SqlStatement(await sr6.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => + await database.SqlStatement(await sr6.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => #else - await database.SqlStatement(await sr6.ReadToEndAsync().ConfigureAwait(false)).SelectQueryAsync(dr => + await database.SqlStatement(await sr6.ReadToEndAsync().ConfigureAwait(false)).SelectQueryAsync(dr => #endif - { - var t = tables.SingleOrDefault(x => x.Schema == dr.GetValue("TABLE_SCHEMA") && x.Name == dr.GetValue("TABLE_NAME")); - if (t == null) - return 0; - - var c = t.Columns.Single(x => x.Name == dr.GetValue("COLUMN_NAME")); - c.IsComputed = true; + { + var t = tables.SingleOrDefault(x => x.Schema == dr.GetValue("TABLE_SCHEMA") && x.Name == dr.GetValue("TABLE_NAME")); + if (t == null) return 0; - }, cancellationToken).ConfigureAwait(false); - } - /// - public override string ToDotNetTypeName(DbColumnSchema schema) - { - var dbType = RemovePrecisionFromDataType(schema.ThrowIfNull(nameof(schema)).Type); - if (string.IsNullOrEmpty(dbType)) - return "string"; - - if (Migration.Args.EmitDotNetDateOnly && schema.IsDotNetDateOnly) - return "DateOnly"; - else if (Migration.Args.EmitDotNetTimeOnly && schema.IsDotNetTimeOnly) - return "TimeOnly"; - - return dbType.ToUpperInvariant() switch - { - "NCHAR" or "CHAR" or "NVARCHAR" or "VARCHAR" or "TEXT" or "NTEXT" => "string", - "DECIMAL" or "MONEY" or "NUMERIC" or "SMALLMONEY" => "decimal", - "DATETIME" or "DATETIME2" or "SMALLDATETIME" => "DateTime", - "DATETIMEOFFSET" => "DateTimeOffset", - "DATE" => "DateOnly", - "TIME" => "TimeOnly", - "ROWVERSION" or "TIMESTAMP" or "BINARY" or "VARBINARY" or "IMAGE" => "byte[]", - "BIT" => "bool", - "FLOAT" => "double", - "INT" => "int", - "BIGINT" => "long", - "SMALLINT" => "short", - "TINYINT" => "byte", - "REAL" => "float", - "UNIQUEIDENTIFIER" => "Guid", - _ => throw new InvalidOperationException($"Database data type '{dbType}' does not have corresponding .NET type mapping defined."), - }; - } + var c = t.Columns.Single(x => x.Name == dr.GetValue("COLUMN_NAME")); + c.IsComputed = true; + return 0; + }, cancellationToken).ConfigureAwait(false); + } + + /// + public override string ToDotNetTypeName(DbColumnSchema schema) + { + var dbType = RemovePrecisionFromDataType(schema.ThrowIfNull(nameof(schema)).Type); + if (string.IsNullOrEmpty(dbType)) + return "string"; - /// - public override string ToFormattedSqlType(DbColumnSchema schema, bool includeNullability = true) + if (Migration.Args.EmitDotNetDateOnly && schema.IsDotNetDateOnly) + return "DateOnly"; + else if (Migration.Args.EmitDotNetTimeOnly && schema.IsDotNetTimeOnly) + return "TimeOnly"; + + return dbType.ToUpperInvariant() switch { - var sb = new StringBuilder(schema.Type!.ToUpperInvariant()); - - sb.Append(schema.Type.ToUpperInvariant() switch - { - "CHAR" or "VARCHAR" or "NCHAR" or "NVARCHAR" => schema.Length.HasValue && schema.Length.Value > 0 ? $"({schema.Length.Value})" : "(MAX)", - "DECIMAL" => $"({schema.Precision}, {schema.Scale})", - "NUMERIC" => $"({schema.Precision}, {schema.Scale})", - "TIME" => schema.Scale.HasValue && schema.Scale.Value > 0 ? $"({schema.Scale})" : string.Empty, - "BINARY" or "VARBINARY" => $"(schema.Precision)", - _ => string.Empty - }); - - if (includeNullability && schema.IsNullable) - sb.Append(" NULL"); - - return sb.ToString(); - } + "NCHAR" or "CHAR" or "NVARCHAR" or "VARCHAR" or "TEXT" or "NTEXT" => "string", + "DECIMAL" or "MONEY" or "NUMERIC" or "SMALLMONEY" => "decimal", + "DATETIME" or "DATETIME2" or "SMALLDATETIME" => "DateTime", + "DATETIMEOFFSET" => "DateTimeOffset", + "DATE" => "DateOnly", + "TIME" => "TimeOnly", + "ROWVERSION" or "TIMESTAMP" or "BINARY" or "VARBINARY" or "IMAGE" => "byte[]", + "BIT" => "bool", + "FLOAT" => "double", + "INT" => "int", + "BIGINT" => "long", + "SMALLINT" => "short", + "TINYINT" => "byte", + "REAL" => "float", + "UNIQUEIDENTIFIER" => "Guid", + _ => throw new InvalidOperationException($"Database data type '{dbType}' does not have corresponding .NET type mapping defined."), + }; + } + + /// + public override string ToFormattedSqlType(DbColumnSchema schema, bool includeNullability = true) + { + var sb = new StringBuilder(schema.Type!.ToUpperInvariant()); - /// - public override string ToFormattedSqlStatementValue(DbColumnSchema dbColumnSchema, object? value) => value switch + sb.Append(schema.Type.ToUpperInvariant() switch { - null => "NULL", - string str => $"N'{str.Replace("'", "''", StringComparison.Ordinal)}'", - bool b => b ? "1" : "0", - Guid => $"CONVERT(UNIQUEIDENTIFIER, '{value}')", - DateTime dt => $"'{dt.ToString(Migration.Args.DataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", - DateTimeOffset dto => $"'{dto.ToString(Migration.Args.DataParserArgs.DateTimeOffsetFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + "CHAR" or "VARCHAR" or "NCHAR" or "NVARCHAR" => schema.Length.HasValue && schema.Length.Value > 0 ? $"({schema.Length.Value})" : "(MAX)", + "DECIMAL" => $"({schema.Precision}, {schema.Scale})", + "NUMERIC" => $"({schema.Precision}, {schema.Scale})", + "TIME" => schema.Scale.HasValue && schema.Scale.Value > 0 ? $"({schema.Scale})" : string.Empty, + "BINARY" or "VARBINARY" => $"(schema.Precision)", + _ => string.Empty + }); + + if (includeNullability && schema.IsNullable) + sb.Append(" NULL"); + + return sb.ToString(); + } + + /// + public override string ToFormattedSqlStatementValue(DbColumnSchema dbColumnSchema, object? value) => value switch + { + null => "NULL", + string str => $"N'{str.Replace("'", "''", StringComparison.Ordinal)}'", + bool b => b ? "1" : "0", + Guid => $"CONVERT(UNIQUEIDENTIFIER, '{value}')", + DateTime dt => $"'{dt.ToString(Migration.Args.DataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + DateTimeOffset dto => $"'{dto.ToString(Migration.Args.DataParserArgs.DateTimeOffsetFormat, System.Globalization.CultureInfo.InvariantCulture)}'", #if NET7_0_OR_GREATER - DateOnly d => $"'{d.ToString(Migration.Args.DataParserArgs.DateOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", - TimeOnly t => $"'{t.ToString(Migration.Args.DataParserArgs.TimeOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + DateOnly d => $"'{d.ToString(Migration.Args.DataParserArgs.DateOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + TimeOnly t => $"'{t.ToString(Migration.Args.DataParserArgs.TimeOnlyFormat, System.Globalization.CultureInfo.InvariantCulture)}'", #endif - _ => value.ToString()! - }; - } + _ => value.ToString()! + }; } \ No newline at end of file diff --git a/src/DbEx/CodeGen/Config/CodeGenConfig.cs b/src/DbEx/CodeGen/Config/CodeGenConfig.cs new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/src/DbEx/CodeGen/Config/CodeGenConfig.cs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/DbEx/CodeGen/Config/IColumnConfig.cs b/src/DbEx/CodeGen/Config/IColumnConfig.cs new file mode 100644 index 0000000..ac3c205 --- /dev/null +++ b/src/DbEx/CodeGen/Config/IColumnConfig.cs @@ -0,0 +1,162 @@ +namespace DbEx.CodeGen.Config; + +/// +/// Defines the column configuration. +/// +public interface IColumnConfig +{ + /// + /// Gets the column name. + /// + string? Name { get; } + + /// + /// Gets the database configuration. + /// + DbColumnSchema? DbColumn { get; } + + /// + /// Gets the qualified name (includes the alias). + /// + public string QualifiedName { get; } + + /// + /// Gets the parameter name. + /// + public string ParameterName { get; } + + /// + /// Gets the SQL type. + /// + public string? SqlType { get; } + + /// + /// Gets the parameter SQL definition. + /// + public string? ParameterSql { get; } + + /// + /// Gets the UDT SQL definition. + /// + public string? UdtSql { get; } + + /// + /// Gets the where equality clause. + /// + public string WhereEquals { get; } + + /// + /// Gets the SQL for defining initial value for comparisons. + /// + public string SqlInitialValue { get; } + + /// + /// Indicates where the column is the "TenantId" column. + /// + public bool IsTenantIdColumn { get; } + + /// + /// Indicates where the column is the "OrgUnitId" column. + /// + public bool IsOrgUnitIdColumn { get; } + + /// + /// Indicates where the column is the "RowVersion" column. + /// + public bool IsRowVersionColumn { get; } + + /// + /// Indicates where the column is the "IsDeleted" column. + /// + public bool IsIsDeletedColumn { get; } + + /// + /// Indicates whether the column is considered an audit column. + /// + public bool IsAudit { get; } + + /// + /// Indicates whether the column is "CreatedBy" or "CreatedDate". + /// + public bool IsCreated { get; } + + /// + /// Indicates whether the column is "CreatedBy". + /// + public bool IsCreatedBy { get; } + + /// + /// Indicates whether the column is "CreatedDate". + /// + public bool IsCreatedDate { get; } + + /// + /// Indicates whether the column is "UpdatedBy" or "UpdatedDate". + /// + public bool IsUpdated { get; } + + /// + /// Indicates whether the column is "UpdatedBy". + /// + public bool IsUpdatedBy { get; } + + /// + /// Indicates whether the column is "UpdatedDate". + /// + public bool IsUpdatedDate { get; } + + /// + /// Indicates whether the column is "DeletedBy" or "DeletedDate". + /// + public bool IsDeleted { get; } + + /// + /// Indicates whether the column is "DeletedBy". + /// + public bool IsDeletedBy { get; } + + /// + /// Indicates whether the column is "DeletedDate". + /// + public bool IsDeletedDate { get; } + + /// + /// Indicates where the column should be considered for a 'Create' operation. + /// + public bool IsCreateColumn { get; } + + /// + /// Indicates where the column should be considered for a 'Update' operation. + /// + public bool IsUpdateColumn { get; } + + /// + /// Indicates where the column should be considered for a 'Delete' operation. + /// + public bool IsDeleteColumn { get; } + + /// + /// Gets the EF SQL Type. + /// + public string? EfSqlType { get; } + + /// + /// Gets the corresponding .NET name. + /// + public string DotNetType { get; } + + /// + /// Indicates whether the .NET property is nullable. + /// + public bool IsDotNetNullable { get; } + + /// + /// Gets the name alias. + /// + public string? NameAlias { get; } + + /// + /// Gets the qualified name with the alias (used in a select). + /// + public string QualifiedNameWithAlias { get; } +} \ No newline at end of file diff --git a/src/DbEx/CodeGen/Config/ISpecialColumns.cs b/src/DbEx/CodeGen/Config/ISpecialColumns.cs new file mode 100644 index 0000000..8a04e3c --- /dev/null +++ b/src/DbEx/CodeGen/Config/ISpecialColumns.cs @@ -0,0 +1,103 @@ +namespace DbEx.CodeGen.Config; + +/// +/// Provides the standardized set of special column names. +/// +public interface ISpecialColumnNames +{ + /// + /// Gets or sets the column name for the `IsDeleted` capability. + /// + string? ColumnNameIsDeleted { get; set; } + + /// + /// Gets or sets the column name for the `TenantId` capability. + /// + string? ColumnNameTenantId { get; set; } + + /// + /// Gets or sets the column name for the `OrgUnitId` capability. + /// + string? ColumnNameOrgUnitId { get; set; } + + /// + /// Gets or sets the column name for the `RowVersion` capability. + /// + string? ColumnNameRowVersion { get; set; } + + /// + /// Gets or sets the column name for the `CreatedBy` capability. + /// + string? ColumnNameCreatedBy { get; set; } + + /// + /// Gets or sets the column name for the `CreatedOn` capability. + /// + string? ColumnNameCreatedDate { get; set; } + + /// + /// Gets or sets the column name for the `UpdatedBy` capability. + /// + string? ColumnNameUpdatedBy { get; set; } + + /// + /// Gets or sets the column name for the `UpdatedOn` capability. + /// + string? ColumnNameUpdatedDate { get; set; } + + /// + /// Gets or sets the column name for the `DeletedBy` capability. + /// + string? ColumnNameDeletedBy { get; set; } + + /// + /// Gets or sets the column name for the `DeletedDate` capability. + /// + string? ColumnNameDeletedDate { get; set; } +} + +/// +/// Provides the standardized set of special columns. +/// +public interface ISpecialColumns +{ + /// + /// Gets the related TenantId column. + /// + IColumnConfig? ColumnTenantId { get; } + + /// + /// Gets the related OrgUnitId column. + /// + IColumnConfig? ColumnOrgUnitId { get; } + + /// + /// Gets the related RowVersion column. + /// + IColumnConfig? ColumnRowVersion { get; } + + /// + /// Gets the related IsDeleted column. + /// + IColumnConfig? ColumnIsDeleted { get; } + + /// + /// Gets the related CreatedBy column. + /// + IColumnConfig? ColumnCreatedBy { get; } + + /// + /// Gets the related CreatedOn column. + /// + IColumnConfig? ColumnCreatedOn { get; } + + /// + /// Gets the related UpdatedBy column. + /// + IColumnConfig? ColumnUpdatedBy { get; } + + /// + /// Gets the related UpdatedDate column. + /// + IColumnConfig? ColumnUpdatedOn { get; } +} \ No newline at end of file diff --git a/src/DbEx/Console/AssemblyValidator.cs b/src/DbEx/Console/AssemblyValidator.cs index a9faf42..3df6a3b 100644 --- a/src/DbEx/Console/AssemblyValidator.cs +++ b/src/DbEx/Console/AssemblyValidator.cs @@ -1,51 +1,38 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Console; -using DbEx.Migration; -using McMaster.Extensions.CommandLineUtils; -using McMaster.Extensions.CommandLineUtils.Validation; -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.IO; -using System.Linq; -using System.Reflection; - -namespace DbEx.Console +/// +/// Validates the assembly name(s). +/// +/// The to update. +public class AssemblyValidator(MigrationArgsBase args) : IOptionValidator { + private readonly MigrationArgsBase _args = args.ThrowIfNull(nameof(args)); + /// - /// Validates the assembly name(s). + /// Performs the validation. /// - /// The to update. - public class AssemblyValidator(MigrationArgsBase args) : IOptionValidator + /// The . + /// The . + /// The . + public ValidationResult GetValidationResult(CommandOption option, ValidationContext context) { - private readonly MigrationArgsBase _args = args.ThrowIfNull(nameof(args)); + option.ThrowIfNull(nameof(option)); + context.ThrowIfNull(nameof(context)); - /// - /// Performs the validation. - /// - /// The . - /// The . - /// The . - public ValidationResult GetValidationResult(CommandOption option, ValidationContext context) + var list = new List(); + foreach (var name in option.Values.Where(x => !string.IsNullOrEmpty(x))) { - option.ThrowIfNull(nameof(option)); - context.ThrowIfNull(nameof(context)); - - var list = new List(); - foreach (var name in option.Values.Where(x => !string.IsNullOrEmpty(x))) + try { - try - { - // Load from the specified file on the file system or by using its long form name. - _args.AddAssembly(File.Exists(name) ? Assembly.LoadFrom(name!) : Assembly.Load(name!)); - } - catch (Exception ex) - { - return new ValidationResult($"The specified assembly '{name}' is invalid: {ex.Message}"); - } + // Load from the specified file on the file system or by using its long form name. + _args.AddAssembly(File.Exists(name) ? Assembly.LoadFrom(name!) : Assembly.Load(name!)); + } + catch (Exception ex) + { + return new ValidationResult($"The specified assembly '{name}' is invalid: {ex.Message}"); } - - return ValidationResult.Success!; } + + return ValidationResult.Success!; } } \ No newline at end of file diff --git a/src/DbEx/Console/MigrationConsoleBase.cs b/src/DbEx/Console/MigrationConsoleBase.cs index bbe269e..6d73523 100644 --- a/src/DbEx/Console/MigrationConsoleBase.cs +++ b/src/DbEx/Console/MigrationConsoleBase.cs @@ -1,444 +1,425 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx - -using DbEx.Migration; -using McMaster.Extensions.CommandLineUtils; -using Microsoft.Extensions.Logging; -using OnRamp; -using OnRamp.Console; -using OnRamp.Utility; -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.Console +namespace DbEx.Console; + +/// +/// Base console that facilitates the by managing the standard console command-line arguments/options. +/// +/// The standard console command-line arguments/options can be controlled via the constructor using the flags. Additional capabilities can be added by inheriting and overriding the +/// , and . Changes to the console output can be achieved by overriding +/// , , and . +/// The underlying command line parsing is provided by . +/// The default that will be overridden/updated by the command-line argument values. +public abstract class MigrationConsoleBase(MigrationArgsBase args) { + private static readonly string[] memberNames = ["args"]; + private const string EntryAssemblyOnlyOptionName = "entry-assembly-only"; + private const string AcceptPromptsOptionName = "accept-prompts"; + private const string DropSchemaObjectsName = "drop-schema-objects"; + private CommandArgument? _commandArg; + private CommandArgument? _additionalArgs; + private CommandOption? _helpOption; + /// - /// Base console that facilitates the by managing the standard console command-line arguments/options. + /// Gets the . /// - /// The standard console command-line arguments/options can be controlled via the constructor using the flags. Additional capabilities can be added by inheriting and overriding the - /// , and . Changes to the console output can be achieved by overriding - /// , , and . - /// The underlying command line parsing is provided by . - /// The default that will be overridden/updated by the command-line argument values. - public abstract class MigrationConsoleBase(MigrationArgsBase args) - { - private static readonly string[] memberNames = ["args"]; - private const string EntryAssemblyOnlyOptionName = "entry-assembly-only"; - private const string AcceptPromptsOptionName = "accept-prompts"; - private const string DropSchemaObjectsName = "drop-schema-objects"; - private CommandArgument? _commandArg; - private CommandArgument? _additionalArgs; - private CommandOption? _helpOption; - - /// - /// Gets the . - /// - public MigrationArgsBase Args { get; } = args.ThrowIfNull(nameof(args)); - - /// - /// Gets the application/command name. - /// - public virtual string AppName => (Assembly.GetEntryAssembly() ?? Assembly.GetCallingAssembly()).GetName()?.Name ?? "UNKNOWN"; - - /// - /// Gets the application/command title. - /// - public virtual string AppTitle => $"{AppName} Database Tool."; - - /// - /// Gets the . - /// - protected ILogger? Logger => Args.Logger; - - /// - /// Gets the console (command line) options. - /// - protected Dictionary ConsoleOptions { get; } = []; - - /// - /// Indicates whether to bypass standard execution of , , and . - /// - protected bool BypassOnWrites { get; set; } - - /// - /// Gets or sets the masthead text used by . - /// - /// Defaults to 'OnRamp Code-Gen Tool' formatted using . - public string? MastheadText { get; protected set; } = @" + public MigrationArgsBase Args { get; } = args.ThrowIfNull(nameof(args)); + + /// + /// Gets the application/command name. + /// + public virtual string AppName => (Assembly.GetEntryAssembly() ?? Assembly.GetCallingAssembly()).GetName()?.Name ?? "UNKNOWN"; + + /// + /// Gets the application/command title. + /// + public virtual string AppTitle => $"{AppName} Database Tool."; + + /// + /// Gets the . + /// + protected ILogger? Logger => Args.Logger; + + /// + /// Gets the console (command line) options. + /// + protected Dictionary ConsoleOptions { get; } = []; + + /// + /// Indicates whether to bypass standard execution of , , and . + /// + protected bool BypassOnWrites { get; set; } + + /// + /// Gets or sets the masthead text used by . + /// + /// Defaults to 'OnRamp Code-Gen Tool' formatted using . + public string? MastheadText { get; protected set; } = @" ╔╦╗┌┐ ╔═╗─┐ ┬ ╔╦╗┌─┐┌┬┐┌─┐┌┐ ┌─┐┌─┐┌─┐ ╔╦╗┌─┐┌─┐┬ ║║├┴┐║╣ ┌┴┬┘ ║║├─┤ │ ├─┤├┴┐├─┤└─┐├┤ ║ │ ││ ││ ═╩╝└─┘╚═╝┴ └─ ═╩╝┴ ┴ ┴ ┴ ┴└─┘┴ ┴└─┘└─┘ ╩ └─┘└─┘┴─┘ "; - /// - /// Gets or sets the supported (s); where executed with an unsupported command an error will occur. - /// - /// Defaults to everything: , , , and . - public MigrationCommand SupportedCommands { get; set; } = MigrationCommand.All | MigrationCommand.Reset | MigrationCommand.Drop | MigrationCommand.Execute | MigrationCommand.Script; - - /// - /// Runs the code generation using the passed . - /// - /// The . - /// The . - /// Zero indicates success; otherwise, unsuccessful. - public async Task RunAsync(MigrationCommand migrationCommand, CancellationToken cancellationToken = default) => await RunAsync(migrationCommand.ToString(), cancellationToken).ConfigureAwait(false); - - /// - /// Runs the code generation using the passed string. - /// - /// The command-line arguments. - /// The . - /// Zero indicates success; otherwise, unsuccessful. - public async Task RunAsync(string? args = null, CancellationToken cancellationToken = default) => await RunAsync(CodeGenConsole.SplitArgumentsIntoArray(args), cancellationToken).ConfigureAwait(false); - - /// - /// Runs the code generation using the passed array. - /// - /// The command-line arguments. - /// The . - /// Zero indicates success; otherwise, unsuccessful. - public async Task RunAsync(string[] args, CancellationToken cancellationToken = default) + /// + /// Gets or sets the supported (s); where executed with an unsupported command an error will occur. + /// + /// Defaults to everything: , , , and . + public MigrationCommand SupportedCommands { get; set; } = MigrationCommand.All | MigrationCommand.Reset | MigrationCommand.Drop | MigrationCommand.Execute | MigrationCommand.Script; + + /// + /// Runs the code generation using the passed . + /// + /// The . + /// The . + /// Zero indicates success; otherwise, unsuccessful. + public async Task RunAsync(MigrationCommand migrationCommand, CancellationToken cancellationToken = default) => await RunAsync(migrationCommand.ToString(), cancellationToken).ConfigureAwait(false); + + /// + /// Runs the code generation using the passed string. + /// + /// The command-line arguments. + /// The . + /// Zero indicates success; otherwise, unsuccessful. + public async Task RunAsync(string? args = null, CancellationToken cancellationToken = default) => await RunAsync(CodeGenConsole.SplitArgumentsIntoArray(args), cancellationToken).ConfigureAwait(false); + + /// + /// Runs the code generation using the passed array. + /// + /// The command-line arguments. + /// The . + /// Zero indicates success; otherwise, unsuccessful. + public async Task RunAsync(string[] args, CancellationToken cancellationToken = default) + { + Args.Logger ??= new ConsoleLogger(PhysicalConsole.Singleton); + HandlebarsHelpers.Logger ??= Args.Logger; + + // Set up the app. + using var app = new CommandLineApplication(PhysicalConsole.Singleton) { Name = AppName, Description = AppTitle }; + _helpOption = app.HelpOption(); + + _commandArg = app.Argument("command", "Database migration command (see https://github.com/Avanade/dbex#commands-functions).").IsRequired(); + ConsoleOptions.Add(nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionString), app.Option("-cs|--connection-string", "Database connection string.", CommandOptionType.SingleValue)); + ConsoleOptions.Add(nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionStringEnvironmentVariableName), app.Option("-cv|--connection-varname", "Database connection string environment variable name.", CommandOptionType.SingleValue)); + ConsoleOptions.Add(nameof(MigrationArgsBase.SchemaOrder), app.Option("-so|--schema-order", "Database schema name (multiple can be specified in priority order).", CommandOptionType.MultipleValue)); + ConsoleOptions.Add(nameof(MigrationArgsBase.OutputDirectory), app.Option("-o|--output", "Output directory path.", CommandOptionType.MultipleValue).Accepts(v => v.ExistingDirectory("Output directory path does not exist."))); + ConsoleOptions.Add(nameof(MigrationArgsBase.Assemblies), app.Option("-a|--assembly", "Assembly containing embedded resources (multiple can be specified in probing order).", CommandOptionType.MultipleValue)); + ConsoleOptions.Add(nameof(MigrationArgsBase.Parameters), app.Option("-p|--param", "Parameter expressed as a 'Name=Value' pair (multiple can be specified).", CommandOptionType.MultipleValue)); + ConsoleOptions.Add(EntryAssemblyOnlyOptionName, app.Option("-eo|--entry-assembly-only", "Use the entry assembly only (ignore all other assemblies).", CommandOptionType.NoValue)); + ConsoleOptions.Add(DropSchemaObjectsName, app.Option("-dso|--drop-schema-objects", "Drop all known schema objects before applying; bypasses automatic skip where all scripts are replacements.", CommandOptionType.NoValue)); + ConsoleOptions.Add(AcceptPromptsOptionName, app.Option("--accept-prompts", "Accept prompts; command should _not_ stop and wait for user confirmation (DROP or RESET commands).", CommandOptionType.NoValue)); + _additionalArgs = app.Argument("args", "Additional arguments; 'Script' arguments (first being the script name) -or- 'Execute' (each a SQL statement to invoke).", multipleValues: true); + + OnBeforeExecute(app); + + // Set up the validation. + app.OnValidate(ctx => { - Args.Logger ??= new ConsoleLogger(PhysicalConsole.Singleton); - HandlebarsHelpers.Logger ??= Args.Logger; - - // Set up the app. - using var app = new CommandLineApplication(PhysicalConsole.Singleton) { Name = AppName, Description = AppTitle }; - _helpOption = app.HelpOption(); - - _commandArg = app.Argument("command", "Database migration command (see https://github.com/Avanade/dbex#commands-functions).").IsRequired(); - ConsoleOptions.Add(nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionString), app.Option("-cs|--connection-string", "Database connection string.", CommandOptionType.SingleValue)); - ConsoleOptions.Add(nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionStringEnvironmentVariableName), app.Option("-cv|--connection-varname", "Database connection string environment variable name.", CommandOptionType.SingleValue)); - ConsoleOptions.Add(nameof(MigrationArgsBase.SchemaOrder), app.Option("-so|--schema-order", "Database schema name (multiple can be specified in priority order).", CommandOptionType.MultipleValue)); - ConsoleOptions.Add(nameof(MigrationArgsBase.OutputDirectory), app.Option("-o|--output", "Output directory path.", CommandOptionType.MultipleValue).Accepts(v => v.ExistingDirectory("Output directory path does not exist."))); - ConsoleOptions.Add(nameof(MigrationArgsBase.Assemblies), app.Option("-a|--assembly", "Assembly containing embedded resources (multiple can be specified in probing order).", CommandOptionType.MultipleValue)); - ConsoleOptions.Add(nameof(MigrationArgsBase.Parameters), app.Option("-p|--param", "Parameter expressed as a 'Name=Value' pair (multiple can be specified).", CommandOptionType.MultipleValue)); - ConsoleOptions.Add(EntryAssemblyOnlyOptionName, app.Option("-eo|--entry-assembly-only", "Use the entry assembly only (ignore all other assemblies).", CommandOptionType.NoValue)); - ConsoleOptions.Add(DropSchemaObjectsName, app.Option("-dso|--drop-schema-objects", "Drop all known schema objects before applying; bypasses automatic skip where all scripts are replacements.", CommandOptionType.NoValue)); - ConsoleOptions.Add(AcceptPromptsOptionName, app.Option("--accept-prompts", "Accept prompts; command should _not_ stop and wait for user confirmation (DROP or RESET commands).", CommandOptionType.NoValue)); - _additionalArgs = app.Argument("args", "Additional arguments; 'Script' arguments (first being the script name) -or- 'Execute' (each a SQL statement to invoke).", multipleValues: true); - - OnBeforeExecute(app); - - // Set up the validation. - app.OnValidate(ctx => - { - Args.MigrationCommand = _commandArg.ParsedValue; - if (!SupportedCommands.HasFlag(Args.MigrationCommand)) - return new ValidationResult($"The specified database migration command is not supported."); + Args.MigrationCommand = _commandArg.ParsedValue; + if (!SupportedCommands.HasFlag(Args.MigrationCommand)) + return new ValidationResult($"The specified database migration command is not supported."); - // Update the options from command line. - var so = GetCommandOption(nameof(MigrationArgsBase.SchemaOrder)); - if (so.HasValue()) - { - Args.SchemaOrder.Clear(); - Args.SchemaOrder.AddRange(so.Values.Where(x => !string.IsNullOrEmpty(x)).OfType().Distinct()); - } + // Update the options from command line. + var so = GetCommandOption(nameof(MigrationArgsBase.SchemaOrder)); + if (so.HasValue()) + { + Args.SchemaOrder.Clear(); + Args.SchemaOrder.AddRange(so.Values.Where(x => !string.IsNullOrEmpty(x)).OfType().Distinct()); + } - UpdateStringOption(nameof(MigrationArgsBase.OutputDirectory), v => Args.OutputDirectory = new DirectoryInfo(v)); + UpdateStringOption(nameof(MigrationArgsBase.OutputDirectory), v => Args.OutputDirectory = new DirectoryInfo(v)); - var vr = ValidateMultipleValue(nameof(MigrationArgsBase.Assemblies), ctx, (ctx, co) => new AssemblyValidator(Args).GetValidationResult(co, ctx)); - if (vr != ValidationResult.Success) - return vr; + var vr = ValidateMultipleValue(nameof(MigrationArgsBase.Assemblies), ctx, (ctx, co) => new AssemblyValidator(Args).GetValidationResult(co, ctx)); + if (vr != ValidationResult.Success) + return vr; - UpdateBooleanOption(EntryAssemblyOnlyOptionName, () => - { - Args.ClearAssemblies(); - Args.AddAssembly(Assembly.GetEntryAssembly()!); - }); + UpdateBooleanOption(EntryAssemblyOnlyOptionName, () => + { + Args.ClearAssemblies(); + Args.AddAssembly(Assembly.GetEntryAssembly()!); + }); - vr = ValidateMultipleValue(nameof(MigrationArgsBase.Parameters), ctx, (ctx, co) => new ParametersValidator(Args).GetValidationResult(co, ctx)); - if (vr != ValidationResult.Success) - return vr; + vr = ValidateMultipleValue(nameof(MigrationArgsBase.Parameters), ctx, (ctx, co) => new ParametersValidator(Args).GetValidationResult(co, ctx)); + if (vr != ValidationResult.Success) + return vr; - if (_additionalArgs.Values.Count > 0 && !(Args.MigrationCommand.HasFlag(MigrationCommand.CodeGen) || Args.MigrationCommand.HasFlag(MigrationCommand.Script) || Args.MigrationCommand.HasFlag(MigrationCommand.Execute))) - return new ValidationResult($"Additional arguments can only be specified when the command is '{nameof(MigrationCommand.CodeGen)}', '{nameof(MigrationCommand.Script)}' or '{nameof(MigrationCommand.Execute)}'.", memberNames); + if (_additionalArgs.Values.Count > 0 && !(Args.MigrationCommand.HasFlag(MigrationCommand.CodeGen) || Args.MigrationCommand.HasFlag(MigrationCommand.Script) || Args.MigrationCommand.HasFlag(MigrationCommand.Execute))) + return new ValidationResult($"Additional arguments can only be specified when the command is '{nameof(MigrationCommand.CodeGen)}', '{nameof(MigrationCommand.Script)}' or '{nameof(MigrationCommand.Execute)}'.", memberNames); - if (Args.MigrationCommand.HasFlag(MigrationCommand.CodeGen) || Args.MigrationCommand.HasFlag(MigrationCommand.Script)) + if (Args.MigrationCommand.HasFlag(MigrationCommand.CodeGen) || Args.MigrationCommand.HasFlag(MigrationCommand.Script)) + { + for (int i = 0; i < _additionalArgs.Values.Count; i++) { - for (int i = 0; i < _additionalArgs.Values.Count; i++) - { - Args.Parameters.Add($"Param{i}", _additionalArgs.Values[i]); - } + Args.Parameters.Add($"Param{i}", _additionalArgs.Values[i]); } + } - if (Args.MigrationCommand.HasFlag(MigrationCommand.Execute)) + if (Args.MigrationCommand.HasFlag(MigrationCommand.Execute)) + { + for (int i = 0; i < _additionalArgs.Values.Count; i++) { - for (int i = 0; i < _additionalArgs.Values.Count; i++) - { - if (string.IsNullOrEmpty(_additionalArgs.Values[i])) - continue; - - Args.ExecuteStatements ??= []; - Args.ExecuteStatements.Add(_additionalArgs.Values[i]!); - } + if (string.IsNullOrEmpty(_additionalArgs.Values[i])) + continue; + + Args.ExecuteStatements ??= []; + Args.ExecuteStatements.Add(_additionalArgs.Values[i]!); } + } - // Handle the connection string, in order of precedence: command-line argument, environment variable, what was passed as initial argument. - var cs = GetCommandOption(nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionString)); - var evn = GetCommandOption(nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionStringEnvironmentVariableName))?.Value(); - if (!string.IsNullOrEmpty(evn)) - Args.ConnectionStringEnvironmentVariableName = evn; + // Handle the connection string, in order of precedence: command-line argument, environment variable, what was passed as initial argument. + var cs = GetCommandOption(nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionString)); + var evn = GetCommandOption(nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionStringEnvironmentVariableName))?.Value(); + if (!string.IsNullOrEmpty(evn)) + Args.ConnectionStringEnvironmentVariableName = evn; - Args.OverrideConnectionString(cs?.Value()); + Args.OverrideConnectionString(cs?.Value()); - // Invoke any additional. - var res = OnValidation(ctx)!; + // Invoke any additional. + var res = OnValidation(ctx)!; - // Action any command input. - var nco = GetCommandOption(AcceptPromptsOptionName); - if (nco == null || !nco.HasValue()) + // Action any command input. + var nco = GetCommandOption(AcceptPromptsOptionName); + if (nco == null || !nco.HasValue()) + { + if (Args.MigrationCommand.HasFlag(MigrationCommand.Drop)) { - if (Args.MigrationCommand.HasFlag(MigrationCommand.Drop)) - { - if (!Args.AcceptPrompts && !Prompt.GetYesNo("DROP: Confirm that where the specified database already exists it should be dropped?", false, ConsoleColor.Yellow)) - return new ValidationResult("Database drop was not confirmed; no execution occurred."); - } - else if (Args.MigrationCommand.HasFlag(MigrationCommand.Reset)) - { - if (!Args.AcceptPrompts && !Prompt.GetYesNo("RESET: Confirm that the existing data within the database should be reset (deleted)?", false, ConsoleColor.Yellow)) - return new ValidationResult("Data reset was not confirmed; no execution occurred."); - } + if (!Args.AcceptPrompts && !Prompt.GetYesNo("DROP: Confirm that where the specified database already exists it should be dropped?", false, ConsoleColor.Yellow)) + return new ValidationResult("Database drop was not confirmed; no execution occurred."); } + else if (Args.MigrationCommand.HasFlag(MigrationCommand.Reset)) + { + if (!Args.AcceptPrompts && !Prompt.GetYesNo("RESET: Confirm that the existing data within the database should be reset (deleted)?", false, ConsoleColor.Yellow)) + return new ValidationResult("Data reset was not confirmed; no execution occurred."); + } + } - // Handle drop schema objects. - var dso = GetCommandOption(DropSchemaObjectsName); - if (dso is not null && dso.HasValue()) - Args.DropSchemaObjects = true; + // Handle drop schema objects. + var dso = GetCommandOption(DropSchemaObjectsName); + if (dso is not null && dso.HasValue()) + Args.DropSchemaObjects = true; - return res; - }); + return res; + }); - // Set up the code generation execution. - app.OnExecuteAsync(RunRunawayAsync); + // Set up the code generation execution. + app.OnExecuteAsync(RunRunawayAsync); - // Execute the command-line app. - try - { - var result = await app.ExecuteAsync(args, cancellationToken).ConfigureAwait(false); + // Execute the command-line app. + try + { + var result = await app.ExecuteAsync(args, cancellationToken).ConfigureAwait(false); - if (result == 0 && _helpOption.HasValue()) - OnWriteHelp(); + if (result == 0 && _helpOption.HasValue()) + OnWriteHelp(); - return result; - } - catch (CommandParsingException cpex) - { - Args.Logger?.LogError("{Content}", cpex.Message); - Args.Logger?.LogError("{Content}", string.Empty); - return 1; - } + return result; } - - /// - /// Gets the selected for the specfied . - /// - /// The option name. - /// The corresponding . - protected CommandOption GetCommandOption(string option) => ConsoleOptions.GetValueOrDefault(option) ?? throw new InvalidOperationException($"Command option '{option}' does not exist."); - - /// - /// Updates the command option from a string option. - /// - /// The option name. - /// The action to perform where is provided. - protected void UpdateStringOption(string option, Action action) + catch (CommandParsingException cpex) { - var co = GetCommandOption(option); - if (co != null && co.HasValue()) - { - var val = co.Value(); - if (!string.IsNullOrEmpty(val)) - action.Invoke(val); - } + Args.Logger?.LogError("{Content}", cpex.Message); + Args.Logger?.LogError("{Content}", string.Empty); + return 1; } + } - /// - /// Updates the command option from a boolean option. - /// - /// The option name. - /// The action to perform where is provided. - protected void UpdateBooleanOption(string option, Action action) - { - var co = GetCommandOption(option); - if (co != null && co.HasValue()) - action.Invoke(); - } + /// + /// Gets the selected for the specfied . + /// + /// The option name. + /// The corresponding . + protected CommandOption GetCommandOption(string option) => ConsoleOptions.GetValueOrDefault(option) ?? throw new InvalidOperationException($"Command option '{option}' does not exist."); - /// - /// Validate multiple options. - /// - /// The option name. - /// The . - /// The function to perform where is provided. - protected ValidationResult ValidateMultipleValue(string option, ValidationContext ctx, Func func) + /// + /// Updates the command option from a string option. + /// + /// The option name. + /// The action to perform where is provided. + protected void UpdateStringOption(string option, Action action) + { + var co = GetCommandOption(option); + if (co != null && co.HasValue()) { - var co = GetCommandOption(option); - if (co == null) - return ValidationResult.Success!; - else - return func(ctx, co); + var val = co.Value(); + if (!string.IsNullOrEmpty(val)) + action.Invoke(val); } + } - /// - /// Invoked before the underlying console execution occurs. - /// - /// The underlying . - /// This enables additional configuration to the prior to execution. For example, adding additional command line arguments. - protected virtual void OnBeforeExecute(CommandLineApplication app) { } - - /// - /// Invoked after command parsing is complete and before the underlying code-generation. - /// - /// The . - /// The . - protected virtual ValidationResult? OnValidation(ValidationContext context) => ValidationResult.Success; - - /// - /// Performs the actual code-generation. - /// - private async Task RunRunawayAsync(CancellationToken cancellationToken) /* Method name inspired by: Slade - Run Runaway - https://www.youtube.com/watch?v=gMxcGaAwy-Q */ - { - try - { - // Create the migrator. - using var migrator = CreateMigrator(); + /// + /// Updates the command option from a boolean option. + /// + /// The option name. + /// The action to perform where is provided. + protected void UpdateBooleanOption(string option, Action action) + { + var co = GetCommandOption(option); + if (co != null && co.HasValue()) + action.Invoke(); + } - // Write header, etc. - if (!BypassOnWrites) - { - OnWriteMasthead(); - OnWriteHeader(); - OnWriteArgs(migrator); - } + /// + /// Validate multiple options. + /// + /// The option name. + /// The . + /// The function to perform where is provided. + protected ValidationResult ValidateMultipleValue(string option, ValidationContext ctx, Func func) + { + var co = GetCommandOption(option); + if (co == null) + return ValidationResult.Success!; + else + return func(ctx, co); + } - // Run the code generator. - var sw = Stopwatch.StartNew(); - if (!await OnMigrateAsync(migrator, cancellationToken).ConfigureAwait(false)) - return 3; + /// + /// Invoked before the underlying console execution occurs. + /// + /// The underlying . + /// This enables additional configuration to the prior to execution. For example, adding additional command line arguments. + protected virtual void OnBeforeExecute(CommandLineApplication app) { } - // Write footer and exit successfully. - sw.Stop(); - if (!BypassOnWrites) - OnWriteFooter(sw.Elapsed.TotalMilliseconds); + /// + /// Invoked after command parsing is complete and before the underlying code-generation. + /// + /// The . + /// The . + protected virtual ValidationResult? OnValidation(ValidationContext context) => ValidationResult.Success; - return 0; - } - catch (CodeGenException gcex) + /// + /// Performs the actual code-generation. + /// + private async Task RunRunawayAsync(CancellationToken cancellationToken) /* Method name inspired by: Slade - Run Runaway - https://www.youtube.com/watch?v=gMxcGaAwy-Q */ + { + try + { + // Create the migrator. + using var migrator = CreateMigrator(); + + // Write header, etc. + if (!BypassOnWrites) { - if (gcex.Message != null) - { - Args.Logger?.LogError("{Content}", gcex.Message); - if (gcex.InnerException != null) - Args.Logger?.LogError("{Content}", gcex.InnerException.Message); + OnWriteMasthead(); + OnWriteHeader(); + OnWriteArgs(migrator); + } - Args.Logger?.LogError("{Content}", string.Empty); - } + // Run the code generator. + var sw = Stopwatch.StartNew(); + if (!await OnMigrateAsync(migrator, cancellationToken).ConfigureAwait(false)) + return 3; - return 2; - } - } + // Write footer and exit successfully. + sw.Stop(); + if (!BypassOnWrites) + OnWriteFooter(sw.Elapsed.TotalMilliseconds); - /// - /// Invoked to execute the . - /// - /// The . - /// The . - /// true indicates success; otherwise, false. - protected virtual async Task OnMigrateAsync(DatabaseMigrationBase migrator, CancellationToken cancellationToken) + return 0; + } + catch (CodeGenException gcex) { - // Perform migration. - if (!await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false)) - return false; + if (gcex.Message != null) + { + Args.Logger?.LogError("{Content}", gcex.Message); + if (gcex.InnerException != null) + Args.Logger?.LogError("{Content}", gcex.InnerException.Message); - Logger?.LogInformation("{Content}", string.Empty); - Logger?.LogInformation("{Content}", new string('-', 80)); + Args.Logger?.LogError("{Content}", string.Empty); + } - return true; + return 2; } + } - /// - /// Creates the that is used to perform the database migration orchestration. - /// - /// The . - protected abstract DatabaseMigrationBase CreateMigrator(); + /// + /// Invoked to execute the . + /// + /// The . + /// The . + /// true indicates success; otherwise, false. + protected virtual async Task OnMigrateAsync(DatabaseMigrationBase migrator, CancellationToken cancellationToken) + { + // Perform migration. + if (!await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false)) + return false; - /// - /// Invoked to write the to the . - /// - protected virtual void OnWriteMasthead() - { - if (MastheadText != null) - Logger?.LogInformation("{Content}", MastheadText); - } + Logger?.LogInformation("{Content}", string.Empty); + Logger?.LogInformation("{Content}", new string('-', 80)); - /// - /// Invoked to write the header information to the . - /// - /// Writes the . - protected virtual void OnWriteHeader() - { - Logger?.LogInformation("{Content}", AppTitle); - Logger?.LogInformation("{Content}", string.Empty); - } + return true; + } - /// - /// Invoked to write the to the . - /// - /// The to write. - protected virtual void OnWriteArgs(DatabaseMigrationBase migrator) => WriteStandardizedArgs(migrator); - - /// - /// Write the context to the in a standardized (reusable) manner. - /// - /// The to write. - /// Provides an optional opportunity to log/write additional information. - public static void WriteStandardizedArgs(DatabaseMigrationBase migrator, Action? additional = null) - { - if (migrator.Args.Logger == null) - return; + /// + /// Creates the that is used to perform the database migration orchestration. + /// + /// The . + protected abstract DatabaseMigrationBase CreateMigrator(); - migrator.Args.Logger.LogInformation("{Content}", $"Command = {migrator.Args.MigrationCommand}"); - migrator.Args.Logger.LogInformation("{Content}", $"Provider = {migrator.Provider}"); - migrator.Args.Logger.LogInformation("{Content}", $"SchemaOrder = {string.Join(", ", [.. migrator.Args.SchemaOrder])}"); - migrator.Args.Logger.LogInformation("{Content}", $"OutDir = {migrator.Args.OutputDirectory?.FullName}"); + /// + /// Invoked to write the to the . + /// + protected virtual void OnWriteMasthead() + { + if (MastheadText != null) + Logger?.LogInformation("{Content}", MastheadText); + } - additional?.Invoke(migrator.Args.Logger); + /// + /// Invoked to write the header information to the . + /// + /// Writes the . + protected virtual void OnWriteHeader() + { + Logger?.LogInformation("{Content}", AppTitle); + Logger?.LogInformation("{Content}", string.Empty); + } - migrator.Args.Logger.LogInformation("{Content}", $"Parameters{(migrator.Args.Parameters.Count == 0 ? " = none" : ":")}"); - foreach (var p in migrator.Args.Parameters.OrderBy(x => x.Key)) - { - migrator.Args.Logger.LogInformation("{Content}", $" {p.Key} = {p.Value}"); - } + /// + /// Invoked to write the to the . + /// + /// The to write. + protected virtual void OnWriteArgs(DatabaseMigrationBase migrator) => WriteStandardizedArgs(migrator); - migrator.Args.Logger.LogInformation("{Content}", $"Assemblies{(!migrator.Args.Assemblies.Any() ? " = none" : ":")}"); - foreach (var a in migrator.Args.Assemblies) - { - migrator.Args.Logger.LogInformation("{Content}", $" {a.Assembly.FullName}"); - } + /// + /// Write the context to the in a standardized (reusable) manner. + /// + /// The to write. + /// Provides an optional opportunity to log/write additional information. + public static void WriteStandardizedArgs(DatabaseMigrationBase migrator, Action? additional = null) + { + if (migrator.Args.Logger == null) + return; + + migrator.Args.Logger.LogInformation("{Content}", $"Command = {migrator.Args.MigrationCommand}"); + migrator.Args.Logger.LogInformation("{Content}", $"Provider = {migrator.Provider}"); + migrator.Args.Logger.LogInformation("{Content}", $"SchemaOrder = {string.Join(", ", [.. migrator.Args.SchemaOrder])}"); + migrator.Args.Logger.LogInformation("{Content}", $"OutDir = {migrator.Args.OutputDirectory?.FullName}"); + + additional?.Invoke(migrator.Args.Logger); + + migrator.Args.Logger.LogInformation("{Content}", $"Parameters{(migrator.Args.Parameters.Count == 0 ? " = none" : ":")}"); + foreach (var p in migrator.Args.Parameters.OrderBy(x => x.Key)) + { + migrator.Args.Logger.LogInformation("{Content}", $" {p.Key} = {p.Value}"); } - /// - /// Invoked to write the footer information to the . - /// - /// The elapsed execution time in milliseconds. - protected virtual void OnWriteFooter(double totalMilliseconds) + migrator.Args.Logger.LogInformation("{Content}", $"Assemblies{(!migrator.Args.Assemblies.Any() ? " = none" : ":")}"); + foreach (var a in migrator.Args.Assemblies) { - Logger?.LogInformation("{Content}", string.Empty); - Logger?.LogInformation("{Content}", $"{AppName} Complete. [{totalMilliseconds}ms]"); - Logger?.LogInformation("{Content}", string.Empty); + migrator.Args.Logger.LogInformation("{Content}", $" {a.Assembly.FullName}"); } + } - /// - /// Invoked to write additional help information to the . - /// - protected virtual void OnWriteHelp() { } + /// + /// Invoked to write the footer information to the . + /// + /// The elapsed execution time in milliseconds. + protected virtual void OnWriteFooter(double totalMilliseconds) + { + Logger?.LogInformation("{Content}", string.Empty); + Logger?.LogInformation("{Content}", $"{AppName} Complete. [{totalMilliseconds}ms]"); + Logger?.LogInformation("{Content}", string.Empty); } + + /// + /// Invoked to write additional help information to the . + /// + protected virtual void OnWriteHelp() { } } \ No newline at end of file diff --git a/src/DbEx/Console/MigrationConsoleBaseT.cs b/src/DbEx/Console/MigrationConsoleBaseT.cs index 920a5ba..6af0eb2 100644 --- a/src/DbEx/Console/MigrationConsoleBaseT.cs +++ b/src/DbEx/Console/MigrationConsoleBaseT.cs @@ -1,125 +1,116 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Console; -using DbEx.Migration; -using System; -using System.Collections.Generic; -using System.IO; -using System.Reflection; - -namespace DbEx.Console +/// +/// Base console that facilitates the by managing the standard console command-line arguments/options. +/// +/// The itself being implemented. +/// The default that will be overridden/updated by the command-line argument values. +public abstract class MigrationConsoleBase(MigrationArgsBase args) : MigrationConsoleBase(args) where TSelf : MigrationConsoleBase { /// - /// Base console that facilitates the by managing the standard console command-line arguments/options. + /// Enables fluent-style method-chaining configuration of /// - /// The itself being implemented. - /// The default that will be overridden/updated by the command-line argument values. - public abstract class MigrationConsoleBase(MigrationArgsBase args) : MigrationConsoleBase(args) where TSelf : MigrationConsoleBase + /// The action to invoke to access the . + /// The current instance to support fluent-style method-chaining. + public TSelf Configure(Action action) { - /// - /// Enables fluent-style method-chaining configuration of - /// - /// The action to invoke to access the . - /// The current instance to support fluent-style method-chaining. - public TSelf Configure(Action action) - { - action?.Invoke((TSelf)this); - return (TSelf)this; - } + action?.Invoke((TSelf)this); + return (TSelf)this; + } - /// - /// Adds the containing the embedded resources (shortcut to the .) - /// - /// The assemblies containing the embedded resources. - /// The current instance to supported fluent-style method-chaining. - public TSelf Assemblies(params Assembly[] assemblies) - { - Args.AddAssembly(assemblies); - return (TSelf)this; - } + /// + /// Adds the containing the embedded resources (shortcut to the .) + /// + /// The assemblies containing the embedded resources. + /// The current instance to supported fluent-style method-chaining. + public TSelf Assemblies(params Assembly[] assemblies) + { + Args.AddAssembly(assemblies); + return (TSelf)this; + } - /// - /// Adds the containing the embedded resources (shortcut to the .) - /// - /// The types to add (infers underlying ). - /// The current instance to supported fluent-style method-chaining. - public TSelf Assemblies(params Type[] types) + /// + /// Adds the containing the embedded resources (shortcut to the .) + /// + /// The types to add (infers underlying ). + /// The current instance to supported fluent-style method-chaining. + public TSelf Assemblies(params Type[] types) + { + var list = new List(); + foreach (var t in types) { - var list = new List(); - foreach (var t in types) - { - list.Add(t.Assembly); - } - - Args.AddAssembly([.. list]); - return (TSelf)this; + list.Add(t.Assembly); } - /// - /// Adds the containing the embedded resources (shortcut to the . - /// - /// The to infer . - /// The current instance to supported fluent-style method-chaining. - public TSelf Assembly() - { - Assemblies(typeof(T)); - return (TSelf)this; - } + Args.AddAssembly([.. list]); + return (TSelf)this; + } - /// - /// Adds the and containing the embedded resources (shortcut to the . - /// - /// The to infer . - /// The to infer . - /// The current instance to supported fluent-style method-chaining. - public TSelf Assembly() - { - Assemblies(typeof(T1), typeof(T2)); - return (TSelf)this; - } + /// + /// Adds the containing the embedded resources (shortcut to the . + /// + /// The to infer . + /// The current instance to supported fluent-style method-chaining. + public TSelf Assembly() + { + Assemblies(typeof(T)); + return (TSelf)this; + } - /// - /// Adds the , and containing the embedded resources (shortcut to the . - /// - /// The to infer . - /// The to infer . - /// The to infer . - /// The current instance to supported fluent-style method-chaining. - public TSelf Assembly() - { - Assemblies(typeof(T1), typeof(T2), typeof(T3)); - return (TSelf)this; - } + /// + /// Adds the and containing the embedded resources (shortcut to the . + /// + /// The to infer . + /// The to infer . + /// The current instance to supported fluent-style method-chaining. + public TSelf Assembly() + { + Assemblies(typeof(T1), typeof(T2)); + return (TSelf)this; + } - /// - /// Sets (overrides) the output where the generated artefacts are to be written. - /// - /// The output . - /// The current instance to supported fluent-style method-chaining. - public TSelf OutputDirectory(string path) - { - Args.OutputDirectory = new DirectoryInfo(path.ThrowIfNull(nameof(path))); - return (TSelf)this; - } + /// + /// Adds the , and containing the embedded resources (shortcut to the . + /// + /// The to infer . + /// The to infer . + /// The to infer . + /// The current instance to supported fluent-style method-chaining. + public TSelf Assembly() + { + Assemblies(typeof(T1), typeof(T2), typeof(T3)); + return (TSelf)this; + } - /// - /// Sets (overrides) the . - /// - /// The supported (s) - /// The current instance to supported fluent-style method-chaining. - public TSelf Supports(MigrationCommand supportedCommands) - { - SupportedCommands = supportedCommands; - return (TSelf)this; - } + /// + /// Sets (overrides) the output where the generated artefacts are to be written. + /// + /// The output . + /// The current instance to supported fluent-style method-chaining. + public TSelf OutputDirectory(string path) + { + Args.OutputDirectory = new DirectoryInfo(path.ThrowIfNull(nameof(path))); + return (TSelf)this; + } - /// - /// Indicates whether to automatically accept any confirmation prompts (command-line execution only). - /// - /// The current instance to supported fluent-style method-chaining. - public TSelf AcceptsPrompts() - { - Args.AcceptPrompts = true; - return (TSelf)this; - } + /// + /// Sets (overrides) the . + /// + /// The supported (s) + /// The current instance to supported fluent-style method-chaining. + public TSelf Supports(MigrationCommand supportedCommands) + { + SupportedCommands = supportedCommands; + return (TSelf)this; + } + + /// + /// Indicates whether to automatically accept any confirmation prompts (command-line execution only). + /// + /// The current instance to supported fluent-style method-chaining. + public TSelf AcceptsPrompts() + { + Args.AcceptPrompts = true; + return (TSelf)this; } } \ No newline at end of file diff --git a/src/DbEx/Console/ParametersValidator.cs b/src/DbEx/Console/ParametersValidator.cs index ec0e7dc..6822a8e 100644 --- a/src/DbEx/Console/ParametersValidator.cs +++ b/src/DbEx/Console/ParametersValidator.cs @@ -1,52 +1,42 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Console; -using DbEx.Migration; -using McMaster.Extensions.CommandLineUtils; -using McMaster.Extensions.CommandLineUtils.Validation; -using System; -using System.ComponentModel.DataAnnotations; -using System.Linq; - -namespace DbEx.Console +/// +/// Validate the Params to ensure format is correct and values are not duplicated. +/// +/// The to update. +public class ParametersValidator(MigrationArgsBase args) : IOptionValidator { + private readonly MigrationArgsBase _args = args.ThrowIfNull(nameof(args)); + /// - /// Validate the Params to ensure format is correct and values are not duplicated. + /// Performs the validation. /// - /// The to update. - public class ParametersValidator(MigrationArgsBase args) : IOptionValidator + /// The . + /// The . + /// The . + public ValidationResult GetValidationResult(CommandOption option, ValidationContext context) { - private readonly MigrationArgsBase _args = args.ThrowIfNull(nameof(args)); + option.ThrowIfNull(nameof(option)); + context.ThrowIfNull(nameof(context)); - /// - /// Performs the validation. - /// - /// The . - /// The . - /// The . - public ValidationResult GetValidationResult(CommandOption option, ValidationContext context) + foreach (var p in option.Values.Where(x => !string.IsNullOrEmpty(x))) { - option.ThrowIfNull(nameof(option)); - context.ThrowIfNull(nameof(context)); - - foreach (var p in option.Values.Where(x => !string.IsNullOrEmpty(x))) - { - var pos = p!.IndexOf('=', StringComparison.Ordinal); - if (pos <= 0) - AddParameter(p, null); - else - AddParameter(p[..pos], string.IsNullOrEmpty(p[(pos + 1)..]) ? null : p[(pos + 1)..]); - } - - return ValidationResult.Success!; + var pos = p!.IndexOf('=', StringComparison.Ordinal); + if (pos <= 0) + AddParameter(p, null); + else + AddParameter(p[..pos], string.IsNullOrEmpty(p[(pos + 1)..]) ? null : p[(pos + 1)..]); } - /// - /// Adds or overriddes the parameter. - /// - private void AddParameter(string key, string? value) - { - if (!_args.Parameters.TryAdd(key, value)) - _args.Parameters[key] = value; - } + return ValidationResult.Success!; + } + + /// + /// Adds or overriddes the parameter. + /// + private void AddParameter(string key, string? value) + { + if (!_args.Parameters.TryAdd(key, value)) + _args.Parameters[key] = value; } } \ No newline at end of file diff --git a/src/DbEx/DatabaseExtensions.cs b/src/DbEx/DatabaseExtensions.cs index db3a18e..a218d01 100644 --- a/src/DbEx/DatabaseExtensions.cs +++ b/src/DbEx/DatabaseExtensions.cs @@ -1,102 +1,89 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx; -using DbEx.Migration; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("DbEx.SqlServer, PublicKey=00240000048000009400000006020000002400005253413100040000010001007dee530af6d801902d40685e9cd0a3d8991ddbf545be3ef6147c9f79bacd7464d92fbd94fee34e885c37e3dff4ea15a4f9978f1f614798e0f48e3a3d5bf15e8b2fba9c19b6966838f97444bc247bc101454946d70ac93207cf2c611956aed59c316f81f1bf8c8486f8f0b3f9adf93c2f07e06a86745f6dc4b819c2bc2f3fdad5")] -[assembly: InternalsVisibleTo("DbEx.Postgres, PublicKey=00240000048000009400000006020000002400005253413100040000010001007dee530af6d801902d40685e9cd0a3d8991ddbf545be3ef6147c9f79bacd7464d92fbd94fee34e885c37e3dff4ea15a4f9978f1f614798e0f48e3a3d5bf15e8b2fba9c19b6966838f97444bc247bc101454946d70ac93207cf2c611956aed59c316f81f1bf8c8486f8f0b3f9adf93c2f07e06a86745f6dc4b819c2bc2f3fdad5")] -[assembly: InternalsVisibleTo("DbEx.MySql, PublicKey=00240000048000009400000006020000002400005253413100040000010001007dee530af6d801902d40685e9cd0a3d8991ddbf545be3ef6147c9f79bacd7464d92fbd94fee34e885c37e3dff4ea15a4f9978f1f614798e0f48e3a3d5bf15e8b2fba9c19b6966838f97444bc247bc101454946d70ac93207cf2c611956aed59c316f81f1bf8c8486f8f0b3f9adf93c2f07e06a86745f6dc4b819c2bc2f3fdad5")] - -namespace DbEx +/// +/// extensions. +/// +public static class DatabaseExtensions { + private static readonly char[] _snakeCamelCaseSeparatorChars = ['_', '-']; + +#if NET6_0_OR_GREATER /// - /// extensions. + /// Throws an if the is null. /// - public static class DatabaseExtensions + /// The . + /// The value to validate as non-null. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + internal static T ThrowIfNull([NotNull] this T? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) { - private static readonly char[] _snakeCamelCaseSeparatorChars = ['_', '-']; - -#if NET6_0_OR_GREATER - /// - /// Throws an if the is null. - /// - /// The . - /// The value to validate as non-null. - /// The name of the parameter with which the corresponds. - /// The to support fluent-style method-chaining. - internal static T ThrowIfNull([NotNull] this T? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) - { - ArgumentNullException.ThrowIfNull(value, paramName); - return value; - } + ArgumentNullException.ThrowIfNull(value, paramName); + return value; + } #else - /// - /// Throws an if the is null. - /// - /// The . - /// The value to validate as non-null. - /// The name of the parameter with which the corresponds. - /// The to support fluent-style method-chaining. - internal static T ThrowIfNull([NotNull] this T? value, string? paramName = "value") - { - if (value is null) - throw new ArgumentNullException(paramName); - - return value; - } + /// + /// Throws an if the is null. + /// + /// The . + /// The value to validate as non-null. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + internal static T ThrowIfNull([NotNull] this T? value, string? paramName = "value") + { + if (value is null) + throw new ArgumentNullException(paramName); + + return value; + } #endif #if NET7_0_OR_GREATER - /// - /// Throws an if the is null or . - /// - /// The value to validate as non-null. - /// The name of the parameter with which the corresponds. - /// The to support fluent-style method-chaining. - internal static string ThrowIfNullOrEmpty([NotNull] this string? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) - { - ArgumentException.ThrowIfNullOrEmpty(value, paramName); - return value; - } + /// + /// Throws an if the is null or . + /// + /// The value to validate as non-null. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + internal static string ThrowIfNullOrEmpty([NotNull] this string? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) + { + ArgumentException.ThrowIfNullOrEmpty(value, paramName); + return value; + } #else - /// - /// Throws an if the is null or . - /// - /// The value to validate as non-null. - /// The name of the parameter with which the corresponds. - /// The to support fluent-style method-chaining. - internal static string ThrowIfNullOrEmpty([NotNull] this string? value, string? paramName = "value") - { - if (string.IsNullOrEmpty(value)) - throw new ArgumentNullException(paramName); + /// + /// Throws an if the is null or . + /// + /// The value to validate as non-null. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + internal static string ThrowIfNullOrEmpty([NotNull] this string? value, string? paramName = "value") + { + if (string.IsNullOrEmpty(value)) + throw new ArgumentNullException(paramName); - return value; - } + return value; + } #endif - /// - /// Performs the specified on each element in the sequence. - /// - /// The item . - /// The sequence to iterate. - /// The action to perform on each element. - /// The sequence. - internal static IEnumerable ForEach(this IEnumerable sequence, Action action) - { - if (sequence == null) - return sequence!; - - action.ThrowIfNull(nameof(action)); + /// + /// Performs the specified on each element in the sequence. + /// + /// The item . + /// The sequence to iterate. + /// The action to perform on each element. + /// The sequence. + internal static IEnumerable ForEach(this IEnumerable sequence, Action action) + { + if (sequence == null) + return sequence!; - foreach (TItem element in sequence.ThrowIfNull(nameof(sequence))) - { - action(element); - } + action.ThrowIfNull(nameof(action)); - return sequence; + foreach (TItem element in sequence.ThrowIfNull(nameof(sequence))) + { + action(element); } + + return sequence; } } \ No newline at end of file diff --git a/src/DbEx/DatabaseSchemaConfig.cs b/src/DbEx/DatabaseSchemaConfig.cs index f8a1381..7327e4f 100644 --- a/src/DbEx/DatabaseSchemaConfig.cs +++ b/src/DbEx/DatabaseSchemaConfig.cs @@ -1,201 +1,189 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx - -using DbEx.DbSchema; -using DbEx.Migration; -using DbEx.Migration.Data; -using System; -using System.Collections.Generic; -using System.Runtime.Serialization; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx +namespace DbEx; + +/// +/// Enables database provider specific configuration and capabilities. +/// +/// The owning . +/// Indicates whether the database supports per-database schema-based separation. +/// The default schema name used where not explicitly specified. +public abstract class DatabaseSchemaConfig(DatabaseMigrationBase migration, bool supportsSchema = false, string? defaultSchema = null) { + private readonly string? _defaultSchema = defaultSchema; + + /// + /// Gets the owning . + /// + public DatabaseMigrationBase Migration { get; } = migration.ThrowIfNull(nameof(migration)); + + /// + /// Indicates whether the database supports per-database schema-based separation. + /// + public bool SupportsSchema { get; } = supportsSchema; + + /// + /// Gets the default schema name used where not explicitly specified. + /// + /// Will throw an appropriate exception where accessed incorrectly. + public string DefaultSchema => SupportsSchema + ? (_defaultSchema ?? throw new InvalidOperationException("The database supports per-database schema-based separation and a default is required.")) + : throw new NotSupportedException("The database does not support per-database schema-based separation."); + + /// + /// Gets the suffix of the identifier column. + /// + /// Where matching reference data columns and the specified column is not found, then the suffix will be appended to the specified column name and an additional match will be performed. + public abstract string IdColumnNameSuffix { get; } + + /// + /// Gets the suffix of the code column. + /// + /// Where matching reference data columns and the specified column is not found, then the suffix will be appended to the specified column name and an additional match will be performed. + public abstract string CodeColumnNameSuffix { get; } + + /// + /// Gets the suffix of the JSON column. + /// + public abstract string JsonColumnNameSuffix { get; } + + /// + /// Gets the name of the CreatedOn audit column (where it exists). + /// + public abstract string CreatedOnColumnName { get; } + + /// + /// Gets the name of the CreatedBy audit column (where it exists). + /// + public abstract string CreatedByColumnName { get; } + + /// + /// Gets the name of the UpdatedOn audit column (where it exists). + /// + public abstract string UpdatedOnColumnName { get; } + + /// + /// Gets the name of the UpdatedBy audit column (where it exists). + /// + public abstract string UpdatedByColumnName { get; } + + /// + /// Gets the name of the TenantId column (where it exists). + /// + public abstract string TenantIdColumnName { get; } + + /// + /// Gets the name of the row-version (ETag) column (where it exists). + /// + public abstract string RowVersionColumnName { get; } + + /// + /// Gets the name of the logically IsDeleted column (where it exists). + /// + public abstract string IsDeletedColumnName { get; } + + /// + /// Gets the name of the reference-data code column (where it exists); + /// + public abstract string RefDataCodeColumnName { get; } + + /// + /// Gets the name of the reference-data text column (where it exists); + /// + public abstract string RefDataTextColumnName { get; } + /// - /// Enables database provider specific configuration and capabilities. + /// Prepares the as the final opportunity to finalize any standard defaults. /// - /// The owning . - /// Indicates whether the database supports per-database schema-based separation. - /// The default schema name used where not explicitly specified. - public abstract class DatabaseSchemaConfig(DatabaseMigrationBase migration, bool supportsSchema = false, string? defaultSchema = null) + /// Where overriding this base method should be invoked first to perform the standardized preparation. + public virtual void PrepareMigrationArgs() { - private readonly string? _defaultSchema = defaultSchema; - - /// - /// Gets the owning . - /// - public DatabaseMigrationBase Migration { get; } = migration.ThrowIfNull(nameof(migration)); - - /// - /// Indicates whether the database supports per-database schema-based separation. - /// - public bool SupportsSchema { get; } = supportsSchema; - - /// - /// Gets the default schema name used where not explicitly specified. - /// - /// Will throw an appropriate exception where accessed incorrectly. - public string DefaultSchema => SupportsSchema - ? (_defaultSchema ?? throw new InvalidOperationException("The database supports per-database schema-based separation and a default is required.")) - : throw new NotSupportedException("The database does not support per-database schema-based separation."); - - /// - /// Gets the suffix of the identifier column. - /// - /// Where matching reference data columns and the specified column is not found, then the suffix will be appended to the specified column name and an additional match will be performed. - public abstract string IdColumnNameSuffix { get; } - - /// - /// Gets the suffix of the code column. - /// - /// Where matching reference data columns and the specified column is not found, then the suffix will be appended to the specified column name and an additional match will be performed. - public abstract string CodeColumnNameSuffix { get; } - - /// - /// Gets the suffix of the JSON column. - /// - public abstract string JsonColumnNameSuffix { get; } - - /// - /// Gets the name of the CreatedOn audit column (where it exists). - /// - public abstract string CreatedOnColumnName { get; } - - /// - /// Gets the name of the CreatedBy audit column (where it exists). - /// - public abstract string CreatedByColumnName { get; } - - /// - /// Gets the name of the UpdatedOn audit column (where it exists). - /// - public abstract string UpdatedOnColumnName { get; } - - /// - /// Gets the name of the UpdatedBy audit column (where it exists). - /// - public abstract string UpdatedByColumnName { get; } - - /// - /// Gets the name of the TenantId column (where it exists). - /// - public abstract string TenantIdColumnName { get; } - - /// - /// Gets the name of the row-version (ETag) column (where it exists). - /// - public abstract string RowVersionColumnName { get; } - - /// - /// Gets the name of the logically IsDeleted column (where it exists). - /// - public abstract string IsDeletedColumnName { get; } - - /// - /// Gets the name of the reference-data code column (where it exists); - /// - public abstract string RefDataCodeColumnName { get; } - - /// - /// Gets the name of the reference-data text column (where it exists); - /// - public abstract string RefDataTextColumnName { get; } - - /// - /// Prepares the as the final opportunity to finalize any standard defaults. - /// - /// Where overriding this base method should be invoked first to perform the standardized preparation. - public virtual void PrepareMigrationArgs() - { - // Override/set the values - ensure consistency between the two. - Migration.Args.IdColumnNameSuffix ??= IdColumnNameSuffix; - Migration.Args.CodeColumnNameSuffix ??= CodeColumnNameSuffix; - Migration.Args.JsonColumnNameSuffix ??= JsonColumnNameSuffix; - Migration.Args.CreatedByColumnName ??= CreatedByColumnName; - Migration.Args.CreatedOnColumnName ??= CreatedOnColumnName; - Migration.Args.UpdatedByColumnName ??= UpdatedByColumnName; - Migration.Args.UpdatedOnColumnName ??= UpdatedOnColumnName; - Migration.Args.TenantIdColumnName ??= TenantIdColumnName; - Migration.Args.RowVersionColumnName ??= RowVersionColumnName; - Migration.Args.IsDeletedColumnName ??= IsDeletedColumnName; - Migration.Args.RefDataCodeColumnName ??= RefDataCodeColumnName; - Migration.Args.RefDataTextColumnName ??= RefDataTextColumnName; - - // Where the database has a default schema then this should be ordered first where not already set. - if (SupportsSchema && !string.IsNullOrEmpty(DefaultSchema) && !Migration.Args.SchemaOrder.Contains(DefaultSchema)) - Migration.Args.SchemaOrder.Insert(0, DefaultSchema); - } - - /// - /// Creates the from the `InformationSchema.Columns` . - /// - /// The corresponding . - /// The . - /// The . - public abstract DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema table, DatabaseRecord dr); - - /// - /// Opportunity to load additional `InformationSchema` related data that is specific to the database. - /// - /// The . - /// The list to load additional data into. - /// The . - public virtual Task LoadAdditionalInformationSchema(IDatabase database, List tables, CancellationToken cancellationToken) => Task.CompletedTask; - - /// - /// Gets the and formatted as the fully qualified name. - /// - /// The schema name. - /// The table name. - /// The fully qualified name. - public abstract string ToFullyQualifiedTableName(string? schema, string table); - - /// - /// Gets the corresponding .NET name for the specified . - /// - /// The . - /// The .NET name. - public abstract string ToDotNetTypeName(DbColumnSchema schema); - - /// - /// Gets the long-form formatted SQL type; includes size, precision, etc. - /// - /// The . - /// Indicates whether to include the nullability within the formatted value. - /// The long-form formatted SQL type. - /// This resulting text is intended for usage within SQL statements. - public abstract string ToFormattedSqlType(DbColumnSchema schema, bool includeNullability = true); - - /// - /// Gets the formatted text representation of the used for parsing (see ). - /// - /// The . - /// The value. - /// The formatted SQL statement representation. - /// This resulting text is intended for usage within JSON/YAML data formatting/parsing. - public virtual string ToFormattedDataParserValue(DataParserArgs args, object? value) - { - args.ThrowIfNull(nameof(args)); + // Override/set the values - ensure consistency between the two. + Migration.Args.IdColumnNameSuffix ??= IdColumnNameSuffix; + Migration.Args.CodeColumnNameSuffix ??= CodeColumnNameSuffix; + Migration.Args.JsonColumnNameSuffix ??= JsonColumnNameSuffix; + Migration.Args.CreatedByColumnName ??= CreatedByColumnName; + Migration.Args.CreatedOnColumnName ??= CreatedOnColumnName; + Migration.Args.UpdatedByColumnName ??= UpdatedByColumnName; + Migration.Args.UpdatedOnColumnName ??= UpdatedOnColumnName; + Migration.Args.TenantIdColumnName ??= TenantIdColumnName; + Migration.Args.RowVersionColumnName ??= RowVersionColumnName; + Migration.Args.IsDeletedColumnName ??= IsDeletedColumnName; + Migration.Args.RefDataCodeColumnName ??= RefDataCodeColumnName; + Migration.Args.RefDataTextColumnName ??= RefDataTextColumnName; + + // Where the database has a default schema then this should be ordered first where not already set. + if (SupportsSchema && !string.IsNullOrEmpty(DefaultSchema) && !Migration.Args.SchemaOrder.Contains(DefaultSchema)) + Migration.Args.SchemaOrder.Insert(0, DefaultSchema); + } + + /// + /// Creates the from the `InformationSchema.Columns` . + /// + /// The corresponding . + /// The . + /// The . + public abstract DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema table, DatabaseRecord dr); + + /// + /// Opportunity to load additional `InformationSchema` related data that is specific to the database. + /// + /// The . + /// The list to load additional data into. + /// The . + public virtual Task LoadAdditionalInformationSchema(IDatabase database, List tables, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Gets the and formatted as the fully qualified name. + /// + /// The schema name. + /// The table name. + /// The fully qualified name. + public abstract string ToFullyQualifiedTableName(string? schema, string table); + + /// + /// Gets the corresponding .NET name for the specified . + /// + /// The . + /// The .NET name. + public abstract string ToDotNetTypeName(DbColumnSchema schema); + + /// + /// Gets the long-form formatted SQL type; includes size, precision, etc. + /// + /// The . + /// Indicates whether to include the nullability within the formatted value. + /// The long-form formatted SQL type. + /// This resulting text is intended for usage within SQL statements. + public abstract string ToFormattedSqlType(DbColumnSchema schema, bool includeNullability = true); + + /// + /// Gets the formatted text representation of the used for parsing (see ). + /// + /// The . + /// The value. + /// The formatted SQL statement representation. + /// This resulting text is intended for usage within JSON/YAML data formatting/parsing. + public virtual string ToFormattedDataParserValue(DataParserArgs args, object? value) + { + args.ThrowIfNull(nameof(args)); - return value switch - { - DateTime dt => dt.ToString(args.DateTimeFormat), - DateTimeOffset dto => dto.ToString(args.DateTimeOffsetFormat), + return value switch + { + DateTime dt => dt.ToString(args.DateTimeFormat), + DateTimeOffset dto => dto.ToString(args.DateTimeOffsetFormat), #if NET7_0_OR_GREATER - DateOnly d => d.ToString(args.DateOnlyFormat), - TimeOnly t => t.ToString(args.TimeOnlyFormat), + DateOnly d => d.ToString(args.DateOnlyFormat), + TimeOnly t => t.ToString(args.TimeOnlyFormat), #endif - _ => value?.ToString() ?? string.Empty, - }; - } - - /// - /// Gets the formatted SQL statement representation (where required) of the . - /// - /// The . - /// The value. - /// The formatted SQL statement representation. - /// This resulting text is intended for usage when generating/outputting SQL statements. - public abstract string ToFormattedSqlStatementValue(DbColumnSchema schema, object? value); + _ => value?.ToString() ?? string.Empty, + }; } + + /// + /// Gets the formatted SQL statement representation (where required) of the . + /// + /// The . + /// The value. + /// The formatted SQL statement representation. + /// This resulting text is intended for usage when generating/outputting SQL statements. + public abstract string ToFormattedSqlStatementValue(DbColumnSchema schema, object? value); } \ No newline at end of file diff --git a/src/DbEx/DbSchema/DbColumnSchema.cs b/src/DbEx/DbSchema/DbColumnSchema.cs index 01aa4ae..0336b64 100644 --- a/src/DbEx/DbSchema/DbColumnSchema.cs +++ b/src/DbEx/DbSchema/DbColumnSchema.cs @@ -1,257 +1,251 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.DbSchema; + +/// +/// Represents the Database Column schema definition. +/// +/// The owning (parent) . +/// The column name. +/// The column type. +/// The .NET name override (optional). +[DebuggerDisplay("{Name} {SqlType} ({DotNetType})")] +public class DbColumnSchema(DbTableSchema dbTable, string name, string type, string? dotNetNameOverride = null) +{ + private string? _dotNetType; + private string? _dotNetName = dotNetNameOverride; + private string? _dotNetCleanedName; + private string? _sqlType; + private string? _sqlType2; -using System; -using System.Diagnostics; + /// + /// Gets the owning (parent) . + /// + public DbTableSchema DbTable { get; } = dbTable.ThrowIfNull(nameof(dbTable)); -namespace DbEx.DbSchema -{ /// - /// Represents the Database Column schema definition. + /// Gets the column name. /// - /// The owning (parent) . - /// The column name. - /// The column type. - /// The .NET name override (optional). - [DebuggerDisplay("{Name} {SqlType} ({DotNetType})")] - public class DbColumnSchema(DbTableSchema dbTable, string name, string type, string? dotNetNameOverride = null) - { - private string? _dotNetType; - private string? _dotNetName = dotNetNameOverride; - private string? _dotNetCleanedName; - private string? _sqlType; - private string? _sqlType2; - - /// - /// Gets the owning (parent) . - /// - public DbTableSchema DbTable { get; } = dbTable.ThrowIfNull(nameof(dbTable)); - - /// - /// Gets the column name. - /// - public string Name { get; } = name.ThrowIfNull(nameof(name)); - - /// - /// Gets the SQL Server data type. - /// - public string Type { get; } = type.ThrowIfNull(nameof(type)); - - /// - /// Indicates whether the column is nullable. - /// - public bool IsNullable { get; set; } - - /// - /// Gets or sets the length. - /// - public ulong? Length { get; set; } - - /// - /// Indicates whether the column has a length greater than zero. - /// - public bool HasLength => Length != null && Length > 0; - - /// - /// Gets or sets the precision. - /// - public ulong? Precision { get; set; } - - /// - /// Gets or sets the scale. - /// - public ulong? Scale { get; set; } - - /// - /// Indicates whether the column is an auto-incremented identity (either Identity or Defaulted). - /// - public bool IsIdentity { get; set; } - - /// - /// Indicates whether the column is an auto-incremented seeded identity. - /// - public bool IsIdentitySeeded => IsIdentity && IdentitySeed != null; - - /// - /// Gets or sets the identity seed value. - /// - public int? IdentitySeed { get; set; } - - /// - /// Gets or sets the identity increment value; - /// - public int? IdentityIncrement { get; set; } - - /// - /// Indicates whether the column is computed. - /// - public bool IsComputed { get; set; } - - /// - /// Gets or sets the default value. - /// - public string? DefaultValue { get; set; } - - /// - /// Indicates whether the column is the primary key. - /// - public bool IsPrimaryKey { get; set; } - - /// - /// Indicates whether the column has a unique constraint. - /// - public bool IsUnique { get; set; } - - /// - /// Gets or sets the foreign key table. - /// - public string? ForeignTable { get; set; } - - /// - /// Gets or sets the foreign key schema. - /// - public string? ForeignSchema { get; set; } - - /// - /// Gets or sets the foreign key column name. - /// - public string? ForeignColumn { get; set; } - - /// - /// Indicates whether the column or the name (after removing 'Id' or 'Code') matches a reference data table/entity in the same schema (where applicable). - /// - public bool IsRefData { get; set; } - - /// - /// Indicates whether the foreign key is referencing a reference data table/entity. - /// - public bool IsForeignRefData { get; set; } - - /// - /// Gets or sets the foreign key reference data column name. - /// - public string? ForeignRefDataCodeColumn { get; set; } - - /// - /// Indicates whether the column is a created audit column; i.e. name is CreatedDate or CreatedBy. - /// - public bool IsCreatedAudit { get; set; } - - /// - /// Indicates whether the column is an updated audit column; i.e. name is UpdatedDate or UpdatedBy. - /// - public bool IsUpdatedAudit { get; set; } - - /// - /// Indicates whether the column is a row-version column; i.e. name is RowVersion. - /// - public bool IsRowVersion { get; set; } - - /// - /// Indicates whether the column is a tenant identifier column; i.e. name is TenantId. - /// - public bool IsTenantId { get; set; } - - /// - /// Indicates whether the column is an is-deleted column; i.e. name is IsDeleted. - /// - public bool IsIsDeleted { get; set; } - - /// - /// Indicates whether the column may contain JSON content by convention ( is a `string` and the ends with `Json` or is a native JSON database type). - /// - public bool IsJsonContent { get; set; } - - /// - /// Gets the corresponding .NET name. - /// - public string DotNetType => _dotNetType ??= DbTable?.Migration.SchemaConfig.ToDotNetTypeName(this) ?? throw new InvalidOperationException($"The {nameof(DbTable)} must be set before the {nameof(DotNetType)} property can be accessed."); - - /// - /// Gets the corresponding .NET name. - /// - public string DotNetName => _dotNetName ??= DbTableSchema.CreateDotNetName(Name); - - /// - /// Gets the corresponding .NET name cleaned; by removing any known suffixes where or - /// - public string DotNetCleanedName { get => _dotNetCleanedName ?? DotNetName; set => _dotNetCleanedName = value; } - - /// - /// Gets the fully defined SQL type (includes nullability). - /// - public string SqlType => _sqlType ??= DbTable?.Migration.SchemaConfig.ToFormattedSqlType(this) ?? throw new InvalidOperationException($"The {nameof(DbTable)} must be set before the {nameof(SqlType)} property can be accessed."); - - /// - /// Gets the fully defined SQL type (excludes nullability). - /// - public string SqlType2 => _sqlType2 ??= DbTable?.Migration.SchemaConfig.ToFormattedSqlType(this, false) ?? throw new InvalidOperationException($"The {nameof(DbTable)} must be set before the {nameof(SqlType)} property can be accessed."); + public string Name { get; } = name.ThrowIfNull(nameof(name)); + + /// + /// Gets the SQL Server data type. + /// + public string Type { get; } = type.ThrowIfNull(nameof(type)); + + /// + /// Indicates whether the column is nullable. + /// + public bool IsNullable { get; set; } + + /// + /// Gets or sets the length. + /// + public ulong? Length { get; set; } + + /// + /// Indicates whether the column has a length greater than zero. + /// + public bool HasLength => Length != null && Length > 0; + + /// + /// Gets or sets the precision. + /// + public ulong? Precision { get; set; } + + /// + /// Gets or sets the scale. + /// + public ulong? Scale { get; set; } + + /// + /// Indicates whether the column is an auto-incremented identity (either Identity or Defaulted). + /// + public bool IsIdentity { get; set; } + + /// + /// Indicates whether the column is an auto-incremented seeded identity. + /// + public bool IsIdentitySeeded => IsIdentity && IdentitySeed != null; + + /// + /// Gets or sets the identity seed value. + /// + public int? IdentitySeed { get; set; } + + /// + /// Gets or sets the identity increment value; + /// + public int? IdentityIncrement { get; set; } + + /// + /// Indicates whether the column is computed. + /// + public bool IsComputed { get; set; } + + /// + /// Gets or sets the default value. + /// + public string? DefaultValue { get; set; } + + /// + /// Indicates whether the column is the primary key. + /// + public bool IsPrimaryKey { get; set; } + + /// + /// Indicates whether the column has a unique constraint. + /// + public bool IsUnique { get; set; } + + /// + /// Gets or sets the foreign key table. + /// + public string? ForeignTable { get; set; } + + /// + /// Gets or sets the foreign key schema. + /// + public string? ForeignSchema { get; set; } + + /// + /// Gets or sets the foreign key column name. + /// + public string? ForeignColumn { get; set; } + + /// + /// Indicates whether the column or the name (after removing 'Id' or 'Code') matches a reference data table/entity in the same schema (where applicable). + /// + public bool IsRefData { get; set; } + + /// + /// Indicates whether the foreign key is referencing a reference data table/entity. + /// + public bool IsForeignRefData { get; set; } + + /// + /// Gets or sets the foreign key reference data column name. + /// + public string? ForeignRefDataCodeColumn { get; set; } + + /// + /// Indicates whether the column is a created audit column; i.e. name is CreatedDate or CreatedBy. + /// + public bool IsCreatedAudit { get; set; } + + /// + /// Indicates whether the column is an updated audit column; i.e. name is UpdatedDate or UpdatedBy. + /// + public bool IsUpdatedAudit { get; set; } + + /// + /// Indicates whether the column is a row-version column; i.e. name is RowVersion. + /// + public bool IsRowVersion { get; set; } + + /// + /// Indicates whether the column is a tenant identifier column; i.e. name is TenantId. + /// + public bool IsTenantId { get; set; } + + /// + /// Indicates whether the column is an is-deleted column; i.e. name is IsDeleted. + /// + public bool IsIsDeleted { get; set; } + + /// + /// Indicates whether the column may contain JSON content by convention ( is a `string` and the ends with `Json` or is a native JSON database type). + /// + public bool IsJsonContent { get; set; } + + /// + /// Gets the corresponding .NET name. + /// + public string DotNetType => _dotNetType ??= DbTable?.Migration.SchemaConfig.ToDotNetTypeName(this) ?? throw new InvalidOperationException($"The {nameof(DbTable)} must be set before the {nameof(DotNetType)} property can be accessed."); + + /// + /// Gets the corresponding .NET name. + /// + public string DotNetName => _dotNetName ??= DbTableSchema.CreateDotNetName(Name); + + /// + /// Gets the corresponding .NET name cleaned; by removing any known suffixes where or + /// + public string DotNetCleanedName { get => _dotNetCleanedName ?? DotNetName; set => _dotNetCleanedName = value; } + + /// + /// Gets the fully defined SQL type (includes nullability). + /// + public string SqlType => _sqlType ??= DbTable?.Migration.SchemaConfig.ToFormattedSqlType(this) ?? throw new InvalidOperationException($"The {nameof(DbTable)} must be set before the {nameof(SqlType)} property can be accessed."); + + /// + /// Gets the fully defined SQL type (excludes nullability). + /// + public string SqlType2 => _sqlType2 ??= DbTable?.Migration.SchemaConfig.ToFormattedSqlType(this, false) ?? throw new InvalidOperationException($"The {nameof(DbTable)} must be set before the {nameof(SqlType)} property can be accessed."); #if NET7_0_OR_GREATER - /// - /// Indicates that the type can be expressed as a .NET type. - /// + /// + /// Indicates that the type can be expressed as a .NET type. + /// #else - /// - /// Indicates that the type can be expressed as a DateOnly .NET type. - /// + /// + /// Indicates that the type can be expressed as a DateOnly .NET type. + /// #endif - public bool IsDotNetDateOnly { get; set; } + public bool IsDotNetDateOnly { get; set; } #if NET7_0_OR_GREATER - /// - /// Indicates that the type can be expressed as a .NET type. - /// + /// + /// Indicates that the type can be expressed as a .NET type. + /// #else - /// - /// Indicates that the type can be expressed as a TimeOnly .NET type. - /// + /// + /// Indicates that the type can be expressed as a TimeOnly .NET type. + /// #endif - public bool IsDotNetTimeOnly { get; set; } - - /// - /// Clones the creating a new instance. - /// - public DbColumnSchema Clone() - { - var c = new DbColumnSchema(DbTable, Name, Type); - c.CopyFrom(this); - return c; - } - - /// - /// Copy all properties (excluding , and ) from specified . - /// - /// The to copy from. - public void CopyFrom(DbColumnSchema column) - { - _dotNetType = column.ThrowIfNull(nameof(column))._dotNetType; - _dotNetName = column._dotNetName; - _dotNetCleanedName = column._dotNetCleanedName; - _sqlType = column._sqlType; - IsNullable = column.IsNullable; - Length = column.Length; - Precision = column.Precision; - Scale = column.Scale; - IsIdentity = column.IsIdentity; - IdentityIncrement = column.IdentityIncrement; - IdentitySeed = column.IdentitySeed; - IsComputed = column.IsComputed; - DefaultValue = column.DefaultValue; - IsPrimaryKey = column.IsPrimaryKey; - IsUnique = column.IsUnique; - ForeignTable = column.ForeignTable; - ForeignSchema = column.ForeignSchema; - ForeignColumn = column.ForeignColumn; - IsForeignRefData = column.IsForeignRefData; - IsRefData = column.IsRefData; - ForeignRefDataCodeColumn = column.ForeignRefDataCodeColumn; - IsCreatedAudit = column.IsCreatedAudit; - IsUpdatedAudit = column.IsUpdatedAudit; - IsRowVersion = column.IsRowVersion; - IsTenantId = column.IsTenantId; - IsIsDeleted = column.IsIsDeleted; - IsDotNetDateOnly = column.IsDotNetDateOnly; - IsDotNetTimeOnly = column.IsDotNetTimeOnly; - } + public bool IsDotNetTimeOnly { get; set; } + + /// + /// Clones the creating a new instance. + /// + public DbColumnSchema Clone() + { + var c = new DbColumnSchema(DbTable, Name, Type); + c.CopyFrom(this); + return c; + } + + /// + /// Copy all properties (excluding , and ) from specified . + /// + /// The to copy from. + public void CopyFrom(DbColumnSchema column) + { + _dotNetType = column.ThrowIfNull(nameof(column))._dotNetType; + _dotNetName = column._dotNetName; + _dotNetCleanedName = column._dotNetCleanedName; + _sqlType = column._sqlType; + IsNullable = column.IsNullable; + Length = column.Length; + Precision = column.Precision; + Scale = column.Scale; + IsIdentity = column.IsIdentity; + IdentityIncrement = column.IdentityIncrement; + IdentitySeed = column.IdentitySeed; + IsComputed = column.IsComputed; + DefaultValue = column.DefaultValue; + IsPrimaryKey = column.IsPrimaryKey; + IsUnique = column.IsUnique; + ForeignTable = column.ForeignTable; + ForeignSchema = column.ForeignSchema; + ForeignColumn = column.ForeignColumn; + IsForeignRefData = column.IsForeignRefData; + IsRefData = column.IsRefData; + ForeignRefDataCodeColumn = column.ForeignRefDataCodeColumn; + IsCreatedAudit = column.IsCreatedAudit; + IsUpdatedAudit = column.IsUpdatedAudit; + IsRowVersion = column.IsRowVersion; + IsTenantId = column.IsTenantId; + IsIsDeleted = column.IsIsDeleted; + IsDotNetDateOnly = column.IsDotNetDateOnly; + IsDotNetTimeOnly = column.IsDotNetTimeOnly; } } \ No newline at end of file diff --git a/src/DbEx/DbSchema/DbTableSchema.cs b/src/DbEx/DbSchema/DbTableSchema.cs index d946c82..355376a 100644 --- a/src/DbEx/DbSchema/DbTableSchema.cs +++ b/src/DbEx/DbSchema/DbTableSchema.cs @@ -1,203 +1,191 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx - -using DbEx.Migration; -using OnRamp.Utility; -using System; -using System.Collections.Generic; -using System.Data; -using System.Diagnostics; -using System.Linq; -using System.Text; - -namespace DbEx.DbSchema +namespace DbEx.DbSchema; + +/// +/// Represents the Database Table schema definition. +/// +[DebuggerDisplay("{QualifiedName}")] +public partial class DbTableSchema { + private static readonly char[] _separators = ['_', '-']; + private static readonly string[] _suffixes = ["Id", "Code", "Json"]; + + private string? _dotNetName; + private string? _pluralName; + /// - /// Represents the Database Table schema definition. + /// Create an alias from the name. /// - [DebuggerDisplay("{QualifiedName}")] - public partial class DbTableSchema + /// The name. + /// The corresponding alias. + /// Converts the name into sentence case and takes first character from each word and converts to lowercase; e.g. 'SalesOrder' will result in an alias of 'so'. + public static string CreateAlias(string name) { - private static readonly char[] _separators = ['_', '-']; - private static readonly string[] _suffixes = ["Id", "Code", "Json"]; - - private string? _dotNetName; - private string? _pluralName; - - /// - /// Create an alias from the name. - /// - /// The name. - /// The corresponding alias. - /// Converts the name into sentence case and takes first character from each word and converts to lowercase; e.g. 'SalesOrder' will result in an alias of 'so'. - public static string CreateAlias(string name) - { - name.ThrowIfNullOrEmpty(nameof(name)); - var s = StringConverter.ToSentenceCase(name)!; - return new string([.. s.Replace(" ", " ").Replace("_", " ").Replace("-", " ").Split(' ').Where(x => !string.IsNullOrEmpty(x)).Select(x => x[..1].ToLower(System.Globalization.CultureInfo.InvariantCulture).ToCharArray()[0])]); - } - - /// - /// Create a .NET friendly name. - /// - /// The name. - /// The .NET friendly name. - /// Removes any snake/camel case separator characters and converts each separated work into Pascal case before combining. - public static string CreateDotNetName(string name) - { - name.ThrowIfNullOrEmpty(nameof(name)); - var sb = new StringBuilder(); - name.Split(_separators, StringSplitOptions.RemoveEmptyEntries).ForEach(part => sb.Append(StringConverter.ToPascalCase(part))); - return sb.ToString(); - } - - /// - /// Create a plural from the singular name. - /// - /// The name. - /// The pluralized name. - public static string CreatePluralName(string name) - { - name.ThrowIfNullOrEmpty(nameof(name)); - var words = StringConverter.ToSentenceCase(name)!.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(x => !string.IsNullOrEmpty(x)).ToList(); - words[^1] = StringConverter.ToPlural(words[^1]); - return string.Join(string.Empty, words); - } - - /// - /// Create a singular from the pluralized name. - /// - /// The name. - /// The singular name. - public static string CreateSingularName(string name) - { - name.ThrowIfNullOrEmpty(nameof(name)); - var words = StringConverter.ToSentenceCase(name)!.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(x => !string.IsNullOrEmpty(x)).ToList(); - words[^1] = StringConverter.ToSingle(words[^1]); - return string.Join(string.Empty, words); - } - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The schema name. - /// The table name. - public DbTableSchema(DatabaseMigrationBase migration, string? schema, string name) - { - Migration = migration.ThrowIfNull(nameof(migration)); - Schema = Migration.SchemaConfig.SupportsSchema ? schema.ThrowIfNull(nameof(schema)) : string.Empty; - Name = name.ThrowIfNullOrEmpty(nameof(name)); - QualifiedName = Migration.SchemaConfig.ToFullyQualifiedTableName(schema, name); - Alias = CreateAlias(Name); - } - - /// - /// Initializes a new instance of the class referencing an existing instance. - /// - /// The existing . - public DbTableSchema(DbTableSchema table) - { - Migration = table.Migration; - Schema = table.Schema; - Name = table.Name; - QualifiedName = table.QualifiedName; - Alias = table.Alias; - IsAView = table.IsAView; - IsRefData = table.IsRefData; - Columns.AddRange(table.Columns); - RefDataCodeColumn = table.RefDataCodeColumn; - } - - /// - /// Gets the schema name. - /// - public string Schema { get; } - - /// - /// Gets the . - /// - public DatabaseMigrationBase Migration { get; } - - /// - /// Gets the table name. - /// - public string Name { get; } - - /// - /// Gets the table name in .NET friendly form. - /// - public string DotNetName => _dotNetName ??= CreateDotNetName(Name); - - /// - /// Gets the in plural form. - /// - public string PluralName => _pluralName ??= CreatePluralName(DotNetName); - - /// - /// Gets or sets the alias (automatically updated from the when instantiated). - /// - public string? Alias { get; set; } - - /// - /// Gets the fully qualified name for the database. - /// - public string? QualifiedName { get; } - - /// - /// Indicates whether the Table is actually a View. - /// - public bool IsAView { get; set; } - - /// - /// Indicates whether the Table is considered reference data. - /// - /// By default determined by existence of columns named and , that are equal false - /// and equal 'string'. - public bool IsRefData { get; set; } - - /// - /// Gets or sets the list. - /// - public List Columns { get; private set; } = []; - - /// - /// Gets the primary key list. - /// - public List PrimaryKeyColumns => Columns?.Where(x => x.IsPrimaryKey).ToList() ?? []; - - /// - /// Gets the standard list (i.e. not primary key, not created audit, not updated audit, not tenant-id, not row-version, not is-deleted). - /// - public List StandardColumns => Columns?.Where(x => !x.IsPrimaryKey && !x.IsCreatedAudit && !x.IsUpdatedAudit && !x.IsTenantId && !x.IsRowVersion && !x.IsIsDeleted).ToList() ?? []; - - /// - /// Gets the tenant idenfifier (if any). - /// - public DbColumnSchema? TenantIdColumn => Columns?.FirstOrDefault(x => x.IsTenantId); - - /// - /// Gets the row version (if any). - /// - public DbColumnSchema? RowVersionColumn => Columns?.FirstOrDefault(x => x.IsRowVersion); - - /// - /// Gets the is-deleted (if any). - /// - public DbColumnSchema? IsDeletedColumn => Columns?.FirstOrDefault(x => x.IsIsDeleted); - - /// - /// Indicates whether the table has any audit columns. - /// - public bool HasAuditColumns => Columns?.Any(x => x.IsCreatedAudit || x.IsUpdatedAudit) ?? false; - - /// - /// Gets or sets the reference-data code . - /// - public DbColumnSchema? RefDataCodeColumn { get; set; } - - /// - /// Gets the list that are part of a constraint (i.e. unique or foreign key). - /// - public List ConstraintColumns => Columns?.Where(x => x.IsUnique || !string.IsNullOrEmpty(x.ForeignTable)).ToList() ?? []; + name.ThrowIfNullOrEmpty(nameof(name)); + var s = StringConverter.ToSentenceCase(name)!; + return new string([.. s.Replace(" ", " ").Replace("_", " ").Replace("-", " ").Split(' ').Where(x => !string.IsNullOrEmpty(x)).Select(x => x[..1].ToLower(System.Globalization.CultureInfo.InvariantCulture).ToCharArray()[0])]); } + + /// + /// Create a .NET friendly name. + /// + /// The name. + /// The .NET friendly name. + /// Removes any snake/camel case separator characters and converts each separated work into Pascal case before combining. + public static string CreateDotNetName(string name) + { + name.ThrowIfNullOrEmpty(nameof(name)); + var sb = new StringBuilder(); + name.Split(_separators, StringSplitOptions.RemoveEmptyEntries).ForEach(part => sb.Append(StringConverter.ToPascalCase(part))); + return sb.ToString(); + } + + /// + /// Create a plural from the singular name. + /// + /// The name. + /// The pluralized name. + public static string CreatePluralName(string name) + { + name.ThrowIfNullOrEmpty(nameof(name)); + var words = StringConverter.ToSentenceCase(name)!.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(x => !string.IsNullOrEmpty(x)).ToList(); + words[^1] = StringConverter.ToPlural(words[^1]); + return string.Join(string.Empty, words); + } + + /// + /// Create a singular from the pluralized name. + /// + /// The name. + /// The singular name. + public static string CreateSingularName(string name) + { + name.ThrowIfNullOrEmpty(nameof(name)); + var words = StringConverter.ToSentenceCase(name)!.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(x => !string.IsNullOrEmpty(x)).ToList(); + words[^1] = StringConverter.ToSingle(words[^1]); + return string.Join(string.Empty, words); + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The schema name. + /// The table name. + public DbTableSchema(DatabaseMigrationBase migration, string? schema, string name) + { + Migration = migration.ThrowIfNull(nameof(migration)); + Schema = Migration.SchemaConfig.SupportsSchema ? schema.ThrowIfNull(nameof(schema)) : string.Empty; + Name = name.ThrowIfNullOrEmpty(nameof(name)); + QualifiedName = Migration.SchemaConfig.ToFullyQualifiedTableName(schema, name); + Alias = CreateAlias(Name); + } + + /// + /// Initializes a new instance of the class referencing an existing instance. + /// + /// The existing . + public DbTableSchema(DbTableSchema table) + { + Migration = table.Migration; + Schema = table.Schema; + Name = table.Name; + QualifiedName = table.QualifiedName; + Alias = table.Alias; + IsAView = table.IsAView; + IsRefData = table.IsRefData; + Columns.AddRange(table.Columns); + RefDataCodeColumn = table.RefDataCodeColumn; + } + + /// + /// Gets the schema name. + /// + public string Schema { get; } + + /// + /// Gets the . + /// + public DatabaseMigrationBase Migration { get; } + + /// + /// Gets the table name. + /// + public string Name { get; } + + /// + /// Gets the table name in .NET friendly form. + /// + public string DotNetName => _dotNetName ??= CreateDotNetName(Name); + + /// + /// Gets the in plural form. + /// + public string PluralName => _pluralName ??= CreatePluralName(DotNetName); + + /// + /// Gets or sets the alias (automatically updated from the when instantiated). + /// + public string? Alias { get; set; } + + /// + /// Gets the fully qualified name for the database. + /// + public string? QualifiedName { get; } + + /// + /// Indicates whether the Table is actually a View. + /// + public bool IsAView { get; set; } + + /// + /// Indicates whether the Table is considered reference data. + /// + /// By default determined by existence of columns named and , that are equal false + /// and equal 'string'. + public bool IsRefData { get; set; } + + /// + /// Gets or sets the list. + /// + public List Columns { get; private set; } = []; + + /// + /// Gets the primary key list. + /// + public List PrimaryKeyColumns => Columns?.Where(x => x.IsPrimaryKey).ToList() ?? []; + + /// + /// Gets the standard list (i.e. not primary key, not created audit, not updated audit, not tenant-id, not row-version, not is-deleted). + /// + public List StandardColumns => Columns?.Where(x => !x.IsPrimaryKey && !x.IsCreatedAudit && !x.IsUpdatedAudit && !x.IsTenantId && !x.IsRowVersion && !x.IsIsDeleted).ToList() ?? []; + + /// + /// Gets the tenant idenfifier (if any). + /// + public DbColumnSchema? TenantIdColumn => Columns?.FirstOrDefault(x => x.IsTenantId); + + /// + /// Gets the row version (if any). + /// + public DbColumnSchema? RowVersionColumn => Columns?.FirstOrDefault(x => x.IsRowVersion); + + /// + /// Gets the is-deleted (if any). + /// + public DbColumnSchema? IsDeletedColumn => Columns?.FirstOrDefault(x => x.IsIsDeleted); + + /// + /// Indicates whether the table has any audit columns. + /// + public bool HasAuditColumns => Columns?.Any(x => x.IsCreatedAudit || x.IsUpdatedAudit) ?? false; + + /// + /// Gets or sets the reference-data code . + /// + public DbColumnSchema? RefDataCodeColumn { get; set; } + + /// + /// Gets the list that are part of a constraint (i.e. unique or foreign key). + /// + public List ConstraintColumns => Columns?.Where(x => x.IsUnique || !string.IsNullOrEmpty(x.ForeignTable)).ToList() ?? []; } \ No newline at end of file diff --git a/src/DbEx/GlobalUsing.cs b/src/DbEx/GlobalUsing.cs new file mode 100644 index 0000000..9f6ae67 --- /dev/null +++ b/src/DbEx/GlobalUsing.cs @@ -0,0 +1,36 @@ +global using DbEx.DbSchema; +global using DbEx.Migration; +global using DbEx.Migration.Data; +global using HandlebarsDotNet; +global using McMaster.Extensions.CommandLineUtils; +global using McMaster.Extensions.CommandLineUtils.Validation; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Logging.Abstractions; +global using OnRamp; +global using OnRamp.Console; +global using OnRamp.Utility; +global using System; +global using System.Collections; +global using System.Collections.Generic; +global using System.Collections.ObjectModel; +global using System.ComponentModel.DataAnnotations; +global using System.Data.Common; +global using System.Diagnostics; +global using System.Diagnostics.CodeAnalysis; +global using System.IO; +global using System.Linq; +global using System.Reflection; +global using System.Runtime.CompilerServices; +global using System.Text; +global using System.Text.Json; +global using System.Text.Json.Serialization; +global using System.Text.RegularExpressions; +global using System.Threading; +global using System.Threading.Tasks; +global using YamlDotNet.Core.Events; +global using YamlDotNet.Serialization; +global using ExplicitMigrationScript = (DbEx.MigrationCommand Command, System.Reflection.Assembly Assembly, string Name); + +[assembly: InternalsVisibleTo("DbEx.SqlServer, PublicKey=00240000048000009400000006020000002400005253413100040000010001007dee530af6d801902d40685e9cd0a3d8991ddbf545be3ef6147c9f79bacd7464d92fbd94fee34e885c37e3dff4ea15a4f9978f1f614798e0f48e3a3d5bf15e8b2fba9c19b6966838f97444bc247bc101454946d70ac93207cf2c611956aed59c316f81f1bf8c8486f8f0b3f9adf93c2f07e06a86745f6dc4b819c2bc2f3fdad5")] +[assembly: InternalsVisibleTo("DbEx.Postgres, PublicKey=00240000048000009400000006020000002400005253413100040000010001007dee530af6d801902d40685e9cd0a3d8991ddbf545be3ef6147c9f79bacd7464d92fbd94fee34e885c37e3dff4ea15a4f9978f1f614798e0f48e3a3d5bf15e8b2fba9c19b6966838f97444bc247bc101454946d70ac93207cf2c611956aed59c316f81f1bf8c8486f8f0b3f9adf93c2f07e06a86745f6dc4b819c2bc2f3fdad5")] +[assembly: InternalsVisibleTo("DbEx.MySql, PublicKey=00240000048000009400000006020000002400005253413100040000010001007dee530af6d801902d40685e9cd0a3d8991ddbf545be3ef6147c9f79bacd7464d92fbd94fee34e885c37e3dff4ea15a4f9978f1f614798e0f48e3a3d5bf15e8b2fba9c19b6966838f97444bc247bc101454946d70ac93207cf2c611956aed59c316f81f1bf8c8486f8f0b3f9adf93c2f07e06a86745f6dc4b819c2bc2f3fdad5")] \ No newline at end of file diff --git a/src/DbEx/Migration/Data/DataColumn.cs b/src/DbEx/Migration/Data/DataColumn.cs index bb94737..fcd3f56 100644 --- a/src/DbEx/Migration/Data/DataColumn.cs +++ b/src/DbEx/Migration/Data/DataColumn.cs @@ -1,59 +1,53 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration.Data; -using DbEx.DbSchema; -using System; - -namespace DbEx.Migration.Data +/// +/// Represents the database relational data column. +/// +public class DataColumn { /// - /// Represents the database relational data column. + /// Initializes a new instance of the class. /// - public class DataColumn + /// The owning (parent) . + /// + internal DataColumn(DataTable table, string name) { - /// - /// Initializes a new instance of the class. - /// - /// The owning (parent) . - /// - internal DataColumn(DataTable table, string name) - { - Table = table.ThrowIfNull(nameof(table)); - Name = name.ThrowIfNullOrEmpty(nameof(name)); - - // Map the column name where specified. - if (table.ColumnNameMappings is not null && table.ColumnNameMappings.TryGetValue(name, out var mappedName)) - Name = mappedName; - } - - /// - /// Gets the . - /// - public DataTable Table { get; } - - /// - /// Gets or sets the column name. - /// - public string Name { get; set; } - - /// - /// Gets or sets the column value. - /// - public object? Value { get; set; } - - /// - /// Gets or sets the underlying configuration. - /// - public DbColumnSchema? DbColumn { get; set; } - - /// - /// Indicates whether to use a foreign key query for the identifier. - /// - public bool UseForeignKeyQueryForId { get; set; } - - /// - /// Gets the value formatted for use in a SQL statement. - /// - /// The value formatted for use in a SQL statement. - public string SqlValue => Table.DbTable.Migration.SchemaConfig.ToFormattedSqlStatementValue(DbColumn ?? throw new InvalidOperationException("The DbColumn property must not be null."), Value); + Table = table.ThrowIfNull(nameof(table)); + Name = name.ThrowIfNullOrEmpty(nameof(name)); + + // Map the column name where specified. + if (table.ColumnNameMappings is not null && table.ColumnNameMappings.TryGetValue(name, out var mappedName)) + Name = mappedName; } + + /// + /// Gets the . + /// + public DataTable Table { get; } + + /// + /// Gets or sets the column name. + /// + public string Name { get; set; } + + /// + /// Gets or sets the column value. + /// + public object? Value { get; set; } + + /// + /// Gets or sets the underlying configuration. + /// + public DbColumnSchema? DbColumn { get; set; } + + /// + /// Indicates whether to use a foreign key query for the identifier. + /// + public bool UseForeignKeyQueryForId { get; set; } + + /// + /// Gets the value formatted for use in a SQL statement. + /// + /// The value formatted for use in a SQL statement. + public string SqlValue => Table.DbTable.Migration.SchemaConfig.ToFormattedSqlStatementValue(DbColumn ?? throw new InvalidOperationException("The DbColumn property must not be null."), Value); } \ No newline at end of file diff --git a/src/DbEx/Migration/Data/DataConfig.cs b/src/DbEx/Migration/Data/DataConfig.cs index a6f82e5..28f72b6 100644 --- a/src/DbEx/Migration/Data/DataConfig.cs +++ b/src/DbEx/Migration/Data/DataConfig.cs @@ -1,30 +1,25 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration.Data; -using System.Text.Json.Serialization; - -namespace DbEx.Migration.Data +/// +/// Represents the data configuration. +/// +public class DataConfig { /// - /// Represents the data configuration. + /// Gets or sets the pre-condition SQL. /// - public class DataConfig - { - /// - /// Gets or sets the pre-condition SQL. - /// - [JsonPropertyName("preConditionSql")] - public string? PreConditionSql { get; set; } + [JsonPropertyName("preConditionSql")] + public string? PreConditionSql { get; set; } - /// - /// Gets or sets the pre-SQL. - /// - [JsonPropertyName("preSql")] - public string? PreSql { get; set; } + /// + /// Gets or sets the pre-SQL. + /// + [JsonPropertyName("preSql")] + public string? PreSql { get; set; } - /// - /// Gets or sets the post-SQL. - /// - [JsonPropertyName("postSql")] - public string? PostSql { get; set; } - } + /// + /// Gets or sets the post-SQL. + /// + [JsonPropertyName("postSql")] + public string? PostSql { get; set; } } \ No newline at end of file diff --git a/src/DbEx/Migration/Data/DataParser.cs b/src/DbEx/Migration/Data/DataParser.cs index 39aee63..2e45a77 100644 --- a/src/DbEx/Migration/Data/DataParser.cs +++ b/src/DbEx/Migration/Data/DataParser.cs @@ -1,385 +1,370 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx - -using DbEx.DbSchema; -using HandlebarsDotNet; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using YamlDotNet.Core.Events; -using YamlDotNet.Serialization; - -namespace DbEx.Migration.Data +namespace DbEx.Migration.Data; + +/// +/// Provides the capabilities to parse database relational data. +/// +public class DataParser { - /// - /// Provides the capabilities to parse database relational data. - /// - public class DataParser + private class YamlNodeTypeResolver : INodeTypeResolver { - private class YamlNodeTypeResolver : INodeTypeResolver - { - private static readonly string[] boolValues = ["true", "false"]; + private static readonly string[] boolValues = ["true", "false"]; - /// - bool INodeTypeResolver.Resolve(NodeEvent? nodeEvent, ref Type currentType) + /// + bool INodeTypeResolver.Resolve(NodeEvent? nodeEvent, ref Type currentType) + { + if (nodeEvent is Scalar scalar && scalar.Style == YamlDotNet.Core.ScalarStyle.Plain) { - if (nodeEvent is Scalar scalar && scalar.Style == YamlDotNet.Core.ScalarStyle.Plain) + if (decimal.TryParse(scalar.Value, out _)) { - if (decimal.TryParse(scalar.Value, out _)) - { - if (scalar.Value.Length > 1 && scalar.Value.StartsWith('0')) // Valid JSON does not support a number that starts with a zero. - currentType = typeof(string); - else - currentType = typeof(decimal); - - return true; - } + if (scalar.Value.Length > 1 && scalar.Value.StartsWith('0')) // Valid JSON does not support a number that starts with a zero. + currentType = typeof(string); + else + currentType = typeof(decimal); - if (boolValues.Contains(scalar.Value)) - { - currentType = typeof(bool); - return true; - } + return true; } - return false; + if (boolValues.Contains(scalar.Value)) + { + currentType = typeof(bool); + return true; + } } - } - /// - /// Initializes a new instance of the class. - /// - /// The owning . - /// The list. - internal DataParser(DatabaseMigrationBase migration, List dbTables) - { - Migration = migration.ThrowIfNull(nameof(migration)); - DbTables = dbTables.ThrowIfNull(nameof(dbTables)); - } - - /// - /// Gets the owning . - /// - public DatabaseMigrationBase Migration { get; } - - /// - /// Gets the list. - /// - public IEnumerable DbTables { get; private set; } - - /// - /// Gets the . - /// - public DataParserArgs ParserArgs => Migration.Args.DataParserArgs; - - /// - /// Reads and parses the database using the specified YAML . - /// - /// The YAML . - /// The . - /// The resulting list. - public Task> ParseYamlAsync(string yaml, CancellationToken cancellationToken = default) - { - using var sr = new StringReader(yaml); - return ParseYamlAsync(sr, cancellationToken); + return false; } + } - /// - /// Reads and parses the database using the specified YAML . - /// - /// The YAML . - /// The . - /// The resulting list. - public Task> ParseYamlAsync(Stream s, CancellationToken cancellationToken = default) - { - using var sr = new StreamReader(s); - return ParseYamlAsync(sr, cancellationToken); - } + /// + /// Initializes a new instance of the class. + /// + /// The owning . + /// The list. + internal DataParser(DatabaseMigrationBase migration, List dbTables) + { + Migration = migration.ThrowIfNull(nameof(migration)); + DbTables = dbTables.ThrowIfNull(nameof(dbTables)); + } - /// - /// Reads and parses the database using the specified . - /// - /// The YAML . - /// The . - /// The resulting list. - public Task> ParseYamlAsync(TextReader tr, CancellationToken cancellationToken = default) - { - var yaml = new DeserializerBuilder().WithNodeTypeResolver(new YamlNodeTypeResolver()).Build().Deserialize(tr)!; - var json = new SerializerBuilder().JsonCompatible().Build().Serialize(yaml); - return ParseJsonAsync(json, cancellationToken); - } + /// + /// Gets the owning . + /// + public DatabaseMigrationBase Migration { get; } - /// - /// Reads and parses the database using the specified JSON . - /// - /// The JSON . - /// The . - /// The resulting list. - public Task> ParseJsonAsync(string json, CancellationToken cancellationToken = default) - { - using var sr = new StringReader(json); - return ParseJsonAsync(sr, cancellationToken); - } + /// + /// Gets the list. + /// + public IEnumerable DbTables { get; private set; } - /// - /// Reads and parses the database using the specified JSON . - /// - /// The JSON . - /// The . - /// The resulting list. - public Task> ParseJsonAsync(Stream s, CancellationToken cancellationToken = default) - { - using var jd = JsonDocument.Parse(s); - return ParseJsonAsync(jd, cancellationToken); - } + /// + /// Gets the . + /// + public DataParserArgs ParserArgs => Migration.Args.DataParserArgs; - /// - /// Reads and parses the database using the specified JSON . - /// - /// The JSON . - /// The . - /// The resulting list. - public Task> ParseJsonAsync(TextReader tr, CancellationToken cancellationToken = default) - { - using var jd = JsonDocument.Parse(tr.ReadToEnd()); - return ParseJsonAsync(jd, cancellationToken); - } + /// + /// Reads and parses the database using the specified YAML . + /// + /// The YAML . + /// The . + /// The resulting list. + public Task> ParseYamlAsync(string yaml, CancellationToken cancellationToken = default) + { + using var sr = new StringReader(yaml); + return ParseYamlAsync(sr, cancellationToken); + } - /// - /// Reads and parses the database using the specified . - /// - private async Task> ParseJsonAsync(JsonDocument json, CancellationToken cancellationToken) - { - // Further update/manipulate the schema. - if (ParserArgs.DbSchemaUpdaterAsync != null) - DbTables = await ParserArgs.DbSchemaUpdaterAsync(DbTables, cancellationToken).ConfigureAwait(false); + /// + /// Reads and parses the database using the specified YAML . + /// + /// The YAML . + /// The . + /// The resulting list. + public Task> ParseYamlAsync(Stream s, CancellationToken cancellationToken = default) + { + using var sr = new StreamReader(s); + return ParseYamlAsync(sr, cancellationToken); + } - // Parse table/row/column data. - var tables = new List(); - DataConfig? dataConfig = null; + /// + /// Reads and parses the database using the specified . + /// + /// The YAML . + /// The . + /// The resulting list. + public Task> ParseYamlAsync(TextReader tr, CancellationToken cancellationToken = default) + { + var yaml = new DeserializerBuilder().WithNodeTypeResolver(new YamlNodeTypeResolver()).Build().Deserialize(tr)!; + var json = new SerializerBuilder().JsonCompatible().Build().Serialize(yaml); + return ParseJsonAsync(json, cancellationToken); + } - // Loop through all the schemas. - foreach (var js in json.RootElement.EnumerateObject()) - { - // Check for data configuration as identified by the * schema - which is a special key notation. - if (js.Name == "*") - { - if (js.Value.ValueKind != JsonValueKind.Object) - throw new DataParserException("Data configuration ('*' schema) is invalid; must be an object."); + /// + /// Reads and parses the database using the specified JSON . + /// + /// The JSON . + /// The . + /// The resulting list. + public Task> ParseJsonAsync(string json, CancellationToken cancellationToken = default) + { + using var sr = new StringReader(json); + return ParseJsonAsync(sr, cancellationToken); + } - dataConfig = js.Value.Deserialize(); - continue; - } + /// + /// Reads and parses the database using the specified JSON . + /// + /// The JSON . + /// The . + /// The resulting list. + public Task> ParseJsonAsync(Stream s, CancellationToken cancellationToken = default) + { + using var jd = JsonDocument.Parse(s); + return ParseJsonAsync(jd, cancellationToken); + } - // Loop through the collection of tables. - foreach (var jto in js.Value.EnumerateArray()) - { - foreach (var jt in jto.EnumerateObject()) - { - await ParseTableJsonAsync(tables, null, js.Name, jt, cancellationToken).ConfigureAwait(false); - } - } - } + /// + /// Reads and parses the database using the specified JSON . + /// + /// The JSON . + /// The . + /// The resulting list. + public Task> ParseJsonAsync(TextReader tr, CancellationToken cancellationToken = default) + { + using var jd = JsonDocument.Parse(tr.ReadToEnd()); + return ParseJsonAsync(jd, cancellationToken); + } - // Applies the data configuration where specified. - if (dataConfig is not null) - { - foreach (var dt in tables) - { - dt.ApplyConfig(dataConfig); - } - } + /// + /// Reads and parses the database using the specified . + /// + private async Task> ParseJsonAsync(JsonDocument json, CancellationToken cancellationToken) + { + // Further update/manipulate the schema. + if (ParserArgs.DbSchemaUpdaterAsync != null) + DbTables = await ParserArgs.DbSchemaUpdaterAsync(DbTables, cancellationToken).ConfigureAwait(false); - return tables; - } + // Parse table/row/column data. + var tables = new List(); + DataConfig? dataConfig = null; - /// - /// Reads and parses the table data. - /// - private async Task ParseTableJsonAsync(List tables, DataRow? parent, string schema, JsonProperty jp, CancellationToken cancellationToken) + // Loop through all the schemas. + foreach (var js in json.RootElement.EnumerateObject()) { - // Get existing or create new table. - var sdt = new DataTable(this, Migration.SchemaConfig.SupportsSchema ? schema : string.Empty, jp.Name); - var prev = tables.SingleOrDefault(x => x.Schema == sdt.Schema && x.Name == sdt.Name); - if (prev is null) - tables.Add(sdt); - else - sdt = prev; - - // Loop through the collection of rows. - foreach (var jro in jp.Value.EnumerateArray()) + // Check for data configuration as identified by the * schema - which is a special key notation. + if (js.Name == "*") { - var row = new DataRow(sdt); + if (js.Value.ValueKind != JsonValueKind.Object) + throw new DataParserException("Data configuration ('*' schema) is invalid; must be an object."); - foreach (var jr in jro.EnumerateObject()) - { - switch (jr.Value.ValueKind) - { - case JsonValueKind.Object: - row.AddColumn(jr.Name, jr.Value.GetRawText()); - break; - - case JsonValueKind.Array: - // Try parsing as a further described nested table configuration (i.e. representing a relationship) or update column with JSON string. - if (sdt.DbTable.Columns.SingleOrDefault(x => x.Name == jr.Name) is null) - await ParseTableJsonAsync(tables, row, sdt.Schema, jr, cancellationToken).ConfigureAwait(false); - else - row.AddColumn(jr.Name, jr.Value.GetRawText()); - - break; - - default: - if (sdt.IsRefData && jro.EnumerateObject().Count() == 1) - { - row.AddColumn(Migration.Args.RefDataCodeColumnName!, jr.Name); - row.AddColumn(Migration.Args.RefDataTextColumnName!, jr.Value.GetString()); - } - else - row.AddColumn(jr.Name, GetColumnValue(jr.Value)); - - break; - } - } + dataConfig = js.Value.Deserialize(); + continue; + } - // Where specified within a hierarchy attempt to be fancy and auto-update from the parent's primary key where same name. - if (parent is not null) + // Loop through the collection of tables. + foreach (var jto in js.Value.EnumerateArray()) + { + foreach (var jt in jto.EnumerateObject()) { - foreach (var pktc in parent.Table.DbTable.PrimaryKeyColumns) - { - var pkc = parent.Columns.SingleOrDefault(x => x.Name == pktc.Name); - if (pkc is not null && row.Table.DbTable.Columns.Any(x => x.Name == pktc.Name) && row.Columns.SingleOrDefault(x => x.Name == pktc.Name) is null) - row.AddColumn(pkc.Name, pkc.Value); - } + await ParseTableJsonAsync(tables, null, js.Name, jt, cancellationToken).ConfigureAwait(false); } - - sdt.AddRow(row); } - - if (sdt.Columns.Count > 0) - await sdt.PrepareAsync(cancellationToken).ConfigureAwait(false); } - /// - /// Gets the column value. - /// - private object? GetColumnValue(JsonElement j) + // Applies the data configuration where specified. + if (dataConfig is not null) { - // TODO: Can we be smarter about the datetime parsing?!? - return j.ValueKind switch + foreach (var dt in tables) { - JsonValueKind.Null => null, - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Number => j.GetDecimal(), - JsonValueKind.String => GetRuntimeParameterValue(j.GetString()), - _ => null - }; + dt.ApplyConfig(dataConfig); + } } - /// - /// Get the runtime parameter value. - /// - private object? GetRuntimeParameterValue(string? value) + return tables; + } + + /// + /// Reads and parses the table data. + /// + private async Task ParseTableJsonAsync(List tables, DataRow? parent, string schema, JsonProperty jp, CancellationToken cancellationToken) + { + // Get existing or create new table. + var sdt = new DataTable(this, Migration.SchemaConfig.SupportsSchema ? schema : string.Empty, jp.Name); + var prev = tables.SingleOrDefault(x => x.Schema == sdt.Schema && x.Name == sdt.Name); + if (prev is null) + tables.Add(sdt); + else + sdt = prev; + + // Loop through the collection of rows. + foreach (var jro in jp.Value.EnumerateArray()) { - if (string.IsNullOrEmpty(value)) - return value; + var row = new DataRow(sdt); - // Get runtime value when formatted like: ^(DateTime.UtcNow) - if (value.StartsWith("^(") && value.EndsWith(')')) + foreach (var jr in jro.EnumerateObject()) { - var key = value[2..^1]; - - // Check against known values and runtime parameters. - switch (key) + switch (jr.Value.ValueKind) { - case "UserName": return ParserArgs.UserName; - case "DateTimeNow": return ParserArgs.DateTimeNow; - case "GuidNew": return Guid.NewGuid(); - default: - if (ParserArgs.Parameters.TryGetValue(key, out object? dval)) - return dval; + case JsonValueKind.Object: + row.AddColumn(jr.Name, jr.Value.GetRawText()); + break; + + case JsonValueKind.Array: + // Try parsing as a further described nested table configuration (i.e. representing a relationship) or update column with JSON string. + if (sdt.DbTable.Columns.SingleOrDefault(x => x.Name == jr.Name) is null) + await ParseTableJsonAsync(tables, row, sdt.Schema, jr, cancellationToken).ConfigureAwait(false); + else + row.AddColumn(jr.Name, jr.Value.GetRawText()); break; - } - // Try instantiating as defined. - var (val, msg) = GetSystemRuntimeValue(key); - if (msg == null) - return val; + default: + if (sdt.IsRefData && jro.EnumerateObject().Count() == 1) + { + row.AddColumn(Migration.Args.RefDataCodeColumnName!, jr.Name); + row.AddColumn(Migration.Args.RefDataTextColumnName!, jr.Value.GetString()); + } + else + row.AddColumn(jr.Name, GetColumnValue(jr.Value)); - // Try again adding the System namespace. - (val, msg) = GetSystemRuntimeValue("System." + key); - if (msg == null) - return val; + break; + } + } - throw new DataParserException(msg); + // Where specified within a hierarchy attempt to be fancy and auto-update from the parent's primary key where same name. + if (parent is not null) + { + foreach (var pktc in parent.Table.DbTable.PrimaryKeyColumns) + { + var pkc = parent.Columns.SingleOrDefault(x => x.Name == pktc.Name); + if (pkc is not null && row.Table.DbTable.Columns.Any(x => x.Name == pktc.Name) && row.Columns.SingleOrDefault(x => x.Name == pktc.Name) is null) + row.AddColumn(pkc.Name, pkc.Value); + } } - else if (ParserArgs.ReplaceShorthandGuids && value.StartsWith('^') && int.TryParse(value[1..], out var i)) - return new Guid(i, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); - else - return value; + + sdt.AddRow(row); } - /// - /// Get the system runtime value. - /// - private static (object? value, string? message) GetSystemRuntimeValue(string param) + if (sdt.Columns.Count > 0) + await sdt.PrepareAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the column value. + /// + private object? GetColumnValue(JsonElement j) + { + // TODO: Can we be smarter about the datetime parsing?!? + return j.ValueKind switch { - var ns = param.Split(","); - if (ns.Length > 2) - return (null, $"Runtime value parameter '{param}' is invalid; incorrect format."); + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number => j.GetDecimal(), + JsonValueKind.String => GetRuntimeParameterValue(j.GetString()), + _ => null + }; + } + + /// + /// Get the runtime parameter value. + /// + private object? GetRuntimeParameterValue(string? value) + { + if (string.IsNullOrEmpty(value)) + return value; - var parts = ns[0].Split("."); - if (parts.Length <= 1) - return (null, $"Runtime value parameter '{param}' is invalid; incorrect format."); + // Get runtime value when formatted like: ^(DateTime.UtcNow) + if (value.StartsWith("^(") && value.EndsWith(')')) + { + var key = value[2..^1]; - Type? type = null; - int i = parts.Length; - for (; i >= 0; i--) + // Check against known values and runtime parameters. + switch (key) { - if (ns.Length == 1) - type = Type.GetType(string.Join('.', parts[0..^(parts.Length - i)])); - else - type = Type.GetType(string.Join('.', parts[0..^(parts.Length - i)]) + "," + ns[1]); + case "UserName": return ParserArgs.UserName; + case "DateTimeNow": return ParserArgs.DateTimeNow; + case "GuidNew": return Guid.NewGuid(); + default: + if (ParserArgs.Parameters.TryGetValue(key, out object? dval)) + return dval; - if (type != null) break; } - if (type == null) - return (null, $"Runtime value parameter '{param}' is invalid; no Type can be found."); + // Try instantiating as defined. + var (val, msg) = GetSystemRuntimeValue(key); + if (msg == null) + return val; + + // Try again adding the System namespace. + (val, msg) = GetSystemRuntimeValue("System." + key); + if (msg == null) + return val; - return GetSystemPropertyValue(param, type, null, parts[i..]); + throw new DataParserException(msg); } + else if (ParserArgs.ReplaceShorthandGuids && value.StartsWith('^') && int.TryParse(value[1..], out var i)) + return new Guid(i, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + else + return value; + } - /// - /// Recursively navigates the properties and values to discern the value. - /// - private static (object? value, string? message) GetSystemPropertyValue(string param, Type type, object? obj, string[] parts) - { - if (parts == null || parts.Length == 0) - return (obj, null); + /// + /// Get the system runtime value. + /// + private static (object? value, string? message) GetSystemRuntimeValue(string param) + { + var ns = param.Split(","); + if (ns.Length > 2) + return (null, $"Runtime value parameter '{param}' is invalid; incorrect format."); - var part = parts[0]; - if (part.EndsWith("()")) - { - var mi = type.GetMethod(part[0..^2], []); - if (mi == null || mi.GetParameters().Length != 0) - return (null, $"Runtime value parameter '{param}' is invalid; specified method '{part}' is invalid."); + var parts = ns[0].Split("."); + if (parts.Length <= 1) + return (null, $"Runtime value parameter '{param}' is invalid; incorrect format."); - return GetSystemPropertyValue(param, mi.ReturnType, mi.Invoke(obj, null), parts[1..]); - } + Type? type = null; + int i = parts.Length; + for (; i >= 0; i--) + { + if (ns.Length == 1) + type = Type.GetType(string.Join('.', parts[0..^(parts.Length - i)])); else - { - var pi = type.GetProperty(part); - if (pi == null || !pi.CanRead) - return (null, $"Runtime value parameter '{param}' is invalid; specified property '{part}' is invalid."); + type = Type.GetType(string.Join('.', parts[0..^(parts.Length - i)]) + "," + ns[1]); - return GetSystemPropertyValue(param, pi.PropertyType, pi.GetValue(obj, null), parts[1..]); - } + if (type != null) + break; + } + + if (type == null) + return (null, $"Runtime value parameter '{param}' is invalid; no Type can be found."); + + return GetSystemPropertyValue(param, type, null, parts[i..]); + } + + /// + /// Recursively navigates the properties and values to discern the value. + /// + private static (object? value, string? message) GetSystemPropertyValue(string param, Type type, object? obj, string[] parts) + { + if (parts == null || parts.Length == 0) + return (obj, null); + + var part = parts[0]; + if (part.EndsWith("()")) + { + var mi = type.GetMethod(part[0..^2], []); + if (mi == null || mi.GetParameters().Length != 0) + return (null, $"Runtime value parameter '{param}' is invalid; specified method '{part}' is invalid."); + + return GetSystemPropertyValue(param, mi.ReturnType, mi.Invoke(obj, null), parts[1..]); + } + else + { + var pi = type.GetProperty(part); + if (pi == null || !pi.CanRead) + return (null, $"Runtime value parameter '{param}' is invalid; specified property '{part}' is invalid."); + + return GetSystemPropertyValue(param, pi.PropertyType, pi.GetValue(obj, null), parts[1..]); } } } \ No newline at end of file diff --git a/src/DbEx/Migration/Data/DataParserArgs.cs b/src/DbEx/Migration/Data/DataParserArgs.cs index ecf73e7..90e3bba 100644 --- a/src/DbEx/Migration/Data/DataParserArgs.cs +++ b/src/DbEx/Migration/Data/DataParserArgs.cs @@ -1,171 +1,161 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration.Data; -using DbEx.DbSchema; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.Migration.Data +/// +/// Represents the arguments. +/// +public class DataParserArgs { /// - /// Represents the arguments. + /// Initializes a new instance of the class. + /// + public DataParserArgs() { } + + /// + /// Initializes a new instance of the class with a pre-existing reference. + /// + /// The parameters reference. + public DataParserArgs(Dictionary parameters) => Parameters = parameters; + + /// + /// Gets or sets the user name. + /// + /// Defaults to '/'. + public string UserName { get; set; } = Environment.UserDomainName == null ? Environment.UserName : $"{Environment.UserDomainName}\\{Environment.UserName}"; + + /// + /// Gets or sets the current . + /// + /// Defaults to . + public DateTimeOffset DateTimeNow { get; set; } = DateTimeOffset.UtcNow; + + /// + /// Gets or sets the . + /// + /// Defaults to . + public IIdentifierGenerator IdentifierGenerator { get; set; } = new GuidIdentifierGenerator(); + + /// + /// Gets or sets the format. + /// + /// Defaults to 'yyyy-MM-ddTHH:mm:ss.FFFFFFF'. + public string DateTimeFormat { get; set; } = "yyyy-MM-ddTHH:mm:ss.FFFFFFF"; + + /// + /// Gets or sets the format. + /// + /// Defaults to 'yyyy-MM-ddTHH:mm:ss.FFFFFFFZ'. + public string DateTimeOffsetFormat { get; set; } = "yyyy-MM-ddTHH:mm:ss.FFFFFFFZ"; + + /// + /// Gets or sets the format. + /// + /// Defaults to 'yyyy-MM-dd'. + public string DateOnlyFormat { get; set; } = "yyyy-MM-dd"; + + /// + /// Gets or sets the format. + /// + /// Defaults to 'HH:mm:ss.FFFFFFF'. + public string TimeOnlyFormat { get; set; } = "HH:mm:ss.FFFFFFF"; + + /// + /// Gets or sets the reference data column defaults dictionary. + /// + /// The list should contain the column name and function that returns the default value (the input to the function is the corresponding row count as specified). + public Dictionary> RefDataColumnDefaults { get; } = []; + + /// + /// Adds a reference data column default to the . + /// + /// The column name. + /// The function that provides the default value. + /// The to support fluent-style method-chaining. + public DataParserArgs RefDataColumnDefault(string column, Func @default) + { + RefDataColumnDefaults.Add(column, @default); + return this; + } + + /// + /// Gets or sets the column defaults collection. + /// + /// The list should contain the column name and function that returns the default value (the input to the function is the corresponding row count as specified). + public DataParserColumnDefaultCollection ColumnDefaults { get; } = []; + + /// + /// Adds a to the . + /// + /// The schema name; a '*' denotes any schema. + /// The table name; a '*' denotes any table. + /// The name of the column to be updated. + /// The function that provides the default value. + /// The to support fluent-style method-chaining. + public DataParserArgs ColumnDefault(string schema, string table, string column, Func @default) + { + ColumnDefaults.Add(new DataParserColumnDefault(schema, table, column, @default)); + return this; + } + + /// + /// Gets the runtime parameters. + /// + public Dictionary Parameters { get; } = []; + + /// + /// Adds a parameter to the where it does not already exist; unless is selected then it will add or override. + /// + /// The parameter key. + /// The parameter value. + /// Indicates whether to override the existing value where it is pre-existing; otherwise, will not add/update. + /// The to support fluent-style method-chaining. + public DataParserArgs Parameter(string key, object? value, bool overrideExisting = false) + { + if (!Parameters.TryAdd(key, value) && overrideExisting) + Parameters[key] = value; + + return this; + } + + /// + /// Gets or sets the updater. + /// + /// This is invoked offering an opportunity to further update (manipulate) the selected from the database using the . + public Func, CancellationToken, Task>>? DbSchemaUpdaterAsync { get; set; } + + /// + /// Gets the . + /// + public DataParserTableNameMappings TableNameMappings { get; } = []; + + /// + /// Indiates whether to replace '^n' values where 'n' is an integer with a equivalent; e.g. '^1' will be '00000001-0000-0000-0000-000000000000' + /// + public bool ReplaceShorthandGuids { get; set; } = true; + + /// + /// Copy and replace from . /// - public class DataParserArgs + /// The to copy from. + public void CopyFrom(DataParserArgs args) { - /// - /// Initializes a new instance of the class. - /// - public DataParserArgs() { } - - /// - /// Initializes a new instance of the class with a pre-existing reference. - /// - /// The parameters reference. - public DataParserArgs(Dictionary parameters) => Parameters = parameters; - - /// - /// Gets or sets the user name. - /// - /// Defaults to '/'. - public string UserName { get; set; } = Environment.UserDomainName == null ? Environment.UserName : $"{Environment.UserDomainName}\\{Environment.UserName}"; - - /// - /// Gets or sets the current . - /// - /// Defaults to . - public DateTimeOffset DateTimeNow { get; set; } = DateTimeOffset.UtcNow; - - /// - /// Gets or sets the . - /// - /// Defaults to . - public IIdentifierGenerator IdentifierGenerator { get; set; } = new GuidIdentifierGenerator(); - - /// - /// Gets or sets the format. - /// - /// Defaults to 'yyyy-MM-ddTHH:mm:ss.FFFFFFF'. - public string DateTimeFormat { get; set; } = "yyyy-MM-ddTHH:mm:ss.FFFFFFF"; - - /// - /// Gets or sets the format. - /// - /// Defaults to 'yyyy-MM-ddTHH:mm:ss.FFFFFFFZ'. - public string DateTimeOffsetFormat { get; set; } = "yyyy-MM-ddTHH:mm:ss.FFFFFFFZ"; - - /// - /// Gets or sets the format. - /// - /// Defaults to 'yyyy-MM-dd'. - public string DateOnlyFormat { get; set; } = "yyyy-MM-dd"; - - /// - /// Gets or sets the format. - /// - /// Defaults to 'HH:mm:ss.FFFFFFF'. - public string TimeOnlyFormat { get; set; } = "HH:mm:ss.FFFFFFF"; - - /// - /// Gets or sets the reference data column defaults dictionary. - /// - /// The list should contain the column name and function that returns the default value (the input to the function is the corresponding row count as specified). - public Dictionary> RefDataColumnDefaults { get; } = []; - - /// - /// Adds a reference data column default to the . - /// - /// The column name. - /// The function that provides the default value. - /// The to support fluent-style method-chaining. - public DataParserArgs RefDataColumnDefault(string column, Func @default) - { - RefDataColumnDefaults.Add(column, @default); - return this; - } - - /// - /// Gets or sets the column defaults collection. - /// - /// The list should contain the column name and function that returns the default value (the input to the function is the corresponding row count as specified). - public DataParserColumnDefaultCollection ColumnDefaults { get; } = []; - - /// - /// Adds a to the . - /// - /// The schema name; a '*' denotes any schema. - /// The table name; a '*' denotes any table. - /// The name of the column to be updated. - /// The function that provides the default value. - /// The to support fluent-style method-chaining. - public DataParserArgs ColumnDefault(string schema, string table, string column, Func @default) - { - ColumnDefaults.Add(new DataParserColumnDefault(schema, table, column, @default)); - return this; - } - - /// - /// Gets the runtime parameters. - /// - public Dictionary Parameters { get; } = []; - - /// - /// Adds a parameter to the where it does not already exist; unless is selected then it will add or override. - /// - /// The parameter key. - /// The parameter value. - /// Indicates whether to override the existing value where it is pre-existing; otherwise, will not add/update. - /// The to support fluent-style method-chaining. - public DataParserArgs Parameter(string key, object? value, bool overrideExisting = false) - { - if (!Parameters.TryAdd(key, value) && overrideExisting) - Parameters[key] = value; - - return this; - } - - /// - /// Gets or sets the updater. - /// - /// This is invoked offering an opportunity to further update (manipulate) the selected from the database using the . - public Func, CancellationToken, Task>>? DbSchemaUpdaterAsync { get; set; } - - /// - /// Gets the . - /// - public DataParserTableNameMappings TableNameMappings { get; } = []; - - /// - /// Indiates whether to replace '^n' values where 'n' is an integer with a equivalent; e.g. '^1' will be '00000001-0000-0000-0000-000000000000' - /// - public bool ReplaceShorthandGuids { get; set; } = true; - - /// - /// Copy and replace from . - /// - /// The to copy from. - public void CopyFrom(DataParserArgs args) - { - args.ThrowIfNull(nameof(args)); - - UserName = args.UserName; - DateTimeNow = args.DateTimeNow; - IdentifierGenerator = args.IdentifierGenerator; - DateTimeFormat = args.DateTimeFormat; - DateTimeOffsetFormat = args.DateTimeOffsetFormat; - DateOnlyFormat = args.DateOnlyFormat; - TimeOnlyFormat = args.TimeOnlyFormat; - DbSchemaUpdaterAsync = args.DbSchemaUpdaterAsync; - RefDataColumnDefaults.Clear(); - args.RefDataColumnDefaults.ForEach(x => RefDataColumnDefaults.Add(x.Key, x.Value)); - ColumnDefaults.Clear(); - args.ColumnDefaults.ForEach(ColumnDefaults.Add); - Parameters.Clear(); - args.Parameters.ForEach(x => Parameters.Add(x.Key, x.Value)); - TableNameMappings.Clear(); - args.TableNameMappings.ForEach(x => TableNameMappings.Add(x.Key.ParsedSchema, x.Key.ParsedTable, x.Value.Schema, x.Value.Table, x.Value.ColumnMappings)); - ReplaceShorthandGuids = args.ReplaceShorthandGuids; - } + args.ThrowIfNull(nameof(args)); + + UserName = args.UserName; + DateTimeNow = args.DateTimeNow; + IdentifierGenerator = args.IdentifierGenerator; + DateTimeFormat = args.DateTimeFormat; + DateTimeOffsetFormat = args.DateTimeOffsetFormat; + DateOnlyFormat = args.DateOnlyFormat; + TimeOnlyFormat = args.TimeOnlyFormat; + DbSchemaUpdaterAsync = args.DbSchemaUpdaterAsync; + RefDataColumnDefaults.Clear(); + args.RefDataColumnDefaults.ForEach(x => RefDataColumnDefaults.Add(x.Key, x.Value)); + ColumnDefaults.Clear(); + args.ColumnDefaults.ForEach(ColumnDefaults.Add); + Parameters.Clear(); + args.Parameters.ForEach(x => Parameters.Add(x.Key, x.Value)); + TableNameMappings.Clear(); + args.TableNameMappings.ForEach(x => TableNameMappings.Add(x.Key.ParsedSchema, x.Key.ParsedTable, x.Value.Schema, x.Value.Table, x.Value.ColumnMappings)); + ReplaceShorthandGuids = args.ReplaceShorthandGuids; } } \ No newline at end of file diff --git a/src/DbEx/Migration/Data/DataParserColumnDefault.cs b/src/DbEx/Migration/Data/DataParserColumnDefault.cs index d9c2826..b792a1c 100644 --- a/src/DbEx/Migration/Data/DataParserColumnDefault.cs +++ b/src/DbEx/Migration/Data/DataParserColumnDefault.cs @@ -1,36 +1,31 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration.Data; -using System; - -namespace DbEx.Migration.Data +/// +/// Provides the configuration. +/// +/// The schema name; a '*' denotes any schema. +/// The table name; a '*' denotes any table. +/// The name of the column to be updated. +/// The function that provides the default value. +public class DataParserColumnDefault(string schema, string table, string column, Func @default) { /// - /// Provides the configuration. + /// Gets the schema name; a '*' denotes any schema. /// - /// The schema name; a '*' denotes any schema. - /// The table name; a '*' denotes any table. - /// The name of the column to be updated. - /// The function that provides the default value. - public class DataParserColumnDefault(string schema, string table, string column, Func @default) - { - /// - /// Gets the schema name; a '*' denotes any schema. - /// - public string Schema { get; } = schema.ThrowIfNull(nameof(schema)); + public string Schema { get; } = schema.ThrowIfNull(nameof(schema)); - /// - /// Gets the table name; a '*' denotes any table. - /// - public string Table { get; } = table.ThrowIfNull(nameof(table)); + /// + /// Gets the table name; a '*' denotes any table. + /// + public string Table { get; } = table.ThrowIfNull(nameof(table)); - /// - /// Gets the column name. - /// - public string Column { get; } = column.ThrowIfNull(nameof(column)); + /// + /// Gets the column name. + /// + public string Column { get; } = column.ThrowIfNull(nameof(column)); - /// - /// Gets the function that provides the default value. - /// - public Func Default { get; } = @default.ThrowIfNull(nameof(@default)); - } + /// + /// Gets the function that provides the default value. + /// + public Func Default { get; } = @default.ThrowIfNull(nameof(@default)); } \ No newline at end of file diff --git a/src/DbEx/Migration/Data/DataParserColumnDefaultCollection.cs b/src/DbEx/Migration/Data/DataParserColumnDefaultCollection.cs index b631d0b..5e2e899 100644 --- a/src/DbEx/Migration/Data/DataParserColumnDefaultCollection.cs +++ b/src/DbEx/Migration/Data/DataParserColumnDefaultCollection.cs @@ -1,70 +1,62 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration.Data; -using DbEx.DbSchema; -using System; -using System.Collections.ObjectModel; -using System.Diagnostics.CodeAnalysis; - -namespace DbEx.Migration.Data +/// +/// Provides a keyed collection. +/// +public class DataParserColumnDefaultCollection : KeyedCollection<(string, string, string), DataParserColumnDefault> { + /// + protected override (string, string, string) GetKeyForItem(DataParserColumnDefault item) => (item.Schema, item.Table, item.Column); + /// - /// Provides a keyed collection. + /// Attempts to get the for the specified , and names. /// - public class DataParserColumnDefaultCollection : KeyedCollection<(string, string, string), DataParserColumnDefault> + /// The schema name. + /// The table name. + /// The column name. + /// The corresponding item where found; otherwise, null. + /// true where found; otherwise, false. + /// Attempts to match as follows: + /// + /// Schema, table and column names match item exactly; + /// Schema and column names match item exactly, and the underlying default table name is configured with '*'; + /// Column names match item exactly, and the underlying default schema and table names are both configured with '*'; + /// Item is not found. + /// + /// + public bool TryGetValue(string schema, string table, string column, [NotNullWhen(true)] out DataParserColumnDefault? item) { - /// - protected override (string, string, string) GetKeyForItem(DataParserColumnDefault item) => (item.Schema, item.Table, item.Column); - - /// - /// Attempts to get the for the specified , and names. - /// - /// The schema name. - /// The table name. - /// The column name. - /// The corresponding item where found; otherwise, null. - /// true where found; otherwise, false. - /// Attempts to match as follows: - /// - /// Schema, table and column names match item exactly; - /// Schema and column names match item exactly, and the underlying default table name is configured with '*'; - /// Column names match item exactly, and the underlying default schema and table names are both configured with '*'; - /// Item is not found. - /// - /// - public bool TryGetValue(string schema, string table, string column, [NotNullWhen(true)] out DataParserColumnDefault? item) - { - schema.ThrowIfNull(nameof(schema)); - table.ThrowIfNull(nameof(table)); - column.ThrowIfNull(nameof(column)); + schema.ThrowIfNull(nameof(schema)); + table.ThrowIfNull(nameof(table)); + column.ThrowIfNull(nameof(column)); - if (TryGetValue((schema, table, column), out item)) - return true; + if (TryGetValue((schema, table, column), out item)) + return true; - if (TryGetValue((schema, "*", column), out item)) - return true; + if (TryGetValue((schema, "*", column), out item)) + return true; - if (TryGetValue(("*", "*", column), out item)) - return true; + if (TryGetValue(("*", "*", column), out item)) + return true; - item = null; - return false; - } + item = null; + return false; + } - /// - /// Get all the configured column defaults for the specified . - /// - /// The . - /// The configured defaults. - public DataParserColumnDefaultCollection GetDefaultsForTable(DbTableSchema table) + /// + /// Get all the configured column defaults for the specified . + /// + /// The . + /// The configured defaults. + public DataParserColumnDefaultCollection GetDefaultsForTable(DbTableSchema table) + { + var dc = new DataParserColumnDefaultCollection(); + foreach (var c in table.ThrowIfNull(nameof(table)).Columns) { - var dc = new DataParserColumnDefaultCollection(); - foreach (var c in table.ThrowIfNull(nameof(table)).Columns) - { - if (TryGetValue(table.Schema, table.Name, c.Name, out var item)) - dc.Add(item); - } - - return dc; + if (TryGetValue(table.Schema, table.Name, c.Name, out var item)) + dc.Add(item); } + + return dc; } } \ No newline at end of file diff --git a/src/DbEx/Migration/Data/DataParserException.cs b/src/DbEx/Migration/Data/DataParserException.cs index 7bceec5..248e978 100644 --- a/src/DbEx/Migration/Data/DataParserException.cs +++ b/src/DbEx/Migration/Data/DataParserException.cs @@ -1,30 +1,25 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration.Data; -using System; - -namespace DbEx.Migration.Data +/// +/// Represents a exception. +/// +public class DataParserException : Exception { /// - /// Represents a exception. + /// Initializes a new instance of the class. /// - public class DataParserException : Exception - { - /// - /// Initializes a new instance of the class. - /// - public DataParserException() { } + public DataParserException() { } - /// - /// Initializes a new instance of the class with a specified . - /// - /// The message. - public DataParserException(string message) : base(message) { } + /// + /// Initializes a new instance of the class with a specified . + /// + /// The message. + public DataParserException(string message) : base(message) { } - /// - /// Initializes a new instance of the class with a specified and . - /// - /// The message. - /// The inner . - public DataParserException(string message, Exception innerException) : base(message, innerException) { } - } + /// + /// Initializes a new instance of the class with a specified and . + /// + /// The message. + /// The inner . + public DataParserException(string message, Exception innerException) : base(message, innerException) { } } \ No newline at end of file diff --git a/src/DbEx/Migration/Data/DataParserTableNameMappings.cs b/src/DbEx/Migration/Data/DataParserTableNameMappings.cs index 82aae56..0324392 100644 --- a/src/DbEx/Migration/Data/DataParserTableNameMappings.cs +++ b/src/DbEx/Migration/Data/DataParserTableNameMappings.cs @@ -1,70 +1,63 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration.Data; -using System; -using System.Collections; -using System.Collections.Generic; - -namespace DbEx.Migration.Data +/// +/// Provides schema, table and column name mappings. +/// +public class DataParserTableNameMappings : IEnumerable? ColumnMappings)>> { + private readonly Dictionary<(string, string), (string, string, Dictionary?)> _dict = []; + /// - /// Provides schema, table and column name mappings. + /// Adds a schema, table and column(s) mapping. /// - public class DataParserTableNameMappings : IEnumerable? ColumnMappings)>> + /// The parsed schema name. + /// The parsed table name. + /// The mapped database schema name. + /// The mapped database table name. + /// The optional parsed and mapped database column names. + /// The instance to support fluent-style method-chaining. + public DataParserTableNameMappings Add(string? parsedSchema, string parsedTable, string? schema, string table, Dictionary? columnMappings = null) { - private readonly Dictionary<(string, string), (string, string, Dictionary?)> _dict = []; - - /// - /// Adds a schema, table and column(s) mapping. - /// - /// The parsed schema name. - /// The parsed table name. - /// The mapped database schema name. - /// The mapped database table name. - /// The optional parsed and mapped database column names. - /// The instance to support fluent-style method-chaining. - public DataParserTableNameMappings Add(string? parsedSchema, string parsedTable, string? schema, string table, Dictionary? columnMappings = null) - { - _dict.Add((EmptyWhereNull(parsedSchema), parsedTable), (EmptyWhereNull(schema), table, columnMappings)); - return this; - } + _dict.Add((EmptyWhereNull(parsedSchema), parsedTable), (EmptyWhereNull(schema), table, columnMappings)); + return this; + } - /// - /// Adds column(s) mappings. - /// - /// The mapped database schema name. - /// The mapped database table name. - /// The parsed and mapped database column names. - /// The instance to support fluent-style method-chaining. - public DataParserTableNameMappings Add(string? schema, string table, Dictionary columnMappings) - { - _dict.Add((EmptyWhereNull(schema), table), (EmptyWhereNull(schema), table, columnMappings.ThrowIfNull(nameof(columnMappings)))); - return this; - } + /// + /// Adds column(s) mappings. + /// + /// The mapped database schema name. + /// The mapped database table name. + /// The parsed and mapped database column names. + /// The instance to support fluent-style method-chaining. + public DataParserTableNameMappings Add(string? schema, string table, Dictionary columnMappings) + { + _dict.Add((EmptyWhereNull(schema), table), (EmptyWhereNull(schema), table, columnMappings.ThrowIfNull(nameof(columnMappings)))); + return this; + } - /// - /// Gets the table mapping. - /// - /// The parsed schema name. - /// The parsed table name. - /// The mapped database schema, table and column name mappings. - public (string Schema, string Table, IDictionary? ColumnMappings) Get(string? parsedSchema, string parsedTable) - => _dict.TryGetValue((EmptyWhereNull(parsedSchema), parsedTable), out var value) ? value : new (EmptyWhereNull(parsedSchema), parsedTable, null); + /// + /// Gets the table mapping. + /// + /// The parsed schema name. + /// The parsed table name. + /// The mapped database schema, table and column name mappings. + public (string Schema, string Table, IDictionary? ColumnMappings) Get(string? parsedSchema, string parsedTable) + => _dict.TryGetValue((EmptyWhereNull(parsedSchema), parsedTable), out var value) ? value : new (EmptyWhereNull(parsedSchema), parsedTable, null); - /// - /// Empties the value where null. - /// - private static string EmptyWhereNull(string? value) => value ?? string.Empty; + /// + /// Empties the value where null. + /// + private static string EmptyWhereNull(string? value) => value ?? string.Empty; - /// - /// Removes all mappings. - /// - public void Clear() => _dict.Clear(); + /// + /// Removes all mappings. + /// + public void Clear() => _dict.Clear(); - /// - IEnumerator? ColumnMappings)>> IEnumerable? ColumnMappings)>>.GetEnumerator() - => _dict.GetEnumerator(); + /// + IEnumerator? ColumnMappings)>> IEnumerable? ColumnMappings)>>.GetEnumerator() + => _dict.GetEnumerator(); - /// - IEnumerator IEnumerable.GetEnumerator() => _dict.GetEnumerator(); - } + /// + IEnumerator IEnumerable.GetEnumerator() => _dict.GetEnumerator(); } \ No newline at end of file diff --git a/src/DbEx/Migration/Data/DataRow.cs b/src/DbEx/Migration/Data/DataRow.cs index a6b364f..70df979 100644 --- a/src/DbEx/Migration/Data/DataRow.cs +++ b/src/DbEx/Migration/Data/DataRow.cs @@ -1,174 +1,167 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration.Data; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace DbEx.Migration.Data +/// +/// Represents the database relational data row. +/// +public class DataRow { /// - /// Represents the database relational data row. + /// Initializes a new instance of the class. + /// + /// The parent . + internal DataRow(DataTable table) => Table = table.ThrowIfNull(nameof(table)); + + /// + /// Gets the . + /// + public DataTable Table { get; } + + /// + /// Gets the columns. + /// + public List Columns { get; } = []; + + /// + /// Gets the insert columns. + /// + public List InsertColumns => [.. Columns.Where(c => Table.InsertColumns.Any(x => x.Name == c.Name))]; + + /// + /// Gets the columns that are used for the merge insert. + /// + public List MergeInsertColumns => [.. Columns.Where(c => Table.MergeInsertColumns.Any(x => x.Name == c.Name))]; + + /// + /// Gets the columns that are used for the merge update. + /// + public List MergeUpdateColumns => [.. Columns.Where(c => Table.MergeUpdateColumns.Any(x => x.Name == c.Name))]; + + /// + /// Adds a to the row using the specified name and value. + /// + /// The column name. + /// The column value. + public void AddColumn(string name, object? value) => AddColumn(new DataColumn(Table, name) { Value = value }); + + /// + /// Adds a to the row. /// - public class DataRow + /// The . + public void AddColumn(DataColumn column) { - /// - /// Initializes a new instance of the class. - /// - /// The parent . - internal DataRow(DataTable table) => Table = table.ThrowIfNull(nameof(table)); - - /// - /// Gets the . - /// - public DataTable Table { get; } - - /// - /// Gets the columns. - /// - public List Columns { get; } = []; - - /// - /// Gets the insert columns. - /// - public List InsertColumns => [.. Columns.Where(c => Table.InsertColumns.Any(x => x.Name == c.Name))]; - - /// - /// Gets the columns that are used for the merge insert. - /// - public List MergeInsertColumns => [.. Columns.Where(c => Table.MergeInsertColumns.Any(x => x.Name == c.Name))]; - - /// - /// Gets the columns that are used for the merge update. - /// - public List MergeUpdateColumns => [.. Columns.Where(c => Table.MergeUpdateColumns.Any(x => x.Name == c.Name))]; - - /// - /// Adds a to the row using the specified name and value. - /// - /// The column name. - /// The column value. - public void AddColumn(string name, object? value) => AddColumn(new DataColumn(Table, name) { Value = value }); - - /// - /// Adds a to the row. - /// - /// The . - public void AddColumn(DataColumn column) + column.ThrowIfNull(nameof(column)); + if (string.IsNullOrEmpty(column.Name)) + throw new ArgumentException("Column.Name must have a value.", nameof(column)); + + var col = Table.DbTable.Columns.Where(c => c.Name == column.Name).SingleOrDefault(); + if (col == null) { - column.ThrowIfNull(nameof(column)); - if (string.IsNullOrEmpty(column.Name)) - throw new ArgumentException("Column.Name must have a value.", nameof(column)); + // Check and see if it is a reference data id. + col = Table.DbTable.Columns.Where(c => c.Name == column.Name + Table.DbTable.Migration.Args.IdColumnNameSuffix!).SingleOrDefault(); + if (col == null || !col.IsForeignRefData) + throw new DataParserException($"Table {Table.SchemaTableName} does not have a column named '{column.Name}' or '{column.Name}{Table.DbTable.Migration.Args.IdColumnNameSuffix!}'; or was not identified as a foreign key to Reference Data."); - var col = Table.DbTable.Columns.Where(c => c.Name == column.Name).SingleOrDefault(); - if (col == null) - { - // Check and see if it is a reference data id. - col = Table.DbTable.Columns.Where(c => c.Name == column.Name + Table.DbTable.Migration.Args.IdColumnNameSuffix!).SingleOrDefault(); - if (col == null || !col.IsForeignRefData) - throw new DataParserException($"Table {Table.SchemaTableName} does not have a column named '{column.Name}' or '{column.Name}{Table.DbTable.Migration.Args.IdColumnNameSuffix!}'; or was not identified as a foreign key to Reference Data."); + column.Name += Table.DbTable.Migration.Args.IdColumnNameSuffix!; + } - column.Name += Table.DbTable.Migration.Args.IdColumnNameSuffix!; - } + if (Columns.Any(x => x.Name == column.Name)) + throw new DataParserException($"Table {Table.SchemaTableName} column '{column.Name}' has been specified more than once."); - if (Columns.Any(x => x.Name == column.Name)) - throw new DataParserException($"Table {Table.SchemaTableName} column '{column.Name}' has been specified more than once."); + column.DbColumn = col; + Columns.Add(column); - column.DbColumn = col; - Columns.Add(column); + if (column.Value == null) + return; - if (column.Value == null) - return; + string? str = null; + try + { + str = column.Table.DbTable.Migration.SchemaConfig.ToFormattedDataParserValue(column.Table.ParserArgs, column.Value); - string? str = null; - try + switch (col.DotNetType) { - str = column.Table.DbTable.Migration.SchemaConfig.ToFormattedDataParserValue(column.Table.ParserArgs, column.Value); - - switch (col.DotNetType) - { - case "string": column.Value = str; break; - case "bool": column.Value = str switch { "1" or "Y" => true, "0" or "N" or "" => false, _ => bool.Parse(str) }; break; - case "DateTime": column.Value = string.IsNullOrEmpty(str) ? DateTime.MinValue : DateTime.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; - case "DateTimeOffset": column.Value = string.IsNullOrEmpty(str) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; - case "decimal": column.Value = string.IsNullOrEmpty(str) ? 0m : decimal.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; - case "double": column.Value = string.IsNullOrEmpty(str) ? 0d : double.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; - case "short": column.Value = string.IsNullOrEmpty(str) ? (short)0 : short.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; - case "ushort": column.Value = string.IsNullOrEmpty(str) ? (ushort)0 : ushort.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; - case "uint": column.Value = string.IsNullOrEmpty(str) ? 0 : uint.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; - case "ulong": column.Value = string.IsNullOrEmpty(str) ? 0 : ulong.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; - case "byte": column.Value = string.IsNullOrEmpty(str) ? byte.MinValue : byte.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; - case "float": column.Value = string.IsNullOrEmpty(str) ? 0f : float.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; - case "byte[]": column.Value = string.IsNullOrEmpty(str) ? [] : Convert.FromBase64String(str); break; - case "TimeSpan": column.Value = string.IsNullOrEmpty(str) ? TimeSpan.Zero : TimeSpan.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; + case "string": column.Value = str; break; + case "bool": column.Value = str switch { "1" or "Y" => true, "0" or "N" or "" => false, _ => bool.Parse(str) }; break; + case "DateTime": column.Value = string.IsNullOrEmpty(str) ? DateTime.MinValue : DateTime.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; + case "DateTimeOffset": column.Value = string.IsNullOrEmpty(str) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; + case "decimal": column.Value = string.IsNullOrEmpty(str) ? 0m : decimal.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; + case "double": column.Value = string.IsNullOrEmpty(str) ? 0d : double.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; + case "short": column.Value = string.IsNullOrEmpty(str) ? (short)0 : short.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; + case "ushort": column.Value = string.IsNullOrEmpty(str) ? (ushort)0 : ushort.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; + case "uint": column.Value = string.IsNullOrEmpty(str) ? 0 : uint.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; + case "ulong": column.Value = string.IsNullOrEmpty(str) ? 0 : ulong.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; + case "byte": column.Value = string.IsNullOrEmpty(str) ? byte.MinValue : byte.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; + case "float": column.Value = string.IsNullOrEmpty(str) ? 0f : float.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; + case "byte[]": column.Value = string.IsNullOrEmpty(str) ? [] : Convert.FromBase64String(str); break; + case "TimeSpan": column.Value = string.IsNullOrEmpty(str) ? TimeSpan.Zero : TimeSpan.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; #if NET7_0_OR_GREATER - case "DateOnly": column.Value = string.IsNullOrEmpty(str) ? DateOnly.MinValue : DateOnly.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; - case "TimeOnly": column.Value = string.IsNullOrEmpty(str) ? TimeOnly.MinValue : TimeOnly.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; + case "DateOnly": column.Value = string.IsNullOrEmpty(str) ? DateOnly.MinValue : DateOnly.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; + case "TimeOnly": column.Value = string.IsNullOrEmpty(str) ? TimeOnly.MinValue : TimeOnly.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; #endif - case "int": - if (int.TryParse(str, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int i)) - column.Value = i; - else if (string.IsNullOrEmpty(str)) - column.Value = 0; + case "int": + if (int.TryParse(str, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int i)) + column.Value = i; + else if (string.IsNullOrEmpty(str)) + column.Value = 0; + else if (col.IsForeignRefData) + { + column.Value = str; + column.UseForeignKeyQueryForId = true; + } + else + throw new DataParserException($"Table {Table.SchemaTableName} column '{column.Name}' value '{str}' is not of Type '{typeof(int).Name}' or column is not a reference data foreign key."); + + break; + + case "long": + if (long.TryParse(str, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out long l)) + column.Value = l; + else if (string.IsNullOrEmpty(str)) + column.Value = 0L; + else if (col.IsForeignRefData) + { + column.Value = str; + column.UseForeignKeyQueryForId = true; + } + else + throw new DataParserException($"Table {Table.SchemaTableName} column '{column.Name}' value '{str}' is not of Type '{typeof(long).Name}' or column is not a reference data foreign key."); + + break; + + case "Guid": + if (int.TryParse(str, out int a)) + column.Value = DataValueConverter.IntToGuid(a); + else if (string.IsNullOrEmpty(str)) + column.Value = Guid.Empty; + else + { + if (Guid.TryParse(str, out Guid g)) + column.Value = g; else if (col.IsForeignRefData) { column.Value = str; column.UseForeignKeyQueryForId = true; } else - throw new DataParserException($"Table {Table.SchemaTableName} column '{column.Name}' value '{str}' is not of Type '{typeof(int).Name}' or column is not a reference data foreign key."); + throw new DataParserException($"Table {Table.SchemaTableName} column '{column.Name}' value '{str}' is not of Type '{typeof(Guid).Name}' or column is not a reference data foreign key."); + } - break; + break; - case "long": - if (long.TryParse(str, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out long l)) - column.Value = l; - else if (string.IsNullOrEmpty(str)) - column.Value = 0L; - else if (col.IsForeignRefData) - { - column.Value = str; - column.UseForeignKeyQueryForId = true; - } - else - throw new DataParserException($"Table {Table.SchemaTableName} column '{column.Name}' value '{str}' is not of Type '{typeof(long).Name}' or column is not a reference data foreign key."); - - break; - - case "Guid": - if (int.TryParse(str, out int a)) - column.Value = DataValueConverter.IntToGuid(a); - else if (string.IsNullOrEmpty(str)) - column.Value = Guid.Empty; - else - { - if (Guid.TryParse(str, out Guid g)) - column.Value = g; - else if (col.IsForeignRefData) - { - column.Value = str; - column.UseForeignKeyQueryForId = true; - } - else - throw new DataParserException($"Table {Table.SchemaTableName} column '{column.Name}' value '{str}' is not of Type '{typeof(Guid).Name}' or column is not a reference data foreign key."); - } - - break; - - default: - throw new DataParserException($"Table {Table.SchemaTableName} column '{column.Name}' type '{col.Type}' is not supported."); - } + default: + throw new DataParserException($"Table {Table.SchemaTableName} column '{column.Name}' type '{col.Type}' is not supported."); } - catch (FormatException fex) + } + catch (FormatException fex) + { + if (col.IsForeignRefData) { - if (col.IsForeignRefData) - { - column.Value = str; - column.UseForeignKeyQueryForId = true; - } - else - throw new DataParserException($"{Table.SchemaTableName} column '{column.Name}' type '{col.Type}' cannot parse value '{column.Value}': {fex.Message}"); + column.Value = str; + column.UseForeignKeyQueryForId = true; } + else + throw new DataParserException($"{Table.SchemaTableName} column '{column.Name}' type '{col.Type}' cannot parse value '{column.Value}': {fex.Message}"); } } } \ No newline at end of file diff --git a/src/DbEx/Migration/Data/DataTable.cs b/src/DbEx/Migration/Data/DataTable.cs index fc5cd34..c4efbad 100644 --- a/src/DbEx/Migration/Data/DataTable.cs +++ b/src/DbEx/Migration/Data/DataTable.cs @@ -1,281 +1,271 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration.Data; -using DbEx.DbSchema; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.Migration.Data +/// +/// Represents a database relational data table. +/// +public class DataTable { /// - /// Represents a database relational data table. + /// Initializes a new instance of the class. /// - public class DataTable + /// The owning (parent) . + /// The schema name. + /// The table name. + internal DataTable(DataParser parser, string schema, string name) { - /// - /// Initializes a new instance of the class. - /// - /// The owning (parent) . - /// The schema name. - /// The table name. - internal DataTable(DataParser parser, string schema, string name) - { - Parser = parser.ThrowIfNull(nameof(parser)); - name.ThrowIfNull(nameof(name)); + Parser = parser.ThrowIfNull(nameof(parser)); + name.ThrowIfNull(nameof(name)); - // Determine features by notation/convention. - if (name.StartsWith('$')) - { - IsMerge = true; - name = name[1..]; - } + // Determine features by notation/convention. + if (name.StartsWith('$')) + { + IsMerge = true; + name = name[1..]; + } - if (name.StartsWith('^')) - { - UseIdentifierGenerator = true; - name = name[1..]; - } + if (name.StartsWith('^')) + { + UseIdentifierGenerator = true; + name = name[1..]; + } - // Determine the schema, table and column name mappings. - var mappings = parser.ParserArgs.TableNameMappings.Get(schema, name); - schema = mappings.Schema; - name = mappings.Table; - ColumnNameMappings = mappings.ColumnMappings ?? new Dictionary(); + // Determine the schema, table and column name mappings. + var mappings = parser.ParserArgs.TableNameMappings.Get(schema, name); + schema = mappings.Schema; + name = mappings.Table; + ColumnNameMappings = mappings.ColumnMappings ?? new Dictionary(); - // Get the database table. - SchemaTableName = $"'{(schema == string.Empty ? name : $"{schema}.{name}")}'"; - DbTable = Parser.DbTables.Where(t => (!Parser.Migration.SchemaConfig.SupportsSchema || t.Schema == schema) && t.Name == name).SingleOrDefault() ?? - throw new DataParserException($"Table {SchemaTableName} does not exist within the specified database."); + // Get the database table. + SchemaTableName = $"'{(schema == string.Empty ? name : $"{schema}.{name}")}'"; + DbTable = Parser.DbTables.Where(t => (!Parser.Migration.SchemaConfig.SupportsSchema || t.Schema == schema) && t.Name == name).SingleOrDefault() ?? + throw new DataParserException($"Table {SchemaTableName} does not exist within the specified database."); - // Check that an identifier generator can be used. - if (UseIdentifierGenerator) - { - if (DbTable.PrimaryKeyColumns.Count == 1 && Enum.TryParse(DbTable.PrimaryKeyColumns[0].DotNetType, true, out var igType)) - IdentifierType = igType; - else - throw new DataParserException($"Table {SchemaTableName} specifies usage of {nameof(IIdentifierGenerator)}; either there is more than one column representing the primary key or the underlying type is not supported."); - } + // Check that an identifier generator can be used. + if (UseIdentifierGenerator) + { + if (DbTable.PrimaryKeyColumns.Count == 1 && Enum.TryParse(DbTable.PrimaryKeyColumns[0].DotNetType, true, out var igType)) + IdentifierType = igType; + else + throw new DataParserException($"Table {SchemaTableName} specifies usage of {nameof(IIdentifierGenerator)}; either there is more than one column representing the primary key or the underlying type is not supported."); } + } - /// - /// Gets the owning (parent) . - /// - public DataParser Parser { get; } - - /// - /// Gets the . - /// - public DataParserArgs ParserArgs => Parser.ParserArgs; - - /// - /// Gets the schema name. - /// - public string Schema => DbTable.Schema; - - /// - /// Gets the table name. - /// - public string Name => DbTable.Name; - - /// - /// Gets the full formatted schema and table name. - /// - public string SchemaTableName { get; } - - /// - /// Gets the underlying . - /// - public DbTableSchema DbTable { get; } - - /// - /// Indicates whether the table is reference data. - /// - public bool IsRefData => DbTable.IsRefData; - - /// - /// Indicates whether the table data is to be merged. - /// - public bool IsMerge { get; } - - /// - /// Indicates whether to use the identifier generator for the primary key (single column) on create (where not specified). - /// - public bool UseIdentifierGenerator { get; } - - /// - /// Gets the identifier generator (see ) . - /// - public DataTableIdentifierType? IdentifierType { get; } - - /// - /// Gets the columns. - /// - public List Columns { get; } = []; - - /// - /// Gets the insert columns. - /// - public List InsertColumns => [.. Columns.Where(x => !x.IsUpdatedAudit)]; - - /// - /// Gets the merge match columns. - /// - public List MergeMatchColumns => [.. Columns.Where(x => !x.IsCreatedAudit && !x.IsUpdatedAudit && !(UseIdentifierGenerator && x.IsPrimaryKey))]; - - /// - /// Gets the merge insert columns. - /// - public List MergeInsertColumns => [.. Columns.Where(x => !x.IsUpdatedAudit)]; - - /// - /// Gets the merge update columns. - /// - public List MergeUpdateColumns => [.. Columns.Where(x => !x.IsCreatedAudit)]; - - /// - /// Gets the primary key columns. - /// - public List PrimaryKeyColumns => [.. Columns.Where(x => x.IsPrimaryKey)]; - - /// - /// Gets the rows. - /// - public List Rows { get; } = []; - - /// - /// Gets the column name mappings (from the where specified). - /// - public IDictionary? ColumnNameMappings { get; } - - /// - /// Gets the formatted pre-condition SQL. - /// - public string? PreConditionSql { get; private set; } - - /// - /// Gets the formatted pre-SQL. - /// - public string? PreSql { get; private set; } - - /// - /// Gets the formatted post-SQL. - /// - public string? PostSql { get; private set; } - - /// - /// Adds a row (key value pairs of column name and corresponding value). - /// - /// The row. - public void AddRow(DataRow row) - { - row.ThrowIfNull(nameof(row)); + /// + /// Gets the owning (parent) . + /// + public DataParser Parser { get; } - foreach (var c in row.Columns) - { - AddColumn(c.Name!); - } + /// + /// Gets the . + /// + public DataParserArgs ParserArgs => Parser.ParserArgs; - Rows.Add(row); - } + /// + /// Gets the schema name. + /// + public string Schema => DbTable.Schema; - /// - /// Add to specified columns. - /// - private void AddColumn(string name) - { - var column = DbTable.Columns.Where(x => x.Name == name).SingleOrDefault(); - if (column == null) - return; + /// + /// Gets the table name. + /// + public string Name => DbTable.Name; - if (!Columns.Any(x => x.Name == name)) - Columns.Add(column); - } + /// + /// Gets the full formatted schema and table name. + /// + public string SchemaTableName { get; } + + /// + /// Gets the underlying . + /// + public DbTableSchema DbTable { get; } + + /// + /// Indicates whether the table is reference data. + /// + public bool IsRefData => DbTable.IsRefData; + + /// + /// Indicates whether the table data is to be merged. + /// + public bool IsMerge { get; } + + /// + /// Indicates whether to use the identifier generator for the primary key (single column) on create (where not specified). + /// + public bool UseIdentifierGenerator { get; } + + /// + /// Gets the identifier generator (see ) . + /// + public DataTableIdentifierType? IdentifierType { get; } + + /// + /// Gets the columns. + /// + public List Columns { get; } = []; + + /// + /// Gets the insert columns. + /// + public List InsertColumns => [.. Columns.Where(x => !x.IsUpdatedAudit)]; + + /// + /// Gets the merge match columns. + /// + public List MergeMatchColumns => [.. Columns.Where(x => !x.IsCreatedAudit && !x.IsUpdatedAudit && !(UseIdentifierGenerator && x.IsPrimaryKey))]; + + /// + /// Gets the merge insert columns. + /// + public List MergeInsertColumns => [.. Columns.Where(x => !x.IsUpdatedAudit)]; + + /// + /// Gets the merge update columns. + /// + public List MergeUpdateColumns => [.. Columns.Where(x => !x.IsCreatedAudit)]; - /// - /// Prepares the data. - /// - internal async Task PrepareAsync(CancellationToken cancellationToken) + /// + /// Gets the primary key columns. + /// + public List PrimaryKeyColumns => [.. Columns.Where(x => x.IsPrimaryKey)]; + + /// + /// Gets the rows. + /// + public List Rows { get; } = []; + + /// + /// Gets the column name mappings (from the where specified). + /// + public IDictionary? ColumnNameMappings { get; } + + /// + /// Gets the formatted pre-condition SQL. + /// + public string? PreConditionSql { get; private set; } + + /// + /// Gets the formatted pre-SQL. + /// + public string? PreSql { get; private set; } + + /// + /// Gets the formatted post-SQL. + /// + public string? PostSql { get; private set; } + + /// + /// Adds a row (key value pairs of column name and corresponding value). + /// + /// The row. + public void AddRow(DataRow row) + { + row.ThrowIfNull(nameof(row)); + + foreach (var c in row.Columns) { - var cds = ParserArgs.ColumnDefaults.GetDefaultsForTable(DbTable); + AddColumn(c.Name!); + } - for (int i = 0; i < Rows.Count; i++) - { - var row = Rows[i]; + Rows.Add(row); + } + + /// + /// Add to specified columns. + /// + private void AddColumn(string name) + { + var column = DbTable.Columns.Where(x => x.Name == name).SingleOrDefault(); + if (column == null) + return; + + if (!Columns.Any(x => x.Name == name)) + Columns.Add(column); + } + + /// + /// Prepares the data. + /// + internal async Task PrepareAsync(CancellationToken cancellationToken) + { + var cds = ParserArgs.ColumnDefaults.GetDefaultsForTable(DbTable); + + for (int i = 0; i < Rows.Count; i++) + { + var row = Rows[i]; - // Apply the configured auditing defaults. - await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.CreatedOnColumnName!, () => Task.FromResult(ParserArgs.DateTimeNow)).ConfigureAwait(false); - await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.CreatedByColumnName!, () => Task.FromResult(ParserArgs.UserName)).ConfigureAwait(false); - await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.UpdatedOnColumnName!, () => Task.FromResult(ParserArgs.DateTimeNow)).ConfigureAwait(false); - await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.UpdatedByColumnName!, () => Task.FromResult(ParserArgs.UserName)).ConfigureAwait(false); + // Apply the configured auditing defaults. + await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.CreatedOnColumnName!, () => Task.FromResult(ParserArgs.DateTimeNow)).ConfigureAwait(false); + await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.CreatedByColumnName!, () => Task.FromResult(ParserArgs.UserName)).ConfigureAwait(false); + await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.UpdatedOnColumnName!, () => Task.FromResult(ParserArgs.DateTimeNow)).ConfigureAwait(false); + await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.UpdatedByColumnName!, () => Task.FromResult(ParserArgs.UserName)).ConfigureAwait(false); - // Apply an reference data defaults. - if (IsRefData && ParserArgs.RefDataColumnDefaults != null) + // Apply an reference data defaults. + if (IsRefData && ParserArgs.RefDataColumnDefaults != null) + { + foreach (var rdd in ParserArgs.RefDataColumnDefaults) { - foreach (var rdd in ParserArgs.RefDataColumnDefaults) - { - await AddColumnWhereNotSpecifiedAsync(row, rdd.Key, () => Task.FromResult(rdd.Value(i + 1))).ConfigureAwait(false); - } + await AddColumnWhereNotSpecifiedAsync(row, rdd.Key, () => Task.FromResult(rdd.Value(i + 1))).ConfigureAwait(false); } + } - // Generate the identifier where specified to do so. - if (UseIdentifierGenerator) + // Generate the identifier where specified to do so. + if (UseIdentifierGenerator) + { + var pkc = DbTable.PrimaryKeyColumns[0]; + var val = row.Columns.SingleOrDefault(x => x.Name == pkc.Name!); + if (val == null) { - var pkc = DbTable.PrimaryKeyColumns[0]; - var val = row.Columns.SingleOrDefault(x => x.Name == pkc.Name!); - if (val == null) + switch (IdentifierType) { - switch (IdentifierType) - { - case DataTableIdentifierType.Guid: - await AddColumnWhereNotSpecifiedAsync(row, pkc.Name!, async () => await ParserArgs.IdentifierGenerator.GenerateGuidIdentifierAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); - break; - - case DataTableIdentifierType.String: - await AddColumnWhereNotSpecifiedAsync(row, pkc.Name!, async () => await ParserArgs.IdentifierGenerator.GenerateStringIdentifierAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); - break; - - case DataTableIdentifierType.Int: - await AddColumnWhereNotSpecifiedAsync(row, pkc.Name!, async () => await ParserArgs.IdentifierGenerator.GenerateInt32IdentifierAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); - break; - - case DataTableIdentifierType.Long: - await AddColumnWhereNotSpecifiedAsync (row, pkc.Name!, async () => await ParserArgs.IdentifierGenerator.GenerateInt64IdentifierAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); - break; - } - } - } + case DataTableIdentifierType.Guid: + await AddColumnWhereNotSpecifiedAsync(row, pkc.Name!, async () => await ParserArgs.IdentifierGenerator.GenerateGuidIdentifierAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + break; - // Apply any configured column defaults. - foreach (var cd in cds) - { - await AddColumnWhereNotSpecifiedAsync(row, cd.Column, () => Task.FromResult(cd.Default(i + 1))).ConfigureAwait(false); + case DataTableIdentifierType.String: + await AddColumnWhereNotSpecifiedAsync(row, pkc.Name!, async () => await ParserArgs.IdentifierGenerator.GenerateStringIdentifierAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + break; + + case DataTableIdentifierType.Int: + await AddColumnWhereNotSpecifiedAsync(row, pkc.Name!, async () => await ParserArgs.IdentifierGenerator.GenerateInt32IdentifierAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + break; + + case DataTableIdentifierType.Long: + await AddColumnWhereNotSpecifiedAsync (row, pkc.Name!, async () => await ParserArgs.IdentifierGenerator.GenerateInt64IdentifierAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + break; + } } } - } - /// - /// Adds the column where not already specified. - /// - private async Task AddColumnWhereNotSpecifiedAsync(DataRow row, string name, Func> value) - { - if (DbTable.Columns.Any(x => x.Name == name) && !row.Columns.Any(x => x.Name == name)) + // Apply any configured column defaults. + foreach (var cd in cds) { - AddColumn(name); - row.AddColumn(name, await value().ConfigureAwait(false)); + await AddColumnWhereNotSpecifiedAsync(row, cd.Column, () => Task.FromResult(cd.Default(i + 1))).ConfigureAwait(false); } } + } - /// - /// Applys the . - /// - /// The . - internal void ApplyConfig(DataConfig? config) + /// + /// Adds the column where not already specified. + /// + private async Task AddColumnWhereNotSpecifiedAsync(DataRow row, string name, Func> value) + { + if (DbTable.Columns.Any(x => x.Name == name) && !row.Columns.Any(x => x.Name == name)) { - PreConditionSql = config?.PreConditionSql?.Replace("{{schema}}", Schema, StringComparison.OrdinalIgnoreCase).Replace("{{table}}", Name, StringComparison.OrdinalIgnoreCase); - PreSql = config?.PreSql?.Replace("{{schema}}", Schema, StringComparison.OrdinalIgnoreCase).Replace("{{table}}", Name, StringComparison.OrdinalIgnoreCase); - PostSql = config?.PostSql?.Replace("{{schema}}", Schema, StringComparison.OrdinalIgnoreCase).Replace("{{table}}", Name, StringComparison.OrdinalIgnoreCase); + AddColumn(name); + row.AddColumn(name, await value().ConfigureAwait(false)); } } + + /// + /// Applys the . + /// + /// The . + internal void ApplyConfig(DataConfig? config) + { + PreConditionSql = config?.PreConditionSql?.Replace("{{schema}}", Schema, StringComparison.OrdinalIgnoreCase).Replace("{{table}}", Name, StringComparison.OrdinalIgnoreCase); + PreSql = config?.PreSql?.Replace("{{schema}}", Schema, StringComparison.OrdinalIgnoreCase).Replace("{{table}}", Name, StringComparison.OrdinalIgnoreCase); + PostSql = config?.PostSql?.Replace("{{schema}}", Schema, StringComparison.OrdinalIgnoreCase).Replace("{{table}}", Name, StringComparison.OrdinalIgnoreCase); + } } \ No newline at end of file diff --git a/src/DbEx/Migration/Data/DataTableIdentifierType.cs b/src/DbEx/Migration/Data/DataTableIdentifierType.cs index 7ebd82f..5945f72 100644 --- a/src/DbEx/Migration/Data/DataTableIdentifierType.cs +++ b/src/DbEx/Migration/Data/DataTableIdentifierType.cs @@ -1,37 +1,32 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration.Data; -using System; - -namespace DbEx.Migration.Data +/// +/// Defines the identifier generator . +/// +public enum DataTableIdentifierType { /// - /// Defines the identifier generator . + /// Represents an invalid . /// - public enum DataTableIdentifierType - { - /// - /// Represents an invalid . - /// - Invalid = 0, + Invalid = 0, - /// - /// Represents a . - /// - String = 1, + /// + /// Represents a . + /// + String = 1, - /// - /// Represents a . - /// - Guid = 2, + /// + /// Represents a . + /// + Guid = 2, - /// - /// Represents an . - /// - Int = 3, + /// + /// Represents an . + /// + Int = 3, - /// - /// Represents a . - /// - Long = 4 - } + /// + /// Represents a . + /// + Long = 4 } \ No newline at end of file diff --git a/src/DbEx/Migration/Data/DataValueConverter.cs b/src/DbEx/Migration/Data/DataValueConverter.cs index 45be308..4fcac0d 100644 --- a/src/DbEx/Migration/Data/DataValueConverter.cs +++ b/src/DbEx/Migration/Data/DataValueConverter.cs @@ -1,22 +1,16 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration.Data; -using System; -using System.Diagnostics; - -namespace DbEx.Migration.Data +/// +/// Provides data value conversion. +/// +[DebuggerStepThrough] +public static class DataValueConverter { /// - /// Provides data value conversion. + /// Converts an to a ; e.g. '1' will result in '00000001-0000-0000-0000-000000000000'. /// - [DebuggerStepThrough] - public static class DataValueConverter - { - /// - /// Converts an to a ; e.g. '1' will result in '00000001-0000-0000-0000-000000000000'. - /// - /// The value. - /// The corresponding . - /// Sets the first argument with the and the remainder with zeroes using . - public static Guid IntToGuid(int value) => new(value, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); - } + /// The value. + /// The corresponding . + /// Sets the first argument with the and the remainder with zeroes using . + public static Guid IntToGuid(int value) => new(value, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); } \ No newline at end of file diff --git a/src/DbEx/Migration/Data/GuidIdentifierGenerator.cs b/src/DbEx/Migration/Data/GuidIdentifierGenerator.cs index 59a8683..1073f2e 100644 --- a/src/DbEx/Migration/Data/GuidIdentifierGenerator.cs +++ b/src/DbEx/Migration/Data/GuidIdentifierGenerator.cs @@ -1,24 +1,17 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration.Data; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.Migration.Data +/// +/// Represents a -based generator that will return a for and . +/// +public class GuidIdentifierGenerator : IIdentifierGenerator { /// - /// Represents a -based generator that will return a for and . + /// Generate a new identifier. /// - public class GuidIdentifierGenerator : IIdentifierGenerator - { - /// - /// Generate a new identifier. - /// - public Task GenerateStringIdentifierAsync(CancellationToken cancellation = default) => Task.FromResult(Guid.NewGuid().ToString()); + public Task GenerateStringIdentifierAsync(CancellationToken cancellation = default) => Task.FromResult(Guid.NewGuid().ToString()); - /// - /// Generate a new identifier. - /// - public Task GenerateGuidIdentifierAsync(CancellationToken cancellation = default) => Task.FromResult(Guid.NewGuid()); - } + /// + /// Generate a new identifier. + /// + public Task GenerateGuidIdentifierAsync(CancellationToken cancellation = default) => Task.FromResult(Guid.NewGuid()); } \ No newline at end of file diff --git a/src/DbEx/Migration/Data/IIdentifierGenerator.cs b/src/DbEx/Migration/Data/IIdentifierGenerator.cs index 11fec1c..61c58a1 100644 --- a/src/DbEx/Migration/Data/IIdentifierGenerator.cs +++ b/src/DbEx/Migration/Data/IIdentifierGenerator.cs @@ -1,38 +1,31 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration.Data; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.Migration.Data +/// +/// Enables the generation of a new identifier value. +/// +public interface IIdentifierGenerator { /// - /// Enables the generation of a new identifier value. + /// Generate a new identifier. /// - public interface IIdentifierGenerator - { - /// - /// Generate a new identifier. - /// - /// The . - Task GenerateStringIdentifierAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); + /// The . + Task GenerateStringIdentifierAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); - /// - /// Generate a new identifier. - /// - /// The . - Task GenerateGuidIdentifierAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); + /// + /// Generate a new identifier. + /// + /// The . + Task GenerateGuidIdentifierAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); - /// - /// Generate a new identifier. - /// - /// The . - Task GenerateInt32IdentifierAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); + /// + /// Generate a new identifier. + /// + /// The . + Task GenerateInt32IdentifierAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); - /// - /// Generate a new identifier. - /// - /// The . - Task GenerateInt64IdentifierAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); - } + /// + /// Generate a new identifier. + /// + /// The . + Task GenerateInt64IdentifierAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); } \ No newline at end of file diff --git a/src/DbEx/Migration/Database.cs b/src/DbEx/Migration/Database.cs index 6ce80ff..3f19faf 100644 --- a/src/DbEx/Migration/Database.cs +++ b/src/DbEx/Migration/Database.cs @@ -1,284 +1,273 @@ -using DbEx.DbSchema; -using System; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.Migration +namespace DbEx.Migration; + +/// +/// Provides the common/base database access functionality. +/// +/// The . +/// The function to create the . +/// The underlying . +public class Database(Func create, DbProviderFactory provider) : IDatabase where TConnection : DbConnection { - /// - /// Provides the common/base database access functionality. - /// - /// The . - /// The function to create the . - /// The underlying . - public class Database(Func create, DbProviderFactory provider) : IDatabase where TConnection : DbConnection - { - private readonly Func _dbConnCreate = create.ThrowIfNull(nameof(create)); - private TConnection? _dbConn; - private readonly SemaphoreSlim _semaphore = new(1, 1); + private readonly Func _dbConnCreate = create.ThrowIfNull(nameof(create)); + private TConnection? _dbConn; + private readonly SemaphoreSlim _semaphore = new(1, 1); - /// - public DbProviderFactory Provider { get; } = provider.ThrowIfNull(nameof(provider)); + /// + public DbProviderFactory Provider { get; } = provider.ThrowIfNull(nameof(provider)); - /// - public DbConnection GetConnection() => _dbConn is not null ? _dbConn : GetConnectionAsync().GetAwaiter().GetResult(); + /// + public DbConnection GetConnection() => _dbConn is not null ? _dbConn : GetConnectionAsync().GetAwaiter().GetResult(); - /// - async Task IDatabase.GetConnectionAsync(CancellationToken cancellationToken) => await GetConnectionAsync(cancellationToken).ConfigureAwait(false); + /// + async Task IDatabase.GetConnectionAsync(CancellationToken cancellationToken) => await GetConnectionAsync(cancellationToken).ConfigureAwait(false); - /// - public async Task GetConnectionAsync(CancellationToken cancellationToken = default) + /// + public async Task GetConnectionAsync(CancellationToken cancellationToken = default) + { + if (_dbConn == null) { - if (_dbConn == null) + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - if (_dbConn != null) - return _dbConn; + if (_dbConn != null) + return _dbConn; - _dbConn = _dbConnCreate() ?? throw new InvalidOperationException($"The create function must create a valid {nameof(TConnection)} instance."); - await _dbConn.OpenAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception) - { - _dbConn?.Dispose(); - _dbConn = null; - throw; - } - finally - { - _semaphore.Release(); - } + _dbConn = _dbConnCreate() ?? throw new InvalidOperationException($"The create function must create a valid {nameof(TConnection)} instance."); + await _dbConn.OpenAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception) + { + _dbConn?.Dispose(); + _dbConn = null; + throw; + } + finally + { + _semaphore.Release(); } - - return _dbConn; } - /// - public DatabaseCommand StoredProcedure(string storedProcedure) => new(this, CommandType.StoredProcedure, storedProcedure.ThrowIfNull(nameof(storedProcedure))); + return _dbConn; + } - /// - public DatabaseCommand SqlStatement(string sqlStatement) => new(this, CommandType.Text, sqlStatement.ThrowIfNull(nameof(sqlStatement))); + /// + public DatabaseCommand StoredProcedure(string storedProcedure) => new(this, System.Data.CommandType.StoredProcedure, storedProcedure.ThrowIfNull(nameof(storedProcedure))); - /// - public async Task> SelectSchemaAsync(DatabaseMigrationBase migration, CancellationToken cancellationToken = default) - { - migration.ThrowIfNull(nameof(migration)); - migration.PreExecutionInitialization(); + /// + public DatabaseCommand SqlStatement(string sqlStatement) => new(this, System.Data.CommandType.Text, sqlStatement.ThrowIfNull(nameof(sqlStatement))); - var tables = new List(); - DbTableSchema? table = null; + /// + public async Task> SelectSchemaAsync(DatabaseMigrationBase migration, CancellationToken cancellationToken = default) + { + migration.ThrowIfNull(nameof(migration)); + migration.PreExecutionInitialization(); + + var tables = new List(); + DbTableSchema? table = null; + + var refDataPredicate = new Func(t => t.Columns.Any(c => c.Name == migration.Args.RefDataCodeColumnName! && !c.IsPrimaryKey && c.DotNetType == "string") && t.Columns.Any(c => c.Name == migration.Args.RefDataTextColumnName && !c.IsPrimaryKey && c.DotNetType == "string")); - var refDataPredicate = new Func(t => t.Columns.Any(c => c.Name == migration.Args.RefDataCodeColumnName! && !c.IsPrimaryKey && c.DotNetType == "string") && t.Columns.Any(c => c.Name == migration.Args.RefDataTextColumnName && !c.IsPrimaryKey && c.DotNetType == "string")); + // Get all the tables and their columns. + var probeAssemblies = new[] { migration.SchemaConfig.GetType().Assembly, typeof(DatabaseExtensions).Assembly }; + using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableAndColumns.sql", probeAssemblies); + await SqlStatement(await ReadSqlAsync(migration, sr, cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => + { + if (!migration.SchemaConfig.SupportsSchema && dr.GetValue("TABLE_SCHEMA") != migration.DatabaseName) + return 0; - // Get all the tables and their columns. - var probeAssemblies = new[] { migration.SchemaConfig.GetType().Assembly, typeof(DatabaseExtensions).Assembly }; - using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableAndColumns.sql", probeAssemblies); - await SqlStatement(await ReadSqlAsync(migration, sr, cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => + var dt = new DbTableSchema(migration, dr.GetValue("TABLE_SCHEMA"), dr.GetValue("TABLE_NAME")!) { - if (!migration.SchemaConfig.SupportsSchema && dr.GetValue("TABLE_SCHEMA") != migration.DatabaseName) - return 0; + IsAView = dr.GetValue("TABLE_TYPE") == "VIEW" + }; - var dt = new DbTableSchema(migration, dr.GetValue("TABLE_SCHEMA"), dr.GetValue("TABLE_NAME")!) - { - IsAView = dr.GetValue("TABLE_TYPE") == "VIEW" - }; + if (table == null || table.Schema != dt.Schema || table.Name != dt.Name) + tables.Add(table = dt); - if (table == null || table.Schema != dt.Schema || table.Name != dt.Name) - tables.Add(table = dt); + var dc = migration.SchemaConfig.CreateColumnFromInformationSchema(table, dr); + dc.IsCreatedAudit = dc.Name == migration.Args?.CreatedByColumnName || dc.Name == migration.Args?.CreatedOnColumnName; + dc.IsUpdatedAudit = dc.Name == migration.Args?.UpdatedByColumnName || dc.Name == migration.Args?.UpdatedOnColumnName; + dc.IsTenantId = dc.Name == migration.Args?.TenantIdColumnName; + dc.IsRowVersion = dc.Name == migration.Args?.RowVersionColumnName; + dc.IsIsDeleted = dc.Name == migration.Args?.IsDeletedColumnName; - var dc = migration.SchemaConfig.CreateColumnFromInformationSchema(table, dr); - dc.IsCreatedAudit = dc.Name == migration.Args?.CreatedByColumnName || dc.Name == migration.Args?.CreatedOnColumnName; - dc.IsUpdatedAudit = dc.Name == migration.Args?.UpdatedByColumnName || dc.Name == migration.Args?.UpdatedOnColumnName; - dc.IsTenantId = dc.Name == migration.Args?.TenantIdColumnName; - dc.IsRowVersion = dc.Name == migration.Args?.RowVersionColumnName; - dc.IsIsDeleted = dc.Name == migration.Args?.IsDeletedColumnName; + table.Columns.Add(dc); + return 0; + }, cancellationToken).ConfigureAwait(false); - table.Columns.Add(dc); - return 0; - }, cancellationToken).ConfigureAwait(false); + // Exit where no tables initially found. + if (tables.Count == 0) + return tables; - // Exit where no tables initially found. - if (tables.Count == 0) - return tables; + // Determine whether a table is considered reference data. + foreach (var t in tables) + { + t.IsRefData = refDataPredicate(t); + if (t.IsRefData) + t.RefDataCodeColumn = t.Columns.Where(x => x.Name == migration.Args.RefDataCodeColumnName).SingleOrDefault(); + } - // Determine whether a table is considered reference data. - foreach (var t in tables) - { - t.IsRefData = refDataPredicate(t); - if (t.IsRefData) - t.RefDataCodeColumn = t.Columns.Where(x => x.Name == migration.Args.RefDataCodeColumnName).SingleOrDefault(); - } + // Configure all the single column primary and unique constraints. + using var sr2 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTablePrimaryKey.sql", probeAssemblies); + var pks = await SqlStatement(await ReadSqlAsync(migration, sr2, cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new + { + ConstraintName = dr.GetValue("CONSTRAINT_NAME"), + TableSchema = dr.GetValue("TABLE_SCHEMA"), + TableName = dr.GetValue("TABLE_NAME"), + TableColumnName = dr.GetValue("COLUMN_NAME"), + IsPrimaryKey = dr.GetValue("CONSTRAINT_TYPE")?.StartsWith("PRIMARY", StringComparison.OrdinalIgnoreCase) ?? false + }, cancellationToken).ConfigureAwait(false); - // Configure all the single column primary and unique constraints. - using var sr2 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTablePrimaryKey.sql", probeAssemblies); - var pks = await SqlStatement(await ReadSqlAsync(migration, sr2, cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new - { - ConstraintName = dr.GetValue("CONSTRAINT_NAME"), - TableSchema = dr.GetValue("TABLE_SCHEMA"), - TableName = dr.GetValue("TABLE_NAME"), - TableColumnName = dr.GetValue("COLUMN_NAME"), - IsPrimaryKey = dr.GetValue("CONSTRAINT_TYPE")?.StartsWith("PRIMARY", StringComparison.OrdinalIgnoreCase) ?? false - }, cancellationToken).ConfigureAwait(false); + if (!migration.SchemaConfig.SupportsSchema) + pks = [.. pks.Where(x => x.TableSchema == migration.DatabaseName)]; - if (!migration.SchemaConfig.SupportsSchema) - pks = [.. pks.Where(x => x.TableSchema == migration.DatabaseName)]; + foreach (var grp in pks.GroupBy(x => new { x.ConstraintName, x.TableSchema, x.TableName })) + { + // Only single column unique columns are supported. + if (grp.Count() > 1 && !grp.First().IsPrimaryKey) + continue; - foreach (var grp in pks.GroupBy(x => new { x.ConstraintName, x.TableSchema, x.TableName })) + // Set the column flags as appropriate. + foreach (var pk in grp) { - // Only single column unique columns are supported. - if (grp.Count() > 1 && !grp.First().IsPrimaryKey) + var col = (from t in tables + from c in t.Columns + where (!migration.SchemaConfig.SupportsSchema || t.Schema == pk.TableSchema) && t.Name == pk.TableName && c.Name == pk.TableColumnName + select c).SingleOrDefault(); + + if (col == null) continue; - // Set the column flags as appropriate. - foreach (var pk in grp) + if (pk.IsPrimaryKey) { - var col = (from t in tables - from c in t.Columns - where (!migration.SchemaConfig.SupportsSchema || t.Schema == pk.TableSchema) && t.Name == pk.TableName && c.Name == pk.TableColumnName - select c).SingleOrDefault(); - - if (col == null) - continue; - - if (pk.IsPrimaryKey) - { - col.IsPrimaryKey = true; - if (!col.IsIdentity) - col.IsIdentity = col.DefaultValue != null; - } - else - col.IsUnique = true; + col.IsPrimaryKey = true; + if (!col.IsIdentity) + col.IsIdentity = col.DefaultValue != null; } + else + col.IsUnique = true; } + } - // Load any additional configuration specific to the database provider. - await migration.SchemaConfig.LoadAdditionalInformationSchema(this, tables, cancellationToken).ConfigureAwait(false); + // Load any additional configuration specific to the database provider. + await migration.SchemaConfig.LoadAdditionalInformationSchema(this, tables, cancellationToken).ConfigureAwait(false); - // Attempt to infer foreign key reference data relationship where not explicitly specified. - foreach (var t in tables) + // Attempt to infer foreign key reference data relationship where not explicitly specified. + foreach (var t in tables) + { + foreach (var c in t.Columns.Where(x => !x.IsPrimaryKey)) { - foreach (var c in t.Columns.Where(x => !x.IsPrimaryKey)) + if (c.ForeignTable != null) { - if (c.ForeignTable != null) + if (c.IsForeignRefData) { - if (c.IsForeignRefData) - { - c.ForeignRefDataCodeColumn = migration.Args.RefDataCodeColumnName; - if (c.Name.EndsWith(migration.Args.IdColumnNameSuffix!, StringComparison.Ordinal)) - c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[0..^migration.Args.IdColumnNameSuffix!.Length]); - } - - continue; + c.ForeignRefDataCodeColumn = migration.Args.RefDataCodeColumnName; + if (c.Name.EndsWith(migration.Args.IdColumnNameSuffix!, StringComparison.Ordinal)) + c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[0..^migration.Args.IdColumnNameSuffix!.Length]); } - if (!c.Name.EndsWith(migration.Args.IdColumnNameSuffix!, StringComparison.Ordinal)) - continue; + continue; + } - // Find table with same name as column in any schema that is considered reference data and has a single primary key. - var fk = tables.Where(x => x != t && x.Name == c.Name[0..^migration.Args.IdColumnNameSuffix!.Length] && x.IsRefData && x.PrimaryKeyColumns.Count == 1).FirstOrDefault(); - if (fk == null) - continue; + if (!c.Name.EndsWith(migration.Args.IdColumnNameSuffix!, StringComparison.Ordinal)) + continue; - c.ForeignSchema = fk.Schema; - c.ForeignTable = fk.Name; - c.ForeignColumn = fk.PrimaryKeyColumns[0].Name; - c.IsForeignRefData = true; - c.ForeignRefDataCodeColumn = migration.Args.RefDataCodeColumnName; - c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[0..^migration.Args.IdColumnNameSuffix!.Length]); - } + // Find table with same name as column in any schema that is considered reference data and has a single primary key. + var fk = tables.Where(x => x != t && x.Name == c.Name[0..^migration.Args.IdColumnNameSuffix!.Length] && x.IsRefData && x.PrimaryKeyColumns.Count == 1).FirstOrDefault(); + if (fk == null) + continue; + + c.ForeignSchema = fk.Schema; + c.ForeignTable = fk.Name; + c.ForeignColumn = fk.PrimaryKeyColumns[0].Name; + c.IsForeignRefData = true; + c.ForeignRefDataCodeColumn = migration.Args.RefDataCodeColumnName; + c.DotNetCleanedName = DbTableSchema.CreateDotNetName(c.Name[0..^migration.Args.IdColumnNameSuffix!.Length]); } + } - // Attempt to infer if a reference data column where not explicitly specified. - foreach (var t in tables) + // Attempt to infer if a reference data column where not explicitly specified. + foreach (var t in tables) + { + foreach (var c in t.Columns.Where(x => !x.IsPrimaryKey)) { - foreach (var c in t.Columns.Where(x => !x.IsPrimaryKey)) + if (c.IsForeignRefData) { - if (c.IsForeignRefData) - { - c.IsRefData = true; - continue; - } + c.IsRefData = true; + continue; + } - // Find possible name by removing suffix by-convention. - string name; - if (c.Name.EndsWith(migration.Args.IdColumnNameSuffix!, StringComparison.Ordinal)) - name = c.Name[0..^migration.Args.IdColumnNameSuffix!.Length]; - else if (c.Name.EndsWith(migration.Args.CodeColumnNameSuffix!, StringComparison.Ordinal)) - name = c.Name[0..^migration.Args.CodeColumnNameSuffix!.Length]; - else - continue; - - // Is there a table match of same name that is considered reference data; if so, consider ref data. - if (tables.Any(x => x.Name == name && x.Schema == t.Schema && x.IsRefData)) - { - c.IsRefData = true; - c.DotNetCleanedName = DbTableSchema.CreateDotNetName(name); - } + // Find possible name by removing suffix by-convention. + string name; + if (c.Name.EndsWith(migration.Args.IdColumnNameSuffix!, StringComparison.Ordinal)) + name = c.Name[0..^migration.Args.IdColumnNameSuffix!.Length]; + else if (c.Name.EndsWith(migration.Args.CodeColumnNameSuffix!, StringComparison.Ordinal)) + name = c.Name[0..^migration.Args.CodeColumnNameSuffix!.Length]; + else + continue; + + // Is there a table match of same name that is considered reference data; if so, consider ref data. + if (tables.Any(x => x.Name == name && x.Schema == t.Schema && x.IsRefData)) + { + c.IsRefData = true; + c.DotNetCleanedName = DbTableSchema.CreateDotNetName(name); } } - - return tables; } - /// - /// Gets the SQL statement from the embedded resource stream - /// - private async static Task ReadSqlAsync(DatabaseMigrationBase migration, StreamReader sr, CancellationToken cancellationToken) - { + return tables; + } + + /// + /// Gets the SQL statement from the embedded resource stream + /// + private async static Task ReadSqlAsync(DatabaseMigrationBase migration, StreamReader sr, CancellationToken cancellationToken) + { #if NET7_0_OR_GREATER - var sql = await sr.ThrowIfNull(nameof(sr)).ReadToEndAsync(cancellationToken).ConfigureAwait(false); + var sql = await sr.ThrowIfNull(nameof(sr)).ReadToEndAsync(cancellationToken).ConfigureAwait(false); #else - var sql = await sr.ThrowIfNull(nameof(sr)).ReadToEndAsync().ConfigureAwait(false); + var sql = await sr.ThrowIfNull(nameof(sr)).ReadToEndAsync().ConfigureAwait(false); #endif - return sql.Replace("{{DatabaseName}}", migration.DatabaseName); - } + return sql.Replace("{{DatabaseName}}", migration.DatabaseName); + } - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } - /// - /// Dispose of the resources. - /// - /// Indicates whether to dispose. - protected virtual void Dispose(bool disposing) + /// + /// Dispose of the resources. + /// + /// Indicates whether to dispose. + protected virtual void Dispose(bool disposing) + { + if (disposing && _dbConn != null) { - if (disposing && _dbConn != null) - { - _dbConn.Dispose(); - _dbConn = null; - } + _dbConn.Dispose(); + _dbConn = null; } + } - /// - public async ValueTask DisposeAsync() - { - await DisposeAsyncCore().ConfigureAwait(false); - GC.SuppressFinalize(this); - } + /// + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore().ConfigureAwait(false); + GC.SuppressFinalize(this); + } - /// - /// Dispose of the resources asynchronously. - /// - public virtual async ValueTask DisposeAsyncCore() + /// + /// Dispose of the resources asynchronously. + /// + public virtual async ValueTask DisposeAsyncCore() + { + if (_dbConn != null) { - if (_dbConn != null) - { - await _dbConn.DisposeAsync().ConfigureAwait(false); - _dbConn = null; - } - - Dispose(); + await _dbConn.DisposeAsync().ConfigureAwait(false); + _dbConn = null; } + + Dispose(); } } \ No newline at end of file diff --git a/src/DbEx/Migration/DatabaseCommand.cs b/src/DbEx/Migration/DatabaseCommand.cs index 7a6bdf2..e65a60a 100644 --- a/src/DbEx/Migration/DatabaseCommand.cs +++ b/src/DbEx/Migration/DatabaseCommand.cs @@ -1,132 +1,124 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.Migration +namespace DbEx.Migration; + +/// +/// Provides extended database command capabilities. +/// +/// The . +/// The . +/// The command text. +/// As the underlying implements this is only created (and automatically disposed) where executing the command proper. +public class DatabaseCommand(IDatabase db, System.Data.CommandType commandType, string commandText) { + private readonly List _parameters = []; + /// - /// Provides extended database command capabilities. + /// Gets the underlying . /// - /// The . - /// The . - /// The command text. - /// As the underlying implements this is only created (and automatically disposed) where executing the command proper. - public class DatabaseCommand(IDatabase db, CommandType commandType, string commandText) - { - private readonly List _parameters = []; - - /// - /// Gets the underlying . - /// - public IDatabase Database { get; } = db.ThrowIfNull(); - - /// - /// Gets the . - /// - public CommandType CommandType { get; } = commandType; - - /// - /// Gets the command text. - /// - public string CommandText { get; } = commandText.ThrowIfNullOrEmpty(); - - /// - /// Adds a parameter to the command. - /// - /// The parameter name. - /// The parameter value. - /// The . - public DatabaseCommand Param(string name, T? value = default) - { - var param = Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null."); - param.ParameterName = name; + public IDatabase Database { get; } = db.ThrowIfNull(); - // Convert to UTC. https://www.tinybird.co/blog/database-timestamps-timezone - param.Value = value is null - ? DBNull.Value - : (value is DateTimeOffset dto ? dto.ToUniversalTime() : value); - - _parameters.Add(param); - return this; - } + /// + /// Gets the . + /// + public System.Data.CommandType CommandType { get; } = commandType; - /// - /// Selects none or more items from the first result set. - /// - /// The item . - /// The mapping function. - /// The . - /// The resulting set. - public async Task> SelectQueryAsync(Func func, CancellationToken cancellationToken = default) - { - func.ThrowIfNull(nameof(func)); + /// + /// Gets the command text. + /// + public string CommandText { get; } = commandText.ThrowIfNullOrEmpty(); - var list = new List(); + /// + /// Adds a parameter to the command. + /// + /// The parameter name. + /// The parameter value. + /// The . + public DatabaseCommand Param(string name, T? value = default) + { + var param = Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null."); + param.ParameterName = name; - using var cmd = await CreateDbCommandAsync(cancellationToken).ConfigureAwait(false); - using var dr = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + // Convert to UTC. https://www.tinybird.co/blog/database-timestamps-timezone + param.Value = value is null + ? DBNull.Value + : (value is DateTimeOffset dto ? dto.ToUniversalTime() : value); - while (await dr.ReadAsync(cancellationToken).ConfigureAwait(false)) - { - list.Add(func(new DatabaseRecord(Database, dr))); - } + _parameters.Add(param); + return this; + } - return list; - } + /// + /// Selects none or more items from the first result set. + /// + /// The item . + /// The mapping function. + /// The . + /// The resulting set. + public async Task> SelectQueryAsync(Func func, CancellationToken cancellationToken = default) + { + func.ThrowIfNull(nameof(func)); - /// - /// Executes the query and returns the first column of the first row in the result set returned by the query. - /// - /// The result . - /// The . - /// The value of the first column of the first row in the result set. - public async Task ScalarAsync(CancellationToken cancellationToken = default) - { - using var cmd = await CreateDbCommandAsync(cancellationToken).ConfigureAwait(false); - var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + var list = new List(); - if (typeof(T) == typeof(DateTimeOffset) && result is DateTime dt) - { - // https://www.tinybird.co/blog/database-timestamps-timezone - var dto = dt.Kind switch - { - DateTimeKind.Utc => new DateTimeOffset(dt, TimeSpan.Zero), - DateTimeKind.Local => new DateTimeOffset(dt), - _ =>throw new InvalidOperationException($"{nameof(DateTime)} with {nameof(DateTime.Kind)} of {dt.Kind} cannot be safely converted to a {nameof(DateTimeOffset)}.") - }; - - return (T)(object)dto; - } - else - return result is null ? default! : result is DBNull ? default! : (T)result; - } + using var cmd = await CreateDbCommandAsync(cancellationToken).ConfigureAwait(false); + using var dr = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); - /// - /// Executes a non-query command. - /// - /// The . - /// The number of rows affected. - public async Task NonQueryAsync(CancellationToken cancellationToken = default) + while (await dr.ReadAsync(cancellationToken).ConfigureAwait(false)) { - using var cmd = await CreateDbCommandAsync(cancellationToken).ConfigureAwait(false); - return await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + list.Add(func(new DatabaseRecord(Database, dr))); } - /// - /// Creates the corresponding . - /// - /// The . - /// The . - private async Task CreateDbCommandAsync(CancellationToken cancellationToken = default) + return list; + } + + /// + /// Executes the query and returns the first column of the first row in the result set returned by the query. + /// + /// The result . + /// The . + /// The value of the first column of the first row in the result set. + public async Task ScalarAsync(CancellationToken cancellationToken = default) + { + using var cmd = await CreateDbCommandAsync(cancellationToken).ConfigureAwait(false); + var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + + if (typeof(T) == typeof(DateTimeOffset) && result is DateTime dt) { - var cmd = (await Database.GetConnectionAsync(cancellationToken).ConfigureAwait(false)).CreateCommand(); - cmd.CommandType = CommandType; - cmd.CommandText = CommandText; - cmd.Parameters.AddRange(_parameters.ToArray()); - return cmd; + // https://www.tinybird.co/blog/database-timestamps-timezone + var dto = dt.Kind switch + { + DateTimeKind.Utc => new DateTimeOffset(dt, TimeSpan.Zero), + DateTimeKind.Local => new DateTimeOffset(dt), + _ =>throw new InvalidOperationException($"{nameof(DateTime)} with {nameof(DateTime.Kind)} of {dt.Kind} cannot be safely converted to a {nameof(DateTimeOffset)}.") + }; + + return (T)(object)dto; } + else + return result is null ? default! : result is DBNull ? default! : (T)result; + } + + /// + /// Executes a non-query command. + /// + /// The . + /// The number of rows affected. + public async Task NonQueryAsync(CancellationToken cancellationToken = default) + { + using var cmd = await CreateDbCommandAsync(cancellationToken).ConfigureAwait(false); + return await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates the corresponding . + /// + /// The . + /// The . + private async Task CreateDbCommandAsync(CancellationToken cancellationToken = default) + { + var cmd = (await Database.GetConnectionAsync(cancellationToken).ConfigureAwait(false)).CreateCommand(); + cmd.CommandType = CommandType; + cmd.CommandText = CommandText; + cmd.Parameters.AddRange(_parameters.ToArray()); + return cmd; } } \ No newline at end of file diff --git a/src/DbEx/Migration/DatabaseJournal.cs b/src/DbEx/Migration/DatabaseJournal.cs index 0fd3d09..a609500 100644 --- a/src/DbEx/Migration/DatabaseJournal.cs +++ b/src/DbEx/Migration/DatabaseJournal.cs @@ -1,77 +1,67 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using System.Linq; - -namespace DbEx.Migration +/// +/// Provides the database-agnostic journaling capability to ensure selected scripts are only executed once. +/// +/// Journaling is the recording/auditing of migration scripts executed against the database to ensure they are executed only once. This is implemented in a manner compatible, same-as, +/// DbUp to ensure consistency. +/// The and values are used to replace the '{{JournalSchema}}' and '{{JournalTable}}' placeholders respectively. +/// The . +public class DatabaseJournal(DatabaseMigrationBase migrator) : IDatabaseJournal { - /// - /// Provides the database-agnostic journaling capability to ensure selected scripts are only executed once. - /// - /// Journaling is the recording/auditing of migration scripts executed against the database to ensure they are executed only once. This is implemented in a manner compatible, same-as, - /// DbUp to ensure consistency. - /// The and values are used to replace the '{{JournalSchema}}' and '{{JournalTable}}' placeholders respectively. - /// The . - public class DatabaseJournal(DatabaseMigrationBase migrator) : IDatabaseJournal - { - private bool _journalExists; + private bool _journalExists; - /// - public string? Schema => Migrator.Args.Parameters[MigrationArgs.JournalSchemaParamName]?.ToString(); + /// + public string? Schema => Migrator.Args.Parameters[MigrationArgs.JournalSchemaParamName]?.ToString(); - /// - public string? Table => Migrator.Args.Parameters[MigrationArgs.JournalTableParamName]?.ToString(); + /// + public string? Table => Migrator.Args.Parameters[MigrationArgs.JournalTableParamName]?.ToString(); - /// - /// Gets the . - /// - public DatabaseMigrationBase Migrator { get; } = migrator.ThrowIfNull(nameof(migrator)); + /// + /// Gets the . + /// + public DatabaseMigrationBase Migrator { get; } = migrator.ThrowIfNull(nameof(migrator)); - /// - public async Task EnsureExistsAsync(CancellationToken cancellationToken = default) - { - if (_journalExists) - return; + /// + public async Task EnsureExistsAsync(CancellationToken cancellationToken = default) + { + if (_journalExists) + return; - using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalExists.sql", [.. Migrator.ArtefactResourceAssemblies])!; - var exists = await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr.ReadToEnd())).ScalarAsync(cancellationToken).ConfigureAwait(false); - if (exists != null) - { - _journalExists = true; - return; - } + using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalExists.sql", [.. Migrator.ArtefactResourceAssemblies])!; + var exists = await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr.ReadToEnd())).ScalarAsync(cancellationToken).ConfigureAwait(false); + if (exists != null) + { + _journalExists = true; + return; + } - using var sr2 = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalCreate.sql", [.. Migrator.ArtefactResourceAssemblies])!; - await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr2.ReadToEnd())).NonQueryAsync(cancellationToken).ConfigureAwait(false); + using var sr2 = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalCreate.sql", [.. Migrator.ArtefactResourceAssemblies])!; + await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr2.ReadToEnd())).NonQueryAsync(cancellationToken).ConfigureAwait(false); - Migrator.Logger.LogInformation(" *Journal table did not exist within the database and was automatically created."); + Migrator.Logger.LogInformation(" *Journal table did not exist within the database and was automatically created."); - _journalExists = true; - } + _journalExists = true; + } - /// - public async Task AuditScriptExecutionAsync(DatabaseMigrationScript script, CancellationToken cancellationToken = default) - { - await EnsureExistsAsync(cancellationToken).ConfigureAwait(false); + /// + public async Task AuditScriptExecutionAsync(DatabaseMigrationScript script, CancellationToken cancellationToken = default) + { + await EnsureExistsAsync(cancellationToken).ConfigureAwait(false); - using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalAudit.sql", [.. Migrator.ArtefactResourceAssemblies])!; - await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr.ReadToEnd())) - .Param("@scriptname", script.Name) - .Param("@applied", DateTime.UtcNow) - .NonQueryAsync(cancellationToken).ConfigureAwait(false); - } + using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalAudit.sql", [.. Migrator.ArtefactResourceAssemblies])!; + await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr.ReadToEnd())) + .Param("@scriptname", script.Name) + .Param("@applied", DateTime.UtcNow) + .NonQueryAsync(cancellationToken).ConfigureAwait(false); + } - /// - public async Task> GetExecutedScriptsAsync(CancellationToken cancellationToken = default) - { - await EnsureExistsAsync(cancellationToken).ConfigureAwait(false); + /// + public async Task> GetExecutedScriptsAsync(CancellationToken cancellationToken = default) + { + await EnsureExistsAsync(cancellationToken).ConfigureAwait(false); - using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalPrevious.sql", [.. Migrator.ArtefactResourceAssemblies])!; - return await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr.ReadToEnd())).SelectQueryAsync(dr => dr.GetValue("scriptname")!, cancellationToken).ConfigureAwait(false); - } + using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalPrevious.sql", [.. Migrator.ArtefactResourceAssemblies])!; + return await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr.ReadToEnd())).SelectQueryAsync(dr => dr.GetValue("scriptname")!, cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/DbEx/Migration/DatabaseMigrationBase.cs b/src/DbEx/Migration/DatabaseMigrationBase.cs index 8739041..a78a6dc 100644 --- a/src/DbEx/Migration/DatabaseMigrationBase.cs +++ b/src/DbEx/Migration/DatabaseMigrationBase.cs @@ -1,1037 +1,1019 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx - -using DbEx.Migration.Data; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using OnRamp.Console; -using OnRamp.Utility; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.Migration +namespace DbEx.Migration; + +/// +/// Represents the base capabilities for the database migration orchestrator leveraging DbUp (where applicable). +/// +public abstract class DatabaseMigrationBase : IDisposable { + private const string NothingFoundText = " ** Nothing found. **"; + private const string OnDatabaseCreateName = "post.database.create"; + private static readonly string[] _splitters = ["\r\n", "\r", "\n"]; + private HandlebarsCodeGenerator? _dataCodeGen; + private bool _hasInitialized = false; + /// - /// Represents the base capabilities for the database migration orchestrator leveraging DbUp (where applicable). + /// Gets the Resource content from the file system and then Resources folder within the until found. /// - public abstract class DatabaseMigrationBase : IDisposable + /// The file name. + /// Assemblies to use to probe for assembly resource (in defined sequence). + /// The file extensions to also probe for. + /// The resource where found; otherwise, throws . + public static StreamReader GetRequiredResourcesStreamReader(string fileName, Assembly[]? assemblies = null, string[]? extensions = null) { - private const string NothingFoundText = " ** Nothing found. **"; - private const string OnDatabaseCreateName = "post.database.create"; - private static readonly string[] _splitters = ["\r\n", "\r", "\n"]; - private HandlebarsCodeGenerator? _dataCodeGen; - private bool _hasInitialized = false; - - /// - /// Gets the Resource content from the file system and then Resources folder within the until found. - /// - /// The file name. - /// Assemblies to use to probe for assembly resource (in defined sequence). - /// The file extensions to also probe for. - /// The resource where found; otherwise, throws . - public static StreamReader GetRequiredResourcesStreamReader(string fileName, Assembly[]? assemblies = null, string[]? extensions = null) - { - var result = StreamLocator.GetResourcesStreamReader(fileName, assemblies, extensions); - if (result.StreamReader == null) - throw new InvalidOperationException($"Embedded resource '{fileName}' is required and was not found within the selected assemblies."); + var result = StreamLocator.GetResourcesStreamReader(fileName, assemblies, extensions); + if (result.StreamReader == null) + throw new InvalidOperationException($"Embedded resource '{fileName}' is required and was not found within the selected assemblies."); - return result.StreamReader; - } + return result.StreamReader; + } - /// - /// Initializes an instance of the class. - /// - /// The . - protected DatabaseMigrationBase(MigrationArgsBase args) - { - Args = args.ThrowIfNull(nameof(args)); - if (string.IsNullOrEmpty(Args.ConnectionString)) - throw new ArgumentException($"{nameof(MigrationArgsBase.ConnectionString)} property must have a value.", nameof(args)); + /// + /// Initializes an instance of the class. + /// + /// The . + protected DatabaseMigrationBase(MigrationArgsBase args) + { + Args = args.ThrowIfNull(nameof(args)); + if (string.IsNullOrEmpty(Args.ConnectionString)) + throw new ArgumentException($"{nameof(MigrationArgsBase.ConnectionString)} property must have a value.", nameof(args)); - Args.Logger ??= NullLogger.Instance; - Args.OutputDirectory ??= new DirectoryInfo(CodeGenConsole.GetBaseExeDirectory()); + Args.Logger ??= NullLogger.Instance; + Args.OutputDirectory ??= new DirectoryInfo(CodeGenConsole.GetBaseExeDirectory()); - Journal = new DatabaseJournal(this); - SchemaObjectTypes = []; - } + Journal = new DatabaseJournal(this); + SchemaObjectTypes = []; + } - /// - /// Gets the . - /// - public MigrationArgsBase Args { get; } - - /// - /// Gets the database provider name. - /// - /// Used as the prefix for embedded resources. - public abstract string Provider { get; } - - /// - /// Gets the database name. - /// - /// This should be inferred from the . - public abstract string DatabaseName { get; } - - /// - /// Gets the . - /// - public abstract IDatabase Database { get; } - - /// - /// Gets the 'master' . - /// - /// Returns the by default (unless specifically overridden). - public virtual IDatabase MasterDatabase => Database; - - /// - /// Gets the . - /// - public abstract DatabaseSchemaConfig SchemaConfig { get; } - - /// - /// Gets the . - /// - public virtual IDatabaseJournal Journal { get; } - - /// - /// Gets the (references ). - /// - public ILogger Logger => Args.Logger!; - - /// - /// Gets the root namespaces for the (ordered by ). - /// - protected IEnumerable Namespaces { get; } = new List(); - - /// - /// Gets or sets the Migrations scripts namespace part name. - /// - public string MigrationsNamespace { get; set; } = "Migrations"; - - /// - /// Gets or sets the Migrations scripts namespace part name. - /// - public string SchemaNamespace { get; set; } = "Schema"; - - /// - /// Gets or sets the list of supported schema object types in the order of precedence. - /// - /// The objects will be added in the order specified, and removed in the reverse order. This is to allow for potential dependencies between the object types. - /// Where none are specified then the phase will be skipped. - public string[] SchemaObjectTypes { get; set; } = []; - - /// - /// Gets or sets the list of schema object types that where found must result in all schema objects being dropped and then recreated. - /// - public string[] MustDropSchemaObjectTypes { get; set; } = []; - - /// - /// Gets the assemblies used for probing the requisite artefact resources (used for providing the underlying requisite database statements for the specified ). - /// - /// Uses the as the base, then adds for this . - public IEnumerable ArtefactResourceAssemblies { get; } = new List(); - - /// - /// Indicates whether functionality is enabled. - /// - /// Where supported the will be invoked and must be overridden to implement. - public bool IsCodeGenEnabled { get; protected set; } - - /// - /// Orchestrates the migration steps as specified by the and returns the corresponding log output. - /// - /// The . - /// true indicates success; otherwise, false. Additionally, returns the log output. - /// This will replace the to enable return of log output as a string. - public async Task<(bool Success, string Output)> MigrateAndLogAsync(CancellationToken cancellationToken = default) - { - var logger = new StringLogger(); - Args.Logger = logger; - var result = await MigrateAsync(cancellationToken).ConfigureAwait(false); - return (result, logger.Output); - } + /// + /// Gets the . + /// + public MigrationArgsBase Args { get; } - /// - /// Orchestrates the migration steps as specified by the . - /// - /// The . - /// true indicates success; otherwise, false. - public virtual async Task MigrateAsync(CancellationToken cancellationToken = default) - { - // Initialize for migration execution. - PreExecutionInitialization(); + /// + /// Gets the database provider name. + /// + /// Used as the prefix for embedded resources. + public abstract string Provider { get; } - // Where only creating a new script, then quickly do it and get out of here! - if (Args.MigrationCommand.HasFlag(MigrationCommand.Script)) - return await CreateScriptAsync(Args.Parameters.TryGetValue("Param0", out var p0) ? p0?.ToString() : null, Args.CreateStringParameters(), cancellationToken).ConfigureAwait(false); + /// + /// Gets the database name. + /// + /// This should be inferred from the . + public abstract string DatabaseName { get; } - // Where only executing SQL statement, then execute and get out of here! - if (Args.MigrationCommand.HasFlag(MigrationCommand.Execute)) - return await ExecuteSqlStatementsAsync(Args.ExecuteStatements?.ToArray() ?? [], cancellationToken).ConfigureAwait(false); + /// + /// Gets the . + /// + public abstract IDatabase Database { get; } - /* The remaining commands are executed in sequence as defined (where selected) to enable multiple in the correct run order. */ + /// + /// Gets the 'master' . + /// + /// Returns the by default (unless specifically overridden). + public virtual IDatabase MasterDatabase => Database; - // Database drop. - if (!await CommandExecuteAsync(MigrationCommand.Drop, "DATABASE DROP: Checking database existence and dropping where found...", DatabaseDropAsync, null, cancellationToken).ConfigureAwait(false)) - return false; + /// + /// Gets the . + /// + public abstract DatabaseSchemaConfig SchemaConfig { get; } - // Database create. - if (!await CommandExecuteAsync(MigrationCommand.Create, "DATABASE CREATE: Checking database existence and creating where not found...", DatabaseCreateAsync, null, cancellationToken).ConfigureAwait(false)) - return false; + /// + /// Gets the . + /// + public virtual IDatabaseJournal Journal { get; } - // Database migration scripts. - if (!await CommandExecuteAsync(MigrationCommand.Migrate, "DATABASE MIGRATE: Migrating the database...", DatabaseMigrateAsync, null, cancellationToken).ConfigureAwait(false)) - return false; + /// + /// Gets the (references ). + /// + public ILogger Logger => Args.Logger!; - // Code-generation (where supported). - if (Args.MigrationCommand == MigrationCommand.CodeGen && !IsCodeGenEnabled) - { - Logger.LogWarning("Code-generation has not been enabled for the database migrator; this feature must be explicitly enabled."); - return false; - } + /// + /// Gets the root namespaces for the (ordered by ). + /// + protected IEnumerable Namespaces { get; } = new List(); - if (IsCodeGenEnabled) - { - string? statistics = null; - string func() => statistics ?? throw new InvalidOperationException("Internal error; expected summary text output from code-generation."); - if (!await CommandExecuteAsync(MigrationCommand.CodeGen, "DATABASE CODEGEN: Code-gen database objects...", async ct => - { - var (Success, Statistics) = await DatabaseCodeGenAsync(ct).ConfigureAwait(false); - statistics = Statistics; - return Success; - }, func, cancellationToken).ConfigureAwait(false)) - return false; - } + /// + /// Gets or sets the Migrations scripts namespace part name. + /// + public string MigrationsNamespace { get; set; } = "Migrations"; - // Database schema scripts. - if (!await CommandExecuteAsync(MigrationCommand.Schema, "DATABASE SCHEMA: Drops and creates/replaces the database objects...", DatabaseSchemaAsync, null, cancellationToken).ConfigureAwait(false)) - return false; + /// + /// Gets or sets the Migrations scripts namespace part name. + /// + public string SchemaNamespace { get; set; } = "Schema"; - // Database reset. - if (!await CommandExecuteAsync(MigrationCommand.Reset, "DATABASE RESET: Resets database by dropping data from all tables...", DatabaseResetAsync, null, cancellationToken).ConfigureAwait(false)) - return false; + /// + /// Gets or sets the list of supported schema object types in the order of precedence. + /// + /// The objects will be added in the order specified, and removed in the reverse order. This is to allow for potential dependencies between the object types. + /// Where none are specified then the phase will be skipped. + public string[] SchemaObjectTypes { get; set; } = []; - // Database data load. - if (!await CommandExecuteAsync(MigrationCommand.Data, "DATABASE DATA: Insert or merge the embedded data [yaml|json|sql]...", DatabaseDataAsync, null, cancellationToken).ConfigureAwait(false)) - return false; + /// + /// Gets or sets the list of schema object types that where found must result in all schema objects being dropped and then recreated. + /// + public string[] MustDropSchemaObjectTypes { get; set; } = []; - return true; - } + /// + /// Gets the assemblies used for probing the requisite artefact resources (used for providing the underlying requisite database statements for the specified ). + /// + /// Uses the as the base, then adds for this . + public IEnumerable ArtefactResourceAssemblies { get; } = new List(); - /// - /// Performs the pre-execution initialization. - /// - public void PreExecutionInitialization() - { - if (_hasInitialized) - return; + /// + /// Indicates whether functionality is enabled. + /// + /// Where supported the will be invoked and must be overridden to implement. + public bool IsCodeGenEnabled { get; protected set; } - _hasInitialized = true; - SchemaConfig.PrepareMigrationArgs(); + /// + /// Orchestrates the migration steps as specified by the and returns the corresponding log output. + /// + /// The . + /// true indicates success; otherwise, false. Additionally, returns the log output. + /// This will replace the to enable return of log output as a string. + public async Task<(bool Success, string Output)> MigrateAndLogAsync(CancellationToken cancellationToken = default) + { + var logger = new StringLogger(); + Args.Logger = logger; + var result = await MigrateAsync(cancellationToken).ConfigureAwait(false); + return (result, logger.Output); + } - var list = (List)Namespaces; - Args.ProbeAssemblies.ForEach(x => list.Add(x.Assembly.GetName().Name!)); + /// + /// Orchestrates the migration steps as specified by the . + /// + /// The . + /// true indicates success; otherwise, false. + public virtual async Task MigrateAsync(CancellationToken cancellationToken = default) + { + // Initialize for migration execution. + PreExecutionInitialization(); - // Walk the assembly hierarchy. - var alist = new List(); - var type = GetType(); - do - { - if (!alist.Contains(type.Assembly)) - alist.Add(type.Assembly); + // Where only creating a new script, then quickly do it and get out of here! + if (Args.MigrationCommand.HasFlag(MigrationCommand.Script)) + return await CreateScriptAsync(Args.Parameters.TryGetValue("Param0", out var p0) ? p0?.ToString() : null, Args.CreateStringParameters(), cancellationToken).ConfigureAwait(false); - type = type.BaseType; - } while (type != null && type != typeof(object)); + // Where only executing SQL statement, then execute and get out of here! + if (Args.MigrationCommand.HasFlag(MigrationCommand.Execute)) + return await ExecuteSqlStatementsAsync(Args.ExecuteStatements?.ToArray() ?? [], cancellationToken).ConfigureAwait(false); - var list2 = (List)ArtefactResourceAssemblies; - list2.AddRange(alist); - } + /* The remaining commands are executed in sequence as defined (where selected) to enable multiple in the correct run order. */ - /// - /// Verifies execution, then wraps and times the command execution. - /// - private async Task CommandExecuteAsync(MigrationCommand command, string title, Func> action, Func? summary, CancellationToken cancellationToken) - { - var isSelected = Args.MigrationCommand.HasFlag(command); + // Database drop. + if (!await CommandExecuteAsync(MigrationCommand.Drop, "DATABASE DROP: Checking database existence and dropping where found...", DatabaseDropAsync, null, cancellationToken).ConfigureAwait(false)) + return false; - if (!await OnBeforeCommandAsync(command, isSelected).ConfigureAwait(false)) - return false; + // Database create. + if (!await CommandExecuteAsync(MigrationCommand.Create, "DATABASE CREATE: Checking database existence and creating where not found...", DatabaseCreateAsync, null, cancellationToken).ConfigureAwait(false)) + return false; - if (isSelected) - { - if (!await CommandExecuteAsync(title, action, summary, cancellationToken).ConfigureAwait(false)) - return false; - } + // Database migration scripts. + if (!await CommandExecuteAsync(MigrationCommand.Migrate, "DATABASE MIGRATE: Migrating the database...", DatabaseMigrateAsync, null, cancellationToken).ConfigureAwait(false)) + return false; - return await OnAfterCommandAsync(command, isSelected).ConfigureAwait(false); + // Code-generation (where supported). + if (Args.MigrationCommand == MigrationCommand.CodeGen && !IsCodeGenEnabled) + { + Logger.LogWarning("Code-generation has not been enabled for the database migrator; this feature must be explicitly enabled."); + return false; } - /// - /// Provides an opportunity to perform additional processing before the is executed. - /// - /// The . - /// Indicates whether the is selected (see ). - /// true indicates success; otherwise, false. - /// This will be invoked for a command even where not selected for execution. - protected virtual Task OnBeforeCommandAsync(MigrationCommand command, bool isSelected) => Task.FromResult(true); - - /// - /// Provides an opportunity to perform additional processing after the is executed. - /// - /// The . - /// Indicates whether the is selected (see ). - /// true indicates success; otherwise, false. - /// This will be invoked for a command even where not selected for execution, or unless the command execution failed. - protected virtual Task OnAfterCommandAsync(MigrationCommand command, bool isSelected) => Task.FromResult(true); - - /// - /// Wraps and times the command execution. - /// - /// The title text. - /// The primary action to be performed. - /// Optional summary text appended to the complete log text. - /// The . - /// This will also catch any unhandled exceptions and log accordingly. - protected async Task CommandExecuteAsync(string title, Func> action, Func? summary, CancellationToken cancellationToken) + if (IsCodeGenEnabled) { - Logger.LogInformation("{Content}", string.Empty); - Logger.LogInformation("{Content}", new string('-', 80)); - Logger.LogInformation("{Content}", string.Empty); - Logger.LogInformation("{Content}", title.ThrowIfNull(nameof(title))); - - try - { - var sw = Stopwatch.StartNew(); - if (!await action.ThrowIfNull(nameof(action)).Invoke(cancellationToken).ConfigureAwait(false)) - return false; - - sw.Stop(); - Logger.LogInformation("{Content}", string.Empty); - Logger.LogInformation("{Content}", $"Complete. [{sw.Elapsed.TotalMilliseconds}ms{summary?.Invoke() ?? string.Empty}]"); - return true; - } - catch (Exception ex) - { - Logger.LogError(ex, "{Content}", ex.Message); + string? statistics = null; + string func() => statistics ?? throw new InvalidOperationException("Internal error; expected summary text output from code-generation."); + if (!await CommandExecuteAsync(MigrationCommand.CodeGen, "DATABASE CODEGEN: Code-gen database objects...", async ct => + { + var (Success, Statistics) = await DatabaseCodeGenAsync(ct).ConfigureAwait(false); + statistics = Statistics; + return Success; + }, func, cancellationToken).ConfigureAwait(false)) return false; - } } - /// - /// Execute the . - /// - /// The list. - /// Indicates whether to include detailed execution logging. - /// The . - /// true indicates success; otherwise, false. - protected virtual async Task ExecuteScriptsAsync(IEnumerable scripts, bool includeExecutionLogging, CancellationToken cancellationToken) - { - await Journal.EnsureExistsAsync(cancellationToken).ConfigureAwait(false); - HashSet? previous = null; - bool somethingExecuted = false; + // Database schema scripts. + if (!await CommandExecuteAsync(MigrationCommand.Schema, "DATABASE SCHEMA: Drops and creates/replaces the database objects...", DatabaseSchemaAsync, null, cancellationToken).ConfigureAwait(false)) + return false; - foreach (var script in scripts.OrderBy(x => x.GroupOrder).ThenBy(x => x.Name)) - { - if (!script.RunAlways) - { - previous ??= [.. await Journal.GetExecutedScriptsAsync(default).ConfigureAwait(false)]; - if (previous.Contains(script.Name)) - continue; - } + // Database reset. + if (!await CommandExecuteAsync(MigrationCommand.Reset, "DATABASE RESET: Resets database by dropping data from all tables...", DatabaseResetAsync, null, cancellationToken).ConfigureAwait(false)) + return false; - if (includeExecutionLogging) - Logger.LogInformation("{Content}", $" {script.Name} ({script.Source}){(string.IsNullOrEmpty(script.Tag) ? "" : $" > {script.Tag}")}"); + // Database data load. + if (!await CommandExecuteAsync(MigrationCommand.Data, "DATABASE DATA: Insert or merge the embedded data [yaml|json|sql]...", DatabaseDataAsync, null, cancellationToken).ConfigureAwait(false)) + return false; - try - { - await ExecuteScriptAsync(script, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.LogCritical(ex, "An error occurred executing the script: {Message}", ex.Message); - return false; - } + return true; + } - await Journal.AuditScriptExecutionAsync(script, default).ConfigureAwait(false); - somethingExecuted = true; - } + /// + /// Performs the pre-execution initialization. + /// + public void PreExecutionInitialization() + { + if (_hasInitialized) + return; - if (includeExecutionLogging && !somethingExecuted) - Logger.LogInformation("{Content}", " No new scripts found to execute."); + _hasInitialized = true; + SchemaConfig.PrepareMigrationArgs(); - return true; - } + var list = (List)Namespaces; + Args.ProbeAssemblies.ForEach(x => list.Add(x.Assembly.GetName().Name!)); - /// - /// Execute the (which may contain multiple commands). - /// - /// The . - /// The . - /// true indicates success; otherwise, false. - protected abstract Task ExecuteScriptAsync(DatabaseMigrationScript script, CancellationToken cancellationToken); - - /// - /// Determines whether the database exists; used by the and commands. - /// - /// The . - /// true indicates that the database exists; otherwise, false. - /// The @DatabaseName literal within the resulting (embedded resource) command is replaced by the using a (i.e. not database parameterized as not all databases support). - protected virtual async Task DatabaseExistsAsync(CancellationToken cancellationToken = default) + // Walk the assembly hierarchy. + var alist = new List(); + var type = GetType(); + do { - using var sr = GetRequiredResourcesStreamReader($"DatabaseExists.sql", [.. ArtefactResourceAssemblies]); - var name = await MasterDatabase.SqlStatement(ReplaceSqlRuntimeParameters(sr.ReadToEnd())).ScalarAsync(cancellationToken); - return name != null; - } + if (!alist.Contains(type.Assembly)) + alist.Add(type.Assembly); + + type = type.BaseType; + } while (type != null && type != typeof(object)); - /// - /// Performs the command. - /// - /// true indicates success; otherwise, false. - /// The . - /// This is invoked by using the . - /// The @DatabaseName literal within the resulting (embedded resource) command is replaced by the using a (i.e. not database parameterized as not all databases support). - protected virtual async Task DatabaseDropAsync(CancellationToken cancellationToken = default) + var list2 = (List)ArtefactResourceAssemblies; + list2.AddRange(alist); + } + + /// + /// Verifies execution, then wraps and times the command execution. + /// + private async Task CommandExecuteAsync(MigrationCommand command, string title, Func> action, Func? summary, CancellationToken cancellationToken) + { + var isSelected = Args.MigrationCommand.HasFlag(command); + + if (!await OnBeforeCommandAsync(command, isSelected).ConfigureAwait(false)) + return false; + + if (isSelected) { - Logger.LogInformation("{Content}", " Drop database..."); + if (!await CommandExecuteAsync(title, action, summary, cancellationToken).ConfigureAwait(false)) + return false; + } - var exists = await DatabaseExistsAsync(cancellationToken).ConfigureAwait(false); - if (!exists) - { - Logger.LogInformation("{Content}", $" Database '{DatabaseName}' does not exist and therefore not dropped."); - return true; - } + return await OnAfterCommandAsync(command, isSelected).ConfigureAwait(false); + } + + /// + /// Provides an opportunity to perform additional processing before the is executed. + /// + /// The . + /// Indicates whether the is selected (see ). + /// true indicates success; otherwise, false. + /// This will be invoked for a command even where not selected for execution. + protected virtual Task OnBeforeCommandAsync(MigrationCommand command, bool isSelected) => Task.FromResult(true); + + /// + /// Provides an opportunity to perform additional processing after the is executed. + /// + /// The . + /// Indicates whether the is selected (see ). + /// true indicates success; otherwise, false. + /// This will be invoked for a command even where not selected for execution, or unless the command execution failed. + protected virtual Task OnAfterCommandAsync(MigrationCommand command, bool isSelected) => Task.FromResult(true); + + /// + /// Wraps and times the command execution. + /// + /// The title text. + /// The primary action to be performed. + /// Optional summary text appended to the complete log text. + /// The . + /// This will also catch any unhandled exceptions and log accordingly. + protected async Task CommandExecuteAsync(string title, Func> action, Func? summary, CancellationToken cancellationToken) + { + Logger.LogInformation("{Content}", string.Empty); + Logger.LogInformation("{Content}", new string('-', 80)); + Logger.LogInformation("{Content}", string.Empty); + Logger.LogInformation("{Content}", title.ThrowIfNull(nameof(title))); - using var sr = GetRequiredResourcesStreamReader($"DatabaseDrop.sql", [.. ArtefactResourceAssemblies]); - await MasterDatabase.SqlStatement(ReplaceSqlRuntimeParameters(sr.ReadToEnd())).NonQueryAsync(cancellationToken); + try + { + var sw = Stopwatch.StartNew(); + if (!await action.ThrowIfNull(nameof(action)).Invoke(cancellationToken).ConfigureAwait(false)) + return false; - Logger.LogInformation("{Content}", $" Database '{DatabaseName}' dropped."); + sw.Stop(); + Logger.LogInformation("{Content}", string.Empty); + Logger.LogInformation("{Content}", $"Complete. [{sw.Elapsed.TotalMilliseconds}ms{summary?.Invoke() ?? string.Empty}]"); return true; } - - /// - /// Performs the command. - /// - /// The . - /// true indicates success; otherwise, false. - /// This is invoked by using the . - /// The @DatabaseName literal within the resulting (embedded resource) is replaced by the using a (i.e. not database parameterized as not all databases support). - protected virtual async Task DatabaseCreateAsync(CancellationToken cancellationToken = default) + catch (Exception ex) { - Logger.LogInformation("{Content}", " Create database..."); + Logger.LogError(ex, "{Content}", ex.Message); + return false; + } + } - var exists = await DatabaseExistsAsync(cancellationToken).ConfigureAwait(false); - if (exists) + /// + /// Execute the . + /// + /// The list. + /// Indicates whether to include detailed execution logging. + /// The . + /// true indicates success; otherwise, false. + protected virtual async Task ExecuteScriptsAsync(IEnumerable scripts, bool includeExecutionLogging, CancellationToken cancellationToken) + { + await Journal.EnsureExistsAsync(cancellationToken).ConfigureAwait(false); + HashSet? previous = null; + bool somethingExecuted = false; + + foreach (var script in scripts.OrderBy(x => x.GroupOrder).ThenBy(x => x.Name)) + { + if (!script.RunAlways) { - Logger.LogInformation("{Content}", $" Database '{DatabaseName}' already exists and therefore not created."); - return true; + previous ??= [.. await Journal.GetExecutedScriptsAsync(default).ConfigureAwait(false)]; + if (previous.Contains(script.Name)) + continue; } - using var sr = GetRequiredResourcesStreamReader($"DatabaseCreate.sql", [.. ArtefactResourceAssemblies]); - await MasterDatabase.SqlStatement(ReplaceSqlRuntimeParameters(sr.ReadToEnd())).NonQueryAsync(cancellationToken); - - Logger.LogInformation("{Content}", $" Database '{DatabaseName}' did not exist and was created."); - Logger.LogInformation("{Content}", string.Empty); - Logger.LogInformation("{Content}", $" Probing for '{OnDatabaseCreateName}' embedded resources: {string.Join(", ", GetNamespacesWithSuffix($"{MigrationsNamespace}.*.sql"))}"); + if (includeExecutionLogging) + Logger.LogInformation("{Content}", $" {script.Name} ({script.Source}){(string.IsNullOrEmpty(script.Tag) ? "" : $" > {script.Tag}")}"); - var scripts = new List(); - foreach (var ass in Args.ProbeAssemblies) + try { - foreach (var name in ass.Assembly.GetManifestResourceNames().Where(rn => Namespaces.Any(ns => rn.StartsWith($"{ns}.{MigrationsNamespace}.", StringComparison.InvariantCulture) && rn.EndsWith($".{OnDatabaseCreateName}.sql", StringComparison.InvariantCultureIgnoreCase))).OrderBy(x => x)) - { - scripts.Add(new DatabaseMigrationScript(this, ass.Assembly, name) { RunAlways = true }); - } + await ExecuteScriptAsync(script, cancellationToken).ConfigureAwait(false); } - - if (scripts.Count == 0) + catch (Exception ex) { - Logger.LogInformation("{Content}", NothingFoundText); - return true; + Logger.LogCritical(ex, "An error occurred executing the script: {Message}", ex.Message); + return false; } - Logger.LogInformation("{Content}", " Execute the embedded resources..."); - return await ExecuteScriptsAsync(scripts, true, cancellationToken).ConfigureAwait(false); + await Journal.AuditScriptExecutionAsync(script, default).ConfigureAwait(false); + somethingExecuted = true; } - /// - /// Performs the command. - /// - private async Task DatabaseMigrateAsync(CancellationToken cancellationToken) - { - Logger.LogInformation("{Content}", $" Probing for embedded resources: {string.Join(", ", GetNamespacesWithSuffix($"{MigrationsNamespace}.*.sql"))}"); + if (includeExecutionLogging && !somethingExecuted) + Logger.LogInformation("{Content}", " No new scripts found to execute."); - // Function to add the script in a consistent manner. - void AddScript(List scripts, Assembly assembly, string name) - { - // A name should be unique; always use the first version. - if (scripts.Any(x => x.Name == name)) - return; + return true; + } - // Determine run order and add script to list. - var order = name.EndsWith(".pre.deploy.sql", StringComparison.InvariantCultureIgnoreCase) ? 1 : - name.EndsWith(".post.deploy.sql", StringComparison.InvariantCultureIgnoreCase) ? 3 : 2; + /// + /// Execute the (which may contain multiple commands). + /// + /// The . + /// The . + /// true indicates success; otherwise, false. + protected abstract Task ExecuteScriptAsync(DatabaseMigrationScript script, CancellationToken cancellationToken); - scripts.Add(new DatabaseMigrationScript(this, assembly, name) { GroupOrder = order, RunAlways = order != 2 }); - }; + /// + /// Determines whether the database exists; used by the and commands. + /// + /// The . + /// true indicates that the database exists; otherwise, false. + /// The @DatabaseName literal within the resulting (embedded resource) command is replaced by the using a (i.e. not database parameterized as not all databases support). + protected virtual async Task DatabaseExistsAsync(CancellationToken cancellationToken = default) + { + using var sr = GetRequiredResourcesStreamReader($"DatabaseExists.sql", [.. ArtefactResourceAssemblies]); + var name = await MasterDatabase.SqlStatement(ReplaceSqlRuntimeParameters(sr.ReadToEnd())).ScalarAsync(cancellationToken); + return name != null; + } - // Get all the resources and their included scripts from the assemblies. - var scripts = new List(); - foreach (var ass in Args.ProbeAssemblies) - { - foreach (var name in ass.Assembly.GetManifestResourceNames().Where(rn => Namespaces.Any(ns => rn.StartsWith($"{ns}.{MigrationsNamespace}.", StringComparison.InvariantCulture))).OrderBy(x => x)) - { - // Ignore any/all database create scripts. - if (name.EndsWith($".{OnDatabaseCreateName}.sql", StringComparison.InvariantCultureIgnoreCase)) - continue; + /// + /// Performs the command. + /// + /// true indicates success; otherwise, false. + /// The . + /// This is invoked by using the . + /// The @DatabaseName literal within the resulting (embedded resource) command is replaced by the using a (i.e. not database parameterized as not all databases support). + protected virtual async Task DatabaseDropAsync(CancellationToken cancellationToken = default) + { + Logger.LogInformation("{Content}", " Drop database..."); - AddScript(scripts, ass.Assembly, name); - } - } + var exists = await DatabaseExistsAsync(cancellationToken).ConfigureAwait(false); + if (!exists) + { + Logger.LogInformation("{Content}", $" Database '{DatabaseName}' does not exist and therefore not dropped."); + return true; + } - // Include any explicitly named scripts. - foreach (var s in Args.Scripts.Where(x => x.Command == MigrationCommand.Migrate)) - { - AddScript(scripts, s.Assembly, s.Name); - } + using var sr = GetRequiredResourcesStreamReader($"DatabaseDrop.sql", [.. ArtefactResourceAssemblies]); + await MasterDatabase.SqlStatement(ReplaceSqlRuntimeParameters(sr.ReadToEnd())).NonQueryAsync(cancellationToken); - if (scripts.Count == 0) - { - Logger.LogInformation("{Content}", NothingFoundText); - return true; - } + Logger.LogInformation("{Content}", $" Database '{DatabaseName}' dropped."); + return true; + } - Logger.LogInformation("{Content}", " Execute the embedded resources..."); - return await ExecuteScriptsAsync(scripts, true, cancellationToken).ConfigureAwait(false); + /// + /// Performs the command. + /// + /// The . + /// true indicates success; otherwise, false. + /// This is invoked by using the . + /// The @DatabaseName literal within the resulting (embedded resource) is replaced by the using a (i.e. not database parameterized as not all databases support). + protected virtual async Task DatabaseCreateAsync(CancellationToken cancellationToken = default) + { + Logger.LogInformation("{Content}", " Create database..."); + + var exists = await DatabaseExistsAsync(cancellationToken).ConfigureAwait(false); + if (exists) + { + Logger.LogInformation("{Content}", $" Database '{DatabaseName}' already exists and therefore not created."); + return true; } - /// - /// Performs the command. - /// - /// The . - /// true indicates success; otherwise, false. Additionally, on success the code-generation statistics summary should be returned to append to the log. - /// This will only be invoked where is set to true. The method must be implemented otherwise a will be thrown. - protected virtual Task<(bool Success, string? Statistics)> DatabaseCodeGenAsync(CancellationToken cancellationToken = default) - => throw new NotImplementedException($"The {nameof(DatabaseCodeGenAsync)} method must be implemented by the inheriting class to enable the code-generation functionality."); - - /// - /// Performs the command. - /// - private async Task DatabaseSchemaAsync(CancellationToken cancellationToken) + using var sr = GetRequiredResourcesStreamReader($"DatabaseCreate.sql", [.. ArtefactResourceAssemblies]); + await MasterDatabase.SqlStatement(ReplaceSqlRuntimeParameters(sr.ReadToEnd())).NonQueryAsync(cancellationToken); + + Logger.LogInformation("{Content}", $" Database '{DatabaseName}' did not exist and was created."); + Logger.LogInformation("{Content}", string.Empty); + Logger.LogInformation("{Content}", $" Probing for '{OnDatabaseCreateName}' embedded resources: {string.Join(", ", GetNamespacesWithSuffix($"{MigrationsNamespace}.*.sql"))}"); + + var scripts = new List(); + foreach (var ass in Args.ProbeAssemblies) { - if (SchemaObjectTypes.Length == 0) + foreach (var name in ass.Assembly.GetManifestResourceNames().Where(rn => Namespaces.Any(ns => rn.StartsWith($"{ns}.{MigrationsNamespace}.", StringComparison.InvariantCulture) && rn.EndsWith($".{OnDatabaseCreateName}.sql", StringComparison.InvariantCultureIgnoreCase))).OrderBy(x => x)) { - Logger.LogWarning("{Content}", $" No schema object types have been configured for support; as such this command will not be executed."); - return true; + scripts.Add(new DatabaseMigrationScript(this, ass.Assembly, name) { RunAlways = true }); } + } - // Build list of all known schema type objects to be dropped and created. - var scripts = new List(); + if (scripts.Count == 0) + { + Logger.LogInformation("{Content}", NothingFoundText); + return true; + } - // See if there are any files out there that should take precedence over embedded resources. - var dir = new DirectoryInfo(CodeGenConsole.GetBaseExeDirectory()); - if (dir != null && dir.Exists) - { - var di = new DirectoryInfo(Path.Combine(dir.FullName, SchemaNamespace)); - Logger.LogInformation("{Content}", $" Probing for files (recursively): {Path.Combine(di.FullName, "*", "*.sql")}"); + Logger.LogInformation("{Content}", " Execute the embedded resources..."); + return await ExecuteScriptsAsync(scripts, true, cancellationToken).ConfigureAwait(false); + } - if (di.Exists) - { - foreach (var fi in di.GetFiles("*.sql", SearchOption.AllDirectories)) - { - var rn = $"{fi.FullName[((dir.Parent?.FullName.Length + 1) ?? 0)..]}".Replace(' ', '_').Replace('-', '_').Replace('\\', '.').Replace('/', '.'); - scripts.Add(new DatabaseMigrationScript(this, fi, rn)); - } - } - } + /// + /// Performs the command. + /// + private async Task DatabaseMigrateAsync(CancellationToken cancellationToken) + { + Logger.LogInformation("{Content}", $" Probing for embedded resources: {string.Join(", ", GetNamespacesWithSuffix($"{MigrationsNamespace}.*.sql"))}"); - // Get all the resources from the assemblies. - Logger.LogInformation("{Content}", $" Probing for embedded resources: {string.Join(", ", GetNamespacesWithSuffix($"{SchemaNamespace}.*.sql"))}"); - foreach (var ass in Args.ProbeAssemblies) - { - foreach (var rn in ass.Assembly.GetManifestResourceNames().OrderBy(x => x)) - { - // Filter on schema namespace prefix and suffix of '.sql'. - if (!(Namespaces.Any(x => rn.StartsWith($"{x}.{SchemaNamespace}.", StringComparison.InvariantCulture) && rn.EndsWith(".sql", StringComparison.InvariantCultureIgnoreCase)))) - continue; + // Function to add the script in a consistent manner. + void AddScript(List scripts, Assembly assembly, string name) + { + // A name should be unique; always use the first version. + if (scripts.Any(x => x.Name == name)) + return; - // Filter out any picked up from file system probe above. - if (scripts.Any(x => x.Name == rn)) - continue; + // Determine run order and add script to list. + var order = name.EndsWith(".pre.deploy.sql", StringComparison.InvariantCultureIgnoreCase) ? 1 : + name.EndsWith(".post.deploy.sql", StringComparison.InvariantCultureIgnoreCase) ? 3 : 2; - scripts.Add(new DatabaseMigrationScript(this, ass.Assembly, rn)); - } - } + scripts.Add(new DatabaseMigrationScript(this, assembly, name) { GroupOrder = order, RunAlways = order != 2 }); + }; - // Include any explicitly named scripts. - foreach (var ss in Args.Scripts.Where(x => x.Command == MigrationCommand.Schema)) + // Get all the resources and their included scripts from the assemblies. + var scripts = new List(); + foreach (var ass in Args.ProbeAssemblies) + { + foreach (var name in ass.Assembly.GetManifestResourceNames().Where(rn => Namespaces.Any(ns => rn.StartsWith($"{ns}.{MigrationsNamespace}.", StringComparison.InvariantCulture))).OrderBy(x => x)) { - if (scripts.Any(x => x.Name == ss.Name)) + // Ignore any/all database create scripts. + if (name.EndsWith($".{OnDatabaseCreateName}.sql", StringComparison.InvariantCultureIgnoreCase)) continue; - scripts.Add(new DatabaseMigrationScript(this, ss.Assembly, ss.Name)); - } - - // Make sure there is work to be done. - if (scripts.Count == 0) - { - Logger.LogInformation("{Content}", NothingFoundText); - return true; + AddScript(scripts, ass.Assembly, name); } + } - // Execute the database specific logic. - return await DatabaseSchemaAsync(scripts, cancellationToken).ConfigureAwait(false); + // Include any explicitly named scripts. + foreach (var s in Args.Scripts.Where(x => x.Command == MigrationCommand.Migrate)) + { + AddScript(scripts, s.Assembly, s.Name); } - /// - /// Performs the command. - /// - /// The list discovered during the file and resource probes. - /// The . - /// true indicates success; otherwise, false. - /// This is invoked by using the . - protected virtual async Task DatabaseSchemaAsync(List migrationScripts, CancellationToken cancellationToken = default) + if (scripts.Count == 0) { - // Parse each migration script and convert to the corresponding schema script. - var list = new List(); - foreach (var migrationScript in migrationScripts) - { - var script = ValidateAndReadySchemaScript(CreateSchemaScript(migrationScript)); - if (script.HasError) - { - Logger.LogError("{Content}", $"SQL script '{migrationScript.Name}' is not valid: {script.ErrorMessage}"); - return false; - } + Logger.LogInformation("{Content}", NothingFoundText); + return true; + } - list.Add(script); - } + Logger.LogInformation("{Content}", " Execute the embedded resources..."); + return await ExecuteScriptsAsync(scripts, true, cancellationToken).ConfigureAwait(false); + } - // Drop all existing (in reverse order). - Logger.LogInformation("{Content}", string.Empty); - Logger.LogInformation("{Content}", " Drop known schema objects..."); + /// + /// Performs the command. + /// + /// The . + /// true indicates success; otherwise, false. Additionally, on success the code-generation statistics summary should be returned to append to the log. + /// This will only be invoked where is set to true. The method must be implemented otherwise a will be thrown. + protected virtual Task<(bool Success, string? Statistics)> DatabaseCodeGenAsync(CancellationToken cancellationToken = default) + => throw new NotImplementedException($"The {nameof(DatabaseCodeGenAsync)} method must be implemented by the inheriting class to enable the code-generation functionality."); - var fullDrop = Args.DropSchemaObjects; - if (!fullDrop && MustDropSchemaObjectTypes.Length > 0) - fullDrop = list.Where(x => MustDropSchemaObjectTypes.Contains(x.Type, StringComparer.OrdinalIgnoreCase)).Any(); + /// + /// Performs the command. + /// + private async Task DatabaseSchemaAsync(CancellationToken cancellationToken) + { + if (SchemaObjectTypes.Length == 0) + { + Logger.LogWarning("{Content}", $" No schema object types have been configured for support; as such this command will not be executed."); + return true; + } + + // Build list of all known schema type objects to be dropped and created. + var scripts = new List(); - int i = 0; - var ss = new List(); - if (fullDrop || list.Where(x => !x.SupportsReplace).Any()) + // See if there are any files out there that should take precedence over embedded resources. + var dir = new DirectoryInfo(CodeGenConsole.GetBaseExeDirectory()); + if (dir != null && dir.Exists) + { + var di = new DirectoryInfo(Path.Combine(dir.FullName, SchemaNamespace)); + Logger.LogInformation("{Content}", $" Probing for files (recursively): {Path.Combine(di.FullName, "*", "*.sql")}"); + + if (di.Exists) { - foreach (var sor in list.Where(x => fullDrop || !x.SupportsReplace).OrderByDescending(x => x.SchemaOrder).ThenByDescending(x => x.TypeOrder).ThenByDescending(x => x.Schema).ThenByDescending(x => x.Name)) + foreach (var fi in di.GetFiles("*.sql", SearchOption.AllDirectories)) { - ss.Add(new DatabaseMigrationScript(this, sor.SqlDropStatement, sor.SqlDropStatement) { GroupOrder = i++, RunAlways = true }); + var rn = $"{fi.FullName[((dir.Parent?.FullName.Length + 1) ?? 0)..]}".Replace(' ', '_').Replace('-', '_').Replace('\\', '.').Replace('/', '.'); + scripts.Add(new DatabaseMigrationScript(this, fi, rn)); } - - if (!await ExecuteScriptsAsync(ss, true, cancellationToken).ConfigureAwait(false)) - return false; } - else - Logger.LogInformation("{Content}", " ** Note: All schema objects implement replace functionality and therefore there is no need to drop existing. **"); + } - // Execute each migration script proper (i.e. create 'em as scripted). - i = 0; - ss.Clear(); - Logger.LogInformation("{Content}", string.Empty); - Logger.LogInformation("{Content}", " Create (or replace) known schema objects..."); - foreach (var sor in list.OrderBy(x => x.SchemaOrder).ThenBy(x => x.TypeOrder).ThenBy(x => x.Schema).ThenBy(x => x.Name)) + // Get all the resources from the assemblies. + Logger.LogInformation("{Content}", $" Probing for embedded resources: {string.Join(", ", GetNamespacesWithSuffix($"{SchemaNamespace}.*.sql"))}"); + foreach (var ass in Args.ProbeAssemblies) + { + foreach (var rn in ass.Assembly.GetManifestResourceNames().OrderBy(x => x)) { - var migrationScript = sor.MigrationScript; - migrationScript.GroupOrder = i++; - migrationScript.RunAlways = true; - migrationScript.Tag = sor.SqlCreateStatement; - ss.Add(migrationScript); + // Filter on schema namespace prefix and suffix of '.sql'. + if (!(Namespaces.Any(x => rn.StartsWith($"{x}.{SchemaNamespace}.", StringComparison.InvariantCulture) && rn.EndsWith(".sql", StringComparison.InvariantCultureIgnoreCase)))) + continue; + + // Filter out any picked up from file system probe above. + if (scripts.Any(x => x.Name == rn)) + continue; + + scripts.Add(new DatabaseMigrationScript(this, ass.Assembly, rn)); } + } - return await ExecuteScriptsAsync(ss, true, cancellationToken).ConfigureAwait(false); + // Include any explicitly named scripts. + foreach (var ss in Args.Scripts.Where(x => x.Command == MigrationCommand.Schema)) + { + if (scripts.Any(x => x.Name == ss.Name)) + continue; + + scripts.Add(new DatabaseMigrationScript(this, ss.Assembly, ss.Name)); + } + + // Make sure there is work to be done. + if (scripts.Count == 0) + { + Logger.LogInformation("{Content}", NothingFoundText); + return true; } - /// - /// Creates a corresponding from the . - /// - /// The . - /// The corresponding . - protected abstract DatabaseSchemaScriptBase CreateSchemaScript(DatabaseMigrationScript migrationScript); - - /// - /// Validate and ready schema (assign type and schema order) script. - /// - private DatabaseSchemaScriptBase ValidateAndReadySchemaScript(DatabaseSchemaScriptBase script) + // Execute the database specific logic. + return await DatabaseSchemaAsync(scripts, cancellationToken).ConfigureAwait(false); + } + + /// + /// Performs the command. + /// + /// The list discovered during the file and resource probes. + /// The . + /// true indicates success; otherwise, false. + /// This is invoked by using the . + protected virtual async Task DatabaseSchemaAsync(List migrationScripts, CancellationToken cancellationToken = default) + { + // Parse each migration script and convert to the corresponding schema script. + var list = new List(); + foreach (var migrationScript in migrationScripts) { + var script = ValidateAndReadySchemaScript(CreateSchemaScript(migrationScript)); if (script.HasError) - return script; + { + Logger.LogError("{Content}", $"SQL script '{migrationScript.Name}' is not valid: {script.ErrorMessage}"); + return false; + } + + list.Add(script); + } - var index = Array.FindIndex(SchemaObjectTypes, x => string.Compare(x, script.Type, StringComparison.OrdinalIgnoreCase) == 0); - if (index < 0) + // Drop all existing (in reverse order). + Logger.LogInformation("{Content}", string.Empty); + Logger.LogInformation("{Content}", " Drop known schema objects..."); + + var fullDrop = Args.DropSchemaObjects; + if (!fullDrop && MustDropSchemaObjectTypes.Length > 0) + fullDrop = list.Where(x => MustDropSchemaObjectTypes.Contains(x.Type, StringComparer.OrdinalIgnoreCase)).Any(); + + int i = 0; + var ss = new List(); + if (fullDrop || list.Where(x => !x.SupportsReplace).Any()) + { + foreach (var sor in list.Where(x => fullDrop || !x.SupportsReplace).OrderByDescending(x => x.SchemaOrder).ThenByDescending(x => x.TypeOrder).ThenByDescending(x => x.Schema).ThenByDescending(x => x.Name)) { - script.ErrorMessage = $"The SQL statement `CREATE` with object type '{script.Type}' is not supported; only the following are supported: {string.Join(", ", SchemaObjectTypes)}."; - return script; + ss.Add(new DatabaseMigrationScript(this, sor.SqlDropStatement, sor.SqlDropStatement) { GroupOrder = i++, RunAlways = true }); } - script.TypeOrder = index; + if (!await ExecuteScriptsAsync(ss, true, cancellationToken).ConfigureAwait(false)) + return false; + } + else + Logger.LogInformation("{Content}", " ** Note: All schema objects implement replace functionality and therefore there is no need to drop existing. **"); + + // Execute each migration script proper (i.e. create 'em as scripted). + i = 0; + ss.Clear(); + Logger.LogInformation("{Content}", string.Empty); + Logger.LogInformation("{Content}", " Create (or replace) known schema objects..."); + foreach (var sor in list.OrderBy(x => x.SchemaOrder).ThenBy(x => x.TypeOrder).ThenBy(x => x.Schema).ThenBy(x => x.Name)) + { + var migrationScript = sor.MigrationScript; + migrationScript.GroupOrder = i++; + migrationScript.RunAlways = true; + migrationScript.Tag = sor.SqlCreateStatement; + ss.Add(migrationScript); + } - if (script.Schema != null) - script.SchemaOrder = Args.SchemaOrder.IndexOf(script.Schema); + return await ExecuteScriptsAsync(ss, true, cancellationToken).ConfigureAwait(false); + } - if (script.SchemaOrder < 0) - script.SchemaOrder = Args.SchemaOrder.Count; + /// + /// Creates a corresponding from the . + /// + /// The . + /// The corresponding . + protected abstract DatabaseSchemaScriptBase CreateSchemaScript(DatabaseMigrationScript migrationScript); + /// + /// Validate and ready schema (assign type and schema order) script. + /// + private DatabaseSchemaScriptBase ValidateAndReadySchemaScript(DatabaseSchemaScriptBase script) + { + if (script.HasError) return script; - } - /// - /// Performs the command. - /// - /// The . - /// true indicates success; otherwise, false. - /// This is invoked by using the . - protected virtual async Task DatabaseResetAsync(CancellationToken cancellationToken = default) + var index = Array.FindIndex(SchemaObjectTypes, x => string.Compare(x, script.Type, StringComparison.OrdinalIgnoreCase) == 0); + if (index < 0) { - Logger.LogInformation("{Content}", " Querying database to infer table(s) schema..."); + script.ErrorMessage = $"The SQL statement `CREATE` with object type '{script.Type}' is not supported; only the following are supported: {string.Join(", ", SchemaObjectTypes)}."; + return script; + } - var tables = await Database.SelectSchemaAsync(this, cancellationToken).ConfigureAwait(false); - var query = tables.Where(DataResetFilterPredicate); - if (Args.DataResetFilterPredicate != null) - query = query.Where(Args.DataResetFilterPredicate); + script.TypeOrder = index; - Logger.LogInformation("{Content}", " Deleting data from all tables (except filtered)..."); - var delete = query.Where(x => !x.IsAView).ToList(); - if (delete.Count == 0) - { - Logger.LogInformation("{Content}", " None."); - return true; - } + if (script.Schema != null) + script.SchemaOrder = Args.SchemaOrder.IndexOf(script.Schema); - using var sr = GetRequiredResourcesStreamReader($"DatabaseReset_sql", [.. ArtefactResourceAssemblies], StreamLocator.HandlebarsExtensions); - var cg = new HandlebarsCodeGenerator(sr); - var sql = cg.Generate(delete); + if (script.SchemaOrder < 0) + script.SchemaOrder = Args.SchemaOrder.Count; - using var sr2 = new StringReader(sql); - string? line; - while ((line = sr2.ReadLine()) != null) - { - Logger.LogInformation("{Content}", $" {line}"); - } + return script; + } - await Database.SqlStatement(ReplaceSqlRuntimeParameters(sql)).SelectQueryAsync(dr => { Logger.LogInformation("{Content}", $" {dr.GetValue("FQN")}"); return 0; }, cancellationToken).ConfigureAwait(false); + /// + /// Performs the command. + /// + /// The . + /// true indicates success; otherwise, false. + /// This is invoked by using the . + protected virtual async Task DatabaseResetAsync(CancellationToken cancellationToken = default) + { + Logger.LogInformation("{Content}", " Querying database to infer table(s) schema..."); + + var tables = await Database.SelectSchemaAsync(this, cancellationToken).ConfigureAwait(false); + var query = tables.Where(DataResetFilterPredicate); + if (Args.DataResetFilterPredicate != null) + query = query.Where(Args.DataResetFilterPredicate); + Logger.LogInformation("{Content}", " Deleting data from all tables (except filtered)..."); + var delete = query.Where(x => !x.IsAView).ToList(); + if (delete.Count == 0) + { + Logger.LogInformation("{Content}", " None."); return true; } - /// - /// Gets the table filtering predicate. - /// - /// Used to filter out any system or internal tables that should not be reset. The is applied after this predicate (i.e. it can not override the base filtering). - protected abstract Func DataResetFilterPredicate { get; } + using var sr = GetRequiredResourcesStreamReader($"DatabaseReset_sql", [.. ArtefactResourceAssemblies], StreamLocator.HandlebarsExtensions); + var cg = new HandlebarsCodeGenerator(sr); + var sql = cg.Generate(delete); - /// - /// Performs the command. - /// - private async Task DatabaseDataAsync(CancellationToken cancellationToken) + using var sr2 = new StringReader(sql); + string? line; + while ((line = sr2.ReadLine()) != null) { - var names = new List(); - foreach (var ass in Args.Assemblies) + Logger.LogInformation("{Content}", $" {line}"); + } + + await Database.SqlStatement(ReplaceSqlRuntimeParameters(sql)).SelectQueryAsync(dr => { Logger.LogInformation("{Content}", $" {dr.GetValue("FQN")}"); return 0; }, cancellationToken).ConfigureAwait(false); + + return true; + } + + /// + /// Gets the table filtering predicate. + /// + /// Used to filter out any system or internal tables that should not be reset. The is applied after this predicate (i.e. it can not override the base filtering). + protected abstract Func DataResetFilterPredicate { get; } + + /// + /// Performs the command. + /// + private async Task DatabaseDataAsync(CancellationToken cancellationToken) + { + var names = new List(); + foreach (var ass in Args.Assemblies) + { + foreach (var dns in ass.DataNamespaces) { - foreach (var dns in ass.DataNamespaces) - { - names.Add($"{ass.Assembly.GetName().Name}.{dns}.*"); - } + names.Add($"{ass.Assembly.GetName().Name}.{dns}.*"); } + } - Logger.LogInformation("{Content}", $" Probing for embedded resources: {string.Join(", ", names)}"); + Logger.LogInformation("{Content}", $" Probing for embedded resources: {string.Join(", ", names)}"); - var list = new List<(Assembly Assembly, string ResourceName)>(); - foreach (var ass in Args.Assemblies) + var list = new List<(Assembly Assembly, string ResourceName)>(); + foreach (var ass in Args.Assemblies) + { + foreach (var rn in ass.Assembly.GetManifestResourceNames().OrderBy(x => x)) { - foreach (var rn in ass.Assembly.GetManifestResourceNames().OrderBy(x => x)) + foreach (var dns in ass.DataNamespaces) { - foreach (var dns in ass.DataNamespaces) - { - // Filter on schema namespace prefix and supported suffixes. - if (!Namespaces.Any(x => rn.StartsWith($"{x}.{dns}.", StringComparison.InvariantCulture) && (rn.EndsWith(".sql", StringComparison.InvariantCultureIgnoreCase) - || rn.EndsWith(".yaml", StringComparison.InvariantCultureIgnoreCase) || rn.EndsWith(".yml", StringComparison.InvariantCultureIgnoreCase) - || rn.EndsWith(".json", StringComparison.InvariantCultureIgnoreCase) || rn.EndsWith(".jsn", StringComparison.InvariantCultureIgnoreCase)))) - continue; - - list.Add((ass.Assembly, rn)); - } + // Filter on schema namespace prefix and supported suffixes. + if (!Namespaces.Any(x => rn.StartsWith($"{x}.{dns}.", StringComparison.InvariantCulture) && (rn.EndsWith(".sql", StringComparison.InvariantCultureIgnoreCase) + || rn.EndsWith(".yaml", StringComparison.InvariantCultureIgnoreCase) || rn.EndsWith(".yml", StringComparison.InvariantCultureIgnoreCase) + || rn.EndsWith(".json", StringComparison.InvariantCultureIgnoreCase) || rn.EndsWith(".jsn", StringComparison.InvariantCultureIgnoreCase)))) + continue; + + list.Add((ass.Assembly, rn)); } } + } - // Make sure there is work to be done. - if (list.Count == 0) - { - Logger.LogInformation("{Content}", NothingFoundText); - return true; - } + // Make sure there is work to be done. + if (list.Count == 0) + { + Logger.LogInformation("{Content}", NothingFoundText); + return true; + } + + // Infer database schema. + Logger.LogInformation("{Content}", " Querying database to infer table(s)/column(s) schema..."); + var dbTables = await Database.SelectSchemaAsync(this, cancellationToken).ConfigureAwait(false); - // Infer database schema. - Logger.LogInformation("{Content}", " Querying database to infer table(s)/column(s) schema..."); - var dbTables = await Database.SelectSchemaAsync(this, cancellationToken).ConfigureAwait(false); + // Iterate through each resource - parse the data, then insert/merge as requested. + var parser = new DataParser(this, dbTables); + foreach (var item in list) + { + using var sr = new StreamReader(item.Assembly.GetManifestResourceStream(item.ResourceName)!); - // Iterate through each resource - parse the data, then insert/merge as requested. - var parser = new DataParser(this, dbTables); - foreach (var item in list) + if (item.ResourceName.EndsWith(".sql", StringComparison.InvariantCultureIgnoreCase)) { - using var sr = new StreamReader(item.Assembly.GetManifestResourceStream(item.ResourceName)!); + // Execute the SQL script directly. + Logger.LogInformation("{Content}", string.Empty); + Logger.LogInformation("{Content}", $"** Executing: {item.ResourceName}"); - if (item.ResourceName.EndsWith(".sql", StringComparison.InvariantCultureIgnoreCase)) + var ss = new DatabaseMigrationScript(this, item.Assembly, item.ResourceName) { RunAlways = true }; + if (!await ExecuteScriptsAsync([ss], false, cancellationToken).ConfigureAwait(false)) + return false; + } + else + { + // Handle the YAML/JSON - parse and execute. + try { - // Execute the SQL script directly. Logger.LogInformation("{Content}", string.Empty); - Logger.LogInformation("{Content}", $"** Executing: {item.ResourceName}"); + Logger.LogInformation("{Content}", $"** Parsing and executing: {item.ResourceName}"); + + var tables = item.ResourceName.EndsWith(".json", StringComparison.InvariantCultureIgnoreCase) || item.ResourceName.EndsWith(".jsn", StringComparison.InvariantCultureIgnoreCase) + ? await parser.ParseJsonAsync(sr, cancellationToken).ConfigureAwait(false) + : await parser.ParseYamlAsync(sr, cancellationToken).ConfigureAwait(false); - var ss = new DatabaseMigrationScript(this, item.Assembly, item.ResourceName) { RunAlways = true }; - if (!await ExecuteScriptsAsync([ss], false, cancellationToken).ConfigureAwait(false)) + if (!await DatabaseDataAsync(tables, cancellationToken).ConfigureAwait(false)) return false; } - else + catch (DataParserException dpex) { - // Handle the YAML/JSON - parse and execute. - try - { - Logger.LogInformation("{Content}", string.Empty); - Logger.LogInformation("{Content}", $"** Parsing and executing: {item.ResourceName}"); - - var tables = item.ResourceName.EndsWith(".json", StringComparison.InvariantCultureIgnoreCase) || item.ResourceName.EndsWith(".jsn", StringComparison.InvariantCultureIgnoreCase) - ? await parser.ParseJsonAsync(sr, cancellationToken).ConfigureAwait(false) - : await parser.ParseYamlAsync(sr, cancellationToken).ConfigureAwait(false); - - if (!await DatabaseDataAsync(tables, cancellationToken).ConfigureAwait(false)) - return false; - } - catch (DataParserException dpex) - { - Logger.LogError("{Content}", dpex.Message); - return false; - } + Logger.LogError("{Content}", dpex.Message); + return false; } } - - // All good if we got this far! - return true; } - /// - /// Performs the command. - /// - /// The list that contains the parsed data to be inserted/merged. - /// The . - /// true indicates success; otherwise, false. - /// This is invoked by using the . - protected virtual async Task DatabaseDataAsync(List dataTables, CancellationToken cancellationToken = default) + // All good if we got this far! + return true; + } + + /// + /// Performs the command. + /// + /// The list that contains the parsed data to be inserted/merged. + /// The . + /// true indicates success; otherwise, false. + /// This is invoked by using the . + protected virtual async Task DatabaseDataAsync(List dataTables, CancellationToken cancellationToken = default) + { + // Cache the compiled code-gen template. + if (_dataCodeGen == null) { - // Cache the compiled code-gen template. - if (_dataCodeGen == null) - { - using var sr = GetRequiredResourcesStreamReader($"DatabaseData_sql", [.. ArtefactResourceAssemblies], StreamLocator.HandlebarsExtensions); + using var sr = GetRequiredResourcesStreamReader($"DatabaseData_sql", [.. ArtefactResourceAssemblies], StreamLocator.HandlebarsExtensions); #if NET7_0_OR_GREATER - _dataCodeGen = new HandlebarsCodeGenerator(await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false)); + _dataCodeGen = new HandlebarsCodeGenerator(await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false)); #else - _dataCodeGen = new HandlebarsCodeGenerator(await sr.ReadToEndAsync().ConfigureAwait(false)); + _dataCodeGen = new HandlebarsCodeGenerator(await sr.ReadToEndAsync().ConfigureAwait(false)); #endif - } + } - foreach (var table in dataTables) + foreach (var table in dataTables) + { + Logger.LogInformation("{Content}", string.Empty); + Logger.LogInformation("{Content}", $"---- Executing {table.Schema}{(table.Schema == string.Empty ? "" : ".")}{table.Name} SQL:"); + + if (table.PreConditionSql is not null) { + var csql = ReplaceSqlRuntimeParameters(table.PreConditionSql); + Logger.LogInformation("{Content}", "Execute pre-condition SQL:"); + Logger.LogInformation("{Content}", csql); Logger.LogInformation("{Content}", string.Empty); - Logger.LogInformation("{Content}", $"---- Executing {table.Schema}{(table.Schema == string.Empty ? "" : ".")}{table.Name} SQL:"); - if (table.PreConditionSql is not null) + var result = await Database.SqlStatement(ReplaceSqlRuntimeParameters(csql)).ScalarAsync(cancellationToken).ConfigureAwait(false); + if (result == 0) { - var csql = ReplaceSqlRuntimeParameters(table.PreConditionSql); - Logger.LogInformation("{Content}", "Execute pre-condition SQL:"); - Logger.LogInformation("{Content}", csql); - Logger.LogInformation("{Content}", string.Empty); - - var result = await Database.SqlStatement(ReplaceSqlRuntimeParameters(csql)).ScalarAsync(cancellationToken).ConfigureAwait(false); - if (result == 0) - { - Logger.LogInformation("{Content}", $"Result: Pre-condition was _not_ satisfied."); - continue; - } - - Logger.LogInformation("{Content}", $"Result: Pre-condition was satisfied."); - Logger.LogInformation("{Content}", string.Empty); + Logger.LogInformation("{Content}", $"Result: Pre-condition was _not_ satisfied."); + continue; } - var sql = ReplaceSqlRuntimeParameters(_dataCodeGen.Generate(table)); - Logger.LogInformation("{Content}", sql); - - var rows = await Database.SqlStatement(sql).ScalarAsync(cancellationToken).ConfigureAwait(false); - Logger.LogInformation("{Content}", $"Result: {rows} rows affected."); + Logger.LogInformation("{Content}", $"Result: Pre-condition was satisfied."); + Logger.LogInformation("{Content}", string.Empty); } - return true; - } + var sql = ReplaceSqlRuntimeParameters(_dataCodeGen.Generate(table)); + Logger.LogInformation("{Content}", sql); - /// - /// Gets the with the specified namespace suffix applied. - /// - private string[] GetNamespacesWithSuffix(string suffix, bool reverse = false) - { - suffix.ThrowIfNull(nameof(suffix)); + var rows = await Database.SqlStatement(sql).ScalarAsync(cancellationToken).ConfigureAwait(false); + Logger.LogInformation("{Content}", $"Result: {rows} rows affected."); + } - var list = new List(); - foreach (var ns in reverse ? Namespaces.Reverse() : Namespaces) - { - list.Add($"{ns}.{suffix}"); - } + return true; + } - return list.Count == 0 ? ["(none)"] : [.. list]; - } + /// + /// Gets the with the specified namespace suffix applied. + /// + private string[] GetNamespacesWithSuffix(string suffix, bool reverse = false) + { + suffix.ThrowIfNull(nameof(suffix)); - /// - /// Creates a new script using the template within the folder. - /// - /// The script resource template name; defaults to 'default'. - /// The optional parameters. - /// The . - /// true indicates success; otherwise, false. - public async Task CreateScriptAsync(string? name = null, IDictionary? parameters = null, CancellationToken cancellationToken = default) + var list = new List(); + foreach (var ns in reverse ? Namespaces.Reverse() : Namespaces) { - PreExecutionInitialization(); - return await CommandExecuteAsync("DATABASE SCRIPT: Create a new database script...", async ct => await CreateScriptInternalAsync(name, parameters, ct).ConfigureAwait(false), null, cancellationToken).ConfigureAwait(false); + list.Add($"{ns}.{suffix}"); } - /// - /// Creates the new script. - /// - private async Task CreateScriptInternalAsync(string? name, IDictionary? parameters, CancellationToken cancellationToken) - { - name ??= "Default"; - var rn = $"Script{name}_sql"; + return list.Count == 0 ? ["(none)"] : [.. list]; + } - // Find the resource. - using var sr = StreamLocator.GetResourcesStreamReader(rn, [.. ArtefactResourceAssemblies], StreamLocator.HandlebarsExtensions).StreamReader; + /// + /// Creates a new script using the template within the folder. + /// + /// The script resource template name; defaults to 'default'. + /// The optional parameters. + /// The . + /// true indicates success; otherwise, false. + public async Task CreateScriptAsync(string? name = null, IDictionary? parameters = null, CancellationToken cancellationToken = default) + { + PreExecutionInitialization(); + return await CommandExecuteAsync("DATABASE SCRIPT: Create a new database script...", async ct => await CreateScriptInternalAsync(name, parameters, ct).ConfigureAwait(false), null, cancellationToken).ConfigureAwait(false); + } - if (sr == null) - { - Logger.LogError("{Content}", $"The Script '{name}' does not exist."); - return false; - } + /// + /// Creates the new script. + /// + private async Task CreateScriptInternalAsync(string? name, IDictionary? parameters, CancellationToken cancellationToken) + { + name ??= "Default"; + var rn = $"Script{name}_sql"; + + // Find the resource. + using var sr = StreamLocator.GetResourcesStreamReader(rn, [.. ArtefactResourceAssemblies], StreamLocator.HandlebarsExtensions).StreamReader; - // Read the resource stream. + if (sr == null) + { + Logger.LogError("{Content}", $"The Script '{name}' does not exist."); + return false; + } + + // Read the resource stream. #if NET7_0_OR_GREATER - var txt = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + var txt = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false); #else - var txt = await sr.ReadToEndAsync().ConfigureAwait(false); + var txt = await sr.ReadToEndAsync().ConfigureAwait(false); #endif - // Extract the filename from content if specified. - var data = new { Parameters = parameters ?? new Dictionary() }; - var lines = txt.Split(_splitters, StringSplitOptions.None); - string fn = "new-script"; - foreach (var line in lines) + // Extract the filename from content if specified. + var data = new { Parameters = parameters ?? new Dictionary() }; + var lines = txt.Split(_splitters, StringSplitOptions.None); + string fn = "new-script"; + foreach (var line in lines) + { + var lt = line.Trim(); + if (lt.StartsWith("{{! FILENAME:", StringComparison.InvariantCultureIgnoreCase) && lt.EndsWith("}}", StringComparison.InvariantCultureIgnoreCase)) { - var lt = line.Trim(); - if (lt.StartsWith("{{! FILENAME:", StringComparison.InvariantCultureIgnoreCase) && lt.EndsWith("}}", StringComparison.InvariantCultureIgnoreCase)) - { - fn = lt[13..^2].Trim(); - continue; - } + fn = lt[13..^2].Trim(); + continue; + } - if (lt.StartsWith("{{! PARAM:", StringComparison.InvariantCultureIgnoreCase) && lt.EndsWith("}}", StringComparison.InvariantCultureIgnoreCase)) - { - var pv = lt[10..^2].Trim(); - if (string.IsNullOrEmpty(pv)) - continue; + if (lt.StartsWith("{{! PARAM:", StringComparison.InvariantCultureIgnoreCase) && lt.EndsWith("}}", StringComparison.InvariantCultureIgnoreCase)) + { + var pv = lt[10..^2].Trim(); + if (string.IsNullOrEmpty(pv)) + continue; - var parts = pv.Split('=', StringSplitOptions.RemoveEmptyEntries); - data.Parameters.TryAdd(parts[0], parts.Length <= 1 ? null : parts[1].Trim()); - } + var parts = pv.Split('=', StringSplitOptions.RemoveEmptyEntries); + data.Parameters.TryAdd(parts[0], parts.Length <= 1 ? null : parts[1].Trim()); } + } - // Update the filename. - if (Args.OutputDirectory == null) - throw new InvalidOperationException("Args.OutputDirectory has not been correctly determined."); + // Update the filename. + if (Args.OutputDirectory == null) + throw new InvalidOperationException("Args.OutputDirectory has not been correctly determined."); - fn = $"{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", System.Globalization.CultureInfo.InvariantCulture)}-{fn.Replace("[", "{{").Replace("]", "}}")}.sql"; - fn = Path.Combine(Args.OutputDirectory.FullName, MigrationsNamespace, new HandlebarsCodeGenerator(fn).Generate(data).Replace(" ", "-").ToLowerInvariant()); - var fi = new FileInfo(fn); + fn = $"{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", System.Globalization.CultureInfo.InvariantCulture)}-{fn.Replace("[", "{{").Replace("]", "}}")}.sql"; + fn = Path.Combine(Args.OutputDirectory.FullName, MigrationsNamespace, new HandlebarsCodeGenerator(fn).Generate(data).Replace(" ", "-").ToLowerInvariant()); + var fi = new FileInfo(fn); - // Generate the script content and write to file system. - if (!fi.Directory!.Exists) - fi.Directory.Create(); + // Generate the script content and write to file system. + if (!fi.Directory!.Exists) + fi.Directory.Create(); - await File.WriteAllTextAsync(fi.FullName, new HandlebarsCodeGenerator(txt).Generate(data), cancellationToken).ConfigureAwait(false); + await File.WriteAllTextAsync(fi.FullName, new HandlebarsCodeGenerator(txt).Generate(data), cancellationToken).ConfigureAwait(false); - Logger.LogWarning("{Content}", $"Script file created: {fi.FullName}"); - return true; - } + Logger.LogWarning("{Content}", $"Script file created: {fi.FullName}"); + return true; + } - /// - /// Executes the raw SQL statements by creating the equivalent and invoking . - /// - /// The SQL statements. - /// The . - /// true indicates success; otherwise, false. - /// A maximum of 999 SQL statements may be executed at one-time. Each script is run independently (i.e. not within an overall database tramsaction); therefore, any preceeding scripts before error will have executed successfully. - public async Task ExecuteSqlStatementsAsync(string[]? statements, CancellationToken cancellationToken = default) - { - PreExecutionInitialization(); - return await CommandExecuteAsync("DATABASE EXECUTE: Executes the SQL statement(s)...", async ct => await ExecuteSqlStatementsInternalAsync(statements, ct).ConfigureAwait(false), null, cancellationToken).ConfigureAwait(false); - } + /// + /// Executes the raw SQL statements by creating the equivalent and invoking . + /// + /// The SQL statements. + /// The . + /// true indicates success; otherwise, false. + /// A maximum of 999 SQL statements may be executed at one-time. Each script is run independently (i.e. not within an overall database tramsaction); therefore, any preceeding scripts before error will have executed successfully. + public async Task ExecuteSqlStatementsAsync(string[]? statements, CancellationToken cancellationToken = default) + { + PreExecutionInitialization(); + return await CommandExecuteAsync("DATABASE EXECUTE: Executes the SQL statement(s)...", async ct => await ExecuteSqlStatementsInternalAsync(statements, ct).ConfigureAwait(false), null, cancellationToken).ConfigureAwait(false); + } - /// - /// Executes the raw SQL statements. - /// - private async Task ExecuteSqlStatementsInternalAsync(string[]? statements, CancellationToken cancellationToken) + /// + /// Executes the raw SQL statements. + /// + private async Task ExecuteSqlStatementsInternalAsync(string[]? statements, CancellationToken cancellationToken) + { + if (statements == null || statements.Length == 0) { - if (statements == null || statements.Length == 0) - { - Logger.LogInformation("{Content}", " No statements to execute."); - return true; - } - - if (statements.Length >= 1000) - throw new ArgumentException("A maximum of 999 SQL statements may be executed at one-time.", nameof(statements)); - - var sn = $"{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", System.Globalization.CultureInfo.InvariantCulture)}-console-execute-"; + Logger.LogInformation("{Content}", " No statements to execute."); + return true; + } - var scripts = new List(); - for (int i = 0; i < statements.Length; i++) - { - if (File.Exists(statements[i])) - scripts.Add(new DatabaseMigrationScript(this, new FileInfo(statements[i]), statements[i])); - else - scripts.Add(new DatabaseMigrationScript(this, statements[i], $"{sn}{i + 1:000}.sql")); - } + if (statements.Length >= 1000) + throw new ArgumentException("A maximum of 999 SQL statements may be executed at one-time.", nameof(statements)); - return await ExecuteScriptsAsync(scripts, false, cancellationToken).ConfigureAwait(false); - } + var sn = $"{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", System.Globalization.CultureInfo.InvariantCulture)}-console-execute-"; - /// - /// Performs a SQL command text runtime parameters (see ) replacement. - /// - /// The SQL command. - /// The resulting SQL command with runtime replacements make. - public string ReplaceSqlRuntimeParameters(string sql) => Args.Parameters.Count == 0 - ? sql : Regex.Replace(sql, "(" + string.Join("|", [.. Args.Parameters.Select(x => $"{{{{{x.Key}}}}}")]) + ")", - m => Args.Parameters.TryGetValue(m.Value[2..^2], out var pv) ? pv?.ToString()! : throw new InvalidOperationException($"Runtime Parameter '{m.Value}' found within SQL command; a corresponding Parameter value has not been configured.")); - - /// - public void Dispose() + var scripts = new List(); + for (int i = 0; i < statements.Length; i++) { - Dispose(true); - GC.SuppressFinalize(this); + if (File.Exists(statements[i])) + scripts.Add(new DatabaseMigrationScript(this, new FileInfo(statements[i]), statements[i])); + else + scripts.Add(new DatabaseMigrationScript(this, statements[i], $"{sn}{i + 1:000}.sql")); } - /// - /// Dispose of the resources. - /// - /// Indicates whether to dispose. - protected virtual void Dispose(bool disposing) - { - if (!disposing) - return; + return await ExecuteScriptsAsync(scripts, false, cancellationToken).ConfigureAwait(false); + } - Database.Dispose(); - MasterDatabase.Dispose(); - } + /// + /// Performs a SQL command text runtime parameters (see ) replacement. + /// + /// The SQL command. + /// The resulting SQL command with runtime replacements make. + public string ReplaceSqlRuntimeParameters(string sql) => Args.Parameters.Count == 0 + ? sql : Regex.Replace(sql, "(" + string.Join("|", [.. Args.Parameters.Select(x => $"{{{{{x.Key}}}}}")]) + ")", + m => Args.Parameters.TryGetValue(m.Value[2..^2], out var pv) ? pv?.ToString()! : throw new InvalidOperationException($"Runtime Parameter '{m.Value}' found within SQL command; a corresponding Parameter value has not been configured.")); + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose of the resources. + /// + /// Indicates whether to dispose. + protected virtual void Dispose(bool disposing) + { + if (!disposing) + return; + + Database.Dispose(); + MasterDatabase.Dispose(); } } \ No newline at end of file diff --git a/src/DbEx/Migration/DatabaseMigrationScript.cs b/src/DbEx/Migration/DatabaseMigrationScript.cs index c548203..894269a 100644 --- a/src/DbEx/Migration/DatabaseMigrationScript.cs +++ b/src/DbEx/Migration/DatabaseMigrationScript.cs @@ -1,95 +1,88 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration; -using System.IO; -using System.Reflection; -using System.Text; - -namespace DbEx.Migration +/// +/// Provides the database migration script configuration. +/// +public class DatabaseMigrationScript { + private readonly FileInfo? _file; + private readonly Assembly? _assembly; + private readonly string? _sql; + /// - /// Provides the database migration script configuration. + /// Initializes a new instance of the class for a . /// - public class DatabaseMigrationScript + /// The owning . + /// The . + /// The file name. + public DatabaseMigrationScript(DatabaseMigrationBase databaseMigation, FileInfo file, string name) { - private readonly FileInfo? _file; - private readonly Assembly? _assembly; - private readonly string? _sql; - - /// - /// Initializes a new instance of the class for a . - /// - /// The owning . - /// The . - /// The file name. - public DatabaseMigrationScript(DatabaseMigrationBase databaseMigation, FileInfo file, string name) - { - DatabaseMigration = databaseMigation.ThrowIfNull(nameof(databaseMigation)); - _file = file.ThrowIfNull(nameof(file)); - Name = name.ThrowIfNullOrEmpty(nameof(name)); - } + DatabaseMigration = databaseMigation.ThrowIfNull(nameof(databaseMigation)); + _file = file.ThrowIfNull(nameof(file)); + Name = name.ThrowIfNullOrEmpty(nameof(name)); + } - /// - /// Initializes a new instance of the class for an embedded resource. - /// - /// The owning . - /// The . - /// The resource name. - public DatabaseMigrationScript(DatabaseMigrationBase databaseMigation, Assembly assembly, string name) - { - DatabaseMigration = databaseMigation.ThrowIfNull(nameof(databaseMigation)); - _assembly = assembly.ThrowIfNull(nameof(assembly)); - Name = name.ThrowIfNullOrEmpty(nameof(name)); - } + /// + /// Initializes a new instance of the class for an embedded resource. + /// + /// The owning . + /// The . + /// The resource name. + public DatabaseMigrationScript(DatabaseMigrationBase databaseMigation, Assembly assembly, string name) + { + DatabaseMigration = databaseMigation.ThrowIfNull(nameof(databaseMigation)); + _assembly = assembly.ThrowIfNull(nameof(assembly)); + Name = name.ThrowIfNullOrEmpty(nameof(name)); + } - /// - /// Initializes a new instance of the class for the specified . - /// - /// The owning . - /// The SQL statement. - /// The sql name. - public DatabaseMigrationScript(DatabaseMigrationBase databaseMigation, string sql, string name) - { - DatabaseMigration = databaseMigation.ThrowIfNull(nameof(databaseMigation)); - _sql = sql.ThrowIfNull(nameof(sql)); - Name = name.ThrowIfNullOrEmpty(nameof(name)); - } + /// + /// Initializes a new instance of the class for the specified . + /// + /// The owning . + /// The SQL statement. + /// The sql name. + public DatabaseMigrationScript(DatabaseMigrationBase databaseMigation, string sql, string name) + { + DatabaseMigration = databaseMigation.ThrowIfNull(nameof(databaseMigation)); + _sql = sql.ThrowIfNull(nameof(sql)); + Name = name.ThrowIfNullOrEmpty(nameof(name)); + } - /// - /// Gets the owning . - /// - public DatabaseMigrationBase DatabaseMigration { get; } + /// + /// Gets the owning . + /// + public DatabaseMigrationBase DatabaseMigration { get; } - /// - /// Gets the name used for journaling. - /// - public string Name { get; } + /// + /// Gets the name used for journaling. + /// + public string Name { get; } - /// - /// Gets or sets the group order for the script. - /// - public int GroupOrder { get; set; } + /// + /// Gets or sets the group order for the script. + /// + public int GroupOrder { get; set; } - /// - /// Indicates whether the script is run once or always. - /// - /// true to run always; otherwise, false to run once (default). - public bool RunAlways { get; set; } + /// + /// Indicates whether the script is run once or always. + /// + /// true to run always; otherwise, false to run once (default). + public bool RunAlways { get; set; } - /// - /// Gets or sets additional tag text to output to the log. - /// - public string? Tag { get; set; } + /// + /// Gets or sets additional tag text to output to the log. + /// + public string? Tag { get; set; } - /// - /// Gets the underlying SQL statement source. - /// - public string Source => _assembly is not null ? "RES" : (_file is not null ? "FILE" : "SQL"); + /// + /// Gets the underlying SQL statement source. + /// + public string Source => _assembly is not null ? "RES" : (_file is not null ? "FILE" : "SQL"); - /// - /// Gets the resource or file . - /// - public StreamReader GetStreamReader() => _assembly is not null - ? new StreamReader(_assembly!.GetManifestResourceStream(Name)!) - : (_file is not null ? _file!.OpenText() : new StreamReader(new MemoryStream(Encoding.Default.GetBytes(_sql!)))); - } + /// + /// Gets the resource or file . + /// + public StreamReader GetStreamReader() => _assembly is not null + ? new StreamReader(_assembly!.GetManifestResourceStream(Name)!) + : (_file is not null ? _file!.OpenText() : new StreamReader(new MemoryStream(Encoding.Default.GetBytes(_sql!)))); } \ No newline at end of file diff --git a/src/DbEx/Migration/DatabaseRecord.cs b/src/DbEx/Migration/DatabaseRecord.cs index 3350684..267087d 100644 --- a/src/DbEx/Migration/DatabaseRecord.cs +++ b/src/DbEx/Migration/DatabaseRecord.cs @@ -1,84 +1,80 @@ -using System; -using System.Data.Common; +namespace DbEx.Migration; -namespace DbEx.Migration +/// +/// Encapsulates the to provide requisite column value capabilities. +/// +/// The owning . +/// The underlying . +public class DatabaseRecord(IDatabase database, DbDataReader dataReader) { /// - /// Encapsulates the to provide requisite column value capabilities. + /// Gets the owning . /// - /// The owning . - /// The underlying . - public class DatabaseRecord(IDatabase database, DbDataReader dataReader) - { - /// - /// Gets the owning . - /// - public IDatabase Database { get; } = database.ThrowIfNull(); + public IDatabase Database { get; } = database.ThrowIfNull(); - /// - /// Gets the underlying . - /// - public DbDataReader DataReader { get; } = dataReader.ThrowIfNull(); + /// + /// Gets the underlying . + /// + public DbDataReader DataReader { get; } = dataReader.ThrowIfNull(); - /// - /// Gets the named column value. - /// - /// The column name. - /// The value. - public object? GetValue(string columnName) => GetValue(DataReader.GetOrdinal(columnName.ThrowIfNull())); + /// + /// Gets the named column value. + /// + /// The column name. + /// The value. + public object? GetValue(string columnName) => GetValue(DataReader.GetOrdinal(columnName.ThrowIfNull())); - /// - /// Gets the specified column value. - /// - /// The ordinal index. - /// The value. - public object? GetValue(int ordinal) - { - if (DataReader.IsDBNull(ordinal)) - return default; + /// + /// Gets the specified column value. + /// + /// The ordinal index. + /// The value. + public object? GetValue(int ordinal) + { + if (DataReader.IsDBNull(ordinal)) + return default; - return DataReader.GetValue(ordinal); - } + return DataReader.GetValue(ordinal); + } - /// - /// Gets the named column value. - /// - /// The value . - /// The column name. - /// The value. - public T? GetValue(string columnName) => GetValue(DataReader.GetOrdinal(columnName.ThrowIfNull())); + /// + /// Gets the named column value. + /// + /// The value . + /// The column name. + /// The value. + public T? GetValue(string columnName) => GetValue(DataReader.GetOrdinal(columnName.ThrowIfNull())); - /// - /// Gets the specified column value. - /// - /// The value . - /// The ordinal index. - /// The value. - public T? GetValue(int ordinal) - { - if (DataReader.IsDBNull(ordinal)) - return default!; + /// + /// Gets the specified column value. + /// + /// The value . + /// The ordinal index. + /// The value. + public T? GetValue(int ordinal) + { + if (DataReader.IsDBNull(ordinal)) + return default!; #if NET7_0_OR_GREATER - if (typeof(T) == typeof(Nullable)) - return (T?)(object)DataReader.GetFieldValue(ordinal); - else if (typeof(T) == typeof(Nullable)) - return (T?)(object)DataReader.GetFieldValue(ordinal); + if (typeof(T) == typeof(Nullable)) + return (T?)(object)DataReader.GetFieldValue(ordinal); + else if (typeof(T) == typeof(Nullable)) + return (T?)(object)DataReader.GetFieldValue(ordinal); #endif - return DataReader.GetFieldValue(ordinal); - } + return DataReader.GetFieldValue(ordinal); + } - /// - /// Indicates whether the named column is . - /// - /// The column name. - /// The corresponding ordinal for the column name. - /// indicates that the column value has a value; otherwise, . - public bool IsDBNull(string columnName, out int ordinal) - { - ordinal = DataReader.GetOrdinal(columnName.ThrowIfNull()); - return DataReader.IsDBNull(ordinal); - } + /// + /// Indicates whether the named column is . + /// + /// The column name. + /// The corresponding ordinal for the column name. + /// indicates that the column value has a value; otherwise, . + public bool IsDBNull(string columnName, out int ordinal) + { + ordinal = DataReader.GetOrdinal(columnName.ThrowIfNull()); + return DataReader.IsDBNull(ordinal); } } \ No newline at end of file diff --git a/src/DbEx/Migration/DatabaseSchemaScriptBase.cs b/src/DbEx/Migration/DatabaseSchemaScriptBase.cs index 031e297..7ea182b 100644 --- a/src/DbEx/Migration/DatabaseSchemaScriptBase.cs +++ b/src/DbEx/Migration/DatabaseSchemaScriptBase.cs @@ -1,100 +1,95 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration; + +/// +/// Enables the base database schema script. +/// +/// The . +/// The optional quote prefix. +/// The optional quote suffix. +public abstract class DatabaseSchemaScriptBase(DatabaseMigrationScript migrationScript, string? quotePrefix = null, string? quoteSuffix = null) +{ + private string? _schema; + private string _name = string.Empty; -using System; + /// + /// Gets the parent . + /// + public DatabaseMigrationScript MigrationScript { get; } = migrationScript.ThrowIfNull(nameof(migrationScript)); + + /// + /// Gets the optional quote prefix. + /// + public string? QuotePrefix { get; } = quotePrefix; + + /// + /// Gets the optional quote suffix. + /// + public string? QuoteSuffix { get; } = quoteSuffix; + + /// + /// Gets or sets the fully qualified name (as per script). + /// + public string FullyQualifiedName { get; protected set; } = string.Empty; + + /// + /// Gets the object type. + /// + public string Type { get; protected set; } = string.Empty; + + /// + /// Gets or sets the underlying schema name (where applicable). + /// + /// This is schema portion of the with any escaping removed. + public string? Schema { get => _schema; protected set => _schema = UnquoteIdentifier(value); } + + /// + /// Gets the object name. + /// + /// This is name portion of the with any escaping removed. + public string Name { get => _name; protected set => _name = UnquoteIdentifier(value)!; } + + /// + /// Gets the order of precedence. + /// + public int TypeOrder { get; internal set; } = -1; + + /// + /// Gets or sets the schema order of precedence. + /// + public int SchemaOrder { get; internal set; } = -1; + + /// + /// Gets or sets the error message. + /// + public string? ErrorMessage { get; internal protected set; } + + /// + /// Indicates whether the schema script has an error. + /// + public bool HasError => ErrorMessage != null; + + /// + /// Indicates whether the schema script supports a create or replace/alter; i.e. does not require a drop and create as two separate operations. + /// + public bool SupportsReplace { get; protected set; } + + /// + /// Gets the corresponding SQL drop statement for the underlying and . + /// + public abstract string SqlDropStatement { get; } + + /// + /// Gets the corresponding SQL create statement for the underlying and . + /// + /// This is only used for logging; the original script is invoked. + public abstract string SqlCreateStatement { get; } -namespace DbEx.Migration -{ /// - /// Enables the base database schema script. + /// Unquotes the by removing the and where they both exist. /// - /// The . - /// The optional quote prefix. - /// The optional quote suffix. - public abstract class DatabaseSchemaScriptBase(DatabaseMigrationScript migrationScript, string? quotePrefix = null, string? quoteSuffix = null) - { - private string? _schema; - private string _name = string.Empty; - - /// - /// Gets the parent . - /// - public DatabaseMigrationScript MigrationScript { get; } = migrationScript.ThrowIfNull(nameof(migrationScript)); - - /// - /// Gets the optional quote prefix. - /// - public string? QuotePrefix { get; } = quotePrefix; - - /// - /// Gets the optional quote suffix. - /// - public string? QuoteSuffix { get; } = quoteSuffix; - - /// - /// Gets or sets the fully qualified name (as per script). - /// - public string FullyQualifiedName { get; protected set; } = string.Empty; - - /// - /// Gets the object type. - /// - public string Type { get; protected set; } = string.Empty; - - /// - /// Gets or sets the underlying schema name (where applicable). - /// - /// This is schema portion of the with any escaping removed. - public string? Schema { get => _schema; protected set => _schema = UnquoteIdentifier(value); } - - /// - /// Gets the object name. - /// - /// This is name portion of the with any escaping removed. - public string Name { get => _name; protected set => _name = UnquoteIdentifier(value)!; } - - /// - /// Gets the order of precedence. - /// - public int TypeOrder { get; internal set; } = -1; - - /// - /// Gets or sets the schema order of precedence. - /// - public int SchemaOrder { get; internal set; } = -1; - - /// - /// Gets or sets the error message. - /// - public string? ErrorMessage { get; internal protected set; } - - /// - /// Indicates whether the schema script has an error. - /// - public bool HasError => ErrorMessage != null; - - /// - /// Indicates whether the schema script supports a create or replace/alter; i.e. does not require a drop and create as two separate operations. - /// - public bool SupportsReplace { get; protected set; } - - /// - /// Gets the corresponding SQL drop statement for the underlying and . - /// - public abstract string SqlDropStatement { get; } - - /// - /// Gets the corresponding SQL create statement for the underlying and . - /// - /// This is only used for logging; the original script is invoked. - public abstract string SqlCreateStatement { get; } - - /// - /// Unquotes the by removing the and where they both exist. - /// - /// The identifier to unquote. - /// The unquoted identifier. - public string? UnquoteIdentifier(string? identifier) - => !string.IsNullOrEmpty(identifier) && !string.IsNullOrEmpty(QuotePrefix) && !string.IsNullOrEmpty(QuoteSuffix) && identifier.StartsWith(QuotePrefix, StringComparison.OrdinalIgnoreCase) && identifier.EndsWith(QuoteSuffix, StringComparison.OrdinalIgnoreCase) - ? identifier[QuotePrefix.Length..^QuoteSuffix.Length] : identifier; - } + /// The identifier to unquote. + /// The unquoted identifier. + public string? UnquoteIdentifier(string? identifier) + => !string.IsNullOrEmpty(identifier) && !string.IsNullOrEmpty(QuotePrefix) && !string.IsNullOrEmpty(QuoteSuffix) && identifier.StartsWith(QuotePrefix, StringComparison.OrdinalIgnoreCase) && identifier.EndsWith(QuoteSuffix, StringComparison.OrdinalIgnoreCase) + ? identifier[QuotePrefix.Length..^QuoteSuffix.Length] : identifier; } \ No newline at end of file diff --git a/src/DbEx/Migration/IDatabase.cs b/src/DbEx/Migration/IDatabase.cs index c2e8841..3f7775e 100644 --- a/src/DbEx/Migration/IDatabase.cs +++ b/src/DbEx/Migration/IDatabase.cs @@ -1,54 +1,46 @@ -using DbEx.DbSchema; -using System; -using System.Collections.Generic; -using System.Data.Common; -using System.Threading; -using System.Threading.Tasks; +namespace DbEx.Migration; -namespace DbEx.Migration +/// +/// Enables database (relational) access. +/// +public interface IDatabase : IAsyncDisposable, IDisposable { /// - /// Enables database (relational) access. + /// Gets the . /// - public interface IDatabase : IAsyncDisposable, IDisposable - { - /// - /// Gets the . - /// - DbProviderFactory Provider { get; } + DbProviderFactory Provider { get; } - /// - /// Gets the . - /// - /// The connection is created and opened on first use, and closed on or . - DbConnection GetConnection(); + /// + /// Gets the . + /// + /// The connection is created and opened on first use, and closed on or . + DbConnection GetConnection(); - /// - /// Gets the . - /// - /// The connection is created and opened on first use, and closed on or . - Task GetConnectionAsync(CancellationToken cancellationToken = default); + /// + /// Gets the . + /// + /// The connection is created and opened on first use, and closed on or . + Task GetConnectionAsync(CancellationToken cancellationToken = default); - /// - /// Creates a stored procedure . - /// - /// The stored procedure name. - /// The . - DatabaseCommand StoredProcedure(string storedProcedure); + /// + /// Creates a stored procedure . + /// + /// The stored procedure name. + /// The . + DatabaseCommand StoredProcedure(string storedProcedure); - /// - /// Creates a SQL statement . - /// - /// The SQL statement. - /// The . - DatabaseCommand SqlStatement(string sqlStatement); + /// + /// Creates a SQL statement . + /// + /// The SQL statement. + /// The . + DatabaseCommand SqlStatement(string sqlStatement); - /// - /// Selects all the table and column schema details from the database. - /// - /// The . - /// The . - /// A list of all the table and column schema details. - Task> SelectSchemaAsync(DatabaseMigrationBase migration, CancellationToken cancellationToken = default); - } + /// + /// Selects all the table and column schema details from the database. + /// + /// The . + /// The . + /// A list of all the table and column schema details. + Task> SelectSchemaAsync(DatabaseMigrationBase migration, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/DbEx/Migration/IDatabaseJournal.cs b/src/DbEx/Migration/IDatabaseJournal.cs index a5b7cfd..0d7d7fc 100644 --- a/src/DbEx/Migration/IDatabaseJournal.cs +++ b/src/DbEx/Migration/IDatabaseJournal.cs @@ -1,44 +1,37 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace DbEx.Migration +/// +/// Enables the database journaling capability to ensure selected scripts are only executed once. +/// +public interface IDatabaseJournal { /// - /// Enables the database journaling capability to ensure selected scripts are only executed once. + /// Gets the journal schema name. /// - public interface IDatabaseJournal - { - /// - /// Gets the journal schema name. - /// - string? Schema { get; } + string? Schema { get; } - /// - /// Gets the journal table name. - /// - string? Table { get; } + /// + /// Gets the journal table name. + /// + string? Table { get; } - /// - /// Ensures that the exists within the database. - /// - /// The . - Task EnsureExistsAsync(CancellationToken cancellationToken = default); + /// + /// Ensures that the exists within the database. + /// + /// The . + Task EnsureExistsAsync(CancellationToken cancellationToken = default); - /// - /// Gets the list of all the previously executed scripts. - /// - /// The . - /// The list of all the previously executed scripts. - Task> GetExecutedScriptsAsync(CancellationToken cancellationToken = default); + /// + /// Gets the list of all the previously executed scripts. + /// + /// The . + /// The list of all the previously executed scripts. + Task> GetExecutedScriptsAsync(CancellationToken cancellationToken = default); - /// - /// Audits (persists) the execution of a . - /// - /// The . - /// The . - Task AuditScriptExecutionAsync(DatabaseMigrationScript script, CancellationToken cancellationToken = default); - } + /// + /// Audits (persists) the execution of a . + /// + /// The . + /// The . + Task AuditScriptExecutionAsync(DatabaseMigrationScript script, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/DbEx/Migration/MigrationArgs.cs b/src/DbEx/Migration/MigrationArgs.cs index 18cc6df..76c347b 100644 --- a/src/DbEx/Migration/MigrationArgs.cs +++ b/src/DbEx/Migration/MigrationArgs.cs @@ -1,32 +1,29 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration; -namespace DbEx.Migration +/// +/// Provides the arguments. +/// +public class MigrationArgs : MigrationArgsBase { /// - /// Provides the arguments. + /// Initializes a new instance of the class. /// - public class MigrationArgs : MigrationArgsBase - { - /// - /// Initializes a new instance of the class. - /// - public MigrationArgs() { } - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The optional connection string. - public MigrationArgs(MigrationCommand migrationCommand, string? connectionString = null) - { - MigrationCommand = migrationCommand; - ConnectionString = connectionString; - } + public MigrationArgs() { } - /// - /// Copy and replace from . - /// - /// The to copy from. - public void CopyFrom(MigrationArgs args) => base.CopyFrom(args); + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The optional connection string. + public MigrationArgs(MigrationCommand migrationCommand, string? connectionString = null) + { + MigrationCommand = migrationCommand; + ConnectionString = connectionString; } + + /// + /// Copy and replace from . + /// + /// The to copy from. + public void CopyFrom(MigrationArgs args) => base.CopyFrom(args); } \ No newline at end of file diff --git a/src/DbEx/Migration/MigrationArgsBase.cs b/src/DbEx/Migration/MigrationArgsBase.cs index 566bb97..09b79db 100644 --- a/src/DbEx/Migration/MigrationArgsBase.cs +++ b/src/DbEx/Migration/MigrationArgsBase.cs @@ -1,357 +1,344 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration; -global using ExplicitMigrationScript = (DbEx.MigrationCommand Command, System.Reflection.Assembly Assembly, string Name); +/// +/// Provides the base arguments. +/// +public abstract class MigrationArgsBase : OnRamp.CodeGeneratorDbArgsBase +{ + private readonly List _assemblies = [new MigrationAssemblyArgs(typeof(MigrationArgsBase).Assembly)]; + private readonly HashSet _scripts = []; -using DbEx.Migration.Data; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; + /// + /// Gets the name. + /// + public const string DatabaseNameParamName = "DatabaseName"; -namespace DbEx.Migration -{ /// - /// Provides the base arguments. + /// Gets the name. /// - public abstract class MigrationArgsBase : OnRamp.CodeGeneratorDbArgsBase - { - private readonly List _assemblies = [new MigrationAssemblyArgs(typeof(MigrationArgsBase).Assembly)]; - private readonly HashSet _scripts = []; - - /// - /// Gets the name. - /// - public const string DatabaseNameParamName = "DatabaseName"; - - /// - /// Gets the name. - /// - public const string JournalSchemaParamName = "JournalSchema"; - - /// - /// Gets the name. - /// - public const string JournalTableParamName = "JournalTable"; - - /// - /// Initializes a new instance of the . - /// - public MigrationArgsBase() => DataParserArgs = new DataParserArgs(Parameters); - - /// - /// Gets or sets the . - /// - public MigrationCommand MigrationCommand { get; set; } = MigrationCommand.None; - - /// - /// Gets the list to use to probe for assembly resource (in defined sequence); will automatically add this (DbEx) assembly also (therefore no need to explicitly specify). - /// - public IEnumerable Assemblies => _assemblies; - - /// - /// Gets the reversed in order for probe-based sequencing. - /// - public IEnumerable ProbeAssemblies => Assemblies.Reverse(); - - /// - /// Gets the list of explicitly named resource-based scripts to be included (executed) as per the specified (phase). - /// - public IEnumerable Scripts => _scripts; - - /// - /// Gets the runtime parameters. - /// - /// The following parameter names are reserved for a specific internal purpose: , and . - /// and can support additional command-line arguments; these are automatically added as 'ParamN' where 'N' is the zero-based index; e.g. 'Param0'. - public Dictionary Parameters { get; } = []; - - /// - /// Gets or sets the to optionally log the underlying database migration progress. - /// - public ILogger? Logger { get; set; } - - /// - /// Gets or sets the output where the generated artefacts are to be written. - /// - public DirectoryInfo? OutputDirectory { get; set; } - - /// - /// Gets the schema priority list (used to specify schema precedence; otherwise equal last). - /// - public List SchemaOrder { get; } = []; - - /// - /// Gets or sets the . - /// - public DataParserArgs DataParserArgs { get; set; } - - /// - /// Gets or sets the suffix of the 'Id' (identifier) column. - /// - /// Where matching columns and the specified column is not found, then the suffix will be appended to the specified column name and an additional match will be performed. - /// Defaults to where not specified (i.e. null). - public string? IdColumnNameSuffix { get; set; } - - /// - /// Gets or sets the suffix of the 'Code' column. - /// - /// Where matching columns and the specified column is not found, then the suffix will be appended to the specified column name and an additional match will be performed. - /// Defaults to where not specified (i.e. null). - public string? CodeColumnNameSuffix { get; set; } - - /// - /// Gets or sets the suffix of the 'Json' column. - /// - /// Where matching columns and the specified column is not found, then the suffix will be appended to the specified column name and an additional match will be performed. - /// Defaults to where not specified (i.e. null). - public string? JsonColumnNameSuffix { get; set; } - - /// - /// Gets or sets the name of the CreatedOn column (where it exists). - /// - /// Defaults to where not specified (i.e. null). - public string? CreatedOnColumnName { get; set; } - - /// - /// Gets or sets the name of the CreatedBy column (where it exists). - /// - /// Defaults to where not specified (i.e. null). - public string? CreatedByColumnName { get; set; } - - /// - /// Gets or sets the name of the UpdatedOn column (where it exists). - /// - /// Defaults to where not specified (i.e. null). - public string? UpdatedOnColumnName { get; set; } - - /// - /// Gets or sets the name of the UpdatedBy column (where it exists). - /// - /// Defaults to where not specified (i.e. null). - public string? UpdatedByColumnName { get; set; } - - /// - /// Gets or sets the name of the TenantId column (where it exists). - /// - /// Defaults to where not specified (i.e. null). - public string? TenantIdColumnName { get; set; } - - /// - /// Gets or sets the name of the row-version (ETag equivalent) column (where it exists). - /// - /// Defaults to where not specified (i.e. null). - public string? RowVersionColumnName { get; set; } - - /// - /// Gets or sets the name of the logically IsDeleted column (where it exists). - /// - /// Defaults to where not specified (i.e. null). - public string? IsDeletedColumnName { get; set; } - - /// - /// Gets or sets the name of the reference-data code column. - /// - /// Defaults to where not specified (i.e. null). - public string? RefDataCodeColumnName { get; set; } - - /// - /// Gets or sets the name of the reference-data text column. - /// - /// Defaults to where not specified (i.e. null). - public string? RefDataTextColumnName { get; set; } - - /// - /// Gets or sets the statements. - /// - public List? ExecuteStatements { get; set; } - - /// - /// Indicates whether to automatically accept any confirmation prompts (command-line execution only). - /// - public bool AcceptPrompts { get; set; } - - /// - /// Indicates whether to drop all the known schema objects before creating/replacing them. - /// - public bool DropSchemaObjects { get; set; } - - /// - /// Gets or sets the table filtering predicate. - /// - /// This is additional to any pre-configured database provider specified . - public Func? DataResetFilterPredicate { get; set; } + public const string JournalSchemaParamName = "JournalSchema"; + + /// + /// Gets the name. + /// + public const string JournalTableParamName = "JournalTable"; + + /// + /// Initializes a new instance of the . + /// + public MigrationArgsBase() => DataParserArgs = new DataParserArgs(Parameters); + + /// + /// Gets or sets the . + /// + public MigrationCommand MigrationCommand { get; set; } = MigrationCommand.None; + + /// + /// Gets the list to use to probe for assembly resource (in defined sequence); will automatically add this (DbEx) assembly also (therefore no need to explicitly specify). + /// + public IEnumerable Assemblies => _assemblies; + + /// + /// Gets the reversed in order for probe-based sequencing. + /// + public IEnumerable ProbeAssemblies => Assemblies.Reverse(); + + /// + /// Gets the list of explicitly named resource-based scripts to be included (executed) as per the specified (phase). + /// + public IEnumerable Scripts => _scripts; + + /// + /// Gets the runtime parameters. + /// + /// The following parameter names are reserved for a specific internal purpose: , and . + /// and can support additional command-line arguments; these are automatically added as 'ParamN' where 'N' is the zero-based index; e.g. 'Param0'. + public Dictionary Parameters { get; } = []; + + /// + /// Gets or sets the to optionally log the underlying database migration progress. + /// + public ILogger? Logger { get; set; } + + /// + /// Gets or sets the output where the generated artefacts are to be written. + /// + public DirectoryInfo? OutputDirectory { get; set; } + + /// + /// Gets the schema priority list (used to specify schema precedence; otherwise equal last). + /// + public List SchemaOrder { get; } = []; + + /// + /// Gets or sets the . + /// + public DataParserArgs DataParserArgs { get; set; } + + /// + /// Gets or sets the suffix of the 'Id' (identifier) column. + /// + /// Where matching columns and the specified column is not found, then the suffix will be appended to the specified column name and an additional match will be performed. + /// Defaults to where not specified (i.e. null). + public string? IdColumnNameSuffix { get; set; } + + /// + /// Gets or sets the suffix of the 'Code' column. + /// + /// Where matching columns and the specified column is not found, then the suffix will be appended to the specified column name and an additional match will be performed. + /// Defaults to where not specified (i.e. null). + public string? CodeColumnNameSuffix { get; set; } + + /// + /// Gets or sets the suffix of the 'Json' column. + /// + /// Where matching columns and the specified column is not found, then the suffix will be appended to the specified column name and an additional match will be performed. + /// Defaults to where not specified (i.e. null). + public string? JsonColumnNameSuffix { get; set; } + + /// + /// Gets or sets the name of the CreatedOn column (where it exists). + /// + /// Defaults to where not specified (i.e. null). + public string? CreatedOnColumnName { get; set; } + + /// + /// Gets or sets the name of the CreatedBy column (where it exists). + /// + /// Defaults to where not specified (i.e. null). + public string? CreatedByColumnName { get; set; } + + /// + /// Gets or sets the name of the UpdatedOn column (where it exists). + /// + /// Defaults to where not specified (i.e. null). + public string? UpdatedOnColumnName { get; set; } + + /// + /// Gets or sets the name of the UpdatedBy column (where it exists). + /// + /// Defaults to where not specified (i.e. null). + public string? UpdatedByColumnName { get; set; } + + /// + /// Gets or sets the name of the TenantId column (where it exists). + /// + /// Defaults to where not specified (i.e. null). + public string? TenantIdColumnName { get; set; } + + /// + /// Gets or sets the name of the row-version (ETag equivalent) column (where it exists). + /// + /// Defaults to where not specified (i.e. null). + public string? RowVersionColumnName { get; set; } + + /// + /// Gets or sets the name of the logically IsDeleted column (where it exists). + /// + /// Defaults to where not specified (i.e. null). + public string? IsDeletedColumnName { get; set; } + + /// + /// Gets or sets the name of the reference-data code column. + /// + /// Defaults to where not specified (i.e. null). + public string? RefDataCodeColumnName { get; set; } + + /// + /// Gets or sets the name of the reference-data text column. + /// + /// Defaults to where not specified (i.e. null). + public string? RefDataTextColumnName { get; set; } + + /// + /// Gets or sets the statements. + /// + public List? ExecuteStatements { get; set; } + + /// + /// Indicates whether to automatically accept any confirmation prompts (command-line execution only). + /// + public bool AcceptPrompts { get; set; } + + /// + /// Indicates whether to drop all the known schema objects before creating/replacing them. + /// + public bool DropSchemaObjects { get; set; } + + /// + /// Gets or sets the table filtering predicate. + /// + /// This is additional to any pre-configured database provider specified . + public Func? DataResetFilterPredicate { get; set; } #if NET7_0_OR_GREATER - /// - /// Indicates whether to emit the as a where ; otherwise, as a (default). - /// + /// + /// Indicates whether to emit the as a where ; otherwise, as a (default). + /// #else - /// - /// Indicates whether to emit the as a DateOnly where ; otherwise, as a (default). - /// + /// + /// Indicates whether to emit the as a DateOnly where ; otherwise, as a (default). + /// #endif - public bool EmitDotNetDateOnly { get; set; } + public bool EmitDotNetDateOnly { get; set; } #if NET7_0_OR_GREATER - /// - /// Indicates whether to emit the as a where ; otherwise, as a (default). - /// + /// + /// Indicates whether to emit the as a where ; otherwise, as a (default). + /// #else - /// - /// Indicates whether to emit the as a TimeOnly where ; otherwise, as a (default). - /// + /// + /// Indicates whether to emit the as a TimeOnly where ; otherwise, as a (default). + /// #endif - public bool EmitDotNetTimeOnly { get; set; } - - /// - /// Clears the by removing all existing items. - /// - public void ClearAssemblies() => _assemblies.Clear(); - - /// - /// Adds one or more to the . - /// - /// The assemblies to add. - /// Where a specified item already exists within the it will not be added again. - public void AddAssembly(params Assembly[] assemblies) => AddAssembly(assemblies.Select(x => new MigrationAssemblyArgs(x)).ToArray()); - - /// - /// Adds one or more to the . - /// - /// The assemblies to add. - /// Where a specified item already exists within the it will not be added again. - public void AddAssembly(params MigrationAssemblyArgs[] assemblies) + public bool EmitDotNetTimeOnly { get; set; } + + /// + /// Clears the by removing all existing items. + /// + public void ClearAssemblies() => _assemblies.Clear(); + + /// + /// Adds one or more to the . + /// + /// The assemblies to add. + /// Where a specified item already exists within the it will not be added again. + public void AddAssembly(params Assembly[] assemblies) => AddAssembly(assemblies.Select(x => new MigrationAssemblyArgs(x)).ToArray()); + + /// + /// Adds one or more to the . + /// + /// The assemblies to add. + /// Where a specified item already exists within the it will not be added again. + public void AddAssembly(params MigrationAssemblyArgs[] assemblies) + { + foreach (var assembly in assemblies) { - foreach (var assembly in assemblies) - { - if (!_assemblies.Any(x => x.Assembly == assembly.Assembly)) - _assemblies.Add(assembly); - } + if (!_assemblies.Any(x => x.Assembly == assembly.Assembly)) + _assemblies.Add(assembly); } + } - /// - /// Adds one or more to the after the specified ; where not found, will be added to the end. - /// - /// The to find within the existing . - /// The assemblies to add - /// Where a specified item already exists within the it will not be added again. - public void AddAssemblyAfter(Assembly assemblyToFind, params Assembly[] assemblies) => AddAssemblyAfter(assemblyToFind, assemblies.Select(x => new MigrationAssemblyArgs(x)).ToArray()); - - /// - /// Adds one or more to the after the specified ; where not found, will be added to the end. - /// - /// The to find within the existing . - /// The assemblies to add - /// Where a specified item already exists within the it will not be added again. - public void AddAssemblyAfter(Assembly assemblyToFind, params MigrationAssemblyArgs[] assemblies) + /// + /// Adds one or more to the after the specified ; where not found, will be added to the end. + /// + /// The to find within the existing . + /// The assemblies to add + /// Where a specified item already exists within the it will not be added again. + public void AddAssemblyAfter(Assembly assemblyToFind, params Assembly[] assemblies) => AddAssemblyAfter(assemblyToFind, assemblies.Select(x => new MigrationAssemblyArgs(x)).ToArray()); + + /// + /// Adds one or more to the after the specified ; where not found, will be added to the end. + /// + /// The to find within the existing . + /// The assemblies to add + /// Where a specified item already exists within the it will not be added again. + public void AddAssemblyAfter(Assembly assemblyToFind, params MigrationAssemblyArgs[] assemblies) + { + var index = _assemblies.FindIndex(x => x.Assembly == assemblyToFind.ThrowIfNull(nameof(assemblyToFind))); + if (index < 0) { - var index = _assemblies.FindIndex(x => x.Assembly == assemblyToFind.ThrowIfNull(nameof(assemblyToFind))); - if (index < 0) - { - AddAssembly(assemblies); - return; - } - - var newAssemblies = new List(); - foreach (var assembly in assemblies) - { - if (!_assemblies.Any(x => x.Assembly == assembly.Assembly) && !newAssemblies.Any(x => x.Assembly == assembly.Assembly)) - newAssemblies.Add(assembly); - } - - _assemblies.InsertRange(index + 1, newAssemblies); + AddAssembly(assemblies); + return; } - /// - /// Adds a parameter to the where it does not already exist; unless is selected then it will add or override. - /// - /// The parameter key. - /// The parameter value. - /// Indicates whether to override the existing value where it is pre-existing; otherwise, will not add/update. - /// The current instance to support fluent-style method-chaining. - public void AddParameter(string key, object? value, bool overrideExisting = false) + var newAssemblies = new List(); + foreach (var assembly in assemblies) { - if (!Parameters.TryAdd(key, value) && overrideExisting) - Parameters[key] = value; + if (!_assemblies.Any(x => x.Assembly == assembly.Assembly) && !newAssemblies.Any(x => x.Assembly == assembly.Assembly)) + newAssemblies.Add(assembly); } - /// - /// Copy and replace from . - /// - /// The to copy from. - protected void CopyFrom(MigrationArgsBase args) + _assemblies.InsertRange(index + 1, newAssemblies); + } + + /// + /// Adds a parameter to the where it does not already exist; unless is selected then it will add or override. + /// + /// The parameter key. + /// The parameter value. + /// Indicates whether to override the existing value where it is pre-existing; otherwise, will not add/update. + /// The current instance to support fluent-style method-chaining. + public void AddParameter(string key, object? value, bool overrideExisting = false) + { + if (!Parameters.TryAdd(key, value) && overrideExisting) + Parameters[key] = value; + } + + /// + /// Copy and replace from . + /// + /// The to copy from. + protected void CopyFrom(MigrationArgsBase args) + { + base.CopyFrom(args.ThrowIfNull(nameof(args))); + + MigrationCommand = args.MigrationCommand; + _assemblies.Clear(); + _assemblies.AddRange(args.Assemblies); + _scripts.Clear(); + args.Scripts.ForEach(x => _scripts.Add(x)); + Parameters.Clear(); + args.Parameters.ForEach(x => Parameters.Add(x.Key, x.Value)); + Logger = args.Logger; + OutputDirectory = args.OutputDirectory; + SchemaOrder.Clear(); + SchemaOrder.AddRange(args.SchemaOrder); + DataParserArgs.CopyFrom(args.DataParserArgs); + IdColumnNameSuffix = args.IdColumnNameSuffix; + CodeColumnNameSuffix = args.CodeColumnNameSuffix; + CreatedOnColumnName = args.CreatedOnColumnName; + CreatedByColumnName = args.CreatedByColumnName; + UpdatedOnColumnName = args.UpdatedOnColumnName; + UpdatedByColumnName = args.UpdatedByColumnName; + RowVersionColumnName = args.RowVersionColumnName; + TenantIdColumnName = args.TenantIdColumnName; + RefDataCodeColumnName = args.RefDataCodeColumnName; + RefDataTextColumnName = args.RefDataTextColumnName; + EmitDotNetDateOnly = args.EmitDotNetDateOnly; + EmitDotNetTimeOnly = args.EmitDotNetTimeOnly; + DataResetFilterPredicate = args.DataResetFilterPredicate; + + if (args.ExecuteStatements == null) + ExecuteStatements = null; + else { - base.CopyFrom(args.ThrowIfNull(nameof(args))); - - MigrationCommand = args.MigrationCommand; - _assemblies.Clear(); - _assemblies.AddRange(args.Assemblies); - _scripts.Clear(); - args.Scripts.ForEach(x => _scripts.Add(x)); - Parameters.Clear(); - args.Parameters.ForEach(x => Parameters.Add(x.Key, x.Value)); - Logger = args.Logger; - OutputDirectory = args.OutputDirectory; - SchemaOrder.Clear(); - SchemaOrder.AddRange(args.SchemaOrder); - DataParserArgs.CopyFrom(args.DataParserArgs); - IdColumnNameSuffix = args.IdColumnNameSuffix; - CodeColumnNameSuffix = args.CodeColumnNameSuffix; - CreatedOnColumnName = args.CreatedOnColumnName; - CreatedByColumnName = args.CreatedByColumnName; - UpdatedOnColumnName = args.UpdatedOnColumnName; - UpdatedByColumnName = args.UpdatedByColumnName; - RowVersionColumnName = args.RowVersionColumnName; - TenantIdColumnName = args.TenantIdColumnName; - RefDataCodeColumnName = args.RefDataCodeColumnName; - RefDataTextColumnName = args.RefDataTextColumnName; - EmitDotNetDateOnly = args.EmitDotNetDateOnly; - EmitDotNetTimeOnly = args.EmitDotNetTimeOnly; - DataResetFilterPredicate = args.DataResetFilterPredicate; - - if (args.ExecuteStatements == null) - ExecuteStatements = null; - else - { - ExecuteStatements = []; - ExecuteStatements.AddRange(args.ExecuteStatements); - } + ExecuteStatements = []; + ExecuteStatements.AddRange(args.ExecuteStatements); } + } - /// - /// Creates a copy of the where all are converted to a or null. - /// - public IDictionary CreateStringParameters() + /// + /// Creates a copy of the where all are converted to a or null. + /// + public IDictionary CreateStringParameters() + { + var dict = new Dictionary(); + foreach (var item in Parameters) { - var dict = new Dictionary(); - foreach (var item in Parameters) - { - if (item.Value == null) - dict.Add(item.Key, null); - else - dict.Add(item.Key, item.Value.ToString()); - } - - return dict; + if (item.Value == null) + dict.Add(item.Key, null); + else + dict.Add(item.Key, item.Value.ToString()); } - /// - /// Adds a being an explicitly named resource-based script to be included (executed) as per the specified (phase). - /// - /// The (phase) where the script should be executed. - /// The where the script resource resides. - /// The corresponding resource name within the . - /// The must be a single value; currently only and are supported. This represents the phase in which the script will be - /// included for execution. - public void AddScript(MigrationCommand command, Assembly assembly, string name) - { - if (command != MigrationCommand.Migrate && command != MigrationCommand.Schema) - throw new ArgumentException($"The migration script command '{command}' is not supported; currently only '{MigrationCommand.Migrate}' and '{MigrationCommand.Schema}' are supported.", nameof(command)); + return dict; + } - _ = assembly.ThrowIfNull().GetManifestResourceInfo(name.ThrowIfNullOrEmpty()) ?? throw new ArgumentException($"The migration script resource '{name}' does not exist within the assembly '{assembly.FullName}'.", nameof(name)); - _scripts.Add((command, assembly, name)); - } + /// + /// Adds a being an explicitly named resource-based script to be included (executed) as per the specified (phase). + /// + /// The (phase) where the script should be executed. + /// The where the script resource resides. + /// The corresponding resource name within the . + /// The must be a single value; currently only and are supported. This represents the phase in which the script will be + /// included for execution. + public void AddScript(MigrationCommand command, Assembly assembly, string name) + { + if (command != MigrationCommand.Migrate && command != MigrationCommand.Schema) + throw new ArgumentException($"The migration script command '{command}' is not supported; currently only '{MigrationCommand.Migrate}' and '{MigrationCommand.Schema}' are supported.", nameof(command)); + + _ = assembly.ThrowIfNull().GetManifestResourceInfo(name.ThrowIfNullOrEmpty()) ?? throw new ArgumentException($"The migration script resource '{name}' does not exist within the assembly '{assembly.FullName}'.", nameof(name)); + _scripts.Add((command, assembly, name)); } } \ No newline at end of file diff --git a/src/DbEx/Migration/MigrationArgsBaseT.cs b/src/DbEx/Migration/MigrationArgsBaseT.cs index 715a15a..4653fa1 100644 --- a/src/DbEx/Migration/MigrationArgsBaseT.cs +++ b/src/DbEx/Migration/MigrationArgsBaseT.cs @@ -1,108 +1,101 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration; -using System; -using System.Collections.Generic; -using System.Reflection; - -namespace DbEx.Migration +/// +/// Provides the base arguments. +/// +/// The itself being implemented. +public abstract class MigrationArgsBase : MigrationArgsBase where TSelf : MigrationArgsBase { /// - /// Provides the base arguments. + /// Adds one or more to the . /// - /// The itself being implemented. - public abstract class MigrationArgsBase : MigrationArgsBase where TSelf : MigrationArgsBase + /// The assemblies to add. + /// The current instance to support fluent-style method-chaining. + public new TSelf AddAssembly(params Assembly[] assemblies) { - /// - /// Adds one or more to the . - /// - /// The assemblies to add. - /// The current instance to support fluent-style method-chaining. - public new TSelf AddAssembly(params Assembly[] assemblies) - { - base.AddAssembly(assemblies); - return (TSelf)this; - } + base.AddAssembly(assemblies); + return (TSelf)this; + } - /// - /// Adds one or more to the . - /// - /// The assemblies to add. - /// The current instance to support fluent-style method-chaining. - public new TSelf AddAssembly(params MigrationAssemblyArgs[] assemblies) - { - base.AddAssembly(assemblies); - return (TSelf)this; - } + /// + /// Adds one or more to the . + /// + /// The assemblies to add. + /// The current instance to support fluent-style method-chaining. + public new TSelf AddAssembly(params MigrationAssemblyArgs[] assemblies) + { + base.AddAssembly(assemblies); + return (TSelf)this; + } - /// - /// Adds one or more (being the underlying ) to . - /// - /// The types to add. - /// The order in which they are specified is the order in which they will be probed for embedded resources. - /// The current instance to support fluent-style method-chaining. - public TSelf AddAssembly(params Type[] types) + /// + /// Adds one or more (being the underlying ) to . + /// + /// The types to add. + /// The order in which they are specified is the order in which they will be probed for embedded resources. + /// The current instance to support fluent-style method-chaining. + public TSelf AddAssembly(params Type[] types) + { + var list = new List(); + foreach (var t in types) { - var list = new List(); - foreach (var t in types) - { - list.Add(t.Assembly); - } - - return AddAssembly(list.ToArray()); + list.Add(t.Assembly); } - /// - /// Adds the (being the underlying ) to . - /// - /// The ; defaults to . - public TSelf AddAssembly(params string[] dataNamespaces) => AddAssembly(new MigrationAssemblyArgs(typeof(TAssembly).Assembly, dataNamespaces)); + return AddAssembly(list.ToArray()); + } - /// - /// Adds a parameter to the where it does not already exist; unless is selected then it will add or override. - /// - /// The parameter key. - /// The parameter value. - /// Indicates whether to override the existing value where it is pre-existing; otherwise, will not add/update. - /// The current instance to support fluent-style method-chaining. - public new TSelf AddParameter(string key, object? value, bool overrideExisting = false) - { - base.AddParameter(key, value, overrideExisting); - return (TSelf)this; - } + /// + /// Adds the (being the underlying ) to . + /// + /// The ; defaults to . + public TSelf AddAssembly(params string[] dataNamespaces) => AddAssembly(new MigrationAssemblyArgs(typeof(TAssembly).Assembly, dataNamespaces)); - /// - /// Adds one or more to the . - /// - /// The schemas to add. - /// The current instance to support fluent-style method-chaining. - public TSelf AddSchemaOrder(params string[] schemas) - { - SchemaOrder.AddRange(schemas); - return (TSelf)this; - } + /// + /// Adds a parameter to the where it does not already exist; unless is selected then it will add or override. + /// + /// The parameter key. + /// The parameter value. + /// Indicates whether to override the existing value where it is pre-existing; otherwise, will not add/update. + /// The current instance to support fluent-style method-chaining. + public new TSelf AddParameter(string key, object? value, bool overrideExisting = false) + { + base.AddParameter(key, value, overrideExisting); + return (TSelf)this; + } - /// - /// Adds a being an explicitly named resource-based script to be included (executed) as per the specified (phase). - /// - /// The (phase) where the script should be executed. - /// The where the script resource resides. - /// The corresponding resource name within the . - /// The must be a single value; currently only and are supported. This represents the phase in which the script will be - /// included for execution. - public new TSelf AddScript(MigrationCommand command, Assembly assembly, string name) - { - base.AddScript(command, assembly, name); - return (TSelf)this; - } + /// + /// Adds one or more to the . + /// + /// The schemas to add. + /// The current instance to support fluent-style method-chaining. + public TSelf AddSchemaOrder(params string[] schemas) + { + SchemaOrder.AddRange(schemas); + return (TSelf)this; + } - /// - /// Adds a being an explicitly named resource-based script to be included (executed) as per the specified (phase). - /// - /// The to use to infer the underlying where the script resource resides. - /// The (phase) where the script should be executed. - /// The corresponding resource name within the . - /// The must be a single value; currently only and are supported. This represents the phase in which the script will be - /// included for execution. - public TSelf AddScript(MigrationCommand command, string name) => AddScript(command, typeof(TAssembly).Assembly, name); + /// + /// Adds a being an explicitly named resource-based script to be included (executed) as per the specified (phase). + /// + /// The (phase) where the script should be executed. + /// The where the script resource resides. + /// The corresponding resource name within the . + /// The must be a single value; currently only and are supported. This represents the phase in which the script will be + /// included for execution. + public new TSelf AddScript(MigrationCommand command, Assembly assembly, string name) + { + base.AddScript(command, assembly, name); + return (TSelf)this; } + + /// + /// Adds a being an explicitly named resource-based script to be included (executed) as per the specified (phase). + /// + /// The to use to infer the underlying where the script resource resides. + /// The (phase) where the script should be executed. + /// The corresponding resource name within the . + /// The must be a single value; currently only and are supported. This represents the phase in which the script will be + /// included for execution. + public TSelf AddScript(MigrationCommand command, string name) => AddScript(command, typeof(TAssembly).Assembly, name); } \ No newline at end of file diff --git a/src/DbEx/Migration/MigrationAssemblyArgs.cs b/src/DbEx/Migration/MigrationAssemblyArgs.cs index b7bfcbf..e20640d 100644 --- a/src/DbEx/Migration/MigrationAssemblyArgs.cs +++ b/src/DbEx/Migration/MigrationAssemblyArgs.cs @@ -1,38 +1,33 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration; -using System.Reflection; - -namespace DbEx.Migration +/// +/// Provides arguments. +/// +public class MigrationAssemblyArgs { /// - /// Provides arguments. + /// Gets or sets the default Data namespace part name. /// - public class MigrationAssemblyArgs - { - /// - /// Gets or sets the default Data namespace part name. - /// - public static string DefaultDataNamespace { get; set; } = "Data"; + public static string DefaultDataNamespace { get; set; } = "Data"; - /// - /// Initializes a new instance of the . - /// - /// The . - /// The ; defaults to . - public MigrationAssemblyArgs(Assembly assembly, params string[] dataNamespaces) - { - Assembly = assembly.ThrowIfNull(nameof(Assembly)); - DataNamespaces = dataNamespaces is null || dataNamespaces.Length == 0 ? [DefaultDataNamespace] : dataNamespaces; - } + /// + /// Initializes a new instance of the . + /// + /// The . + /// The ; defaults to . + public MigrationAssemblyArgs(Assembly assembly, params string[] dataNamespaces) + { + Assembly = assembly.ThrowIfNull(nameof(Assembly)); + DataNamespaces = dataNamespaces is null || dataNamespaces.Length == 0 ? [DefaultDataNamespace] : dataNamespaces; + } - /// - /// Gets the . - /// - public Assembly Assembly { get; } + /// + /// Gets the . + /// + public Assembly Assembly { get; } - /// - /// Gets the Data namespace part name(s). - /// - public string[] DataNamespaces { get; } - } + /// + /// Gets the Data namespace part name(s). + /// + public string[] DataNamespaces { get; } } \ No newline at end of file diff --git a/src/DbEx/Migration/StringLogger.cs b/src/DbEx/Migration/StringLogger.cs index afe8692..e30adb2 100644 --- a/src/DbEx/Migration/StringLogger.cs +++ b/src/DbEx/Migration/StringLogger.cs @@ -1,31 +1,24 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx.Migration; -using Microsoft.Extensions.Logging; -using System; -using System.Text; - -namespace DbEx.Migration +/// +/// Provides a basic that captures the as a . +/// +internal class StringLogger : ILogger { - /// - /// Provides a basic that captures the as a . - /// - internal class StringLogger : ILogger - { - private readonly StringBuilder _stringBuilder = new(); - private readonly LoggerExternalScopeProvider _scopeProvider = new(); + private readonly StringBuilder _stringBuilder = new(); + private readonly LoggerExternalScopeProvider _scopeProvider = new(); - /// - public IDisposable BeginScope(TState state) where TState : notnull => _scopeProvider.Push(state); + /// + public IDisposable BeginScope(TState state) where TState : notnull => _scopeProvider.Push(state); - /// - public bool IsEnabled(LogLevel logLevel) => true; + /// + public bool IsEnabled(LogLevel logLevel) => true; - /// - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => _stringBuilder.AppendLine(formatter(state, exception)); + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => _stringBuilder.AppendLine(formatter(state, exception)); - /// - /// Gets the log output. - /// - public string Output => _stringBuilder.ToString(); - } + /// + /// Gets the log output. + /// + public string Output => _stringBuilder.ToString(); } \ No newline at end of file diff --git a/src/DbEx/MigrationCommand.cs b/src/DbEx/MigrationCommand.cs index 5d719ae..fc3af59 100644 --- a/src/DbEx/MigrationCommand.cs +++ b/src/DbEx/MigrationCommand.cs @@ -1,120 +1,114 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +namespace DbEx; -using System; -using DbEx.Migration; - -namespace DbEx +/// +/// Represents the migration command, in that it controls the underlying migration tasks that are to be performed. +/// +[Flags] +public enum MigrationCommand { /// - /// Represents the migration command, in that it controls the underlying migration tasks that are to be performed. - /// - [Flags] - public enum MigrationCommand - { - /// - /// Nothing specified. - /// - None = 0, - - /// - /// Drop the existing database (where it already exists). - /// - Drop = 1, - - /// - /// Create the database (where it does not already exist). - /// - Create = 2, - - /// - /// Migrate the database using the Migrations scripts (those that have not already been executed). - /// - Migrate = 4, - - /// - /// Generates the likes of database Schema objects via code-generation (where applicable). - /// - /// The must be set true to enable execution within the orchestration flow. - CodeGen = 8, - - /// - /// Drops and creates the known database Schema objects. - /// - /// These are generally schema related artefacts that are applied as scripted on every invocation. These may be deleted (where underlying object is pre-existing) and then (re-)applied where object type is known. - Schema = 16, - - /// - /// Resets the database by deleting all existing data. - /// - /// This is intended for development and testing purposes only; therefore, this should never be used in a production environment. - Reset = 32, - - /// - /// Inserts or merges Data from embedded YAML files. - /// - Data = 64, - - /// - /// Performs all the primary commands as follows; , , , and . - /// - All = Create | Migrate | CodeGen | Schema | Data, - - /// - /// Performs , and . - /// - /// This can be useful in development scenarios where the results in a new migration script that needs to be applied before any corresponding operations are performed. - CreateMigrateAndCodeGen = Create | Migrate | CodeGen, - - /// - /// Performs and . - /// - Deploy = Migrate | Schema, - - /// - /// Performs with . - /// - DeployWithData = Deploy | Data, - - /// - /// Performs and . - /// - DropAndAll = Drop | All, - - /// - /// Performs and . - /// - ResetAndAll = Reset | All, - - /// - /// Performs only the database commands as follows: , , and . - /// - Database = Create | Migrate | Schema | Data, - - /// - /// Performs and . - /// - DropAndDatabase = Drop | Database, - - /// - /// Performs and . - /// - ResetAndDatabase = Reset | Database, - - /// - /// Performs and . - /// - ResetAndData = Reset | Data, - - /// - /// Executes the SQL statement(s) passed as additional arguments. - /// - /// This can not be used with any of the other commands. - Execute = 1024, - - /// - /// Creates a new migration script file using the defined naming convention. - /// - /// This can not be used with any of the other commands. - Script = 2048 - } + /// Nothing specified. + /// + None = 0, + + /// + /// Drop the existing database (where it already exists). + /// + Drop = 1, + + /// + /// Create the database (where it does not already exist). + /// + Create = 2, + + /// + /// Migrate the database using the Migrations scripts (those that have not already been executed). + /// + Migrate = 4, + + /// + /// Generates the likes of database Schema objects via code-generation (where applicable). + /// + /// The must be set true to enable execution within the orchestration flow. + CodeGen = 8, + + /// + /// Drops and creates the known database Schema objects. + /// + /// These are generally schema related artefacts that are applied as scripted on every invocation. These may be deleted (where underlying object is pre-existing) and then (re-)applied where object type is known. + Schema = 16, + + /// + /// Resets the database by deleting all existing data. + /// + /// This is intended for development and testing purposes only; therefore, this should never be used in a production environment. + Reset = 32, + + /// + /// Inserts or merges Data from embedded YAML files. + /// + Data = 64, + + /// + /// Performs all the primary commands as follows; , , , and . + /// + All = Create | Migrate | CodeGen | Schema | Data, + + /// + /// Performs , and . + /// + /// This can be useful in development scenarios where the results in a new migration script that needs to be applied before any corresponding operations are performed. + CreateMigrateAndCodeGen = Create | Migrate | CodeGen, + + /// + /// Performs and . + /// + Deploy = Migrate | Schema, + + /// + /// Performs with . + /// + DeployWithData = Deploy | Data, + + /// + /// Performs and . + /// + DropAndAll = Drop | All, + + /// + /// Performs and . + /// + ResetAndAll = Reset | All, + + /// + /// Performs only the database commands as follows: , , and . + /// + Database = Create | Migrate | Schema | Data, + + /// + /// Performs and . + /// + DropAndDatabase = Drop | Database, + + /// + /// Performs and . + /// + ResetAndDatabase = Reset | Database, + + /// + /// Performs and . + /// + ResetAndData = Reset | Data, + + /// + /// Executes the SQL statement(s) passed as additional arguments. + /// + /// This can not be used with any of the other commands. + Execute = 1024, + + /// + /// Creates a new migration script file using the defined naming convention. + /// + /// This can not be used with any of the other commands. + Script = 2048 } \ No newline at end of file From 0bcf9925763ffc5e6294358ce7c6fc0d9b254565 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Sun, 29 Mar 2026 13:21:40 -0700 Subject: [PATCH 03/11] Preview-1 --- CHANGELOG.md | 11 +- Common.targets | 2 +- src/DbEx/CodeGen/Config/CodeGenConfig.cs | 1 - src/DbEx/CodeGen/Config/IColumnConfig.cs | 162 --------------------- src/DbEx/CodeGen/Config/ISpecialColumns.cs | 103 ------------- src/DbEx/DbEx.csproj | 4 + src/DbEx/DbSchema/DbColumnSchema.cs | 4 +- src/DbEx/GlobalUsing.cs | 1 + 8 files changed, 14 insertions(+), 274 deletions(-) delete mode 100644 src/DbEx/CodeGen/Config/CodeGenConfig.cs delete mode 100644 src/DbEx/CodeGen/Config/IColumnConfig.cs delete mode 100644 src/DbEx/CodeGen/Config/ISpecialColumns.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a0163e..c0ce476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ Represents the **NuGet** versions. -## v3.0.0 -All internal dependecies to [`CoreEx`](https://github.com/avanade/coreex) have been removed. This is intended to further generalize the capabilities of `DbEx`; but more importantly, break the circular dependency reference between the two repositories. +## v3.0.0 [preview-only; subject to change] +All internal dependencies to [`CoreEx`](https://github.com/avanade/coreex) have been removed. This is intended to further generalize the capabilities of `DbEx`; but more importantly, break the circular dependency reference between the two repositories. - *Enhancement:* Added `net10.0` support and updated all related package dependencies to latest. Removed `net6.0` support. - *Enhancement:* List of key **breaking changes** as follows: - `DatabaseSchemaConfig.CreatedDate` renamed to `DatabaseSchemaConfig.CreatedOn`. @@ -11,9 +11,10 @@ All internal dependecies to [`CoreEx`](https://github.com/avanade/coreex) have b - `MigrationArgsBase.CreatedDateColumnName` renamed to `MigrationArgsBase.CreatedOnColumnName`. - `MigrationArgsBase.UpdatedDateColumnName` renamed to `MigrationArgsBase.UpdatedOnColumnName`. - `DateTimeOffset` is the preferred .NET type for date/time auditing/timestamping. -- *Enhancement:* Absorbing the [`Beef`](https://github.com/avanade/beef) database code-generation capabilities into `DbEx` to enable greater usage and consistency. - - The code-generation templates have been updated to reflect the latest patterns and practices (where applicable). - - The code-generation configuration file has been renamed to `dbex.yaml` to avoid conflicts; schema remains largely the same. +- *Enhancement:* [**Pending**] Intent in next preview (2) is the introduction of key [`Beef`](https://github.com/avanade/beef) database code-generation capabilities into `DbEx` to enable greater usage. + - Initial focus will be on the Entity Framework (EF) convention-based model generation; being the most commonly used, and having the broadest applicability (all supported databases included). + - The code-generation templates will be updated to reflect the latest patterns and practices (where applicable). + - The code-generation configuration file will be named to `dbex.yaml` to avoid conflicts. The enhancements have been made in a manner to maximize backwards compatibility with previous versions of `DbEx` where possible; however, some breaking changes were unfortunately unavoidable (and made to improve overall). diff --git a/Common.targets b/Common.targets index c341773..76bd7e0 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@ - 3.0.0 + 3.0.0-preview-1 preview Avanade Avanade diff --git a/src/DbEx/CodeGen/Config/CodeGenConfig.cs b/src/DbEx/CodeGen/Config/CodeGenConfig.cs deleted file mode 100644 index 5f28270..0000000 --- a/src/DbEx/CodeGen/Config/CodeGenConfig.cs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/DbEx/CodeGen/Config/IColumnConfig.cs b/src/DbEx/CodeGen/Config/IColumnConfig.cs deleted file mode 100644 index ac3c205..0000000 --- a/src/DbEx/CodeGen/Config/IColumnConfig.cs +++ /dev/null @@ -1,162 +0,0 @@ -namespace DbEx.CodeGen.Config; - -/// -/// Defines the column configuration. -/// -public interface IColumnConfig -{ - /// - /// Gets the column name. - /// - string? Name { get; } - - /// - /// Gets the database configuration. - /// - DbColumnSchema? DbColumn { get; } - - /// - /// Gets the qualified name (includes the alias). - /// - public string QualifiedName { get; } - - /// - /// Gets the parameter name. - /// - public string ParameterName { get; } - - /// - /// Gets the SQL type. - /// - public string? SqlType { get; } - - /// - /// Gets the parameter SQL definition. - /// - public string? ParameterSql { get; } - - /// - /// Gets the UDT SQL definition. - /// - public string? UdtSql { get; } - - /// - /// Gets the where equality clause. - /// - public string WhereEquals { get; } - - /// - /// Gets the SQL for defining initial value for comparisons. - /// - public string SqlInitialValue { get; } - - /// - /// Indicates where the column is the "TenantId" column. - /// - public bool IsTenantIdColumn { get; } - - /// - /// Indicates where the column is the "OrgUnitId" column. - /// - public bool IsOrgUnitIdColumn { get; } - - /// - /// Indicates where the column is the "RowVersion" column. - /// - public bool IsRowVersionColumn { get; } - - /// - /// Indicates where the column is the "IsDeleted" column. - /// - public bool IsIsDeletedColumn { get; } - - /// - /// Indicates whether the column is considered an audit column. - /// - public bool IsAudit { get; } - - /// - /// Indicates whether the column is "CreatedBy" or "CreatedDate". - /// - public bool IsCreated { get; } - - /// - /// Indicates whether the column is "CreatedBy". - /// - public bool IsCreatedBy { get; } - - /// - /// Indicates whether the column is "CreatedDate". - /// - public bool IsCreatedDate { get; } - - /// - /// Indicates whether the column is "UpdatedBy" or "UpdatedDate". - /// - public bool IsUpdated { get; } - - /// - /// Indicates whether the column is "UpdatedBy". - /// - public bool IsUpdatedBy { get; } - - /// - /// Indicates whether the column is "UpdatedDate". - /// - public bool IsUpdatedDate { get; } - - /// - /// Indicates whether the column is "DeletedBy" or "DeletedDate". - /// - public bool IsDeleted { get; } - - /// - /// Indicates whether the column is "DeletedBy". - /// - public bool IsDeletedBy { get; } - - /// - /// Indicates whether the column is "DeletedDate". - /// - public bool IsDeletedDate { get; } - - /// - /// Indicates where the column should be considered for a 'Create' operation. - /// - public bool IsCreateColumn { get; } - - /// - /// Indicates where the column should be considered for a 'Update' operation. - /// - public bool IsUpdateColumn { get; } - - /// - /// Indicates where the column should be considered for a 'Delete' operation. - /// - public bool IsDeleteColumn { get; } - - /// - /// Gets the EF SQL Type. - /// - public string? EfSqlType { get; } - - /// - /// Gets the corresponding .NET name. - /// - public string DotNetType { get; } - - /// - /// Indicates whether the .NET property is nullable. - /// - public bool IsDotNetNullable { get; } - - /// - /// Gets the name alias. - /// - public string? NameAlias { get; } - - /// - /// Gets the qualified name with the alias (used in a select). - /// - public string QualifiedNameWithAlias { get; } -} \ No newline at end of file diff --git a/src/DbEx/CodeGen/Config/ISpecialColumns.cs b/src/DbEx/CodeGen/Config/ISpecialColumns.cs deleted file mode 100644 index 8a04e3c..0000000 --- a/src/DbEx/CodeGen/Config/ISpecialColumns.cs +++ /dev/null @@ -1,103 +0,0 @@ -namespace DbEx.CodeGen.Config; - -/// -/// Provides the standardized set of special column names. -/// -public interface ISpecialColumnNames -{ - /// - /// Gets or sets the column name for the `IsDeleted` capability. - /// - string? ColumnNameIsDeleted { get; set; } - - /// - /// Gets or sets the column name for the `TenantId` capability. - /// - string? ColumnNameTenantId { get; set; } - - /// - /// Gets or sets the column name for the `OrgUnitId` capability. - /// - string? ColumnNameOrgUnitId { get; set; } - - /// - /// Gets or sets the column name for the `RowVersion` capability. - /// - string? ColumnNameRowVersion { get; set; } - - /// - /// Gets or sets the column name for the `CreatedBy` capability. - /// - string? ColumnNameCreatedBy { get; set; } - - /// - /// Gets or sets the column name for the `CreatedOn` capability. - /// - string? ColumnNameCreatedDate { get; set; } - - /// - /// Gets or sets the column name for the `UpdatedBy` capability. - /// - string? ColumnNameUpdatedBy { get; set; } - - /// - /// Gets or sets the column name for the `UpdatedOn` capability. - /// - string? ColumnNameUpdatedDate { get; set; } - - /// - /// Gets or sets the column name for the `DeletedBy` capability. - /// - string? ColumnNameDeletedBy { get; set; } - - /// - /// Gets or sets the column name for the `DeletedDate` capability. - /// - string? ColumnNameDeletedDate { get; set; } -} - -/// -/// Provides the standardized set of special columns. -/// -public interface ISpecialColumns -{ - /// - /// Gets the related TenantId column. - /// - IColumnConfig? ColumnTenantId { get; } - - /// - /// Gets the related OrgUnitId column. - /// - IColumnConfig? ColumnOrgUnitId { get; } - - /// - /// Gets the related RowVersion column. - /// - IColumnConfig? ColumnRowVersion { get; } - - /// - /// Gets the related IsDeleted column. - /// - IColumnConfig? ColumnIsDeleted { get; } - - /// - /// Gets the related CreatedBy column. - /// - IColumnConfig? ColumnCreatedBy { get; } - - /// - /// Gets the related CreatedOn column. - /// - IColumnConfig? ColumnCreatedOn { get; } - - /// - /// Gets the related UpdatedBy column. - /// - IColumnConfig? ColumnUpdatedBy { get; } - - /// - /// Gets the related UpdatedDate column. - /// - IColumnConfig? ColumnUpdatedOn { get; } -} \ No newline at end of file diff --git a/src/DbEx/DbEx.csproj b/src/DbEx/DbEx.csproj index 55a31fc..9757f58 100644 --- a/src/DbEx/DbEx.csproj +++ b/src/DbEx/DbEx.csproj @@ -25,6 +25,10 @@ + + + + diff --git a/src/DbEx/DbSchema/DbColumnSchema.cs b/src/DbEx/DbSchema/DbColumnSchema.cs index 0336b64..c99e01b 100644 --- a/src/DbEx/DbSchema/DbColumnSchema.cs +++ b/src/DbEx/DbSchema/DbColumnSchema.cs @@ -127,12 +127,12 @@ public class DbColumnSchema(DbTableSchema dbTable, string name, string type, str public string? ForeignRefDataCodeColumn { get; set; } /// - /// Indicates whether the column is a created audit column; i.e. name is CreatedDate or CreatedBy. + /// Indicates whether the column is a created audit column; i.e. name is CreatedOn or CreatedBy. /// public bool IsCreatedAudit { get; set; } /// - /// Indicates whether the column is an updated audit column; i.e. name is UpdatedDate or UpdatedBy. + /// Indicates whether the column is an updated audit column; i.e. name is UpdatedOn or UpdatedBy. /// public bool IsUpdatedAudit { get; set; } diff --git a/src/DbEx/GlobalUsing.cs b/src/DbEx/GlobalUsing.cs index 9f6ae67..b8885af 100644 --- a/src/DbEx/GlobalUsing.cs +++ b/src/DbEx/GlobalUsing.cs @@ -7,6 +7,7 @@ global using Microsoft.Extensions.Logging; global using Microsoft.Extensions.Logging.Abstractions; global using OnRamp; +global using OnRamp.Config; global using OnRamp.Console; global using OnRamp.Utility; global using System; From 8e84fc5374f647a2fb7643fe84fc89e706e4aed4 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 1 Apr 2026 17:41:48 -0700 Subject: [PATCH 04/11] Preview of code-gen. --- CHANGELOG.md | 13 +- Common.targets | 2 +- .../Console/SqlServerMigrationConsole.cs | 1 + src/DbEx.SqlServer/DbEx.SqlServer.csproj | 3 +- .../Resources/ScriptOutbox_sql.hbs | 41 +++ .../Resources/ScriptSchema_sql.hbs | 3 +- src/DbEx.SqlServer/Scripts/Database.yaml | 11 + .../Templates/SpOutboxBatchClaim_sql.hbs | 117 +++++++++ .../Templates/SpOutboxBatchComplete_sql.hbs | 74 ++++++ .../Templates/SpOutboxEnqueue_sql.hbs | 37 +++ .../Templates/SpOutboxLeaseAcquire_sql.hbs | 74 ++++++ .../Templates/SpOutboxLeaseRelease_sql.hbs | 45 ++++ .../Templates/spOutboxBatchCancel_sql.hbs | 72 ++++++ src/DbEx/CodeGen/Config/CodeGenConfig.cs | 239 +++++++++++++++++ src/DbEx/CodeGen/Config/ColumnConfig.cs | 53 ++++ .../Config/IByConventionColumnNames.cs | 42 +++ .../CodeGen/Config/IByConventionColumns.cs | 47 ++++ src/DbEx/CodeGen/Config/RefDataConfig.cs | 55 ++++ src/DbEx/CodeGen/Config/TableConfig.cs | 242 ++++++++++++++++++ src/DbEx/CodeGen/DbCodeGenerator.cs | 16 ++ .../Generators/EfModelBuilderGenerator.cs | 13 + .../CodeGen/Generators/EfModelGenerator.cs | 13 + .../CodeGen/Generators/OutboxGenerator.cs | 13 + src/DbEx/Console/MigrationConsoleBase.cs | 10 + src/DbEx/DbEx.csproj | 7 +- src/DbEx/DbSchema/DbColumnSchema.cs | 44 +++- src/DbEx/DbSchema/DbTableSchema.cs | 8 +- src/DbEx/Migration/Database.cs | 23 +- src/DbEx/Migration/DatabaseMigrationBase.cs | 70 ++++- src/DbEx/Migration/MigrationArgsBase.cs | 12 + src/DbEx/Templates/EfModelBuilder_cs.hbs | 38 +++ src/DbEx/Templates/EfModel_cs.hbs | 30 +++ .../008-create-test-outbox-tables.sql | 37 +++ .../Properties/launchSettings.json | 2 +- .../spOutboxBatchCancel.g.sql | 71 +++++ .../spOutboxBatchClaim.g.sql | 116 +++++++++ .../spOutboxBatchComplete.g.sql | 73 ++++++ .../Stored Procedures/spOutboxEnqueue.g.sql | 36 +++ .../spOutboxLeaseAcquire.g.sql | 73 ++++++ .../spOutboxLeaseRelease.g.sql | 44 ++++ tests/DbEx.Test.Console/dbex.yaml | 11 + tests/DbEx.Test.Empty/DbEx.Test.Empty.csproj | 25 +- tests/DbEx.Test.Empty/GlobalUsing.cs | 2 + tests/DbEx.Test.Empty/Persistence/Address.cs | 9 + tests/DbEx.Test.Empty/Persistence/Contact.cs | 5 + .../DbEx.Test.Empty/Persistence/Contact.g.cs | 63 +++++ .../Persistence/JsonConverter.cs | 8 + tests/DbEx.Test.Empty/Persistence/Person.g.cs | 58 +++++ .../Repositories/TestDbContext.g.cs | 52 ++++ 49 files changed, 2111 insertions(+), 42 deletions(-) create mode 100644 src/DbEx.SqlServer/Resources/ScriptOutbox_sql.hbs create mode 100644 src/DbEx.SqlServer/Scripts/Database.yaml create mode 100644 src/DbEx.SqlServer/Templates/SpOutboxBatchClaim_sql.hbs create mode 100644 src/DbEx.SqlServer/Templates/SpOutboxBatchComplete_sql.hbs create mode 100644 src/DbEx.SqlServer/Templates/SpOutboxEnqueue_sql.hbs create mode 100644 src/DbEx.SqlServer/Templates/SpOutboxLeaseAcquire_sql.hbs create mode 100644 src/DbEx.SqlServer/Templates/SpOutboxLeaseRelease_sql.hbs create mode 100644 src/DbEx.SqlServer/Templates/spOutboxBatchCancel_sql.hbs create mode 100644 src/DbEx/CodeGen/Config/CodeGenConfig.cs create mode 100644 src/DbEx/CodeGen/Config/ColumnConfig.cs create mode 100644 src/DbEx/CodeGen/Config/IByConventionColumnNames.cs create mode 100644 src/DbEx/CodeGen/Config/IByConventionColumns.cs create mode 100644 src/DbEx/CodeGen/Config/RefDataConfig.cs create mode 100644 src/DbEx/CodeGen/Config/TableConfig.cs create mode 100644 src/DbEx/CodeGen/DbCodeGenerator.cs create mode 100644 src/DbEx/CodeGen/Generators/EfModelBuilderGenerator.cs create mode 100644 src/DbEx/CodeGen/Generators/EfModelGenerator.cs create mode 100644 src/DbEx/CodeGen/Generators/OutboxGenerator.cs create mode 100644 src/DbEx/Templates/EfModelBuilder_cs.hbs create mode 100644 src/DbEx/Templates/EfModel_cs.hbs create mode 100644 tests/DbEx.Test.Console/Migrations/008-create-test-outbox-tables.sql create mode 100644 tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxBatchCancel.g.sql create mode 100644 tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxBatchClaim.g.sql create mode 100644 tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxBatchComplete.g.sql create mode 100644 tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxEnqueue.g.sql create mode 100644 tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql create mode 100644 tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql create mode 100644 tests/DbEx.Test.Console/dbex.yaml create mode 100644 tests/DbEx.Test.Empty/GlobalUsing.cs create mode 100644 tests/DbEx.Test.Empty/Persistence/Address.cs create mode 100644 tests/DbEx.Test.Empty/Persistence/Contact.cs create mode 100644 tests/DbEx.Test.Empty/Persistence/Contact.g.cs create mode 100644 tests/DbEx.Test.Empty/Persistence/JsonConverter.cs create mode 100644 tests/DbEx.Test.Empty/Persistence/Person.g.cs create mode 100644 tests/DbEx.Test.Empty/Repositories/TestDbContext.g.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c0ce476..80204cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,18 @@ Represents the **NuGet** versions. ## v3.0.0 [preview-only; subject to change] All internal dependencies to [`CoreEx`](https://github.com/avanade/coreex) have been removed. This is intended to further generalize the capabilities of `DbEx`; but more importantly, break the circular dependency reference between the two repositories. -- *Enhancement:* Added `net10.0` support and updated all related package dependencies to latest. Removed `net6.0` support. +- *Enhancement:* Added `net10.0` support and updated all related package dependencies to latest. Supports only `net8.0`, `net9.0` and `net10.0`. - *Enhancement:* List of key **breaking changes** as follows: - `DatabaseSchemaConfig.CreatedDate` renamed to `DatabaseSchemaConfig.CreatedOn`. - `DatabaseSchemaConfig.UpdatedDate` renamed to `DatabaseSchemaConfig.UpdatedOn`. - `MigrationArgsBase.CreatedDateColumnName` renamed to `MigrationArgsBase.CreatedOnColumnName`. - `MigrationArgsBase.UpdatedDateColumnName` renamed to `MigrationArgsBase.UpdatedOnColumnName`. - `DateTimeOffset` is the preferred .NET type for date/time auditing/timestamping. -- *Enhancement:* [**Pending**] Intent in next preview (2) is the introduction of key [`Beef`](https://github.com/avanade/beef) database code-generation capabilities into `DbEx` to enable greater usage. - - Initial focus will be on the Entity Framework (EF) convention-based model generation; being the most commonly used, and having the broadest applicability (all supported databases included). - - The code-generation templates will be updated to reflect the latest patterns and practices (where applicable). - - The code-generation configuration file will be named to `dbex.yaml` to avoid conflicts. +- *Enhancement:* Introduced basic code-generation (leverages [`OnRamp`](https://github.com/avanade/onramp)). + - Entity Framework (EF) convention-based model and model-builder code generation added (all supported databases included). + - Transactional `Outbox` and corresponding `OutboxLease` code-generation added (SQL Server only). + - The existence of the code-generation configuration file `dbex.yaml` is required to enable. + - Preview 3 will add `dbex.yaml` schema and documentation. The enhancements have been made in a manner to maximize backwards compatibility with previous versions of `DbEx` where possible; however, some breaking changes were unfortunately unavoidable (and made to improve overall). @@ -51,7 +52,7 @@ The enhancements have been made in a manner to maximize backwards compatibility - *Fixed:* SQL Server `data` merge statement fixed to include the `TenantIdColumn` where applicable to avoid possible duplicate key. ## v2.5.7 -- *Fixed:* Corrected issue introduced in version `2.5.5` where some strings were being incorrectly converted to a guid. +- *Fixed:* Corrected issue introduced in version `2.5.5` where some strings were being incorrectly converted to a GUID. ## v2.5.6 - *Fixed:* Release build and publish; version `2.5.5` was not published correctly. diff --git a/Common.targets b/Common.targets index 76bd7e0..ac63ff3 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@ - 3.0.0-preview-1 + 3.0.0-preview-2 preview Avanade Avanade diff --git a/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs b/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs index 654bab8..b72dc4f 100644 --- a/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs +++ b/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs @@ -55,6 +55,7 @@ public void WriteScriptHelp() Logger?.LogInformation("{help}", " script cdc
Creates a SQL script to turn on CDC for the specified table."); Logger?.LogInformation("{help}", " script cdcdb Creates a SQL script to turn on CDC for the database."); Logger?.LogInformation("{help}", " script create
Creates a SQL script to perform a CREATE TABLE."); + Logger?.LogInformation("{help}", " script outbox
Creates a SQL script to perform a CREATE TABLE(s) for an Outbox."); Logger?.LogInformation("{help}", " script refdata
Creates a SQL script to perform a CREATE TABLE as reference data."); Logger?.LogInformation("{help}", " script schema Creates a SQL script to perform a CREATE SCHEMA."); } diff --git a/src/DbEx.SqlServer/DbEx.SqlServer.csproj b/src/DbEx.SqlServer/DbEx.SqlServer.csproj index 1cda3c6..e1c5d8c 100644 --- a/src/DbEx.SqlServer/DbEx.SqlServer.csproj +++ b/src/DbEx.SqlServer/DbEx.SqlServer.csproj @@ -1,7 +1,7 @@  - net8.0;net9.0;net10.0;netstandard2.1 + net8.0;net9.0;net10.0 DbEx.SqlServer DbEx DbEx SQL Server Migration Tool. @@ -19,6 +19,7 @@ + diff --git a/src/DbEx.SqlServer/Resources/ScriptOutbox_sql.hbs b/src/DbEx.SqlServer/Resources/ScriptOutbox_sql.hbs new file mode 100644 index 0000000..38fd750 --- /dev/null +++ b/src/DbEx.SqlServer/Resources/ScriptOutbox_sql.hbs @@ -0,0 +1,41 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +{{! FILENAME:create-[lower (lookup Parameters 'Param1')]-[lower (lookup Parameters 'Param2')]-tables }} +{{! PARAM:Param1=Schema }} +{{! PARAM:Param2=Outbox }} +-- Create table: [{{lookup Parameters 'Param1'}}].[{{lookup Parameters 'Param2'}}] and [{{lookup Parameters 'Param1'}}].[{{lookup Parameters 'Param2'}}Lease] + +BEGIN TRANSACTION + +CREATE TABLE [{{lookup Parameters 'Param1'}}].[{{lookup Parameters 'Param2'}}] ( + [{{lookup Parameters 'Param2'}}Id] BIGINT IDENTITY (1, 1) NOT NULL PRIMARY KEY, + [TenantId] NVARCHAR(255) NOT NULL, -- Optional, null indicates no tenancy. + [PartitionId] INT NOT NULL, -- Partition number; computed in application from partition-key. + [Status] TINYINT NOT NULL DEFAULT 0, -- 0=Pending, 1=Processing, 2=Done. + [EnqueuedUtc] DATETIME2 NOT NULL, -- When the event was enqueued within application. + [AvailableUtc] DATETIME2 NOT NULL, -- When the event is eligible for processing (retry delay). + [DequeuedUtc] DATETIME2 NULL, -- When the event was successfully dequeued/relayed. + [Attempts] INT NOT NULL DEFAULT 0, -- Retry attempt count. + + -- Message: + [Destination] NVARCHAR(255) NULL, -- Message destination; i.e. queue/topic/etc. + [Event] NVARCHAR(MAX) NOT NULL, -- CloudEvent as JSON. + + -- Claim/leasing: + [LeaseId] UNIQUEIDENTIFIER NULL, -- Unique identifier of the lease. + [LeaseUntilUtc] DATETIME2 NULL, -- Leased until UTC; after which assume released due to possible application crash. + + INDEX [IX_{{lookup Parameters 'Param1'}}_{{lookup Parameters 'Param2'}}_PartitionOrder] ([TenantId], [PartitionId], [{{lookup Parameters 'Param2'}}Id]) INCLUDE ([Status], [AvailableUtc], [LeaseUntilUtc], [Destination], [Event], [Attempts]), + INDEX [IX_{{lookup Parameters 'Param1'}}_{{lookup Parameters 'Param2'}}_WorkerPull] ([TenantId], [PartitionId], [Status]) INCLUDE ([{{lookup Parameters 'Param2'}}Id], [AvailableUtc]), + INDEX [IX_{{lookup Parameters 'Param1'}}_{{lookup Parameters 'Param2'}}_CleanUp] ([{{lookup Parameters 'Param2'}}Id]) INCLUDE ([DequeuedUtc]) WHERE [Status] = 2 +); + +CREATE TABLE [{{lookup Parameters 'Param1'}}].[{{lookup Parameters 'Param2'}}Lease] ( + [TenantId] NVARCHAR(255) NOT NULL, -- Optional, null indicates no tenancy. + [PartitionId] INT NOT NULL, -- Partition number; computed in application from partition-key. + [LeaseId] UNIQUEIDENTIFIER NULL, -- Unique identifier of the leasee. + [LeaseUntilUtc] DATETIME2 NULL -- Leased until UTC; after which assume released due to possible application crash. + + CONSTRAINT PK_{{lookup Parameters 'Param1'}}_{{lookup Parameters 'Param2'}}Lease PRIMARY KEY (TenantId, PartitionId) +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/src/DbEx.SqlServer/Resources/ScriptSchema_sql.hbs b/src/DbEx.SqlServer/Resources/ScriptSchema_sql.hbs index 9066d04..17441e2 100644 --- a/src/DbEx.SqlServer/Resources/ScriptSchema_sql.hbs +++ b/src/DbEx.SqlServer/Resources/ScriptSchema_sql.hbs @@ -3,5 +3,4 @@ {{! PARAM:Param1=Name }} -- Create schema: [{{lookup Parameters 'Param1'}}] -CREATE SCHEMA [{{lookup Parameters 'Param1'}}] - AUTHORIZATION [dbo]; \ No newline at end of file +CREATE SCHEMA [{{lookup Parameters 'Param1'}}]; \ No newline at end of file diff --git a/src/DbEx.SqlServer/Scripts/Database.yaml b/src/DbEx.SqlServer/Scripts/Database.yaml new file mode 100644 index 0000000..4dca701 --- /dev/null +++ b/src/DbEx.SqlServer/Scripts/Database.yaml @@ -0,0 +1,11 @@ +configType: DbEx.CodeGen.Config.CodeGenConfig, DbEx +generators: +- { type: 'DbEx.CodeGen.Generators.EfModelGenerator, DbEx', template: 'EfModel_cs', file: '{{EfModelName}}.g.cs', directory: '{{Root.DotNetDataProjectDirectory.FullName}}/{{Root.DotNetDataEfModelsPath}}', text: Entity-framework (EF) model(s) generation } +- { type: 'DbEx.CodeGen.Generators.EfModelBuilderGenerator, DbEx', template: 'EfModelBuilder_cs', file: '{{Domain}}DbContext.g.cs', directory: '{{Root.DotNetDataProjectDirectory.FullName}}/{{Root.DotNetDataEfRepositoriesPath}}', text: Entity-framework (EF) DbContext model builder generation } + +- { type: 'DbEx.CodeGen.Generators.OutboxGenerator, DbEx', template: 'SpOutboxEnqueue_sql', file: 'sp{{OutboxName}}Enqueue.g.sql', directory: '{{Root.DatabaseDirectory.FullName}}/Schema/Stored Procedures', text: Outbox enqueue stored-procedure generation } +- { type: 'DbEx.CodeGen.Generators.OutboxGenerator, DbEx', template: 'SpOutboxBatchClaim_sql', file: 'sp{{OutboxName}}BatchClaim.g.sql', directory: '{{Root.DatabaseDirectory.FullName}}/Schema/Stored Procedures', text: Outbox batch-claim stored-procedure generation } +- { type: 'DbEx.CodeGen.Generators.OutboxGenerator, DbEx', template: 'SpOutboxBatchComplete_sql', file: 'sp{{OutboxName}}BatchComplete.g.sql', directory: '{{Root.DatabaseDirectory.FullName}}/Schema/Stored Procedures', text: Outbox batch-complete stored-procedure generation } +- { type: 'DbEx.CodeGen.Generators.OutboxGenerator, DbEx', template: 'SpOutboxBatchCancel_sql', file: 'sp{{OutboxName}}BatchCancel.g.sql', directory: '{{Root.DatabaseDirectory.FullName}}/Schema/Stored Procedures', text: Outbox batch-cancel stored-procedure generation } +- { type: 'DbEx.CodeGen.Generators.OutboxGenerator, DbEx', template: 'SpOutboxLeaseAcquire_sql', file: 'sp{{OutboxName}}LeaseAcquire.g.sql', directory: '{{Root.DatabaseDirectory.FullName}}/Schema/Stored Procedures', text: Outbox lease-acquire stored-procedure generation } +- { type: 'DbEx.CodeGen.Generators.OutboxGenerator, DbEx', template: 'SpOutboxLeaseRelease_sql', file: 'sp{{OutboxName}}LeaseRelease.g.sql', directory: '{{Root.DatabaseDirectory.FullName}}/Schema/Stored Procedures', text: Outbox lease-release stored-procedure generation } \ No newline at end of file diff --git a/src/DbEx.SqlServer/Templates/SpOutboxBatchClaim_sql.hbs b/src/DbEx.SqlServer/Templates/SpOutboxBatchClaim_sql.hbs new file mode 100644 index 0000000..a194457 --- /dev/null +++ b/src/DbEx.SqlServer/Templates/SpOutboxBatchClaim_sql.hbs @@ -0,0 +1,117 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +CREATE OR ALTER PROCEDURE [{{OutboxSchema}}].[sp{{OutboxName}}BatchClaim] + @TenantId NVARCHAR(255) = NULL, + @PartitionId INT, + @BatchSize INT, + @LeaseId UNIQUEIDENTIFIER, + @LeaseSeconds INT +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Claims the next batch of pending/processing messages for a tenant/partition, marking them as processing with a lease. + * > Returns: + * 0 = Success; batch returned in result set. + * -1 = No rows updated (e.g. already claimed by another or transient error). + * -2 = No batch to claim (e.g. all completed). + * -3 = Unable to acquire lease (e.g. another active batch or transient error). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @LeaseUntilUtc DATETIME2; + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + SET LOCK_TIMEOUT 5000; -- Milliseconds. + + -- 1) Acquire a partition lease; exit where unsuccessful. + DECLARE @RC INT; + EXEC @RC = [{{OutboxSchema}}].[sp{{OutboxName}}LeaseAcquire] @EffectiveTenantId, @PartitionId, @LeaseId, @LeaseSeconds, @LeaseUntilUtc OUTPUT; + IF (@RC < 0) RETURN -3; + + -- 2) Claim the next batch (contiguous by {{OutboxName}}Id) for the tenant/partition. + BEGIN TRY + BEGIN TRAN; + + DECLARE @HeadId BIGINT; + DECLARE @BlockerId BIGINT; + + -- Determine head (first pending/processing) for strict contiguity. + SELECT @HeadId = MIN(o.{{OutboxName}}Id) + FROM [{{OutboxSchema}}].[{{OutboxName}}] o WITH (UPDLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[Status] IN (0, 1) + OPTION (RECOMPILE); + + IF @HeadId IS NULL + BEGIN + COMMIT; + + -- Release the lease outside transaction. + EXEC [{{OutboxSchema}}].[sp{{OutboxName}}LeaseRelease] @LeaseId; + RETURN -2; -- Nothing available. + END + + -- Find first blocker at/after head: actively leased or not yet available. + SELECT @BlockerId = MIN(o.{{OutboxName}}Id) + FROM [{{OutboxSchema}}].[{{OutboxName}}] o WITH (READPAST, UPDLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[{{OutboxName}}Id] >= @HeadId + AND ((o.Status = 1 AND o.[LeaseUntilUtc] IS NOT NULL AND o.[LeaseUntilUtc] > @Now) + OR (o.Status = 0 AND o.[AvailableUtc] > @Now)) + OPTION (RECOMPILE); + + -- Claim contiguous run from head to before blocker. + ;WITH claim AS + ( + SELECT TOP (@BatchSize) + o.[{{OutboxName}}Id], o.[TenantId], o.[Status], o.[PartitionId], o.[Destination], o.[Event], + o.[Attempts], o.[EnqueuedUtc], o.[AvailableUtc], o.[LeaseId], o.[LeaseUntilUtc] + FROM [{{OutboxSchema}}].[{{OutboxName}}] o WITH (READPAST, UPDLOCK, ROWLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[{{OutboxName}}Id] >= @HeadId + AND (@BlockerId IS NULL OR o.[{{OutboxName}}Id] < @BlockerId) + AND ((o.[Status] = 0 AND o.[AvailableUtc] <= @Now) + OR (o.[Status] = 1 AND (o.[LeaseUntilUtc] IS NULL OR o.[LeaseUntilUtc] <= @Now))) + ORDER BY o.{{OutboxName}}Id + ) + UPDATE claim + SET [Status] = 1, + [LeaseId] = @LeaseId, + [LeaseUntilUtc] = @LeaseUntilUtc + OUTPUT + inserted.[{{OutboxName}}Id], + inserted.[TenantId], + inserted.[Status], + inserted.[PartitionId], + inserted.[Destination], + inserted.[Event], + inserted.[Attempts], + inserted.[EnqueuedUtc], + inserted.[AvailableUtc], + inserted.[LeaseUntilUtc]; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + + -- Release the lease outside transaction. + EXEC [{{OutboxSchema}}].[sp{{OutboxName}}LeaseRelease] @LeaseId; + RETURN -1; -- No rows updated. + END + + COMMIT; + RETURN 0; + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END \ No newline at end of file diff --git a/src/DbEx.SqlServer/Templates/SpOutboxBatchComplete_sql.hbs b/src/DbEx.SqlServer/Templates/SpOutboxBatchComplete_sql.hbs new file mode 100644 index 0000000..b9ec120 --- /dev/null +++ b/src/DbEx.SqlServer/Templates/SpOutboxBatchComplete_sql.hbs @@ -0,0 +1,74 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +CREATE OR ALTER PROCEDURE [{{OutboxSchema}}].[sp{{OutboxName}}BatchComplete] + @LeaseId UNIQUEIDENTIFIER, + @DequeuedUtc DATETIME2 NULL +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Marks a batch as completed by LeaseId, releasing the lease and making way for the next batch. + * > Returns: + * 0 = Success. + * -1 = No rows updated (e.g. already completed or invalid LeaseId). + * -2 = No batch to claim (e.g. all completed since claim). + * -3 = Unable to acquire lease (e.g. another active batch or transient error). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @TenantId NVARCHAR(255); + DECLARE @PartitionId INT; + DECLARE @Completed TABLE (TenantId NVARCHAR(255), PartitionId INT); + + BEGIN TRY + BEGIN TRAN; + + -- 1) Complete the batch and capture tenant/partition atomically. + UPDATE o + SET o.[Status] = 2, + o.[LeaseId] = NULL, + o.[LeaseUntilUtc] = NULL, + o.[DequeuedUtc] = COALESCE(@DequeuedUtc, @Now) + OUTPUT + deleted.[TenantId], + deleted.[PartitionId] + INTO @Completed + FROM [{{OutboxSchema}}].[{{OutboxName}}] AS o WITH (UPDLOCK, ROWLOCK) + WHERE o.[LeaseId] = @LeaseId + AND o.[Status] = 1; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + RETURN -1; -- No rows updated. + END + + -- 2) Capture tenant/partition from first completed row. + SELECT TOP 1 + @TenantId = TenantId, + @PartitionId = PartitionId + FROM @Completed; + + COMMIT; + + -- 3) Release the partition lease where identified. + BEGIN TRY + EXEC [{{OutboxSchema}}].[sp{{OutboxName}}LeaseRelease] @LeaseId; + END TRY + BEGIN CATCH + -- Ignore: lease will expire. Don't fail completion. + END CATCH + + RETURN 0 + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH + +END \ No newline at end of file diff --git a/src/DbEx.SqlServer/Templates/SpOutboxEnqueue_sql.hbs b/src/DbEx.SqlServer/Templates/SpOutboxEnqueue_sql.hbs new file mode 100644 index 0000000..8c097bf --- /dev/null +++ b/src/DbEx.SqlServer/Templates/SpOutboxEnqueue_sql.hbs @@ -0,0 +1,37 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +CREATE OR ALTER PROCEDURE [{{OutboxSchema}}].[sp{{OutboxName}}Enqueue] + @TenantId AS NVARCHAR(255) = NULL, + @PartitionId AS INT, + @Destination AS NVARCHAR(255), + @Event AS NVARCHAR(MAX), + @EnqueuedUtc AS DATETIME2 = NULL, + @AvailableUtc AS DATETIME2 = NULL +AS +BEGIN + /* + * This file is automatically generated; any changes will be lost. + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + INSERT INTO [{{OutboxSchema}}].[{{OutboxName}}] ( + [TenantId], + [PartitionId], + [Destination], + [Event], + [EnqueuedUtc], + [AvailableUtc] + ) + VALUES ( + @EffectiveTenantId, + @PartitionId, + @Destination, + @Event, + COALESCE(@EnqueuedUtc, @Now), + COALESCE(@AvailableUtc, COALESCE(@EnqueuedUtc, @Now)) + ) +END \ No newline at end of file diff --git a/src/DbEx.SqlServer/Templates/SpOutboxLeaseAcquire_sql.hbs b/src/DbEx.SqlServer/Templates/SpOutboxLeaseAcquire_sql.hbs new file mode 100644 index 0000000..47feeb1 --- /dev/null +++ b/src/DbEx.SqlServer/Templates/SpOutboxLeaseAcquire_sql.hbs @@ -0,0 +1,74 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +CREATE OR ALTER PROCEDURE [{{OutboxSchema}}].[sp{{OutboxName}}LeaseAcquire] + @TenantId NVARCHAR(255) = NULL, + @PartitionId INT, + @LeaseId UNIQUEIDENTIFIER, + @LeaseSeconds INT, + @LeaseUntilUtc DATETIME2 OUTPUT +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Attempts to acquire a lease for a tenant/partition, returning success status and lease until timestamp. + * > Returns: + * 0 = Lease acquired; caller may proceed with batch claim. + * -1 = Lease not acquired; caller should backoff and retry. + * + * Notes: + * - The procedure is resilient to transient errors and will return -1 where lease acquisition is unsuccessful, including where another active lease exists or where a transient error occurs (e.g. lock timeout). + * - The caller should implement an appropriate retry/backoff strategy where -1 is returned, including randomization to avoid thundering herd issues. + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @Until DATETIME2 = DATEADD(SECOND, @LeaseSeconds, @Now) + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + BEGIN TRY + BEGIN TRAN; + + -- 1) Ensure the row exists (self-seeding); lock the key-range for this PartitionId to avoid insert races. + IF NOT EXISTS ( + SELECT 1 + FROM [{{OutboxSchema}}].[{{OutboxName}}Lease] WITH (UPDLOCK, HOLDLOCK) + WHERE [TenantId] = @EffectiveTenantId AND [PartitionId] = @PartitionId + ) + BEGIN + INSERT INTO [{{OutboxSchema}}].[{{OutboxName}}Lease] ([TenantId], [PartitionId]) + VALUES (@EffectiveTenantId, @PartitionId); + END + + -- 2) Attempt to acquire lease where expired/empty. + UPDATE ol + SET ol.[LeaseId] = @LeaseId, + ol.[LeaseUntilUtc] = @Until + FROM [{{OutboxSchema}}].[{{OutboxName}}Lease] AS ol WITH (UPDLOCK, ROWLOCK) + WHERE ol.[PartitionId] = @PartitionId + AND ol.[TenantId] = @EffectiveTenantId + AND (ol.[LeaseUntilUtc] IS NULL OR ol.[LeaseUntilUtc] <= @Now) + OPTION (RECOMPILE); + + -- 3) Commit and return lease success status. + DECLARE @Rows INT = @@ROWCOUNT; + + COMMIT; + + IF @Rows = 1 + BEGIN + SET @LeaseUntilUtc = @Until; + RETURN 0; -- Lease successful. + END + + SET @LeaseUntilUtc = NULL; + RETURN -1; -- Lease unsuccessful. + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END \ No newline at end of file diff --git a/src/DbEx.SqlServer/Templates/SpOutboxLeaseRelease_sql.hbs b/src/DbEx.SqlServer/Templates/SpOutboxLeaseRelease_sql.hbs new file mode 100644 index 0000000..3a8e1f8 --- /dev/null +++ b/src/DbEx.SqlServer/Templates/SpOutboxLeaseRelease_sql.hbs @@ -0,0 +1,45 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +CREATE OR ALTER PROCEDURE [{{OutboxSchema}}].[sp{{OutboxName}}LeaseRelease] + @LeaseId UNIQUEIDENTIFIER +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Releases a lease by LeaseId, making way for the next batch. + * > Returns: + * 0 = Success; lease released and available for next claim. + * -1 = No rows updated (e.g. already released or invalid LeaseId). + * + * Notes: + * - The procedure is resilient to transient errors and will return -1 where release is unsuccessful, including where the lease is already released or where a transient error occurs (e.g. lock timeout). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + BEGIN TRY + BEGIN TRAN; + + -- 1) Release lease where leasee. + UPDATE ol + SET ol.[LeaseId] = NULL, + ol.[LeaseUntilUtc] = NULL + FROM [{{OutboxSchema}}].[{{OutboxName}}Lease] AS ol WITH (UPDLOCK, ROWLOCK) + WHERE ol.[LeaseId] = @LeaseId; + + -- 2) Commit and return release success status. + DECLARE @Rows INT = @@ROWCOUNT; + + COMMIT; + + IF @Rows = 1 RETURN 0; -- Release successful. + RETURN -1; -- Release unsuccessful. + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END \ No newline at end of file diff --git a/src/DbEx.SqlServer/Templates/spOutboxBatchCancel_sql.hbs b/src/DbEx.SqlServer/Templates/spOutboxBatchCancel_sql.hbs new file mode 100644 index 0000000..6941db4 --- /dev/null +++ b/src/DbEx.SqlServer/Templates/spOutboxBatchCancel_sql.hbs @@ -0,0 +1,72 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +CREATE OR ALTER PROCEDURE [{{OutboxSchema}}].[sp{{OutboxName}}BatchCancel] + @LeaseId UNIQUEIDENTIFIER, + @BackoffSeconds INT +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Cancels a batch by LeaseId, marking messages as pending with backoff and releasing the lease. + * > Returns: + * 0 = Success. + * -1 = No rows updated (e.g. already completed or invalid LeaseId). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @TenantId NVARCHAR(255); + DECLARE @PartitionId INT; + DECLARE @Cancelled TABLE (TenantId NVARCHAR(255), PartitionId INT); + + BEGIN TRY + BEGIN TRAN; + + -- 1) Cancel the batch and capture tenant/partition atomically. + UPDATE o + SET o.[Status] = 0, + o.[Attempts] = o.[Attempts] + 1, + o.[AvailableUtc] = DATEADD(SECOND, @BackoffSeconds, @Now), + o.[LeaseId] = NULL, + o.[LeaseUntilUtc] = NULL + OUTPUT + deleted.[TenantId], + deleted.[PartitionId] + INTO @Cancelled + FROM [{{OutboxSchema}}].[{{OutboxName}}] AS o WITH (UPDLOCK, ROWLOCK) + WHERE o.[LeaseId] = @LeaseId + AND o.[Status] = 1; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + RETURN -1; -- No rows updated. + END + + -- 2) Capture tenant/partition from first cancelled row. + SELECT TOP 1 + @TenantId = TenantId, + @PartitionId = PartitionId + FROM @Cancelled; + + COMMIT; + + -- 3) Release the partition lease. + BEGIN TRY + EXEC [{{OutboxSchema}}].[sp{{OutboxName}}LeaseRelease] @LeaseId; + END TRY + BEGIN CATCH + -- Ignore: lease will expire. Don't fail cancel. + END CATCH + + RETURN 0; + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END diff --git a/src/DbEx/CodeGen/Config/CodeGenConfig.cs b/src/DbEx/CodeGen/Config/CodeGenConfig.cs new file mode 100644 index 0000000..bf1a704 --- /dev/null +++ b/src/DbEx/CodeGen/Config/CodeGenConfig.cs @@ -0,0 +1,239 @@ +namespace DbEx.CodeGen.Config; + +/// +/// Provides the root code-generation configuration. +/// +[CodeGenClass("CodeGeneration", Title = "Database-driven by-convention code-generation.")] +[CodeGenCategory("Primary", Title = "Provides the _primary_ configuration.")] +[CodeGenCategory("Entity Framework", Title = "Provides the configuration for the Entity Framework (EF) capabilities.")] +[CodeGenCategory("Outbox", Title = "Provides the configuration for the transactional-outbox capabilities.")] +[CodeGenCategory("Paths", Title = "Provides the configuration for the paths used in code generation.")] +[CodeGenCategory("By-Convention", Title = "Provides the by-convention column-naming configuration.")] +[CodeGenCategory("Collections", Title = "Provides the collections configuration.")] +public class CodeGenConfig : ConfigRootBase, IByConventionColumnNames +{ + private DatabaseMigrationBase? _migrator; + private List? _dbTables; + + /// + /// Gets the owning . + /// + public DatabaseMigrationBase Migrator { get => _migrator ?? throw new CodeGenException("The 'Migrator' has not been set."); private set => _migrator = value; } + + /// + /// Gets or sets the default database schema name. + /// + [JsonPropertyName("schema")] + [CodeGenProperty("Primary", Title = "The default database schema name.", IsImportant = true)] + public string? Schema { get; set; } + + /// + /// Gets or sets the .NET domain name. + /// + [JsonPropertyName("domain")] + [CodeGenProperty("Primary", Title = "The domain name.", IsImportant = true, Description = "This is the .NET domain name. Attempts to default from the underlying data project file path; uses the second to last segment of the child-most sub-directory by convention. For example, '/xxx/yyy/My.App.Sales.Database', the domain would be 'Sales'.")] + public string? Domain { get; set; } + + /// + /// Indicates the default entity-framework code-generation choice. + /// + [JsonPropertyName("efModel")] + [CodeGenProperty("Entity Framework", Title = "The default entity-framework code-generation choice.", Description = "Defaults to 'Yes' (indicates combination of 'ModelOnly' and 'ModelBuilderOnly').", Options = ["Yes", "No", "ModelOnly", "ModelBuilderOnly"])] + public string? EfModel { get; set; } + + /// + /// Gets or sets the relative path to the data-related .NET project. + /// + [JsonPropertyName("dotNetDataProjectPath")] + [CodeGenProperty("Paths", Title = "The relative path for the .NET data-related project.", Description = "Defaults to automatic inference using expected name of 'Infrastructure'.")] + public string? DotNetDataProjectPath { get; set; } + + /// + /// Gets or sets the path to append to the for the .NET generated entity-framework repository code. + /// + [JsonPropertyName("dotNetDataEfRepositoriesPath")] + [CodeGenProperty("Paths", Title = "The path to append to the '{DotNetDataProjectPath}' for the .NET generated entity-framework repository code.", Description = "Defaults to 'Repositories'.")] + public string? DotNetDataEfRepositoriesPath { get; set; } + + /// + /// Gets or sets the path to append to the for the .NET generated entity-framework models code. + /// + [JsonPropertyName("dotNetDataEfModelsPath")] + [CodeGenProperty("Paths", Title = "The path to append to the '{DotNetDataProjectPath}' for the .NET generated entity-framework models code.", Description = "Defaults to 'Persistence'.")] + public string? DotNetDataEfModelsPath { get; set; } + + /// + /// Gets or sets the .NET namespace for the entity-framework generated repository code. + /// + public string? DotNetDataEfRepositoriesNamespace { get; set; } + + /// + /// Gets or sets the .NET namespace for the entity-framework generated persistence models code. + /// + public string? DotNetDataEfModelsNamespace { get; set; } + + #region By-Convention + + /// + [JsonPropertyName("columnNameIsDeleted")] + [CodeGenProperty("By-Convention", Title = "The default 'IsDeleted' column name.", Description = "Defaults to 'IsDeleted'.")] + public string? ColumnNameIsDeleted { get; set; } + + /// + [JsonPropertyName("columnNameTenantId")] + [CodeGenProperty("By-Convention", Title = "The default 'TenantId' column name.", Description = "Defaults to 'TenantId'.")] + public string? ColumnNameTenantId { get; set; } + + /// + [JsonPropertyName("columnNameRowVersion")] + [CodeGenProperty("By-Convention", Title = "The default 'RowVersion' column name.", Description = "Defaults to 'RowVersion'.")] + public string? ColumnNameRowVersion { get; set; } + + /// + [JsonPropertyName("columnNameCreatedBy")] + [CodeGenProperty("By-Convention", Title = "The default 'CreatedBy' column name.", Description = "Defaults to 'CreatedBy'.")] + public string? ColumnNameCreatedBy { get; set; } + + /// + [JsonPropertyName("columnNameCreatedOn")] + [CodeGenProperty("By-Convention", Title = "The default 'CreatedOn' column name.", Description = "Defaults to 'CreatedOn'.")] + public string? ColumnNameCreatedOn { get; set; } + + /// + [JsonPropertyName("columnNameUpdatedBy")] + [CodeGenProperty("By-Convention", Title = "The default 'UpdatedBy' column name.", Description = "Defaults to 'UpdatedBy'.")] + public string? ColumnNameUpdatedBy { get; set; } + + /// + [JsonPropertyName("columnNameUpdatedOn")] + [CodeGenProperty("By-Convention", Title = "The default 'UpdatedOn' column name.", Description = "Defaults to 'UpdatedOn'.")] + public string? ColumnNameUpdatedOn { get; set; } + + #endregion + + #region Outbox + + /// + /// Indicates whether to generate the transactional-outbox database capabilities. + /// + [JsonPropertyName("outbox")] + [CodeGenProperty("Outbox", Title = "Indicates whether to generate the transactional-outbox database capabilities.", Description = "Defaults to 'false'.")] + public bool? Outbox { get; set; } + + /// + /// Gets or sets the database schema name used for outbox tables and stored procedures. + /// + [JsonPropertyName("outboxSchema")] + [CodeGenProperty("Outbox", Title = "The database schema name for the outbox tables and stored procedures.", Description = "Defaults to '{schema}'.")] + public string? OutboxSchema { get; set; } + + /// + /// Gets or sets the name of the outbox table used for storing outgoing messages. + /// + [JsonPropertyName("outboxName")] + [CodeGenProperty("Outbox", Title = "The name of the outbox table.", Description = "Defaults to 'Outbox'.")] + public string? OutboxName { get; set; } + + #endregion + + /// + /// Gets the database table collection configuration. + /// + [JsonPropertyName("tables")] + [CodeGenPropertyCollection("Collections", Title = "The database table collection configuration.", IsImportant = true)] + public List? Tables { get; set; } + + /// + /// Gets the database tables collection where the entity-framework model code-generation indicator is 'Yes' or 'ModelOnly'. + /// + public List EfModels => Tables?.Where(x => x.EfModel == "Yes" || x.EfModel == "ModelOnly").ToList() ?? []; + + /// + /// Gets the database tables collection where the entity-framework model code-generation indicator is 'Yes' or 'ModelBuilderOnly'. + /// + public List EfModelBuilders => Tables?.Where(x => x.EfModel == "Yes" || x.EfModel == "ModelBuilderOnly").ToList() ?? []; + + /// + /// Gets the for the initiating database project itself. + /// + public DirectoryInfo? DatabaseDirectory { get; private set; } + + /// + /// Gets the for the . + /// + public DirectoryInfo? DotNetDataProjectDirectory { get; private set; } + + /// + /// Gets or sets the list of tables that exist within the database. + /// + public List DbTables => _dbTables!; + + /// + protected async override Task PrepareAsync() + { + // Get the migrator from the runtime parameters. + _migrator = (RuntimeParameters.TryGetValue("__migrator", out var migrator) && migrator is DatabaseMigrationBase m ? m : null) ?? throw new CodeGenException("The 'Migrator' runtime parameter is not set."); + + // Get the base paths, etc. + DatabaseDirectory = CodeGenArgs?.OutputDirectory ?? throw new InvalidOperationException("The 'OutputDirectory' property of the 'CodeGenArgs' is not set."); + var parts = DatabaseDirectory.Name.Split('.'); + + // Using the DotNet data project relative path, get the absolute path and ensure it exists. + if (DotNetDataProjectPath is not null) + { + if (!DotNetDataProjectPath.StartsWith('.')) + throw new CodeGenException(this, nameof(DotNetDataProjectPath), $"'{DotNetDataProjectPath}' should be a relative path to '{CodeGenArgs.OutputDirectory.FullName}'."); + } + else + DotNetDataProjectPath = Path.Combine("..", string.Join('.', parts.Take(parts.Length - 1).Append("Infrastructure"))); + + DotNetDataProjectDirectory = new DirectoryInfo(Path.Combine(CodeGenArgs.OutputDirectory.FullName, DotNetDataProjectPath)); + if (!DotNetDataProjectDirectory.Exists) + throw new CodeGenException(this, nameof(DotNetDataProjectPath), $"'{DotNetDataProjectPath}' does not exist relative to '{CodeGenArgs.OutputDirectory.FullName}'."); + + // Handle the EF paths and namespaces. + DotNetDataEfRepositoriesPath = DefaultWhereNull(DotNetDataEfRepositoriesPath, () => "Repositories"); + DotNetDataEfModelsPath = DefaultWhereNull(DotNetDataEfModelsPath, () => "Persistence"); + + DotNetDataEfRepositoriesNamespace = $"{DotNetDataProjectDirectory.Name}.{DotNetDataEfRepositoriesPath}"; + DotNetDataEfModelsNamespace = $"{DotNetDataProjectDirectory.Name}.{DotNetDataEfModelsPath}"; + + // Default the domain name from the file path (2nd to last part) if not explicitly set. + Domain = DefaultWhereNull(Domain, () => parts.Length >= 2 ? parts[^2] : null) ?? throw new CodeGenException(this, nameof(Domain), $"Could not be defaulted from the file path; please explicitly set the property in the configuration."); + Schema = DefaultWhereNull(Schema, () => Migrator.SchemaConfig.SupportsSchema ? Domain : null); + EfModel = DefaultWhereNull(EfModel, () => "Yes"); + + // Default the by-convention properties. + ColumnNameIsDeleted = DefaultWhereNull(ColumnNameIsDeleted, () => Migrator.SchemaConfig.IsDeletedColumnName ?? "IsDeleted"); + ColumnNameTenantId = DefaultWhereNull(ColumnNameTenantId, () => Migrator.SchemaConfig.TenantIdColumnName ?? "TenantId"); + ColumnNameRowVersion = DefaultWhereNull(ColumnNameRowVersion, () => Migrator.SchemaConfig.RowVersionColumnName ?? "RowVersion"); + ColumnNameCreatedBy = DefaultWhereNull(ColumnNameCreatedBy, () => Migrator.SchemaConfig.CreatedByColumnName ?? "CreatedBy"); + ColumnNameCreatedOn = DefaultWhereNull(ColumnNameCreatedOn, () => Migrator.SchemaConfig.CreatedOnColumnName ?? "CreatedOn"); + ColumnNameUpdatedBy = DefaultWhereNull(ColumnNameUpdatedBy, () => Migrator.SchemaConfig.UpdatedByColumnName ?? "UpdatedBy"); + ColumnNameUpdatedOn = DefaultWhereNull(ColumnNameUpdatedOn, () => Migrator.SchemaConfig.UpdatedOnColumnName ?? "UpdatedOn"); + + // Default the outbox properties. + OutboxSchema = DefaultWhereNull(OutboxSchema, () => Schema); + OutboxName = DefaultWhereNull(OutboxName, () => "Outbox"); + + // Load the database tables and columns configuration. + await LoadDbTablesConfigAsync().ConfigureAwait(false); + + Tables = await PrepareCollectionAsync(Tables).ConfigureAwait(false); + } + + /// + /// Load the database table and columns configuration. + /// + private async Task LoadDbTablesConfigAsync() + { + CodeGenArgs!.Logger?.Log(LogLevel.Information, "{Content}", string.Empty); + CodeGenArgs.Logger?.Log(LogLevel.Information, "{Content}", $"Querying database to infer table(s)/column(s) schema configuration..."); + + var sw = Stopwatch.StartNew(); + _dbTables = await Migrator.Database.SelectSchemaAsync(Migrator).ConfigureAwait(false); + + sw.Stop(); + CodeGenArgs.Logger?.Log(LogLevel.Information, "{Content}", $" Database schema query complete [{sw.ElapsedMilliseconds}ms]"); + } +} \ No newline at end of file diff --git a/src/DbEx/CodeGen/Config/ColumnConfig.cs b/src/DbEx/CodeGen/Config/ColumnConfig.cs new file mode 100644 index 0000000..0fb3d3f --- /dev/null +++ b/src/DbEx/CodeGen/Config/ColumnConfig.cs @@ -0,0 +1,53 @@ +namespace DbEx.CodeGen.Config; + +/// +/// Provides the database column code-generation configuration. +/// +[CodeGenClass("Column", Title = "Database column configuration.")] +[CodeGenCategory("Primary", Title = "Provides the _primary_ configuration.")] +[CodeGenCategory("Entity Framework", Title = "Provides the configuration for the Entity Framework (EF) capabilities.")] +public class ColumnConfig : ConfigBase +{ + /// + /// Gets or sets the database column name. + /// + [JsonPropertyName("name")] + [CodeGenProperty("Primary", Title = "The database column name.", IsMandatory = true)] + public string? Name { get; set; } + + /// + /// Gets or sets the .NET equivalent name for the column. + /// + [JsonPropertyName("property")] + [CodeGenProperty("Entity Framework", Title = "The .NET property name equivalent for the column.", IsImportant = true, Description = "Defaults to the database column's .NET formatted name.")] + public string? Property { get; set; } + + /// + /// Gets or sets the corresponding .NET type for the column. + /// + [JsonPropertyName("type")] + [CodeGenProperty("Entity Framework", Title = "The corresponding .NET type equivalent for the column (including nullability).", IsImportant = true, Description = "Defaults to the database column's .NET type.")] + public string? Type { get; set; } + + /// + /// Gets or sets the .NET value converter source code for the column (where applicable). + /// + [JsonPropertyName("valueConverter")] + [CodeGenProperty("Entity Framework", Title = "The .NET value converter source code for the column.", Description = "Defaults to null. This must be valid C# source code as it is applied as-is.")] + public string? ValueConverter { get; set; } + + /// + /// Gets or sets the actual . + /// + public DbColumnSchema? DbColumn { get; set; } + + /// + protected override Task PrepareAsync() + { + DbColumn ??= Parent?.DbTable?.Columns.SingleOrDefault(x => x.Name == Name) ?? throw new CodeGenException(this, nameof(Name), $"Column '{Name}' for table '{Root!.Migrator.SchemaConfig.ToFullyQualifiedTableName(Parent!.Schema, Parent.Name!)}' not found in database."); + Property = DefaultWhereNull(Property, () => DbColumn.DotNetName); + Type = DefaultWhereNull(Type, () => DbColumn.DotNetTypeWithNullability); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/DbEx/CodeGen/Config/IByConventionColumnNames.cs b/src/DbEx/CodeGen/Config/IByConventionColumnNames.cs new file mode 100644 index 0000000..32abc3e --- /dev/null +++ b/src/DbEx/CodeGen/Config/IByConventionColumnNames.cs @@ -0,0 +1,42 @@ +namespace DbEx.CodeGen.Config; + +/// +/// Provides the standardized set of special column names. +/// +public interface IByConventionColumnNames +{ + /// + /// Gets or sets the column name for the 'IsDeleted' capability. + /// + string? ColumnNameIsDeleted { get; set; } + + /// + /// Gets or sets the column name for the 'TenantId' capability. + /// + string? ColumnNameTenantId { get; set; } + + /// + /// Gets or sets the column name for the 'RowVersion' capability. + /// + string? ColumnNameRowVersion { get; set; } + + /// + /// Gets or sets the column name for the 'CreatedBy' capability. + /// + string? ColumnNameCreatedBy { get; set; } + + /// + /// Gets or sets the column name for the 'CreatedOn' capability. + /// + string? ColumnNameCreatedOn { get; set; } + + /// + /// Gets or sets the column name for the 'UpdatedBy' capability. + /// + string? ColumnNameUpdatedBy { get; set; } + + /// + /// Gets or sets the column name for the 'UpdatedOn' capability. + /// + string? ColumnNameUpdatedOn { get; set; } +} \ No newline at end of file diff --git a/src/DbEx/CodeGen/Config/IByConventionColumns.cs b/src/DbEx/CodeGen/Config/IByConventionColumns.cs new file mode 100644 index 0000000..a334c9c --- /dev/null +++ b/src/DbEx/CodeGen/Config/IByConventionColumns.cs @@ -0,0 +1,47 @@ +namespace DbEx.CodeGen.Config; + +/// +/// Enables the by-convention columns configurations. +/// +public interface IByConventionColumns +{ + /// + /// Gets or sets the column for the 'IsDeleted' capability. + /// + ColumnConfig? ColumnIsDeleted { get; } + + /// + /// Gets or sets the column for the 'TenantId' capability. + /// + ColumnConfig? ColumnTenantId { get; } + + /// + /// Gets or sets the column for the 'RowVersion' capability. + /// + ColumnConfig? ColumnRowVersion { get; } + + /// + /// Gets or sets the column for the 'CreatedBy' capability. + /// + ColumnConfig? ColumnCreatedBy { get; } + + /// + /// Gets or sets the column for the 'CreatedOn' capability. + /// + ColumnConfig? ColumnCreatedOn { get; } + + /// + /// Gets or sets the column for the 'UpdatedBy' capability. + /// + ColumnConfig? ColumnUpdatedBy { get; } + + /// + /// Gets or sets the column for the 'UpdatedOn' capability. + /// + ColumnConfig? ColumnUpdatedOn { get; } + + /// + /// Indicates whether at least one of the audit columns (CreatedBy, CreatedOn, UpdatedBy, UpdatedOn) is present. + /// + bool HasAtLeastOneAuditColumn { get; } +} \ No newline at end of file diff --git a/src/DbEx/CodeGen/Config/RefDataConfig.cs b/src/DbEx/CodeGen/Config/RefDataConfig.cs new file mode 100644 index 0000000..e862e45 --- /dev/null +++ b/src/DbEx/CodeGen/Config/RefDataConfig.cs @@ -0,0 +1,55 @@ +namespace DbEx.CodeGen.Config; + +/// +/// Provides the reference-data configuration as as extension of the . +/// +public class RefDataConfig() : ConfigBase +{ + /// + /// Gets the column configuration for the property named "Code", if it exists. + /// + public ColumnConfig? CodeProperty => Parent!.Columns!.SingleOrDefault(c => c.Property == "Code"); + + /// + /// Gets the column configuration for the property named "Text", if it exists. + /// + public ColumnConfig? TextProperty => Parent!.Columns!.SingleOrDefault(c => c.Property == "Text"); + + /// + /// Gets the column configuration for the property named "Description", if it exists. + /// + public ColumnConfig? DescriptionProperty => Parent!.Columns!.SingleOrDefault(c => c.Property == "Description"); + + /// + /// Gets the column configuration for the property named "SortOrder", if it exists. + /// + public ColumnConfig? SortOrderProperty => Parent!.Columns!.SingleOrDefault(c => c.Property == "SortOrder"); + + /// + /// Gets the column configuration for the property named "IsActive", if it exists. + /// + public ColumnConfig? IsActiveProperty => Parent!.Columns!.SingleOrDefault(c => c.Property == "IsActive"); + + /// + /// Gets the column configuration for the property named "StartsOn", if it exists. + /// + public ColumnConfig? StartsOnProperty => Parent!.Columns!.SingleOrDefault(c => c.Property == "StartsOn"); + + /// + /// Gets the column configuration for the property named "EndsOn", if it exists. + /// + public ColumnConfig? EndsOnProperty => Parent!.Columns!.SingleOrDefault(c => c.Property == "EndsOn"); + + /// + /// Gets the collection of reserved column configurations that are part of the standard set. + /// + public List ReservedProperties => [.. Parent!.Columns!.Where(c => c.Property == "Code" || c.Property == "Text" || c.Property == "Description" || c.Property == "SortOrder" || c.Property == "IsActive" || c.Property == "StartsOn" || c.Property == "EndsOn")]; + + /// + /// Gets the collection of additional column configurations that are not part of the standard set of properties. + /// + public List AdditionalProperties => [.. Parent!.StandardColumns!.Where(c => c.Property != "Code" && c.Property != "Text" && c.Property != "Description" && c.Property != "SortOrder" && c.Property != "IsActive" && c.Property != "StartsOn" && c.Property != "EndsOn")]; + + /// + protected override Task PrepareAsync() => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/DbEx/CodeGen/Config/TableConfig.cs b/src/DbEx/CodeGen/Config/TableConfig.cs new file mode 100644 index 0000000..f271dd2 --- /dev/null +++ b/src/DbEx/CodeGen/Config/TableConfig.cs @@ -0,0 +1,242 @@ +namespace DbEx.CodeGen.Config; + +/// +/// Provides the database table code-generation configuration. +/// +[CodeGenClass("Table", Title = "Database table configuration.")] +[CodeGenCategory("Primary", Title = "Provides the _primary_ configuration.")] +[CodeGenCategory("Columns", Title = "Provides the configuration for the database columns.")] +[CodeGenCategory("Entity Framework", Title = "Provides the configuration for the Entity Framework (EF) capabilities.")] +[CodeGenCategory("By-Convention", Title = "Provides the by-convention column-naming configuration.")] +[CodeGenCategory("Collections", Title = "Provides the collections configuration.")] +public class TableConfig : ConfigBase, IByConventionColumnNames, IByConventionColumns +{ + /// + /// Gets or sets the database table name. + /// + [JsonPropertyName("name")] + [CodeGenProperty("Primary", Title = "The database table name.", IsMandatory = true)] + public string? Name { get; set; } + + /// + /// Gets or sets the database schema name (where applicable). + /// + [JsonPropertyName("schema")] + [CodeGenProperty("Primary", Title = "The database schema name (where applicable).", Description = "Defaults to the root '{Schema}' configuration.")] + public string? Schema { get; set; } + + /// + /// Gets or sets the list of database columns to include explicitly. + /// + [JsonPropertyName("includeColumns")] + [CodeGenProperty("Columns", Title = "The list of database columns to include specifically.", Description = "All columns are included by default; this provides a means to simply select those for inclusion.")] + public List? IncludeColumns { get; set; } + + /// + /// Gets or sets the list of database column names to exclude explicitly. + /// + [JsonPropertyName("excludeColumns")] + [CodeGenProperty("Columns", Title = "The list of database columns to exclude specifically.", Description = "All columns are included by default; this provides a means to simply select those for exclusion. A single item of '*' indicates all columns are to be excluded.")] + public List? ExcludeColumns { get; set; } + + /// + /// Indicates the default entity-framework code-generation choice. + /// + [JsonPropertyName("efModel")] + [CodeGenProperty("Entity Framework", Title = "The entity-framework code-generation choice.", Description = "Defaults to parent '{EfModel}'. A 'Yes' indicates combination of 'ModelOnly' and 'ModelBuilderOnly'.", Options = ["Yes", "No", "ModelOnly", "ModelBuilderOnly"])] + public string? EfModel { get; set; } + + /// + /// Gets or sets the name of the entity-framework model associated with this instance. + /// + [JsonPropertyName("efModelName")] + [CodeGenProperty("Entity Framework", Title = "The name of the entity-framework model associated with this instance.", Description = "Defaults to the database table's .NET formatted name.")] + public string? EfModelName { get; set; } + + #region By-Convention + + /// + [JsonPropertyName("columnNameIsDeleted")] + [CodeGenProperty("By-Convention", Title = "The default 'IsDeleted' column name.", Description = "Defaults to 'IsDeleted'.")] + public string? ColumnNameIsDeleted { get; set; } + + /// + [JsonPropertyName("columnNameTenantId")] + [CodeGenProperty("By-Convention", Title = "The default 'TenantId' column name.", Description = "Defaults to 'TenantId'.")] + public string? ColumnNameTenantId { get; set; } + + /// + [JsonPropertyName("columnNameRowVersion")] + [CodeGenProperty("By-Convention", Title = "The default 'RowVersion' column name.", Description = "Defaults to 'RowVersion'.")] + public string? ColumnNameRowVersion { get; set; } + + /// + [JsonPropertyName("columnNameCreatedBy")] + [CodeGenProperty("By-Convention", Title = "The default 'CreatedBy' column name.", Description = "Defaults to 'CreatedBy'.")] + public string? ColumnNameCreatedBy { get; set; } + + /// + [JsonPropertyName("columnNameCreatedOn")] + [CodeGenProperty("By-Convention", Title = "The default 'CreatedOn' column name.", Description = "Defaults to 'CreatedOn'.")] + public string? ColumnNameCreatedOn { get; set; } + + /// + [JsonPropertyName("columnNameUpdatedBy")] + [CodeGenProperty("By-Convention", Title = "The default 'UpdatedBy' column name.", Description = "Defaults to 'UpdatedBy'.")] + public string? ColumnNameUpdatedBy { get; set; } + + /// + [JsonPropertyName("columnNameUpdatedOn")] + [CodeGenProperty("By-Convention", Title = "The default 'UpdatedOn' column name.", Description = "Defaults to 'UpdatedOn'.")] + public string? ColumnNameUpdatedOn { get; set; } + + /// + public ColumnConfig? ColumnIsDeleted => Columns!.SingleOrDefault(x => x.Name == ColumnNameIsDeleted); + + /// + public ColumnConfig? ColumnTenantId => Columns!.SingleOrDefault(x => x.Name == ColumnNameTenantId); + + /// + public ColumnConfig? ColumnRowVersion => Columns!.SingleOrDefault(x => x.Name == ColumnNameRowVersion); + + /// + public ColumnConfig? ColumnCreatedBy => Columns!.SingleOrDefault(x => x.Name == ColumnNameCreatedBy); + + /// + public ColumnConfig? ColumnCreatedOn => Columns!.SingleOrDefault(x => x.Name == ColumnNameCreatedOn); + + /// + public ColumnConfig? ColumnUpdatedBy => Columns!.SingleOrDefault(x => x.Name == ColumnNameUpdatedBy); + + /// + public ColumnConfig? ColumnUpdatedOn => Columns!.SingleOrDefault(x => x.Name == ColumnNameUpdatedOn); + + /// + /// Indicates whether the 'IsDeleted' column exists. + /// + public bool HasColumnIsDeleted => ColumnIsDeleted is not null; + + /// + /// Indicates whether the 'TenantId' column exists. + /// + public bool HasColumnTenantId => ColumnTenantId is not null; + + /// + /// Indicates whether the 'RowVersion' column exists. + /// + public bool HasColumnRowVersion => ColumnRowVersion is not null; + + /// + /// Indicates whether the 'CreatedOn' column exists. + /// + public bool HasColumnCreatedOn => ColumnCreatedOn is not null; + + /// + /// Indicates whether the 'CreatedBy' column exists. + /// + public bool HasColumnCreatedBy => ColumnCreatedBy is not null; + + /// + /// Indicates whether the 'UpdatedOn' column exists. + /// + public bool HasColumnUpdatedOn => ColumnUpdatedOn is not null; + + /// + /// Indicates whether the 'UpdatedBy' column exists. + /// + public bool HasColumnUpdatedBy => ColumnUpdatedBy is not null; + + /// + /// Indicates whether all the audit columns exist. + /// + public bool HasAllAuditColumns => ColumnCreatedBy is not null && ColumnCreatedOn is not null && ColumnUpdatedBy is not null && ColumnUpdatedOn is not null; + + /// + public bool HasAtLeastOneAuditColumn => ColumnCreatedBy is not null || ColumnCreatedOn is not null || ColumnUpdatedBy is not null || ColumnUpdatedOn is not null; + + #endregion + + /// + /// Gets the list of configured columns. + /// + [JsonPropertyName("columns")] + [CodeGenPropertyCollection("Collections", Title = "The database column collection configuration.", IsImportant = true, + Description = "This collection is optional. It is used to specifically declare and/or override the column configurations. This bypasses the {IncludeColumns} and {ExcludesColumns} lists.")] + public List? Columns { get; set; } + + /// + /// Gets the corresponding (actual) database table configuration. + /// + public DbTableSchema? DbTable { get; private set; } + + /// + /// Gets the list of configured columns that represent the primary key. + /// + public List PrimaryKeyColumns => [.. Columns!.Where(x => x.DbColumn!.IsPrimaryKey)]; + + /// + /// Indicates whether there is a single primary key column that is an identifier (i.e. named with suffix 'Id' (any case)). + /// + public bool HasPrimaryKeyIdentifier => DbTable!.HasPrimaryKeyIdentifier; + + /// + /// Gets the column configuration that represents the primary key identifier, if one exists. + /// + public ColumnConfig? PrimaryKeyIdentifierColumn => Columns!.SingleOrDefault(x => x.DbColumn!.IsPrimaryKeyIdentifier); + + /// + /// Gets the list of configured columns that do not represent the primary key, and are not audit, tenant-id, row-version or soft-delete related columns (i.e. the "standard" columns). + /// + public List StandardColumns => [.. Columns!.Where(x => !x.DbColumn!.IsPrimaryKey && !x.DbColumn!.IsCreatedAudit && !x.DbColumn!.IsUpdatedAudit && !x.DbColumn!.IsRowVersion && !x.DbColumn!.IsTenantId && !x.DbColumn!.IsIsDeleted)]; + + /// + /// Gets the list of configured columns that are convention-based; i.e. those that represent audit, tenant-id, row-version or soft-delete related columns based on standard naming conventions (or explicit configuration). + /// + public List ConventionColumns => [.. Columns!.Where(x => x.DbColumn!.IsCreatedAudit || x.DbColumn!.IsUpdatedAudit || x.DbColumn!.IsRowVersion || x.DbColumn!.IsTenantId || x.DbColumn!.IsIsDeleted)]; + + /// + /// Gets the reference-data configuration for this table, if applicable (i.e. if the table is considered reference data based on the presence of conventionally named 'Code' and 'Text' columns that are not primary keys, and are of type 'string'). + /// + public RefDataConfig RefData { get; } = new(); + + /// + protected async override Task PrepareAsync() + { + Schema = DefaultWhereNull(Schema, () => Parent!.Schema); + DbTable = Root!.DbTables.SingleOrDefault(x => x.Name == Name && x.Schema == Schema) ?? throw new CodeGenException(this, nameof(Name), $"Table '{Root!.Migrator.SchemaConfig.ToFullyQualifiedTableName(Schema, Name!)}' not found in database."); + EfModel = DefaultWhereNull(EfModel, () => Parent!.EfModel); + EfModelName = DefaultWhereNull(EfModelName, () => DbTable.DotNetName); + + // Default the by-convention properties. + ColumnNameIsDeleted = DefaultWhereNull(ColumnNameIsDeleted, () => Parent!.ColumnNameIsDeleted); + ColumnNameTenantId = DefaultWhereNull(ColumnNameTenantId, () => Parent!.ColumnNameTenantId); + ColumnNameRowVersion = DefaultWhereNull(ColumnNameRowVersion, () => Parent!.ColumnNameRowVersion); + ColumnNameCreatedBy = DefaultWhereNull(ColumnNameCreatedBy, () => Parent!.ColumnNameCreatedBy); + ColumnNameCreatedOn = DefaultWhereNull(ColumnNameCreatedOn, () => Parent!.ColumnNameCreatedOn); + ColumnNameUpdatedBy = DefaultWhereNull(ColumnNameUpdatedBy, () => Parent!.ColumnNameUpdatedBy); + ColumnNameUpdatedOn = DefaultWhereNull(ColumnNameUpdatedOn, () => Parent!.ColumnNameUpdatedOn); + + // Merge configured (always included) and selected columns to build the final column configuration collection for the table. This approach attempts to keep the declared order of the columns in the database. + var columns = new List(); + foreach (var column in DbTable.Columns) + { + var configuredColumn = Columns?.SingleOrDefault(x => x.Name == column.Name); + if (configuredColumn is not null) + columns.Add(configuredColumn); + else + { + if (ExcludeColumns is not null && ExcludeColumns.Count == 1 && ExcludeColumns[0] == "*") + continue; + + if ((ExcludeColumns is null || !ExcludeColumns.Contains(column.Name)) && (IncludeColumns is null || IncludeColumns.Contains(column.Name))) + columns.Add(new ColumnConfig { Name = column.Name, DbColumn = column }); + } + } + + // Prepare the column configurations (e.g. to resolve the column type, etc.) and replace as final. + Columns = await PrepareCollectionAsync(columns).ConfigureAwait(false); + + // Prepare the reference-data configuration. + await RefData.PrepareAsync(Root!, this).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/DbEx/CodeGen/DbCodeGenerator.cs b/src/DbEx/CodeGen/DbCodeGenerator.cs new file mode 100644 index 0000000..5fc66b9 --- /dev/null +++ b/src/DbEx/CodeGen/DbCodeGenerator.cs @@ -0,0 +1,16 @@ +using OnRamp.Scripts; + +namespace DbEx.CodeGen; + +/// +/// Represents the code generator for database-related code generation, such as for models, contexts, and related code artefacts. +/// +internal class DbCodeGenerator(CodeGeneratorArgs args, CodeGenScript scripts) : CodeGenerator(args, scripts) +{ + private DatabaseMigrationBase? _migrator; + + /// + /// Gets or sets the requisite . + /// + public DatabaseMigrationBase Migrator { get => _migrator ?? throw new InvalidOperationException("Migrator is not set."); set => _migrator = value; } +} \ No newline at end of file diff --git a/src/DbEx/CodeGen/Generators/EfModelBuilderGenerator.cs b/src/DbEx/CodeGen/Generators/EfModelBuilderGenerator.cs new file mode 100644 index 0000000..54813d2 --- /dev/null +++ b/src/DbEx/CodeGen/Generators/EfModelBuilderGenerator.cs @@ -0,0 +1,13 @@ +using DbEx.CodeGen.Config; +using OnRamp.Generators; + +namespace DbEx.CodeGen.Generators; + +/// +/// Provides the entity-framework model builder code-generator. +/// +public class EfModelBuilderGenerator : CodeGeneratorBase +{ + /// + protected override IEnumerable SelectGenConfig(CodeGenConfig config) => config.EfModelBuilders.Count == 0 ? [] : [config]; +} \ No newline at end of file diff --git a/src/DbEx/CodeGen/Generators/EfModelGenerator.cs b/src/DbEx/CodeGen/Generators/EfModelGenerator.cs new file mode 100644 index 0000000..4e1f377 --- /dev/null +++ b/src/DbEx/CodeGen/Generators/EfModelGenerator.cs @@ -0,0 +1,13 @@ +using DbEx.CodeGen.Config; +using OnRamp.Generators; + +namespace DbEx.CodeGen.Generators; + +/// +/// Provides the entity-framework model code-generator. +/// +public class EfModelGenerator : CodeGeneratorBase +{ + /// + protected override IEnumerable SelectGenConfig(CodeGenConfig config) => config.EfModels; +} \ No newline at end of file diff --git a/src/DbEx/CodeGen/Generators/OutboxGenerator.cs b/src/DbEx/CodeGen/Generators/OutboxGenerator.cs new file mode 100644 index 0000000..7bc1b57 --- /dev/null +++ b/src/DbEx/CodeGen/Generators/OutboxGenerator.cs @@ -0,0 +1,13 @@ +using DbEx.CodeGen.Config; +using OnRamp.Generators; + +namespace DbEx.CodeGen.Generators; + +/// +/// Provides the outbox code-generator. +/// +public class OutboxGenerator : CodeGeneratorBase +{ + /// + protected override IEnumerable SelectGenConfig(CodeGenConfig config) => config.Outbox.HasValue && config.Outbox.Value ? [config] : []; +} \ No newline at end of file diff --git a/src/DbEx/Console/MigrationConsoleBase.cs b/src/DbEx/Console/MigrationConsoleBase.cs index 6d73523..b4f4de6 100644 --- a/src/DbEx/Console/MigrationConsoleBase.cs +++ b/src/DbEx/Console/MigrationConsoleBase.cs @@ -14,6 +14,7 @@ public abstract class MigrationConsoleBase(MigrationArgsBase args) private const string EntryAssemblyOnlyOptionName = "entry-assembly-only"; private const string AcceptPromptsOptionName = "accept-prompts"; private const string DropSchemaObjectsName = "drop-schema-objects"; + private const string ExpectNoChangesName = "expect-no-changes"; private CommandArgument? _commandArg; private CommandArgument? _additionalArgs; private CommandOption? _helpOption; @@ -105,6 +106,7 @@ public async Task RunAsync(string[] args, CancellationToken cancellationTok ConsoleOptions.Add(EntryAssemblyOnlyOptionName, app.Option("-eo|--entry-assembly-only", "Use the entry assembly only (ignore all other assemblies).", CommandOptionType.NoValue)); ConsoleOptions.Add(DropSchemaObjectsName, app.Option("-dso|--drop-schema-objects", "Drop all known schema objects before applying; bypasses automatic skip where all scripts are replacements.", CommandOptionType.NoValue)); ConsoleOptions.Add(AcceptPromptsOptionName, app.Option("--accept-prompts", "Accept prompts; command should _not_ stop and wait for user confirmation (DROP or RESET commands).", CommandOptionType.NoValue)); + ConsoleOptions.Add(ExpectNoChangesName, app.Option("--expect-no-changes", "Indicates to expect no changes during code-generation (i.e. result in error on change).", CommandOptionType.NoValue)); _additionalArgs = app.Argument("args", "Additional arguments; 'Script' arguments (first being the script name) -or- 'Execute' (each a SQL statement to invoke).", multipleValues: true); OnBeforeExecute(app); @@ -195,6 +197,11 @@ public async Task RunAsync(string[] args, CancellationToken cancellationTok if (dso is not null && dso.HasValue()) Args.DropSchemaObjects = true; + // Handle expect no changes. + var enc = GetCommandOption(ExpectNoChangesName); + if (enc is not null && enc.HasValue()) + Args.ExpectNoChanges = true; + return res; }); @@ -392,6 +399,9 @@ public static void WriteStandardizedArgs(DatabaseMigrationBase migrator, Action< migrator.Args.Logger.LogInformation("{Content}", $"SchemaOrder = {string.Join(", ", [.. migrator.Args.SchemaOrder])}"); migrator.Args.Logger.LogInformation("{Content}", $"OutDir = {migrator.Args.OutputDirectory?.FullName}"); + if (migrator.Args.ExpectNoChanges) + migrator.Args.Logger.LogInformation("{Content}", $"Expect-no-changes"); + additional?.Invoke(migrator.Args.Logger); migrator.Args.Logger.LogInformation("{Content}", $"Parameters{(migrator.Args.Parameters.Count == 0 ? " = none" : ":")}"); diff --git a/src/DbEx/DbEx.csproj b/src/DbEx/DbEx.csproj index 9757f58..be1e59a 100644 --- a/src/DbEx/DbEx.csproj +++ b/src/DbEx/DbEx.csproj @@ -1,7 +1,7 @@  - net8.0;net9.0;net10.0;netstandard2.1 + net8.0;net9.0;net10.0 DbEx DbEx DbEx Database Migration Tool. @@ -18,6 +18,7 @@ + @@ -25,10 +26,6 @@ - - - - diff --git a/src/DbEx/DbSchema/DbColumnSchema.cs b/src/DbEx/DbSchema/DbColumnSchema.cs index c99e01b..00ab21e 100644 --- a/src/DbEx/DbSchema/DbColumnSchema.cs +++ b/src/DbEx/DbSchema/DbColumnSchema.cs @@ -91,6 +91,11 @@ public class DbColumnSchema(DbTableSchema dbTable, string name, string type, str /// public bool IsPrimaryKey { get; set; } + /// + /// Indicates whether the column is the primary key and is named using the table name followed by 'Id' convention. + /// + public bool IsPrimaryKeyIdentifier { get; set; } + /// /// Indicates whether the column has a unique constraint. /// @@ -126,15 +131,35 @@ public class DbColumnSchema(DbTableSchema dbTable, string name, string type, str /// public string? ForeignRefDataCodeColumn { get; set; } + /// + /// Indicates whether the column is a created-on audit column; i.e. name is CreatedOn. + /// + public bool IsCreatedOn { get; set; } + + /// + /// Indicates whether the column is a created-by audit column; i.e. name is CreatedBy. + /// + public bool IsCreatedBy { get; set; } + + /// + /// Indicates whether the column is an updated-on audit column; i.e. name is UpdatedOn. + /// + public bool IsUpdatedOn { get; set; } + + /// + /// Indicates whether the column is an updated-by audit column; i.e. name is UpdatedBy. + /// + public bool IsUpdatedBy { get; set; } + /// /// Indicates whether the column is a created audit column; i.e. name is CreatedOn or CreatedBy. /// - public bool IsCreatedAudit { get; set; } + public bool IsCreatedAudit => IsCreatedOn || IsCreatedBy; /// /// Indicates whether the column is an updated audit column; i.e. name is UpdatedOn or UpdatedBy. /// - public bool IsUpdatedAudit { get; set; } + public bool IsUpdatedAudit => IsUpdatedOn || IsUpdatedBy; /// /// Indicates whether the column is a row-version column; i.e. name is RowVersion. @@ -157,10 +182,16 @@ public class DbColumnSchema(DbTableSchema dbTable, string name, string type, str public bool IsJsonContent { get; set; } /// - /// Gets the corresponding .NET name. + /// Gets the corresponding .NET name (excludes nullability). /// public string DotNetType => _dotNetType ??= DbTable?.Migration.SchemaConfig.ToDotNetTypeName(this) ?? throw new InvalidOperationException($"The {nameof(DbTable)} must be set before the {nameof(DotNetType)} property can be accessed."); + /// + /// Gets the corresponding .NET name (including nullability). + /// + /// A type is always considered nullable; otherwise, the nullability is determined by the property. + public string DotNetTypeWithNullability => IsNullable || DotNetType == "string" ? $"{DotNetType}?" : DotNetType; + /// /// Gets the corresponding .NET name. /// @@ -233,6 +264,7 @@ public void CopyFrom(DbColumnSchema column) IsComputed = column.IsComputed; DefaultValue = column.DefaultValue; IsPrimaryKey = column.IsPrimaryKey; + IsPrimaryKeyIdentifier = column.IsPrimaryKeyIdentifier; IsUnique = column.IsUnique; ForeignTable = column.ForeignTable; ForeignSchema = column.ForeignSchema; @@ -240,8 +272,10 @@ public void CopyFrom(DbColumnSchema column) IsForeignRefData = column.IsForeignRefData; IsRefData = column.IsRefData; ForeignRefDataCodeColumn = column.ForeignRefDataCodeColumn; - IsCreatedAudit = column.IsCreatedAudit; - IsUpdatedAudit = column.IsUpdatedAudit; + IsCreatedOn = column.IsCreatedOn; + IsCreatedBy = column.IsCreatedBy; + IsUpdatedOn = column.IsUpdatedOn; + IsUpdatedBy = column.IsUpdatedBy; IsRowVersion = column.IsRowVersion; IsTenantId = column.IsTenantId; IsIsDeleted = column.IsIsDeleted; diff --git a/src/DbEx/DbSchema/DbTableSchema.cs b/src/DbEx/DbSchema/DbTableSchema.cs index 355376a..89eaebf 100644 --- a/src/DbEx/DbSchema/DbTableSchema.cs +++ b/src/DbEx/DbSchema/DbTableSchema.cs @@ -7,7 +7,6 @@ public partial class DbTableSchema { private static readonly char[] _separators = ['_', '-']; - private static readonly string[] _suffixes = ["Id", "Code", "Json"]; private string? _dotNetName; private string? _pluralName; @@ -154,13 +153,18 @@ public DbTableSchema(DbTableSchema table) /// public List PrimaryKeyColumns => Columns?.Where(x => x.IsPrimaryKey).ToList() ?? []; + /// + /// Indicates whether there is a single primary key column that is an identifier (i.e. named with suffix 'Id' (any case)). + /// + public bool HasPrimaryKeyIdentifier => PrimaryKeyColumns.Count == 1 && PrimaryKeyColumns.First().IsPrimaryKeyIdentifier; + /// /// Gets the standard list (i.e. not primary key, not created audit, not updated audit, not tenant-id, not row-version, not is-deleted). /// public List StandardColumns => Columns?.Where(x => !x.IsPrimaryKey && !x.IsCreatedAudit && !x.IsUpdatedAudit && !x.IsTenantId && !x.IsRowVersion && !x.IsIsDeleted).ToList() ?? []; /// - /// Gets the tenant idenfifier (if any). + /// Gets the tenant identifier (if any). /// public DbColumnSchema? TenantIdColumn => Columns?.FirstOrDefault(x => x.IsTenantId); diff --git a/src/DbEx/Migration/Database.cs b/src/DbEx/Migration/Database.cs index 3f19faf..88c3cc0 100644 --- a/src/DbEx/Migration/Database.cs +++ b/src/DbEx/Migration/Database.cs @@ -84,8 +84,10 @@ await SqlStatement(await ReadSqlAsync(migration, sr, cancellationToken).Configur tables.Add(table = dt); var dc = migration.SchemaConfig.CreateColumnFromInformationSchema(table, dr); - dc.IsCreatedAudit = dc.Name == migration.Args?.CreatedByColumnName || dc.Name == migration.Args?.CreatedOnColumnName; - dc.IsUpdatedAudit = dc.Name == migration.Args?.UpdatedByColumnName || dc.Name == migration.Args?.UpdatedOnColumnName; + dc.IsCreatedOn = dc.Name == migration.Args?.CreatedOnColumnName; + dc.IsCreatedBy = dc.Name == migration.Args?.CreatedByColumnName; + dc.IsUpdatedOn = dc.Name == migration.Args?.UpdatedOnColumnName; + dc.IsUpdatedBy = dc.Name == migration.Args?.UpdatedByColumnName; dc.IsTenantId = dc.Name == migration.Args?.TenantIdColumnName; dc.IsRowVersion = dc.Name == migration.Args?.RowVersionColumnName; dc.IsIsDeleted = dc.Name == migration.Args?.IsDeletedColumnName; @@ -98,14 +100,6 @@ await SqlStatement(await ReadSqlAsync(migration, sr, cancellationToken).Configur if (tables.Count == 0) return tables; - // Determine whether a table is considered reference data. - foreach (var t in tables) - { - t.IsRefData = refDataPredicate(t); - if (t.IsRefData) - t.RefDataCodeColumn = t.Columns.Where(x => x.Name == migration.Args.RefDataCodeColumnName).SingleOrDefault(); - } - // Configure all the single column primary and unique constraints. using var sr2 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTablePrimaryKey.sql", probeAssemblies); var pks = await SqlStatement(await ReadSqlAsync(migration, sr2, cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new @@ -140,6 +134,7 @@ from c in t.Columns if (pk.IsPrimaryKey) { col.IsPrimaryKey = true; + col.IsPrimaryKeyIdentifier = col.Name.EndsWith("Id", StringComparison.InvariantCultureIgnoreCase); if (!col.IsIdentity) col.IsIdentity = col.DefaultValue != null; } @@ -148,6 +143,14 @@ from c in t.Columns } } + // Determine whether a table is considered reference data. + foreach (var t in tables) + { + t.IsRefData = t.HasPrimaryKeyIdentifier && refDataPredicate(t); + if (t.IsRefData) + t.RefDataCodeColumn = t.Columns.Where(x => x.Name == migration.Args.RefDataCodeColumnName).SingleOrDefault(); + } + // Load any additional configuration specific to the database provider. await migration.SchemaConfig.LoadAdditionalInformationSchema(this, tables, cancellationToken).ConfigureAwait(false); diff --git a/src/DbEx/Migration/DatabaseMigrationBase.cs b/src/DbEx/Migration/DatabaseMigrationBase.cs index a78a6dc..4a8019a 100644 --- a/src/DbEx/Migration/DatabaseMigrationBase.cs +++ b/src/DbEx/Migration/DatabaseMigrationBase.cs @@ -37,6 +37,7 @@ protected DatabaseMigrationBase(MigrationArgsBase args) if (string.IsNullOrEmpty(Args.ConnectionString)) throw new ArgumentException($"{nameof(MigrationArgsBase.ConnectionString)} property must have a value.", nameof(args)); + Args.DatabaseMigrator = this; Args.Logger ??= NullLogger.Instance; Args.OutputDirectory ??= new DirectoryInfo(CodeGenConsole.GetBaseExeDirectory()); @@ -90,7 +91,7 @@ protected DatabaseMigrationBase(MigrationArgsBase args) /// /// Gets the root namespaces for the (ordered by ). /// - protected IEnumerable Namespaces { get; } = new List(); + protected List Namespaces { get; } = []; /// /// Gets or sets the Migrations scripts namespace part name. @@ -118,14 +119,21 @@ protected DatabaseMigrationBase(MigrationArgsBase args) /// Gets the assemblies used for probing the requisite artefact resources (used for providing the underlying requisite database statements for the specified ). /// /// Uses the as the base, then adds for this . - public IEnumerable ArtefactResourceAssemblies { get; } = new List(); + public List ArtefactResourceAssemblies { get; } = []; /// /// Indicates whether functionality is enabled. /// - /// Where supported the will be invoked and must be overridden to implement. + /// The will be automatically set to when the 'dbex.yaml' code-generation configuration file () is found. + /// Otherwise, to implement custom code-generation functionality, this property can be manually set and the method overridden."/> public bool IsCodeGenEnabled { get; protected set; } + /// + /// Gets the for the 'dbex.yaml' code-generation configuration. + /// + /// Where the file is determined to exist then the will automatically be set to . + public FileInfo? CodeGenConfigFile { get; private set; } + /// /// Orchestrates the migration steps as specified by the and returns the corresponding log output. /// @@ -234,6 +242,11 @@ public void PreExecutionInitialization() var list2 = (List)ArtefactResourceAssemblies; list2.AddRange(alist); + + // Determine whether code-gen is enabled by checking for the presence of the 'dbex.yaml' file. + CodeGenConfigFile = new FileInfo(Path.Combine(Args.OutputDirectory?.FullName ?? string.Empty, "dbex.yaml")); + if (CodeGenConfigFile.Exists) + IsCodeGenEnabled = true; } /// @@ -497,9 +510,50 @@ void AddScript(List scripts, Assembly assembly, string /// /// The . /// true indicates success; otherwise, false. Additionally, on success the code-generation statistics summary should be returned to append to the log. - /// This will only be invoked where is set to true. The method must be implemented otherwise a will be thrown. - protected virtual Task<(bool Success, string? Statistics)> DatabaseCodeGenAsync(CancellationToken cancellationToken = default) - => throw new NotImplementedException($"The {nameof(DatabaseCodeGenAsync)} method must be implemented by the inheriting class to enable the code-generation functionality."); + /// This will only be invoked where is set to true. + protected async virtual Task<(bool Success, string? Statistics)> DatabaseCodeGenAsync(CancellationToken cancellationToken = default) + { + if (CodeGenConfigFile == null || !CodeGenConfigFile.Exists) + throw new InvalidOperationException("Internal error; expected code-gen configuration file to be set and to exist; or alternatively, this method can be overridden to provide the custom code-generation functionality."); + + // Create the code-generator arguments. + var cga = new CodeGeneratorArgs + { + Logger = Args.Logger, + OutputDirectory = Args.OutputDirectory, + ScriptFileName = "Database.yaml", + ConfigFileName = CodeGenConfigFile.FullName, + ExpectNoChanges = Args.ExpectNoChanges + }; + + cga.Parameters.Add("__migrator", this); + + // Add the assemblies in reverse order to ensure the assembly with the highest precedence is processed last (as the code-gen will use the first found resource where duplicates exist). + cga.Assemblies.AddRange(Args.Assemblies.Select(x => x.Assembly).Reverse()); + + // Create the code-generator and set the reference to this migrator for use during code-generation. + var codeGenerator = await CodeGenerator.CreateAsync(cga).ConfigureAwait(false); + codeGenerator.Migrator = this; + + // Execute the code-generation. + try + { + var stats = await codeGenerator.GenerateAsync(cga.ConfigFileName).ConfigureAwait(false); + return (true, $", Files: Unchanged = {stats.NotChangedCount}, Updated = {stats.UpdatedCount}, Created = {stats.CreatedCount}, TotalLines = {stats.LinesOfCodeCount}"); + } + catch (CodeGenException cgex) + { + Logger.LogError("{Content}", cgex.Message); + Logger.LogInformation("{Content}", string.Empty); + return (false, null); + } + catch (CodeGenChangesFoundException cgcfex) + { + Logger.LogError("{Content}", cgcfex.Message); + Logger.LogInformation("{Content}", string.Empty); + return (false, null); + } + } /// /// Performs the command. @@ -860,7 +914,7 @@ private string[] GetNamespacesWithSuffix(string suffix, bool reverse = false) suffix.ThrowIfNull(nameof(suffix)); var list = new List(); - foreach (var ns in reverse ? Namespaces.Reverse() : Namespaces) + foreach (var ns in reverse ? Namespaces.AsEnumerable().Reverse() : Namespaces) { list.Add($"{ns}.{suffix}"); } @@ -953,7 +1007,7 @@ private async Task CreateScriptInternalAsync(string? name, IDictionaryThe SQL statements. /// The . /// true indicates success; otherwise, false. - /// A maximum of 999 SQL statements may be executed at one-time. Each script is run independently (i.e. not within an overall database tramsaction); therefore, any preceeding scripts before error will have executed successfully. + /// A maximum of 999 SQL statements may be executed at one-time. Each script is run independently (i.e. not within an overall database transaction); therefore, any preceding scripts before error will have executed successfully. public async Task ExecuteSqlStatementsAsync(string[]? statements, CancellationToken cancellationToken = default) { PreExecutionInitialization(); diff --git a/src/DbEx/Migration/MigrationArgsBase.cs b/src/DbEx/Migration/MigrationArgsBase.cs index 09b79db..02806ff 100644 --- a/src/DbEx/Migration/MigrationArgsBase.cs +++ b/src/DbEx/Migration/MigrationArgsBase.cs @@ -28,6 +28,11 @@ public abstract class MigrationArgsBase : OnRamp.CodeGeneratorDbArgsBase /// public MigrationArgsBase() => DataParserArgs = new DataParserArgs(Parameters); + /// + /// Gets the to be used to perform the migration; will be automatically set by the on migration start. + /// + public DatabaseMigrationBase? DatabaseMigrator { get; internal set; } + /// /// Gets or sets the . /// @@ -165,6 +170,13 @@ public abstract class MigrationArgsBase : OnRamp.CodeGeneratorDbArgsBase /// public bool DropSchemaObjects { get; set; } + /// + /// Indicates to expect no changes during code-generation. + /// + /// Will result in an error where changes are identified. This is useful in the likes of build pipelines to confirm that the underlying configuration and build output are in sync; and/or + /// changes have been made manually overriding the generated artefacts in error. + public bool ExpectNoChanges { get; set; } + /// /// Gets or sets the table filtering predicate. /// diff --git a/src/DbEx/Templates/EfModelBuilder_cs.hbs b/src/DbEx/Templates/EfModelBuilder_cs.hbs new file mode 100644 index 0000000..241add4 --- /dev/null +++ b/src/DbEx/Templates/EfModelBuilder_cs.hbs @@ -0,0 +1,38 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace {{Root.DotNetDataEfRepositoriesNamespace}}; + +public partial class {{Domain}}DbContext +{ + /// + /// Adds the generated models to the . + /// + /// The . + public void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder) + { +{{#each EfModels}} + {{#unless @first}} + + {{/unless}} + // Add the entity/model configuration for the {{DbTable.QualifiedName}} database table. + modelBuilder.Entity<{{Root.DotNetDataEfModelsNamespace}}.{{EfModelName}}>(e => + { + e.{{#if DbTable.IsAView}}ToView{{else}}ToTable{{/if}}("{{Name}}"{{#ifne Schema ''}}, "{{Schema}}"{{/ifne}}); + {{#ifne PrimaryKeyColumns.Count 0}} + e.HasKey({{#ifeq PrimaryKeyColumns.Count 1}}p => p.{{#each PrimaryKeyColumns}}{{Property}}{{/each}}{{else}}{{#each PrimaryKeyColumns}}"{{Property}}"{{#unless @last}}, {{/unless}}{{/each}}{{/ifeq}}); + {{/ifne}} + {{#each Columns}} + e.Property(p => p.{{Property}}).HasColumnName("{{Name}}").HasColumnType("{{DbColumn.SqlType2}}"){{#if DbColumn.IsComputed}}.ValueGeneratedOnAddOrUpdate(){{/if}}{{#if DbColumn.IsRowVersionColumn}}.IsRowVersion(){{/if}}{{#if DbColumn.IsCreatedAudit}}.ValueGeneratedOnUpdate(){{/if}}{{#if DbColumn.IsUpdatedAudit}}.ValueGeneratedOnAdd(){{/if}}{{#if ValueConverter}}.HasConversion({{ValueConverter}}){{/if}}; + {{/each}} + }); +{{/each}} + } +} + +#nullable restore \ No newline at end of file diff --git a/src/DbEx/Templates/EfModel_cs.hbs b/src/DbEx/Templates/EfModel_cs.hbs new file mode 100644 index 0000000..4ae1b0d --- /dev/null +++ b/src/DbEx/Templates/EfModel_cs.hbs @@ -0,0 +1,30 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace {{Root.DotNetDataEfModelsNamespace}}; + +/// +/// Persistence model representing the '{{DbTable.QualifiedName}}' database table. +/// +public partial class {{EfModelName}} +{ +{{#each Columns}} + {{#unless @first}} + + {{/unless}} + /// + /// Gets or sets the value of the '{{DbColumn.Name}}' column ({{DbColumn.SqlType}}). + /// + {{#if DbColumn.IsPrimaryKey}} + /// This is {{#ifeq Parent.DbTable.PrimaryKeyColumns.Count 1}}the primary key{{else}}part of the primary key{{/ifeq}}. + {{/if}} + public {{Type}} {{Property}} { get; set; } +{{/each}} +} + +#nullable restore \ No newline at end of file diff --git a/tests/DbEx.Test.Console/Migrations/008-create-test-outbox-tables.sql b/tests/DbEx.Test.Console/Migrations/008-create-test-outbox-tables.sql new file mode 100644 index 0000000..c902369 --- /dev/null +++ b/tests/DbEx.Test.Console/Migrations/008-create-test-outbox-tables.sql @@ -0,0 +1,37 @@ +-- Create table: [Test].[Outbox] and [Test].[OutboxLease] + +BEGIN TRANSACTION + +CREATE TABLE [Test].[Outbox] ( + [OutboxId] BIGINT IDENTITY (1, 1) NOT NULL PRIMARY KEY, + [TenantId] NVARCHAR(255) NOT NULL, -- Optional, null indicates no tenancy. + [PartitionId] INT NOT NULL, -- Partition number; computed in application from partition-key. + [Status] TINYINT NOT NULL DEFAULT 0, -- 0=Pending, 1=Processing, 2=Done. + [EnqueuedUtc] DATETIME2 NOT NULL, -- When the event was enqueued within application. + [AvailableUtc] DATETIME2 NOT NULL, -- When the event is eligible for processing (retry delay). + [DequeuedUtc] DATETIME2 NULL, -- When the event was successfully dequeued/relayed. + [Attempts] INT NOT NULL DEFAULT 0, -- Retry attempt count. + + -- Message: + [Destination] NVARCHAR(255) NULL, -- Message destination; i.e. queue/topic/etc. + [Event] NVARCHAR(MAX) NOT NULL, -- CloudEvent as JSON. + + -- Claim/leasing: + [LeaseId] UNIQUEIDENTIFIER NULL, -- Unique identifier of the lease. + [LeaseUntilUtc] DATETIME2 NULL, -- Leased until UTC; after which assume released due to possible application crash. + + INDEX [IX_Test_Outbox_PartitionOrder] ([TenantId], [PartitionId], [OutboxId]) INCLUDE ([Status], [AvailableUtc], [LeaseUntilUtc], [Destination], [Event], [Attempts]), + INDEX [IX_Test_Outbox_WorkerPull] ([TenantId], [PartitionId], [Status]) INCLUDE ([OutboxId], [AvailableUtc]), + INDEX [IX_Test_Outbox_CleanUp] ([OutboxId]) INCLUDE ([DequeuedUtc]) WHERE [Status] = 2 +); + +CREATE TABLE [Test].[OutboxLease] ( + [TenantId] NVARCHAR(255) NOT NULL, -- Optional, null indicates no tenancy. + [PartitionId] INT NOT NULL, -- Partition number; computed in application from partition-key. + [LeaseId] UNIQUEIDENTIFIER NULL, -- Unique identifier of the leasee. + [LeaseUntilUtc] DATETIME2 NULL -- Leased until UTC; after which assume released due to possible application crash. + + CONSTRAINT PK_Test_OutboxLease PRIMARY KEY (TenantId, PartitionId) +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/tests/DbEx.Test.Console/Properties/launchSettings.json b/tests/DbEx.Test.Console/Properties/launchSettings.json index c044115..1552e80 100644 --- a/tests/DbEx.Test.Console/Properties/launchSettings.json +++ b/tests/DbEx.Test.Console/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "DbEx.Test.Console": { "commandName": "Project", - "commandLineArgs": "all" + "commandLineArgs": "codegen" } } } \ No newline at end of file diff --git a/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxBatchCancel.g.sql b/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxBatchCancel.g.sql new file mode 100644 index 0000000..d5b8ee2 --- /dev/null +++ b/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxBatchCancel.g.sql @@ -0,0 +1,71 @@ +CREATE OR ALTER PROCEDURE [Test].[spOutboxBatchCancel] + @LeaseId UNIQUEIDENTIFIER, + @BackoffSeconds INT +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Cancels a batch by LeaseId, marking messages as pending with backoff and releasing the lease. + * > Returns: + * 0 = Success. + * -1 = No rows updated (e.g. already completed or invalid LeaseId). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @TenantId NVARCHAR(255); + DECLARE @PartitionId INT; + DECLARE @Cancelled TABLE (TenantId NVARCHAR(255), PartitionId INT); + + BEGIN TRY + BEGIN TRAN; + + -- 1) Cancel the batch and capture tenant/partition atomically. + UPDATE o + SET o.[Status] = 0, + o.[Attempts] = o.[Attempts] + 1, + o.[AvailableUtc] = DATEADD(SECOND, @BackoffSeconds, @Now), + o.[LeaseId] = NULL, + o.[LeaseUntilUtc] = NULL + OUTPUT + deleted.[TenantId], + deleted.[PartitionId] + INTO @Cancelled + FROM [Test].[Outbox] AS o WITH (UPDLOCK, ROWLOCK) + WHERE o.[LeaseId] = @LeaseId + AND o.[Status] = 1; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + RETURN -1; -- No rows updated. + END + + -- 2) Capture tenant/partition from first cancelled row. + SELECT TOP 1 + @TenantId = TenantId, + @PartitionId = PartitionId + FROM @Cancelled; + + COMMIT; + + -- 3) Release the partition lease. + BEGIN TRY + EXEC [Test].[spOutboxLeaseRelease] @LeaseId; + END TRY + BEGIN CATCH + -- Ignore: lease will expire. Don't fail cancel. + END CATCH + + RETURN 0; + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END diff --git a/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxBatchClaim.g.sql b/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxBatchClaim.g.sql new file mode 100644 index 0000000..0808765 --- /dev/null +++ b/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxBatchClaim.g.sql @@ -0,0 +1,116 @@ +CREATE OR ALTER PROCEDURE [Test].[spOutboxBatchClaim] + @TenantId NVARCHAR(255) = NULL, + @PartitionId INT, + @BatchSize INT, + @LeaseId UNIQUEIDENTIFIER, + @LeaseSeconds INT +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Claims the next batch of pending/processing messages for a tenant/partition, marking them as processing with a lease. + * > Returns: + * 0 = Success; batch returned in result set. + * -1 = No rows updated (e.g. already claimed by another or transient error). + * -2 = No batch to claim (e.g. all completed). + * -3 = Unable to acquire lease (e.g. another active batch or transient error). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @LeaseUntilUtc DATETIME2; + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + SET LOCK_TIMEOUT 5000; -- Milliseconds. + + -- 1) Acquire a partition lease; exit where unsuccessful. + DECLARE @RC INT; + EXEC @RC = [Test].[spOutboxLeaseAcquire] @EffectiveTenantId, @PartitionId, @LeaseId, @LeaseSeconds, @LeaseUntilUtc OUTPUT; + IF (@RC < 0) RETURN -3; + + -- 2) Claim the next batch (contiguous by OutboxId) for the tenant/partition. + BEGIN TRY + BEGIN TRAN; + + DECLARE @HeadId BIGINT; + DECLARE @BlockerId BIGINT; + + -- Determine head (first pending/processing) for strict contiguity. + SELECT @HeadId = MIN(o.OutboxId) + FROM [Test].[Outbox] o WITH (UPDLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[Status] IN (0, 1) + OPTION (RECOMPILE); + + IF @HeadId IS NULL + BEGIN + COMMIT; + + -- Release the lease outside transaction. + EXEC [Test].[spOutboxLeaseRelease] @LeaseId; + RETURN -2; -- Nothing available. + END + + -- Find first blocker at/after head: actively leased or not yet available. + SELECT @BlockerId = MIN(o.OutboxId) + FROM [Test].[Outbox] o WITH (READPAST, UPDLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[OutboxId] >= @HeadId + AND ((o.Status = 1 AND o.[LeaseUntilUtc] IS NOT NULL AND o.[LeaseUntilUtc] > @Now) + OR (o.Status = 0 AND o.[AvailableUtc] > @Now)) + OPTION (RECOMPILE); + + -- Claim contiguous run from head to before blocker. + ;WITH claim AS + ( + SELECT TOP (@BatchSize) + o.[OutboxId], o.[TenantId], o.[Status], o.[PartitionId], o.[Destination], o.[Event], + o.[Attempts], o.[EnqueuedUtc], o.[AvailableUtc], o.[LeaseId], o.[LeaseUntilUtc] + FROM [Test].[Outbox] o WITH (READPAST, UPDLOCK, ROWLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[OutboxId] >= @HeadId + AND (@BlockerId IS NULL OR o.[OutboxId] < @BlockerId) + AND ((o.[Status] = 0 AND o.[AvailableUtc] <= @Now) + OR (o.[Status] = 1 AND (o.[LeaseUntilUtc] IS NULL OR o.[LeaseUntilUtc] <= @Now))) + ORDER BY o.OutboxId + ) + UPDATE claim + SET [Status] = 1, + [LeaseId] = @LeaseId, + [LeaseUntilUtc] = @LeaseUntilUtc + OUTPUT + inserted.[OutboxId], + inserted.[TenantId], + inserted.[Status], + inserted.[PartitionId], + inserted.[Destination], + inserted.[Event], + inserted.[Attempts], + inserted.[EnqueuedUtc], + inserted.[AvailableUtc], + inserted.[LeaseUntilUtc]; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + + -- Release the lease outside transaction. + EXEC [Test].[spOutboxLeaseRelease] @LeaseId; + RETURN -1; -- No rows updated. + END + + COMMIT; + RETURN 0; + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END \ No newline at end of file diff --git a/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxBatchComplete.g.sql b/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxBatchComplete.g.sql new file mode 100644 index 0000000..1fb6636 --- /dev/null +++ b/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxBatchComplete.g.sql @@ -0,0 +1,73 @@ +CREATE OR ALTER PROCEDURE [Test].[spOutboxBatchComplete] + @LeaseId UNIQUEIDENTIFIER, + @DequeuedUtc DATETIME2 NULL +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Marks a batch as completed by LeaseId, releasing the lease and making way for the next batch. + * > Returns: + * 0 = Success. + * -1 = No rows updated (e.g. already completed or invalid LeaseId). + * -2 = No batch to claim (e.g. all completed since claim). + * -3 = Unable to acquire lease (e.g. another active batch or transient error). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @TenantId NVARCHAR(255); + DECLARE @PartitionId INT; + DECLARE @Completed TABLE (TenantId NVARCHAR(255), PartitionId INT); + + BEGIN TRY + BEGIN TRAN; + + -- 1) Complete the batch and capture tenant/partition atomically. + UPDATE o + SET o.[Status] = 2, + o.[LeaseId] = NULL, + o.[LeaseUntilUtc] = NULL, + o.[DequeuedUtc] = COALESCE(@DequeuedUtc, @Now) + OUTPUT + deleted.[TenantId], + deleted.[PartitionId] + INTO @Completed + FROM [Test].[Outbox] AS o WITH (UPDLOCK, ROWLOCK) + WHERE o.[LeaseId] = @LeaseId + AND o.[Status] = 1; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + RETURN -1; -- No rows updated. + END + + -- 2) Capture tenant/partition from first completed row. + SELECT TOP 1 + @TenantId = TenantId, + @PartitionId = PartitionId + FROM @Completed; + + COMMIT; + + -- 3) Release the partition lease where identified. + BEGIN TRY + EXEC [Test].[spOutboxLeaseRelease] @LeaseId; + END TRY + BEGIN CATCH + -- Ignore: lease will expire. Don't fail completion. + END CATCH + + RETURN 0 + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH + +END \ No newline at end of file diff --git a/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxEnqueue.g.sql b/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxEnqueue.g.sql new file mode 100644 index 0000000..1020753 --- /dev/null +++ b/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxEnqueue.g.sql @@ -0,0 +1,36 @@ +CREATE OR ALTER PROCEDURE [Test].[spOutboxEnqueue] + @TenantId AS NVARCHAR(255) = NULL, + @PartitionId AS INT, + @Destination AS NVARCHAR(255), + @Event AS NVARCHAR(MAX), + @EnqueuedUtc AS DATETIME2 = NULL, + @AvailableUtc AS DATETIME2 = NULL +AS +BEGIN + /* + * This file is automatically generated; any changes will be lost. + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + INSERT INTO [Test].[Outbox] ( + [TenantId], + [PartitionId], + [Destination], + [Event], + [EnqueuedUtc], + [AvailableUtc] + ) + VALUES ( + @EffectiveTenantId, + @PartitionId, + @Destination, + @Event, + COALESCE(@EnqueuedUtc, @Now), + COALESCE(@AvailableUtc, COALESCE(@EnqueuedUtc, @Now)) + ) +END \ No newline at end of file diff --git a/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql b/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql new file mode 100644 index 0000000..66eabbc --- /dev/null +++ b/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql @@ -0,0 +1,73 @@ +CREATE OR ALTER PROCEDURE [Test].[spOutboxLeaseAcquire] + @TenantId NVARCHAR(255) = NULL, + @PartitionId INT, + @LeaseId UNIQUEIDENTIFIER, + @LeaseSeconds INT, + @LeaseUntilUtc DATETIME2 OUTPUT +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Attempts to acquire a lease for a tenant/partition, returning success status and lease until timestamp. + * > Returns: + * 0 = Lease acquired; caller may proceed with batch claim. + * -1 = Lease not acquired; caller should backoff and retry. + * + * Notes: + * - The procedure is resilient to transient errors and will return -1 where lease acquisition is unsuccessful, including where another active lease exists or where a transient error occurs (e.g. lock timeout). + * - The caller should implement an appropriate retry/backoff strategy where -1 is returned, including randomization to avoid thundering herd issues. + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @Until DATETIME2 = DATEADD(SECOND, @LeaseSeconds, @Now) + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + BEGIN TRY + BEGIN TRAN; + + -- 1) Ensure the row exists (self-seeding); lock the key-range for this PartitionId to avoid insert races. + IF NOT EXISTS ( + SELECT 1 + FROM [Test].[OutboxLease] WITH (UPDLOCK, HOLDLOCK) + WHERE [TenantId] = @EffectiveTenantId AND [PartitionId] = @PartitionId + ) + BEGIN + INSERT INTO [Test].[OutboxLease] ([TenantId], [PartitionId]) + VALUES (@EffectiveTenantId, @PartitionId); + END + + -- 2) Attempt to acquire lease where expired/empty. + UPDATE ol + SET ol.[LeaseId] = @LeaseId, + ol.[LeaseUntilUtc] = @Until + FROM [Test].[OutboxLease] AS ol WITH (UPDLOCK, ROWLOCK) + WHERE ol.[PartitionId] = @PartitionId + AND ol.[TenantId] = @EffectiveTenantId + AND (ol.[LeaseUntilUtc] IS NULL OR ol.[LeaseUntilUtc] <= @Now) + OPTION (RECOMPILE); + + -- 3) Commit and return lease success status. + DECLARE @Rows INT = @@ROWCOUNT; + + COMMIT; + + IF @Rows = 1 + BEGIN + SET @LeaseUntilUtc = @Until; + RETURN 0; -- Lease successful. + END + + SET @LeaseUntilUtc = NULL; + RETURN -1; -- Lease unsuccessful. + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END \ No newline at end of file diff --git a/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql b/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql new file mode 100644 index 0000000..c55ed76 --- /dev/null +++ b/tests/DbEx.Test.Console/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql @@ -0,0 +1,44 @@ +CREATE OR ALTER PROCEDURE [Test].[spOutboxLeaseRelease] + @LeaseId UNIQUEIDENTIFIER +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Releases a lease by LeaseId, making way for the next batch. + * > Returns: + * 0 = Success; lease released and available for next claim. + * -1 = No rows updated (e.g. already released or invalid LeaseId). + * + * Notes: + * - The procedure is resilient to transient errors and will return -1 where release is unsuccessful, including where the lease is already released or where a transient error occurs (e.g. lock timeout). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + BEGIN TRY + BEGIN TRAN; + + -- 1) Release lease where leasee. + UPDATE ol + SET ol.[LeaseId] = NULL, + ol.[LeaseUntilUtc] = NULL + FROM [Test].[OutboxLease] AS ol WITH (UPDLOCK, ROWLOCK) + WHERE ol.[LeaseId] = @LeaseId; + + -- 2) Commit and return release success status. + DECLARE @Rows INT = @@ROWCOUNT; + + COMMIT; + + IF @Rows = 1 RETURN 0; -- Release successful. + RETURN -1; -- Release unsuccessful. + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END \ No newline at end of file diff --git a/tests/DbEx.Test.Console/dbex.yaml b/tests/DbEx.Test.Console/dbex.yaml new file mode 100644 index 0000000..6fbac48 --- /dev/null +++ b/tests/DbEx.Test.Console/dbex.yaml @@ -0,0 +1,11 @@ +dotNetDataProjectPath: ../DbEx.Test.Empty +schema: Test +outbox: true +tables: +- name: Contact +- name: Person + columns: + - name: AddressJson + property: Address + type: Persistence.Address? + valueConverter: new Persistence.JsonConverter() \ No newline at end of file diff --git a/tests/DbEx.Test.Empty/DbEx.Test.Empty.csproj b/tests/DbEx.Test.Empty/DbEx.Test.Empty.csproj index f7424b9..1ea5b6c 100644 --- a/tests/DbEx.Test.Empty/DbEx.Test.Empty.csproj +++ b/tests/DbEx.Test.Empty/DbEx.Test.Empty.csproj @@ -1,7 +1,30 @@ - + net8.0;net9.0;net10.0 + enable + + + 10.0.5 + + + + + + 9.0.14 + + + + + + 8.0.25 + + + + + + + diff --git a/tests/DbEx.Test.Empty/GlobalUsing.cs b/tests/DbEx.Test.Empty/GlobalUsing.cs new file mode 100644 index 0000000..c32e607 --- /dev/null +++ b/tests/DbEx.Test.Empty/GlobalUsing.cs @@ -0,0 +1,2 @@ +global using System; +global using Microsoft.EntityFrameworkCore; \ No newline at end of file diff --git a/tests/DbEx.Test.Empty/Persistence/Address.cs b/tests/DbEx.Test.Empty/Persistence/Address.cs new file mode 100644 index 0000000..ce85057 --- /dev/null +++ b/tests/DbEx.Test.Empty/Persistence/Address.cs @@ -0,0 +1,9 @@ +namespace DbEx.Test.Empty.Persistence; + +public class Address +{ + public string? Street { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } +} \ No newline at end of file diff --git a/tests/DbEx.Test.Empty/Persistence/Contact.cs b/tests/DbEx.Test.Empty/Persistence/Contact.cs new file mode 100644 index 0000000..4cd6451 --- /dev/null +++ b/tests/DbEx.Test.Empty/Persistence/Contact.cs @@ -0,0 +1,5 @@ +namespace DbEx.Test.Empty.Persistence; + +public partial class Contact +{ +} \ No newline at end of file diff --git a/tests/DbEx.Test.Empty/Persistence/Contact.g.cs b/tests/DbEx.Test.Empty/Persistence/Contact.g.cs new file mode 100644 index 0000000..6b13f36 --- /dev/null +++ b/tests/DbEx.Test.Empty/Persistence/Contact.g.cs @@ -0,0 +1,63 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace DbEx.Test.Empty.Persistence; + +/// +/// Model class representing the '[Test].[Contact]' database table. +/// +public partial class Contact +{ + /// + /// Gets or sets the value of the 'ContactId' column (INT). + /// + /// This is the primary key. + public int ContactId { get; set; } + + /// + /// Gets or sets the value of the 'Name' column (NVARCHAR(200)). + /// + public string? Name { get; set; } + + /// + /// Gets or sets the value of the 'Phone' column (VARCHAR(15) NULL). + /// + public string? Phone { get; set; } + + /// + /// Gets or sets the value of the 'DateOfBirth' column (DATE NULL). + /// + public DateOnly? DateOfBirth { get; set; } + + /// + /// Gets or sets the value of the 'ContactTypeId' column (INT). + /// + public int ContactTypeId { get; set; } + + /// + /// Gets or sets the value of the 'GenderId' column (INT NULL). + /// + public int? GenderId { get; set; } + + /// + /// Gets or sets the value of the 'TenantId' column (NVARCHAR(50) NULL). + /// + public string? TenantId { get; set; } + + /// + /// Gets or sets the value of the 'Notes' column (NVARCHAR(MAX) NULL). + /// + public string? Notes { get; set; } + + /// + /// Gets or sets the value of the 'ContactTypeCode' column (NVARCHAR(50) NULL). + /// + public string? ContactTypeCode { get; set; } +} + +#nullable restore \ No newline at end of file diff --git a/tests/DbEx.Test.Empty/Persistence/JsonConverter.cs b/tests/DbEx.Test.Empty/Persistence/JsonConverter.cs new file mode 100644 index 0000000..ef4b0eb --- /dev/null +++ b/tests/DbEx.Test.Empty/Persistence/JsonConverter.cs @@ -0,0 +1,8 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace DbEx.Test.Empty.Persistence +{ + public class JsonConverter() : ValueConverter(v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null), v => System.Text.Json.JsonSerializer.Deserialize(v, (System.Text.Json.JsonSerializerOptions?)null)!) + { + } +} \ No newline at end of file diff --git a/tests/DbEx.Test.Empty/Persistence/Person.g.cs b/tests/DbEx.Test.Empty/Persistence/Person.g.cs new file mode 100644 index 0000000..15c0a79 --- /dev/null +++ b/tests/DbEx.Test.Empty/Persistence/Person.g.cs @@ -0,0 +1,58 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace DbEx.Test.Empty.Persistence; + +/// +/// Model class representing the '[Test].[Person]' database table. +/// +public partial class Person +{ + /// + /// Gets or sets the value of the 'PersonId' column (UNIQUEIDENTIFIER). + /// + /// This is the primary key. + public Guid PersonId { get; set; } + + /// + /// Gets or sets the value of the 'Name' column (NVARCHAR(200)). + /// + public string? Name { get; set; } + + /// + /// Gets or sets the value of the 'NicknamesJson' column (NVARCHAR(500) NULL). + /// + public string? NicknamesJson { get; set; } + + /// + /// Gets or sets the value of the 'AddressJson' column (NVARCHAR(500) NULL). + /// + public Persistence.Address? Address { get; set; } + + /// + /// Gets or sets the value of the 'CreatedBy' column (NVARCHAR(250) NULL). + /// + public string? CreatedBy { get; set; } + + /// + /// Gets or sets the value of the 'CreatedOn' column (DATETIMEOFFSET NULL). + /// + public DateTimeOffset? CreatedOn { get; set; } + + /// + /// Gets or sets the value of the 'UpdatedBy' column (NVARCHAR(250) NULL). + /// + public string? UpdatedBy { get; set; } + + /// + /// Gets or sets the value of the 'UpdatedOn' column (DATETIMEOFFSET NULL). + /// + public DateTimeOffset? UpdatedOn { get; set; } +} + +#nullable restore \ No newline at end of file diff --git a/tests/DbEx.Test.Empty/Repositories/TestDbContext.g.cs b/tests/DbEx.Test.Empty/Repositories/TestDbContext.g.cs new file mode 100644 index 0000000..8842bc7 --- /dev/null +++ b/tests/DbEx.Test.Empty/Repositories/TestDbContext.g.cs @@ -0,0 +1,52 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace DbEx.Test.Empty.Repositories; + +public partial class TestDbContext +{ + /// + /// Adds the generated models to the . + /// + /// The . + public void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder) + { + // Add the entity/model configuration for the [Test].[Contact] database table. + modelBuilder.Entity(e => + { + e.ToTable("Contact", "Test"); + e.HasKey(p => p.ContactId); + e.Property(p => p.ContactId).HasColumnName("ContactId").HasColumnType("INT"); + e.Property(p => p.Name).HasColumnName("Name").HasColumnType("NVARCHAR(200)"); + e.Property(p => p.Phone).HasColumnName("Phone").HasColumnType("VARCHAR(15)"); + e.Property(p => p.DateOfBirth).HasColumnName("DateOfBirth").HasColumnType("DATE"); + e.Property(p => p.ContactTypeId).HasColumnName("ContactTypeId").HasColumnType("INT"); + e.Property(p => p.GenderId).HasColumnName("GenderId").HasColumnType("INT"); + e.Property(p => p.TenantId).HasColumnName("TenantId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Notes).HasColumnName("Notes").HasColumnType("NVARCHAR(MAX)"); + e.Property(p => p.ContactTypeCode).HasColumnName("ContactTypeCode").HasColumnType("NVARCHAR(50)"); + }); + + // Add the entity/model configuration for the [Test].[Person] database table. + modelBuilder.Entity(e => + { + e.ToTable("Person", "Test"); + e.HasKey(p => p.PersonId); + e.Property(p => p.PersonId).HasColumnName("PersonId").HasColumnType("UNIQUEIDENTIFIER"); + e.Property(p => p.Name).HasColumnName("Name").HasColumnType("NVARCHAR(200)"); + e.Property(p => p.NicknamesJson).HasColumnName("NicknamesJson").HasColumnType("NVARCHAR(500)"); + e.Property(p => p.Address).HasColumnName("AddressJson").HasColumnType("NVARCHAR(500)").HasConversion(new Persistence.JsonConverter()); + e.Property(p => p.CreatedBy).HasColumnName("CreatedBy").HasColumnType("NVARCHAR(250)").ValueGeneratedOnUpdate(); + e.Property(p => p.CreatedOn).HasColumnName("CreatedOn").HasColumnType("DATETIMEOFFSET").ValueGeneratedOnUpdate(); + e.Property(p => p.UpdatedBy).HasColumnName("UpdatedBy").HasColumnType("NVARCHAR(250)").ValueGeneratedOnAdd(); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET").ValueGeneratedOnAdd(); + }); + } +} + +#nullable restore \ No newline at end of file From f90fed4ae4970a894f556bad3b544682d0540588 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Fri, 1 May 2026 09:35:16 -0700 Subject: [PATCH 05/11] Updated EfModel code-gen to honor non-null of strings. --- CHANGELOG.md | 2 +- Common.targets | 2 +- src/DbEx/DbSchema/DbColumnSchema.cs | 2 +- src/DbEx/Templates/EfModel_cs.hbs | 2 +- tests/DbEx.Test.Console/Properties/launchSettings.json | 2 +- tests/DbEx.Test.Empty/Persistence/Contact.g.cs | 4 ++-- tests/DbEx.Test.Empty/Persistence/JsonConverter.cs | 2 +- tests/DbEx.Test.Empty/Persistence/Person.g.cs | 4 ++-- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80204cb..a73258a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ All internal dependencies to [`CoreEx`](https://github.com/avanade/coreex) have - Entity Framework (EF) convention-based model and model-builder code generation added (all supported databases included). - Transactional `Outbox` and corresponding `OutboxLease` code-generation added (SQL Server only). - The existence of the code-generation configuration file `dbex.yaml` is required to enable. - - Preview 3 will add `dbex.yaml` schema and documentation. + - Preview 4 will add `dbex.yaml` schema and documentation. The enhancements have been made in a manner to maximize backwards compatibility with previous versions of `DbEx` where possible; however, some breaking changes were unfortunately unavoidable (and made to improve overall). diff --git a/Common.targets b/Common.targets index ac63ff3..3bfcffe 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@ - 3.0.0-preview-2 + 3.0.0-preview-3 preview Avanade Avanade diff --git a/src/DbEx/DbSchema/DbColumnSchema.cs b/src/DbEx/DbSchema/DbColumnSchema.cs index 00ab21e..d3f160a 100644 --- a/src/DbEx/DbSchema/DbColumnSchema.cs +++ b/src/DbEx/DbSchema/DbColumnSchema.cs @@ -190,7 +190,7 @@ public class DbColumnSchema(DbTableSchema dbTable, string name, string type, str /// Gets the corresponding .NET name (including nullability). /// /// A type is always considered nullable; otherwise, the nullability is determined by the property. - public string DotNetTypeWithNullability => IsNullable || DotNetType == "string" ? $"{DotNetType}?" : DotNetType; + public string DotNetTypeWithNullability => IsNullable ? $"{DotNetType}?" : DotNetType; /// /// Gets the corresponding .NET name. diff --git a/src/DbEx/Templates/EfModel_cs.hbs b/src/DbEx/Templates/EfModel_cs.hbs index 4ae1b0d..1031d13 100644 --- a/src/DbEx/Templates/EfModel_cs.hbs +++ b/src/DbEx/Templates/EfModel_cs.hbs @@ -23,7 +23,7 @@ public partial class {{EfModelName}} {{#if DbColumn.IsPrimaryKey}} /// This is {{#ifeq Parent.DbTable.PrimaryKeyColumns.Count 1}}the primary key{{else}}part of the primary key{{/ifeq}}. {{/if}} - public {{Type}} {{Property}} { get; set; } + public {{Type}} {{Property}} { get; set; }{{#unless DbColumn.IsNullable}}{{#ifeq DbColumn.DotNetType "string"}} = default!;{{/ifeq}}{{/unless}} {{/each}} } diff --git a/tests/DbEx.Test.Console/Properties/launchSettings.json b/tests/DbEx.Test.Console/Properties/launchSettings.json index 1552e80..c044115 100644 --- a/tests/DbEx.Test.Console/Properties/launchSettings.json +++ b/tests/DbEx.Test.Console/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "DbEx.Test.Console": { "commandName": "Project", - "commandLineArgs": "codegen" + "commandLineArgs": "all" } } } \ No newline at end of file diff --git a/tests/DbEx.Test.Empty/Persistence/Contact.g.cs b/tests/DbEx.Test.Empty/Persistence/Contact.g.cs index 6b13f36..70bd968 100644 --- a/tests/DbEx.Test.Empty/Persistence/Contact.g.cs +++ b/tests/DbEx.Test.Empty/Persistence/Contact.g.cs @@ -9,7 +9,7 @@ namespace DbEx.Test.Empty.Persistence; /// -/// Model class representing the '[Test].[Contact]' database table. +/// Persistence model representing the '[Test].[Contact]' database table. /// public partial class Contact { @@ -22,7 +22,7 @@ public partial class Contact /// /// Gets or sets the value of the 'Name' column (NVARCHAR(200)). /// - public string? Name { get; set; } + public string Name { get; set; } = default!; /// /// Gets or sets the value of the 'Phone' column (VARCHAR(15) NULL). diff --git a/tests/DbEx.Test.Empty/Persistence/JsonConverter.cs b/tests/DbEx.Test.Empty/Persistence/JsonConverter.cs index ef4b0eb..622b0f1 100644 --- a/tests/DbEx.Test.Empty/Persistence/JsonConverter.cs +++ b/tests/DbEx.Test.Empty/Persistence/JsonConverter.cs @@ -2,7 +2,7 @@ namespace DbEx.Test.Empty.Persistence { - public class JsonConverter() : ValueConverter(v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null), v => System.Text.Json.JsonSerializer.Deserialize(v, (System.Text.Json.JsonSerializerOptions?)null)!) + public class JsonConverter() : ValueConverter(v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null), v => System.Text.Json.JsonSerializer.Deserialize(v!, (System.Text.Json.JsonSerializerOptions?)null)!) { } } \ No newline at end of file diff --git a/tests/DbEx.Test.Empty/Persistence/Person.g.cs b/tests/DbEx.Test.Empty/Persistence/Person.g.cs index 15c0a79..b94bb30 100644 --- a/tests/DbEx.Test.Empty/Persistence/Person.g.cs +++ b/tests/DbEx.Test.Empty/Persistence/Person.g.cs @@ -9,7 +9,7 @@ namespace DbEx.Test.Empty.Persistence; /// -/// Model class representing the '[Test].[Person]' database table. +/// Persistence model representing the '[Test].[Person]' database table. /// public partial class Person { @@ -22,7 +22,7 @@ public partial class Person /// /// Gets or sets the value of the 'Name' column (NVARCHAR(200)). /// - public string? Name { get; set; } + public string Name { get; set; } = default!; /// /// Gets or sets the value of the 'NicknamesJson' column (NVARCHAR(500) NULL). From 0ee903e822c92c626833e5df926d30d0e9a3c7d1 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Fri, 8 May 2026 11:26:19 -0700 Subject: [PATCH 06/11] Script suffixes and dbex.json schema. --- CHANGELOG.md | 7 +- Common.targets | 2 +- DbEx.sln | 14 ++ schema/dbex.json | 230 ++++++++++++++++++ src/DbEx.MySql/MySqlSchemaConfig.cs | 4 +- ...atabaseCreate.sql => DatabaseCreate.mysql} | 0 ...aseData_sql.hbs => DatabaseData_mysql.hbs} | 0 .../{DatabaseDrop.sql => DatabaseDrop.mysql} | 0 ...atabaseExists.sql => DatabaseExists.mysql} | 0 ...eReset_sql.hbs => DatabaseReset_mysql.hbs} | 0 .../{JournalAudit.sql => JournalAudit.mysql} | 0 ...{JournalCreate.sql => JournalCreate.mysql} | 0 ...{JournalExists.sql => JournalExists.mysql} | 0 ...rnalPrevious.sql => JournalPrevious.mysql} | 0 ...iptAlter_sql.hbs => ScriptAlter_mysql.hbs} | 0 ...tCreate_sql.hbs => ScriptCreate_mysql.hbs} | 0 ...efault_sql.hbs => ScriptDefault_mysql.hbs} | 0 ...efData_sql.hbs => ScriptRefData_mysql.hbs} | 0 ...olumns.sql => SelectTableAndColumns.mysql} | 0 ...nKeys.sql => SelectTableForeignKeys.mysql} | 0 ...aryKey.sql => SelectTablePrimaryKey.mysql} | 0 .../Console/MigrationArgsExtensions.cs | 2 +- .../Console/PostgresMigrationConsole.cs | 1 + src/DbEx.Postgres/DbEx.Postgres.csproj | 4 + .../Migration/PostgresMigration.cs | 3 +- src/DbEx.Postgres/PostgresSchemaConfig.cs | 4 +- ...atabaseCreate.sql => DatabaseCreate.pgsql} | 0 ...aseData_sql.hbs => DatabaseData_pgsql.hbs} | 0 .../{DatabaseDrop.sql => DatabaseDrop.pgsql} | 0 ...atabaseExists.sql => DatabaseExists.pgsql} | 0 ...eReset_sql.hbs => DatabaseReset_pgsql.hbs} | 0 ...t_tenant_id.sql => fn_get_tenant_id.pgsql} | 0 ...t_timestamp.sql => fn_get_timestamp.pgsql} | 0 ...n_get_user_id.sql => fn_get_user_id.pgsql} | 0 ...get_username.sql => fn_get_username.pgsql} | 0 ...ntext.sql => sp_set_session_context.pgsql} | 0 ...=> sp_throw_authorization_exception.pgsql} | 0 ....sql => sp_throw_business_exception.pgsql} | 0 ...l => sp_throw_concurrency_exception.pgsql} | 0 ....sql => sp_throw_conflict_exception.pgsql} | 0 ...sql => sp_throw_duplicate_exception.pgsql} | 0 ...sql => sp_throw_not_found_exception.pgsql} | 0 ...ql => sp_throw_validation_exception.pgsql} | 0 .../{JournalAudit.sql => JournalAudit.pgsql} | 0 ...{JournalCreate.sql => JournalCreate.pgsql} | 0 ...{JournalExists.sql => JournalExists.pgsql} | 0 ...rnalPrevious.sql => JournalPrevious.pgsql} | 0 ...iptAlter_sql.hbs => ScriptAlter_pgsql.hbs} | 2 +- ...tCreate_sql.hbs => ScriptCreate_pgsql.hbs} | 2 +- ...efault_sql.hbs => ScriptDefault_pgsql.hbs} | 0 .../Resources/ScriptOutbox_pgsql.hbs | 41 ++++ ...efData_sql.hbs => ScriptRefData_pgsql.hbs} | 2 +- ...tSchema_sql.hbs => ScriptSchema_pgsql.hbs} | 0 ...olumns.sql => SelectTableAndColumns.pgsql} | 0 ...nKeys.sql => SelectTableForeignKeys.pgsql} | 0 ...aryKey.sql => SelectTablePrimaryKey.pgsql} | 0 src/DbEx.Postgres/Scripts/Database.yaml | 11 + .../Templates/FnOutboxBatchCancel_pgsql.hbs | 59 +++++ .../Templates/FnOutboxBatchClaim_pgsql.hbs | 125 ++++++++++ .../Templates/FnOutboxBatchComplete_pgsql.hbs | 60 +++++ .../Templates/FnOutboxEnqueue_pgsql.hbs | 41 ++++ .../Templates/FnOutboxLeaseAcquire_pgsql.hbs | 62 +++++ .../Templates/FnOutboxLeaseRelease_pgsql.hbs | 46 ++++ src/DbEx/CodeGen/Config/TableConfig.cs | 4 +- src/DbEx/DatabaseSchemaConfig.cs | 8 +- src/DbEx/Migration/Database.cs | 4 +- src/DbEx/Migration/DatabaseJournal.cs | 8 +- src/DbEx/Migration/DatabaseMigrationBase.cs | 42 ++-- .../DbEx.Test.MySqlConsole.csproj | 15 +- ... 002-create-test-contact-type-table.mysql} | 0 ...sql => 003-create-test-gender-table.mysql} | 0 ...ql => 004-create-test-contact-table.mysql} | 0 ...ql => 005-create-test-multipk-table.mysql} | 0 .../{spGetContact.sql => spGetContact.mysql} | 0 .../DbEx.Test.PostgresConsole.csproj | 15 +- ... 002-create-test-contact-type-table.pgsql} | 0 ...sql => 003-create-test-gender-table.pgsql} | 0 ...ql => 004-create-test-contact-table.pgsql} | 0 ...ql => 005-create-test-multipk-table.pgsql} | 0 .../{spGetContact.sql => spGetContact.pgsql} | 0 .../DbEx.Tooling.Console.csproj | 14 ++ tools/DbEx.Tooling.Console/Program.cs | 24 ++ tools/DbEx.Tooling.Console/generate.ps1 | 1 + 83 files changed, 795 insertions(+), 62 deletions(-) create mode 100644 schema/dbex.json rename src/DbEx.MySql/Resources/{DatabaseCreate.sql => DatabaseCreate.mysql} (100%) rename src/DbEx.MySql/Resources/{DatabaseData_sql.hbs => DatabaseData_mysql.hbs} (100%) rename src/DbEx.MySql/Resources/{DatabaseDrop.sql => DatabaseDrop.mysql} (100%) rename src/DbEx.MySql/Resources/{DatabaseExists.sql => DatabaseExists.mysql} (100%) rename src/DbEx.MySql/Resources/{DatabaseReset_sql.hbs => DatabaseReset_mysql.hbs} (100%) rename src/DbEx.MySql/Resources/{JournalAudit.sql => JournalAudit.mysql} (100%) rename src/DbEx.MySql/Resources/{JournalCreate.sql => JournalCreate.mysql} (100%) rename src/DbEx.MySql/Resources/{JournalExists.sql => JournalExists.mysql} (100%) rename src/DbEx.MySql/Resources/{JournalPrevious.sql => JournalPrevious.mysql} (100%) rename src/DbEx.MySql/Resources/{ScriptAlter_sql.hbs => ScriptAlter_mysql.hbs} (100%) rename src/DbEx.MySql/Resources/{ScriptCreate_sql.hbs => ScriptCreate_mysql.hbs} (100%) rename src/DbEx.MySql/Resources/{ScriptDefault_sql.hbs => ScriptDefault_mysql.hbs} (100%) rename src/DbEx.MySql/Resources/{ScriptRefData_sql.hbs => ScriptRefData_mysql.hbs} (100%) rename src/DbEx.MySql/Resources/{SelectTableAndColumns.sql => SelectTableAndColumns.mysql} (100%) rename src/DbEx.MySql/Resources/{SelectTableForeignKeys.sql => SelectTableForeignKeys.mysql} (100%) rename src/DbEx.MySql/Resources/{SelectTablePrimaryKey.sql => SelectTablePrimaryKey.mysql} (100%) rename src/DbEx.Postgres/Resources/{DatabaseCreate.sql => DatabaseCreate.pgsql} (100%) rename src/DbEx.Postgres/Resources/{DatabaseData_sql.hbs => DatabaseData_pgsql.hbs} (100%) rename src/DbEx.Postgres/Resources/{DatabaseDrop.sql => DatabaseDrop.pgsql} (100%) rename src/DbEx.Postgres/Resources/{DatabaseExists.sql => DatabaseExists.pgsql} (100%) rename src/DbEx.Postgres/Resources/{DatabaseReset_sql.hbs => DatabaseReset_pgsql.hbs} (100%) rename src/DbEx.Postgres/Resources/ExtendedSchema/Functions/{fn_get_tenant_id.sql => fn_get_tenant_id.pgsql} (100%) rename src/DbEx.Postgres/Resources/ExtendedSchema/Functions/{fn_get_timestamp.sql => fn_get_timestamp.pgsql} (100%) rename src/DbEx.Postgres/Resources/ExtendedSchema/Functions/{fn_get_user_id.sql => fn_get_user_id.pgsql} (100%) rename src/DbEx.Postgres/Resources/ExtendedSchema/Functions/{fn_get_username.sql => fn_get_username.pgsql} (100%) rename src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/{sp_set_session_context.sql => sp_set_session_context.pgsql} (100%) rename src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/{sp_throw_authorization_exception.sql => sp_throw_authorization_exception.pgsql} (100%) rename src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/{sp_throw_business_exception.sql => sp_throw_business_exception.pgsql} (100%) rename src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/{sp_throw_concurrency_exception.sql => sp_throw_concurrency_exception.pgsql} (100%) rename src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/{sp_throw_conflict_exception.sql => sp_throw_conflict_exception.pgsql} (100%) rename src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/{sp_throw_duplicate_exception.sql => sp_throw_duplicate_exception.pgsql} (100%) rename src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/{sp_throw_not_found_exception.sql => sp_throw_not_found_exception.pgsql} (100%) rename src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/{sp_throw_validation_exception.sql => sp_throw_validation_exception.pgsql} (100%) rename src/DbEx.Postgres/Resources/{JournalAudit.sql => JournalAudit.pgsql} (100%) rename src/DbEx.Postgres/Resources/{JournalCreate.sql => JournalCreate.pgsql} (100%) rename src/DbEx.Postgres/Resources/{JournalExists.sql => JournalExists.pgsql} (100%) rename src/DbEx.Postgres/Resources/{JournalPrevious.sql => JournalPrevious.pgsql} (100%) rename src/DbEx.Postgres/Resources/{ScriptAlter_sql.hbs => ScriptAlter_pgsql.hbs} (92%) rename src/DbEx.Postgres/Resources/{ScriptCreate_sql.hbs => ScriptCreate_pgsql.hbs} (92%) rename src/DbEx.Postgres/Resources/{ScriptDefault_sql.hbs => ScriptDefault_pgsql.hbs} (100%) create mode 100644 src/DbEx.Postgres/Resources/ScriptOutbox_pgsql.hbs rename src/DbEx.Postgres/Resources/{ScriptRefData_sql.hbs => ScriptRefData_pgsql.hbs} (92%) rename src/DbEx.Postgres/Resources/{ScriptSchema_sql.hbs => ScriptSchema_pgsql.hbs} (100%) rename src/DbEx.Postgres/Resources/{SelectTableAndColumns.sql => SelectTableAndColumns.pgsql} (100%) rename src/DbEx.Postgres/Resources/{SelectTableForeignKeys.sql => SelectTableForeignKeys.pgsql} (100%) rename src/DbEx.Postgres/Resources/{SelectTablePrimaryKey.sql => SelectTablePrimaryKey.pgsql} (100%) create mode 100644 src/DbEx.Postgres/Scripts/Database.yaml create mode 100644 src/DbEx.Postgres/Templates/FnOutboxBatchCancel_pgsql.hbs create mode 100644 src/DbEx.Postgres/Templates/FnOutboxBatchClaim_pgsql.hbs create mode 100644 src/DbEx.Postgres/Templates/FnOutboxBatchComplete_pgsql.hbs create mode 100644 src/DbEx.Postgres/Templates/FnOutboxEnqueue_pgsql.hbs create mode 100644 src/DbEx.Postgres/Templates/FnOutboxLeaseAcquire_pgsql.hbs create mode 100644 src/DbEx.Postgres/Templates/FnOutboxLeaseRelease_pgsql.hbs rename tests/DbEx.Test.MySqlConsole/Migrations/{002-create-test-contact-type-table.sql => 002-create-test-contact-type-table.mysql} (100%) rename tests/DbEx.Test.MySqlConsole/Migrations/{003-create-test-gender-table.sql => 003-create-test-gender-table.mysql} (100%) rename tests/DbEx.Test.MySqlConsole/Migrations/{004-create-test-contact-table.sql => 004-create-test-contact-table.mysql} (100%) rename tests/DbEx.Test.MySqlConsole/Migrations/{005-create-test-multipk-table.sql => 005-create-test-multipk-table.mysql} (100%) rename tests/DbEx.Test.MySqlConsole/Schema/{spGetContact.sql => spGetContact.mysql} (100%) rename tests/DbEx.Test.PostgresConsole/Migrations/{002-create-test-contact-type-table.sql => 002-create-test-contact-type-table.pgsql} (100%) rename tests/DbEx.Test.PostgresConsole/Migrations/{003-create-test-gender-table.sql => 003-create-test-gender-table.pgsql} (100%) rename tests/DbEx.Test.PostgresConsole/Migrations/{004-create-test-contact-table.sql => 004-create-test-contact-table.pgsql} (100%) rename tests/DbEx.Test.PostgresConsole/Migrations/{005-create-test-multipk-table.sql => 005-create-test-multipk-table.pgsql} (100%) rename tests/DbEx.Test.PostgresConsole/Schema/{spGetContact.sql => spGetContact.pgsql} (100%) create mode 100644 tools/DbEx.Tooling.Console/DbEx.Tooling.Console.csproj create mode 100644 tools/DbEx.Tooling.Console/Program.cs create mode 100644 tools/DbEx.Tooling.Console/generate.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index a73258a..cb15371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,14 @@ All internal dependencies to [`CoreEx`](https://github.com/avanade/coreex) have - `MigrationArgsBase.CreatedDateColumnName` renamed to `MigrationArgsBase.CreatedOnColumnName`. - `MigrationArgsBase.UpdatedDateColumnName` renamed to `MigrationArgsBase.UpdatedOnColumnName`. - `DateTimeOffset` is the preferred .NET type for date/time auditing/timestamping. +- *Enhancement:* Added script suffix to discern the type of script; e.g. `*.sql`, `*.pgsql` and `*.mysql`, etc. This is a standard convention-based approach to enable support for multiple databases within the same project/assembly, specifically the likes of intellisense. + - As the name suffix has changed, the existing convention-based discovery of scripts will not find any scripts until they have been renamed to include the suffix; e.g. `MyScript.sql` for SQL Server, `MyScript.pgsql` for PostgreSQL and `MyScript.mysql` for MySQL. + - Additionally, existing journal entries will not be found as the script name is used as the journal identifier; i.e. the existing journal entries will need to be updated to include the updated suffix. - *Enhancement:* Introduced basic code-generation (leverages [`OnRamp`](https://github.com/avanade/onramp)). - Entity Framework (EF) convention-based model and model-builder code generation added (all supported databases included). - - Transactional `Outbox` and corresponding `OutboxLease` code-generation added (SQL Server only). + - Transactional `Outbox` and corresponding `OutboxLease` code-generation added (SQL Server and PostgreSQL only). - The existence of the code-generation configuration file `dbex.yaml` is required to enable. - - Preview 4 will add `dbex.yaml` schema and documentation. + - Added `dbex.yaml` support for `$schema` reference; see [`dbex.json`](./schema/dbex.json). The enhancements have been made in a manner to maximize backwards compatibility with previous versions of `DbEx` where possible; however, some breaking changes were unfortunately unavoidable (and made to improve overall). diff --git a/Common.targets b/Common.targets index 3bfcffe..6adb0f3 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@ - 3.0.0-preview-3 + 3.0.0-preview-4 preview Avanade Avanade diff --git a/DbEx.sln b/DbEx.sln index 0ade183..1267f26 100644 --- a/DbEx.sln +++ b/DbEx.sln @@ -51,6 +51,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DbEx.Postgres", "src\DbEx.P EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbEx.Test.PostgresConsole", "tests\DbEx.Test.PostgresConsole\DbEx.Test.PostgresConsole.csproj", "{9DBC4E5E-6321-4F56-82CC-7ECEE1020EE0}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{D0DB9FBD-1CF8-421C-84A4-F2398289AA71}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbEx.Tooling.Console", "tools\DbEx.Tooling.Console\DbEx.Tooling.Console.csproj", "{D66FA528-5FEB-48C1-9612-B61F3134741E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Schema", "Schema", "{86DCB8F5-DA63-41B0-B6ED-9D7BFA62F391}" + ProjectSection(SolutionItems) = preProject + schema\dbex.json = schema\dbex.json + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -97,6 +106,10 @@ Global {9DBC4E5E-6321-4F56-82CC-7ECEE1020EE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {9DBC4E5E-6321-4F56-82CC-7ECEE1020EE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {9DBC4E5E-6321-4F56-82CC-7ECEE1020EE0}.Release|Any CPU.Build.0 = Release|Any CPU + {D66FA528-5FEB-48C1-9612-B61F3134741E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D66FA528-5FEB-48C1-9612-B61F3134741E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D66FA528-5FEB-48C1-9612-B61F3134741E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D66FA528-5FEB-48C1-9612-B61F3134741E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -109,6 +122,7 @@ Global {2069346C-9769-48DF-B71F-A58ED6A2192B} = {06385968-DFF7-4470-B87E-55D98CC4661C} {80EF0604-F641-4D01-9922-0162D0C69E02} = {06385968-DFF7-4470-B87E-55D98CC4661C} {9DBC4E5E-6321-4F56-82CC-7ECEE1020EE0} = {06385968-DFF7-4470-B87E-55D98CC4661C} + {D66FA528-5FEB-48C1-9612-B61F3134741E} = {D0DB9FBD-1CF8-421C-84A4-F2398289AA71} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {1A02148E-CFB1-43D0-8DC0-123232A179A7} diff --git a/schema/dbex.json b/schema/dbex.json new file mode 100644 index 0000000..5b9d68e --- /dev/null +++ b/schema/dbex.json @@ -0,0 +1,230 @@ +{ + "title": "JSON Schema for DbEx code-generation (https://github.com/avanade/dbex).", + "$schema": "https://json-schema.org/draft-04/schema#", + "definitions": { + "CodeGeneration": { + "type": "object", + "title": "Database-driven by-convention code-generation.", + "properties": { + "schema": { + "type": "string", + "title": "The default database schema name." + }, + "domain": { + "type": "string", + "title": "The domain name.", + "description": "This is the .NET domain name. Attempts to default from the underlying data project file path; uses the second to last segment of the child-most sub-directory by convention. For example, \u0027/xxx/yyy/My.App.Sales.Database\u0027, the domain would be \u0027Sales\u0027." + }, + "efModel": { + "type": "string", + "title": "The default entity-framework code-generation choice.", + "description": "Defaults to \u0027Yes\u0027 (indicates combination of \u0027ModelOnly\u0027 and \u0027ModelBuilderOnly\u0027).", + "enum": [ + "Yes", + "No", + "ModelOnly", + "ModelBuilderOnly" + ] + }, + "dotNetDataProjectPath": { + "type": "string", + "title": "The relative path for the .NET data-related project.", + "description": "Defaults to automatic inference using expected name of \u0027Infrastructure\u0027." + }, + "dotNetDataEfRepositoriesPath": { + "type": "string", + "title": "The path to append to the \u0027{DotNetDataProjectPath}\u0027 for the .NET generated entity-framework repository code.", + "description": "Defaults to \u0027Repositories\u0027." + }, + "dotNetDataEfModelsPath": { + "type": "string", + "title": "The path to append to the \u0027{DotNetDataProjectPath}\u0027 for the .NET generated entity-framework models code.", + "description": "Defaults to \u0027Persistence\u0027." + }, + "columnNameIsDeleted": { + "type": "string", + "title": "The default \u0027IsDeleted\u0027 column name.", + "description": "Defaults to \u0027IsDeleted\u0027." + }, + "columnNameTenantId": { + "type": "string", + "title": "The default \u0027TenantId\u0027 column name.", + "description": "Defaults to \u0027TenantId\u0027." + }, + "columnNameRowVersion": { + "type": "string", + "title": "The default \u0027RowVersion\u0027 column name.", + "description": "Defaults to \u0027RowVersion\u0027." + }, + "columnNameCreatedBy": { + "type": "string", + "title": "The default \u0027CreatedBy\u0027 column name.", + "description": "Defaults to \u0027CreatedBy\u0027." + }, + "columnNameCreatedOn": { + "type": "string", + "title": "The default \u0027CreatedOn\u0027 column name.", + "description": "Defaults to \u0027CreatedOn\u0027." + }, + "columnNameUpdatedBy": { + "type": "string", + "title": "The default \u0027UpdatedBy\u0027 column name.", + "description": "Defaults to \u0027UpdatedBy\u0027." + }, + "columnNameUpdatedOn": { + "type": "string", + "title": "The default \u0027UpdatedOn\u0027 column name.", + "description": "Defaults to \u0027UpdatedOn\u0027." + }, + "outbox": { + "type": "boolean", + "title": "Indicates whether to generate the transactional-outbox database capabilities.", + "description": "Defaults to \u0027false\u0027." + }, + "outboxSchema": { + "type": "string", + "title": "The database schema name for the outbox tables and stored procedures.", + "description": "Defaults to \u0027{schema}\u0027." + }, + "outboxName": { + "type": "string", + "title": "The name of the outbox table.", + "description": "Defaults to \u0027Outbox\u0027." + }, + "tables": { + "type": "array", + "title": "The database table collection configuration.", + "items": { + "$ref": "#/definitions/Table" + } + } + } + }, + "Table": { + "type": "object", + "title": "Database table configuration.", + "properties": { + "name": { + "type": "string", + "title": "The database table name." + }, + "schema": { + "type": "string", + "title": "The database schema name (where applicable).", + "description": "Defaults to the root \u0027{Schema}\u0027 configuration." + }, + "includeColumns": { + "type": "array", + "title": "The list of database columns to include specifically.", + "description": "All columns are included by default; this provides a means to simply select those for inclusion.", + "items": { + "type": "string" + } + }, + "excludeColumns": { + "type": "array", + "title": "The list of database columns to exclude specifically.", + "description": "All columns are included by default; this provides a means to simply select those for exclusion. A single item of \u0027*\u0027 indicates all columns are to be excluded.", + "items": { + "type": "string" + } + }, + "efModel": { + "type": "string", + "title": "The entity-framework code-generation choice.", + "description": "Defaults to parent \u0027{EfModel}\u0027. A \u0027Yes\u0027 indicates combination of \u0027ModelOnly\u0027 and \u0027ModelBuilderOnly\u0027.", + "enum": [ + "Yes", + "No", + "ModelOnly", + "ModelBuilderOnly" + ] + }, + "efModelName": { + "type": "string", + "title": "The name of the entity-framework model associated with this instance.", + "description": "Defaults to the database table\u0027s .NET formatted name." + }, + "columnNameIsDeleted": { + "type": "string", + "title": "The default \u0027IsDeleted\u0027 column name.", + "description": "Defaults to \u0027IsDeleted\u0027." + }, + "columnNameTenantId": { + "type": "string", + "title": "The default \u0027TenantId\u0027 column name.", + "description": "Defaults to \u0027TenantId\u0027." + }, + "columnNameRowVersion": { + "type": "string", + "title": "The default \u0027RowVersion\u0027 column name.", + "description": "Defaults to \u0027RowVersion\u0027." + }, + "columnNameCreatedBy": { + "type": "string", + "title": "The default \u0027CreatedBy\u0027 column name.", + "description": "Defaults to \u0027CreatedBy\u0027." + }, + "columnNameCreatedOn": { + "type": "string", + "title": "The default \u0027CreatedOn\u0027 column name.", + "description": "Defaults to \u0027CreatedOn\u0027." + }, + "columnNameUpdatedBy": { + "type": "string", + "title": "The default \u0027UpdatedBy\u0027 column name.", + "description": "Defaults to \u0027UpdatedBy\u0027." + }, + "columnNameUpdatedOn": { + "type": "string", + "title": "The default \u0027UpdatedOn\u0027 column name.", + "description": "Defaults to \u0027UpdatedOn\u0027." + }, + "columns": { + "type": "array", + "title": "The database column collection configuration.", + "description": "This collection is optional. It is used to specifically declare and/or override the column configurations. This bypasses the {IncludeColumns} and {ExcludesColumns} lists.", + "items": { + "$ref": "#/definitions/Column" + } + } + }, + "required": [ + "name" + ] + }, + "Column": { + "type": "object", + "title": "Database column configuration.", + "properties": { + "name": { + "type": "string", + "title": "The database column name." + }, + "property": { + "type": "string", + "title": "The .NET property name equivalent for the column.", + "description": "Defaults to the database column\u0027s .NET formatted name." + }, + "type": { + "type": "string", + "title": "The corresponding .NET type equivalent for the column (including nullability).", + "description": "Defaults to the database column\u0027s .NET type." + }, + "valueConverter": { + "type": "string", + "title": "The .NET value converter source code for the column.", + "description": "Defaults to null. This must be valid C# source code as it is applied as-is." + } + }, + "required": [ + "name" + ] + } + }, + "allOf": [ + { + "$ref": "#/definitions/CodeGeneration" + } + ] +} \ No newline at end of file diff --git a/src/DbEx.MySql/MySqlSchemaConfig.cs b/src/DbEx.MySql/MySqlSchemaConfig.cs index 8207598..2e48c60 100644 --- a/src/DbEx.MySql/MySqlSchemaConfig.cs +++ b/src/DbEx.MySql/MySqlSchemaConfig.cs @@ -4,7 +4,7 @@ /// Provides MySQL specific configuration and capabilities. /// /// The owning . -public class MySqlSchemaConfig(MySqlMigration migration) : DatabaseSchemaConfig(migration) +public class MySqlSchemaConfig(MySqlMigration migration) : DatabaseSchemaConfig(migration, scriptSuffix: "mysql") { /// /// Value is '_id'. @@ -115,7 +115,7 @@ public override DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema t public override async Task LoadAdditionalInformationSchema(IDatabase database, List tables, CancellationToken cancellationToken) { // Configure all the single column foreign keys. - using var sr3 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableForeignKeys.sql", [typeof(MySqlSchemaConfig).Assembly]); + using var sr3 = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"SelectTableForeignKeys.{ScriptSuffix}", [typeof(MySqlSchemaConfig).Assembly]); #if NET7_0_OR_GREATER var fks = await database.SqlStatement(await sr3.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new #else diff --git a/src/DbEx.MySql/Resources/DatabaseCreate.sql b/src/DbEx.MySql/Resources/DatabaseCreate.mysql similarity index 100% rename from src/DbEx.MySql/Resources/DatabaseCreate.sql rename to src/DbEx.MySql/Resources/DatabaseCreate.mysql diff --git a/src/DbEx.MySql/Resources/DatabaseData_sql.hbs b/src/DbEx.MySql/Resources/DatabaseData_mysql.hbs similarity index 100% rename from src/DbEx.MySql/Resources/DatabaseData_sql.hbs rename to src/DbEx.MySql/Resources/DatabaseData_mysql.hbs diff --git a/src/DbEx.MySql/Resources/DatabaseDrop.sql b/src/DbEx.MySql/Resources/DatabaseDrop.mysql similarity index 100% rename from src/DbEx.MySql/Resources/DatabaseDrop.sql rename to src/DbEx.MySql/Resources/DatabaseDrop.mysql diff --git a/src/DbEx.MySql/Resources/DatabaseExists.sql b/src/DbEx.MySql/Resources/DatabaseExists.mysql similarity index 100% rename from src/DbEx.MySql/Resources/DatabaseExists.sql rename to src/DbEx.MySql/Resources/DatabaseExists.mysql diff --git a/src/DbEx.MySql/Resources/DatabaseReset_sql.hbs b/src/DbEx.MySql/Resources/DatabaseReset_mysql.hbs similarity index 100% rename from src/DbEx.MySql/Resources/DatabaseReset_sql.hbs rename to src/DbEx.MySql/Resources/DatabaseReset_mysql.hbs diff --git a/src/DbEx.MySql/Resources/JournalAudit.sql b/src/DbEx.MySql/Resources/JournalAudit.mysql similarity index 100% rename from src/DbEx.MySql/Resources/JournalAudit.sql rename to src/DbEx.MySql/Resources/JournalAudit.mysql diff --git a/src/DbEx.MySql/Resources/JournalCreate.sql b/src/DbEx.MySql/Resources/JournalCreate.mysql similarity index 100% rename from src/DbEx.MySql/Resources/JournalCreate.sql rename to src/DbEx.MySql/Resources/JournalCreate.mysql diff --git a/src/DbEx.MySql/Resources/JournalExists.sql b/src/DbEx.MySql/Resources/JournalExists.mysql similarity index 100% rename from src/DbEx.MySql/Resources/JournalExists.sql rename to src/DbEx.MySql/Resources/JournalExists.mysql diff --git a/src/DbEx.MySql/Resources/JournalPrevious.sql b/src/DbEx.MySql/Resources/JournalPrevious.mysql similarity index 100% rename from src/DbEx.MySql/Resources/JournalPrevious.sql rename to src/DbEx.MySql/Resources/JournalPrevious.mysql diff --git a/src/DbEx.MySql/Resources/ScriptAlter_sql.hbs b/src/DbEx.MySql/Resources/ScriptAlter_mysql.hbs similarity index 100% rename from src/DbEx.MySql/Resources/ScriptAlter_sql.hbs rename to src/DbEx.MySql/Resources/ScriptAlter_mysql.hbs diff --git a/src/DbEx.MySql/Resources/ScriptCreate_sql.hbs b/src/DbEx.MySql/Resources/ScriptCreate_mysql.hbs similarity index 100% rename from src/DbEx.MySql/Resources/ScriptCreate_sql.hbs rename to src/DbEx.MySql/Resources/ScriptCreate_mysql.hbs diff --git a/src/DbEx.MySql/Resources/ScriptDefault_sql.hbs b/src/DbEx.MySql/Resources/ScriptDefault_mysql.hbs similarity index 100% rename from src/DbEx.MySql/Resources/ScriptDefault_sql.hbs rename to src/DbEx.MySql/Resources/ScriptDefault_mysql.hbs diff --git a/src/DbEx.MySql/Resources/ScriptRefData_sql.hbs b/src/DbEx.MySql/Resources/ScriptRefData_mysql.hbs similarity index 100% rename from src/DbEx.MySql/Resources/ScriptRefData_sql.hbs rename to src/DbEx.MySql/Resources/ScriptRefData_mysql.hbs diff --git a/src/DbEx.MySql/Resources/SelectTableAndColumns.sql b/src/DbEx.MySql/Resources/SelectTableAndColumns.mysql similarity index 100% rename from src/DbEx.MySql/Resources/SelectTableAndColumns.sql rename to src/DbEx.MySql/Resources/SelectTableAndColumns.mysql diff --git a/src/DbEx.MySql/Resources/SelectTableForeignKeys.sql b/src/DbEx.MySql/Resources/SelectTableForeignKeys.mysql similarity index 100% rename from src/DbEx.MySql/Resources/SelectTableForeignKeys.sql rename to src/DbEx.MySql/Resources/SelectTableForeignKeys.mysql diff --git a/src/DbEx.MySql/Resources/SelectTablePrimaryKey.sql b/src/DbEx.MySql/Resources/SelectTablePrimaryKey.mysql similarity index 100% rename from src/DbEx.MySql/Resources/SelectTablePrimaryKey.sql rename to src/DbEx.MySql/Resources/SelectTablePrimaryKey.mysql diff --git a/src/DbEx.Postgres/Console/MigrationArgsExtensions.cs b/src/DbEx.Postgres/Console/MigrationArgsExtensions.cs index c5d7797..dfc9c58 100644 --- a/src/DbEx.Postgres/Console/MigrationArgsExtensions.cs +++ b/src/DbEx.Postgres/Console/MigrationArgsExtensions.cs @@ -23,7 +23,7 @@ public static MigrationArgs IncludeExtendedSchemaScripts(this MigrationArgs args /// The to support fluent-style method-chaining. public static void AddExtendedSchemaScripts(TArgs args) where TArgs : MigrationArgsBase { - foreach (var rn in typeof(MigrationArgsExtensions).Assembly.GetManifestResourceNames().Where(x => x.StartsWith("DbEx.Postgres.Resources.ExtendedSchema.") && x.EndsWith(".sql"))) + foreach (var rn in typeof(MigrationArgsExtensions).Assembly.GetManifestResourceNames().Where(x => x.StartsWith("DbEx.Postgres.Resources.ExtendedSchema.") && x.EndsWith($".{args.DatabaseMigrator?.SchemaConfig.ScriptSuffix ?? "pgsql"}"))) { args.AddScript(MigrationCommand.Schema, typeof(MigrationArgsExtensions).Assembly, rn); } diff --git a/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs b/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs index efd96aa..be38a83 100644 --- a/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs +++ b/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs @@ -53,6 +53,7 @@ public void WriteScriptHelp() Logger?.LogInformation("{help}", " script [default] Creates a default (empty) SQL script."); Logger?.LogInformation("{help}", " script alter
Creates a SQL script to perform an ALTER TABLE."); Logger?.LogInformation("{help}", " script create
Creates a SQL script to perform a CREATE TABLE."); + Logger?.LogInformation("{help}", " script outbox
Creates a SQL script to perform a CREATE TABLE(s) for an Outbox."); Logger?.LogInformation("{help}", " script refdata
Creates a SQL script to perform a CREATE TABLE as reference data."); Logger?.LogInformation("{help}", " script schema Creates a SQL script to perform a CREATE SCHEMA."); } diff --git a/src/DbEx.Postgres/DbEx.Postgres.csproj b/src/DbEx.Postgres/DbEx.Postgres.csproj index 7c076f1..8f322c4 100644 --- a/src/DbEx.Postgres/DbEx.Postgres.csproj +++ b/src/DbEx.Postgres/DbEx.Postgres.csproj @@ -22,6 +22,10 @@ + + + + diff --git a/src/DbEx.Postgres/Migration/PostgresMigration.cs b/src/DbEx.Postgres/Migration/PostgresMigration.cs index 24c5870..2a47699 100644 --- a/src/DbEx.Postgres/Migration/PostgresMigration.cs +++ b/src/DbEx.Postgres/Migration/PostgresMigration.cs @@ -23,7 +23,8 @@ public PostgresMigration(MigrationArgsBase args) : base(args) throw new ArgumentException($"The {nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionString)} property must contain a database name.", nameof(args)); _databaseName = csb.Database; - _database = new PostgresDatabase(() => new NpgsqlConnection(Args.ConnectionString)); + var ds1 = NpgsqlDataSource.Create(Args.ConnectionString!); + _database = new PostgresDatabase(() => NpgsqlDataSource.Create(Args.ConnectionString!).CreateConnection()); csb.Database = null; _masterDatabase = new PostgresDatabase(() => new NpgsqlConnection(csb.ConnectionString)); diff --git a/src/DbEx.Postgres/PostgresSchemaConfig.cs b/src/DbEx.Postgres/PostgresSchemaConfig.cs index 02858cf..6e52464 100644 --- a/src/DbEx.Postgres/PostgresSchemaConfig.cs +++ b/src/DbEx.Postgres/PostgresSchemaConfig.cs @@ -4,7 +4,7 @@ /// Provides PostgreSQL specific configuration and capabilities. /// /// The owning . -public class PostgresSchemaConfig(PostgresMigration migration) : DatabaseSchemaConfig(migration, true, "public") +public class PostgresSchemaConfig(PostgresMigration migration) : DatabaseSchemaConfig(migration, true, "public", "pgsql") { /// /// Value is '_id'. @@ -112,7 +112,7 @@ public override async Task LoadAdditionalInformationSchema(IDatabase database, L } // Configure all the single column foreign keys. - using var sr3 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableForeignKeys.sql", [typeof(PostgresSchemaConfig).Assembly]); + using var sr3 = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"SelectTableForeignKeys.{ScriptSuffix}", [typeof(PostgresSchemaConfig).Assembly]); var fks = await database.SqlStatement(await sr3.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new { ConstraintName = dr.GetValue("constraint_name"), diff --git a/src/DbEx.Postgres/Resources/DatabaseCreate.sql b/src/DbEx.Postgres/Resources/DatabaseCreate.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/DatabaseCreate.sql rename to src/DbEx.Postgres/Resources/DatabaseCreate.pgsql diff --git a/src/DbEx.Postgres/Resources/DatabaseData_sql.hbs b/src/DbEx.Postgres/Resources/DatabaseData_pgsql.hbs similarity index 100% rename from src/DbEx.Postgres/Resources/DatabaseData_sql.hbs rename to src/DbEx.Postgres/Resources/DatabaseData_pgsql.hbs diff --git a/src/DbEx.Postgres/Resources/DatabaseDrop.sql b/src/DbEx.Postgres/Resources/DatabaseDrop.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/DatabaseDrop.sql rename to src/DbEx.Postgres/Resources/DatabaseDrop.pgsql diff --git a/src/DbEx.Postgres/Resources/DatabaseExists.sql b/src/DbEx.Postgres/Resources/DatabaseExists.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/DatabaseExists.sql rename to src/DbEx.Postgres/Resources/DatabaseExists.pgsql diff --git a/src/DbEx.Postgres/Resources/DatabaseReset_sql.hbs b/src/DbEx.Postgres/Resources/DatabaseReset_pgsql.hbs similarity index 100% rename from src/DbEx.Postgres/Resources/DatabaseReset_sql.hbs rename to src/DbEx.Postgres/Resources/DatabaseReset_pgsql.hbs diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_tenant_id.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_tenant_id.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_tenant_id.sql rename to src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_tenant_id.pgsql diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_timestamp.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_timestamp.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_timestamp.sql rename to src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_timestamp.pgsql diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_user_id.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_user_id.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_user_id.sql rename to src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_user_id.pgsql diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_username.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_username.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_username.sql rename to src/DbEx.Postgres/Resources/ExtendedSchema/Functions/fn_get_username.pgsql diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_set_session_context.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_set_session_context.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_set_session_context.sql rename to src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_set_session_context.pgsql diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_authorization_exception.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_authorization_exception.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_authorization_exception.sql rename to src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_authorization_exception.pgsql diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_business_exception.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_business_exception.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_business_exception.sql rename to src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_business_exception.pgsql diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_concurrency_exception.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_concurrency_exception.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_concurrency_exception.sql rename to src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_concurrency_exception.pgsql diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_conflict_exception.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_conflict_exception.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_conflict_exception.sql rename to src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_conflict_exception.pgsql diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_duplicate_exception.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_duplicate_exception.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_duplicate_exception.sql rename to src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_duplicate_exception.pgsql diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_not_found_exception.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_not_found_exception.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_not_found_exception.sql rename to src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_not_found_exception.pgsql diff --git a/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_validation_exception.sql b/src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_validation_exception.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_validation_exception.sql rename to src/DbEx.Postgres/Resources/ExtendedSchema/Stored Procedures/sp_throw_validation_exception.pgsql diff --git a/src/DbEx.Postgres/Resources/JournalAudit.sql b/src/DbEx.Postgres/Resources/JournalAudit.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/JournalAudit.sql rename to src/DbEx.Postgres/Resources/JournalAudit.pgsql diff --git a/src/DbEx.Postgres/Resources/JournalCreate.sql b/src/DbEx.Postgres/Resources/JournalCreate.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/JournalCreate.sql rename to src/DbEx.Postgres/Resources/JournalCreate.pgsql diff --git a/src/DbEx.Postgres/Resources/JournalExists.sql b/src/DbEx.Postgres/Resources/JournalExists.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/JournalExists.sql rename to src/DbEx.Postgres/Resources/JournalExists.pgsql diff --git a/src/DbEx.Postgres/Resources/JournalPrevious.sql b/src/DbEx.Postgres/Resources/JournalPrevious.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/JournalPrevious.sql rename to src/DbEx.Postgres/Resources/JournalPrevious.pgsql diff --git a/src/DbEx.Postgres/Resources/ScriptAlter_sql.hbs b/src/DbEx.Postgres/Resources/ScriptAlter_pgsql.hbs similarity index 92% rename from src/DbEx.Postgres/Resources/ScriptAlter_sql.hbs rename to src/DbEx.Postgres/Resources/ScriptAlter_pgsql.hbs index fda840a..b938ccd 100644 --- a/src/DbEx.Postgres/Resources/ScriptAlter_sql.hbs +++ b/src/DbEx.Postgres/Resources/ScriptAlter_pgsql.hbs @@ -5,4 +5,4 @@ -- Alter table: "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" ALTER TABLE "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" - ADD "Column" VARCHAR(50) NULL; \ No newline at end of file + ADD "column" VARCHAR(50) NULL; \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/ScriptCreate_sql.hbs b/src/DbEx.Postgres/Resources/ScriptCreate_pgsql.hbs similarity index 92% rename from src/DbEx.Postgres/Resources/ScriptCreate_sql.hbs rename to src/DbEx.Postgres/Resources/ScriptCreate_pgsql.hbs index 0de8d98..a5c93e8 100644 --- a/src/DbEx.Postgres/Resources/ScriptCreate_sql.hbs +++ b/src/DbEx.Postgres/Resources/ScriptCreate_pgsql.hbs @@ -5,7 +5,7 @@ -- Create table: "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" CREATE TABLE "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" ( - "{{lookup Parameters 'Param1'}}_id" SERIAL PRIMARY KEY, + "{{lookup Parameters 'Param2'}}_id" SERIAL PRIMARY KEY, -- "code" VARCHAR(50) NULL UNIQUE, -- "text" VARCHAR(250) NULL, -- "bool" BOOLEAN NULL, diff --git a/src/DbEx.Postgres/Resources/ScriptDefault_sql.hbs b/src/DbEx.Postgres/Resources/ScriptDefault_pgsql.hbs similarity index 100% rename from src/DbEx.Postgres/Resources/ScriptDefault_sql.hbs rename to src/DbEx.Postgres/Resources/ScriptDefault_pgsql.hbs diff --git a/src/DbEx.Postgres/Resources/ScriptOutbox_pgsql.hbs b/src/DbEx.Postgres/Resources/ScriptOutbox_pgsql.hbs new file mode 100644 index 0000000..71fbbdd --- /dev/null +++ b/src/DbEx.Postgres/Resources/ScriptOutbox_pgsql.hbs @@ -0,0 +1,41 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +{{! FILENAME:create-[lower (lookup Parameters 'Param1')]-[lower (lookup Parameters 'Param2')]-tables }} +{{! PARAM:Param1=Schema }} +{{! PARAM:Param2=Outbox }} +-- Create table: "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" and "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}_lease" + +BEGIN; + +CREATE TABLE "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" ( + "{{lookup Parameters 'Param2'}}_id" BIGSERIAL NOT NULL PRIMARY KEY, + "tenant_id" VARCHAR(255) NOT NULL, -- Optional, null indicates no tenancy. + "partition_id" INTEGER NOT NULL, -- Partition number; computed in application from partition-key. + "status" SMALLINT NOT NULL DEFAULT 0, -- 0=Pending, 1=Processing, 2=Done. + "enqueued_utc" TIMESTAMPTZ NOT NULL, -- When the event was enqueued within application. + "available_utc" TIMESTAMPTZ NOT NULL, -- When the event is eligible for processing (retry delay). + "dequeued_utc" TIMESTAMPTZ NULL, -- When the event was successfully dequeued/relayed. + "attempts" INTEGER NOT NULL DEFAULT 0, -- Retry attempt count. + + -- Message: + "destination" VARCHAR(255) NULL, -- Message destination; i.e. queue/topic/etc. + "event" TEXT NOT NULL, -- CloudEvent as JSON. + + -- Claim/leasing: + "lease_id" UUID NULL, -- Unique identifier of the lease. + "lease_until_utc" TIMESTAMPTZ NULL -- Leased until UTC; after which assume released due to possible application crash. +); + +CREATE INDEX "ix_{{lookup Parameters 'Param1'}}_{{lookup Parameters 'Param2'}}_partition_order" ON "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" ("tenant_id", "partition_id", "{{lookup Parameters 'Param2'}}_id", "status", "available_utc", "lease_until_utc", "destination", "event", "attempts"); +CREATE INDEX "ix_{{lookup Parameters 'Param1'}}_{{lookup Parameters 'Param2'}}_worker_pull" ON "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" ("tenant_id", "partition_id", "status", "{{lookup Parameters 'Param2'}}_id", "available_utc"); +CREATE INDEX "ix_{{lookup Parameters 'Param1'}}_{{lookup Parameters 'Param2'}}_clean_up" ON "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" ("{{lookup Parameters 'Param2'}}_id", "dequeued_utc") WHERE "status" = 2; + +CREATE TABLE "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}_lease" ( + "tenant_id" VARCHAR(255) NOT NULL, -- Optional, null indicates no tenancy. + "partition_id" INTEGER NOT NULL, -- Partition number; computed in application from partition-key. + "lease_id" UUID NULL, -- Unique identifier of the leasee. + "lease_until_utc" TIMESTAMPTZ NULL, -- Leased until UTC; after which assume released due to possible application crash. + + CONSTRAINT "pk_{{lookup Parameters 'Param1'}}_{{lookup Parameters 'Param2'}}_lease" PRIMARY KEY ("tenant_id", "partition_id") +); + +COMMIT; \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/ScriptRefData_sql.hbs b/src/DbEx.Postgres/Resources/ScriptRefData_pgsql.hbs similarity index 92% rename from src/DbEx.Postgres/Resources/ScriptRefData_sql.hbs rename to src/DbEx.Postgres/Resources/ScriptRefData_pgsql.hbs index 8751e7e..d00317b 100644 --- a/src/DbEx.Postgres/Resources/ScriptRefData_sql.hbs +++ b/src/DbEx.Postgres/Resources/ScriptRefData_pgsql.hbs @@ -5,7 +5,7 @@ -- Create Reference Data table: "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" CREATE TABLE "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" ( - "{{lookup Parameters 'Param1'}}_id" SERIAL PRIMARY KEY, + "{{lookup Parameters 'Param2'}}_id" SERIAL PRIMARY KEY, "code" VARCHAR(50) NOT NULL UNIQUE, "text" VARCHAR(250) NULL, "is_active" BOOLEAN NULL, diff --git a/src/DbEx.Postgres/Resources/ScriptSchema_sql.hbs b/src/DbEx.Postgres/Resources/ScriptSchema_pgsql.hbs similarity index 100% rename from src/DbEx.Postgres/Resources/ScriptSchema_sql.hbs rename to src/DbEx.Postgres/Resources/ScriptSchema_pgsql.hbs diff --git a/src/DbEx.Postgres/Resources/SelectTableAndColumns.sql b/src/DbEx.Postgres/Resources/SelectTableAndColumns.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/SelectTableAndColumns.sql rename to src/DbEx.Postgres/Resources/SelectTableAndColumns.pgsql diff --git a/src/DbEx.Postgres/Resources/SelectTableForeignKeys.sql b/src/DbEx.Postgres/Resources/SelectTableForeignKeys.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/SelectTableForeignKeys.sql rename to src/DbEx.Postgres/Resources/SelectTableForeignKeys.pgsql diff --git a/src/DbEx.Postgres/Resources/SelectTablePrimaryKey.sql b/src/DbEx.Postgres/Resources/SelectTablePrimaryKey.pgsql similarity index 100% rename from src/DbEx.Postgres/Resources/SelectTablePrimaryKey.sql rename to src/DbEx.Postgres/Resources/SelectTablePrimaryKey.pgsql diff --git a/src/DbEx.Postgres/Scripts/Database.yaml b/src/DbEx.Postgres/Scripts/Database.yaml new file mode 100644 index 0000000..6a67e11 --- /dev/null +++ b/src/DbEx.Postgres/Scripts/Database.yaml @@ -0,0 +1,11 @@ +configType: DbEx.CodeGen.Config.CodeGenConfig, DbEx +generators: +- { type: 'DbEx.CodeGen.Generators.EfModelGenerator, DbEx', template: 'EfModel_cs', file: '{{EfModelName}}.g.cs', directory: '{{Root.DotNetDataProjectDirectory.FullName}}/{{Root.DotNetDataEfModelsPath}}', text: Entity-framework (EF) model(s) generation } +- { type: 'DbEx.CodeGen.Generators.EfModelBuilderGenerator, DbEx', template: 'EfModelBuilder_cs', file: '{{Domain}}DbContext.g.cs', directory: '{{Root.DotNetDataProjectDirectory.FullName}}/{{Root.DotNetDataEfRepositoriesPath}}', text: Entity-framework (EF) DbContext model builder generation } + +- { type: 'DbEx.CodeGen.Generators.OutboxGenerator, DbEx', template: 'FnOutboxEnqueue_pgsql', file: 'fn_{{snake OutboxName}}_enqueue.g.pgsql', directory: '{{Root.DatabaseDirectory.FullName}}/Schema/Functions', text: Outbox enqueue function generation } +- { type: 'DbEx.CodeGen.Generators.OutboxGenerator, DbEx', template: 'FnOutboxBatchClaim_pgsql', file: 'fn_{{snake OutboxName}}_batch_claim.g.pgsql', directory: '{{Root.DatabaseDirectory.FullName}}/Schema/Functions', text: Outbox batch-claim function generation } +- { type: 'DbEx.CodeGen.Generators.OutboxGenerator, DbEx', template: 'FnOutboxBatchComplete_pgsql', file: 'fn_{{snake OutboxName}}_batch_complete.g.pgsql', directory: '{{Root.DatabaseDirectory.FullName}}/Schema/Functions', text: Outbox batch-complete function generation } +- { type: 'DbEx.CodeGen.Generators.OutboxGenerator, DbEx', template: 'FnOutboxBatchCancel_pgsql', file: 'fn_{{snake OutboxName}}_batch_cancel.g.pgsql', directory: '{{Root.DatabaseDirectory.FullName}}/Schema/Functions', text: Outbox batch-cancel function generation } +- { type: 'DbEx.CodeGen.Generators.OutboxGenerator, DbEx', template: 'FnOutboxLeaseAcquire_pgsql', file: 'fn_{{snake OutboxName}}_lease_acquire.g.pgsql', directory: '{{Root.DatabaseDirectory.FullName}}/Schema/Functions', text: Outbox lease-acquire function generation } +- { type: 'DbEx.CodeGen.Generators.OutboxGenerator, DbEx', template: 'FnOutboxLeaseRelease_pgsql', file: 'fn_{{snake OutboxName}}_lease_release.g.pgsql', directory: '{{Root.DatabaseDirectory.FullName}}/Schema/Functions', text: Outbox lease-release function generation } \ No newline at end of file diff --git a/src/DbEx.Postgres/Templates/FnOutboxBatchCancel_pgsql.hbs b/src/DbEx.Postgres/Templates/FnOutboxBatchCancel_pgsql.hbs new file mode 100644 index 0000000..e4f678b --- /dev/null +++ b/src/DbEx.Postgres/Templates/FnOutboxBatchCancel_pgsql.hbs @@ -0,0 +1,59 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +CREATE OR REPLACE FUNCTION "{{OutboxSchema}}"."fn_{{OutboxName}}_batch_cancel"( + p_lease_id UUID, + p_backoff_seconds INTEGER +) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + _now TIMESTAMPTZ; + _row_count INTEGER; +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Cancels a batch by lease_id, marking messages as pending with backoff and releasing the lease. + * > Returns: + * 0 = Success. + * -1 = No rows updated (e.g. already completed or invalid lease_id). + */ + + -- Set transaction parameters + SET LOCAL lock_timeout = '5s'; + SET LOCAL transaction_isolation = 'read committed'; + + _now := NOW() AT TIME ZONE 'UTC'; + + -- 1) Cancel all rows in the batch. + UPDATE "{{OutboxSchema}}"."{{OutboxName}}" AS o + SET "status" = 0, + "attempts" = o."attempts" + 1, + "available_utc" = _now + (p_backoff_seconds || ' seconds')::INTERVAL, + "lease_id" = NULL, + "lease_until_utc" = NULL + WHERE o."lease_id" = p_lease_id + AND o."status" = 1; + + GET DIAGNOSTICS _row_count = ROW_COUNT; + + IF _row_count = 0 THEN + RETURN -1; -- No rows updated; already completed or invalid lease_id. + END IF; + + -- 2) Release the partition lease. + BEGIN + PERFORM "{{OutboxSchema}}"."fn_{{OutboxName}}_lease_release"(p_lease_id); + EXCEPTION + WHEN OTHERS THEN + -- Ignore: lease will expire. Don't fail cancel. + NULL; + END; + + RETURN 0; + +EXCEPTION + WHEN OTHERS THEN + RAISE; -- Re-raise preserves error details to caller. +END +$$; \ No newline at end of file diff --git a/src/DbEx.Postgres/Templates/FnOutboxBatchClaim_pgsql.hbs b/src/DbEx.Postgres/Templates/FnOutboxBatchClaim_pgsql.hbs new file mode 100644 index 0000000..f42c349 --- /dev/null +++ b/src/DbEx.Postgres/Templates/FnOutboxBatchClaim_pgsql.hbs @@ -0,0 +1,125 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +CREATE OR REPLACE FUNCTION "{{OutboxSchema}}"."fn_{{OutboxName}}_batch_claim"( + p_partition_id INTEGER, + p_batch_size INTEGER, + p_lease_id UUID, + p_lease_seconds INTEGER, + p_tenant_id VARCHAR(255) DEFAULT NULL +) +RETURNS TABLE ( + "{{OutboxName}}_id" BIGINT, + "status" SMALLINT, + "destination" VARCHAR(255), + "event" TEXT, + "attempts" INTEGER, + "enqueued_utc" TIMESTAMPTZ, + "available_utc" TIMESTAMPTZ, + "lease_until_utc" TIMESTAMPTZ +) +LANGUAGE plpgsql +AS $$ +DECLARE + _now TIMESTAMPTZ; + _lease_until_utc TIMESTAMPTZ; + _return_code INTEGER; + _effective_tenant_id VARCHAR(255); + _head_id BIGINT; + _blocker_id BIGINT; + _row_count INTEGER; +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Claims the next batch of pending/processing messages for a tenant/partition, marking them as processing with a lease. + * > Returns: + * Result set with claimed batch rows (may be empty). + */ + + -- Set transaction parameters + SET LOCAL lock_timeout = '5s'; + SET LOCAL transaction_isolation = 'read committed'; + + _now := NOW() AT TIME ZONE 'UTC'; + _effective_tenant_id := COALESCE(p_tenant_id, '(none)'); + + -- 1) Acquire a partition lease; exit where unsuccessful. + SELECT * INTO _lease_until_utc, _return_code + FROM "{{OutboxSchema}}"."fn_{{OutboxName}}_lease_acquire"( + p_partition_id, + p_lease_id, + p_lease_seconds, + _effective_tenant_id + ); + + IF _return_code < 0 THEN + RETURN; -- Unable to acquire lease; return empty result set. + END IF; + + -- 2) Claim the next batch (contiguous by {{OutboxName}}_id) for the tenant/partition. + + -- Determine head (first pending/processing) and first blocker (actively leased or not yet available) in a single pass. + -- Any blocker row has status IN (0, 1) by definition, so blocker_id >= head_id is always true; no secondary scan needed. + SELECT + MIN(o."{{OutboxName}}_id") FILTER (WHERE o."status" IN (0, 1)), + MIN(o."{{OutboxName}}_id") FILTER (WHERE + (o."status" = 1 AND o."lease_until_utc" IS NOT NULL AND o."lease_until_utc" > _now) + OR (o."status" = 0 AND o."available_utc" > _now)) + INTO _head_id, _blocker_id + FROM "{{OutboxSchema}}"."{{OutboxName}}" o + WHERE o."tenant_id" = _effective_tenant_id + AND o."partition_id" = p_partition_id + AND o."status" IN (0, 1); + + IF _head_id IS NULL THEN + -- Release the lease. + PERFORM "{{OutboxSchema}}"."fn_{{OutboxName}}_lease_release"(p_lease_id); + RETURN; -- No batch to claim; return empty result set. + END IF; + + -- Claim contiguous run from head to before blocker. + RETURN QUERY + WITH claim_cte AS ( + SELECT + o."{{OutboxName}}_id", o."tenant_id", o."status", o."partition_id", + o."destination", o."event", o."attempts", o."enqueued_utc", + o."available_utc", o."lease_id", o."lease_until_utc" + FROM "{{OutboxSchema}}"."{{OutboxName}}" o + WHERE o."tenant_id" = _effective_tenant_id + AND o."partition_id" = p_partition_id + AND o."{{OutboxName}}_id" >= _head_id + AND (_blocker_id IS NULL OR o."{{OutboxName}}_id" < _blocker_id) + AND ((o."status" = 0 AND o."available_utc" <= _now) + OR (o."status" = 1 AND (o."lease_until_utc" IS NULL OR o."lease_until_utc" <= _now))) + ORDER BY o."{{OutboxName}}_id" + LIMIT p_batch_size + FOR UPDATE SKIP LOCKED + ) + UPDATE "{{OutboxSchema}}"."{{OutboxName}}" o + SET "status" = 1, + "lease_id" = p_lease_id, + "lease_until_utc" = _lease_until_utc + FROM claim_cte + WHERE o."{{OutboxName}}_id" = claim_cte."{{OutboxName}}_id" + RETURNING + o."{{OutboxName}}_id", + o."status", + o."destination", + o."event", + o."attempts", + o."enqueued_utc", + o."available_utc", + o."lease_until_utc"; + + GET DIAGNOSTICS _row_count = ROW_COUNT; + + IF _row_count = 0 THEN + -- Release the lease. + PERFORM "{{OutboxSchema}}"."fn_{{OutboxName}}_lease_release"(p_lease_id); + -- No row updated; return empty result set. + END IF; + +EXCEPTION + WHEN OTHERS THEN + RAISE; -- Re-raise preserves error details to caller. +END +$$; \ No newline at end of file diff --git a/src/DbEx.Postgres/Templates/FnOutboxBatchComplete_pgsql.hbs b/src/DbEx.Postgres/Templates/FnOutboxBatchComplete_pgsql.hbs new file mode 100644 index 0000000..9572612 --- /dev/null +++ b/src/DbEx.Postgres/Templates/FnOutboxBatchComplete_pgsql.hbs @@ -0,0 +1,60 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +CREATE OR REPLACE FUNCTION "{{OutboxSchema}}"."fn_{{OutboxName}}_batch_complete"( + p_lease_id UUID, + p_dequeued_utc TIMESTAMPTZ DEFAULT NULL +) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + _now TIMESTAMPTZ; + _row_count INTEGER; +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Marks a batch as completed by lease_id, releasing the lease and making way for the next batch. + * > Returns: + * 0 = Success. + * -1 = No rows updated (e.g. already completed or invalid lease_id). + * -2 = No batch to claim (e.g. all completed since claim). + * -3 = Unable to acquire lease (e.g. another active batch or transient error). + */ + + -- Set transaction parameters + SET LOCAL lock_timeout = '5s'; + SET LOCAL transaction_isolation = 'read committed'; + + _now := NOW() AT TIME ZONE 'UTC'; + + -- 1) Complete all rows in the batch. + UPDATE "{{OutboxSchema}}"."{{OutboxName}}" AS o + SET "status" = 2, + "lease_id" = NULL, + "lease_until_utc" = NULL, + "dequeued_utc" = COALESCE(p_dequeued_utc, _now) + WHERE o."lease_id" = p_lease_id + AND o."status" = 1; + + GET DIAGNOSTICS _row_count = ROW_COUNT; + + IF _row_count = 0 THEN + RETURN -1; -- No rows updated; already completed or invalid lease_id. + END IF; + + -- 2) Release the partition lease where identified. + BEGIN + PERFORM "{{OutboxSchema}}"."fn_{{OutboxName}}_lease_release"(p_lease_id); + EXCEPTION + WHEN OTHERS THEN + -- Ignore: lease will expire. Don't fail completion. + NULL; + END; + + RETURN 0; + +EXCEPTION + WHEN OTHERS THEN + RAISE; -- Re-raise preserves error details to caller. +END +$$; \ No newline at end of file diff --git a/src/DbEx.Postgres/Templates/FnOutboxEnqueue_pgsql.hbs b/src/DbEx.Postgres/Templates/FnOutboxEnqueue_pgsql.hbs new file mode 100644 index 0000000..3bea80c --- /dev/null +++ b/src/DbEx.Postgres/Templates/FnOutboxEnqueue_pgsql.hbs @@ -0,0 +1,41 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +CREATE OR REPLACE FUNCTION "{{OutboxSchema}}"."fn_{{OutboxName}}_enqueue"( + p_partition_id INTEGER, + p_destination VARCHAR(255), + p_event TEXT, + p_enqueued_utc TIMESTAMPTZ DEFAULT NULL, + p_tenant_id VARCHAR(255) DEFAULT NULL, + p_available_utc TIMESTAMPTZ DEFAULT NULL +) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + _now TIMESTAMPTZ; + _effective_tenant_id VARCHAR(255); +BEGIN + /* + * This file is automatically generated; any changes will be lost. + */ + + _now := NOW() AT TIME ZONE 'UTC'; + _effective_tenant_id := COALESCE(p_tenant_id, '(none)'); + + INSERT INTO "{{OutboxSchema}}"."{{OutboxName}}" ( + "tenant_id", + "partition_id", + "destination", + "event", + "enqueued_utc", + "available_utc" + ) + VALUES ( + _effective_tenant_id, + p_partition_id, + p_destination, + p_event, + COALESCE(p_enqueued_utc, _now), + COALESCE(p_available_utc, p_enqueued_utc, _now) + ); +END +$$; \ No newline at end of file diff --git a/src/DbEx.Postgres/Templates/FnOutboxLeaseAcquire_pgsql.hbs b/src/DbEx.Postgres/Templates/FnOutboxLeaseAcquire_pgsql.hbs new file mode 100644 index 0000000..c27e2f4 --- /dev/null +++ b/src/DbEx.Postgres/Templates/FnOutboxLeaseAcquire_pgsql.hbs @@ -0,0 +1,62 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +CREATE OR REPLACE FUNCTION "{{OutboxSchema}}"."fn_{{OutboxName}}_lease_acquire"( + p_partition_id INTEGER, + p_lease_id UUID, + p_lease_seconds INTEGER, + p_tenant_id VARCHAR(255) DEFAULT NULL, + OUT p_lease_until_utc TIMESTAMPTZ, + OUT p_return_code INTEGER +) +LANGUAGE plpgsql +AS $$ +DECLARE + _now TIMESTAMPTZ; + _until TIMESTAMPTZ; + _effective_tenant_id VARCHAR(255); + _row_count INTEGER; +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Attempts to acquire a lease for a tenant/partition, returning success status and lease until timestamp. + * > Returns: + * return_code 0 = Lease acquired; caller may proceed with batch claim. + * return_code -1 = Lease not acquired; caller should backoff and retry. + * + * Notes: + * - The function is resilient to transient errors and will return -1 where lease acquisition is unsuccessful, including where another active lease exists or where a transient error occurs (e.g. lock timeout). + * - The caller should implement an appropriate retry/backoff strategy where -1 is returned, including randomization to avoid thundering herd issues. + */ + + -- Set transaction parameters + SET LOCAL lock_timeout = '5s'; + SET LOCAL transaction_isolation = 'read committed'; + + _now := NOW() AT TIME ZONE 'UTC'; + _until := _now + (p_lease_seconds || ' seconds')::INTERVAL; + _effective_tenant_id := COALESCE(p_tenant_id, '(none)'); + + -- 1) Seed the row if it does not exist, and acquire the lease if expired/empty; single atomic statement. + INSERT INTO "{{OutboxSchema}}"."{{OutboxName}}_lease" ("tenant_id", "partition_id", "lease_id", "lease_until_utc") + VALUES (_effective_tenant_id, p_partition_id, p_lease_id, _until) + ON CONFLICT ("tenant_id", "partition_id") DO UPDATE + SET "lease_id" = p_lease_id, + "lease_until_utc" = _until + WHERE "{{OutboxName}}_lease"."lease_until_utc" IS NULL OR "{{OutboxName}}_lease"."lease_until_utc" <= _now; + + -- 2) Return lease success status. + GET DIAGNOSTICS _row_count = ROW_COUNT; + + IF _row_count = 1 THEN + p_lease_until_utc := _until; + p_return_code := 0; -- Lease successful. + ELSE + p_lease_until_utc := NULL; + p_return_code := -1; -- Lease unsuccessful. + END IF; + +EXCEPTION + WHEN OTHERS THEN + RAISE; -- Re-raise preserves error details to caller. +END +$$; \ No newline at end of file diff --git a/src/DbEx.Postgres/Templates/FnOutboxLeaseRelease_pgsql.hbs b/src/DbEx.Postgres/Templates/FnOutboxLeaseRelease_pgsql.hbs new file mode 100644 index 0000000..c8ada87 --- /dev/null +++ b/src/DbEx.Postgres/Templates/FnOutboxLeaseRelease_pgsql.hbs @@ -0,0 +1,46 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +CREATE OR REPLACE FUNCTION "{{OutboxSchema}}"."fn_{{OutboxName}}_lease_release"( + p_lease_id UUID +) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + _row_count INTEGER; +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Releases a lease by lease_id, making way for the next batch. + * > Returns: + * 0 = Success; lease released and available for next claim. + * -1 = No rows updated (e.g. already released or invalid lease_id). + * + * Notes: + * - The function is resilient to transient errors and will return -1 where release is unsuccessful, including where the lease is already released or where a transient error occurs (e.g. lock timeout). + */ + + -- Set transaction parameters + SET LOCAL lock_timeout = '5s'; + SET LOCAL transaction_isolation = 'read committed'; + + -- 1) Release lease where leasee. + UPDATE "{{OutboxSchema}}"."{{OutboxName}}_lease" AS ol + SET "lease_id" = NULL, + "lease_until_utc" = NULL + WHERE ol."lease_id" = p_lease_id; + + -- 2) Check row count and return release success status. + GET DIAGNOSTICS _row_count = ROW_COUNT; + + IF _row_count = 1 THEN + RETURN 0; -- Release successful. + END IF; + + RETURN -1; -- Release unsuccessful. + +EXCEPTION + WHEN OTHERS THEN + RAISE; -- Re-raise preserves error details to caller. +END +$$; \ No newline at end of file diff --git a/src/DbEx/CodeGen/Config/TableConfig.cs b/src/DbEx/CodeGen/Config/TableConfig.cs index f271dd2..1208ca2 100644 --- a/src/DbEx/CodeGen/Config/TableConfig.cs +++ b/src/DbEx/CodeGen/Config/TableConfig.cs @@ -29,14 +29,14 @@ public class TableConfig : ConfigBase, IByConventi /// Gets or sets the list of database columns to include explicitly. /// [JsonPropertyName("includeColumns")] - [CodeGenProperty("Columns", Title = "The list of database columns to include specifically.", Description = "All columns are included by default; this provides a means to simply select those for inclusion.")] + [CodeGenPropertyCollection("Columns", Title = "The list of database columns to include specifically.", Description = "All columns are included by default; this provides a means to simply select those for inclusion.")] public List? IncludeColumns { get; set; } /// /// Gets or sets the list of database column names to exclude explicitly. /// [JsonPropertyName("excludeColumns")] - [CodeGenProperty("Columns", Title = "The list of database columns to exclude specifically.", Description = "All columns are included by default; this provides a means to simply select those for exclusion. A single item of '*' indicates all columns are to be excluded.")] + [CodeGenPropertyCollection("Columns", Title = "The list of database columns to exclude specifically.", Description = "All columns are included by default; this provides a means to simply select those for exclusion. A single item of '*' indicates all columns are to be excluded.")] public List? ExcludeColumns { get; set; } /// diff --git a/src/DbEx/DatabaseSchemaConfig.cs b/src/DbEx/DatabaseSchemaConfig.cs index 7327e4f..3b58989 100644 --- a/src/DbEx/DatabaseSchemaConfig.cs +++ b/src/DbEx/DatabaseSchemaConfig.cs @@ -6,7 +6,8 @@ /// The owning . /// Indicates whether the database supports per-database schema-based separation. /// The default schema name used where not explicitly specified. -public abstract class DatabaseSchemaConfig(DatabaseMigrationBase migration, bool supportsSchema = false, string? defaultSchema = null) +/// The suffix of the migration script files (e.g. "sql"). +public abstract class DatabaseSchemaConfig(DatabaseMigrationBase migration, bool supportsSchema = false, string? defaultSchema = null, string? scriptSuffix = "sql") { private readonly string? _defaultSchema = defaultSchema; @@ -28,6 +29,11 @@ public abstract class DatabaseSchemaConfig(DatabaseMigrationBase migration, bool ? (_defaultSchema ?? throw new InvalidOperationException("The database supports per-database schema-based separation and a default is required.")) : throw new NotSupportedException("The database does not support per-database schema-based separation."); + /// + /// Gets or sets the suffix of the migration script files (e.g. "sql"). + /// + public string ScriptSuffix { get; } = scriptSuffix.ThrowIfNull(nameof(scriptSuffix)); + /// /// Gets the suffix of the identifier column. /// diff --git a/src/DbEx/Migration/Database.cs b/src/DbEx/Migration/Database.cs index 88c3cc0..00fa13d 100644 --- a/src/DbEx/Migration/Database.cs +++ b/src/DbEx/Migration/Database.cs @@ -69,7 +69,7 @@ public async Task> SelectSchemaAsync(DatabaseMigrationBase m // Get all the tables and their columns. var probeAssemblies = new[] { migration.SchemaConfig.GetType().Assembly, typeof(DatabaseExtensions).Assembly }; - using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableAndColumns.sql", probeAssemblies); + using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"SelectTableAndColumns.{migration.SchemaConfig.ScriptSuffix}", probeAssemblies); await SqlStatement(await ReadSqlAsync(migration, sr, cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => { if (!migration.SchemaConfig.SupportsSchema && dr.GetValue("TABLE_SCHEMA") != migration.DatabaseName) @@ -101,7 +101,7 @@ await SqlStatement(await ReadSqlAsync(migration, sr, cancellationToken).Configur return tables; // Configure all the single column primary and unique constraints. - using var sr2 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTablePrimaryKey.sql", probeAssemblies); + using var sr2 = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"SelectTablePrimaryKey.{migration.SchemaConfig.ScriptSuffix}", probeAssemblies); var pks = await SqlStatement(await ReadSqlAsync(migration, sr2, cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new { ConstraintName = dr.GetValue("CONSTRAINT_NAME"), diff --git a/src/DbEx/Migration/DatabaseJournal.cs b/src/DbEx/Migration/DatabaseJournal.cs index a609500..d026101 100644 --- a/src/DbEx/Migration/DatabaseJournal.cs +++ b/src/DbEx/Migration/DatabaseJournal.cs @@ -28,7 +28,7 @@ public async Task EnsureExistsAsync(CancellationToken cancellationToken = defaul if (_journalExists) return; - using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalExists.sql", [.. Migrator.ArtefactResourceAssemblies])!; + using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalExists.{Migrator.SchemaConfig.ScriptSuffix}", [.. Migrator.ArtefactResourceAssemblies])!; var exists = await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr.ReadToEnd())).ScalarAsync(cancellationToken).ConfigureAwait(false); if (exists != null) { @@ -36,7 +36,7 @@ public async Task EnsureExistsAsync(CancellationToken cancellationToken = defaul return; } - using var sr2 = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalCreate.sql", [.. Migrator.ArtefactResourceAssemblies])!; + using var sr2 = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalCreate.{Migrator.SchemaConfig.ScriptSuffix}", [.. Migrator.ArtefactResourceAssemblies])!; await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr2.ReadToEnd())).NonQueryAsync(cancellationToken).ConfigureAwait(false); Migrator.Logger.LogInformation(" *Journal table did not exist within the database and was automatically created."); @@ -49,7 +49,7 @@ public async Task AuditScriptExecutionAsync(DatabaseMigrationScript script, Canc { await EnsureExistsAsync(cancellationToken).ConfigureAwait(false); - using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalAudit.sql", [.. Migrator.ArtefactResourceAssemblies])!; + using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalAudit.{Migrator.SchemaConfig.ScriptSuffix}", [.. Migrator.ArtefactResourceAssemblies])!; await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr.ReadToEnd())) .Param("@scriptname", script.Name) .Param("@applied", DateTime.UtcNow) @@ -61,7 +61,7 @@ public async Task> GetExecutedScriptsAsync(CancellationToken { await EnsureExistsAsync(cancellationToken).ConfigureAwait(false); - using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalPrevious.sql", [.. Migrator.ArtefactResourceAssemblies])!; + using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader($"JournalPrevious.{Migrator.SchemaConfig.ScriptSuffix}", [.. Migrator.ArtefactResourceAssemblies])!; return await Migrator.Database.SqlStatement(Migrator.ReplaceSqlRuntimeParameters(sr.ReadToEnd())).SelectQueryAsync(dr => dr.GetValue("scriptname")!, cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/DbEx/Migration/DatabaseMigrationBase.cs b/src/DbEx/Migration/DatabaseMigrationBase.cs index 4a8019a..ca0f121 100644 --- a/src/DbEx/Migration/DatabaseMigrationBase.cs +++ b/src/DbEx/Migration/DatabaseMigrationBase.cs @@ -380,7 +380,7 @@ protected virtual async Task ExecuteScriptsAsync(IEnumerableThe @DatabaseName literal within the resulting (embedded resource) command is replaced by the using a (i.e. not database parameterized as not all databases support). protected virtual async Task DatabaseExistsAsync(CancellationToken cancellationToken = default) { - using var sr = GetRequiredResourcesStreamReader($"DatabaseExists.sql", [.. ArtefactResourceAssemblies]); + using var sr = GetRequiredResourcesStreamReader($"DatabaseExists.{SchemaConfig.ScriptSuffix}", [.. ArtefactResourceAssemblies]); var name = await MasterDatabase.SqlStatement(ReplaceSqlRuntimeParameters(sr.ReadToEnd())).ScalarAsync(cancellationToken); return name != null; } @@ -403,7 +403,7 @@ protected virtual async Task DatabaseDropAsync(CancellationToken cancellat return true; } - using var sr = GetRequiredResourcesStreamReader($"DatabaseDrop.sql", [.. ArtefactResourceAssemblies]); + using var sr = GetRequiredResourcesStreamReader($"DatabaseDrop.{SchemaConfig.ScriptSuffix}", [.. ArtefactResourceAssemblies]); await MasterDatabase.SqlStatement(ReplaceSqlRuntimeParameters(sr.ReadToEnd())).NonQueryAsync(cancellationToken); Logger.LogInformation("{Content}", $" Database '{DatabaseName}' dropped."); @@ -428,17 +428,17 @@ protected virtual async Task DatabaseCreateAsync(CancellationToken cancell return true; } - using var sr = GetRequiredResourcesStreamReader($"DatabaseCreate.sql", [.. ArtefactResourceAssemblies]); + using var sr = GetRequiredResourcesStreamReader($"DatabaseCreate.{SchemaConfig.ScriptSuffix}", [.. ArtefactResourceAssemblies]); await MasterDatabase.SqlStatement(ReplaceSqlRuntimeParameters(sr.ReadToEnd())).NonQueryAsync(cancellationToken); Logger.LogInformation("{Content}", $" Database '{DatabaseName}' did not exist and was created."); Logger.LogInformation("{Content}", string.Empty); - Logger.LogInformation("{Content}", $" Probing for '{OnDatabaseCreateName}' embedded resources: {string.Join(", ", GetNamespacesWithSuffix($"{MigrationsNamespace}.*.sql"))}"); + Logger.LogInformation("{Content}", $" Probing for '{OnDatabaseCreateName}' embedded resources: {string.Join(", ", GetNamespacesWithSuffix($"{MigrationsNamespace}.*.{SchemaConfig.ScriptSuffix}"))}"); var scripts = new List(); foreach (var ass in Args.ProbeAssemblies) { - foreach (var name in ass.Assembly.GetManifestResourceNames().Where(rn => Namespaces.Any(ns => rn.StartsWith($"{ns}.{MigrationsNamespace}.", StringComparison.InvariantCulture) && rn.EndsWith($".{OnDatabaseCreateName}.sql", StringComparison.InvariantCultureIgnoreCase))).OrderBy(x => x)) + foreach (var name in ass.Assembly.GetManifestResourceNames().Where(rn => Namespaces.Any(ns => rn.StartsWith($"{ns}.{MigrationsNamespace}.", StringComparison.InvariantCulture) && rn.EndsWith($".{OnDatabaseCreateName}.{SchemaConfig.ScriptSuffix}", StringComparison.InvariantCultureIgnoreCase))).OrderBy(x => x)) { scripts.Add(new DatabaseMigrationScript(this, ass.Assembly, name) { RunAlways = true }); } @@ -459,7 +459,7 @@ protected virtual async Task DatabaseCreateAsync(CancellationToken cancell /// private async Task DatabaseMigrateAsync(CancellationToken cancellationToken) { - Logger.LogInformation("{Content}", $" Probing for embedded resources: {string.Join(", ", GetNamespacesWithSuffix($"{MigrationsNamespace}.*.sql"))}"); + Logger.LogInformation("{Content}", $" Probing for embedded resources: {string.Join(", ", GetNamespacesWithSuffix($"{MigrationsNamespace}.*.{SchemaConfig.ScriptSuffix}"))}"); // Function to add the script in a consistent manner. void AddScript(List scripts, Assembly assembly, string name) @@ -469,8 +469,8 @@ void AddScript(List scripts, Assembly assembly, string return; // Determine run order and add script to list. - var order = name.EndsWith(".pre.deploy.sql", StringComparison.InvariantCultureIgnoreCase) ? 1 : - name.EndsWith(".post.deploy.sql", StringComparison.InvariantCultureIgnoreCase) ? 3 : 2; + var order = name.EndsWith($".pre.deploy.{SchemaConfig.ScriptSuffix}", StringComparison.InvariantCultureIgnoreCase) ? 1 : + name.EndsWith($".post.deploy.{SchemaConfig.ScriptSuffix}", StringComparison.InvariantCultureIgnoreCase) ? 3 : 2; scripts.Add(new DatabaseMigrationScript(this, assembly, name) { GroupOrder = order, RunAlways = order != 2 }); }; @@ -482,7 +482,7 @@ void AddScript(List scripts, Assembly assembly, string foreach (var name in ass.Assembly.GetManifestResourceNames().Where(rn => Namespaces.Any(ns => rn.StartsWith($"{ns}.{MigrationsNamespace}.", StringComparison.InvariantCulture))).OrderBy(x => x)) { // Ignore any/all database create scripts. - if (name.EndsWith($".{OnDatabaseCreateName}.sql", StringComparison.InvariantCultureIgnoreCase)) + if (name.EndsWith($".{OnDatabaseCreateName}.{SchemaConfig.ScriptSuffix}", StringComparison.InvariantCultureIgnoreCase)) continue; AddScript(scripts, ass.Assembly, name); @@ -574,11 +574,11 @@ private async Task DatabaseSchemaAsync(CancellationToken cancellationToken if (dir != null && dir.Exists) { var di = new DirectoryInfo(Path.Combine(dir.FullName, SchemaNamespace)); - Logger.LogInformation("{Content}", $" Probing for files (recursively): {Path.Combine(di.FullName, "*", "*.sql")}"); + Logger.LogInformation("{Content}", $" Probing for files (recursively): {Path.Combine(di.FullName, "*", $"*.{SchemaConfig.ScriptSuffix}")}"); if (di.Exists) { - foreach (var fi in di.GetFiles("*.sql", SearchOption.AllDirectories)) + foreach (var fi in di.GetFiles($"*.{SchemaConfig.ScriptSuffix}", SearchOption.AllDirectories)) { var rn = $"{fi.FullName[((dir.Parent?.FullName.Length + 1) ?? 0)..]}".Replace(' ', '_').Replace('-', '_').Replace('\\', '.').Replace('/', '.'); scripts.Add(new DatabaseMigrationScript(this, fi, rn)); @@ -587,13 +587,13 @@ private async Task DatabaseSchemaAsync(CancellationToken cancellationToken } // Get all the resources from the assemblies. - Logger.LogInformation("{Content}", $" Probing for embedded resources: {string.Join(", ", GetNamespacesWithSuffix($"{SchemaNamespace}.*.sql"))}"); + Logger.LogInformation("{Content}", $" Probing for embedded resources: {string.Join(", ", GetNamespacesWithSuffix($"{SchemaNamespace}.*.{SchemaConfig.ScriptSuffix}"))}"); foreach (var ass in Args.ProbeAssemblies) { foreach (var rn in ass.Assembly.GetManifestResourceNames().OrderBy(x => x)) { - // Filter on schema namespace prefix and suffix of '.sql'. - if (!(Namespaces.Any(x => rn.StartsWith($"{x}.{SchemaNamespace}.", StringComparison.InvariantCulture) && rn.EndsWith(".sql", StringComparison.InvariantCultureIgnoreCase)))) + // Filter on schema namespace prefix and suffix of '.'. + if (!(Namespaces.Any(x => rn.StartsWith($"{x}.{SchemaNamespace}.", StringComparison.InvariantCulture) && rn.EndsWith($".{SchemaConfig.ScriptSuffix}", StringComparison.InvariantCultureIgnoreCase)))) continue; // Filter out any picked up from file system probe above. @@ -743,7 +743,7 @@ protected virtual async Task DatabaseResetAsync(CancellationToken cancella return true; } - using var sr = GetRequiredResourcesStreamReader($"DatabaseReset_sql", [.. ArtefactResourceAssemblies], StreamLocator.HandlebarsExtensions); + using var sr = GetRequiredResourcesStreamReader($"DatabaseReset_{SchemaConfig.ScriptSuffix}", [.. ArtefactResourceAssemblies], StreamLocator.HandlebarsExtensions); var cg = new HandlebarsCodeGenerator(sr); var sql = cg.Generate(delete); @@ -789,7 +789,7 @@ private async Task DatabaseDataAsync(CancellationToken cancellationToken) foreach (var dns in ass.DataNamespaces) { // Filter on schema namespace prefix and supported suffixes. - if (!Namespaces.Any(x => rn.StartsWith($"{x}.{dns}.", StringComparison.InvariantCulture) && (rn.EndsWith(".sql", StringComparison.InvariantCultureIgnoreCase) + if (!Namespaces.Any(x => rn.StartsWith($"{x}.{dns}.", StringComparison.InvariantCulture) && (rn.EndsWith($".{SchemaConfig.ScriptSuffix}", StringComparison.InvariantCultureIgnoreCase) || rn.EndsWith(".yaml", StringComparison.InvariantCultureIgnoreCase) || rn.EndsWith(".yml", StringComparison.InvariantCultureIgnoreCase) || rn.EndsWith(".json", StringComparison.InvariantCultureIgnoreCase) || rn.EndsWith(".jsn", StringComparison.InvariantCultureIgnoreCase)))) continue; @@ -816,7 +816,7 @@ private async Task DatabaseDataAsync(CancellationToken cancellationToken) { using var sr = new StreamReader(item.Assembly.GetManifestResourceStream(item.ResourceName)!); - if (item.ResourceName.EndsWith(".sql", StringComparison.InvariantCultureIgnoreCase)) + if (item.ResourceName.EndsWith($".{SchemaConfig.ScriptSuffix}", StringComparison.InvariantCultureIgnoreCase)) { // Execute the SQL script directly. Logger.LogInformation("{Content}", string.Empty); @@ -865,7 +865,7 @@ protected virtual async Task DatabaseDataAsync(List dataTables, // Cache the compiled code-gen template. if (_dataCodeGen == null) { - using var sr = GetRequiredResourcesStreamReader($"DatabaseData_sql", [.. ArtefactResourceAssemblies], StreamLocator.HandlebarsExtensions); + using var sr = GetRequiredResourcesStreamReader($"DatabaseData_{SchemaConfig.ScriptSuffix}", [.. ArtefactResourceAssemblies], StreamLocator.HandlebarsExtensions); #if NET7_0_OR_GREATER _dataCodeGen = new HandlebarsCodeGenerator(await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false)); #else @@ -941,7 +941,7 @@ public async Task CreateScriptAsync(string? name = null, IDictionary CreateScriptInternalAsync(string? name, IDictionary? parameters, CancellationToken cancellationToken) { name ??= "Default"; - var rn = $"Script{name}_sql"; + var rn = $"Script{name}_{SchemaConfig.ScriptSuffix}"; // Find the resource. using var sr = StreamLocator.GetResourcesStreamReader(rn, [.. ArtefactResourceAssemblies], StreamLocator.HandlebarsExtensions).StreamReader; @@ -987,7 +987,7 @@ private async Task CreateScriptInternalAsync(string? name, IDictionary ExecuteSqlStatementsInternalAsync(string[]? statements, if (File.Exists(statements[i])) scripts.Add(new DatabaseMigrationScript(this, new FileInfo(statements[i]), statements[i])); else - scripts.Add(new DatabaseMigrationScript(this, statements[i], $"{sn}{i + 1:000}.sql")); + scripts.Add(new DatabaseMigrationScript(this, statements[i], $"{sn}{i + 1:000}.{SchemaConfig.ScriptSuffix}")); } return await ExecuteScriptsAsync(scripts, false, cancellationToken).ConfigureAwait(false); diff --git a/tests/DbEx.Test.MySqlConsole/DbEx.Test.MySqlConsole.csproj b/tests/DbEx.Test.MySqlConsole/DbEx.Test.MySqlConsole.csproj index 66b6973..6e67d1c 100644 --- a/tests/DbEx.Test.MySqlConsole/DbEx.Test.MySqlConsole.csproj +++ b/tests/DbEx.Test.MySqlConsole/DbEx.Test.MySqlConsole.csproj @@ -9,20 +9,15 @@ - - - - - - - - - - + + + + + diff --git a/tests/DbEx.Test.MySqlConsole/Migrations/002-create-test-contact-type-table.sql b/tests/DbEx.Test.MySqlConsole/Migrations/002-create-test-contact-type-table.mysql similarity index 100% rename from tests/DbEx.Test.MySqlConsole/Migrations/002-create-test-contact-type-table.sql rename to tests/DbEx.Test.MySqlConsole/Migrations/002-create-test-contact-type-table.mysql diff --git a/tests/DbEx.Test.MySqlConsole/Migrations/003-create-test-gender-table.sql b/tests/DbEx.Test.MySqlConsole/Migrations/003-create-test-gender-table.mysql similarity index 100% rename from tests/DbEx.Test.MySqlConsole/Migrations/003-create-test-gender-table.sql rename to tests/DbEx.Test.MySqlConsole/Migrations/003-create-test-gender-table.mysql diff --git a/tests/DbEx.Test.MySqlConsole/Migrations/004-create-test-contact-table.sql b/tests/DbEx.Test.MySqlConsole/Migrations/004-create-test-contact-table.mysql similarity index 100% rename from tests/DbEx.Test.MySqlConsole/Migrations/004-create-test-contact-table.sql rename to tests/DbEx.Test.MySqlConsole/Migrations/004-create-test-contact-table.mysql diff --git a/tests/DbEx.Test.MySqlConsole/Migrations/005-create-test-multipk-table.sql b/tests/DbEx.Test.MySqlConsole/Migrations/005-create-test-multipk-table.mysql similarity index 100% rename from tests/DbEx.Test.MySqlConsole/Migrations/005-create-test-multipk-table.sql rename to tests/DbEx.Test.MySqlConsole/Migrations/005-create-test-multipk-table.mysql diff --git a/tests/DbEx.Test.MySqlConsole/Schema/spGetContact.sql b/tests/DbEx.Test.MySqlConsole/Schema/spGetContact.mysql similarity index 100% rename from tests/DbEx.Test.MySqlConsole/Schema/spGetContact.sql rename to tests/DbEx.Test.MySqlConsole/Schema/spGetContact.mysql diff --git a/tests/DbEx.Test.PostgresConsole/DbEx.Test.PostgresConsole.csproj b/tests/DbEx.Test.PostgresConsole/DbEx.Test.PostgresConsole.csproj index 4984900..091815c 100644 --- a/tests/DbEx.Test.PostgresConsole/DbEx.Test.PostgresConsole.csproj +++ b/tests/DbEx.Test.PostgresConsole/DbEx.Test.PostgresConsole.csproj @@ -8,20 +8,15 @@ - - - - - - - - - - + + + + + diff --git a/tests/DbEx.Test.PostgresConsole/Migrations/002-create-test-contact-type-table.sql b/tests/DbEx.Test.PostgresConsole/Migrations/002-create-test-contact-type-table.pgsql similarity index 100% rename from tests/DbEx.Test.PostgresConsole/Migrations/002-create-test-contact-type-table.sql rename to tests/DbEx.Test.PostgresConsole/Migrations/002-create-test-contact-type-table.pgsql diff --git a/tests/DbEx.Test.PostgresConsole/Migrations/003-create-test-gender-table.sql b/tests/DbEx.Test.PostgresConsole/Migrations/003-create-test-gender-table.pgsql similarity index 100% rename from tests/DbEx.Test.PostgresConsole/Migrations/003-create-test-gender-table.sql rename to tests/DbEx.Test.PostgresConsole/Migrations/003-create-test-gender-table.pgsql diff --git a/tests/DbEx.Test.PostgresConsole/Migrations/004-create-test-contact-table.sql b/tests/DbEx.Test.PostgresConsole/Migrations/004-create-test-contact-table.pgsql similarity index 100% rename from tests/DbEx.Test.PostgresConsole/Migrations/004-create-test-contact-table.sql rename to tests/DbEx.Test.PostgresConsole/Migrations/004-create-test-contact-table.pgsql diff --git a/tests/DbEx.Test.PostgresConsole/Migrations/005-create-test-multipk-table.sql b/tests/DbEx.Test.PostgresConsole/Migrations/005-create-test-multipk-table.pgsql similarity index 100% rename from tests/DbEx.Test.PostgresConsole/Migrations/005-create-test-multipk-table.sql rename to tests/DbEx.Test.PostgresConsole/Migrations/005-create-test-multipk-table.pgsql diff --git a/tests/DbEx.Test.PostgresConsole/Schema/spGetContact.sql b/tests/DbEx.Test.PostgresConsole/Schema/spGetContact.pgsql similarity index 100% rename from tests/DbEx.Test.PostgresConsole/Schema/spGetContact.sql rename to tests/DbEx.Test.PostgresConsole/Schema/spGetContact.pgsql diff --git a/tools/DbEx.Tooling.Console/DbEx.Tooling.Console.csproj b/tools/DbEx.Tooling.Console/DbEx.Tooling.Console.csproj new file mode 100644 index 0000000..59e51c2 --- /dev/null +++ b/tools/DbEx.Tooling.Console/DbEx.Tooling.Console.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/tools/DbEx.Tooling.Console/Program.cs b/tools/DbEx.Tooling.Console/Program.cs new file mode 100644 index 0000000..d62d7a0 --- /dev/null +++ b/tools/DbEx.Tooling.Console/Program.cs @@ -0,0 +1,24 @@ +using OnRamp.Utility; +using DbEx.CodeGen.Config; + +namespace DbEx.Tooling.Console; + +public static class Program +{ + private static void Main(string[] args) + { + if (args.Length == 1) + { + switch (args[0].ToUpperInvariant()) + { + case "--GENERATE-JSON-SCHEMA": + JsonSchemaGenerator.Generate("../../schema/dbex.json", "JSON Schema for DbEx code-generation (https://github.com/avanade/dbex)."); + break; + + case "--GENERATE-DOC-MARKDOWN": + // TODO: MarkdownDocumentationGenerator.Generate() + break; + } + } + } +} \ No newline at end of file diff --git a/tools/DbEx.Tooling.Console/generate.ps1 b/tools/DbEx.Tooling.Console/generate.ps1 new file mode 100644 index 0000000..078b078 --- /dev/null +++ b/tools/DbEx.Tooling.Console/generate.ps1 @@ -0,0 +1 @@ +dotnet run --framework net10.0 --no-launch-profile -- --generate-json-schema \ No newline at end of file From e2aed35a205af16a014210ea149bc33664d46534 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 11 May 2026 11:24:27 -0700 Subject: [PATCH 07/11] Wrap update v3. --- CHANGELOG.md | 12 ++++++------ Common.targets | 2 +- schema/dbex.json | 5 +++++ .../Templates/FnOutboxBatchComplete_pgsql.hbs | 2 -- src/DbEx/CodeGen/Config/ColumnConfig.cs | 7 +++++++ src/DbEx/Templates/EfModel_cs.hbs | 2 +- tests/DbEx.Test.Console/dbex.yaml | 4 +++- tests/DbEx.Test.Empty/Persistence/Person.g.cs | 2 +- 8 files changed, 24 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb15371..8b7df57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,23 +2,23 @@ Represents the **NuGet** versions. -## v3.0.0 [preview-only; subject to change] -All internal dependencies to [`CoreEx`](https://github.com/avanade/coreex) have been removed. This is intended to further generalize the capabilities of `DbEx`; but more importantly, break the circular dependency reference between the two repositories. -- *Enhancement:* Added `net10.0` support and updated all related package dependencies to latest. Supports only `net8.0`, `net9.0` and `net10.0`. +## v3.0.0 +All internal dependencies to [`CoreEx`](https://github.com/avanade/coreex) have been removed. This is intended to further generalize the capabilities of `DbEx`; but more importantly, break the pseudo circular dependency reference between the two repositories. +- *Enhancement:* Added `net10.0` support and updated all related package dependencies to latest; now supports only `net8.0`, `net9.0` and `net10.0`. - *Enhancement:* List of key **breaking changes** as follows: - `DatabaseSchemaConfig.CreatedDate` renamed to `DatabaseSchemaConfig.CreatedOn`. - `DatabaseSchemaConfig.UpdatedDate` renamed to `DatabaseSchemaConfig.UpdatedOn`. - `MigrationArgsBase.CreatedDateColumnName` renamed to `MigrationArgsBase.CreatedOnColumnName`. - `MigrationArgsBase.UpdatedDateColumnName` renamed to `MigrationArgsBase.UpdatedOnColumnName`. - `DateTimeOffset` is the preferred .NET type for date/time auditing/timestamping. -- *Enhancement:* Added script suffix to discern the type of script; e.g. `*.sql`, `*.pgsql` and `*.mysql`, etc. This is a standard convention-based approach to enable support for multiple databases within the same project/assembly, specifically the likes of intellisense. - - As the name suffix has changed, the existing convention-based discovery of scripts will not find any scripts until they have been renamed to include the suffix; e.g. `MyScript.sql` for SQL Server, `MyScript.pgsql` for PostgreSQL and `MyScript.mysql` for MySQL. +- *Enhancement:* Added script suffix to discern the database-type; e.g. `*.sql` (SQL Server), `*.pgsql` (PostgreSQL) and `*.mysql` (MySQL), etc. This is a standard convention-based approach to enable support for multiple databases within the same project/assembly, specifically the likes of intellisense. Breaking change implications: + - As the name suffix has changed, the existing convention-based discovery of scripts will not find any scripts until they have been renamed to include the correct suffix; e.g. `MyScript.sql`, `MyScript.pgsql` and `MyScript.mysql`. - Additionally, existing journal entries will not be found as the script name is used as the journal identifier; i.e. the existing journal entries will need to be updated to include the updated suffix. - *Enhancement:* Introduced basic code-generation (leverages [`OnRamp`](https://github.com/avanade/onramp)). - Entity Framework (EF) convention-based model and model-builder code generation added (all supported databases included). - Transactional `Outbox` and corresponding `OutboxLease` code-generation added (SQL Server and PostgreSQL only). - The existence of the code-generation configuration file `dbex.yaml` is required to enable. - - Added `dbex.yaml` support for `$schema` reference; see [`dbex.json`](./schema/dbex.json). + - Added `dbex.yaml` support for `$schema` reference; see [`dbex.json`](./schema/dbex.json) (JSON-schema). The enhancements have been made in a manner to maximize backwards compatibility with previous versions of `DbEx` where possible; however, some breaking changes were unfortunately unavoidable (and made to improve overall). diff --git a/Common.targets b/Common.targets index 6adb0f3..c341773 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@ - 3.0.0-preview-4 + 3.0.0 preview Avanade Avanade diff --git a/schema/dbex.json b/schema/dbex.json index 5b9d68e..8f2d87c 100644 --- a/schema/dbex.json +++ b/schema/dbex.json @@ -215,6 +215,11 @@ "type": "string", "title": "The .NET value converter source code for the column.", "description": "Defaults to null. This must be valid C# source code as it is applied as-is." + }, + "default": { + "type": "string", + "title": "The .NET default value for the column\u0027s property.", + "description": "Defaults to null. This must be valid C# source code as it is applied as-is." } }, "required": [ diff --git a/src/DbEx.Postgres/Templates/FnOutboxBatchComplete_pgsql.hbs b/src/DbEx.Postgres/Templates/FnOutboxBatchComplete_pgsql.hbs index 9572612..15d4773 100644 --- a/src/DbEx.Postgres/Templates/FnOutboxBatchComplete_pgsql.hbs +++ b/src/DbEx.Postgres/Templates/FnOutboxBatchComplete_pgsql.hbs @@ -17,8 +17,6 @@ BEGIN * > Returns: * 0 = Success. * -1 = No rows updated (e.g. already completed or invalid lease_id). - * -2 = No batch to claim (e.g. all completed since claim). - * -3 = Unable to acquire lease (e.g. another active batch or transient error). */ -- Set transaction parameters diff --git a/src/DbEx/CodeGen/Config/ColumnConfig.cs b/src/DbEx/CodeGen/Config/ColumnConfig.cs index 0fb3d3f..27500ae 100644 --- a/src/DbEx/CodeGen/Config/ColumnConfig.cs +++ b/src/DbEx/CodeGen/Config/ColumnConfig.cs @@ -36,6 +36,13 @@ public class ColumnConfig : ConfigBase [CodeGenProperty("Entity Framework", Title = "The .NET value converter source code for the column.", Description = "Defaults to null. This must be valid C# source code as it is applied as-is.")] public string? ValueConverter { get; set; } + /// + /// Gets or sets the .NET default value for the column's property. + /// + [JsonPropertyName("default")] + [CodeGenProperty("Entity Framework", Title = "The .NET default value for the column's property.", Description = "Defaults to null. This must be valid C# source code as it is applied as-is.")] + public string? Default { get; set; } + /// /// Gets or sets the actual . /// diff --git a/src/DbEx/Templates/EfModel_cs.hbs b/src/DbEx/Templates/EfModel_cs.hbs index 1031d13..7c8bb5c 100644 --- a/src/DbEx/Templates/EfModel_cs.hbs +++ b/src/DbEx/Templates/EfModel_cs.hbs @@ -23,7 +23,7 @@ public partial class {{EfModelName}} {{#if DbColumn.IsPrimaryKey}} /// This is {{#ifeq Parent.DbTable.PrimaryKeyColumns.Count 1}}the primary key{{else}}part of the primary key{{/ifeq}}. {{/if}} - public {{Type}} {{Property}} { get; set; }{{#unless DbColumn.IsNullable}}{{#ifeq DbColumn.DotNetType "string"}} = default!;{{/ifeq}}{{/unless}} + public {{Type}} {{Property}} { get; set; }{{#ifval Default}} = {{Default}};{{else}}{{#unless DbColumn.IsNullable}}{{#ifeq DbColumn.DotNetType "string"}} = default!;{{/ifeq}}{{/unless}}{{/ifval}} {{/each}} } diff --git a/tests/DbEx.Test.Console/dbex.yaml b/tests/DbEx.Test.Console/dbex.yaml index 6fbac48..c787b85 100644 --- a/tests/DbEx.Test.Console/dbex.yaml +++ b/tests/DbEx.Test.Console/dbex.yaml @@ -8,4 +8,6 @@ tables: - name: AddressJson property: Address type: Persistence.Address? - valueConverter: new Persistence.JsonConverter() \ No newline at end of file + valueConverter: new Persistence.JsonConverter() + - name: PersonId + default: Guid.NewGuid() \ No newline at end of file diff --git a/tests/DbEx.Test.Empty/Persistence/Person.g.cs b/tests/DbEx.Test.Empty/Persistence/Person.g.cs index b94bb30..d36dba8 100644 --- a/tests/DbEx.Test.Empty/Persistence/Person.g.cs +++ b/tests/DbEx.Test.Empty/Persistence/Person.g.cs @@ -17,7 +17,7 @@ public partial class Person /// Gets or sets the value of the 'PersonId' column (UNIQUEIDENTIFIER). /// /// This is the primary key. - public Guid PersonId { get; set; } + public Guid PersonId { get; set; } = Guid.NewGuid(); /// /// Gets or sets the value of the 'Name' column (NVARCHAR(200)). From 96202a66f3d3b40a3ebda956116fd8b3fb815e86 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 11 May 2026 13:25:38 -0700 Subject: [PATCH 08/11] Quick fix. --- src/DbEx.Postgres/Migration/PostgresSchemaScript.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DbEx.Postgres/Migration/PostgresSchemaScript.cs b/src/DbEx.Postgres/Migration/PostgresSchemaScript.cs index 20bbf97..03257be 100644 --- a/src/DbEx.Postgres/Migration/PostgresSchemaScript.cs +++ b/src/DbEx.Postgres/Migration/PostgresSchemaScript.cs @@ -27,7 +27,7 @@ public static PostgresSchemaScript Create(DatabaseMigrationScript migrationScrip { if (string.Compare(tokens[i + 1], "or", StringComparison.OrdinalIgnoreCase) == 0 && string.Compare(tokens[i + 2], "replace", StringComparison.OrdinalIgnoreCase) == 0) { - i =+ 2; + i += 2; script.SupportsReplace = true; } From 26b4c62f3b1f8511e49d207c32eb867c47e632ef Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 11 May 2026 14:06:21 -0700 Subject: [PATCH 09/11] Fixes based on copilot review. --- src/DbEx.Postgres/Migration/PostgresMigration.cs | 3 +-- src/DbEx.Postgres/Templates/FnOutboxLeaseAcquire_pgsql.hbs | 2 +- src/DbEx.Postgres/Templates/FnOutboxLeaseRelease_pgsql.hbs | 2 +- src/DbEx.SqlServer/Templates/SpOutboxLeaseAcquire_sql.hbs | 2 +- src/DbEx.SqlServer/Templates/SpOutboxLeaseRelease_sql.hbs | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/DbEx.Postgres/Migration/PostgresMigration.cs b/src/DbEx.Postgres/Migration/PostgresMigration.cs index 2a47699..7d4de1e 100644 --- a/src/DbEx.Postgres/Migration/PostgresMigration.cs +++ b/src/DbEx.Postgres/Migration/PostgresMigration.cs @@ -1,7 +1,7 @@ namespace DbEx.Postgres.Migration; /// -/// Provides the PostgreSQL migration orchestration. +/// Provides the Npgsql (PostgreSQL) migration orchestration. /// public class PostgresMigration : DatabaseMigrationBase { @@ -23,7 +23,6 @@ public PostgresMigration(MigrationArgsBase args) : base(args) throw new ArgumentException($"The {nameof(OnRamp.CodeGeneratorDbArgsBase.ConnectionString)} property must contain a database name.", nameof(args)); _databaseName = csb.Database; - var ds1 = NpgsqlDataSource.Create(Args.ConnectionString!); _database = new PostgresDatabase(() => NpgsqlDataSource.Create(Args.ConnectionString!).CreateConnection()); csb.Database = null; diff --git a/src/DbEx.Postgres/Templates/FnOutboxLeaseAcquire_pgsql.hbs b/src/DbEx.Postgres/Templates/FnOutboxLeaseAcquire_pgsql.hbs index c27e2f4..ad9bdc3 100644 --- a/src/DbEx.Postgres/Templates/FnOutboxLeaseAcquire_pgsql.hbs +++ b/src/DbEx.Postgres/Templates/FnOutboxLeaseAcquire_pgsql.hbs @@ -24,7 +24,7 @@ BEGIN * return_code -1 = Lease not acquired; caller should backoff and retry. * * Notes: - * - The function is resilient to transient errors and will return -1 where lease acquisition is unsuccessful, including where another active lease exists or where a transient error occurs (e.g. lock timeout). + * - The function will return -1 where lease acquisition is unsuccessful, including where another active lease exists or where a transient error occurs (e.g. lock timeout). * - The caller should implement an appropriate retry/backoff strategy where -1 is returned, including randomization to avoid thundering herd issues. */ diff --git a/src/DbEx.Postgres/Templates/FnOutboxLeaseRelease_pgsql.hbs b/src/DbEx.Postgres/Templates/FnOutboxLeaseRelease_pgsql.hbs index c8ada87..3dfdcaf 100644 --- a/src/DbEx.Postgres/Templates/FnOutboxLeaseRelease_pgsql.hbs +++ b/src/DbEx.Postgres/Templates/FnOutboxLeaseRelease_pgsql.hbs @@ -17,7 +17,7 @@ BEGIN * -1 = No rows updated (e.g. already released or invalid lease_id). * * Notes: - * - The function is resilient to transient errors and will return -1 where release is unsuccessful, including where the lease is already released or where a transient error occurs (e.g. lock timeout). + * - The function will return -1 where release is unsuccessful, including where the lease is already released or where a transient error occurs (e.g. lock timeout). */ -- Set transaction parameters diff --git a/src/DbEx.SqlServer/Templates/SpOutboxLeaseAcquire_sql.hbs b/src/DbEx.SqlServer/Templates/SpOutboxLeaseAcquire_sql.hbs index 47feeb1..96ff263 100644 --- a/src/DbEx.SqlServer/Templates/SpOutboxLeaseAcquire_sql.hbs +++ b/src/DbEx.SqlServer/Templates/SpOutboxLeaseAcquire_sql.hbs @@ -16,7 +16,7 @@ BEGIN * -1 = Lease not acquired; caller should backoff and retry. * * Notes: - * - The procedure is resilient to transient errors and will return -1 where lease acquisition is unsuccessful, including where another active lease exists or where a transient error occurs (e.g. lock timeout). + * - The procedure will return -1 where lease acquisition is unsuccessful, including where another active lease exists or where a transient error occurs (e.g. lock timeout). * - The caller should implement an appropriate retry/backoff strategy where -1 is returned, including randomization to avoid thundering herd issues. */ diff --git a/src/DbEx.SqlServer/Templates/SpOutboxLeaseRelease_sql.hbs b/src/DbEx.SqlServer/Templates/SpOutboxLeaseRelease_sql.hbs index 3a8e1f8..7b46cd7 100644 --- a/src/DbEx.SqlServer/Templates/SpOutboxLeaseRelease_sql.hbs +++ b/src/DbEx.SqlServer/Templates/SpOutboxLeaseRelease_sql.hbs @@ -12,7 +12,7 @@ BEGIN * -1 = No rows updated (e.g. already released or invalid LeaseId). * * Notes: - * - The procedure is resilient to transient errors and will return -1 where release is unsuccessful, including where the lease is already released or where a transient error occurs (e.g. lock timeout). + * - The procedure will return -1 where release is unsuccessful, including where the lease is already released or where a transient error occurs (e.g. lock timeout). */ SET NOCOUNT ON; From 17b92c06467a9c5db1aa337632c081446c147f61 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 21:07:50 +0000 Subject: [PATCH 10/11] Swap expected/actual order in PostgresMigrationTest SqlState assertions Agent-Logs-Url: https://github.com/Avanade/DbEx/sessions/126ac7fe-c6d7-4e5a-b088-8e42839291aa Co-authored-by: chullybun <12836934+chullybun@users.noreply.github.com> --- tests/DbEx.Test/PostgresMigrationTest.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/DbEx.Test/PostgresMigrationTest.cs b/tests/DbEx.Test/PostgresMigrationTest.cs index d0db490..1cc90f1 100644 --- a/tests/DbEx.Test/PostgresMigrationTest.cs +++ b/tests/DbEx.Test/PostgresMigrationTest.cs @@ -54,13 +54,13 @@ public async Task B110_Throw_Exceptions() var cs = UnitTest.GetConfig("DbEx_").GetConnectionString("PostgresDb"); using var db = new PostgresDatabase(() => new Npgsql.NpgsqlConnection(cs)); - Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_authorization_exception").Param("@message", (string)null).NonQueryAsync()).SqlState, "56003"); - Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_business_exception").Param("@message", (string)null).NonQueryAsync()).SqlState, "56002"); - Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_concurrency_exception").Param("@message", (string)null).NonQueryAsync()).SqlState, "56004"); - Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_conflict_exception").Param("@message", (string)null).NonQueryAsync()).SqlState, "56006"); - Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_duplicate_exception").Param("@message", (string)null).NonQueryAsync()).SqlState, "56007"); - Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_not_found_exception").Param("@message", (string)null).NonQueryAsync()).SqlState, "56005"); - Assert.AreEqual(Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_validation_exception").Param("@message", (string)null).NonQueryAsync()).SqlState, "56001"); + Assert.AreEqual("56003", Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_authorization_exception").Param("@message", (string)null).NonQueryAsync()).SqlState); + Assert.AreEqual("56002", Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_business_exception").Param("@message", (string)null).NonQueryAsync()).SqlState); + Assert.AreEqual("56004", Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_concurrency_exception").Param("@message", (string)null).NonQueryAsync()).SqlState); + Assert.AreEqual("56006", Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_conflict_exception").Param("@message", (string)null).NonQueryAsync()).SqlState); + Assert.AreEqual("56007", Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_duplicate_exception").Param("@message", (string)null).NonQueryAsync()).SqlState); + Assert.AreEqual("56005", Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_not_found_exception").Param("@message", (string)null).NonQueryAsync()).SqlState); + Assert.AreEqual("56001", Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_validation_exception").Param("@message", (string)null).NonQueryAsync()).SqlState); var vex = Assert.ThrowsAsync(() => db.StoredProcedure("sp_throw_validation_exception").Param("@message", "On no!").NonQueryAsync()); Assert.AreEqual("On no!", vex.MessageText.TrimEnd()); @@ -98,4 +98,4 @@ await db.StoredProcedure("\"public\".\"sp_set_session_context\"") Assert.That(await db2.SqlStatement("select fn_get_user_id()").ScalarAsync(), Is.Null); } } -} \ No newline at end of file +} From 0c3ff1c289306eb3a63da49431c95c3ae45874ec Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 11 May 2026 14:48:51 -0700 Subject: [PATCH 11/11] Fix test parallel for CI. --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index a32985a..31220eb 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -59,4 +59,4 @@ jobs: echo "DbEx_ConnectionStrings__PostgresDb=Server=localhost; Port=5432; Database=dbex_test; Username=postgres; Pwd=yourStrong#!Password; Pooling=false" >> $GITHUB_ENV - name: Test - run: dotnet test --no-build --verbosity normal \ No newline at end of file + run: dotnet test --no-build --verbosity normal -p:TestTfmsInParallel=false \ No newline at end of file