diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 4a2c076..1c1bc47 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -39,8 +39,14 @@ jobs: - name: Start MySQL run: docker run --name db-mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=yourStrong#!Password -d mysql + + - name: Pull Postgres + run: docker pull postgres + + - name: Start Postgres + run: docker run --name db-postgres -p 5432:5432 -e POSTGRES_PASSWORD=yourStrong#!Password -d postgres - - name: Sleep (allow SqlServer and MySQL to complete startup) + - name: Sleep (allow databases to complete startup) run: sleep 10 - name: Set EnvVar for Test @@ -50,6 +56,7 @@ jobs: echo "DbEx_ConnectionStrings__EmptyDb=Data Source=localhost, 1433;Initial Catalog=DbEx.Empty;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true" >> $GITHUB_ENV echo "DbEx_ConnectionStrings__ConsoleDb=Data Source=localhost, 1433;Initial Catalog=DbEx.Console;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true" >> $GITHUB_ENV echo "DbEx_ConnectionStrings__MySqlDb=Server=localhost; Port=3306; Database=dbex_test; Uid=root; Pwd=yourStrong#!Password;" >> $GITHUB_ENV + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e2b257..f9f8270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,18 @@ Represents the **NuGet** versions. +## v2.5.0 +- *Enhancement:* Added [PostgreSQL](https://www.postgresql.org/) database migrations support. +- *Enhancement:* Added `DateOnly` and `TimeOnly` support (requires `net7.0`+) (see also `MigrationArgs.EmitDotNetDateOnly` and `MigrationArgs.EmitDotNetTimeOnly` to explicitly enable). +- *Enhamcement:* Improved the `MigrationArgs` support throughout to simplify usage, and improve configurablility and consistency; enabling greater flexibility to control the migration process/activities. +- *Internal*: + - All `throw new ArgumentNullException` checking migrated to the `xxx.ThrowIfNull` extension method equivalent. + - All _Run Code Analysis_ issues resolved. + ## v2.4.0 - *Enhancement:* Added `MigrationAssemblyArgs` to allow for the specification of zero or more `Data` folder names. - *Fixed:* Updated `CoreEx` to version `3.9.0`. - ## v2.3.15 - *Fixed:* Updated `CoreEx` to version `3.8.0`. - *Fixed:* Updated `OnRamp` to version `2.0.0` which necessitated internal change from `Newtonsoft.Json` (now deprecated) to `System.Text.Json`; additionally, the `DataParser` was refactored accordingly. diff --git a/Common.targets b/Common.targets index 948c28e..cdbd139 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@ - 2.4.0 + 2.5.0 preview Avanade Avanade diff --git a/DbEx.sln b/DbEx.sln index 42c3455..b3a85ea 100644 --- a/DbEx.sln +++ b/DbEx.sln @@ -48,6 +48,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Github Actions", "Github Ac .github\workflows\CI.yml = .github\workflows\CI.yml EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DbEx.Postgres", "src\DbEx.Postgres\DbEx.Postgres.csproj", "{C0ADA5D9-8952-4E0F-9865-2968FEACFC9A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbEx.Test.PostgresConsole", "tests\DbEx.Test.PostgresConsole\DbEx.Test.PostgresConsole.csproj", "{9DBC4E5E-6321-4F56-82CC-7ECEE1020EE0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -90,6 +94,14 @@ Global {80EF0604-F641-4D01-9922-0162D0C69E02}.Debug|Any CPU.Build.0 = Debug|Any CPU {80EF0604-F641-4D01-9922-0162D0C69E02}.Release|Any CPU.ActiveCfg = Release|Any CPU {80EF0604-F641-4D01-9922-0162D0C69E02}.Release|Any CPU.Build.0 = Release|Any CPU + {C0ADA5D9-8952-4E0F-9865-2968FEACFC9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0ADA5D9-8952-4E0F-9865-2968FEACFC9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0ADA5D9-8952-4E0F-9865-2968FEACFC9A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0ADA5D9-8952-4E0F-9865-2968FEACFC9A}.Release|Any CPU.Build.0 = Release|Any CPU + {9DBC4E5E-6321-4F56-82CC-7ECEE1020EE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -102,6 +114,7 @@ Global {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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {1A02148E-CFB1-43D0-8DC0-123232A179A7} diff --git a/README.md b/README.md index 1a551b8..2bff678 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Package | Status | Source & documentation -|-|- `DbEx` | [![NuGet version](https://badge.fury.io/nu/DbEx.svg)](https://badge.fury.io/nu/DbEx) | [Link](./src/DbEx) `DbEx.MySql` | [![NuGet version](https://badge.fury.io/nu/DbEx.MySql.svg)](https://badge.fury.io/nu/DbEx.MySql) | [Link](./src/DbEx.MySql) +`DbEx.Postgres` | [![NuGet version](https://badge.fury.io/nu/DbEx.Postgres.svg)](https://badge.fury.io/nu/DbEx.Postgres) | [Link](./src/DbEx.Postgres) `DbEx.SqlServer` | [![NuGet version](https://badge.fury.io/nu/DbEx.SqlServer.svg)](https://badge.fury.io/nu/DbEx.SqlServer) | [Link](./src/DbEx.SqlServer) @@ -313,8 +314,8 @@ To simplify the database management here are some further considerations that ma ## Other repos These other _Avanade_ repositories leverage _DbEx_: -- [NTangle](https://github.com/Avanade/NTangle) - Change Data Capture (CDC) code generation tool and runtime. -- [Beef](https://github.com/Avanade/Beef) - Business Entity Execution Framework to enable industralisation of API development. +- [*NTangle*](https://github.com/Avanade/NTangle) - Change Data Capture (CDC) code generation tool and runtime. +- [*Beef*](https://github.com/Avanade/Beef) - Business Entity Execution Framework to enable industralisation of API development.
diff --git a/nuget-publish.ps1 b/nuget-publish.ps1 index 6dbd1ba..ce3cbaa 100644 --- a/nuget-publish.ps1 +++ b/nuget-publish.ps1 @@ -50,6 +50,7 @@ param( [String[]]$ProjectsToPublish = @( "src\DbEx", "src\DbEx.MySql", + "src\DbEx.Postgres", "src\DbEx.SqlServer") ) diff --git a/src/DbEx.MySql/Console/MySqlMigrationConsole.cs b/src/DbEx.MySql/Console/MySqlMigrationConsole.cs index 36dd4db..20111ab 100644 --- a/src/DbEx.MySql/Console/MySqlMigrationConsole.cs +++ b/src/DbEx.MySql/Console/MySqlMigrationConsole.cs @@ -1,5 +1,6 @@ // 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; @@ -32,7 +33,7 @@ public sealed class MySqlMigrationConsole : MigrationConsoleBase class that provides a default for the . /// /// The database connection string. - public MySqlMigrationConsole(string connectionString) : base(new MigrationArgs { ConnectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)) }) { } + public MySqlMigrationConsole(string connectionString) : base(new MigrationArgs { ConnectionString = connectionString.ThrowIfNull(nameof(connectionString)) }) { } /// /// Gets the . diff --git a/src/DbEx.MySql/DbEx.MySql.csproj b/src/DbEx.MySql/DbEx.MySql.csproj index ff43fcf..91cbcdc 100644 --- a/src/DbEx.MySql/DbEx.MySql.csproj +++ b/src/DbEx.MySql/DbEx.MySql.csproj @@ -35,12 +35,14 @@ + + - - + + diff --git a/src/DbEx.MySql/Migration/MySqlMigration.cs b/src/DbEx.MySql/Migration/MySqlMigration.cs index 81662de..22f8b3d 100644 --- a/src/DbEx.MySql/Migration/MySqlMigration.cs +++ b/src/DbEx.MySql/Migration/MySqlMigration.cs @@ -16,10 +16,8 @@ namespace DbEx.MySql.Migration /// /// Provides the MySQL 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 `dbo` and `SchemaVersions` respectively. + /// 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; @@ -33,6 +31,8 @@ public class MySqlMigration : DatabaseMigrationBase /// The . public MySqlMigration(MigrationArgsBase args) : base(args) { + SchemaConfig = new MySqlSchemaConfig(this); + var csb = new MySqlConnectionStringBuilder(Args.ConnectionString); _databaseName = csb.Database; if (string.IsNullOrEmpty(_databaseName)) @@ -51,9 +51,9 @@ public MySqlMigration(MigrationArgsBase args) : base(args) SchemaObjectTypes = ["FUNCTION", "VIEW", "PROCEDURE"]; // Add/set standard parameters. - Args.Parameter(MigrationArgsBase.DatabaseNameParamName, _databaseName, true); - Args.Parameter(MigrationArgsBase.JournalSchemaParamName, null, true); - Args.Parameter(MigrationArgsBase.JournalTableParamName, "schemaversions"); + Args.AddParameter(MigrationArgsBase.DatabaseNameParamName, _databaseName, true); + Args.AddParameter(MigrationArgsBase.JournalSchemaParamName, null, true); + Args.AddParameter(MigrationArgsBase.JournalTableParamName, "schemaversions"); } /// @@ -69,7 +69,7 @@ public MySqlMigration(MigrationArgsBase args) : base(args) public override IDatabase MasterDatabase => _masterDatabase; /// - public override DatabaseSchemaConfig DatabaseSchemaConfig => new MySqlSchemaConfig(DatabaseName); + public override DatabaseSchemaConfig SchemaConfig { get; } /// protected override DatabaseSchemaScriptBase CreateSchemaScript(DatabaseMigrationScript migrationScript) => MySqlSchemaScript.Create(migrationScript); @@ -78,7 +78,7 @@ public MySqlMigration(MigrationArgsBase args) : base(args) protected override async Task DatabaseResetAsync(CancellationToken cancellationToken = default) { // Filter out the versioning table. - _resetBypass.Add($"`{Journal.Table}`"); + _resetBypass.Add(SchemaConfig.ToFullyQualifiedTableName(Journal.Schema!, Journal.Table!)); // Carry on as they say ;-) return await base.DatabaseResetAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/DbEx.MySql/Migration/MySqlSchemaScript.cs b/src/DbEx.MySql/Migration/MySqlSchemaScript.cs index c7c0d3a..8150039 100644 --- a/src/DbEx.MySql/Migration/MySqlSchemaScript.cs +++ b/src/DbEx.MySql/Migration/MySqlSchemaScript.cs @@ -57,9 +57,9 @@ private MySqlSchemaScript(DatabaseMigrationScript migrationScript) : base(migrat /// public override string SqlCreateStatement => $"CREATE {Type.ToUpperInvariant()} `{Name}`"; - private class SqlCommandTokenizer : SqlCommandReader + private class SqlCommandTokenizer(string sqlText) : SqlCommandReader(sqlText) { - public SqlCommandTokenizer(string sqlText) : base(sqlText) { } + private readonly char[] delimiters = ['(', ')', ';', ',', '=']; public string[] ReadAllTokens() { @@ -81,7 +81,7 @@ public string[] ReadAllTokens() sb.Clear(); break; } - else if (new char[] { '(', ')', ';', ',', '=' }.Contains(c)) + else if (delimiters.Contains(c)) { if (sb.Length > 0) words.Add(sb.ToString()); @@ -114,7 +114,7 @@ public string[] ReadAllTokens() if (sb.Length > 0) words.Add(sb.ToString()); - return words.ToArray(); + return [.. words]; } } } diff --git a/src/DbEx.MySql/MySqlSchemaConfig.cs b/src/DbEx.MySql/MySqlSchemaConfig.cs index 2012f54..4c8e057 100644 --- a/src/DbEx.MySql/MySqlSchemaConfig.cs +++ b/src/DbEx.MySql/MySqlSchemaConfig.cs @@ -1,34 +1,38 @@ // 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.Data; +using DbEx.Migration; +using DbEx.MySql.Migration; using System; using System.Collections.Generic; +using System.Data; using System.Linq; -using System.Reflection; using System.Text; -using System.Threading.Tasks; using System.Threading; -using DbEx.Migration; +using System.Threading.Tasks; namespace DbEx.MySql { /// /// Provides MySQL specific configuration and capabilities. /// - public class MySqlSchemaConfig : DatabaseSchemaConfig + /// The owning . + public class MySqlSchemaConfig(MySqlMigration migration) : DatabaseSchemaConfig(migration) { - /// - /// Initializes a new instance of the class. - /// - /// The database name. - public MySqlSchemaConfig(string databaseName) : base(databaseName, false) { } - /// /// Value is '_id'. public override string IdColumnNameSuffix => "_id"; + /// + /// Value is '_code'. + public override string CodeColumnNameSuffix => "_code"; + + /// + /// Value is '_json'. + public override string JsonColumnNameSuffix => "_json"; + /// /// Value is 'created_date'. public override string CreatedDateColumnName => "created_date"; @@ -66,48 +70,39 @@ public MySqlSchemaConfig(string databaseName) : base(databaseName, false) { } 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 PrepareDataParserArgs(DataParserArgs dataParserArgs) + public override void PrepareMigrationArgs() { - if (dataParserArgs == null) - return; - - if (dataParserArgs.RefDataColumnDefaults.Count == 0) - { - dataParserArgs.RefDataColumnDefaults.TryAdd("is_active", _ => true); - dataParserArgs.RefDataColumnDefaults.TryAdd("sort_order", i => i); - } + base.PrepareMigrationArgs(); - dataParserArgs.IdColumnNameSuffix ??= IdColumnNameSuffix; - dataParserArgs.CreatedByColumnName ??= CreatedByColumnName; - dataParserArgs.CreatedDateColumnName ??= CreatedDateColumnName; - dataParserArgs.UpdatedByColumnName ??= UpdatedByColumnName; - dataParserArgs.UpdatedDateColumnName ??= UpdatedDateColumnName; - dataParserArgs.TenantIdColumnName ??= TenantIdColumnName; - dataParserArgs.RowVersionColumnName ??= RowVersionColumnName; - dataParserArgs.IsDeletedColumnName ??= IsDeletedColumnName; - dataParserArgs.RefDataCodeColumnName ??= RefDataCodeColumnName; - dataParserArgs.RefDataTextColumnName ??= RefDataTextColumnName; + 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) { var dt = dr.GetValue("DATA_TYPE"); - if (string.Compare(dt, "TINYINT", StringComparison.InvariantCultureIgnoreCase) == 0 && dr.GetValue("COLUMN_TYPE").ToUpperInvariant() == "TINYINT(1)") + 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) { - IsNullable = dr.GetValue("IS_NULLABLE").ToUpperInvariant() == "YES", + 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") + DefaultValue = dr.GetValue("COLUMN_DEFAULT"), + IsDotNetDateOnly = RemovePrecisionFromDataType(dt).Equals("DATE", StringComparison.OrdinalIgnoreCase), + IsDotNetTimeOnly = RemovePrecisionFromDataType(dt).Equals("TIME", StringComparison.OrdinalIgnoreCase) }; + c.IsJsonContent = c.Type.ToUpper() == "JSON" || (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) @@ -126,12 +121,21 @@ public override DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema t 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, DataParserArgs? dataParserArgs, CancellationToken cancellationToken) + 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", new Assembly[] { typeof(MySqlSchemaConfig).Assembly }); + 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 +#else 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"), @@ -141,7 +145,7 @@ public override async Task LoadAdditionalInformationSchema(IDatabase database, L ForiegnColumn = dr.GetValue("pk_column_name") }, cancellationToken).ConfigureAwait(false); - foreach (var grp in fks.Where(x => x.TableSchema == DatabaseName).GroupBy(x => new { x.ConstraintName, x.TableSchema, x.TableName }).Where(x => x.Count() == 1)) + 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 @@ -162,32 +166,31 @@ from c in t.Columns /// public override string ToDotNetTypeName(DbColumnSchema schema) { - var dbType = (schema ?? throw new ArgumentNullException(nameof(schema))).Type; + var dbType = RemovePrecisionFromDataType(schema.ThrowIfNull(nameof(schema)).Type); if (string.IsNullOrEmpty(dbType)) return "string"; - if (dbType.EndsWith(')')) - { - var i = dbType.LastIndexOf('('); - if (i > 0) - dbType = dbType[..i]; - } + 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", "DATE" or "DATETIME" or "TIMESTAMP" => "DateTime", + "DATETIMEOFFSET" => "DateTimeOffset", + "Date" => "DateTime", // Date only + "TIME" => "TimeSpan", // Time only "BINARY" or "VARBINARY" or "TINYBLOB" or "BLOB" or "MEDIUMBLOB" or "LONGBLOB" => "byte[]", "BIT" or "BOOL" or "BOOLEAN" => "bool", - "DATETIMEOFFSET" => "DateTimeOffset", "DOUBLE" => "double", "INT" => "int", "BIGINT" => "long", "SMALLINT" => "short", "TINYINT" => "byte", "FLOAT" => "float", - "TIME" => "TimeSpan", "UNIQUEIDENTIFIER" => "Guid", _ => throw new InvalidOperationException($"Database data type '{dbType}' does not have corresponding .NET type mapping defined."), }; @@ -214,36 +217,19 @@ public override string ToFormattedSqlType(DbColumnSchema schema, bool includeNul } /// - public override string ToFormattedSqlStatementValue(DbColumnSchema dbColumnSchema, DataParserArgs dataParserArgs, object? value) => value switch + 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(dataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", - DateTimeOffset dto => $"'{dto.ToString(dataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + DateTime dt => $"'{dt.ToString(Migration.Args.DataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + DateTimeOffset dto => $"'{dto.ToString(Migration.Args.DataParserArgs.DateTimeFormat, 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)}'", +#endif _ => value.ToString()! }; - - /// - public override bool IsDbTypeInteger(string? dbType) => dbType != null && dbType.ToUpperInvariant() switch - { - "INT" or "BIGINT" or "SMALLINT" or "TINYINT" => true, - _ => false - }; - - /// - public override bool IsDbTypeDecimal(string? dbType) => dbType != null && dbType.ToUpperInvariant() switch - { - "DECIMAL" => true, - _ => false - }; - - /// - public override bool IsDbTypeString(string? dbType) => dbType != null && dbType.ToUpperInvariant() switch - { - "CHAR" or "VARCHAR" or "TINYTEXT" or "TEXT" or "MEDIUMTEXT" or "LONGTEXT" or "SET" or "ENUM" or "NCHAR" or "NVARCHAR" or "JSON" => true, - _ => false - }; } } \ No newline at end of file diff --git a/src/DbEx.MySql/Resources/ScriptAlter_sql.hbs b/src/DbEx.MySql/Resources/ScriptAlter_sql.hbs index 1e39b3c..0a9ae01 100644 --- a/src/DbEx.MySql/Resources/ScriptAlter_sql.hbs +++ b/src/DbEx.MySql/Resources/ScriptAlter_sql.hbs @@ -1,5 +1,5 @@ {{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} -{{! FILENAME:alter-[lookup Parameters 'Param1']-table }} +{{! FILENAME:alter-[lower (lookup Parameters 'Param1')]-table }} {{! PARAM:Param1=Name }} -- Alter table: `{{lookup Parameters 'Param1'}}` diff --git a/src/DbEx.MySql/Resources/ScriptCreate_sql.hbs b/src/DbEx.MySql/Resources/ScriptCreate_sql.hbs index e13a646..e3babb5 100644 --- a/src/DbEx.MySql/Resources/ScriptCreate_sql.hbs +++ b/src/DbEx.MySql/Resources/ScriptCreate_sql.hbs @@ -1,5 +1,5 @@ {{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} -{{! FILENAME:create-[lookup Parameters 'Param1']-table }} +{{! FILENAME:create-[lower (lookup Parameters 'Param1')]-table }} {{! PARAM:Param1=Name }} -- Create table: `{{lookup Parameters 'Param1'}}` diff --git a/src/DbEx.MySql/Resources/ScriptRefData_sql.hbs b/src/DbEx.MySql/Resources/ScriptRefData_sql.hbs index 5ce6576..6499c65 100644 --- a/src/DbEx.MySql/Resources/ScriptRefData_sql.hbs +++ b/src/DbEx.MySql/Resources/ScriptRefData_sql.hbs @@ -1,5 +1,5 @@ {{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} -{{! FILENAME:create-[lookup Parameters 'Param1']-refdata-table }} +{{! FILENAME:create-[lower (lookup Parameters 'Param1')]-refdata-table }} {{! PARAM:Param1=Name }} -- Create Reference Data table: `{{lookup Parameters 'Param1'}}` diff --git a/src/DbEx/Resources/SelectTableAndColumns.sql b/src/DbEx.MySql/Resources/SelectTableAndColumns.sql similarity index 100% rename from src/DbEx/Resources/SelectTableAndColumns.sql rename to src/DbEx.MySql/Resources/SelectTableAndColumns.sql diff --git a/src/DbEx/Resources/SelectTablePrimaryKey.sql b/src/DbEx.MySql/Resources/SelectTablePrimaryKey.sql similarity index 100% rename from src/DbEx/Resources/SelectTablePrimaryKey.sql rename to src/DbEx.MySql/Resources/SelectTablePrimaryKey.sql diff --git a/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs b/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs new file mode 100644 index 0000000..0b4d751 --- /dev/null +++ b/src/DbEx.Postgres/Console/PostgresMigrationConsole.cs @@ -0,0 +1,70 @@ +// 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; +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 + { + /// + /// 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)); + + /// + /// 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)) }) { } + + /// + /// Gets the . + /// + public new MigrationArgs Args => (MigrationArgs)base.Args; + + /// + protected override DatabaseMigrationBase CreateMigrator() => new PostgresMigration(Args); + + /// + public override string AppTitle => base.AppTitle + " [PostgreSQL]"; + + /// + 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."); + } + } +} \ No newline at end of file diff --git a/src/DbEx.Postgres/DbEx.Postgres.csproj b/src/DbEx.Postgres/DbEx.Postgres.csproj new file mode 100644 index 0000000..5109897 --- /dev/null +++ b/src/DbEx.Postgres/DbEx.Postgres.csproj @@ -0,0 +1,55 @@ + + + + net6.0;net7.0;net8.0;netstandard2.1 + DbEx.Postgres + DbEx + DbEx PostgreSQL Migration Tool. + DbEX Database Migration tool for PostgreSQL. + dbex database dbup db-up postgres postgresql sql + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DbEx.Postgres/Migration/PostgresMigration.cs b/src/DbEx.Postgres/Migration/PostgresMigration.cs new file mode 100644 index 0000000..0117a7d --- /dev/null +++ b/src/DbEx.Postgres/Migration/PostgresMigration.cs @@ -0,0 +1,97 @@ +// 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; +using Npgsql; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +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 = []; + + /// + /// Initializes an instance of the class. + /// + /// The . + public PostgresMigration(MigrationArgsBase args) : base(args) + { + 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)); + + _databaseName = csb.Database; + _database = new PostgresDatabase(() => new NpgsqlConnection(Args.ConnectionString)); + + csb.Database = null; + _masterDatabase = new PostgresDatabase(() => new NpgsqlConnection(csb.ConnectionString)); + + Args.AddAssemblyAfter(typeof(DatabaseMigrationBase).Assembly, typeof(PostgresMigration).Assembly); + + 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"); + } + + /// + public override string Provider => "Postgres"; + + /// + public override string DatabaseName => _databaseName; + + /// + public override IDatabase Database => _database; + + /// + public override IDatabase MasterDatabase => _masterDatabase; + + /// + public override DatabaseSchemaConfig SchemaConfig { get; } + + /// + 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!)); + + // 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 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.Postgres/Migration/PostgresSchemaScript.cs b/src/DbEx.Postgres/Migration/PostgresSchemaScript.cs new file mode 100644 index 0000000..7b800ca --- /dev/null +++ b/src/DbEx.Postgres/Migration/PostgresSchemaScript.cs @@ -0,0 +1,139 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx + +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 + { + /// + /// Creates the from the . + /// + /// The . + /// The . + public static PostgresSchemaScript Create(DatabaseMigrationScript migrationScript) + { + var script = new PostgresSchemaScript(migrationScript); + + 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; + + if (i + 4 < tokens.Length) + { + if (string.Compare(tokens[i + 1], "or", StringComparison.OrdinalIgnoreCase) == 0 && string.Compare(tokens[i + 2], "replace", StringComparison.OrdinalIgnoreCase) == 0) + { + i = +2; + script.SupportsReplace = true; + } + + 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; + } + } + + 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 SqlCreateStatement => $"CREATE {(SupportsReplace ? "OR REPLACE " : "")}{Type.ToUpperInvariant()} \"{Schema}\".\"{Name}\""; + + private class SqlCommandTokenizer(string sqlText) : SqlCommandReader(sqlText) + { + private readonly char[] delimiters = ['(', ')', ';', ',', '=']; + + public string[] ReadAllTokens() + { + var words = new List(); + var sb = new StringBuilder(); + + while (!HasReachedEnd) + { + ReadCharacter += (type, c) => + { + 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.SlashStarComment: + case CharacterType.DashComment: + case CharacterType.CustomStatement: + case CharacterType.Delimiter: + break; + + default: + throw new ArgumentOutOfRangeException(nameof(type), type, null); + } + }; + + Parse(); + } + + if (sb.Length > 0) + words.Add(sb.ToString()); + + return [.. words]; + } + } + } +} \ No newline at end of file diff --git a/src/DbEx.Postgres/PostgresSchemaConfig.cs b/src/DbEx.Postgres/PostgresSchemaConfig.cs new file mode 100644 index 0000000..a1aed83 --- /dev/null +++ b/src/DbEx.Postgres/PostgresSchemaConfig.cs @@ -0,0 +1,229 @@ +// 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; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace DbEx.Postgres +{ + /// + /// 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 '_code'. + public override string CodeColumnNameSuffix => "_code"; + + /// + /// Value is '_json'. + public override string JsonColumnNameSuffix => "_json"; + + /// + /// Value is 'created_date'. + public override string CreatedDateColumnName => "created_date"; + + /// + /// Value is 'created_by'. + public override string CreatedByColumnName => "created_by"; + + /// + /// Value is 'updated_date'. + public override string UpdatedDateColumnName => "updated_date"; + + /// + /// Value is 'updated_by'. + public override string UpdatedByColumnName => "updated_by"; + + /// + /// 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 'is_deleted'. + public override string IsDeletedColumnName => "is_deleted"; + + /// + /// Value is 'code'. + public override string RefDataCodeColumnName => "code"; + + /// + /// Value is 'text'. + public override string RefDataTextColumnName => "text"; + + /// + public override string ToFullyQualifiedTableName(string? schema, string table) => string.IsNullOrEmpty(schema) ? $"\"{table}\"" : $"\"{schema}\".\"{table}\""; + + /// + public override void PrepareMigrationArgs() + { + base.PrepareMigrationArgs(); + + 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) + { + 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.ToUpper() == "JSON" || (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; + + /// + 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) + { + 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().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 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(); + } + } + + /// + 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"; + + // 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" or "TIMESTAMP WITH TIME ZONE" => "DateTime", + "TIME WITH TIME ZONE" => "DateTimeOffset", + "INTERVAL" => "TimeSpan", + "TIME WITHOUT TIME ZONE" => "TimeSpan", // TimeOnly + "DATE" => "DateTime", // 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."), + }; + } + + /// + 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 + }); + + if (includeNullability && schema.IsNullable) + sb.Append(" NULL"); + + 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.DateTimeFormat, 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)}'", +#endif + _ => value.ToString()! + }; + } +} \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/DatabaseCreate.sql b/src/DbEx.Postgres/Resources/DatabaseCreate.sql new file mode 100644 index 0000000..46a7501 --- /dev/null +++ b/src/DbEx.Postgres/Resources/DatabaseCreate.sql @@ -0,0 +1 @@ +CREATE DATABASE "{{DatabaseName}}" \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/DatabaseData_sql.hbs b/src/DbEx.Postgres/Resources/DatabaseData_sql.hbs new file mode 100644 index 0000000..7a35509 --- /dev/null +++ b/src/DbEx.Postgres/Resources/DatabaseData_sql.hbs @@ -0,0 +1,20 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +{{#if PreSql}} +{{PreSql}} + +{{/if}} +{{#if IsMerge}} + {{#each Rows}} +INSERT INTO "{{Table.Schema}}"."{{Table.Name}}" ({{#each MergeInsertColumns}}"{{Name}}"{{#unless @last}}, {{/unless}}{{/each}}) VALUES ({{#each MergeInsertColumns}}{{#if UseForeignKeyQueryForId}}(SELECT "{{DbColumn.ForeignColumn}}" FROM "{{DbColumn.ForeignSchema}}"."{{DbColumn.ForeignTable}}" WHERE "{{DbColumn.ForeignRefDataCodeColumn}}" = {{{SqlValue}}} LIMIT 1){{else}}{{{SqlValue}}}{{/if}}{{#unless @last}}, {{/unless}}{{/each}}) ON CONFLICT ({{#each Table.DbTable.ConstraintColumns}}"{{Name}}"{{#unless @last}}, {{/unless}}{{/each}}) DO UPDATE SET {{#each MergeUpdateColumns}}"{{Name}}" = {{{SqlValue}}}{{#unless @last}}, {{/unless}}{{/each}}; + {{/each}} +SELECT {{Rows.Count}}; -- Total rows upserted +{{else}} + {{#each Rows}} +INSERT INTO "{{Table.Schema}}"."{{Table.Name}}" ({{#each InsertColumns}}"{{Name}}"{{#unless @last}}, {{/unless}}{{/each}}) VALUES ({{#each InsertColumns}}{{#if UseForeignKeyQueryForId}}(SELECT "{{DbColumn.ForeignColumn}}" FROM "{{DbColumn.ForeignSchema}}"."{{DbColumn.ForeignTable}}" WHERE "{{DbColumn.ForeignRefDataCodeColumn}}" = {{{SqlValue}}} LIMIT 1){{else}}{{{SqlValue}}}{{/if}}{{#unless @last}}, {{/unless}}{{/each}}); + {{/each}} +SELECT {{Rows.Count}}; -- Total rows inserted +{{/if}} +{{#if PostSql}} + +{{PostSql}} +{{/if}} \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/DatabaseDrop.sql b/src/DbEx.Postgres/Resources/DatabaseDrop.sql new file mode 100644 index 0000000..63aa75b --- /dev/null +++ b/src/DbEx.Postgres/Resources/DatabaseDrop.sql @@ -0,0 +1 @@ +DROP DATABASE "{{DatabaseName}}" WITH (FORCE) \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/DatabaseExists.sql b/src/DbEx.Postgres/Resources/DatabaseExists.sql new file mode 100644 index 0000000..980850e --- /dev/null +++ b/src/DbEx.Postgres/Resources/DatabaseExists.sql @@ -0,0 +1 @@ +select datname from pg_database where datname = '{{DatabaseName}}' \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/DatabaseReset_sql.hbs b/src/DbEx.Postgres/Resources/DatabaseReset_sql.hbs new file mode 100644 index 0000000..c9bd548 --- /dev/null +++ b/src/DbEx.Postgres/Resources/DatabaseReset_sql.hbs @@ -0,0 +1,6 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +TRUNCATE +{{#each .}} + {{QualifiedName}}{{#unless @last}},{{/unless}} +{{/each}} + RESTART IDENTITY; \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/JournalAudit.sql b/src/DbEx.Postgres/Resources/JournalAudit.sql new file mode 100644 index 0000000..2ae1e6a --- /dev/null +++ b/src/DbEx.Postgres/Resources/JournalAudit.sql @@ -0,0 +1,2 @@ +-- Inspired by https://github.com/DbUp/dbup-postgresql/blob/main/src/dbup-postgresql/PostgresqlTableJournal.cs for consistency. +INSERT INTO "{{JournalSchema}}"."{{JournalTable}}" (ScriptName, Applied) VALUES (@scriptname, @applied) \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/JournalCreate.sql b/src/DbEx.Postgres/Resources/JournalCreate.sql new file mode 100644 index 0000000..8144abf --- /dev/null +++ b/src/DbEx.Postgres/Resources/JournalCreate.sql @@ -0,0 +1,5 @@ +-- Inspired by https://github.com/DbUp/dbup-postgresql/blob/main/src/dbup-postgresql/PostgresqlTableJournal.cs for consistency. +CREATE TABLE "{{JournalSchema}}"."{{JournalTable}}" ( + schemaversionsid serial NOT NULL PRIMARY KEY, + scriptname CHARACTER VARYING(255) NOT NULL, + applied TIMESTAMP WITHOUT TIME ZONE NOT NULL) \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/JournalExists.sql b/src/DbEx.Postgres/Resources/JournalExists.sql new file mode 100644 index 0000000..5e46108 --- /dev/null +++ b/src/DbEx.Postgres/Resources/JournalExists.sql @@ -0,0 +1,2 @@ +-- Inspired by https://github.com/DbUp/dbup-postgresql/blob/main/src/dbup-postgresql/PostgresqlTableJournal.cs for consistency. +SELECT 1 FROM information_schema.tables WHERE table_name = '{{JournalTable}}' AND table_schema = '{{JournalSchema}}' \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/JournalPrevious.sql b/src/DbEx.Postgres/Resources/JournalPrevious.sql new file mode 100644 index 0000000..5f1ba1c --- /dev/null +++ b/src/DbEx.Postgres/Resources/JournalPrevious.sql @@ -0,0 +1,2 @@ +-- Inspired by https://github.com/DbUp/dbup-postgresql/blob/main/src/dbup-postgresql/PostgresqlTableJournal.cs for consistency. +SELECT DISTINCT "scriptname" FROM "{{JournalSchema}}"."{{JournalTable}}" \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/ScriptAlter_sql.hbs b/src/DbEx.Postgres/Resources/ScriptAlter_sql.hbs new file mode 100644 index 0000000..fda840a --- /dev/null +++ b/src/DbEx.Postgres/Resources/ScriptAlter_sql.hbs @@ -0,0 +1,8 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +{{! FILENAME:alter-[lower (lookup Parameters 'Param1')]-[lower (lookup Parameters 'Param2')]-table }} +{{! PARAM:Param1=Schema }} +{{! PARAM:Param2=Name }} +-- 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 diff --git a/src/DbEx.Postgres/Resources/ScriptCreate_sql.hbs b/src/DbEx.Postgres/Resources/ScriptCreate_sql.hbs new file mode 100644 index 0000000..5c1c424 --- /dev/null +++ b/src/DbEx.Postgres/Resources/ScriptCreate_sql.hbs @@ -0,0 +1,17 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +{{! FILENAME:create-[lower (lookup Parameters 'Param1')]-[lower (lookup Parameters 'Param2')]-table }} +{{! PARAM:Param1=Schema }} +{{! PARAM:Param2=Name }} +-- Create table: "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" + +CREATE TABLE "{{lookup Parameters 'Param1'}}"."{{lookup Parameters 'Param2'}}" ( + "{{lookup Parameters 'Param1'}}_id" SERIAL PRIMARY KEY, + -- "code" VARCHAR(50) NULL UNIQUE, + -- "text" VARCHAR(250) NULL, + -- "bool" BOOLEAN NULL, + -- "date" DATE NULL, + "created_by" VARCHAR(250) NULL, + "created_date" TIMESTAMPTZ NULL, + "updated_by" VARCHAR(250) NULL, + "updated_date" TIMESTAMPTZ NULL +); \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/ScriptDefault_sql.hbs b/src/DbEx.Postgres/Resources/ScriptDefault_sql.hbs new file mode 100644 index 0000000..879abb5 --- /dev/null +++ b/src/DbEx.Postgres/Resources/ScriptDefault_sql.hbs @@ -0,0 +1,9 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +{{! FILENAME:script-comment-text }} +-- Migration Script + +START TRANSACTION; + +-- Replace with the required SQL STATEMENT(s). + +COMMIT WORK; \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/ScriptRefData_sql.hbs b/src/DbEx.Postgres/Resources/ScriptRefData_sql.hbs new file mode 100644 index 0000000..2df69ec --- /dev/null +++ b/src/DbEx.Postgres/Resources/ScriptRefData_sql.hbs @@ -0,0 +1,17 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +{{! FILENAME:create-[lower (lookup Parameters 'Param1')]-[lower (lookup Parameters 'Param2')]-refdata-table }} +{{! PARAM:Param1=Schema }} +{{! PARAM:Param2=Name }} +-- 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, + "code" VARCHAR(50) NOT NULL UNIQUE, + "text" VARCHAR(250) NULL, + "is_active" BOOLEAN NULL, + "sort_order" INT NULL, + "created_by" VARCHAR(250) NULL, + "created_date" TIMESTAMPTZ NULL, + "updated_by" VARCHAR(250) NULL, + "updated_date" TIMESTAMPTZ NULL +); \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/ScriptSchema_sql.hbs b/src/DbEx.Postgres/Resources/ScriptSchema_sql.hbs new file mode 100644 index 0000000..7e6f8ae --- /dev/null +++ b/src/DbEx.Postgres/Resources/ScriptSchema_sql.hbs @@ -0,0 +1,6 @@ +{{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} +{{! FILENAME:create-[lower (lookup Parameters 'Param1')]-schema }} +{{! PARAM:Param1=Name }} +-- Create schema: "{{lookup Parameters 'Param1'}}" + +CREATE SCHEMA "{{lookup Parameters 'Param1'}}"; \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/SelectTableAndColumns.sql b/src/DbEx.Postgres/Resources/SelectTableAndColumns.sql new file mode 100644 index 0000000..a41ca3c --- /dev/null +++ b/src/DbEx.Postgres/Resources/SelectTableAndColumns.sql @@ -0,0 +1,12 @@ +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx + +SELECT * + FROM INFORMATION_SCHEMA.TABLES as t + INNER JOIN INFORMATION_SCHEMA.COLUMNS as c + ON t.TABLE_CATALOG = c.TABLE_CATALOG + AND t.TABLE_SCHEMA = c.TABLE_SCHEMA + AND t.TABLE_NAME = c.TABLE_NAME + WHERE t.TABLE_CATALOG = '{{DatabaseName}}' + AND t.TABLE_SCHEMA <> 'information_schema' + AND t.TABLE_SCHEMA <> 'pg_catalog' + ORDER BY t.TABLE_SCHEMA, t.TABLE_NAME, c.ORDINAL_POSITION \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/SelectTableForeignKeys.sql b/src/DbEx.Postgres/Resources/SelectTableForeignKeys.sql new file mode 100644 index 0000000..d73c07a --- /dev/null +++ b/src/DbEx.Postgres/Resources/SelectTableForeignKeys.sql @@ -0,0 +1,12 @@ +-- Inspired by: https://stackoverflow.com/a/52130270 +SELECT + tc.constraint_name, tc.table_catalog, tc.table_schema, tc.table_name, kcu.column_name, + ccu.table_schema AS foreign_schema_name, ccu.table_name AS foreign_table_name, ccu.column_name AS foreign_column_name + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name + WHERE constraint_type = 'FOREIGN KEY' + AND tc.table_catalog = '{{DatabaseName}}' + AND ccu.table_catalog = '{{DatabaseName}}' \ No newline at end of file diff --git a/src/DbEx.Postgres/Resources/SelectTablePrimaryKey.sql b/src/DbEx.Postgres/Resources/SelectTablePrimaryKey.sql new file mode 100644 index 0000000..206eca7 --- /dev/null +++ b/src/DbEx.Postgres/Resources/SelectTablePrimaryKey.sql @@ -0,0 +1,11 @@ +SELECT kcu.TABLE_SCHEMA, kcu.TABLE_NAME, kcu.CONSTRAINT_NAME, tc.CONSTRAINT_TYPE, kcu.COLUMN_NAME, kcu.ORDINAL_POSITION + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS tc + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS kcu + ON kcu.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + AND kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA + AND kcu.TABLE_NAME = tc.TABLE_NAME + WHERE kcu.TABLE_CATALOG = '{{DatabaseName}}' + AND kcu.TABLE_SCHEMA NOT IN ('information_schema', 'pg_catalog') + AND tc.CONSTRAINT_TYPE IN ( 'PRIMARY KEY', 'UNIQUE' ) + ORDER BY kcu.TABLE_SCHEMA, kcu.TABLE_NAME, tc.CONSTRAINT_TYPE, kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION; \ No newline at end of file diff --git a/src/DbEx.Postgres/strong-name-key.snk b/src/DbEx.Postgres/strong-name-key.snk new file mode 100644 index 0000000..5bced39 Binary files /dev/null and b/src/DbEx.Postgres/strong-name-key.snk differ diff --git a/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs b/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs index 04c0786..abecd87 100644 --- a/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs +++ b/src/DbEx.SqlServer/Console/SqlServerMigrationConsole.cs @@ -1,5 +1,6 @@ // 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; @@ -32,7 +33,7 @@ public sealed class SqlServerMigrationConsole : MigrationConsoleBase class that provides a default for the . /// /// The database connection string. - public SqlServerMigrationConsole(string connectionString) : base(new MigrationArgs { ConnectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)) }) { } + public SqlServerMigrationConsole(string connectionString) : base(new MigrationArgs { ConnectionString = connectionString.ThrowIfNull(nameof(connectionString)) }) { } /// /// Gets the . diff --git a/src/DbEx.SqlServer/DbEx.SqlServer.csproj b/src/DbEx.SqlServer/DbEx.SqlServer.csproj index 8e05bde..db94ba0 100644 --- a/src/DbEx.SqlServer/DbEx.SqlServer.csproj +++ b/src/DbEx.SqlServer/DbEx.SqlServer.csproj @@ -24,14 +24,16 @@ + + - - + + diff --git a/src/DbEx.SqlServer/Migration/SqlServerMigration.cs b/src/DbEx.SqlServer/Migration/SqlServerMigration.cs index b45ac36..961006a 100644 --- a/src/DbEx.SqlServer/Migration/SqlServerMigration.cs +++ b/src/DbEx.SqlServer/Migration/SqlServerMigration.cs @@ -21,7 +21,7 @@ namespace DbEx.SqlServer.Migration /// 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 `dbo` and `SchemaVersions` respectively. + /// The base instance is updated; the and properties are set to `` and `SchemaVersions` respectively. public class SqlServerMigration : DatabaseMigrationBase { private readonly string _databaseName; @@ -35,6 +35,8 @@ public class SqlServerMigration : DatabaseMigrationBase /// The . public SqlServerMigration(MigrationArgsBase args) : base(args) { + SchemaConfig = new SqlServerSchemaConfig(this); + var csb = new SqlConnectionStringBuilder(Args.ConnectionString); _databaseName = csb.InitialCatalog; if (string.IsNullOrEmpty(_databaseName)) @@ -53,13 +55,13 @@ public SqlServerMigration(MigrationArgsBase args) : base(args) SchemaObjectTypes = ["TYPE", "FUNCTION", "VIEW", "PROCEDURE", "PROC"]; // Always add the dbo schema _first_ unless already specified. - if (!Args.SchemaOrder.Contains("dbo")) - Args.SchemaOrder.Insert(0, "dbo"); + if (!Args.SchemaOrder.Contains(SchemaConfig.DefaultSchema)) + Args.SchemaOrder.Insert(0, SchemaConfig.DefaultSchema); // Add/set standard parameters. - Args.Parameter(MigrationArgsBase.DatabaseNameParamName, _databaseName, true); - Args.Parameter(MigrationArgsBase.JournalSchemaParamName, "dbo"); - Args.Parameter(MigrationArgsBase.JournalTableParamName, "SchemaVersions"); + Args.AddParameter(MigrationArgsBase.DatabaseNameParamName, _databaseName, true); + Args.AddParameter(MigrationArgsBase.JournalSchemaParamName, SchemaConfig.DefaultSchema); + Args.AddParameter(MigrationArgsBase.JournalTableParamName, "SchemaVersions"); } /// @@ -75,7 +77,7 @@ public SqlServerMigration(MigrationArgsBase args) : base(args) public override IDatabase MasterDatabase => _masterDatabase; /// - public override DatabaseSchemaConfig DatabaseSchemaConfig => new SqlServerSchemaConfig(DatabaseName); + public override DatabaseSchemaConfig SchemaConfig { get; } /// protected override DatabaseSchemaScriptBase CreateSchemaScript(DatabaseMigrationScript migrationScript) => SqlServerSchemaScript.Create(migrationScript); diff --git a/src/DbEx.SqlServer/Migration/SqlServerSchemaScript.cs b/src/DbEx.SqlServer/Migration/SqlServerSchemaScript.cs index 1ddddf2..fb2b866 100644 --- a/src/DbEx.SqlServer/Migration/SqlServerSchemaScript.cs +++ b/src/DbEx.SqlServer/Migration/SqlServerSchemaScript.cs @@ -40,7 +40,7 @@ public static SqlServerSchemaScript Create(DatabaseMigrationScript migrationScri var index = script.FullyQualifiedName.IndexOf('.'); if (index < 0) { - script.Schema = "dbo"; + script.Schema = migrationScript.DatabaseMigration.SchemaConfig.DefaultSchema; script.Name = script.FullyQualifiedName; } else @@ -69,9 +69,9 @@ private SqlServerSchemaScript(DatabaseMigrationScript migrationScript) : base(mi /// public override string SqlCreateStatement => $"CREATE {Type.ToUpperInvariant()} [{Schema}].[{Name}]"; - private class SqlCommandTokenizer : SqlCommandReader + private class SqlCommandTokenizer(string sqlText) : SqlCommandReader(sqlText) { - public SqlCommandTokenizer(string sqlText) : base(sqlText) { } + private readonly char[] delimiters = ['(', ')', ';', ',', '=']; public string[] ReadAllTokens() { @@ -126,7 +126,7 @@ public string[] ReadAllTokens() if (sb.Length > 0) words.Add(sb.ToString()); - return words.ToArray(); + return [.. words]; } } } diff --git a/src/DbEx.SqlServer/Resources/JournalAudit.sql b/src/DbEx.SqlServer/Resources/JournalAudit.sql index 1877084..291a0f6 100644 --- a/src/DbEx.SqlServer/Resources/JournalAudit.sql +++ b/src/DbEx.SqlServer/Resources/JournalAudit.sql @@ -1,2 +1,2 @@ -- Inspired by https://github.com/DbUp/DbUp/blob/master/src/dbup-sqlserver/SqlTableJournal.cs for consistency. -INSERT INTO [{{JournalSchema}}].[{{JournalTable}}] (ScriptName, Applied) values (@scriptname, @applied) \ No newline at end of file +INSERT INTO [{{JournalSchema}}].[{{JournalTable}}] (ScriptName, Applied) VALUES (@scriptname, @applied) \ No newline at end of file diff --git a/src/DbEx.SqlServer/Resources/ScriptAlter_sql.hbs b/src/DbEx.SqlServer/Resources/ScriptAlter_sql.hbs index 967c918..06dffcf 100644 --- a/src/DbEx.SqlServer/Resources/ScriptAlter_sql.hbs +++ b/src/DbEx.SqlServer/Resources/ScriptAlter_sql.hbs @@ -1,5 +1,5 @@ {{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} -{{! FILENAME:alter-[lookup Parameters 'Param1']-[lookup Parameters 'Param2']-table }} +{{! FILENAME:alter-[lower (lookup Parameters 'Param1')]-[lower (lookup Parameters 'Param2')]-table }} {{! PARAM:Param1=Schema }} {{! PARAM:Param2=Name }} -- Alter table: [{{lookup Parameters 'Param1'}}].[{{lookup Parameters 'Param2'}}] diff --git a/src/DbEx.SqlServer/Resources/ScriptCdc_sql.hbs b/src/DbEx.SqlServer/Resources/ScriptCdc_sql.hbs index f36e73f..e36cc76 100644 --- a/src/DbEx.SqlServer/Resources/ScriptCdc_sql.hbs +++ b/src/DbEx.SqlServer/Resources/ScriptCdc_sql.hbs @@ -1,5 +1,5 @@ {{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} -{{! FILENAME:enable-cdc-for-[lookup Parameters 'Param1']-[lookup Parameters 'Param2']-table }} +{{! FILENAME:enable-cdc-for-[lower (lookup Parameters 'Param1')]-[lower (lookup Parameters 'Param2')]-table }} {{! PARAM:Param1=Schema }} {{! PARAM:Param2=Name }} -- Enable Change-Data-Capture (CDC) for table: [{{lookup Parameters 'Param1'}}].[{{lookup Parameters 'Param2'}}] diff --git a/src/DbEx.SqlServer/Resources/ScriptCreate_sql.hbs b/src/DbEx.SqlServer/Resources/ScriptCreate_sql.hbs index f9556bc..a4973c0 100644 --- a/src/DbEx.SqlServer/Resources/ScriptCreate_sql.hbs +++ b/src/DbEx.SqlServer/Resources/ScriptCreate_sql.hbs @@ -1,5 +1,5 @@ {{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} -{{! FILENAME:create-[lookup Parameters 'Param1']-[lookup Parameters 'Param2']-table }} +{{! FILENAME:create-[lower (lookup Parameters 'Param1')]-[lower (lookup Parameters 'Param2')]-table }} {{! PARAM:Param1=Schema }} {{! PARAM:Param2=Name }} -- Create table: [{{lookup Parameters 'Param1'}}].[{{lookup Parameters 'Param2'}}] diff --git a/src/DbEx.SqlServer/Resources/ScriptRefData_sql.hbs b/src/DbEx.SqlServer/Resources/ScriptRefData_sql.hbs index 0c14369..dd264e1 100644 --- a/src/DbEx.SqlServer/Resources/ScriptRefData_sql.hbs +++ b/src/DbEx.SqlServer/Resources/ScriptRefData_sql.hbs @@ -1,5 +1,5 @@ {{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} -{{! FILENAME:create-[lookup Parameters 'Param1']-[lookup Parameters 'Param2']-refdata-table }} +{{! FILENAME:create-[lower (lookup Parameters 'Param1')]-[lower (lookup Parameters 'Param2')]-refdata-table }} {{! PARAM:Param1=Schema }} {{! PARAM:Param2=Name }} -- Create Reference Data table: [{{lookup Parameters 'Param1'}}].[{{lookup Parameters 'Param2'}}] diff --git a/src/DbEx.SqlServer/Resources/ScriptSchema_sql.hbs b/src/DbEx.SqlServer/Resources/ScriptSchema_sql.hbs index 2f5528c..9066d04 100644 --- a/src/DbEx.SqlServer/Resources/ScriptSchema_sql.hbs +++ b/src/DbEx.SqlServer/Resources/ScriptSchema_sql.hbs @@ -1,5 +1,5 @@ {{! Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx }} -{{! FILENAME:create-[lookup Parameters 'Param1']-schema }} +{{! FILENAME:create-[lower (lookup Parameters 'Param1')]-schema }} {{! PARAM:Param1=Name }} -- Create schema: [{{lookup Parameters 'Param1'}}] diff --git a/src/DbEx.SqlServer/Resources/SelectTableAndColumns.sql b/src/DbEx.SqlServer/Resources/SelectTableAndColumns.sql new file mode 100644 index 0000000..838253b --- /dev/null +++ b/src/DbEx.SqlServer/Resources/SelectTableAndColumns.sql @@ -0,0 +1,10 @@ +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx + +SELECT * + FROM INFORMATION_SCHEMA.TABLES as t + INNER JOIN INFORMATION_SCHEMA.COLUMNS as c + ON t.TABLE_CATALOG = c.TABLE_CATALOG + AND t.TABLE_SCHEMA = c.TABLE_SCHEMA + AND t.TABLE_NAME = c.TABLE_NAME + WHERE t.TABLE_CATALOG = '{{DatabaseName}}' + ORDER BY t.TABLE_SCHEMA, t.TABLE_NAME, c.ORDINAL_POSITION \ No newline at end of file diff --git a/src/DbEx.SqlServer/Resources/SelectTablePrimaryKey.sql b/src/DbEx.SqlServer/Resources/SelectTablePrimaryKey.sql new file mode 100644 index 0000000..0f5fa05 --- /dev/null +++ b/src/DbEx.SqlServer/Resources/SelectTablePrimaryKey.sql @@ -0,0 +1,11 @@ +-- Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx + +SELECT kcu.TABLE_SCHEMA, kcu.TABLE_NAME, kcu.CONSTRAINT_NAME, tc.CONSTRAINT_TYPE, kcu.COLUMN_NAME, kcu.ORDINAL_POSITION + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS tc + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS kcu + ON kcu.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + AND kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA + AND kcu.TABLE_NAME = tc.TABLE_NAME + WHERE tc.CONSTRAINT_TYPE in ( 'PRIMARY KEY', 'UNIQUE' ) + ORDER BY kcu.TABLE_SCHEMA, kcu.TABLE_NAME, tc.CONSTRAINT_TYPE, kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION; \ No newline at end of file diff --git a/src/DbEx.SqlServer/SqlServerSchemaConfig.cs b/src/DbEx.SqlServer/SqlServerSchemaConfig.cs index c9b4e24..8f0d2e9 100644 --- a/src/DbEx.SqlServer/SqlServerSchemaConfig.cs +++ b/src/DbEx.SqlServer/SqlServerSchemaConfig.cs @@ -1,13 +1,13 @@ // 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.Migration.Data; +using DbEx.SqlServer.Migration; using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -17,18 +17,21 @@ namespace DbEx.SqlServer /// /// Provides SQL Server specific configuration and capabilities. /// - public class SqlServerSchemaConfig : DatabaseSchemaConfig + /// The owning . + public class SqlServerSchemaConfig(SqlServerMigration migration) : DatabaseSchemaConfig(migration, true, "dbo") { - /// - /// Initializes a new instance of the class. - /// - /// The database name. - public SqlServerSchemaConfig(string databaseName) : base(databaseName) { } - /// /// Value is 'Id'. public override string IdColumnNameSuffix => "Id"; + /// + /// Value is 'Code'. + public override string CodeColumnNameSuffix => "Code"; + + /// + /// Value is 'Json'. + public override string JsonColumnNameSuffix => "Json"; + /// /// Value is 'CreatedDate'. public override string CreatedDateColumnName => "CreatedDate"; @@ -66,48 +69,52 @@ public SqlServerSchemaConfig(string databaseName) : base(databaseName) { } 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 PrepareDataParserArgs(DataParserArgs dataParserArgs) + public override void PrepareMigrationArgs() { - if (dataParserArgs == null) - return; - - if (dataParserArgs.RefDataColumnDefaults.Count == 0) - { - dataParserArgs.RefDataColumnDefaults.TryAdd("IsActive", _ => true); - dataParserArgs.RefDataColumnDefaults.TryAdd("SortOrder", i => i); - } + base.PrepareMigrationArgs(); - dataParserArgs.IdColumnNameSuffix ??= IdColumnNameSuffix; - dataParserArgs.CreatedByColumnName ??= CreatedByColumnName; - dataParserArgs.CreatedDateColumnName ??= CreatedDateColumnName; - dataParserArgs.UpdatedByColumnName ??= UpdatedByColumnName; - dataParserArgs.UpdatedDateColumnName ??= UpdatedDateColumnName; - dataParserArgs.TenantIdColumnName ??= TenantIdColumnName; - dataParserArgs.RowVersionColumnName ??= RowVersionColumnName; - dataParserArgs.IsDeletedColumnName ??= IsDeletedColumnName; - dataParserArgs.RefDataCodeColumnName ??= RefDataCodeColumnName; - dataParserArgs.RefDataTextColumnName ??= RefDataTextColumnName; + Migration.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("IsActive", _ => true); + Migration.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("SortOrder", i => i); } /// - public override DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema table, DatabaseRecord dr) => new(table, dr.GetValue("COLUMN_NAME"), dr.GetValue("DATA_TYPE")) + public override DbColumnSchema CreateColumnFromInformationSchema(DbTableSchema table, DatabaseRecord dr) { - IsNullable = dr.GetValue("IS_NULLABLE").ToUpperInvariant() == "YES", - 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") - }; + 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; + } + + /// + /// 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, DataParserArgs? dataParserArgs, CancellationToken cancellationToken) + 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", new Assembly[] { typeof(SqlServerSchemaConfig).Assembly }); + 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 +#else 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"), @@ -136,8 +143,12 @@ from c in t.Columns } // Select the table identity columns. - using var sr4 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableIdentityColumns.sql", new Assembly[] { typeof(SqlServerSchemaConfig).Assembly }); + 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 => +#else 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) @@ -151,8 +162,12 @@ await database.SqlStatement(await sr4.ReadToEndAsync().ConfigureAwait(false)).Se }, cancellationToken).ConfigureAwait(false); // Select the "always" generated columns. - using var sr5 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableAlwaysGeneratedColumns.sql", new Assembly[] { typeof(SqlServerSchemaConfig).Assembly }); + 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 => +#else 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) @@ -164,8 +179,12 @@ await database.SqlStatement(await sr5.ReadToEndAsync().ConfigureAwait(false)).Se }, cancellationToken).ConfigureAwait(false); // Select the generated columns. - using var sr6 = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableGeneratedColumns.sql", new Assembly[] { typeof(SqlServerSchemaConfig).Assembly }); + 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 => +#else 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) @@ -180,32 +199,31 @@ await database.SqlStatement(await sr6.ReadToEndAsync().ConfigureAwait(false)).Se /// public override string ToDotNetTypeName(DbColumnSchema schema) { - var dbType = (schema ?? throw new ArgumentNullException(nameof(schema))).Type; + var dbType = RemovePrecisionFromDataType(schema.ThrowIfNull(nameof(schema)).Type); if (string.IsNullOrEmpty(dbType)) return "string"; - if (dbType.EndsWith(')')) - { - var i = dbType.LastIndexOf('('); - if (i > 0) - dbType = dbType[..i]; - } + 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", - "DATE" or "DATETIME" or "DATETIME2" or "SMALLDATETIME" => "DateTime", + "DATETIME" or "DATETIME2" or "SMALLDATETIME" => "DateTime", + "DATETIMEOFFSET" => "DateTimeOffset", + "DATE" => "DateTime", // Date only + "TIME" => "TimeSpan", // Time only "ROWVERSION" or "TIMESTAMP" or "BINARY" or "VARBINARY" or "IMAGE" => "byte[]", "BIT" => "bool", - "DATETIMEOFFSET" => "DateTimeOffset", "FLOAT" => "double", "INT" => "int", "BIGINT" => "long", "SMALLINT" => "short", "TINYINT" => "byte", "REAL" => "float", - "TIME" => "TimeSpan", "UNIQUEIDENTIFIER" => "Guid", _ => throw new InvalidOperationException($"Database data type '{dbType}' does not have corresponding .NET type mapping defined."), }; @@ -233,36 +251,19 @@ public override string ToFormattedSqlType(DbColumnSchema schema, bool includeNul } /// - public override string ToFormattedSqlStatementValue(DbColumnSchema dbColumnSchema, DataParserArgs dataParserArgs, object? value) => value switch + 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 => $"'{value}'", - DateTime dt => $"'{dt.ToString(dataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", - DateTimeOffset dto => $"'{dto.ToString(dataParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture)}'", + 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)}'", +#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)}'", +#endif _ => value.ToString()! }; - - /// - public override bool IsDbTypeInteger(string? dbType) => dbType != null && dbType.ToUpperInvariant() switch - { - "INT" or "BIGINT" or "SMALLINT" or "TINYINT" => true, - _ => false - }; - - /// - public override bool IsDbTypeDecimal(string? dbType) => dbType != null && dbType.ToUpperInvariant() switch - { - "DECIMAL" or "MONEY" or "NUMERIC" or "SMALLMONEY" => true, - _ => false - }; - - /// - public override bool IsDbTypeString(string? dbType) => dbType != null && dbType.ToUpperInvariant() switch - { - "NCHAR" or "CHAR" or "NVARCHAR" or "VARCHAR" or "TEXT" or "NTEXT" => true, - _ => false - }; } } \ No newline at end of file diff --git a/src/DbEx/Console/AssemblyValidator.cs b/src/DbEx/Console/AssemblyValidator.cs index 679de1f..ffdaaf5 100644 --- a/src/DbEx/Console/AssemblyValidator.cs +++ b/src/DbEx/Console/AssemblyValidator.cs @@ -16,7 +16,7 @@ namespace DbEx.Console /// /// Validates the assembly name(s). /// - /// The to update. + /// The to update. public class AssemblyValidator(MigrationArgsBase args) : IOptionValidator { private readonly MigrationArgsBase _args = args.ThrowIfNull(nameof(args)); @@ -29,11 +29,8 @@ public class AssemblyValidator(MigrationArgsBase args) : IOptionValidator /// The . public ValidationResult GetValidationResult(CommandOption option, ValidationContext context) { - if (option == null) - throw new ArgumentNullException(nameof(option)); - - if (context == null) - throw new ArgumentNullException(nameof(context)); + option.ThrowIfNull(nameof(option)); + context.ThrowIfNull(nameof(context)); var list = new List(); foreach (var name in option.Values.Where(x => !string.IsNullOrEmpty(x))) diff --git a/src/DbEx/Console/MigrationConsoleBase.cs b/src/DbEx/Console/MigrationConsoleBase.cs index a4b5b93..2e1dc90 100644 --- a/src/DbEx/Console/MigrationConsoleBase.cs +++ b/src/DbEx/Console/MigrationConsoleBase.cs @@ -1,5 +1,6 @@ // 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; @@ -37,8 +38,8 @@ public abstract class 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) => Args = args ?? throw new ArgumentNullException(nameof(args)); + /// The default that will be overridden/updated by the command-line argument values. + protected MigrationConsoleBase(MigrationArgsBase args) => Args = args.ThrowIfNull(nameof(args)); /// /// Gets the . @@ -120,10 +121,10 @@ public async Task RunAsync(string[] args, CancellationToken cancellationTok _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(MigrationArgs.SchemaOrder), app.Option("-so|--schema-order", "Database schema name (multiple can be specified in priority order).", CommandOptionType.MultipleValue)); - ConsoleOptions.Add(nameof(MigrationArgs.OutputDirectory), app.Option("-o|--output", "Output directory path.", CommandOptionType.MultipleValue).Accepts(v => v.ExistingDirectory("Output directory path does not exist."))); - ConsoleOptions.Add(nameof(MigrationArgs.Assemblies), app.Option("-a|--assembly", "Assembly containing embedded resources (multiple can be specified in probing order).", CommandOptionType.MultipleValue)); - ConsoleOptions.Add(nameof(MigrationArgs.Parameters), app.Option("-p|--param", "Parameter expressed as a 'Name=Value' pair (multiple can be specified).", CommandOptionType.MultipleValue)); + 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(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); @@ -138,16 +139,16 @@ public async Task RunAsync(string[] args, CancellationToken cancellationTok return new ValidationResult($"The specified database migration command is not supported."); // Update the options from command line. - var so = GetCommandOption(nameof(MigrationArgs.SchemaOrder)); + 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(MigrationArgs.OutputDirectory), v => Args.OutputDirectory = new DirectoryInfo(v)); + UpdateStringOption(nameof(MigrationArgsBase.OutputDirectory), v => Args.OutputDirectory = new DirectoryInfo(v)); - var vr = ValidateMultipleValue(nameof(MigrationArgs.Assemblies), ctx, (ctx, co) => new AssemblyValidator(Args).GetValidationResult(co, ctx)); + var vr = ValidateMultipleValue(nameof(MigrationArgsBase.Assemblies), ctx, (ctx, co) => new AssemblyValidator(Args).GetValidationResult(co, ctx)); if (vr != ValidationResult.Success) return vr; @@ -157,7 +158,7 @@ public async Task RunAsync(string[] args, CancellationToken cancellationTok Args.AddAssembly(Assembly.GetEntryAssembly()!); }); - vr = ValidateMultipleValue(nameof(MigrationArgs.Parameters), ctx, (ctx, co) => new ParametersValidator(Args).GetValidationResult(co, ctx)); + vr = ValidateMultipleValue(nameof(MigrationArgsBase.Parameters), ctx, (ctx, co) => new ParametersValidator(Args).GetValidationResult(co, ctx)); if (vr != ValidationResult.Success) return vr; diff --git a/src/DbEx/Console/MigrationConsoleBaseT.cs b/src/DbEx/Console/MigrationConsoleBaseT.cs index c0f39b2..9e88619 100644 --- a/src/DbEx/Console/MigrationConsoleBaseT.cs +++ b/src/DbEx/Console/MigrationConsoleBaseT.cs @@ -1,5 +1,6 @@ // 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; @@ -17,7 +18,7 @@ public abstract class MigrationConsoleBase : MigrationConsoleBase where T /// /// Initializes a new instance of the class. /// - /// The default that will be overridden/updated by the command-line argument values. + /// The default that will be overridden/updated by the command-line argument values. protected MigrationConsoleBase(MigrationArgsBase args) : base(args) { } /// @@ -102,7 +103,7 @@ public TSelf Assembly() /// The current instance to supported fluent-style method-chaining. public TSelf OutputDirectory(string path) { - Args.OutputDirectory = new DirectoryInfo(path ?? throw new ArgumentNullException(nameof(path))); + Args.OutputDirectory = new DirectoryInfo(path.ThrowIfNull(nameof(path))); return (TSelf)this; } diff --git a/src/DbEx/Console/ParametersValidator.cs b/src/DbEx/Console/ParametersValidator.cs index a21cbdf..f527b91 100644 --- a/src/DbEx/Console/ParametersValidator.cs +++ b/src/DbEx/Console/ParametersValidator.cs @@ -1,27 +1,22 @@ // 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; using System; using System.ComponentModel.DataAnnotations; using System.Linq; -using YamlDotNet.Core.Tokens; namespace DbEx.Console { /// /// Validate the Params to ensure format is correct and values are not duplicated. /// - public class ParametersValidator : IOptionValidator + /// The to update. + public class ParametersValidator(MigrationArgsBase args) : IOptionValidator { - private readonly MigrationArgsBase _args; - - /// - /// Initilizes a new instance of the class. - /// - /// The to update. - public ParametersValidator(MigrationArgsBase args) => _args = args ?? throw new ArgumentNullException(nameof(args)); + private readonly MigrationArgsBase _args = args.ThrowIfNull(nameof(args)); /// /// Performs the validation. @@ -31,15 +26,12 @@ public class ParametersValidator : IOptionValidator /// The . public ValidationResult GetValidationResult(CommandOption option, ValidationContext context) { - if (option == null) - throw new ArgumentNullException(nameof(option)); - - if (context == null) - throw new ArgumentNullException(nameof(context)); + option.ThrowIfNull(nameof(option)); + context.ThrowIfNull(nameof(context)); foreach (var p in option.Values.Where(x => !string.IsNullOrEmpty(x))) { - var pos = p!.IndexOf("=", StringComparison.InvariantCultureIgnoreCase); + 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 52c2d6d..4029f5b 100644 --- a/src/DbEx/DatabaseExtensions.cs +++ b/src/DbEx/DatabaseExtensions.cs @@ -1,18 +1,15 @@ // 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.Migration.Data; using System; using System.Collections.Generic; +using System.IO; using System.Linq; -using System.Reflection; -using System.Text.RegularExpressions; -using System.Text; using System.Threading; using System.Threading.Tasks; -using OnRamp.Utility; namespace DbEx { @@ -21,38 +18,36 @@ namespace DbEx /// public static class DatabaseExtensions { + private static readonly char[] _snakeCamelCaseSeparatorChars = ['_', '-']; + /// /// Selects all the table and column schema details from the database. /// /// The . - /// The . - /// The optional . + /// The . /// The . /// A list of all the table and column schema details. - public static async Task> SelectSchemaAsync(this IDatabase database, DatabaseSchemaConfig databaseSchemaConfig, DataParserArgs? dataParserArgs = null, CancellationToken cancellationToken = default) + public static async Task> SelectSchemaAsync(this IDatabase database, DatabaseMigrationBase migration, CancellationToken cancellationToken = default) { + database.ThrowIfNull(nameof(database)); + migration.ThrowIfNull(nameof(migration)); + + migration.PreExecutionInitialization(); + var tables = new List(); DbTableSchema? table = null; - dataParserArgs ??= new DataParserArgs(); - databaseSchemaConfig.PrepareDataParserArgs(dataParserArgs); - var idColumnNameSuffix = dataParserArgs?.IdColumnNameSuffix!; - var refDataCodeColumn = dataParserArgs?.RefDataCodeColumnName!; - var refDataTextColumn = dataParserArgs?.RefDataTextColumnName!; - var refDataPredicate = new Func(t => t.Columns.Any(c => c.Name == refDataCodeColumn && !c.IsPrimaryKey && c.DotNetType == "string") && t.Columns.Any(c => c.Name == refDataTextColumn && !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. - using var sr = DatabaseMigrationBase.GetRequiredResourcesStreamReader("SelectTableAndColumns.sql", [typeof(DatabaseExtensions).Assembly]); -#if NET7_0_OR_GREATER - await database.SqlStatement(await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => -#else - await database.SqlStatement(await sr.ReadToEndAsync().ConfigureAwait(false)).SelectQueryAsync(dr => -#endif + 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 (!databaseSchemaConfig.SupportsSchema && dr.GetValue("TABLE_SCHEMA") != databaseSchemaConfig.DatabaseName) + if (!migration.SchemaConfig.SupportsSchema && dr.GetValue("TABLE_SCHEMA") != migration.DatabaseName) return 0; - var dt = new DbTableSchema(databaseSchemaConfig, dr.GetValue("TABLE_SCHEMA"), dr.GetValue("TABLE_NAME")) + var dt = new DbTableSchema(migration, dr.GetValue("TABLE_SCHEMA"), dr.GetValue("TABLE_NAME")) { IsAView = dr.GetValue("TABLE_TYPE") == "VIEW" }; @@ -60,12 +55,12 @@ await database.SqlStatement(await sr.ReadToEndAsync().ConfigureAwait(false)).Sel if (table == null || table.Schema != dt.Schema || table.Name != dt.Name) tables.Add(table = dt); - var dc = databaseSchemaConfig.CreateColumnFromInformationSchema(table, dr); - dc.IsCreatedAudit = dc.Name == dataParserArgs?.CreatedByColumnName || dc.Name == dataParserArgs?.CreatedDateColumnName; - dc.IsUpdatedAudit = dc.Name == dataParserArgs?.UpdatedByColumnName || dc.Name == dataParserArgs?.UpdatedDateColumnName; - dc.IsTenantId = dc.Name == dataParserArgs?.TenantIdColumnName; - dc.IsRowVersion = dc.Name == dataParserArgs?.RowVersionColumnName; - dc.IsIsDeleted = dc.Name == dataParserArgs?.IsDeletedColumnName; + 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; @@ -80,26 +75,22 @@ await database.SqlStatement(await sr.ReadToEndAsync().ConfigureAwait(false)).Sel { t.IsRefData = refDataPredicate(t); if (t.IsRefData) - t.RefDataCodeColumn = t.Columns.Where(x => x.Name == refDataCodeColumn).SingleOrDefault(); + 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", [typeof(DatabaseExtensions).Assembly]); -#if NET7_0_OR_GREATER - var pks = await database.SqlStatement(await sr2.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).SelectQueryAsync(dr => new -#else - var pks = await database.SqlStatement(await sr2.ReadToEndAsync().ConfigureAwait(false)).SelectQueryAsync(dr => new -#endif + 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.InvariantCultureIgnoreCase) + IsPrimaryKey = dr.GetValue("CONSTRAINT_TYPE").StartsWith("PRIMARY", StringComparison.OrdinalIgnoreCase) }, cancellationToken).ConfigureAwait(false); - if (!databaseSchemaConfig.SupportsSchema) - pks = pks.Where(x => x.TableSchema == databaseSchemaConfig.DatabaseName).ToArray(); + 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 })) { @@ -112,7 +103,7 @@ await database.SqlStatement(await sr.ReadToEndAsync().ConfigureAwait(false)).Sel { var col = (from t in tables from c in t.Columns - where (!databaseSchemaConfig.SupportsSchema || t.Schema == pk.TableSchema) && t.Name == pk.TableName && c.Name == pk.TableColumnName + where (!migration.SchemaConfig.SupportsSchema || t.Schema == pk.TableSchema) && t.Name == pk.TableName && c.Name == pk.TableColumnName select c).SingleOrDefault(); if (col == null) @@ -130,7 +121,7 @@ from c in t.Columns } // Load any additional configuration specific to the database provider. - await databaseSchemaConfig.LoadAdditionalInformationSchema(database, tables, dataParserArgs, cancellationToken).ConfigureAwait(false); + 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) @@ -140,16 +131,20 @@ from c in t.Columns if (c.ForeignTable != null) { if (c.IsForeignRefData) - c.ForeignRefDataCodeColumn = refDataCodeColumn; + { + 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(idColumnNameSuffix, StringComparison.InvariantCultureIgnoreCase)) + 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..^idColumnNameSuffix.Length] && x.IsRefData && x.PrimaryKeyColumns.Count == 1).FirstOrDefault(); + 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; @@ -157,13 +152,12 @@ from c in t.Columns c.ForeignTable = fk.Name; c.ForeignColumn = fk.PrimaryKeyColumns[0].Name; c.IsForeignRefData = true; - c.ForeignRefDataCodeColumn = refDataCodeColumn; + 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. - var sb = new StringBuilder(); - foreach (var t in tables) { foreach (var c in t.Columns.Where(x => !x.IsPrimaryKey)) @@ -174,19 +168,38 @@ from c in t.Columns continue; } - sb.Clear(); - c.Name.Split(new char[] { '_', '-' }, StringSplitOptions.RemoveEmptyEntries).ForEach(part => sb.Append(StringConverter.ToPascalCase(part))); - var words = Regex.Split(sb.ToString(), DbTableSchema.WordSplitPattern).Where(x => !string.IsNullOrEmpty(x)); - if (words.Count() > 1 && new string[] { "Id", "Code" }.Contains(words.Last(), StringComparer.InvariantCultureIgnoreCase)) + // 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)) { - var name = string.Join(string.Empty, words.Take(words.Count() - 1)); - if (tables.Any(x => x.Name == name && x.Schema == t.Schema && x.IsRefData)) - c.IsRefData = true; + c.IsRefData = true; + c.DotNetCleanedName = DbTableSchema.CreateDotNetName(name); } } } 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); + } } } \ No newline at end of file diff --git a/src/DbEx/DatabaseSchemaConfig.cs b/src/DbEx/DatabaseSchemaConfig.cs index 7d23119..7317aa0 100644 --- a/src/DbEx/DatabaseSchemaConfig.cs +++ b/src/DbEx/DatabaseSchemaConfig.cs @@ -1,13 +1,13 @@ // 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.Data; +using DbEx.Migration; using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -16,30 +16,47 @@ namespace DbEx /// /// Enables database provider specific configuration and capabilities. /// - public abstract class DatabaseSchemaConfig + /// 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; + /// - /// Initializes a new instance of the class. + /// Gets the owning . /// - /// The database name. - /// Indicates whether the database supports per-database schema-based separation. - protected DatabaseSchemaConfig(string databaseName, bool supportsSchema = true) - { - DatabaseName = databaseName; - SupportsSchema = supportsSchema; - RefDataPredicate = new Func(t => t.Columns.Any(c => c.Name == RefDataCodeColumnName && !c.IsPrimaryKey && c.DotNetType == "string") && t.Columns.Any(c => c.Name == RefDataTextColumnName && !c.IsPrimaryKey && c.DotNetType == "string")); - } + public DatabaseMigrationBase Migration { get; } = migration.ThrowIfNull(nameof(migration)); /// - /// Gets or sets the database name. + /// Indicates whether the database supports per-database schema-based separation. /// - /// Used to filter schemas for database that do not . - public string DatabaseName { get; } + public bool SupportsSchema { get; } = supportsSchema; /// - /// Indicates whether the database supports per-database schema-based separation. + /// 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 bool SupportsSchema { get; } + public abstract string JsonColumnNameSuffix { get; } /// /// Gets the name of the column (where it exists). @@ -71,39 +88,41 @@ protected DatabaseSchemaConfig(string databaseName, bool supportsSchema = true) /// public abstract string RowVersionColumnName { get; } - /// - /// Gets the default column. - /// - public abstract string RefDataCodeColumnName { get; } - - /// - /// Gets the default column. - /// - public abstract string RefDataTextColumnName { get; } - /// /// Gets the default column. /// public abstract string IsDeletedColumnName { get; } /// - /// Gets the default reference data predicate to determine . + /// Gets the default column. /// - /// By default determined by existence of columns named and (case-insensitive), that are equal false - /// and equal 'string'. - public virtual Func RefDataPredicate { get; } + public abstract string RefDataCodeColumnName { get; } /// - /// Gets or sets the suffix of the identifier column where not fully specified. + /// Gets the default 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; } + public abstract string RefDataTextColumnName { get; } /// - /// Prepares the prior to parsing as a final opportunity to finalize any standard defaults. + /// Prepares the as the final opportunity to finalize any standard defaults. /// - /// The . - public abstract void PrepareDataParserArgs(DataParserArgs dataParserArgs); + /// 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.CreatedDateColumnName ??= CreatedDateColumnName; + Migration.Args.UpdatedByColumnName ??= UpdatedByColumnName; + Migration.Args.UpdatedDateColumnName ??= UpdatedDateColumnName; + Migration.Args.TenantIdColumnName ??= TenantIdColumnName; + Migration.Args.RowVersionColumnName ??= RowVersionColumnName; + Migration.Args.IsDeletedColumnName ??= IsDeletedColumnName; + Migration.Args.RefDataCodeColumnName ??= RefDataCodeColumnName; + Migration.Args.RefDataTextColumnName ??= RefDataTextColumnName; + } /// /// Creates the from the `InformationSchema.Columns` . @@ -118,9 +137,8 @@ protected DatabaseSchemaConfig(string databaseName, bool supportsSchema = true) /// /// The . /// The list to load additional data into. - /// The . /// The . - public virtual Task LoadAdditionalInformationSchema(IDatabase database, List tables, DataParserArgs? dataParserArgs, CancellationToken cancellationToken) => Task.CompletedTask; + public virtual Task LoadAdditionalInformationSchema(IDatabase database, List tables, CancellationToken cancellationToken) => Task.CompletedTask; /// /// Gets the and formatted as the fully qualified name. @@ -128,7 +146,7 @@ protected DatabaseSchemaConfig(string databaseName, bool supportsSchema = true) /// The schema name. /// The table name. /// The fully qualified name. - public abstract string ToFullyQualifiedTableName(string schema, string table); + public abstract string ToFullyQualifiedTableName(string? schema, string table); /// /// Gets the corresponding .NET name for the specified . @@ -149,30 +167,8 @@ protected DatabaseSchemaConfig(string databaseName, bool supportsSchema = true) /// Gets the formatted SQL statement representation of the . /// /// The . - /// The . /// The value. /// The formatted SQL statement representation. - public abstract string ToFormattedSqlStatementValue(DbColumnSchema dbColumnSchema, DataParserArgs dataParserArgs, object? value); - - /// - /// Indicates whether the is considered an integer. - /// - /// The database type. - /// true indicates it is; otherwise, false. - public abstract bool IsDbTypeInteger(string? dbType); - - /// - /// Indicates whether the is considered a decimal. - /// - /// The database type. - /// true indicates it is; otherwise, false. - public abstract bool IsDbTypeDecimal(string? dbType); - - /// - /// Indicates whether the is considered a string. - /// - /// The database type. - /// true indicates it is; otherwise, false. - public abstract bool IsDbTypeString(string? dbType); + public abstract string ToFormattedSqlStatementValue(DbColumnSchema dbColumnSchema, object? value); } } \ No newline at end of file diff --git a/src/DbEx/DbEx.csproj b/src/DbEx/DbEx.csproj index 474c427..29e359a 100644 --- a/src/DbEx/DbEx.csproj +++ b/src/DbEx/DbEx.csproj @@ -15,13 +15,12 @@ - - + diff --git a/src/DbEx/DbSchema/DbColumnSchema.cs b/src/DbEx/DbSchema/DbColumnSchema.cs index 4314be9..717706f 100644 --- a/src/DbEx/DbSchema/DbColumnSchema.cs +++ b/src/DbEx/DbSchema/DbColumnSchema.cs @@ -1,5 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +using CoreEx; using System; using System.Diagnostics; @@ -8,41 +9,32 @@ 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 + public class DbColumnSchema(DbTableSchema dbTable, string name, string type, string? dotNetNameOverride = null) { private string? _dotNetType; - private string? _dotNetName; + private string? _dotNetName = dotNetNameOverride; private string? _dotNetCleanedName; private string? _sqlType; - /// - /// Initializes a new instance of the class. - /// - /// The owning (parent) . - /// The column name. - /// The column type. - public DbColumnSchema(DbTableSchema dbTable, string name, string type) - { - DbTable = dbTable ?? throw new ArgumentNullException(nameof(dbTable)); - Name = name ?? throw new ArgumentNullException(nameof(name)); - Type = type ?? throw new ArgumentNullException(nameof(type)); - } - /// /// Gets the owning (parent) . /// - public DbTableSchema DbTable { get; } + public DbTableSchema DbTable { get; } = dbTable.ThrowIfNull(nameof(dbTable)); /// /// Gets the column name. /// - public string Name { get; } + public string Name { get; } = name.ThrowIfNull(nameof(name)); /// /// Gets the SQL Server data type. /// - public string Type { get; } + public string Type { get; } = type.ThrowIfNull(nameof(type)); /// /// Indicates whether the column is nullable. @@ -165,14 +157,14 @@ public DbColumnSchema(DbTableSchema dbTable, string name, string type) public bool IsIsDeleted { get; set; } /// - /// Indicates whether the column may contain JSON content by convention ( is a `string` and the ends with `Json`) . + /// 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 => DotNetType == "string" && Name.EndsWith("Json", StringComparison.OrdinalIgnoreCase); + public bool IsJsonContent { get; set; } /// /// Gets the corresponding .NET name. /// - public string DotNetType => _dotNetType ??= DbTable?.Config.ToDotNetTypeName(this) ?? throw new InvalidOperationException($"The {nameof(DbTable)} must be set before the {nameof(DotNetType)} property can be accessed."); + 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. @@ -182,17 +174,38 @@ public DbColumnSchema(DbTableSchema dbTable, string name, string type) /// /// Gets the corresponding .NET name cleaned; by removing any known suffixes where or /// - public string DotNetCleanedName => _dotNetCleanedName ??= DbTableSchema.CreateDotNetName(Name, IsRefData || IsJsonContent); + public string DotNetCleanedName { get => _dotNetCleanedName ?? DotNetName; set => _dotNetCleanedName = value; } /// /// Gets the fully defined SQL type. /// - public string SqlType => _sqlType ??= DbTable?.Config.ToFormattedSqlType(this) ?? throw new InvalidOperationException($"The {nameof(DbTable)} must be set before the {nameof(SqlType)} property can be accessed."); + 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."); + +#if NET7_0_OR_GREATER + /// + /// Indicates that the type can be expressed as a .NET type. + /// +#else + /// + /// Indicates that the type can be expressed as a DateOnly .NET type. + /// +#endif + public bool IsDotNetDateOnly { get; set; } + +#if NET7_0_OR_GREATER + /// + /// Indicates that the type can be expressed as a .NET type. + /// +#else + /// + /// 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); @@ -206,7 +219,11 @@ public DbColumnSchema Clone() /// The to copy from. public void CopyFrom(DbColumnSchema column) { - IsNullable = (column ?? throw new ArgumentNullException(nameof(column))).IsNullable; + _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; @@ -228,10 +245,8 @@ public void CopyFrom(DbColumnSchema column) IsRowVersion = column.IsRowVersion; IsTenantId = column.IsTenantId; IsIsDeleted = column.IsIsDeleted; - _dotNetType = column._dotNetType; - _dotNetName = column._dotNetName; - _dotNetCleanedName = column._dotNetCleanedName; - _sqlType = column._sqlType; + 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 b705fc4..f18218f 100644 --- a/src/DbEx/DbSchema/DbTableSchema.cs +++ b/src/DbEx/DbSchema/DbTableSchema.cs @@ -1,6 +1,9 @@ // 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; using System.Collections.Generic; @@ -8,7 +11,6 @@ using System.Diagnostics; using System.Linq; using System.Text; -using System.Text.RegularExpressions; namespace DbEx.DbSchema { @@ -16,16 +18,14 @@ namespace DbEx.DbSchema /// Represents the Database Table schema definition. /// [DebuggerDisplay("{QualifiedName}")] - public class DbTableSchema + public partial class DbTableSchema { + private static readonly char[] _separators = ['_', '-']; + private static readonly string[] _suffixes = ["Id", "Code", "Json"]; + private string? _dotNetName; private string? _pluralName; - /// - /// The expression pattern for splitting strings into words. - /// - public const string WordSplitPattern = "([a-z](?=[A-Z])|[A-Z](?=[A-Z][a-z]))"; - /// /// Create an alias from the name. /// @@ -34,9 +34,7 @@ public class DbTableSchema /// 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) { - if (string.IsNullOrEmpty(name)) - throw new ArgumentNullException(nameof(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()); } @@ -45,25 +43,14 @@ public static string CreateAlias(string name) /// Create a .NET friendly name. /// /// The name. - /// Indicates whether to remove the known suffix. /// The .NET friendly name. - public static string CreateDotNetName(string name, bool removeKnownSuffix = false) + /// Removes any snake/camel case separator characters and converts each separated work into Pascal case before combining. + public static string CreateDotNetName(string name) { - if (string.IsNullOrEmpty(name)) - throw new ArgumentNullException(nameof(name)); - + name.ThrowIfNullOrEmpty(nameof(name)); var sb = new StringBuilder(); - name.Split(new char[] { '_', '-' }, StringSplitOptions.RemoveEmptyEntries).ForEach(part => sb.Append(StringConverter.ToPascalCase(part))); - var dotNet = sb.ToString(); - - if (removeKnownSuffix) - { - var words = Regex.Split(dotNet, WordSplitPattern).Where(x => !string.IsNullOrEmpty(x)); - if (words.Count() > 1 && new string[] { "Id", "Code", "Json" }.Contains(words.Last(), StringComparer.InvariantCultureIgnoreCase)) - dotNet = string.Join(string.Empty, words.Take(words.Count() - 1)); - } - - return dotNet; + name.Split(_separators, StringSplitOptions.RemoveEmptyEntries).ForEach(part => sb.Append(StringConverter.ToPascalCase(part))); + return sb.ToString(); } /// @@ -73,10 +60,8 @@ public static string CreateDotNetName(string name, bool removeKnownSuffix = fals /// The pluralized name. public static string CreatePluralName(string name) { - if (string.IsNullOrEmpty(name)) - throw new ArgumentNullException(nameof(name)); - - var words = Regex.Split(name, WordSplitPattern).Where(x => !string.IsNullOrEmpty(x)).ToList(); + name.ThrowIfNullOrEmpty(nameof(name)); + var words = SentenceCase.SplitIntoWords(name).Where(x => !string.IsNullOrEmpty(x)).ToList(); words[^1] = StringConverter.ToPlural(words[^1]); return string.Join(string.Empty, words); } @@ -88,10 +73,8 @@ public static string CreatePluralName(string name) /// The singular name. public static string CreateSingularName(string name) { - if (string.IsNullOrEmpty(name)) - throw new ArgumentNullException(nameof(name)); - - var words = Regex.Split(name, WordSplitPattern).Where(x => !string.IsNullOrEmpty(x)).ToList(); + name.ThrowIfNullOrEmpty(nameof(name)); + var words = SentenceCase.SplitIntoWords(name).Where(x => !string.IsNullOrEmpty(x)).ToList(); words[^1] = StringConverter.ToSingle(words[^1]); return string.Join(string.Empty, words); } @@ -99,15 +82,15 @@ public static string CreateSingularName(string name) /// /// Initializes a new instance of the class. /// - /// The database schema configuration. + /// The . /// The schema name. /// The table name. - public DbTableSchema(DatabaseSchemaConfig config, string schema, string name) + public DbTableSchema(DatabaseMigrationBase migration, string? schema, string name) { - Config = config ?? throw new ArgumentNullException(nameof(config)); - Schema = config.SupportsSchema ? (schema ?? throw new ArgumentNullException(nameof(schema))) : string.Empty; - Name = name ?? throw new ArgumentNullException(nameof(name)); - QualifiedName = config.ToFullyQualifiedTableName(schema, 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); } @@ -117,7 +100,7 @@ public DbTableSchema(DatabaseSchemaConfig config, string schema, string name) /// The existing . public DbTableSchema(DbTableSchema table) { - Config = table.Config; + Migration = table.Migration; Schema = table.Schema; Name = table.Name; QualifiedName = table.QualifiedName; @@ -129,9 +112,14 @@ public DbTableSchema(DbTableSchema table) } /// - /// Gets the . + /// Gets the schema name. /// - public DatabaseSchemaConfig Config { get; } + public string Schema { get; } + + /// + /// Gets the . + /// + public DatabaseMigrationBase Migration { get; } /// /// Gets the table name. @@ -148,11 +136,6 @@ public DbTableSchema(DbTableSchema table) /// public string PluralName => _pluralName ??= CreatePluralName(DotNetName); - /// - /// Gets the schema name. - /// - public string Schema { get; } - /// /// Gets or sets the alias (automatically updated from the when instantiated). /// @@ -171,6 +154,8 @@ public DbTableSchema(DbTableSchema table) /// /// 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; } /// @@ -212,5 +197,10 @@ public DbTableSchema(DbTableSchema table) /// Gets or sets the . /// 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/Migration/Data/DataColumn.cs b/src/DbEx/Migration/Data/DataColumn.cs index e9cc6cf..894b0fc 100644 --- a/src/DbEx/Migration/Data/DataColumn.cs +++ b/src/DbEx/Migration/Data/DataColumn.cs @@ -1,5 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +using CoreEx; using DbEx.DbSchema; using System; @@ -17,8 +18,8 @@ public class DataColumn /// internal DataColumn(DataTable table, string name) { - Table = table ?? throw new ArgumentNullException(nameof(table)); - Name = name ?? throw new ArgumentNullException(nameof(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)) @@ -54,6 +55,6 @@ internal DataColumn(DataTable table, string name) /// Gets the value formatted for use in a SQL statement. /// /// The value formatted for use in a SQL statement. - public string SqlValue => Table.DbTable.Config.ToFormattedSqlStatementValue(DbColumn ?? throw new InvalidOperationException("The DbColumn property must not be null."), Table.Args, Value); + 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/DataParser.cs b/src/DbEx/Migration/Data/DataParser.cs index 8f8cf67..1d50d81 100644 --- a/src/DbEx/Migration/Data/DataParser.cs +++ b/src/DbEx/Migration/Data/DataParser.cs @@ -1,5 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +using CoreEx; using DbEx.DbSchema; using HandlebarsDotNet; using System; @@ -7,7 +8,6 @@ using System.IO; using System.Linq; using System.Text.Json; -using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using YamlDotNet.Core.Events; @@ -53,20 +53,18 @@ bool INodeTypeResolver.Resolve(NodeEvent? nodeEvent, ref Type currentType) /// /// Initializes a new instance of the class. /// - /// The . + /// The owning . /// The list. - /// The optional (will use defaults where not specified). - public DataParser(DatabaseSchemaConfig databaseSchemaConfig, List dbTables, DataParserArgs? args = null) + internal DataParser(DatabaseMigrationBase migration, List dbTables) { - DatabaseSchemaConfig = databaseSchemaConfig ?? throw new ArgumentNullException(nameof(databaseSchemaConfig)); - DbTables = dbTables ?? throw new ArgumentNullException(nameof(dbTables)); - databaseSchemaConfig.PrepareDataParserArgs(Args = args ?? new DataParserArgs()); + Migration = migration.ThrowIfNull(nameof(migration)); + DbTables = dbTables.ThrowIfNull(nameof(dbTables)); } /// - /// Gets the . + /// Gets the owning . /// - public DatabaseSchemaConfig DatabaseSchemaConfig { get; } + public DatabaseMigrationBase Migration { get; } /// /// Gets the list. @@ -76,7 +74,7 @@ public DataParser(DatabaseSchemaConfig databaseSchemaConfig, List /// /// Gets the . /// - public DataParserArgs Args { get; } + public DataParserArgs ParserArgs => Migration.Args.DataParserArgs; /// /// Reads and parses the database using the specified YAML . @@ -157,8 +155,8 @@ public Task> ParseJsonAsync(TextReader tr, CancellationToken can private async Task> ParseJsonAsync(JsonDocument json, CancellationToken cancellationToken) { // Further update/manipulate the schema. - if (Args.DbSchemaUpdaterAsync != null) - DbTables = await Args.DbSchemaUpdaterAsync(DbTables, cancellationToken).ConfigureAwait(false); + if (ParserArgs.DbSchemaUpdaterAsync != null) + DbTables = await ParserArgs.DbSchemaUpdaterAsync(DbTables, cancellationToken).ConfigureAwait(false); // Parse table/row/column data. var tables = new List(); @@ -205,7 +203,7 @@ private async Task> ParseJsonAsync(JsonDocument json, Cancellati 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, DatabaseSchemaConfig.SupportsSchema ? schema : string.Empty, jp.Name); + 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); @@ -232,8 +230,8 @@ private async Task ParseTableJsonAsync(List tables, DataRow? parent, default: if (sdt.IsRefData && jro.EnumerateObject().Count() == 1) { - row.AddColumn(Args.RefDataCodeColumnName ?? DatabaseSchemaConfig.RefDataCodeColumnName, jr.Name); - row.AddColumn(Args.RefDataTextColumnName ?? DatabaseSchemaConfig.RefDataTextColumnName, jr.Value.GetString()); + row.AddColumn(Migration.Args.RefDataCodeColumnName!, jr.Name); + row.AddColumn(Migration.Args.RefDataTextColumnName!, jr.Value.GetString()); } else row.AddColumn(jr.Name, GetColumnValue(jr.Value)); @@ -256,77 +254,23 @@ private async Task ParseTableJsonAsync(List tables, DataRow? parent, sdt.AddRow(row); } - //// Loop through the collection of rows. - //foreach (var jro in GetChildObjects(jp)) - //{ - // var row = new DataRow(sdt); - - // foreach (var jr in jro.Children()) - // { - // if (jr.Value.Type == JTokenType.Object) - // { - // throw new DataParserException($"Table '{sdt.Schema}.{sdt.Name}' has unsupported '{jr.Name}' column value; must not be an object: {jr.Value}."); - // } - // else if (jr.Value.Type == JTokenType.Array) - // { - // // Try parsing as a further described nested table configuration; i.e. representing a relationship. - // await ParseTableJsonAsync(tables, row, sdt.Schema, jr, cancellationToken).ConfigureAwait(false); - // } - // else - // { - // if (sdt.IsRefData && jro.Children().Count() == 1) - // { - // row.AddColumn(Args.RefDataCodeColumnName ?? DatabaseSchemaConfig.RefDataCodeColumnName, GetColumnValue(jr.Name)); - // row.AddColumn(Args.RefDataTextColumnName ?? DatabaseSchemaConfig.RefDataTextColumnName, GetColumnValue(jr.Value)); - // } - // else - // row.AddColumn(jr.Name, GetColumnValue(jr.Value)); - // } - // } - - // // 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); - // } - // } - - // sdt.AddRow(row); - //} - if (sdt.Columns.Count > 0) await sdt.PrepareAsync(cancellationToken).ConfigureAwait(false); } - ///// - ///// Gets the child objects. - ///// - //private static IEnumerable GetChildObjects(JToken j) - //{ - // foreach (var jc in j.Children()) - // { - // return jc.Children(); - // } - - // return Array.Empty(); - //} - /// /// Gets the column value. /// private object? GetColumnValue(JsonElement j) { + // TODO: Can we be smarter about the datetime parsing?!? return j.ValueKind switch { JsonValueKind.Null => null, JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Number => j.GetDecimal(), - JsonValueKind.String => j.TryGetDateTime(out var dt) ? dt : GetRuntimeParameterValue(j.GetString()), + JsonValueKind.String => GetRuntimeParameterValue(j.GetString()), _ => null }; } @@ -347,10 +291,10 @@ private async Task ParseTableJsonAsync(List tables, DataRow? parent, // Check against known values and runtime parameters. switch (key) { - case "UserName": return Args.UserName; - case "DateTimeNow": return Args.DateTimeNow; + case "UserName": return ParserArgs.UserName; + case "DateTimeNow": return ParserArgs.DateTimeNow; default: - if (Args.Parameters.TryGetValue(key, out object? dval)) + if (ParserArgs.Parameters.TryGetValue(key, out object? dval)) return dval; break; diff --git a/src/DbEx/Migration/Data/DataParserArgs.cs b/src/DbEx/Migration/Data/DataParserArgs.cs index ee5235c..676f411 100644 --- a/src/DbEx/Migration/Data/DataParserArgs.cs +++ b/src/DbEx/Migration/Data/DataParserArgs.cs @@ -1,7 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx -using CoreEx.Entities; -using CoreEx.RefData; +using CoreEx; using DbEx.DbSchema; using System; using System.Collections.Generic; @@ -40,77 +39,28 @@ public DataParserArgs() { } public DateTime DateTimeNow { get; set; } = DateTime.UtcNow; /// - /// Gets or sets the suffix of the identifier column where not fully specified. - /// - /// 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 name of the column (where it exists). - /// - /// Defaults to where not specified (i.e. null). - public string? CreatedDateColumnName { get; set; } - - /// - /// Gets or sets the name of the 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). - /// - /// Defaults to where not specified (i.e. null). - public string? UpdatedDateColumnName { get; set; } - - /// - /// Gets or sets the name of the 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). - /// - /// 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). - /// - /// Defaults to where not specified (i.e. null). - public string? RowVersionColumnName { get; set; } - - /// - /// Gets or sets the name of the 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 . /// - /// Defaults to where not specified (i.e. null). - public string? RefDataCodeColumnName { get; set; } + /// Defaults to . + public IIdentifierGenerator IdentifierGenerator { get; set; } = new GuidIdentifierGenerator(); /// - /// Gets or sets the name of the column. + /// Gets or sets the format. /// - /// Defaults to where not specified (i.e. null). - public string? RefDataTextColumnName { get; set; } + /// Defaults to 'yyyy-MM-ddTHH:mm:ss.FFFFFFF'. + public string DateTimeFormat { get; set; } = "yyyy-MM-ddTHH:mm:ss.FFFFFFF"; /// - /// Gets or sets the . + /// Gets or sets the format. /// - /// Defaults to . - public IIdentifierGenerator IdentifierGenerator { get; set; } = new GuidIdentifierGenerator(); + /// Defaults to 'yyyy-MM-dd'. + public string DateOnlyFormat { get; set; } = "yyyy-MM-dd"; /// /// Gets or sets the format. /// - /// Defaults to 'yyyy-MM-ddTHH:mm:ss.fffffff'. - public string DateTimeFormat { get; set; } = "yyyy-MM-ddTHH:mm:ss.fffffff"; + /// Defaults to 'HH:mm:ss.FFFFFFF'. + public string TimeOnlyFormat { get; set; } = "HH:mm:ss.FFFFFFF"; /// /// Gets or sets the reference data column defaults dictionary. @@ -187,20 +137,10 @@ public DataParserArgs Parameter(string key, object? value, bool overrideExisting /// The to copy from. public void CopyFrom(DataParserArgs args) { - if (args == null) - throw new ArgumentNullException(nameof(args)); + args.ThrowIfNull(nameof(args)); UserName = args.UserName; DateTimeNow = args.DateTimeNow; - IdColumnNameSuffix = args.IdColumnNameSuffix; - CreatedDateColumnName = args.CreatedDateColumnName; - CreatedByColumnName = args.CreatedByColumnName; - UpdatedDateColumnName = args.UpdatedDateColumnName; - UpdatedByColumnName = args.UpdatedByColumnName; - RowVersionColumnName = args.RowVersionColumnName; - TenantIdColumnName = args.TenantIdColumnName; - RefDataCodeColumnName = args.RefDataCodeColumnName; - RefDataTextColumnName = args.RefDataTextColumnName; IdentifierGenerator = args.IdentifierGenerator; DateTimeFormat = args.DateTimeFormat; DbSchemaUpdaterAsync = args.DbSchemaUpdaterAsync; diff --git a/src/DbEx/Migration/Data/DataParserColumnDefault.cs b/src/DbEx/Migration/Data/DataParserColumnDefault.cs index 0c4e2f4..b085572 100644 --- a/src/DbEx/Migration/Data/DataParserColumnDefault.cs +++ b/src/DbEx/Migration/Data/DataParserColumnDefault.cs @@ -1,5 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +using CoreEx; using System; namespace DbEx.Migration.Data @@ -7,41 +8,30 @@ namespace DbEx.Migration.Data /// /// Provides the configuration. /// - public class DataParserColumnDefault + /// 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) { - /// - /// Initializes a new instance of the class. - /// - /// 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 DataParserColumnDefault(string schema, string table, string column, Func @default) - { - Schema = schema ?? throw new ArgumentNullException(nameof(schema)); - Table = table ?? throw new ArgumentNullException(nameof(table)); - Column = column ?? throw new ArgumentNullException(nameof(column)); - Default = @default ?? throw new ArgumentNullException(nameof(@default)); - } - /// /// Gets the schema name; a '*' denotes any schema. /// - public string Schema { get; } + public string Schema { get; } = schema.ThrowIfNull(nameof(schema)); /// /// Gets the table name; a '*' denotes any table. /// - public string Table { get; } + public string Table { get; } = table.ThrowIfNull(nameof(table)); /// /// Gets the column name. /// - public string Column { get; } + public string Column { get; } = column.ThrowIfNull(nameof(column)); /// /// Gets the function that provides the default value. /// - public Func Default { get; } + 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 8f37977..0fde106 100644 --- a/src/DbEx/Migration/Data/DataParserColumnDefaultCollection.cs +++ b/src/DbEx/Migration/Data/DataParserColumnDefaultCollection.cs @@ -1,5 +1,6 @@ // 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; @@ -33,14 +34,9 @@ public class DataParserColumnDefaultCollection : KeyedCollection<(string, string /// public bool TryGetValue(string schema, string table, string column, [NotNullWhen(true)] out DataParserColumnDefault? item) { - if (schema == null) - throw new ArgumentNullException(nameof(schema)); - - if (table == null) - throw new ArgumentNullException(nameof(table)); - - if (column == null) - throw new ArgumentNullException(nameof(column)); + schema.ThrowIfNull(nameof(schema)); + table.ThrowIfNull(nameof(table)); + column.ThrowIfNull(nameof(column)); if (TryGetValue((schema, table, column), out item)) return true; @@ -63,7 +59,7 @@ public bool TryGetValue(string schema, string table, string column, [NotNullWhen public DataParserColumnDefaultCollection GetDefaultsForTable(DbTableSchema table) { var dc = new DataParserColumnDefaultCollection(); - foreach (var c in (table ?? throw new ArgumentNullException(nameof(table))).Columns) + foreach (var c in table.ThrowIfNull(nameof(table)).Columns) { if (TryGetValue(table.Schema, table.Name, c.Name, out var item)) dc.Add(item); diff --git a/src/DbEx/Migration/Data/DataParserTableNameMappings.cs b/src/DbEx/Migration/Data/DataParserTableNameMappings.cs index b8bcb4d..5c0ac61 100644 --- a/src/DbEx/Migration/Data/DataParserTableNameMappings.cs +++ b/src/DbEx/Migration/Data/DataParserTableNameMappings.cs @@ -1,5 +1,6 @@ // 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; @@ -37,7 +38,7 @@ public DataParserTableNameMappings Add(string? parsedSchema, string parsedTable, /// 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 ?? throw new ArgumentNullException(nameof(columnMappings)))); + _dict.Add((EmptyWhereNull(schema), table), (EmptyWhereNull(schema), table, columnMappings.ThrowIfNull(nameof(columnMappings)))); return this; } diff --git a/src/DbEx/Migration/Data/DataRow.cs b/src/DbEx/Migration/Data/DataRow.cs index 81b0f0f..6a0e3e3 100644 --- a/src/DbEx/Migration/Data/DataRow.cs +++ b/src/DbEx/Migration/Data/DataRow.cs @@ -1,5 +1,6 @@ // 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; @@ -15,7 +16,7 @@ public class DataRow /// Initializes a new instance of the class. /// /// The parent . - internal DataRow(DataTable table) => Table = table ?? throw new ArgumentNullException(nameof(table)); + internal DataRow(DataTable table) => Table = table.ThrowIfNull(nameof(table)); /// /// Gets the . @@ -55,9 +56,7 @@ public class DataRow /// The . public void AddColumn(DataColumn column) { - if (column == null) - throw new ArgumentNullException(nameof(column)); - + column.ThrowIfNull(nameof(column)); if (string.IsNullOrEmpty(column.Name)) throw new ArgumentException("Column.Name must have a value.", nameof(column)); @@ -65,11 +64,11 @@ public void AddColumn(DataColumn column) if (col == null) { // Check and see if it is a reference data id. - col = Table.DbTable.Columns.Where(c => c.Name == column.Name + (Table.Parser.Args.IdColumnNameSuffix ?? Table.Parser.DatabaseSchemaConfig.IdColumnNameSuffix)).SingleOrDefault(); + 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.Parser.Args.IdColumnNameSuffix ?? Table.Parser.DatabaseSchemaConfig.IdColumnNameSuffix}'; or was not identified as a foreign key to Reference Data."); + 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.Parser.Args.IdColumnNameSuffix ?? Table.Parser.DatabaseSchemaConfig.IdColumnNameSuffix; + column.Name += Table.DbTable.Migration.Args.IdColumnNameSuffix!; } if (Columns.Any(x => x.Name == column.Name)) @@ -84,20 +83,28 @@ public void AddColumn(DataColumn column) string? str = null; try { - str = column.Value is DateTime time ? time.ToString(Table.Parser.Args.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture) : column.Value.ToString()!; + str = column.Value is DateTime time ? time.ToString(Table.Parser.ParserArgs.DateTimeFormat, System.Globalization.CultureInfo.InvariantCulture) : column.Value.ToString()!; switch (col.DotNetType) { case "string": column.Value = str; break; - case "decimal": column.Value = string.IsNullOrEmpty(str) ? 0m : decimal.Parse(str, System.Globalization.CultureInfo.InvariantCulture); break; - case "DateTime": column.Value = string.IsNullOrEmpty(str) ? DateTime.MinValue : DateTime.Parse(str, System.Globalization.CultureInfo.InvariantCulture); 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; +#endif case "int": if (int.TryParse(str, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int i)) diff --git a/src/DbEx/Migration/Data/DataTable.cs b/src/DbEx/Migration/Data/DataTable.cs index 50d1bcb..cd382bd 100644 --- a/src/DbEx/Migration/Data/DataTable.cs +++ b/src/DbEx/Migration/Data/DataTable.cs @@ -1,5 +1,6 @@ // 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; @@ -22,10 +23,8 @@ public class DataTable /// The table name. internal DataTable(DataParser parser, string schema, string name) { - Parser = parser ?? throw new ArgumentNullException(nameof(parser)); - - if (name == null) - throw new ArgumentNullException(nameof(name)); + Parser = parser.ThrowIfNull(nameof(parser)); + name.ThrowIfNull(nameof(name)); // Determine features by notation/convention. if (name.StartsWith('$')) @@ -41,14 +40,14 @@ internal DataTable(DataParser parser, string schema, string name) } // Determine the schema, table and column name mappings. - var mappings = parser.Args.TableNameMappings.Get(schema, name); + 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.DatabaseSchemaConfig.SupportsSchema || t.Schema == schema) && t.Name == name).SingleOrDefault() ?? + 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. @@ -69,7 +68,7 @@ internal DataTable(DataParser parser, string schema, string name) /// /// Gets the . /// - public DataParserArgs Args => Parser.Args; + public DataParserArgs ParserArgs => Parser.ParserArgs; /// /// Gets the schema name. @@ -172,8 +171,7 @@ internal DataTable(DataParser parser, string schema, string name) /// The row. public void AddRow(DataRow row) { - if (row == null) - throw new ArgumentNullException(nameof(row)); + row.ThrowIfNull(nameof(row)); foreach (var c in row.Columns) { @@ -201,22 +199,22 @@ private void AddColumn(string name) /// internal async Task PrepareAsync(CancellationToken cancellationToken) { - var cds = Args.ColumnDefaults.GetDefaultsForTable(DbTable); + 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, Args.CreatedDateColumnName ?? Parser.DatabaseSchemaConfig.CreatedDateColumnName, () => Task.FromResult(Args.DateTimeNow)).ConfigureAwait(false); - await AddColumnWhereNotSpecifiedAsync(row, Args.CreatedByColumnName ?? Parser.DatabaseSchemaConfig.CreatedByColumnName, () => Task.FromResult(Args.UserName)).ConfigureAwait(false); - await AddColumnWhereNotSpecifiedAsync(row, Args.UpdatedDateColumnName ?? Parser.DatabaseSchemaConfig.UpdatedDateColumnName, () => Task.FromResult(Args.DateTimeNow)).ConfigureAwait(false); - await AddColumnWhereNotSpecifiedAsync(row, Args.UpdatedByColumnName ?? Parser.DatabaseSchemaConfig.UpdatedByColumnName, () => Task.FromResult(Args.UserName)).ConfigureAwait(false); + await AddColumnWhereNotSpecifiedAsync(row, DbTable.Migration.Args.CreatedDateColumnName!, () => 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.UpdatedByColumnName!, () => Task.FromResult(ParserArgs.UserName)).ConfigureAwait(false); // Apply an reference data defaults. - if (IsRefData && Args.RefDataColumnDefaults != null) + if (IsRefData && ParserArgs.RefDataColumnDefaults != null) { - foreach (var rdd in Args.RefDataColumnDefaults) + foreach (var rdd in ParserArgs.RefDataColumnDefaults) { await AddColumnWhereNotSpecifiedAsync(row, rdd.Key, () => Task.FromResult(rdd.Value(i + 1))).ConfigureAwait(false); } @@ -232,19 +230,19 @@ internal async Task PrepareAsync(CancellationToken cancellationToken) switch (IdentifierType) { case DataTableIdentifierType.Guid: - await AddColumnWhereNotSpecifiedAsync(row, pkc.Name!, async () => await Args.IdentifierGenerator.GenerateGuidIdentifierAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + 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 Args.IdentifierGenerator.GenerateStringIdentifierAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + 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 Args.IdentifierGenerator.GenerateInt32IdentifierAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + 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 Args.IdentifierGenerator.GenerateInt64IdentifierAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + await AddColumnWhereNotSpecifiedAsync (row, pkc.Name!, async () => await ParserArgs.IdentifierGenerator.GenerateInt64IdentifierAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); break; } } diff --git a/src/DbEx/Migration/DatabaseJournal.cs b/src/DbEx/Migration/DatabaseJournal.cs index 51c3b25..b3823e1 100644 --- a/src/DbEx/Migration/DatabaseJournal.cs +++ b/src/DbEx/Migration/DatabaseJournal.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using System.Linq; +using CoreEx; namespace DbEx.Migration { @@ -16,26 +17,21 @@ namespace DbEx.Migration /// 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. - public class DatabaseJournal : IDatabaseJournal + /// The . + public class DatabaseJournal(DatabaseMigrationBase migrator) : IDatabaseJournal { private bool _journalExists; - /// - /// Initializes a new instance of the class. - /// - /// The . - public DatabaseJournal(DatabaseMigrationBase migrator) => Migrator = migrator ?? throw new ArgumentNullException(nameof(migrator)); - /// - public string? Schema => Migrator.Args.Parameters[MigrationArgsBase.JournalSchemaParamName]?.ToString(); + public string? Schema => Migrator.Args.Parameters[MigrationArgs.JournalSchemaParamName]?.ToString(); /// - public string? Table => Migrator.Args.Parameters[MigrationArgsBase.JournalTableParamName]?.ToString(); + public string? Table => Migrator.Args.Parameters[MigrationArgs.JournalTableParamName]?.ToString(); /// /// Gets the . /// - public DatabaseMigrationBase Migrator { get; } + public DatabaseMigrationBase Migrator { get; } = migrator.ThrowIfNull(nameof(migrator)); /// public async Task EnsureExistsAsync(CancellationToken cancellationToken = default) diff --git a/src/DbEx/Migration/DatabaseMigrationBase.cs b/src/DbEx/Migration/DatabaseMigrationBase.cs index f9189be..ee767a3 100644 --- a/src/DbEx/Migration/DatabaseMigrationBase.cs +++ b/src/DbEx/Migration/DatabaseMigrationBase.cs @@ -1,5 +1,6 @@ // 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; @@ -51,9 +52,9 @@ public static StreamReader GetRequiredResourcesStreamReader(string fileName, Ass /// The . protected DatabaseMigrationBase(MigrationArgsBase args) { - Args = args ?? throw new ArgumentNullException(nameof(args)); + Args = args.ThrowIfNull(nameof(args)); if (string.IsNullOrEmpty(Args.ConnectionString)) - throw new ArgumentException($"{nameof(MigrationArgs.ConnectionString)} property must have a value.", nameof(args)); + throw new ArgumentException($"{nameof(MigrationArgsBase.ConnectionString)} property must have a value.", nameof(args)); Args.Logger ??= NullLogger.Instance; Args.OutputDirectory ??= new DirectoryInfo(CodeGenConsole.GetBaseExeDirectory()); @@ -93,7 +94,7 @@ protected DatabaseMigrationBase(MigrationArgsBase args) /// /// Gets the . /// - public abstract DatabaseSchemaConfig DatabaseSchemaConfig { get; } + public abstract DatabaseSchemaConfig SchemaConfig { get; } /// /// Gets the . @@ -221,14 +222,15 @@ public virtual async Task MigrateAsync(CancellationToken cancellationToken } /// - /// Performs any pre-execution initialization. + /// Performs the pre-execution initialization. /// - private void PreExecutionInitialization() + public void PreExecutionInitialization() { if (_hasInitialized) return; _hasInitialized = true; + SchemaConfig.PrepareMigrationArgs(); var list = (List)Namespaces; Args.ProbeAssemblies.ForEach(x => list.Add(x.Assembly.GetName().Name!)); @@ -298,12 +300,12 @@ protected async Task CommandExecuteAsync(string title, Func DatabaseCreateAsync(CancellationToken cancell { 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(ass.Assembly, name) { RunAlways = true }); + scripts.Add(new DatabaseMigrationScript(this, ass.Assembly, name) { RunAlways = true }); } } @@ -473,7 +475,7 @@ private async Task DatabaseMigrateAsync(CancellationToken cancellationToke var order = name.EndsWith(".pre.deploy.sql", StringComparison.InvariantCultureIgnoreCase) ? 1 : name.EndsWith(".post.deploy.sql", StringComparison.InvariantCultureIgnoreCase) ? 3 : 2; - scripts.Add(new DatabaseMigrationScript(ass.Assembly, name) { GroupOrder = order, RunAlways = order != 2 }); + scripts.Add(new DatabaseMigrationScript(this, ass.Assembly, name) { GroupOrder = order, RunAlways = order != 2 }); } } @@ -521,7 +523,7 @@ private async Task DatabaseSchemaAsync(CancellationToken cancellationToken foreach (var fi in di.GetFiles("*.sql", SearchOption.AllDirectories)) { var rn = $"{fi.FullName[((Args.OutputDirectory?.Parent?.FullName.Length + 1) ?? 0)..]}".Replace(' ', '_').Replace('-', '_').Replace('\\', '.').Replace('/', '.'); - scripts.Add(new DatabaseMigrationScript(fi, rn)); + scripts.Add(new DatabaseMigrationScript(this, fi, rn)); } } } @@ -540,7 +542,7 @@ private async Task DatabaseSchemaAsync(CancellationToken cancellationToken if (scripts.Any(x => x.Name == rn)) continue; - scripts.Add(new DatabaseMigrationScript(ass.Assembly, rn)); + scripts.Add(new DatabaseMigrationScript(this, ass.Assembly, rn)); } } @@ -583,12 +585,14 @@ protected virtual async Task DatabaseSchemaAsync(List(); Logger.LogInformation("{Content}", string.Empty); Logger.LogInformation("{Content}", " Drop known schema objects..."); - foreach (var sor in list.OrderByDescending(x => x.SchemaOrder).ThenByDescending(x => x.TypeOrder).ThenByDescending(x => x.Schema).ThenByDescending(x => x.Name)) + foreach (var sor in list.Where(x => !x.SupportsReplace).OrderByDescending(x => x.SchemaOrder).ThenByDescending(x => x.TypeOrder).ThenByDescending(x => x.Schema).ThenByDescending(x => x.Name)) { - ss.Add(new DatabaseMigrationScript(sor.SqlDropStatement, sor.SqlDropStatement) { GroupOrder = i++, RunAlways = true }); + ss.Add(new DatabaseMigrationScript(this, sor.SqlDropStatement, sor.SqlDropStatement) { GroupOrder = i++, RunAlways = true }); } - if (!await ExecuteScriptsAsync(ss, true, cancellationToken).ConfigureAwait(false)) + if (i == 0) + Logger.LogInformation("{Content}", " None."); + else if (!await ExecuteScriptsAsync(ss, true, cancellationToken).ConfigureAwait(false)) return false; // Execute each migration script proper (i.e. create 'em as scripted). @@ -651,7 +655,7 @@ protected virtual async Task DatabaseResetAsync(CancellationToken cancella { Logger.LogInformation("{Content}", " Querying database to infer table(s) schema..."); - var tables = await Database.SelectSchemaAsync(DatabaseSchemaConfig, Args.DataParserArgs, cancellationToken).ConfigureAwait(false); + var tables = await Database.SelectSchemaAsync(this, cancellationToken).ConfigureAwait(false); var query = tables.Where(DataResetFilterPredicate); if (Args.DataResetFilterPredicate != null) query = query.Where(Args.DataResetFilterPredicate); @@ -729,10 +733,10 @@ private async Task DatabaseDataAsync(CancellationToken cancellationToken) // Infer database schema. Logger.LogInformation("{Content}", " Querying database to infer table(s)/column(s) schema..."); - var dbTables = await Database.SelectSchemaAsync(DatabaseSchemaConfig, Args.DataParserArgs, cancellationToken).ConfigureAwait(false); + 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(DatabaseSchemaConfig, dbTables, Args.DataParserArgs); + var parser = new DataParser(this, dbTables); foreach (var item in list) { using var sr = new StreamReader(item.Assembly.GetManifestResourceStream(item.ResourceName)!); @@ -743,7 +747,7 @@ private async Task DatabaseDataAsync(CancellationToken cancellationToken) Logger.LogInformation("{Content}", string.Empty); Logger.LogInformation("{Content}", $"** Executing: {item.ResourceName}"); - var ss = new DatabaseMigrationScript(item.Assembly, item.ResourceName) { RunAlways = true }; + var ss = new DatabaseMigrationScript(this, item.Assembly, item.ResourceName) { RunAlways = true }; if (!await ExecuteScriptsAsync(new DatabaseMigrationScript[] { ss }, false, cancellationToken).ConfigureAwait(false)) return false; } @@ -787,7 +791,11 @@ protected virtual async Task DatabaseDataAsync(List dataTables, if (_dataCodeGen == null) { using var sr = GetRequiredResourcesStreamReader($"DatabaseData_sql", ArtefactResourceAssemblies.ToArray(), StreamLocator.HandlebarsExtensions); +#if NET7_0_OR_GREATER + _dataCodeGen = new HandlebarsCodeGenerator(await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false)); +#else _dataCodeGen = new HandlebarsCodeGenerator(await sr.ReadToEndAsync().ConfigureAwait(false)); +#endif } foreach (var table in dataTables) @@ -828,8 +836,7 @@ protected virtual async Task DatabaseDataAsync(List dataTables, /// private string[] GetNamespacesWithSuffix(string suffix, bool reverse = false) { - if (suffix == null) - throw new ArgumentNullException(nameof(suffix)); + suffix.ThrowIfNull(nameof(suffix)); var list = new List(); foreach (var ns in reverse ? Namespaces.Reverse() : Namespaces) @@ -872,7 +879,11 @@ private async Task CreateScriptInternalAsync(string? name, IDictionary() }; @@ -949,9 +960,9 @@ private async Task ExecuteSqlStatementsInternalAsync(string[]? statements, for (int i = 0; i < statements.Length; i++) { if (File.Exists(statements[i])) - scripts.Add(new DatabaseMigrationScript(new FileInfo(statements[i]), statements[i])); + scripts.Add(new DatabaseMigrationScript(this, new FileInfo(statements[i]), statements[i])); else - scripts.Add(new DatabaseMigrationScript(statements[i], $"{sn}{i + 1:000}.sql")); + scripts.Add(new DatabaseMigrationScript(this, statements[i], $"{sn}{i + 1:000}.sql")); } return await ExecuteScriptsAsync(scripts, false, cancellationToken).ConfigureAwait(false); diff --git a/src/DbEx/Migration/DatabaseMigrationScript.cs b/src/DbEx/Migration/DatabaseMigrationScript.cs index feb7585..04ea008 100644 --- a/src/DbEx/Migration/DatabaseMigrationScript.cs +++ b/src/DbEx/Migration/DatabaseMigrationScript.cs @@ -1,5 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +using CoreEx; using System; using System.IO; using System.Reflection; @@ -19,37 +20,48 @@ public class DatabaseMigrationScript /// /// Initializes a new instance of the class for a . /// + /// The owning . /// The . /// The file name. - public DatabaseMigrationScript(FileInfo file, string name) + public DatabaseMigrationScript(DatabaseMigrationBase databaseMigation, FileInfo file, string name) { - _file = file ?? throw new ArgumentNullException(nameof(file)); - Name = name ?? throw new ArgumentNullException(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(Assembly assembly, string name) + public DatabaseMigrationScript(DatabaseMigrationBase databaseMigation, Assembly assembly, string name) { - _assembly = assembly ?? throw new ArgumentNullException(nameof(assembly)); - Name = name ?? throw new ArgumentNullException(nameof(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 name. /// - public DatabaseMigrationScript(string sql, string name) + public DatabaseMigrationScript(DatabaseMigrationBase databaseMigation, string sql, string name) { - _sql = sql ?? throw new ArgumentNullException(nameof(sql)); - Name = name ?? throw new ArgumentNullException(nameof(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 name used for journaling. /// diff --git a/src/DbEx/Migration/DatabaseSchemaScriptBase.cs b/src/DbEx/Migration/DatabaseSchemaScriptBase.cs index ccbe2f9..4a51464 100644 --- a/src/DbEx/Migration/DatabaseSchemaScriptBase.cs +++ b/src/DbEx/Migration/DatabaseSchemaScriptBase.cs @@ -1,5 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +using CoreEx; using System; namespace DbEx.Migration @@ -7,38 +8,28 @@ namespace DbEx.Migration /// /// Enables the base database schema script. /// - public abstract class DatabaseSchemaScriptBase + /// 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; - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The optional quote prefix. - /// The optional quote suffix. - public DatabaseSchemaScriptBase(DatabaseMigrationScript migrationScript, string? quotePrefix = null, string? quoteSuffix = null) - { - MigrationScript = migrationScript ?? throw new ArgumentNullException(nameof(migrationScript)); - QuotePrefix = quotePrefix; - QuoteSuffix = quoteSuffix; - } - /// /// Gets the parent . /// - public DatabaseMigrationScript MigrationScript { get; } + public DatabaseMigrationScript MigrationScript { get; } = migrationScript.ThrowIfNull(nameof(migrationScript)); /// /// Gets the optional quote prefix. /// - public string? QuotePrefix { get; } + public string? QuotePrefix { get; } = quotePrefix; /// /// Gets the optional quote suffix. /// - public string? QuoteSuffix { get; } + public string? QuoteSuffix { get; } = quoteSuffix; /// /// Gets or sets the fully qualified name (as per script). @@ -82,6 +73,11 @@ public DatabaseSchemaScriptBase(DatabaseMigrationScript migrationScript, string? /// public bool HasError => ErrorMessage != null; + /// + /// Indicates whether the schema script supports a create or replace; 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 . /// diff --git a/src/DbEx/Migration/MigrationArgsBase.cs b/src/DbEx/Migration/MigrationArgsBase.cs index b8d9fd3..b747115 100644 --- a/src/DbEx/Migration/MigrationArgsBase.cs +++ b/src/DbEx/Migration/MigrationArgsBase.cs @@ -1,5 +1,8 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/DbEx +using CoreEx; +using CoreEx.Entities; +using CoreEx.RefData; using DbEx.Migration.Data; using Microsoft.Extensions.Logging; using System; @@ -15,7 +18,7 @@ namespace DbEx.Migration /// public abstract class MigrationArgsBase : OnRamp.CodeGeneratorDbArgsBase { - private readonly List _assemblies = [new MigrationAssemblyArgs(typeof(MigrationArgs).Assembly)]; + private readonly List _assemblies = [new MigrationAssemblyArgs(typeof(MigrationArgsBase).Assembly)]; /// /// Gets the name. @@ -79,6 +82,81 @@ public abstract class MigrationArgsBase : OnRamp.CodeGeneratorDbArgsBase /// 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 column (where it exists). + /// + /// Defaults to where not specified (i.e. null). + public string? CreatedDateColumnName { get; set; } + + /// + /// Gets or sets the name of the 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). + /// + /// Defaults to where not specified (i.e. null). + public string? UpdatedDateColumnName { get; set; } + + /// + /// Gets or sets the name of the 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). + /// + /// 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). + /// + /// Defaults to where not specified (i.e. null). + public string? RowVersionColumnName { get; set; } + + /// + /// Gets or sets the name of the 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. + /// + /// Defaults to where not specified (i.e. null). + public string? RefDataCodeColumnName { get; set; } + + /// + /// Gets or sets the name of the column. + /// + /// Defaults to where not specified (i.e. null). + public string? RefDataTextColumnName { get; set; } + /// /// Gets or sets the statements. /// @@ -95,6 +173,28 @@ public abstract class MigrationArgsBase : OnRamp.CodeGeneratorDbArgsBase /// 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). + /// +#else + /// + /// Indicates whether to emit the as a DateOnly where ; otherwise, as a (default). + /// +#endif + public bool EmitDotNetDateOnly { get; set; } + +#if NET7_0_OR_GREATER + /// + /// 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). + /// +#endif + public bool EmitDotNetTimeOnly { get; set; } + /// /// Clears the by removing all existing items. /// @@ -137,7 +237,7 @@ public void AddAssembly(params MigrationAssemblyArgs[] assemblies) /// 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 ?? throw new ArgumentNullException(nameof(assemblyToFind)))); + var index = _assemblies.FindIndex(x => x.Assembly == assemblyToFind.ThrowIfNull(nameof(assemblyToFind))); if (index < 0) { AddAssembly(assemblies); @@ -151,7 +251,6 @@ public void AddAssemblyAfter(Assembly assemblyToFind, params MigrationAssemblyAr newAssemblies.Add(assembly); } - _assemblies.InsertRange(index + 1, newAssemblies); } @@ -162,7 +261,7 @@ public void AddAssemblyAfter(Assembly assemblyToFind, params MigrationAssemblyAr /// 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 Parameter(string key, object? value, bool overrideExisting = false) + public void AddParameter(string key, object? value, bool overrideExisting = false) { if (!Parameters.TryAdd(key, value) && overrideExisting) Parameters[key] = value; @@ -174,7 +273,7 @@ public void Parameter(string key, object? value, bool overrideExisting = false) /// The to copy from. protected void CopyFrom(MigrationArgsBase args) { - base.CopyFrom(args ?? throw new ArgumentNullException(nameof(args))); + base.CopyFrom(args.ThrowIfNull(nameof(args))); MigrationCommand = args.MigrationCommand; _assemblies.Clear(); @@ -186,6 +285,18 @@ protected void CopyFrom(MigrationArgsBase args) SchemaOrder.Clear(); SchemaOrder.AddRange(args.SchemaOrder); DataParserArgs.CopyFrom(args.DataParserArgs); + IdColumnNameSuffix = args.IdColumnNameSuffix; + CodeColumnNameSuffix = args.CodeColumnNameSuffix; + CreatedDateColumnName = args.CreatedDateColumnName; + CreatedByColumnName = args.CreatedByColumnName; + UpdatedDateColumnName = args.UpdatedDateColumnName; + 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) diff --git a/src/DbEx/Migration/MigrationArgsBaseT.cs b/src/DbEx/Migration/MigrationArgsBaseT.cs index f85dfcf..414f25d 100644 --- a/src/DbEx/Migration/MigrationArgsBaseT.cs +++ b/src/DbEx/Migration/MigrationArgsBaseT.cs @@ -64,9 +64,9 @@ public TSelf AddAssembly(params Type[] types) /// 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 Parameter(string key, object? value, bool overrideExisting = false) + public new TSelf AddParameter(string key, object? value, bool overrideExisting = false) { - base.Parameter(key, value, overrideExisting); + base.AddParameter(key, value, overrideExisting); return (TSelf)this; } diff --git a/tests/DbEx.Test.Console/Migrations/004-create-test-contact-table.sql b/tests/DbEx.Test.Console/Migrations/004-create-test-contact-table.sql index 2bfa38b..32faefd 100644 --- a/tests/DbEx.Test.Console/Migrations/004-create-test-contact-table.sql +++ b/tests/DbEx.Test.Console/Migrations/004-create-test-contact-table.sql @@ -7,5 +7,6 @@ [GenderId] INT NULL, [TenantId] NVARCHAR(50), [Notes] NVARCHAR(MAX) NULL, + [ContactTypeCode] NVARCHAR(50) NULL, CONSTRAINT [FK_Test_Contact_ContactType] FOREIGN KEY ([ContactTypeId]) REFERENCES [Test].[ContactType] ([ContactTypeId]) ) \ 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 75a6e4c..e2aa1a7 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 @@ -10,5 +10,6 @@ `created_date` DATETIME NULL, `updated_by` VARCHAR (50) NULL, `updated_date` 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.PostgresConsole/Data/Data.yaml b/tests/DbEx.Test.PostgresConsole/Data/Data.yaml new file mode 100644 index 0000000..6f958c0 --- /dev/null +++ b/tests/DbEx.Test.PostgresConsole/Data/Data.yaml @@ -0,0 +1,11 @@ +public: +- $contact_type: + - E: External + - I: Internal +- $gender: + - F: Female + - M: Male + - O: Other +- contact: + - { contact_id: 1, contact_type: E, gender: M, name: Bob, date_of_birth: 2001-10-22 } + - { contact_id: 2, contact_type: I, name: Jane, phone: 1234 } \ No newline at end of file diff --git a/tests/DbEx.Test.PostgresConsole/DbEx.Test.PostgresConsole.csproj b/tests/DbEx.Test.PostgresConsole/DbEx.Test.PostgresConsole.csproj new file mode 100644 index 0000000..cdf5283 --- /dev/null +++ b/tests/DbEx.Test.PostgresConsole/DbEx.Test.PostgresConsole.csproj @@ -0,0 +1,27 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + 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.sql new file mode 100644 index 0000000..005499e --- /dev/null +++ b/tests/DbEx.Test.PostgresConsole/Migrations/002-create-test-contact-type-table.sql @@ -0,0 +1,6 @@ + CREATE TABLE "contact_type" ( + "contact_type_id" SERIAL PRIMARY KEY, + "code" VARCHAR (50) NOT NULL UNIQUE, + "text" VARCHAR (256) NOT NULL, + "sort_order" INT NULL + ) \ No newline at end of file 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 new file mode 100644 index 0000000..53835a9 --- /dev/null +++ b/tests/DbEx.Test.PostgresConsole/Migrations/003-create-test-gender-table.sql @@ -0,0 +1,9 @@ + CREATE TABLE "gender" ( + "gender_id" SERIAL PRIMARY KEY, + "code" VARCHAR (50) NOT NULL UNIQUE, + "text" VARCHAR (256) NOT NULL, + "created_by" VARCHAR (50) NULL, + "created_date" TIMESTAMPTZ NULL, + "updated_by" VARCHAR (50) NULL, + "updated_date" 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 new file mode 100644 index 0000000..d56029d --- /dev/null +++ b/tests/DbEx.Test.PostgresConsole/Migrations/004-create-test-contact-table.sql @@ -0,0 +1,15 @@ + CREATE TABLE "public"."contact" ( + "contact_id" SERIAL PRIMARY KEY, + "name" VARCHAR (200) NOT NULL, + "phone" VARCHAR (15) NULL, + "date_of_birth" DATE NULL, + "contact_type_id" INT NOT NULL DEFAULT 1, + "gender_id" INT NULL, + "notes" TEXT NULL, + "created_by" VARCHAR (50) NULL, + "created_date" TIMESTAMPTZ NULL, + "updated_by" VARCHAR (50) NULL, + "updated_date" 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.PostgresConsole/Migrations/005-create-test-multipk-table.sql b/tests/DbEx.Test.PostgresConsole/Migrations/005-create-test-multipk-table.sql new file mode 100644 index 0000000..7bb8b22 --- /dev/null +++ b/tests/DbEx.Test.PostgresConsole/Migrations/005-create-test-multipk-table.sql @@ -0,0 +1,7 @@ + CREATE TABLE "multi_pk" ( + "part1" INT NOT NULL, + "part2" INT NOT NULL, + "value" money NULL, + "parts" INT GENERATED ALWAYS AS ("part1" + "part2") STORED, + PRIMARY KEY ("part1", "part2") + ) \ No newline at end of file diff --git a/tests/DbEx.Test.PostgresConsole/PostgresStuff.cs b/tests/DbEx.Test.PostgresConsole/PostgresStuff.cs new file mode 100644 index 0000000..c50d2e8 --- /dev/null +++ b/tests/DbEx.Test.PostgresConsole/PostgresStuff.cs @@ -0,0 +1,3 @@ +namespace DbEx.Test.PostgresConsole; + +public class PostgresStuff { } \ No newline at end of file diff --git a/tests/DbEx.Test.PostgresConsole/Schema/spGetContact.sql b/tests/DbEx.Test.PostgresConsole/Schema/spGetContact.sql new file mode 100644 index 0000000..73573c9 --- /dev/null +++ b/tests/DbEx.Test.PostgresConsole/Schema/spGetContact.sql @@ -0,0 +1,8 @@ +CREATE OR REPLACE PROCEDURE "spGetContact" ( + IN "contact_id" INT /* this is a comment */ +) +LANGUAGE SQL +AS $$ + -- This is a comment. + SELECT * FROM "contact" AS "c" WHERE "c"."contact_id" = "contact_id" +$$; \ No newline at end of file diff --git a/tests/DbEx.Test/DatabaseSchemaTest.cs b/tests/DbEx.Test/DatabaseSchemaTest.cs index 2d19df5..be61dc8 100644 --- a/tests/DbEx.Test/DatabaseSchemaTest.cs +++ b/tests/DbEx.Test/DatabaseSchemaTest.cs @@ -1,10 +1,11 @@ using CoreEx.Database.MySql; +using CoreEx.Database.Postgres; using CoreEx.Database.SqlServer; using DbEx.Migration; -using DbEx.MySql; using DbEx.MySql.Migration; -using DbEx.SqlServer; +using DbEx.Postgres.Migration; using DbEx.SqlServer.Migration; +using DbEx.Test.PostgresConsole; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; using MySql.Data.MySqlClient; @@ -29,7 +30,7 @@ public async Task SqlServerSelectSchema() Assert.IsTrue(r); using var db = new SqlServerDatabase(() => new SqlConnection(cs)); - var tables = await db.SelectSchemaAsync(new SqlServerSchemaConfig("DbEx.Console")).ConfigureAwait(false); + var tables = await db.SelectSchemaAsync(m).ConfigureAwait(false); Assert.IsNotNull(tables); // [Test].[ContactType] @@ -103,7 +104,7 @@ public async Task SqlServerSelectSchema() Assert.AreEqual("[Test].[Contact]", tab.QualifiedName); Assert.IsFalse(tab.IsAView); Assert.IsFalse(tab.IsRefData); - Assert.AreEqual(8, tab.Columns.Count); + Assert.AreEqual(9, tab.Columns.Count); Assert.AreEqual(1, tab.PrimaryKeyColumns.Count); Assert.AreEqual("Contact", tab.DotNetName); Assert.AreEqual("Contacts", tab.PluralName); @@ -219,6 +220,10 @@ public async Task SqlServerSelectSchema() Assert.IsFalse(col.IsForeignRefData); Assert.IsNull(col.DefaultValue); + col = tab.Columns[8]; + Assert.AreEqual("ContactTypeCode", col.Name); + Assert.IsTrue(col.IsRefData); + // [Test].[MultiPk] tab = tables.Where(x => x.Name == "MultiPk").SingleOrDefault(); Assert.IsNotNull(tab); @@ -364,7 +369,7 @@ public async Task MySqlSelectSchema() Assert.IsTrue(r); using var db = new MySqlDatabase(() => new MySqlConnection(cs)); - var tables = await db.SelectSchemaAsync(new MySqlSchemaConfig("dbex_test")).ConfigureAwait(false); + var tables = await db.SelectSchemaAsync(m).ConfigureAwait(false); Assert.IsNotNull(tables); // [Test].[ContactType] @@ -441,12 +446,11 @@ public async Task MySqlSelectSchema() Assert.AreEqual("`contact`", tab.QualifiedName); Assert.IsFalse(tab.IsAView); Assert.IsFalse(tab.IsRefData); - Assert.AreEqual(11, tab.Columns.Count); + Assert.AreEqual(12, tab.Columns.Count); Assert.AreEqual(1, tab.PrimaryKeyColumns.Count); Assert.AreEqual("Contact", tab.DotNetName); Assert.AreEqual("Contacts", tab.PluralName); - col = tab.Columns[0]; Assert.AreEqual("contact_id", col.Name); Assert.AreEqual("int", col.Type); @@ -553,6 +557,10 @@ public async Task MySqlSelectSchema() Assert.IsFalse(col.IsForeignRefData); Assert.IsNull(col.DefaultValue); + col = tab.Columns[11]; + Assert.AreEqual("contact_type_code", col.Name); + Assert.IsTrue(col.IsRefData); + // [Test].[MultiPk] tab = tables.Where(x => x.Name == "multi_pk").SingleOrDefault(); Assert.IsNotNull(tab); @@ -651,5 +659,331 @@ public async Task MySqlSelectSchema() Assert.IsNull(col.ForeignColumn); Assert.IsNull(col.DefaultValue); } + + [Test] + public async Task PostgresSelectSchema() + { + var cs = UnitTest.GetConfig("DbEx_").GetConnectionString("PostgresDb"); + var l = UnitTest.GetLogger(); + var a = new MigrationArgs(MigrationCommand.Drop | MigrationCommand.Create | MigrationCommand.Migrate | MigrationCommand.Schema, cs) { Logger = l }.AddAssembly(typeof(PostgresStuff)); + using var m = new PostgresMigration(a); + var r = await m.MigrateAsync().ConfigureAwait(false); + Assert.IsTrue(r); + + using var db = new PostgresDatabase(() => new Npgsql.NpgsqlConnection(cs)); + var tables = await db.SelectSchemaAsync(m).ConfigureAwait(false); + Assert.IsNotNull(tables); + + // [Test].[ContactType] + var tab = tables.Where(x => x.Name == "contact_type").SingleOrDefault(); + Assert.IsNotNull(tab); + Assert.AreEqual("public", tab.Schema); + Assert.AreEqual("contact_type", tab.Name); + Assert.AreEqual("ct", tab.Alias); + Assert.AreEqual("\"public\".\"contact_type\"", tab.QualifiedName); + Assert.IsFalse(tab.IsAView); + Assert.IsTrue(tab.IsRefData); + Assert.AreEqual(5, tab.Columns.Count); + Assert.AreEqual(1, tab.PrimaryKeyColumns.Count); + Assert.AreEqual("ContactType", tab.DotNetName); + Assert.AreEqual("ContactTypes", tab.PluralName); + + var col = tab.Columns[0]; + Assert.AreEqual("contact_type_id", col.Name); + Assert.AreEqual("integer", col.Type); + Assert.AreEqual("INTEGER", col.SqlType); + Assert.IsNull(col.Length); + Assert.AreEqual(0, col.Scale); + Assert.AreEqual(32, col.Precision); + Assert.AreEqual("int", col.DotNetType); + Assert.IsFalse(col.IsNullable); + Assert.IsTrue(col.IsPrimaryKey); + Assert.IsTrue(col.IsIdentity); + Assert.IsNull(col.IdentitySeed); + Assert.IsNull(col.IdentityIncrement); + Assert.IsFalse(col.IsUnique); + Assert.IsFalse(col.IsComputed); + Assert.IsFalse(col.IsForeignRefData); + Assert.IsNull(col.ForeignSchema); + Assert.IsNull(col.ForeignTable); + Assert.IsNull(col.ForeignColumn); + Assert.IsNull(col.DefaultValue); + Assert.AreEqual("ContactTypeId", col.DotNetName); + Assert.AreEqual("ContactTypeId", col.DotNetCleanedName); + + col = tab.Columns[1]; + Assert.AreEqual("code", col.Name); + Assert.AreEqual("character varying", col.Type); + Assert.AreEqual("CHARACTER VARYING(50)", col.SqlType); + Assert.AreEqual(50, col.Length); + Assert.IsNull(col.Scale); + Assert.IsNull(col.Precision); + Assert.AreEqual("string", col.DotNetType); + Assert.IsFalse(col.IsNullable); + Assert.IsFalse(col.IsPrimaryKey); + Assert.IsFalse(col.IsIdentity); + Assert.IsNull(col.IdentitySeed); + Assert.IsNull(col.IdentityIncrement); + Assert.IsTrue(col.IsUnique); + Assert.IsFalse(col.IsComputed); + Assert.IsFalse(col.IsForeignRefData); + Assert.IsNull(col.ForeignSchema); + Assert.IsNull(col.ForeignTable); + Assert.IsNull(col.ForeignColumn); + Assert.IsNull(col.DefaultValue); + Assert.AreEqual("Code", col.DotNetName); + + col = tab.Columns[2]; + Assert.AreEqual("text", col.Name); + + col = tab.Columns[3]; + Assert.AreEqual("sort_order", col.Name); + + col = tab.Columns[4]; + Assert.AreEqual("xmin", col.Name); + Assert.AreEqual("xid", col.Type); + Assert.AreEqual("XID", col.SqlType); + Assert.IsNull(col.Length); + Assert.AreEqual(0, col.Scale); + Assert.AreEqual(32, col.Precision); + Assert.AreEqual("uint", col.DotNetType); + Assert.IsFalse(col.IsNullable); + Assert.IsFalse(col.IsPrimaryKey); + Assert.IsFalse(col.IsIdentity); + Assert.IsTrue(col.IsRowVersion); + Assert.IsNull(col.IdentitySeed); + Assert.IsNull(col.IdentityIncrement); + Assert.IsFalse(col.IsUnique); + Assert.IsTrue(col.IsComputed); + Assert.IsFalse(col.IsForeignRefData); + Assert.IsNull(col.ForeignSchema); + Assert.IsNull(col.ForeignTable); + Assert.IsNull(col.ForeignColumn); + Assert.IsNull(col.DefaultValue); + Assert.AreEqual("RowVersion", col.DotNetName); + Assert.AreEqual("RowVersion", col.DotNetCleanedName); + + // [Test].[Contact] + tab = tables.Where(x => x.Name == "contact").SingleOrDefault(); + Assert.IsNotNull(tab); + Assert.AreEqual("public", tab.Schema); + Assert.AreEqual("contact", tab.Name); + Assert.AreEqual("c", tab.Alias); + Assert.AreEqual("\"public\".\"contact\"", tab.QualifiedName); + Assert.IsFalse(tab.IsAView); + Assert.IsFalse(tab.IsRefData); + Assert.AreEqual(13, tab.Columns.Count); + Assert.AreEqual(1, tab.PrimaryKeyColumns.Count); + Assert.AreEqual("Contact", tab.DotNetName); + Assert.AreEqual("Contacts", tab.PluralName); + + col = tab.Columns[0]; + Assert.AreEqual("contact_id", col.Name); + Assert.AreEqual("integer", col.Type); + Assert.AreEqual("INTEGER", col.SqlType); + Assert.IsNull(col.Length); + Assert.AreEqual(0, col.Scale); + Assert.AreEqual(32, col.Precision); + Assert.AreEqual("int", col.DotNetType); + Assert.IsFalse(col.IsNullable); + Assert.IsTrue(col.IsPrimaryKey); + Assert.IsTrue(col.IsIdentity); + Assert.IsNull(col.IdentitySeed); + Assert.IsNull(col.IdentityIncrement); + Assert.IsFalse(col.IsUnique); + Assert.IsFalse(col.IsComputed); + Assert.IsFalse(col.IsForeignRefData); + Assert.IsNull(col.ForeignSchema); + Assert.IsNull(col.ForeignTable); + Assert.IsNull(col.ForeignColumn); + Assert.IsNull(col.DefaultValue); + Assert.AreEqual("ContactId", col.DotNetName); + + col = tab.Columns[3]; + Assert.AreEqual("date_of_birth", col.Name); + Assert.AreEqual("date", col.Type); + Assert.AreEqual("DATE NULL", col.SqlType); + Assert.IsNull(col.Length); + Assert.IsNull(col.Scale); + Assert.AreEqual(0, col.Precision); + Assert.AreEqual("DateTime", col.DotNetType); + Assert.IsTrue(col.IsNullable); + Assert.IsFalse(col.IsPrimaryKey); + Assert.IsFalse(col.IsIdentity); + Assert.IsNull(col.IdentitySeed); + Assert.IsNull(col.IdentityIncrement); + Assert.IsFalse(col.IsUnique); + Assert.IsFalse(col.IsComputed); + Assert.IsFalse(col.IsForeignRefData); + Assert.IsNull(col.ForeignSchema); + Assert.IsNull(col.ForeignTable); + Assert.IsNull(col.ForeignColumn); + Assert.IsNull(col.DefaultValue); + Assert.AreEqual("DateOfBirth", col.DotNetName); + + col = tab.Columns[4]; + Assert.AreEqual("contact_type_id", col.Name); + Assert.AreEqual("integer", col.Type); + Assert.AreEqual("INTEGER", col.SqlType); + Assert.IsNull(col.Length); + Assert.AreEqual(0, col.Scale); + Assert.AreEqual(32, col.Precision); + Assert.AreEqual("int", col.DotNetType); + Assert.IsFalse(col.IsNullable); + Assert.IsFalse(col.IsPrimaryKey); + Assert.IsFalse(col.IsIdentity); + Assert.IsNull(col.IdentitySeed); + Assert.IsNull(col.IdentityIncrement); + Assert.IsFalse(col.IsUnique); + Assert.IsFalse(col.IsComputed); + Assert.IsTrue(col.IsForeignRefData); + Assert.AreEqual("public", col.ForeignSchema); + Assert.AreEqual("contact_type", col.ForeignTable); + Assert.AreEqual("contact_type_id", col.ForeignColumn); + Assert.AreEqual("code", col.ForeignRefDataCodeColumn); + Assert.AreEqual("1", col.DefaultValue); + Assert.AreEqual("ContactTypeId", col.DotNetName); + + col = tab.Columns[5]; + Assert.AreEqual("gender_id", col.Name); + Assert.AreEqual("integer", col.Type); + Assert.AreEqual("INTEGER NULL", col.SqlType); + Assert.IsNull(col.Length); + Assert.AreEqual(0, col.Scale); + Assert.AreEqual(32, col.Precision); + Assert.AreEqual("int", col.DotNetType); + Assert.IsTrue(col.IsNullable); + Assert.IsFalse(col.IsPrimaryKey); + Assert.IsFalse(col.IsIdentity); + Assert.IsNull(col.IdentitySeed); + Assert.IsNull(col.IdentityIncrement); + Assert.IsFalse(col.IsUnique); + Assert.IsFalse(col.IsComputed); + Assert.IsTrue(col.IsForeignRefData); + Assert.AreEqual("public", col.ForeignSchema); + Assert.AreEqual("gender", col.ForeignTable); + Assert.AreEqual("gender_id", col.ForeignColumn); + Assert.IsNull(col.DefaultValue); + + col = tab.Columns[6]; + Assert.AreEqual("notes", col.Name); + Assert.AreEqual("text", col.Type); + Assert.AreEqual("TEXT NULL", col.SqlType); + Assert.IsNull(col.Length); + Assert.IsNull(col.Scale); + Assert.IsNull(col.Precision); + Assert.AreEqual("string", col.DotNetType); + Assert.IsTrue(col.IsNullable); + Assert.IsFalse(col.IsPrimaryKey); + Assert.IsFalse(col.IsIdentity); + Assert.IsNull(col.IdentitySeed); + Assert.IsNull(col.IdentityIncrement); + Assert.IsFalse(col.IsUnique); + Assert.IsFalse(col.IsComputed); + Assert.IsFalse(col.IsForeignRefData); + Assert.IsNull(col.DefaultValue); + + col = tab.Columns[11]; + Assert.AreEqual("contact_type_code", col.Name); + Assert.IsTrue(col.IsRefData); + + // [Test].[MultiPk] + tab = tables.Where(x => x.Name == "multi_pk").SingleOrDefault(); + Assert.IsNotNull(tab); + Assert.AreEqual("public", tab.Schema); + Assert.AreEqual("multi_pk", tab.Name); + Assert.AreEqual("mp", tab.Alias); + Assert.AreEqual("\"public\".\"multi_pk\"", tab.QualifiedName); + Assert.IsFalse(tab.IsAView); + Assert.IsFalse(tab.IsRefData); + Assert.AreEqual(5, tab.Columns.Count); + Assert.AreEqual(2, tab.PrimaryKeyColumns.Count); + Assert.AreEqual("MultiPk", tab.DotNetName); + Assert.AreEqual("MultiPks", tab.PluralName); + + col = tab.Columns[0]; + Assert.AreEqual("part1", col.Name); + Assert.AreEqual("integer", col.Type); + Assert.AreEqual("INTEGER", col.SqlType); + Assert.IsNull(col.Length); + Assert.AreEqual(0, col.Scale); + Assert.AreEqual(32, col.Precision); + Assert.AreEqual("int", col.DotNetType); + Assert.IsFalse(col.IsNullable); + Assert.IsTrue(col.IsPrimaryKey); + Assert.IsFalse(col.IsIdentity); + Assert.IsNull(col.IdentitySeed); + Assert.IsNull(col.IdentityIncrement); + Assert.IsFalse(col.IsUnique); + Assert.IsFalse(col.IsComputed); + Assert.IsFalse(col.IsForeignRefData); + Assert.IsNull(col.ForeignSchema); + Assert.IsNull(col.ForeignTable); + Assert.IsNull(col.ForeignColumn); + Assert.IsNull(col.DefaultValue); + + col = tab.Columns[1]; + Assert.AreEqual("part2", col.Name); + Assert.AreEqual("integer", col.Type); + Assert.AreEqual("INTEGER", col.SqlType); + Assert.IsNull(col.Length); + Assert.AreEqual(0, col.Scale); + Assert.AreEqual(32, col.Precision); + Assert.AreEqual("int", col.DotNetType); + Assert.IsFalse(col.IsNullable); + Assert.IsTrue(col.IsPrimaryKey); + Assert.IsFalse(col.IsIdentity); + Assert.IsNull(col.IdentitySeed); + Assert.IsNull(col.IdentityIncrement); + Assert.IsFalse(col.IsUnique); + Assert.IsFalse(col.IsComputed); + Assert.IsFalse(col.IsForeignRefData); + Assert.IsNull(col.ForeignSchema); + Assert.IsNull(col.ForeignTable); + Assert.IsNull(col.ForeignColumn); + Assert.IsNull(col.DefaultValue); + + col = tab.Columns[2]; + Assert.AreEqual("value", col.Name); + Assert.AreEqual("money", col.Type); + Assert.AreEqual("MONEY NULL", col.SqlType); + Assert.IsNull(col.Length); + Assert.IsNull(col.Scale); + Assert.IsNull(col.Precision); + Assert.AreEqual("decimal", col.DotNetType); + Assert.IsTrue(col.IsNullable); + Assert.IsFalse(col.IsPrimaryKey); + Assert.IsFalse(col.IsIdentity); + Assert.IsNull(col.IdentitySeed); + Assert.IsNull(col.IdentityIncrement); + Assert.IsFalse(col.IsUnique); + Assert.IsFalse(col.IsComputed); + Assert.IsFalse(col.IsForeignRefData); + Assert.IsNull(col.ForeignSchema); + Assert.IsNull(col.ForeignTable); + Assert.IsNull(col.ForeignColumn); + Assert.IsNull(col.DefaultValue); + + col = tab.Columns[3]; + Assert.AreEqual("parts", col.Name); + Assert.AreEqual("integer", col.Type); + Assert.AreEqual("INTEGER NULL", col.SqlType); + Assert.IsNull(col.Length); + Assert.AreEqual(0, col.Scale); + Assert.AreEqual(32, col.Precision); + Assert.AreEqual("int", col.DotNetType); + Assert.IsTrue(col.IsNullable); + Assert.IsFalse(col.IsPrimaryKey); + Assert.IsFalse(col.IsIdentity); + Assert.IsNull(col.IdentitySeed); + Assert.IsNull(col.IdentityIncrement); + Assert.IsFalse(col.IsUnique); + Assert.IsTrue(col.IsComputed); + Assert.IsFalse(col.IsForeignRefData); + Assert.IsNull(col.ForeignSchema); + Assert.IsNull(col.ForeignTable); + Assert.IsNull(col.ForeignColumn); + Assert.IsNull(col.DefaultValue); + } } } \ No newline at end of file diff --git a/tests/DbEx.Test/DbEx.Test.csproj b/tests/DbEx.Test/DbEx.Test.csproj index b5227d1..6036970 100644 --- a/tests/DbEx.Test/DbEx.Test.csproj +++ b/tests/DbEx.Test/DbEx.Test.csproj @@ -11,7 +11,7 @@ - + @@ -22,6 +22,7 @@ + @@ -29,6 +30,7 @@ + diff --git a/tests/DbEx.Test/PostgresMigrationTest.cs b/tests/DbEx.Test/PostgresMigrationTest.cs new file mode 100644 index 0000000..74794b6 --- /dev/null +++ b/tests/DbEx.Test/PostgresMigrationTest.cs @@ -0,0 +1,44 @@ +using DbEx.Migration; +using DbEx.Postgres.Migration; +using DbEx.Test.PostgresConsole; +using Microsoft.Extensions.Configuration; +using NUnit.Framework; +using System.Threading.Tasks; + +namespace DbEx.Test +{ + [TestFixture] + [NonParallelizable] + public class PostgresMigrationTest + { + [Test] + public async Task A120_MigrateAll() + { + var cs = UnitTest.GetConfig("DbEx_").GetConnectionString("PostgresDb"); + var l = UnitTest.GetLogger(); + var a = new MigrationArgs(MigrationCommand.DropAndAll, cs) { Logger = l }.AddAssembly(); + using var m = new PostgresMigration(a); + var r = await m.MigrateAsync().ConfigureAwait(false); + Assert.IsTrue(r); + } + + [Test] + public async Task A120_MigrateReset() + { + var cs = UnitTest.GetConfig("DbEx_").GetConnectionString("PostgresDb"); + var l = UnitTest.GetLogger(); + var a = new MigrationArgs(MigrationCommand.ResetAndDatabase, cs) { Logger = l }.AddAssembly(); + a.DataParserArgs.RefDataColumnDefaults.Add("sort_order", i => i); + + using var m = new PostgresMigration(a); + var r = await m.MigrateAsync().ConfigureAwait(false); + Assert.IsTrue(r); + + a.MigrationCommand = MigrationCommand.ResetAndData; + using var m2 = new PostgresMigration(a); + + r = await m2.MigrateAsync().ConfigureAwait(false); + Assert.IsTrue(r); + } + } +} \ No newline at end of file diff --git a/tests/DbEx.Test/SqlServerMigrationTest.cs b/tests/DbEx.Test/SqlServerMigrationTest.cs index beed77a..0412e9c 100644 --- a/tests/DbEx.Test/SqlServerMigrationTest.cs +++ b/tests/DbEx.Test/SqlServerMigrationTest.cs @@ -268,36 +268,52 @@ public async Task B130_Execute_Console_Batch_Success() } [Test] - public void SqlServerSchemaScript_SchemaAndObject() + public async Task SqlServerSchemaScript_SchemaAndObject() { - var ss = SqlServerSchemaScript.Create(new Migration.DatabaseMigrationScript("CREATE PROC [Ref].[USStates]", "blah")); + var c = await CreateConsoleDb().ConfigureAwait(false); + var a = new MigrationArgs(MigrationCommand.Execute, c.cs) { Logger = c.l }.AddAssembly(typeof(Console.Program).Assembly); + using var m = new SqlServerMigration(a); + + var ss = SqlServerSchemaScript.Create(new Migration.DatabaseMigrationScript(m, "CREATE PROC [Ref].[USStates]", "blah")); Assert.That(ss.HasError, Is.False); Assert.That(ss.Schema, Is.EqualTo("Ref")); Assert.That(ss.Name, Is.EqualTo("USStates")); } [Test] - public void SqlServerSchemaScript_NoSchemaAndObject() + public async Task SqlServerSchemaScript_NoSchemaAndObject() { - var ss = SqlServerSchemaScript.Create(new Migration.DatabaseMigrationScript("CREATE PROC [USStates]", "blah")); + var c = await CreateConsoleDb().ConfigureAwait(false); + var a = new MigrationArgs(MigrationCommand.Execute, c.cs) { Logger = c.l }.AddAssembly(typeof(Console.Program).Assembly); + using var m = new SqlServerMigration(a); + + var ss = SqlServerSchemaScript.Create(new Migration.DatabaseMigrationScript(m, "CREATE PROC [USStates]", "blah")); Assert.That(ss.HasError, Is.False); Assert.That(ss.Schema, Is.EqualTo("dbo")); Assert.That(ss.Name, Is.EqualTo("USStates")); } [Test] - public void SqlServerSchemaScript_FunctionWithBrackets() + public async Task SqlServerSchemaScript_FunctionWithBrackets() { - var ss = SqlServerSchemaScript.Create(new Migration.DatabaseMigrationScript("CREATE FUNCTION [Sec].[fnGetUserHasPermission]( some, other='stuf', num = 1.3 );", "blah")); + var c = await CreateConsoleDb().ConfigureAwait(false); + var a = new MigrationArgs(MigrationCommand.Execute, c.cs) { Logger = c.l }.AddAssembly(typeof(Console.Program).Assembly); + using var m = new SqlServerMigration(a); + + var ss = SqlServerSchemaScript.Create(new Migration.DatabaseMigrationScript(m, "CREATE FUNCTION [Sec].[fnGetUserHasPermission]( some, other='stuf', num = 1.3 );", "blah")); Assert.That(ss.HasError, Is.False); Assert.That(ss.Schema, Is.EqualTo("Sec")); Assert.That(ss.Name, Is.EqualTo("fnGetUserHasPermission")); } [Test] - public void SqlServerSchemaScript_FunctionWithBrackets2() + public async Task SqlServerSchemaScript_FunctionWithBrackets2() { - var ss = SqlServerSchemaScript.Create(new Migration.DatabaseMigrationScript(@"CREATE FUNCTION [Sec].[fnGetUserHasPermission]() + var c = await CreateConsoleDb().ConfigureAwait(false); + var a = new MigrationArgs(MigrationCommand.Execute, c.cs) { Logger = c.l }.AddAssembly(typeof(Console.Program).Assembly); + using var m = new SqlServerMigration(a); + + var ss = SqlServerSchemaScript.Create(new Migration.DatabaseMigrationScript(m, @"CREATE FUNCTION [Sec].[fnGetUserHasPermission]() some other stuf", "blah")); Assert.That(ss.HasError, Is.False); diff --git a/tests/DbEx.Test/appsettings.json b/tests/DbEx.Test/appsettings.json index 92f0a7d..07aa1e7 100644 --- a/tests/DbEx.Test/appsettings.json +++ b/tests/DbEx.Test/appsettings.json @@ -5,7 +5,8 @@ "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;" + "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",