diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml deleted file mode 100644 index aee92346..00000000 --- a/.github/workflows/dotnet.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: .NET - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - - runs-on: ubuntu-latest - - services: - sqlserver: - image: mcr.microsoft.com/mssql/server:2019-latest - ports: - - 1433:1433 - env: - SA_PASSWORD: YourStrong@Passw0rd - ACCEPT_EULA: Y - options: >- - --health-cmd "bash -c '- - --health-cmd="pg_isready -U testuser" - --health-interval=10s - --health-timeout=5s - --health-retries=5 - - oracle: - image: gvenzl/oracle-free:latest - ports: - - 1521:1521 - env: - ORACLE_RANDOM_PASSWORD: true - APP_USER: test - APP_USER_PASSWORD: test - options: >- - --health-cmd healthcheck.sh - --health-interval 10s - --health-timeout 5s - --health-retries 10 - - mysql: - image: mysql:8.0 - ports: - - 3306:3306 - env: - MYSQL_ROOT_PASSWORD: rootpass - MYSQL_DATABASE: testdb - MYSQL_USER: testuser - MYSQL_PASSWORD: testpass - options: >- - --health-cmd="mysqladmin ping -h localhost -u root -prootpass" - --health-interval=10s - --health-timeout=5s - --health-retries=10 - - steps: - - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 9.0.x - - name: Install SQLCMD tools - run: | - curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - - curl https://packages.microsoft.com/config/ubuntu/20.04/prod.list | sudo tee /etc/apt/sources.list.d/msprod.list - sudo apt-get update - sudo ACCEPT_EULA=Y apt-get install -y mssql-tools unixodbc-dev - echo 'export PATH="$PATH:/opt/mssql-tools/bin"' >> ~/.bashrc - source ~/.bashrc - - name: Create SQLServer database - run: | - /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'YourStrong@Passw0rd' -Q "CREATE DATABASE [Whatever];" - - name: Restore dependencies - run: | - dotnet restore Migrator.slnx - - name: Build - run: | - dotnet build Migrator.slnx - - name: Test - run: | - dotnet test Migrator.slnx diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index a82ca52f..c6616cc2 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -1,12 +1,13 @@ name: .NET Pull Request on: + push: + branches: [master] pull_request: - branches: [ master ] + branches: [master] jobs: build: - runs-on: ubuntu-latest services: @@ -42,9 +43,7 @@ jobs: ports: - 1521:1521 env: - ORACLE_RANDOM_PASSWORD: true - APP_USER: test - APP_USER_PASSWORD: test + ORACLE_PASSWORD: adfkweflajdfglkj options: >- --health-cmd healthcheck.sh --health-interval 10s @@ -67,29 +66,41 @@ jobs: --health-retries=10 steps: - - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 9.0.x - - name: Install SQLCMD tools - run: | - curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - - curl https://packages.microsoft.com/config/ubuntu/20.04/prod.list | sudo tee /etc/apt/sources.list.d/msprod.list - sudo apt-get update - sudo ACCEPT_EULA=Y apt-get install -y mssql-tools unixodbc-dev - echo 'export PATH="$PATH:/opt/mssql-tools/bin"' >> ~/.bashrc - source ~/.bashrc - - name: Create SQLServer database - run: | - /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'YourStrong@Passw0rd' -Q "CREATE DATABASE [Whatever];" - - name: Restore dependencies - run: | - dotnet restore Migrator.slnx - - name: Build - run: | - dotnet build Migrator.slnx - - name: Test - run: | - dotnet test Migrator.slnx \ No newline at end of file + - uses: actions/checkout@v4 + - uses: gvenzl/setup-oracle-sqlcl@v1 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 9.0.x + - name: Install SQLCMD tools + run: | + curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - + curl https://packages.microsoft.com/config/ubuntu/20.04/prod.list | sudo tee /etc/apt/sources.list.d/msprod.list + sudo apt-get update + sudo ACCEPT_EULA=Y apt-get install -y mssql-tools unixodbc-dev + echo 'export PATH="$PATH:/opt/mssql-tools/bin"' >> ~/.bashrc + source ~/.bashrc + - name: Create SQLServer database + run: | + /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'YourStrong@Passw0rd' -Q "CREATE DATABASE [Whatever];" + - name: Create Oracle user + run: | + sql sys/adfkweflajdfglkj@localhost/FREEPDB1 as sysdba < + /// Deletes all integration test databases older than the given time span. + /// + // TODO CK time span! + protected readonly TimeSpan MinTimeSpanBeforeDatabaseDeletion = TimeSpan.FromMinutes(1); // TimeSpan.FromMinutes(60); + + protected IDatabaseNameService DatabaseNameService { get; private set; } = databaseNameService; + + abstract public Task CreateTestDatabaseAsync(DatabaseConnectionConfig databaseConnectionConfig, CancellationToken cancellationToken); + + abstract public Task DropDatabaseAsync(DatabaseInfo databaseInfo, CancellationToken cancellationToken); + + protected DateTime ReadTimeStampFromDatabaseName(string name) + { + var creationDate = DatabaseNameService.ReadTimeStampFromString(name); + + if (!creationDate.HasValue) + { + throw new Exception("You tried to drop a database that was not created by this service. For safety reasons we deny your request."); + } + + return creationDate.Value; + } +} \ No newline at end of file diff --git a/src/Migrator.Tests/Database/DatabaseIntegrationTestServiceFactory.cs b/src/Migrator.Tests/Database/DatabaseIntegrationTestServiceFactory.cs new file mode 100644 index 00000000..424f5a27 --- /dev/null +++ b/src/Migrator.Tests/Database/DatabaseIntegrationTestServiceFactory.cs @@ -0,0 +1,12 @@ +using DryIoc; +using Migrator.Tests.Database.Interfaces; + +namespace Migrator.Tests.Database; + +public class DatabaseIntegrationTestServiceFactory(IResolver resolver) : IDatabaseIntegrationTestServiceFactory +{ + public IDatabaseIntegrationTestService Create(DatabaseProviderType providerType) + { + return resolver.Resolve(serviceKey: providerType); + } +} \ No newline at end of file diff --git a/src/Migrator.Tests/Database/DatabaseIntegrationTestServiceRegistry.cs b/src/Migrator.Tests/Database/DatabaseIntegrationTestServiceRegistry.cs new file mode 100644 index 00000000..268fd50a --- /dev/null +++ b/src/Migrator.Tests/Database/DatabaseIntegrationTestServiceRegistry.cs @@ -0,0 +1,28 @@ +using Migrator.Tests.Database.DatabaseName.Interfaces; +using Migrator.Tests.Database.GuidServices.Interfaces; +using Migrator.Tests.Database.GuidServices; +using Migrator.Tests.Database.Interfaces; +using Migrator.Tests.Database.DerivedDatabaseIntegrationTestServices; +using System; +using DryIoc; +using Migrator.Test.Shared.Database; +using Migrator.Tests.Settings.Interfaces; +using Migrator.Tests.Settings; + +namespace Migrator.Tests.Database; + +public static class DatabaseCreationServiceRegistry +{ + public static void RegisterDatabaseIntegrationTestService(this IRegistrator container) + { + container.Register(reuse: Reuse.Transient); + container.Register(reuse: Reuse.Transient); + container.RegisterInstance(TimeProvider.System, ifAlreadyRegistered: IfAlreadyRegistered.Keep); + container.Register(reuse: Reuse.Transient, ifAlreadyRegistered: IfAlreadyRegistered.Keep); + container.Register(serviceKey: DatabaseProviderType.Oracle); + container.Register(serviceKey: DatabaseProviderType.SQLite); + container.Register(serviceKey: DatabaseProviderType.Postgres); + container.Register(serviceKey: DatabaseProviderType.SQLServer); + container.Register(reuse: Reuse.Singleton, ifAlreadyRegistered: IfAlreadyRegistered.Keep); + } +} diff --git a/src/Migrator.Tests/Database/DatabaseName/DatabaseNameService.cs b/src/Migrator.Tests/Database/DatabaseName/DatabaseNameService.cs new file mode 100644 index 00000000..8d8e7d2f --- /dev/null +++ b/src/Migrator.Tests/Database/DatabaseName/DatabaseNameService.cs @@ -0,0 +1,46 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Migrator.Tests.Database.DatabaseName.Interfaces; +using Migrator.Tests.Database.GuidServices.Interfaces; + +namespace Migrator.Test.Shared.Database; + +public partial class DatabaseNameService(TimeProvider timeProvider, IGuidService guidService) : IDatabaseNameService +{ + private const string TestDatabaseString = "Test"; + private const string TimeStampPattern = "yyyyMMddHHmmssfff"; + + public DateTime? ReadTimeStampFromString(string name) + { + name = Path.GetFileNameWithoutExtension(name); + + var regex = DateTimeRegex(); + var match = regex.Match(name); + + if (match.Success && DateTime.TryParseExact(match.Value, TimeStampPattern, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var res)) + { + return res; + } + + return null; + } + + public string CreateDatabaseName() + { + var dateTimePattern = timeProvider.GetUtcNow() + .ToString(TimeStampPattern); + + var randomString = string.Concat(guidService.NewGuid() + .ToString("N") + .Reverse() + .Take(9)); + + return $"{dateTimePattern}{TestDatabaseString}{randomString}"; + } + + [GeneratedRegex(@"^(\d+)(?=Test.{9}$)")] + private static partial Regex DateTimeRegex(); +} \ No newline at end of file diff --git a/src/Migrator.Tests/Database/DatabaseName/Interfaces/IDatabaseNameService.cs b/src/Migrator.Tests/Database/DatabaseName/Interfaces/IDatabaseNameService.cs new file mode 100644 index 00000000..c5be42e6 --- /dev/null +++ b/src/Migrator.Tests/Database/DatabaseName/Interfaces/IDatabaseNameService.cs @@ -0,0 +1,22 @@ +using System; + +namespace Migrator.Tests.Database.DatabaseName.Interfaces; + +/// +/// Used for integration tests. During integration tests we need to create unique database names for parallel testing. +/// +public interface IDatabaseNameService +{ + /// + /// Reads the date time from the date part of the database or user name (in Oracle we use the user name/schema name). + /// + /// + /// + DateTime? ReadTimeStampFromString(string name); + + /// + /// Creates a database name + /// + /// + string CreateDatabaseName(); +} \ No newline at end of file diff --git a/src/Migrator.Tests/Database/DatabaseProviderType.cs b/src/Migrator.Tests/Database/DatabaseProviderType.cs new file mode 100644 index 00000000..e477f508 --- /dev/null +++ b/src/Migrator.Tests/Database/DatabaseProviderType.cs @@ -0,0 +1,21 @@ +namespace Migrator.Tests.Database; + +public enum DatabaseProviderType +{ + // Do not use in any case not even as default + None = 0, + + Unknown, + + // Postgre SQL + Postgres, + + // SQL Server + SQLServer, + + // SQLite + SQLite, + + // Oracle + Oracle +} \ No newline at end of file diff --git a/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/OracleDatabaseIntegrationTestService.cs b/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/OracleDatabaseIntegrationTestService.cs new file mode 100644 index 00000000..4cecd7a7 --- /dev/null +++ b/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/OracleDatabaseIntegrationTestService.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using LinqToDB; +using LinqToDB.Data; +using Mapster; +using Migrator.Tests.Database.DatabaseName.Interfaces; +using Migrator.Tests.Database.Interfaces; +using Migrator.Tests.Database.Models; +using Migrator.Tests.Settings.Models; +using Oracle.ManagedDataAccess.Client; + +namespace Migrator.Tests.Database.DerivedDatabaseIntegrationTestServices; + +public class OracleDatabaseIntegrationTestService( + TimeProvider timeProvider, + IDatabaseNameService databaseNameService + // IImportExportMappingSchemaFactory importExportMappingSchemaFactory + ) + : DatabaseIntegrationTestServiceBase(databaseNameService), IDatabaseIntegrationTestService +{ + private const string UserStringKey = "User Id"; + private const string PasswordStringKey = "Password"; + private const string ReplaceString = "RandomStringThatIsNotQuotedByTheBuilderDoNotChange"; + // private readonly IImportExportMappingSchemaFactory _importExportMappingSchemaFactory = importExportMappingSchemaFactory; + + /// + /// Creates an oracle database for test purposes. + /// + /// + /// For the creation of the Oracle user used in this method follow these steps: + /// + /// Use a SYSDBA user, connect or switch to the default PDB. + /// On the free docker container the name of the default PDB is "FREEPDB1" use it as the service name or alternatively switch containers. For installations other than the "FREE" Oracle + /// Docker image find out the (default) PDB and switch to it then create grant privileges listed below. Having all set you can create a connection string using the newly created user + /// and password and add it to appsettings.Development (for dev environment) + /// + /// ALTER SESSION SET CONTAINER = FREEPDB1 + /// CREATE USER myuser IDENTIFIED BY mypassword + /// GRANT CREATE USER TO myuser + /// GRANT DROP USER TO myuser + /// GRANT CREATE SESSION TO myuser WITH ADMIN OPTION + /// GRANT RESOURCE TO myuser WITH ADMIN OPTION + /// GRANT CONNECT TO myuser WITH ADMIN OPTION + /// GRANT UNLIMITED TABLESPACE TO myuser with ADMIN OPTION + /// GRANT SELECT ON V_$SESSION TO myuser with GRANT OPTION + /// GRANT ALTER SYSTEM TO myuser + /// + /// Having all set you can create a connection string using the newly created user and password and add it into appsettings.development + /// + /// + /// + /// + /// + public override async Task CreateTestDatabaseAsync(DatabaseConnectionConfig databaseConnectionConfig, CancellationToken cancellationToken) + { + DataConnection context; + + var tempDatabaseConnectionConfig = databaseConnectionConfig.Adapt(); + + var connectionStringBuilder = new OracleConnectionStringBuilder() + { + ConnectionString = tempDatabaseConnectionConfig.ConnectionString + }; + + if (!connectionStringBuilder.TryGetValue(UserStringKey, out var user)) + { + throw new Exception($"Cannot find key '{UserStringKey}'"); + } + + if (!connectionStringBuilder.TryGetValue(PasswordStringKey, out var password)) + { + throw new Exception($"Cannot find key '{PasswordStringKey}'"); + } + + var tempUserName = DatabaseNameService.CreateDatabaseName(); + + List userNames; + + var dataOptions = new DataOptions().UseOracle(databaseConnectionConfig.ConnectionString); + + using (context = new DataConnection(dataOptions)) + { + userNames = await context.QueryToListAsync("SELECT username FROM all_users", cancellationToken); + } + + var toBeDeletedUsers = userNames.Where(x => + { + var creationDate = DatabaseNameService.ReadTimeStampFromString(x); + + return creationDate.HasValue && creationDate.Value < timeProvider.GetUtcNow().Subtract(MinTimeSpanBeforeDatabaseDeletion); + }).ToList(); + + await Parallel.ForEachAsync( + toBeDeletedUsers, + new ParallelOptions { MaxDegreeOfParallelism = 3, CancellationToken = cancellationToken }, + async (x, cancellationTokenInner) => + { + var databaseInfoToBeDeleted = new DatabaseInfo + { + DatabaseConnectionConfig = databaseConnectionConfig.Adapt(), + DatabaseConnectionConfigMaster = databaseConnectionConfig.Adapt(), + SchemaName = x + }; + + await DropDatabaseAsync(databaseInfoToBeDeleted, cancellationTokenInner); + + }); + + using (context = new DataConnection(dataOptions)) + { + await context.ExecuteAsync($"CREATE USER \"{tempUserName}\" IDENTIFIED BY \"{tempUserName}\"", cancellationToken); + + var privileges = new[] + { + "CONNECT", + "CREATE SESSION", + "RESOURCE", + "UNLIMITED TABLESPACE" + }; + + await context.ExecuteAsync($"GRANT {string.Join(", ", privileges)} TO \"{tempUserName}\"", cancellationToken); + await context.ExecuteAsync($"GRANT SELECT ON SYS.V_$SESSION TO \"{tempUserName}\"", cancellationToken); + } + + connectionStringBuilder.Add(UserStringKey, ReplaceString); + connectionStringBuilder.Add(PasswordStringKey, ReplaceString); + + tempDatabaseConnectionConfig.ConnectionString = connectionStringBuilder.ConnectionString; + tempDatabaseConnectionConfig.ConnectionString = tempDatabaseConnectionConfig.ConnectionString.Replace(ReplaceString, $"\"{tempUserName}\""); + + var databaseInfo = new DatabaseInfo + { + DatabaseConnectionConfigMaster = databaseConnectionConfig.Adapt(), + DatabaseConnectionConfig = tempDatabaseConnectionConfig, + SchemaName = tempUserName, + }; + + return databaseInfo; + } + + public override async Task DropDatabaseAsync(DatabaseInfo databaseInfo, CancellationToken cancellationToken) + { + var creationDate = ReadTimeStampFromDatabaseName(databaseInfo.SchemaName); + + var dataOptions = new DataOptions().UseOracle(databaseInfo.DatabaseConnectionConfigMaster.ConnectionString); + // .UseMappingSchema(_importExportMappingSchemaFactory.CreateOracleMappingSchema()); + + using var context = new DataConnection(dataOptions); + + // var vSessions = await context.GetTable() + // .Where(x => x.UserName == databaseInfo.SchemaName) + // .ToListAsync(cancellationToken); + + // await Parallel.ForEachAsync( + // vSessions, + // new ParallelOptions { MaxDegreeOfParallelism = 3, CancellationToken = cancellationToken }, + // async (x, cancellationTokenInner) => + // { + // using var killSessionContext = new DataConnection(dataOptions); + + // var killStatement = $"ALTER SYSTEM KILL SESSION '{x.SID},{x.SerialHashTag}' IMMEDIATE"; + // try + // { + // await killSessionContext.ExecuteAsync(killStatement, cancellationToken); + + // // Oracle does not close the session immediately as they pretend so we need to wait a while + // // Since this happens only in very rare cases we accept waiting for a while. + // // If nobody connects to the database this will never happen. + // await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); + // } + // catch + // { + // // Most probably killed by another parallel running integration test. If not, the DROP USER exception will show the details. + // } + // }); + + try + { + await context.ExecuteAsync($"DROP USER \"{databaseInfo.SchemaName}\" CASCADE", cancellationToken); + } + catch + { + await Task.Delay(2000, cancellationToken); + + // In next Linq2db version this can be replaced by ...FromSql().First(); + // https://github.com/linq2db/linq2db/issues/2779 + // TODO CK create issue in Redmine and refer to it here + var countList = await context.QueryToListAsync($"SELECT COUNT(*) FROM all_users WHERE username = '{databaseInfo.SchemaName}'", cancellationToken); + var count = countList.First(); + + if (count == 1) + { + throw; + } + else + { + // The user was removed by another asynchronously running test that kicked in earlier. + // That's ok for us as we have achieved the goal. + } + } + } +} \ No newline at end of file diff --git a/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/PostgreSqlDatabaseIntegrationTestService.cs b/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/PostgreSqlDatabaseIntegrationTestService.cs new file mode 100644 index 00000000..90c77c72 --- /dev/null +++ b/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/PostgreSqlDatabaseIntegrationTestService.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using LinqToDB; +using LinqToDB.Async; +using LinqToDB.Data; +using Mapster; +using Migrator.Tests.Database.DatabaseName.Interfaces; +using Migrator.Tests.Database.Interfaces; +using Migrator.Tests.Database.Models; +using Migrator.Tests.Settings.Models; +using Npgsql; + +namespace Migrator.Tests.Database.DerivedDatabaseIntegrationTestServices; + +public class PostgreSqlDatabaseIntegrationTestService(TimeProvider timeProvider, IDatabaseNameService databaseNameService) + : DatabaseIntegrationTestServiceBase(databaseNameService), IDatabaseIntegrationTestService +{ + public override async Task CreateTestDatabaseAsync(DatabaseConnectionConfig databaseConnectionConfig, CancellationToken cancellationToken) + { + var clonedDatabaseConnectionConfig = databaseConnectionConfig.Adapt(); + + var builder = new NpgsqlConnectionStringBuilder + { + ConnectionString = clonedDatabaseConnectionConfig.ConnectionString, + Database = "postgres" + }; + + List databaseNames; + + using (var context = new DataConnection(new DataOptions().UsePostgreSQL(builder.ConnectionString))) + { + databaseNames = await context.FromSql("SELECT datname from pg_database WHERE datistemplate = false").ToListAsync(cancellationToken); + } + + var toBeDeletedDatabaseNames = databaseNames.Where(x => + { + var creationDate = DatabaseNameService.ReadTimeStampFromString(x); + + return creationDate.HasValue && creationDate.Value < timeProvider.GetUtcNow().Subtract(MinTimeSpanBeforeDatabaseDeletion); + }).ToList(); + + foreach (var databaseName in toBeDeletedDatabaseNames) + { + var databaseInfoToBeDeleted = new DatabaseInfo { DatabaseConnectionConfig = databaseConnectionConfig, DatabaseName = databaseName }; + await DropDatabaseAsync(databaseInfoToBeDeleted, cancellationToken); + } + + var newDatabaseName = DatabaseNameService.CreateDatabaseName(); + using (var context = new DataConnection(new DataOptions().UsePostgreSQL(builder.ConnectionString))) + { + await context.ExecuteAsync($"CREATE DATABASE \"{newDatabaseName}\"", cancellationToken); + } + + var connectionStringBuilder2 = new NpgsqlConnectionStringBuilder + { + ConnectionString = clonedDatabaseConnectionConfig.ConnectionString, + Database = newDatabaseName + }; + + clonedDatabaseConnectionConfig.ConnectionString = connectionStringBuilder2.ConnectionString; + + var databaseInfo = new DatabaseInfo + { + DatabaseConnectionConfig = clonedDatabaseConnectionConfig, + DatabaseName = newDatabaseName + }; + + return databaseInfo; + } + + public override async Task DropDatabaseAsync(DatabaseInfo databaseInfo, CancellationToken cancellationToken) + { + var creationDate = DatabaseNameService.ReadTimeStampFromString(databaseInfo.DatabaseName); + + if (!creationDate.HasValue) + { + throw new Exception("You tried to drop a database that was not created by this service. For safety reasons we deny your request."); + } + + var builder = new NpgsqlConnectionStringBuilder(databaseInfo.DatabaseConnectionConfig.ConnectionString) + { + Database = "postgres" + }; + + var dataOptions = new DataOptions().UsePostgreSQL(builder.ConnectionString); + + using var context = new DataConnection(dataOptions); + + try + { + await context.ExecuteAsync($"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{databaseInfo.DatabaseName}'", cancellationToken); + await context.ExecuteAsync($"DROP DATABASE \"{databaseInfo.DatabaseName}\"", cancellationToken); + } + catch + { + await Task.Delay(2000, cancellationToken); + + var count = await context.ExecuteAsync($"SELECT COUNT(*) from pg_database WHERE datistemplate = false AND datname = '{databaseInfo.DatabaseName}'", cancellationToken); + + if (count == 1) + { + throw; + } + else + { + // The database was removed by another asynchronously running test that kicked in earlier. + // That's ok for us as we have achieved our objective. + } + } + } +} \ No newline at end of file diff --git a/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/SQLiteDatabaseIntegrationTestService.cs b/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/SQLiteDatabaseIntegrationTestService.cs new file mode 100644 index 00000000..a4e8770d --- /dev/null +++ b/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/SQLiteDatabaseIntegrationTestService.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Data.SQLite; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using LinqToDB; +using LinqToDB.Data; +using Mapster; +using System.Linq; +using Microsoft.Data.Sqlite; +using Migrator.Tests.Database.DatabaseName.Interfaces; +using Migrator.Tests.Database.Interfaces; +using Migrator.Tests.Settings.Models; +using Migrator.Tests.Database.Models; + +namespace Migrator.Tests.Database.DerivedDatabaseIntegrationTestServices; + +public class SQLiteDatabaseIntegrationTestService(TimeProvider timeProvider, IDatabaseNameService databaseNameService) + : DatabaseIntegrationTestServiceBase(databaseNameService), IDatabaseIntegrationTestService +{ + private const string SqliteDataSourceName = "data source"; + private static readonly string[] _sqliteFileExtensions = ["*.sqlite", "*.db", "*.sqlite3", "*.db3", "*.sqlitedb", "*.*wal", "*.*shm", "*.*journal"]; + + public override async Task CreateTestDatabaseAsync(DatabaseConnectionConfig databaseConnectionConfig, CancellationToken cancellationToken) + { + var builder = new SQLiteConnectionStringBuilder { ConnectionString = databaseConnectionConfig.ConnectionString }; + + if (!builder.TryGetValue(SqliteDataSourceName, out var dataSource)) + { + throw new Exception($@"No {SqliteDataSourceName} given in your SQLite connection string. Use a fully qualified path, e.g. Data Source=C:\bla\bla.db"); + } + + var dataSourceString = (string)dataSource; + + if (dataSourceString.Contains("memory", StringComparison.InvariantCultureIgnoreCase)) + { + throw new Exception("You are using an 'in memory' SQLite database connection string."); + } + + if (!Path.IsPathFullyQualified(dataSourceString)) + { + throw new Exception("You need to use a fully qualified path in your SQLite connection string."); + } + + var directory = Path.GetDirectoryName(dataSourceString); + + var filePaths = _sqliteFileExtensions.Select(x => Directory.EnumerateFiles(directory, x, SearchOption.TopDirectoryOnly)) + .SelectMany(x => x) + .ToList(); + + List toBeDeletedDatabases = []; + + foreach (var filePath in filePaths) + { + var fileName = Path.GetFileName(filePath); + + var creationDate = DatabaseNameService.ReadTimeStampFromString(fileName); + + if (creationDate.HasValue && creationDate.Value < timeProvider.GetUtcNow().Subtract(MinTimeSpanBeforeDatabaseDeletion)) + { + var builderExistingFile = new SqliteConnectionStringBuilder { DataSource = filePath }; + var dataConnectionConfigExistingFile = databaseConnectionConfig.Adapt(); + dataConnectionConfigExistingFile.ConnectionString = builderExistingFile.ConnectionString; + + var databaseInfo = new DatabaseInfo + { + DatabaseConnectionConfig = dataConnectionConfigExistingFile, + DatabaseName = fileName + }; + + toBeDeletedDatabases.Add(databaseInfo); + } + } + + foreach (var toBeDeletedDatabase in toBeDeletedDatabases) + { + await DropDatabaseAsync(toBeDeletedDatabase, cancellationToken); + } + + builder.Remove(SqliteDataSourceName); + + var newSqliteDatabaseName = $"{DatabaseNameService.CreateDatabaseName()}.db"; + var fullSqliteDatabaseName = Path.Combine(directory, newSqliteDatabaseName); + + builder.Add(SqliteDataSourceName, fullSqliteDatabaseName); + + var newDatabaseConnectionConfig = databaseConnectionConfig.Adapt(); + newDatabaseConnectionConfig.ConnectionString = builder.ConnectionString; + + // Create the database file physically + using var context = new DataConnection(new DataOptions().UseSQLite(newDatabaseConnectionConfig.ConnectionString)); + + var databaseInfoNew = new DatabaseInfo + { + DatabaseConnectionConfig = newDatabaseConnectionConfig, + DatabaseConnectionConfigMaster = databaseConnectionConfig.Adapt(), + DatabaseName = newSqliteDatabaseName, + }; + + return databaseInfoNew; + } + + public override async Task DropDatabaseAsync(DatabaseInfo databaseInfo, CancellationToken cancellationToken) + { + var builder = new DbConnectionStringBuilder { ConnectionString = databaseInfo.DatabaseConnectionConfig.ConnectionString }; + + if (!builder.TryGetValue(SqliteDataSourceName, out var dataSource)) + { + throw new Exception(); + } + + var dataSourceString = (string)dataSource; + + if (!Path.IsPathFullyQualified(dataSourceString)) + { + throw new Exception("Path is not fully qualified."); + } + + var fileName = Path.GetFileName(dataSourceString); + + var creationDate = DatabaseNameService.ReadTimeStampFromString(fileName); + + if (!creationDate.HasValue) + { + throw new Exception("You tried to drop a database that was not created by this service. For safety reasons we deny your request."); + } + + File.Delete(dataSourceString); + + await Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/SqlServerDatabaseIntegrationTestService.cs b/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/SqlServerDatabaseIntegrationTestService.cs new file mode 100644 index 00000000..204fff30 --- /dev/null +++ b/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/SqlServerDatabaseIntegrationTestService.cs @@ -0,0 +1,112 @@ +using System; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using LinqToDB; +using LinqToDB.Async; +using LinqToDB.Data; +using Mapster; +using Microsoft.Data.SqlClient; +using Migrator.Tests.Database.DatabaseName.Interfaces; +using Migrator.Tests.Database.Interfaces; +using Migrator.Tests.Database.Models; +using Migrator.Tests.Settings.Models; + +namespace Migrator.Tests.Database.DerivedDatabaseIntegrationTestServices; + +public class SqlServerDatabaseIntegrationTestService(TimeProvider timeProvider, IDatabaseNameService databaseNameService) + : DatabaseIntegrationTestServiceBase(databaseNameService), IDatabaseIntegrationTestService +{ + private const string SqlServerInitialCatalogString = "Initial Catalog"; + + public override async Task CreateTestDatabaseAsync(DatabaseConnectionConfig databaseConnectionConfig, CancellationToken cancellationToken) + { + using var context = new DataConnection(new DataOptions().UseSqlServer(databaseConnectionConfig.ConnectionString)); + await context.ExecuteAsync("use master", cancellationToken); + + var databaseNames = context.Query($"SELECT name FROM sys.databases WHERE name NOT IN ('master', 'model', 'msdb', 'tempdb')").ToList(); + + var toBeDeletedDatabaseNames = databaseNames.Where(x => + { + var creationDate = DatabaseNameService.ReadTimeStampFromString(x); + return creationDate.HasValue && creationDate.Value < timeProvider.GetUtcNow().Subtract(MinTimeSpanBeforeDatabaseDeletion); + }).ToList(); + + foreach (var databaseName in toBeDeletedDatabaseNames) + { + var databaseInfoToBeDeleted = new DatabaseInfo { DatabaseConnectionConfig = databaseConnectionConfig, DatabaseName = databaseName }; + await DropDatabaseAsync(databaseInfoToBeDeleted, cancellationToken); + } + + var newDatabaseName = DatabaseNameService.CreateDatabaseName(); + + await context.ExecuteAsync($"CREATE DATABASE [{newDatabaseName}]", cancellationToken); + + var clonedDatabaseConnectionConfig = databaseConnectionConfig.Adapt(); + + var builder = new DbConnectionStringBuilder + { + ConnectionString = clonedDatabaseConnectionConfig.ConnectionString + }; + + if (builder.TryGetValue(SqlServerInitialCatalogString, out var value)) + { + builder.Remove(SqlServerInitialCatalogString); + builder.Add(SqlServerInitialCatalogString, newDatabaseName); + } + + clonedDatabaseConnectionConfig.ConnectionString = builder.ConnectionString; + + var databaseInfo = new DatabaseInfo + { + DatabaseConnectionConfig = clonedDatabaseConnectionConfig, + DatabaseName = newDatabaseName + }; + + return databaseInfo; + } + + public override async Task DropDatabaseAsync(DatabaseInfo databaseInfo, CancellationToken cancellationToken) + { + var creationDate = DatabaseNameService.ReadTimeStampFromString(databaseInfo.DatabaseName); + + if (!creationDate.HasValue) + { + throw new Exception("You tried to drop a database that was not created by this service. For safety reasons we deny your request."); + } + + using var context = new DataConnection(new DataOptions().UseSqlServer(databaseInfo.DatabaseConnectionConfig.ConnectionString)); + await context.ExecuteAsync("use master", cancellationToken); + + try + { + await context.ExecuteAsync($"ALTER DATABASE [{databaseInfo.DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE", cancellationToken); + await context.ExecuteAsync($"DROP DATABASE [{databaseInfo.DatabaseName}]", cancellationToken); + } + catch (SqlException ex) + { + // 3701: "Cannot drop the database because it does not exist or you do not have permission" + if (ex.Errors.Count > 0 && ex.Errors.Cast().Any(x => x.Number == 3701)) + { + await Task.Delay(5000, cancellationToken); + + var count = await context.ExecuteAsync($"SELECT COUNT(*) FROM sys.databases WHERE name = '{databaseInfo.DatabaseName}'"); + + if (count == 1) + { + throw new UnauthorizedAccessException($"The database '{databaseInfo.DatabaseName}' cannot be dropped but it still exists so we assume you do not have sufficient privileges to drop databases or this database.", ex); + } + else + { + // The database was removed by another (asynchronously) running test that kicked in earlier. + // That's ok for us as we have achieved the goal. + } + } + else + { + throw; + } + } + } +} diff --git a/src/Migrator.Tests/Database/GuidServices/GuidService.cs b/src/Migrator.Tests/Database/GuidServices/GuidService.cs new file mode 100644 index 00000000..12662802 --- /dev/null +++ b/src/Migrator.Tests/Database/GuidServices/GuidService.cs @@ -0,0 +1,12 @@ +using System; +using Migrator.Tests.Database.GuidServices.Interfaces; + +namespace Migrator.Tests.Database.GuidServices; + +public class GuidService : IGuidService +{ + public Guid NewGuid() + { + return Guid.NewGuid(); + } +} \ No newline at end of file diff --git a/src/Migrator.Tests/Database/GuidServices/Interfaces/IGuidService.cs b/src/Migrator.Tests/Database/GuidServices/Interfaces/IGuidService.cs new file mode 100644 index 00000000..16b66068 --- /dev/null +++ b/src/Migrator.Tests/Database/GuidServices/Interfaces/IGuidService.cs @@ -0,0 +1,13 @@ +using System; + +namespace Migrator.Tests.Database.GuidServices.Interfaces; + +public interface IGuidService +{ + /// + /// Creates a new database friendly Guid depending on the given database type. + /// + /// + /// + Guid NewGuid(); +} \ No newline at end of file diff --git a/src/Migrator.Tests/Database/Interfaces/IDatabaseIntegrationTestService.cs b/src/Migrator.Tests/Database/Interfaces/IDatabaseIntegrationTestService.cs new file mode 100644 index 00000000..cea8aee9 --- /dev/null +++ b/src/Migrator.Tests/Database/Interfaces/IDatabaseIntegrationTestService.cs @@ -0,0 +1,27 @@ +using System.Threading; +using System.Threading.Tasks; +using Migrator.Tests.Database.Models; +using Migrator.Tests.Settings.Models; + +namespace Migrator.Tests.Database.Interfaces; + +public interface IDatabaseIntegrationTestService +{ + /// + /// Creates a new test database. The database name contains a timestamp and some random alphanumeric chars to increase uniqueness of the name. + /// It also removes old databases that could be leftovers from broken unit tests. + /// + /// + /// + /// + Task CreateTestDatabaseAsync(DatabaseConnectionConfig databaseConnectionConfig, CancellationToken cancellationToken); + + /// + /// Drops a test database. The should hold the of the user with elevated privileges and the + /// Oracle: Schema should hold the name of the user (in Oracle the schema is equal to user) + /// + /// + /// + /// + Task DropDatabaseAsync(DatabaseInfo databaseInfo, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Migrator.Tests/Database/Interfaces/IDatabaseIntegrationTestServiceFactory.cs b/src/Migrator.Tests/Database/Interfaces/IDatabaseIntegrationTestServiceFactory.cs new file mode 100644 index 00000000..cc604348 --- /dev/null +++ b/src/Migrator.Tests/Database/Interfaces/IDatabaseIntegrationTestServiceFactory.cs @@ -0,0 +1,13 @@ + + +namespace Migrator.Tests.Database.Interfaces; + +public interface IDatabaseIntegrationTestServiceFactory +{ + /// + /// Creates a depending on the provider type (Oracle, PostgreSQL etc.). + /// + /// + /// + IDatabaseIntegrationTestService Create(DatabaseProviderType providerType); +} diff --git a/src/Migrator.Tests/Database/Models/DatabaseInfo.cs b/src/Migrator.Tests/Database/Models/DatabaseInfo.cs new file mode 100644 index 00000000..d66c475a --- /dev/null +++ b/src/Migrator.Tests/Database/Models/DatabaseInfo.cs @@ -0,0 +1,26 @@ +using Migrator.Tests.Settings.Models; + +namespace Migrator.Tests.Database.Models; + +public class DatabaseInfo +{ + /// + /// Gets or sets the master + /// + public DatabaseConnectionConfig DatabaseConnectionConfigMaster { get; set; } + + /// + /// Cloned with manipulated connection string. The connection string contains the new database name. + /// + public DatabaseConnectionConfig DatabaseConnectionConfig { get; set; } + + /// + /// Gets or sets the name of the created test database. + /// + public string DatabaseName { get; set; } + + /// + /// Gets or sets the schema name. In Oracle the user name is equal to the schema name. + /// + public string SchemaName { get; set; } +} \ No newline at end of file diff --git a/src/Migrator.Tests/Database/Parsers/Interfaces/ILinq2DBNameToDatabaseServerTypeParser.cs b/src/Migrator.Tests/Database/Parsers/Interfaces/ILinq2DBNameToDatabaseServerTypeParser.cs new file mode 100644 index 00000000..48888d9c --- /dev/null +++ b/src/Migrator.Tests/Database/Parsers/Interfaces/ILinq2DBNameToDatabaseServerTypeParser.cs @@ -0,0 +1,11 @@ +namespace Migrator.Tests.Database.Parsers.Interfaces; + +public interface ILinq2DBNameToDatabaseServerTypeParser +{ + /// + /// Parses the Linq2Db provider name to . + /// + /// + /// + DatabaseProviderType Parse(string linq2DbName); +} \ No newline at end of file diff --git a/src/Migrator.Tests/Migrator.Tests.csproj b/src/Migrator.Tests/Migrator.Tests.csproj index b130a8c8..9d081733 100644 --- a/src/Migrator.Tests/Migrator.Tests.csproj +++ b/src/Migrator.Tests/Migrator.Tests.csproj @@ -11,6 +11,7 @@ + @@ -18,6 +19,10 @@ + + + + diff --git a/src/Migrator.Tests/ProviderFactoryTest.cs b/src/Migrator.Tests/ProviderFactoryTest.cs index 5465e1cd..6a8061d2 100644 --- a/src/Migrator.Tests/ProviderFactoryTest.cs +++ b/src/Migrator.Tests/ProviderFactoryTest.cs @@ -5,7 +5,7 @@ using Migrator.Providers; using Migrator.Tests.Settings; using Migrator.Tests.Settings.Config; -using Npgsql; +using Npgsql; using NUnit.Framework; namespace Migrator.Tests; @@ -25,77 +25,77 @@ public void CanGetDialectsForProvider() } [SetUp] - public void SetUp() - { - DbProviderFactories.RegisterFactory("Npgsql", () => NpgsqlFactory.Instance); - DbProviderFactories.RegisterFactory("MySql.Data.MySqlClient", () => MySql.Data.MySqlClient.MySqlClientFactory.Instance); - DbProviderFactories.RegisterFactory("Oracle.DataAccess.Client", () => Oracle.ManagedDataAccess.Client.OracleClientFactory.Instance); + public void SetUp() + { + DbProviderFactories.RegisterFactory("Npgsql", () => NpgsqlFactory.Instance); + DbProviderFactories.RegisterFactory("MySql.Data.MySqlClient", () => MySql.Data.MySqlClient.MySqlClientFactory.Instance); + DbProviderFactories.RegisterFactory("Oracle.DataAccess.Client", () => Oracle.ManagedDataAccess.Client.OracleClientFactory.Instance); DbProviderFactories.RegisterFactory("System.Data.SqlClient", () => Microsoft.Data.SqlClient.SqlClientFactory.Instance); - DbProviderFactories.RegisterFactory("System.Data.SQLite", () => System.Data.SQLite.SQLiteFactory.Instance); + DbProviderFactories.RegisterFactory("System.Data.SQLite", () => System.Data.SQLite.SQLiteFactory.Instance); } [Test] [Category("MySql")] public void CanLoad_MySqlProvider() - { + { var configReader = new ConfigurationReader(); - var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.MySQL)?.ConnectionString; - if (!String.IsNullOrEmpty(connectionString)) - { - using var provider = ProviderFactory.Create(ProviderTypes.Mysql, connectionString, null); - Assert.That(provider, Is.Not.Null); + var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.MySQLId)?.ConnectionString; + if (!String.IsNullOrEmpty(connectionString)) + { + using var provider = ProviderFactory.Create(ProviderTypes.Mysql, connectionString, null); + Assert.That(provider, Is.Not.Null); } } [Test] [Category("Oracle")] public void CanLoad_OracleProvider() - { + { var configReader = new ConfigurationReader(); - var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.Oracle)?.ConnectionString; - if (!String.IsNullOrEmpty(connectionString)) - { - using var provider = ProviderFactory.Create(ProviderTypes.Oracle, connectionString, null); - Assert.That(provider, Is.Not.Null); + var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.OracleId)?.ConnectionString; + if (!String.IsNullOrEmpty(connectionString)) + { + using var provider = ProviderFactory.Create(ProviderTypes.Oracle, connectionString, null); + Assert.That(provider, Is.Not.Null); } } [Test] [Category("Postgre")] public void CanLoad_PostgreSQLProvider() - { + { var configReader = new ConfigurationReader(); var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.PostgreSQL)?.ConnectionString; - if (!String.IsNullOrEmpty(connectionString)) - { - using var provider = ProviderFactory.Create(ProviderTypes.PostgreSQL, connectionString, null); - Assert.That(provider, Is.Not.Null); + if (!String.IsNullOrEmpty(connectionString)) + { + using var provider = ProviderFactory.Create(ProviderTypes.PostgreSQL, connectionString, null); + Assert.That(provider, Is.Not.Null); } } [Test] [Category("SQLite")] public void CanLoad_SQLiteProvider() - { + { var configReader = new ConfigurationReader(); - var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.SQLiteConnectionConfigId)?.ConnectionString; - if (!String.IsNullOrEmpty(connectionString)) - { - using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connectionString, null); - Assert.That(provider, Is.Not.Null); + var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.SQLiteId)?.ConnectionString; + if (!String.IsNullOrEmpty(connectionString)) + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connectionString, null); + Assert.That(provider, Is.Not.Null); } } [Test] [Category("SqlServer")] public void CanLoad_SqlServerProvider() - { + { var configReader = new ConfigurationReader(); - var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.SQLServerConnectionConfigId)?.ConnectionString; - if (!String.IsNullOrEmpty(connectionString)) - { - using var provider = ProviderFactory.Create(ProviderTypes.SqlServer, connectionString, null); - Assert.That(provider, Is.Not.Null); + var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.SQLServerId)?.ConnectionString; + if (!String.IsNullOrEmpty(connectionString)) + { + using var provider = ProviderFactory.Create(ProviderTypes.SqlServer, connectionString, null); + Assert.That(provider, Is.Not.Null); } } } diff --git a/src/Migrator.Tests/Providers/Base/TransformationProviderConstraintBase.cs b/src/Migrator.Tests/Providers/Base/TransformationProviderConstraintBase.cs index 8a4d4b2e..9da124c7 100644 --- a/src/Migrator.Tests/Providers/Base/TransformationProviderConstraintBase.cs +++ b/src/Migrator.Tests/Providers/Base/TransformationProviderConstraintBase.cs @@ -1,5 +1,6 @@ using System; using System.Data; +using System.Linq; using Migrator.Framework; using NUnit.Framework; @@ -86,7 +87,7 @@ public virtual void CanAddCheckConstraint() [Test] public virtual void RemoveForeignKey() - { + { Console.WriteLine($"Test running in class: {TestContext.CurrentContext.Test.ClassName}"); AddForeignKey(); Provider.RemoveForeignKey("TestTwo", "FK_Test_TestTwo"); @@ -146,11 +147,93 @@ public void AddTableWithCompoundPrimaryKeyShouldKeepNullForOtherProperties() new Column("AddressId", DbType.Int32, ColumnProperty.PrimaryKey), new Column("Name", DbType.String, 30, ColumnProperty.Null) ); + Assert.That(Provider.TableExists("Test"), Is.True, "Table doesn't exist"); Assert.That(Provider.PrimaryKeyExists("Test", "PK_Test"), Is.True, "Constraint doesn't exist"); var column = Provider.GetColumnByName("Test", "Name"); + Assert.That(column, Is.Not.Null); Assert.That((column.ColumnProperty & ColumnProperty.Null) == ColumnProperty.Null, Is.True); } + + [Test] + public void GetForeignKeyConstraints_SingleColumn_Success() + { + // Arrange + const string fkName = "MyForeignKey"; + const string childTableName = "ChildTable"; + const string parentTableName = "ParentTable"; + const string idColumn = "Id"; + const string parentIdColumn = "ParentId"; + + Provider.AddTable(parentTableName, + new Column(idColumn, DbType.Int32, ColumnProperty.PrimaryKey) + ); + + Provider.AddTable(childTableName, + new Column(idColumn, DbType.Int32, ColumnProperty.PrimaryKey), + new Column(parentIdColumn, DbType.Int32) + ); + + Provider.AddForeignKey(fkName, childTableName, parentIdColumn, parentTableName, idColumn); + + // Act + var foreignKeyConstraints = Provider.GetForeignKeyConstraints(childTableName); + + // Assert + var resultSingle = foreignKeyConstraints.Single(); + + Assert.That(resultSingle.Name.ToLowerInvariant(), Is.EqualTo(fkName.ToLowerInvariant())); + Assert.That(resultSingle.ChildTable.ToLowerInvariant(), Is.EqualTo(childTableName.ToLowerInvariant())); + Assert.That(resultSingle.ParentTable.ToLowerInvariant(), Is.EqualTo(parentTableName.ToLowerInvariant())); + Assert.That(resultSingle.ChildColumns.Select(x => x.ToLowerInvariant()).Single(), Is.EqualTo(parentIdColumn.ToLowerInvariant())); + Assert.That(resultSingle.ParentColumns.Select(x => x.ToLowerInvariant()).Single(), Is.EqualTo(idColumn.ToLowerInvariant())); + } + + [Test] + public void GetForeignKeyConstraints_MultiColumnColumn_Success() + { + // Arrange + const string fkName = "MyForeignKey"; + const string childTableName = "ChildTable"; + const string parentTableName = "ParentTable"; + + const string parentColumnId = "Id"; + const string parentColumnTest = "Test"; + const string childColumnParentId = "ParentId"; + const string childColumnParentTest = "ParentTest"; + + Provider.AddTable(parentTableName, + new Column(parentColumnId, DbType.Int32, ColumnProperty.PrimaryKey), + new Column(parentColumnTest, DbType.Int32, ColumnProperty.NotNull) + ); + + Provider.AddTable(childTableName, + new Column(childColumnParentId, DbType.Int32, ColumnProperty.PrimaryKey), + new Column(childColumnParentTest, DbType.Int32) + ); + + Provider.AddUniqueConstraint("MyUniqueConstraint", parentTableName, [parentColumnId, parentColumnTest]); + + Provider.AddForeignKey(fkName, childTableName, [childColumnParentId, childColumnParentTest], parentTableName, [parentColumnId, parentColumnTest]); + + // Act + var foreignKeyConstraints = Provider.GetForeignKeyConstraints(childTableName); + + // Assert + var resultSingle = foreignKeyConstraints.Single(); + + Assert.That(resultSingle.Name.ToLowerInvariant(), Is.EqualTo(fkName.ToLowerInvariant())); + Assert.That(resultSingle.ChildTable.ToLowerInvariant(), Is.EqualTo(childTableName.ToLowerInvariant())); + Assert.That(resultSingle.ParentTable.ToLowerInvariant(), Is.EqualTo(parentTableName.ToLowerInvariant())); + + var childColumns = resultSingle.ChildColumns.Select(x => x.ToLowerInvariant()).ToList(); + var parentColumns = resultSingle.ParentColumns.Select(x => x.ToLowerInvariant()).ToList(); + + Assert.That(childColumns[0], Is.EqualTo(childColumnParentId.ToLowerInvariant())); + Assert.That(childColumns[1], Is.EqualTo(childColumnParentTest.ToLowerInvariant())); + Assert.That(parentColumns[0], Is.EqualTo(parentColumnId.ToLowerInvariant())); + Assert.That(parentColumns[1], Is.EqualTo(parentColumnTest.ToLowerInvariant())); + } } diff --git a/src/Migrator.Tests/Providers/MySQL/MySqlTransformationProviderTest.cs b/src/Migrator.Tests/Providers/MySQL/MySqlTransformationProviderTest.cs index 29d37a2f..a8ee884a 100644 --- a/src/Migrator.Tests/Providers/MySQL/MySqlTransformationProviderTest.cs +++ b/src/Migrator.Tests/Providers/MySQL/MySqlTransformationProviderTest.cs @@ -1,61 +1,61 @@ -using System; -using System.Data; -using Migrator.Framework; -using Migrator.Providers; -using Migrator.Providers.Mysql; -using Migrator.Tests.Settings; -using Migrator.Tests.Settings.Config; -using NUnit.Framework; - -namespace Migrator.Tests.Providers.MySQL; - -[TestFixture] -[Category("MySql")] -public class MySqlTransformationProviderTest : TransformationProviderConstraintBase -{ - [SetUp] - public void SetUp() - { - var configReader = new ConfigurationReader(); - var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.MySQL) - ?.ConnectionString; - - if (string.IsNullOrEmpty(connectionString)) - { - throw new IgnoreException("No MySQL ConnectionString is Set."); - } - - DbProviderFactories.RegisterFactory("MySql.Data.MySqlClient", () => MySql.Data.MySqlClient.MySqlClientFactory.Instance); - - Provider = new MySqlTransformationProvider(new MysqlDialect(), connectionString, "default", null); - - AddDefaultTable(); - } - - [TearDown] - public override void TearDown() - { - DropTestTables(); - } - - // [Test,Ignore("MySql doesn't support check constraints")] - public override void CanAddCheckConstraint() - { - } - - [Test] - public void AddTableWithMyISAMEngine() - { - Provider.AddTable("Test", "MyISAM", - new Column("Id", DbType.Int32, ColumnProperty.NotNull), - new Column("name", DbType.String, 50) - ); - } - - [Test] - [Ignore("needs to be fixed")] - public override void RemoveForeignKey() - { - //Foreign Key exists method seems not to return the key, but the ConstraintExists does - } -} +// using System; +// using System.Data; +// using Migrator.Framework; +// using Migrator.Providers; +// using Migrator.Providers.Mysql; +// using Migrator.Tests.Settings; +// using Migrator.Tests.Settings.Config; +// using NUnit.Framework; + +// namespace Migrator.Tests.Providers.MySQL; + +// [TestFixture] +// [Category("MySql")] +// public class MySqlTransformationProviderTest : TransformationProviderConstraintBase +// { +// [SetUp] +// public void SetUp() +// { +// var configReader = new ConfigurationReader(); +// var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.MySQLId) +// ?.ConnectionString; + +// if (string.IsNullOrEmpty(connectionString)) +// { +// throw new IgnoreException("No MySQL ConnectionString is Set."); +// } + +// DbProviderFactories.RegisterFactory("MySql.Data.MySqlClient", () => MySql.Data.MySqlClient.MySqlClientFactory.Instance); + +// Provider = new MySqlTransformationProvider(new MysqlDialect(), connectionString, "default", null); + +// AddDefaultTable(); +// } + +// [TearDown] +// public override void TearDown() +// { +// DropTestTables(); +// } + +// // [Test,Ignore("MySql doesn't support check constraints")] +// public override void CanAddCheckConstraint() +// { +// } + +// [Test] +// public void AddTableWithMyISAMEngine() +// { +// Provider.AddTable("Test", "MyISAM", +// new Column("Id", DbType.Int32, ColumnProperty.NotNull), +// new Column("name", DbType.String, 50) +// ); +// } + +// [Test] +// [Ignore("needs to be fixed")] +// public override void RemoveForeignKey() +// { +// //Foreign Key exists method seems not to return the key, but the ConstraintExists does +// } +// } diff --git a/src/Migrator.Tests/Providers/Oracle/OracleTransformationProviderTest.cs b/src/Migrator.Tests/Providers/Oracle/OracleTransformationProviderTest.cs deleted file mode 100644 index b68977e5..00000000 --- a/src/Migrator.Tests/Providers/Oracle/OracleTransformationProviderTest.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Data; -using Migrator.Framework; -using Migrator.Providers; -using Migrator.Providers.Oracle; -using Migrator.Tests.Settings; -using Migrator.Tests.Settings.Config; -using NUnit.Framework; - -namespace Migrator.Tests.Providers; - -[TestFixture] -[Category("Oracle")] -public class OracleTransformationProviderTest : TransformationProviderConstraintBase -{ - [SetUp] - public void SetUp() - { - var configReader = new ConfigurationReader(); - var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.Oracle) - ?.ConnectionString; - - if (string.IsNullOrEmpty(connectionString)) - { - throw new IgnoreException("No Oracle ConnectionString is Set."); - } - - DbProviderFactories.RegisterFactory("Oracle.DataAccess.Client", () => Oracle.ManagedDataAccess.Client.OracleClientFactory.Instance); - - Provider = new OracleTransformationProvider(new OracleDialect(), connectionString, null, "default", null); - Provider.BeginTransaction(); - - AddDefaultTable(); - } - - [Test] - public void ChangeColumn_FromNotNullToNotNull() - { - Provider.ExecuteNonQuery("DELETE FROM TestTwo"); - Provider.ChangeColumn("TestTwo", new Column("TestId", DbType.String, 50, ColumnProperty.Null)); - Provider.Insert("TestTwo", ["Id", "TestId"], [3, "Not an Int val."]); - Provider.ChangeColumn("TestTwo", new Column("TestId", DbType.String, 50, ColumnProperty.NotNull)); - Provider.ChangeColumn("TestTwo", new Column("TestId", DbType.String, 50, ColumnProperty.NotNull)); - } -} diff --git a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProviderTest.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProviderTest.cs new file mode 100644 index 00000000..f3a2513f --- /dev/null +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProviderTest.cs @@ -0,0 +1,61 @@ +using System; +using System.Data; +using System.Threading; +using System.Threading.Tasks; +using DotNetProjects.Migrator.Providers.Impl.Oracle; +using DryIoc; +using Migrator.Framework; +using Migrator.Providers; +using Migrator.Providers.Oracle; +using Migrator.Tests.Database; +using Migrator.Tests.Database.Interfaces; +using Migrator.Tests.Settings; +using Migrator.Tests.Settings.Config; +using Migrator.Tests.Settings.Models; +using NUnit.Framework; + +namespace Migrator.Tests.Providers.OracleProvider; + +[TestFixture] +[Category("Oracle")] +public class OracleTransformationProviderTest : TransformationProviderConstraintBase +{ + [SetUp] + public async Task SetUpAsync() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var configReader = new ConfigurationReader(); + + var databaseConnectionConfig = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.OracleId); + + var connectionString = databaseConnectionConfig?.ConnectionString; + + if (string.IsNullOrEmpty(connectionString)) + { + throw new IgnoreException($"No Oracle {nameof(DatabaseConnectionConfig.ConnectionString)} is set."); + } + + DbProviderFactories.RegisterFactory("Oracle.ManagedDataAccess.Client", () => Oracle.ManagedDataAccess.Client.OracleClientFactory.Instance); + + using var container = new Container(); + container.RegisterDatabaseIntegrationTestService(); + var databaseIntegrationTestServiceFactory = container.Resolve(); + var oracleIntegrationTestService = databaseIntegrationTestServiceFactory.Create(DatabaseProviderType.Oracle); + var databaseInfo = await oracleIntegrationTestService.CreateTestDatabaseAsync(databaseConnectionConfig, cts.Token); + + Provider = new OracleTransformationProvider(new OracleDialect(), databaseInfo.DatabaseConnectionConfig.ConnectionString, null, "default", "Oracle.ManagedDataAccess.Client"); + Provider.BeginTransaction(); + + AddDefaultTable(); + } + + [Test] + public void ChangeColumn_FromNotNullToNotNull() + { + Provider.ExecuteNonQuery("DELETE FROM TestTwo"); + Provider.ChangeColumn("TestTwo", new Column("TestId", DbType.String, 50, ColumnProperty.Null)); + Provider.Insert("TestTwo", ["Id", "TestId"], [3, "Not an Int val."]); + Provider.ChangeColumn("TestTwo", new Column("TestId", DbType.String, 50, ColumnProperty.NotNull)); + Provider.ChangeColumn("TestTwo", new Column("TestId", DbType.String, 50, ColumnProperty.NotNull)); + } +} diff --git a/src/Migrator.Tests/Providers/SQLServer/Base/SQLServerTransformationProviderTestBase.cs b/src/Migrator.Tests/Providers/SQLServer/Base/SQLServerTransformationProviderTestBase.cs index 9e942de0..65f19e70 100644 --- a/src/Migrator.Tests/Providers/SQLServer/Base/SQLServerTransformationProviderTestBase.cs +++ b/src/Migrator.Tests/Providers/SQLServer/Base/SQLServerTransformationProviderTestBase.cs @@ -1,9 +1,14 @@ -using System.Data.SqlClient; +using System.Threading; +using System.Threading.Tasks; +using DryIoc; using Migrator.Providers; using Migrator.Providers.SqlServer; +using Migrator.Tests.Database; +using Migrator.Tests.Database.Interfaces; using Migrator.Tests.Providers.Base; using Migrator.Tests.Settings; using Migrator.Tests.Settings.Config; +using Migrator.Tests.Settings.Models; using NUnit.Framework; namespace Migrator.Tests.Providers.SQLServer.Base; @@ -13,21 +18,28 @@ namespace Migrator.Tests.Providers.SQLServer.Base; public abstract class SQLServerTransformationProviderTestBase : TransformationProviderSimpleBase { [SetUp] - public void SetUp() + public async Task SetUpAsync() { var configReader = new ConfigurationReader(); - var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.SQLServerConnectionConfigId) - ?.ConnectionString; - + + var databaseConnectionConfig = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.SQLServerId); + + var connectionString = databaseConnectionConfig?.ConnectionString; + if (string.IsNullOrEmpty(connectionString)) { - throw new IgnoreException("No SqlServer ConnectionString is Set."); + throw new IgnoreException($"No SQL Server {nameof(DatabaseConnectionConfig.ConnectionString)} is set."); } DbProviderFactories.RegisterFactory("Microsoft.Data.SqlClient", () => Microsoft.Data.SqlClient.SqlClientFactory.Instance); - Provider = new SqlServerTransformationProvider(new SqlServerDialect(), connectionString, "dbo", "default", "Microsoft.Data.SqlClient"); - Provider.BeginTransaction(); + using var container = new Container(); + container.RegisterDatabaseIntegrationTestService(); + var databaseIntegrationTestServiceFactory = container.Resolve(); + var sqlServerIntegrationTestService = databaseIntegrationTestServiceFactory.Create(DatabaseProviderType.SQLServer); + var databaseInfo = await sqlServerIntegrationTestService.CreateTestDatabaseAsync(databaseConnectionConfig, CancellationToken.None); + + Provider = new SqlServerTransformationProvider(new SqlServerDialect(), databaseInfo.DatabaseConnectionConfig.ConnectionString, "dbo", "default", "Microsoft.Data.SqlClient"); AddDefaultTable(); } diff --git a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderGenericTests.cs b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderGenericTests.cs index 583d8133..02a22015 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderGenericTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderGenericTests.cs @@ -1,8 +1,15 @@ +using System; using System.Data; +using System.Threading; +using System.Threading.Tasks; +using DryIoc; using Migrator.Providers; using Migrator.Providers.SqlServer; +using Migrator.Tests.Database; +using Migrator.Tests.Database.Interfaces; using Migrator.Tests.Settings; using Migrator.Tests.Settings.Config; +using Migrator.Tests.Settings.Models; using NUnit.Framework; namespace Migrator.Tests.Providers.SQLServer; @@ -12,20 +19,28 @@ namespace Migrator.Tests.Providers.SQLServer; public class SqlServerTransformationProviderGenericTests : TransformationProviderConstraintBase { [SetUp] - public void SetUp() + public async Task SetUpAsync() { var configReader = new ConfigurationReader(); - var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.SQLServerConnectionConfigId) - ?.ConnectionString; - + + var databaseConnectionConfig = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.SQLServerId); + + var connectionString = databaseConnectionConfig?.ConnectionString; + if (string.IsNullOrEmpty(connectionString)) { - throw new IgnoreException("No SqlServer ConnectionString is Set."); + throw new IgnoreException($"No SQL Server {nameof(DatabaseConnectionConfig.ConnectionString)} is set."); } DbProviderFactories.RegisterFactory("Microsoft.Data.SqlClient", () => Microsoft.Data.SqlClient.SqlClientFactory.Instance); - Provider = new SqlServerTransformationProvider(new SqlServerDialect(), connectionString, "dbo", "default", "Microsoft.Data.SqlClient"); + using var container = new Container(); + container.RegisterDatabaseIntegrationTestService(); + var databaseIntegrationTestServiceFactory = container.Resolve(); + var sqlServerIntegrationTestService = databaseIntegrationTestServiceFactory.Create(DatabaseProviderType.SQLServer); + var databaseInfo = await sqlServerIntegrationTestService.CreateTestDatabaseAsync(databaseConnectionConfig, CancellationToken.None); + + Provider = new SqlServerTransformationProvider(new SqlServerDialect(), databaseInfo.DatabaseConnectionConfig.ConnectionString, "dbo", "default", "Microsoft.Data.SqlClient"); Provider.BeginTransaction(); AddDefaultTable(); diff --git a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs index c9fb548e..f415f87e 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Threading.Tasks; using Migrator.Providers; using Migrator.Providers.SqlServer; using Migrator.Tests.Providers.SQLServer.Base; diff --git a/src/Migrator.Tests/Providers/SQLite/Base/SQLiteTransformationProviderTestBase.cs b/src/Migrator.Tests/Providers/SQLite/Base/SQLiteTransformationProviderTestBase.cs index c04d308e..62de83cd 100644 --- a/src/Migrator.Tests/Providers/SQLite/Base/SQLiteTransformationProviderTestBase.cs +++ b/src/Migrator.Tests/Providers/SQLite/Base/SQLiteTransformationProviderTestBase.cs @@ -15,7 +15,7 @@ public abstract class SQLiteTransformationProviderTestBase : TransformationProvi public void SetUp() { var configReader = new ConfigurationReader(); - var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.SQLiteConnectionConfigId) + var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.SQLiteId) .ConnectionString; Provider = new SQLiteTransformationProvider(new SQLiteDialect(), connectionString, "default", null); diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProviderGenericTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProviderGenericTests.cs index 20b8ba32..b5d9b7e1 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProviderGenericTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProviderGenericTests.cs @@ -14,7 +14,7 @@ public class SQLiteTransformationProviderGenericTests : TransformationProviderBa public void SetUp() { var configReader = new ConfigurationReader(); - var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.SQLiteConnectionConfigId) + var connectionString = configReader.GetDatabaseConnectionConfigById(DatabaseConnectionConfigIds.SQLiteId) .ConnectionString; Provider = new SQLiteTransformationProvider(new SQLiteDialect(), connectionString, "default", null); diff --git a/src/Migrator.Tests/Settings/Config/ConnectionIds.cs b/src/Migrator.Tests/Settings/Config/ConnectionIds.cs index 2c8f2f6f..e4b2deb9 100644 --- a/src/Migrator.Tests/Settings/Config/ConnectionIds.cs +++ b/src/Migrator.Tests/Settings/Config/ConnectionIds.cs @@ -2,9 +2,9 @@ namespace Migrator.Tests.Settings.Config; public static class DatabaseConnectionConfigIds { - public const string Oracle = "Oracle"; - public const string MySQL = "MySQL"; + public const string OracleId = "Oracle"; + public const string MySQLId = "MySQL"; public const string PostgreSQL = "PostgreSQL"; - public const string SQLiteConnectionConfigId = "SQLite"; - public const string SQLServerConnectionConfigId = "SQLServer"; + public const string SQLiteId = "SQLite"; + public const string SQLServerId = "SQLServer"; } diff --git a/src/Migrator.Tests/appsettings.json b/src/Migrator.Tests/appsettings.json index a5ab1369..5bd3c423 100644 --- a/src/Migrator.Tests/appsettings.json +++ b/src/Migrator.Tests/appsettings.json @@ -14,11 +14,11 @@ }, { "Id": "Oracle", - "ConnectionString": "Data Source=//localhost:1521/FREEPDB1;User Id=test;Password=test;" + "ConnectionString": "Data Source=//localhost:1521/FREEPDB1;User Id=k;Password=k;" }, { "Id": "MySQL", "ConnectionString": "Server=127.0.0.1;Port=3306;Database=testdb;User Id=testuser;Password=testpass;" } ] -} +} \ No newline at end of file diff --git a/src/Migrator/Framework/ITransformationProvider.cs b/src/Migrator/Framework/ITransformationProvider.cs index ac334299..08fe2eb3 100644 --- a/src/Migrator/Framework/ITransformationProvider.cs +++ b/src/Migrator/Framework/ITransformationProvider.cs @@ -423,6 +423,12 @@ public interface ITransformationProvider : IDisposable /// The names of all the tables. string[] GetTables(); + /// + /// Get all foreign keys by the given table name. + /// ATTENTION: For Postgre SQL the result will be lower case if the names were not quoted on table creation of on FK creation! For Oracle they are uppercase! + /// + /// + /// ForeignKeyConstraint[] GetForeignKeyConstraints(string table); /// diff --git a/src/Migrator/Providers/Impl/Oracle/MsOracleDialect.cs b/src/Migrator/Providers/Impl/Oracle/MsOracleDialect.cs index 28795302..ca5a4bb3 100644 --- a/src/Migrator/Providers/Impl/Oracle/MsOracleDialect.cs +++ b/src/Migrator/Providers/Impl/Oracle/MsOracleDialect.cs @@ -1,4 +1,5 @@ using System.Data; +using DotNetProjects.Migrator.Providers.Impl.Oracle; using Migrator.Framework; namespace Migrator.Providers.Oracle; diff --git a/src/Migrator/Providers/Impl/Oracle/MsOracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/MsOracleTransformationProvider.cs index 65ceb457..43ea9c52 100644 --- a/src/Migrator/Providers/Impl/Oracle/MsOracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/MsOracleTransformationProvider.cs @@ -1,6 +1,7 @@ using System.Data; +using Migrator.Providers; -namespace Migrator.Providers.Oracle; +namespace DotNetProjects.Migrator.Providers.Impl.Oracle; public class MsOracleTransformationProvider : OracleTransformationProvider { diff --git a/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs b/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs index dbe90830..ab94a2da 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs @@ -1,5 +1,6 @@ using System; using System.Data; +using DotNetProjects.Migrator.Providers.Impl.Oracle; using Migrator.Framework; using Migrator.Providers.Impl.Oracle; diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index bb3b4a4f..23c4246f 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -1,14 +1,17 @@ using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Models; using Migrator.Framework; +using Migrator.Providers; using System; using System.Collections.Generic; using System.Data; using System.Globalization; using System.Linq; using System.Text; +using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; using Index = Migrator.Framework.Index; -namespace Migrator.Providers.Oracle; +namespace DotNetProjects.Migrator.Providers.Impl.Oracle; public class OracleTransformationProvider : TransformationProvider { @@ -17,7 +20,7 @@ public class OracleTransformationProvider : TransformationProvider public OracleTransformationProvider(Dialect dialect, string connectionString, string defaultSchema, string scope, string providerName) : base(dialect, connectionString, defaultSchema, scope) { - this.CreateConnection(providerName); + CreateConnection(providerName); } public OracleTransformationProvider(Dialect dialect, IDbConnection connection, string defaultSchema, string scope, string providerName) @@ -46,6 +49,78 @@ public override void DropDatabases(string databaseName) } } + public override ForeignKeyConstraint[] GetForeignKeyConstraints(string table) + { + var constraints = new List(); + var sb = new StringBuilder(); + sb.AppendLine("SELECT"); + sb.AppendLine(" a.OWNER AS TABLE_SCHEMA,"); + sb.AppendLine(" c.CONSTRAINT_NAME AS FK_KEY,"); + sb.AppendLine(" a.TABLE_NAME AS CHILD_TABLE,"); + sb.AppendLine(" a.COLUMN_NAME AS CHILD_COLUMN,"); + sb.AppendLine(" c_pk.TABLE_NAME AS PARENT_TABLE,"); + sb.AppendLine(" col_pk.COLUMN_NAME AS PARENT_COLUMN"); + sb.AppendLine("FROM "); + sb.AppendLine(" ALL_CONS_COLUMNS a "); + sb.AppendLine("JOIN ALL_CONSTRAINTS c"); + sb.AppendLine(" ON a.owner = c.owner AND a.CONSTRAINT_NAME = c.CONSTRAINT_NAME"); + sb.AppendLine("JOIN ALL_CONSTRAINTS c_pk"); + sb.AppendLine(" ON c.R_OWNER = c_pk.OWNER AND c.R_CONSTRAINT_NAME = c_pk.CONSTRAINT_NAME"); + sb.AppendLine("JOIN ALL_CONS_COLUMNS col_pk"); + sb.AppendLine(" ON c_pk.CONSTRAINT_NAME = col_pk.CONSTRAINT_NAME AND c_pk.OWNER = col_pk.OWNER AND a.POSITION = col_pk.POSITION"); + sb.AppendLine($"WHERE LOWER(a.TABLE_NAME) = LOWER('{table}') AND c.CONSTRAINT_TYPE = 'R'"); + sb.AppendLine("ORDER BY a.POSITION"); + + var sql = sb.ToString(); + List foreignKeyConstraintItems = []; + + using (var cmd = CreateCommand()) + using (var reader = ExecuteQuery(cmd, sql)) + { + while (reader.Read()) + { + var constraintItem = new ForeignKeyConstraintItem + { + SchemaName = reader.GetString(reader.GetOrdinal("TABLE_SCHEMA")), + ForeignKeyName = reader.GetString(reader.GetOrdinal("FK_KEY")), + ChildTableName = reader.GetString(reader.GetOrdinal("CHILD_TABLE")), + ChildColumnName = reader.GetString(reader.GetOrdinal("CHILD_COLUMN")), + ParentTableName = reader.GetString(reader.GetOrdinal("PARENT_TABLE")), + ParentColumnName = reader.GetString(reader.GetOrdinal("PARENT_COLUMN")) + }; + + foreignKeyConstraintItems.Add(constraintItem); + } + } + + var schemaChildTableGroups = foreignKeyConstraintItems.GroupBy(x => new { x.SchemaName, x.ChildTableName }).Count(); + + if (schemaChildTableGroups > 1) + { + throw new MigrationException($"Duplicates found (grouping by schema name and child table name). Since we do not offer schemas in '{nameof(GetForeignKeyConstraints)}' at this moment in time we cannot filter your target schema. Your database use the same table name in different schemas."); + } + + var groups = foreignKeyConstraintItems.GroupBy(x => x.ForeignKeyName); + + foreach (var group in groups) + { + var first = group.First(); + + var foreignKeyConstraint = new ForeignKeyConstraint + { + Name = first.ForeignKeyName, + ParentTable = first.ParentTableName, + ParentColumns = [.. group.Select(x => x.ParentColumnName).Distinct()], + ChildTable = first.ChildTableName, + ChildColumns = [.. group.Select(x => x.ChildColumnName).Distinct()] + }; + + constraints.Add(foreignKeyConstraint); + } + + return [.. constraints]; + } + public override void AddForeignKey(string name, string primaryTable, string[] primaryColumns, string refTable, string[] refColumns, ForeignKeyConstraintType constraint) { diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index 44f4cf74..00726572 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -16,7 +16,6 @@ using System; using System.Collections.Generic; using System.Data; -using System.Data.Common; using System.Globalization; using Index = Migrator.Framework.Index; @@ -64,7 +63,39 @@ protected virtual void CreateConnection(string providerName) collationString = "Latin1_General_CI_AS"; } - this.Dialect.RegisterProperty(ColumnProperty.CaseSensitive, "COLLATE " + collationString.Replace("_CI_", "_CS_")); + Dialect.RegisterProperty(ColumnProperty.CaseSensitive, "COLLATE " + collationString.Replace("_CI_", "_CS_")); + } + + public override bool TableExists(string tableName) + { + // This is not clean! Usually you should use schema as well as this query will find tables in other tables as well! + + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $"SELECT OBJECT_ID('{tableName}', 'U')"); + + if (reader.Read()) + { + var result = reader.GetValue(0); + var tableExists = result != DBNull.Value && result != null; + + return tableExists; + } + + return false; + } + + public override bool ViewExists(string viewName) + { + // This is not clean! Usually you should use schema as well as this query will find views in other tables as well! + + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $"SELECT OBJECT_ID('{viewName}', 'V')"); + + var result = cmd.ExecuteScalar(); + + var viewExists = result != DBNull.Value && result != null; + + return viewExists; } public override bool ConstraintExists(string table, string name) @@ -183,50 +214,6 @@ public override void RemoveColumnDefaultValue(string table, string column) } } - - public override bool TableExists(string table) - { - string schema; - - var firstIndex = table.IndexOf("."); - if (firstIndex >= 0) - { - schema = table.Substring(0, firstIndex).Trim(); - table = table.Substring(firstIndex + 1).Trim(); - } - else - { - schema = _defaultSchema; - } - - schema = schema.StartsWith("[") && schema.EndsWith("]") ? schema.Substring(1, schema.Length - 2) : schema; - table = table.StartsWith("[") && table.EndsWith("]") ? table.Substring(1, table.Length - 2) : table; - - using var cmd = CreateCommand(); - using var reader = base.ExecuteQuery(cmd, string.Format("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME='{0}' AND TABLE_SCHEMA='{1}'", table, schema)); - return reader.Read(); - } - - public override bool ViewExists(string view) - { - string schema; - - var firstIndex = view.IndexOf("."); - if (firstIndex >= 0) - { - schema = view.Substring(0, firstIndex); - view = view.Substring(firstIndex + 1); - } - else - { - schema = _defaultSchema; - } - - using var cmd = CreateCommand(); - using var reader = base.ExecuteQuery(cmd, string.Format("SELECT * FROM INFORMATION_SCHEMA.VIEWS WHERE TABLE_NAME='{0}' AND TABLE_SCHEMA='{1}'", view, schema)); - return reader.Read(); - } - public override Index[] GetIndexes(string table) { var retVal = new List(); diff --git a/src/Migrator/Providers/Models/ForeignKeyConstraintItem.cs b/src/Migrator/Providers/Models/ForeignKeyConstraintItem.cs new file mode 100644 index 00000000..03f58e20 --- /dev/null +++ b/src/Migrator/Providers/Models/ForeignKeyConstraintItem.cs @@ -0,0 +1,11 @@ +namespace DotNetProjects.Migrator.Providers.Models; + +public class ForeignKeyConstraintItem +{ + public string SchemaName { get; set; } + public string ForeignKeyName { get; set; } + public string ChildTableName { get; set; } + public string ChildColumnName { get; set; } + public string ParentTableName { get; set; } + public string ParentColumnName { get; set; } +} \ No newline at end of file diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index edaabe57..9786273f 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -12,6 +12,7 @@ #endregion using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Models; using Migrator.Framework; using Migrator.Framework.Loggers; using Migrator.Framework.SchemaBuilder; @@ -140,34 +141,82 @@ public virtual Column[] GetColumns(string table) return columns.ToArray(); } + /// + /// Basic implementation works for Postgre and probably for MySQL (not tested). For Oracle it should be overridden + /// + /// + /// + /// public virtual ForeignKeyConstraint[] GetForeignKeyConstraints(string table) { var constraints = new List(); + var sb = new StringBuilder(); + sb.AppendLine("SELECT"); + sb.AppendLine(" tc.CONSTRAINT_NAME AS FK_KEY,"); + sb.AppendLine(" tc.TABLE_SCHEMA,"); + sb.AppendLine(" tc.TABLE_NAME AS CHILD_TABLE,"); + sb.AppendLine(" kcu.COLUMN_NAME AS CHILD_COLUMN,"); + sb.AppendLine(" ccu.TABLE_NAME AS PARENT_TABLE,"); + sb.AppendLine(" ccu.COLUMN_NAME AS PARENT_COLUMN"); + sb.AppendLine("FROM "); + sb.AppendLine(" INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc "); + sb.AppendLine("JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE as kcu"); + sb.AppendLine(" ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA"); + sb.AppendLine("JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS as rc"); + sb.AppendLine(" ON tc.CONSTRAINT_NAME = rc.CONSTRAINT_NAME AND tc.TABLE_SCHEMA = rc.CONSTRAINT_SCHEMA"); + sb.AppendLine("JOIN INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE AS ccu"); + sb.AppendLine(" ON rc.UNIQUE_CONSTRAINT_NAME = ccu.CONSTRAINT_NAME AND rc.UNIQUE_CONSTRAINT_SCHEMA = ccu.CONSTRAINT_SCHEMA"); + sb.AppendLine($"WHERE LOWER(tc.TABLE_NAME) = LOWER('{table}') AND tc.CONSTRAINT_TYPE = 'FOREIGN KEY'"); + sb.AppendLine("ORDER BY kcu.ORDINAL_POSITION"); + + var sql = sb.ToString(); + List foreignKeyConstraintItems = []; + using (var cmd = CreateCommand()) - using ( - var reader = - // TODO: - // In this statement the naming of alias PK is misleading since INFORMATION_SCHEMA.TABLE_CONSTRAINTS (alias PK) is the child - // while INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS (alias C) is the parent - ExecuteQuery( - cmd, string.Format("SELECT K_Table = FK.TABLE_NAME, FK_Column = CU.COLUMN_NAME, PK_Table = PK.TABLE_NAME, PK_Column = PT.COLUMN_NAME, Constraint_Name = C.CONSTRAINT_NAME FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS C INNER JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS FK ON C.CONSTRAINT_NAME = FK.CONSTRAINT_NAME INNER JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS PK ON C.UNIQUE_CONSTRAINT_NAME = PK.CONSTRAINT_NAME INNER JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE CU ON C.CONSTRAINT_NAME = CU.CONSTRAINT_NAME INNER JOIN ( SELECT i1.TABLE_NAME, i2.COLUMN_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS i1 INNER JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE i2 ON i1.CONSTRAINT_NAME = i2.CONSTRAINT_NAME WHERE i1.CONSTRAINT_TYPE = 'PRIMARY KEY' ) PT ON PT.TABLE_NAME = PK.TABLE_NAME WHERE FK.table_name = '{0}'", table))) + using (var reader = ExecuteQuery(cmd, sql)) { while (reader.Read()) { - var constraint = new ForeignKeyConstraint + var constraintItem = new ForeignKeyConstraintItem { - Name = reader.GetString(4), - ParentTable = reader.GetString(0), - ParentColumns = [reader.GetString(1)], - ChildTable = reader.GetString(2), - ChildColumns = [reader.GetString(3)] + SchemaName = reader.GetString(reader.GetOrdinal("TABLE_SCHEMA")), + ForeignKeyName = reader.GetString(reader.GetOrdinal("FK_KEY")), + ChildTableName = reader.GetString(reader.GetOrdinal("CHILD_TABLE")), + ChildColumnName = reader.GetString(reader.GetOrdinal("CHILD_COLUMN")), + ParentTableName = reader.GetString(reader.GetOrdinal("PARENT_TABLE")), + ParentColumnName = reader.GetString(reader.GetOrdinal("PARENT_COLUMN")) }; - constraints.Add(constraint); + foreignKeyConstraintItems.Add(constraintItem); } } - return constraints.ToArray(); + var schemaChildTableGroups = foreignKeyConstraintItems.GroupBy(x => new { x.SchemaName, x.ChildTableName }).Count(); + + if (schemaChildTableGroups > 1) + { + throw new MigrationException($"Duplicates found (grouping by schema name and child table name). Since we do not offer schemas in '{nameof(GetForeignKeyConstraints)}' at this moment in time we cannot filter your target schema. Your database use the same table name in different schemas."); + } + + var groups = foreignKeyConstraintItems.GroupBy(x => x.ForeignKeyName); + + foreach (var group in groups) + { + var first = group.First(); + + var foreignKeyConstraint = new ForeignKeyConstraint + { + Name = first.ForeignKeyName, + ParentTable = first.ParentTableName, + ParentColumns = [.. group.Select(x => x.ParentColumnName).Distinct()], + ChildTable = first.ChildTableName, + ChildColumns = [.. group.Select(x => x.ChildColumnName).Distinct()] + }; + + constraints.Add(foreignKeyConstraint); + } + + return [.. constraints]; } public virtual string[] GetConstraints(string table) @@ -509,28 +558,12 @@ public virtual void RemoveColumnDefaultValue(string table, string column) public virtual bool TableExists(string table) { - try - { - ExecuteNonQuery("SELECT COUNT(*) FROM " + table); - return true; - } - catch (Exception) - { - return false; - } + throw new NotImplementedException(); } public virtual bool ViewExists(string view) { - try - { - ExecuteNonQuery("SELECT COUNT(*) FROM " + view); - return true; - } - catch (Exception) - { - return false; - } + throw new NotImplementedException(); } public virtual void SwitchDatabase(string databaseName) @@ -1754,20 +1787,23 @@ public virtual void AddTable(string table, string engine, string columns) { table = _dialect.TableNameNeedsQuote ? _dialect.Quote(table) : table; var sqlCreate = string.Format("CREATE TABLE {0} ({1})", table, columns); + ExecuteNonQuery(sqlCreate); } public virtual List GetPrimaryKeys(IEnumerable columns) { - var pks = new List(); + var primaryKeys = new List(); + foreach (var col in columns) { if (col.IsPrimaryKey) { - pks.Add(col.Name); + primaryKeys.Add(col.Name); } } - return pks; + + return primaryKeys; } public virtual void AddColumnDefaultValue(string table, string column, object defaultValue)