diff --git a/src/EFCore.Relational/Migrations/HistoryRepository.cs b/src/EFCore.Relational/Migrations/HistoryRepository.cs index ed111047ece..7a4688d5db5 100644 --- a/src/EFCore.Relational/Migrations/HistoryRepository.cs +++ b/src/EFCore.Relational/Migrations/HistoryRepository.cs @@ -23,8 +23,6 @@ namespace Microsoft.EntityFrameworkCore.Migrations; /// See Database migrations for more information and examples. /// /// -// TODO: Leverage query pipeline for GetAppliedMigrations -// TODO: Leverage update pipeline for GetInsertScript & GetDeleteScript public abstract class HistoryRepository : IHistoryRepository { /// @@ -122,15 +120,14 @@ protected virtual string ProductVersionColumnName /// /// if the table already exists, otherwise. public virtual bool Exists() - => Dependencies.DatabaseCreator.Exists() - && InterpretExistsResult( - Dependencies.RawSqlCommandBuilder.Build(ExistsSql).ExecuteScalar( - new RelationalCommandParameterObject( - Dependencies.Connection, - null, - null, - Dependencies.CurrentContext.Context, - Dependencies.CommandLogger, CommandSource.Migrations))); + => InterpretExistsResult( + Dependencies.RawSqlCommandBuilder.Build(ExistsSql).ExecuteScalar( + new RelationalCommandParameterObject( + Dependencies.Connection, + null, + null, + Dependencies.CurrentContext.Context, + Dependencies.CommandLogger, CommandSource.Migrations))); /// /// Checks whether or not the history table exists. @@ -142,16 +139,15 @@ public virtual bool Exists() /// /// If the is canceled. public virtual async Task ExistsAsync(CancellationToken cancellationToken = default) - => await Dependencies.DatabaseCreator.ExistsAsync(cancellationToken).ConfigureAwait(false) - && InterpretExistsResult( - await Dependencies.RawSqlCommandBuilder.Build(ExistsSql).ExecuteScalarAsync( - new RelationalCommandParameterObject( - Dependencies.Connection, - null, - null, - Dependencies.CurrentContext.Context, - Dependencies.CommandLogger, CommandSource.Migrations), - cancellationToken).ConfigureAwait(false)); + => InterpretExistsResult( + await Dependencies.RawSqlCommandBuilder.Build(ExistsSql).ExecuteScalarAsync( + new RelationalCommandParameterObject( + Dependencies.Connection, + null, + null, + Dependencies.CurrentContext.Context, + Dependencies.CommandLogger, CommandSource.Migrations), + cancellationToken).ConfigureAwait(false)); /// /// Interprets the result of executing . @@ -171,15 +167,49 @@ await Dependencies.RawSqlCommandBuilder.Build(ExistsSql).ExecuteScalarAsync( /// /// The SQL script. public virtual string GetCreateScript() + => string.Concat(GetCreateCommands().Select(c => c.CommandText)); + + /// + /// Creates the history table. + /// + public virtual void Create() + => Dependencies.MigrationCommandExecutor.ExecuteNonQuery(GetCreateCommands(), Dependencies.Connection); + + /// + /// Creates the history table. + /// + public virtual Task CreateAsync(CancellationToken cancellationToken = default) + => Dependencies.MigrationCommandExecutor.ExecuteNonQueryAsync(GetCreateCommands(), Dependencies.Connection, cancellationToken); + + /// + /// Returns the migration commands that will create the history table. + /// + /// The migration commands that will create the history table. + protected virtual IReadOnlyList GetCreateCommands() { var model = EnsureModel(); var operations = Dependencies.ModelDiffer.GetDifferences(null, model.GetRelationalModel()); var commandList = Dependencies.MigrationsSqlGenerator.Generate(operations, model); - - return string.Concat(commandList.Select(c => c.CommandText)); + return commandList; } + /// + /// Gets an exclusive lock on the database. + /// + /// The time to wait for the lock before an exception is thrown. + /// An object that can be disposed to release the lock. + public abstract IMigrationDatabaseLock GetDatabaseLock(TimeSpan timeout); + + /// + /// Gets an exclusive lock on the database. + /// + /// The time to wait for the lock before an exception is thrown. + /// A to observe while waiting for the task to complete. + /// An object that can be disposed to release the lock. + /// If the is canceled. + public abstract Task GetDatabaseLockAsync(TimeSpan timeout, CancellationToken cancellationToken = default); + /// /// Configures the entity type mapped to the history table. /// diff --git a/src/EFCore.Relational/Migrations/HistoryRepositoryDependencies.cs b/src/EFCore.Relational/Migrations/HistoryRepositoryDependencies.cs index b3e83400593..b5f40bd8ba2 100644 --- a/src/EFCore.Relational/Migrations/HistoryRepositoryDependencies.cs +++ b/src/EFCore.Relational/Migrations/HistoryRepositoryDependencies.cs @@ -52,6 +52,7 @@ public HistoryRepositoryDependencies( IDbContextOptions options, IMigrationsModelDiffer modelDiffer, IMigrationsSqlGenerator migrationsSqlGenerator, + IMigrationCommandExecutor migrationCommandExecutor, ISqlGenerationHelper sqlGenerationHelper, IConventionSetBuilder conventionSetBuilder, ModelDependencies modelDependencies, @@ -66,6 +67,7 @@ public HistoryRepositoryDependencies( Options = options; ModelDiffer = modelDiffer; MigrationsSqlGenerator = migrationsSqlGenerator; + MigrationCommandExecutor = migrationCommandExecutor; SqlGenerationHelper = sqlGenerationHelper; ConventionSetBuilder = conventionSetBuilder; ModelDependencies = modelDependencies; @@ -110,6 +112,11 @@ public HistoryRepositoryDependencies( /// public ISqlGenerationHelper SqlGenerationHelper { get; init; } + /// + /// The service for executing Migrations operations. + /// + public IMigrationCommandExecutor MigrationCommandExecutor { get; init; } + /// /// The core convention set to use when creating the model. /// diff --git a/src/EFCore.Relational/Migrations/IHistoryRepository.cs b/src/EFCore.Relational/Migrations/IHistoryRepository.cs index 70abc61b27d..cce9afe3a65 100644 --- a/src/EFCore.Relational/Migrations/IHistoryRepository.cs +++ b/src/EFCore.Relational/Migrations/IHistoryRepository.cs @@ -24,13 +24,13 @@ namespace Microsoft.EntityFrameworkCore.Migrations; public interface IHistoryRepository { /// - /// Checks whether or not the history table exists. + /// Checks whether the history table exists. /// /// if the table already exists, otherwise. bool Exists(); /// - /// Checks whether or not the history table exists. + /// Checks whether the history table exists. /// /// A to observe while waiting for the task to complete. /// @@ -40,6 +40,22 @@ public interface IHistoryRepository /// If the is canceled. Task ExistsAsync(CancellationToken cancellationToken = default); + /// + /// Creates the history table. + /// + void Create(); + + /// + /// Creates the history table. + /// + /// A to observe while waiting for the task to complete. + /// + /// A task that represents the asynchronous operation. The task result contains + /// if the table already exists, otherwise. + /// + /// If the is canceled. + Task CreateAsync(CancellationToken cancellationToken = default); + /// /// Queries the history table for all migrations that have been applied. /// @@ -58,6 +74,22 @@ public interface IHistoryRepository Task> GetAppliedMigrationsAsync( CancellationToken cancellationToken = default); + /// + /// Gets an exclusive lock on the database. + /// + /// The time to wait for the lock before an exception is thrown. + /// An object that can be disposed to release the lock. + IMigrationDatabaseLock GetDatabaseLock(TimeSpan timeout); + + /// + /// Gets an exclusive lock on the database. + /// + /// The time to wait for the lock before an exception is thrown. + /// A to observe while waiting for the task to complete. + /// An object that can be disposed to release the lock. + /// If the is canceled. + Task GetDatabaseLockAsync(TimeSpan timeout, CancellationToken cancellationToken = default); + /// /// Generates a SQL script that will create the history table. /// diff --git a/src/EFCore.Relational/Migrations/IMigrationDatabaseLock.cs b/src/EFCore.Relational/Migrations/IMigrationDatabaseLock.cs new file mode 100644 index 00000000000..da6175e0dda --- /dev/null +++ b/src/EFCore.Relational/Migrations/IMigrationDatabaseLock.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Migrations; + +/// +/// Represents an exclusive lock on the database that is used to ensure that only one migration application can be run at a time. +/// +public interface IMigrationDatabaseLock : IDisposable, IAsyncDisposable +{ +} diff --git a/src/EFCore.Relational/Migrations/Internal/Migrator.cs b/src/EFCore.Relational/Migrations/Internal/Migrator.cs index 67d61c60844..6263bec3a9b 100644 --- a/src/EFCore.Relational/Migrations/Internal/Migrator.cs +++ b/src/EFCore.Relational/Migrations/Internal/Migrator.cs @@ -61,6 +61,14 @@ public Migrator( _activeProvider = databaseProvider.Name; } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected virtual TimeSpan LockTimeout { get; } = TimeSpan.FromMinutes(30); + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -71,29 +79,32 @@ public virtual void Migrate(string? targetMigration = null) { _logger.MigrateUsingConnection(this, _connection); - if (!_historyRepository.Exists()) + if (!_databaseCreator.Exists()) + { + _databaseCreator.Create(); + } + + try { - if (!_databaseCreator.Exists()) + _connection.Open(); + + using var _ = _historyRepository.GetDatabaseLock(LockTimeout); + + if (!_historyRepository.Exists()) { - _databaseCreator.Create(); + _historyRepository.Create(); } - var command = _rawSqlCommandBuilder.Build( - _historyRepository.GetCreateScript()); + var commandLists = GetMigrationCommandLists(_historyRepository.GetAppliedMigrations(), targetMigration); - command.ExecuteNonQuery( - new RelationalCommandParameterObject( - _connection, - null, - null, - _currentContext.Context, - _commandLogger, CommandSource.Migrations)); + foreach (var commandList in commandLists) + { + _migrationCommandExecutor.ExecuteNonQuery(commandList(), _connection); + } } - - var commandLists = GetMigrationCommandLists(_historyRepository.GetAppliedMigrations(), targetMigration); - foreach (var commandList in commandLists) + finally { - _migrationCommandExecutor.ExecuteNonQuery(commandList(), _connection); + _connection.Close(); } } @@ -109,35 +120,36 @@ public virtual async Task MigrateAsync( { _logger.MigrateUsingConnection(this, _connection); - if (!await _historyRepository.ExistsAsync(cancellationToken).ConfigureAwait(false)) + if (!await _databaseCreator.ExistsAsync(cancellationToken).ConfigureAwait(false)) { - if (!await _databaseCreator.ExistsAsync(cancellationToken).ConfigureAwait(false)) + await _databaseCreator.CreateAsync(cancellationToken).ConfigureAwait(false); + } + + try + { + await _connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + var dbLock = await _historyRepository.GetDatabaseLockAsync(LockTimeout, cancellationToken).ConfigureAwait(false); + await using var _ = dbLock.ConfigureAwait(false); + + if (!await _historyRepository.ExistsAsync(cancellationToken).ConfigureAwait(false)) { - await _databaseCreator.CreateAsync(cancellationToken).ConfigureAwait(false); + await _historyRepository.CreateAsync(cancellationToken).ConfigureAwait(false); } - var command = _rawSqlCommandBuilder.Build( - _historyRepository.GetCreateScript()); - - await command.ExecuteNonQueryAsync( - new RelationalCommandParameterObject( - _connection, - null, - null, - _currentContext.Context, - _commandLogger, CommandSource.Migrations), - cancellationToken) - .ConfigureAwait(false); - } - - var commandLists = GetMigrationCommandLists( - await _historyRepository.GetAppliedMigrationsAsync(cancellationToken).ConfigureAwait(false), - targetMigration); + var commandLists = GetMigrationCommandLists( + await _historyRepository.GetAppliedMigrationsAsync(cancellationToken).ConfigureAwait(false), + targetMigration); - foreach (var commandList in commandLists) + foreach (var commandList in commandLists) + { + await _migrationCommandExecutor.ExecuteNonQueryAsync(commandList(), _connection, cancellationToken) + .ConfigureAwait(false); + } + } + finally { - await _migrationCommandExecutor.ExecuteNonQueryAsync(commandList(), _connection, cancellationToken) - .ConfigureAwait(false); + _connection.Close(); } } @@ -303,8 +315,9 @@ public virtual string GenerateScript( .Append(_sqlGenerationHelper.BatchTerminator); } - var transactionStarted = false; - + var idempotencyEnd = idempotent + ? _historyRepository.GetEndIfScript() + : null; for (var i = 0; i < migrationsToRevert.Count; i++) { var migration = migrationsToRevert[i]; @@ -314,107 +327,82 @@ public virtual string GenerateScript( _logger.MigrationGeneratingDownScript(this, migration, fromMigration, toMigration, idempotent); - foreach (var command in GenerateDownSql(migration, previousMigration, options)) - { - if (!noTransactions) - { - if (!transactionStarted && !command.TransactionSuppressed) - { - builder - .AppendLine(_sqlGenerationHelper.StartTransactionStatement) - .Append(_sqlGenerationHelper.BatchTerminator); - transactionStarted = true; - } - - if (transactionStarted && command.TransactionSuppressed) - { - builder - .AppendLine(_sqlGenerationHelper.CommitTransactionStatement) - .Append(_sqlGenerationHelper.BatchTerminator); - transactionStarted = false; - } - } + var idempotencyCondition = idempotent + ? _historyRepository.GetBeginIfExistsScript(migration.GetId()) + : null; - if (idempotent) - { - builder.AppendLine(_historyRepository.GetBeginIfExistsScript(migration.GetId())); - using (builder.Indent()) - { - builder.AppendLines(command.CommandText); - } - - builder.Append(_historyRepository.GetEndIfScript()); - } - else - { - builder.Append(command.CommandText); - } - - builder.Append(_sqlGenerationHelper.BatchTerminator); - } - - if (!noTransactions && transactionStarted) - { - builder - .AppendLine(_sqlGenerationHelper.CommitTransactionStatement) - .Append(_sqlGenerationHelper.BatchTerminator); - transactionStarted = false; - } + GenerateSqlScript(GenerateDownSql(migration, previousMigration, options), builder, _sqlGenerationHelper, noTransactions, idempotencyCondition, idempotencyEnd); } foreach (var migration in migrationsToApply) { _logger.MigrationGeneratingUpScript(this, migration, fromMigration, toMigration, idempotent); - foreach (var command in GenerateUpSql(migration, options)) + var idempotencyCondition = idempotent + ? _historyRepository.GetBeginIfNotExistsScript(migration.GetId()) + : null; + + GenerateSqlScript(GenerateUpSql(migration, options), builder, _sqlGenerationHelper, noTransactions, idempotencyCondition, idempotencyEnd); + } + + return builder.ToString(); + } + + private static void GenerateSqlScript( + IEnumerable commands, + IndentedStringBuilder builder, + ISqlGenerationHelper sqlGenerationHelper, + bool noTransactions = false, + string? idempotencyCondition = null, + string? idempotencyEnd = null) + { + var transactionStarted = false; + foreach (var command in commands) + { + if (!noTransactions) { - if (!noTransactions) + if (!transactionStarted && !command.TransactionSuppressed) { - if (!transactionStarted && !command.TransactionSuppressed) - { - builder - .AppendLine(_sqlGenerationHelper.StartTransactionStatement) - .Append(_sqlGenerationHelper.BatchTerminator); - transactionStarted = true; - } - - if (transactionStarted && command.TransactionSuppressed) - { - builder - .AppendLine(_sqlGenerationHelper.CommitTransactionStatement) - .Append(_sqlGenerationHelper.BatchTerminator); - transactionStarted = false; - } + builder + .AppendLine(sqlGenerationHelper.StartTransactionStatement) + .Append(sqlGenerationHelper.BatchTerminator); + transactionStarted = true; } - if (idempotent) + if (transactionStarted && command.TransactionSuppressed) { - builder.AppendLine(_historyRepository.GetBeginIfNotExistsScript(migration.GetId())); - using (builder.Indent()) - { - builder.AppendLines(command.CommandText); - } - - builder.Append(_historyRepository.GetEndIfScript()); + builder + .AppendLine(sqlGenerationHelper.CommitTransactionStatement) + .Append(sqlGenerationHelper.BatchTerminator); + transactionStarted = false; } - else + } + + if (idempotencyCondition != null + && idempotencyEnd != null) + { + builder.AppendLine(idempotencyCondition); + using (builder.Indent()) { - builder.Append(command.CommandText); + builder.AppendLines(command.CommandText); } - builder.Append(_sqlGenerationHelper.BatchTerminator); + builder.Append(idempotencyEnd); } - - if (!noTransactions && transactionStarted) + else { - builder - .AppendLine(_sqlGenerationHelper.CommitTransactionStatement) - .Append(_sqlGenerationHelper.BatchTerminator); - transactionStarted = false; + builder.Append(command.CommandText); } + + builder.Append(sqlGenerationHelper.BatchTerminator); } - return builder.ToString(); + if (!noTransactions && transactionStarted) + { + builder + .AppendLine(sqlGenerationHelper.CommitTransactionStatement) + .Append(sqlGenerationHelper.BatchTerminator); + } } /// @@ -432,7 +420,7 @@ protected virtual IReadOnlyList GenerateUpSql( return _migrationsSqlGenerator .Generate(migration.UpOperations, FinalizeModel(migration.TargetModel), options) - .Concat(new[] { new MigrationCommand(insertCommand, _currentContext.Context, _commandLogger) }) + .Concat([new MigrationCommand(insertCommand, _currentContext.Context, _commandLogger)]) .ToList(); } @@ -453,7 +441,7 @@ protected virtual IReadOnlyList GenerateDownSql( return _migrationsSqlGenerator .Generate( migration.DownOperations, previousMigration == null ? null : FinalizeModel(previousMigration.TargetModel), options) - .Concat(new[] { new MigrationCommand(deleteCommand, _currentContext.Context, _commandLogger) }) + .Concat([new MigrationCommand(deleteCommand, _currentContext.Context, _commandLogger)]) .ToList(); } diff --git a/src/EFCore.SqlServer/Migrations/Internal/SqlServerHistoryRepository.cs b/src/EFCore.SqlServer/Migrations/Internal/SqlServerHistoryRepository.cs index 1169be0a89d..f1c81949784 100644 --- a/src/EFCore.SqlServer/Migrations/Internal/SqlServerHistoryRepository.cs +++ b/src/EFCore.SqlServer/Migrations/Internal/SqlServerHistoryRepository.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text; +using Microsoft.Data.SqlClient; namespace Microsoft.EntityFrameworkCore.SqlServer.Migrations.Internal; @@ -53,6 +54,94 @@ protected override string ExistsSql protected override bool InterpretExistsResult(object? value) => value != DBNull.Value; + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override IMigrationDatabaseLock GetDatabaseLock(TimeSpan timeout) + { + var dbLock = CreateMigrationDatabaseLock(); + int result; + try + { + result = (int)CreateGetLockCommand(timeout).ExecuteScalar(CreateRelationalCommandParameters())!; + } + catch + { + try + { + dbLock.Dispose(); + } + catch + { + } + + throw; + } + + return result < 0 + ? throw new TimeoutException() + : dbLock; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override async Task GetDatabaseLockAsync(TimeSpan timeout, CancellationToken cancellationToken = default) + { + var dbLock = CreateMigrationDatabaseLock(); + int result; + try + { + result = (int)(await CreateGetLockCommand(timeout).ExecuteScalarAsync(CreateRelationalCommandParameters(), cancellationToken) + .ConfigureAwait(false))!; + } + catch (Exception) + { + try + { + await dbLock.DisposeAsync().ConfigureAwait(false); + } + catch (Exception) + { + } + + throw; + } + + return result < 0 + ? throw new TimeoutException() + : dbLock; + } + + private IRelationalCommand CreateGetLockCommand(TimeSpan timeout) + => Dependencies.RawSqlCommandBuilder.Build(@" +DECLARE @result int; +EXEC @result = sp_getapplock @Resource = '__EFLock', @LockOwner = 'Session', @LockMode = 'Exclusive', @LockTimeout = @LockTimeout; +SELECT @result", + [new SqlParameter("@LockTimeout", timeout.TotalMilliseconds)]).RelationalCommand; + + private SqlServerMigrationDatabaseLock CreateMigrationDatabaseLock() + => new SqlServerMigrationDatabaseLock( + Dependencies.RawSqlCommandBuilder.Build(@" +DECLARE @result int; +EXEC @result = sp_releaseapplock @Resource = '__EFLock', @LockOwner = 'Session'; +SELECT @result"), + CreateRelationalCommandParameters()); + + private RelationalCommandParameterObject CreateRelationalCommandParameters() + => new( + Dependencies.Connection, + null, + null, + Dependencies.CurrentContext.Context, + Dependencies.CommandLogger, CommandSource.Migrations); + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationDatabaseLock.cs b/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationDatabaseLock.cs new file mode 100644 index 00000000000..78b85c54abf --- /dev/null +++ b/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationDatabaseLock.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.SqlServer.Migrations.Internal; + +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +public class SqlServerMigrationDatabaseLock + (IRelationalCommand relationalCommand, + RelationalCommandParameterObject relationalCommandParameters, + CancellationToken cancellationToken = default) + : IMigrationDatabaseLock +{ + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public void Dispose() + => relationalCommand.ExecuteScalar(relationalCommandParameters); + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public async ValueTask DisposeAsync() + => await relationalCommand.ExecuteScalarAsync(relationalCommandParameters, cancellationToken).ConfigureAwait(false); +} diff --git a/src/EFCore.Sqlite.Core/Migrations/Internal/SqliteHistoryRepository.cs b/src/EFCore.Sqlite.Core/Migrations/Internal/SqliteHistoryRepository.cs index 1d0a57fd312..f968d0e0350 100644 --- a/src/EFCore.Sqlite.Core/Migrations/Internal/SqliteHistoryRepository.cs +++ b/src/EFCore.Sqlite.Core/Migrations/Internal/SqliteHistoryRepository.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text; using Microsoft.EntityFrameworkCore.Sqlite.Internal; namespace Microsoft.EntityFrameworkCore.Sqlite.Migrations.Internal; @@ -13,6 +14,8 @@ namespace Microsoft.EntityFrameworkCore.Sqlite.Migrations.Internal; /// public class SqliteHistoryRepository : HistoryRepository { + private static readonly TimeSpan _retryDelay = TimeSpan.FromSeconds(1); + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -30,16 +33,20 @@ public SqliteHistoryRepository(HistoryRepositoryDependencies dependencies) /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// - protected override string ExistsSql + protected override string ExistsSql => CreateExistsSql(TableName); + + /// + /// The name of the table that will serve as a database-wide lock for migrations. + /// + protected virtual string LockTableName { get; } = "__EFLock"; + + private string CreateExistsSql(string tableName) { - get - { - var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string)); + var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string)); - return "SELECT COUNT(*) FROM \"sqlite_master\" WHERE \"name\" = " - + stringTypeMapping.GenerateSqlLiteral(TableName) - + " AND \"type\" = 'table';"; - } + return "SELECT COUNT(*) FROM \"sqlite_master\" WHERE \"name\" = " + + stringTypeMapping.GenerateSqlLiteral(tableName) + + " AND \"type\" = 'table';"; } /// @@ -60,7 +67,6 @@ protected override bool InterpretExistsResult(object? value) public override string GetCreateIfNotExistsScript() { var script = GetCreateScript(); - return script.Insert(script.IndexOf("CREATE TABLE", StringComparison.Ordinal) + 12, " IF NOT EXISTS"); } @@ -90,4 +96,171 @@ public override string GetBeginIfExistsScript(string migrationId) /// public override string GetEndIfScript() => throw new NotSupportedException(SqliteStrings.MigrationScriptGenerationNotSupported); + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override IMigrationDatabaseLock GetDatabaseLock(TimeSpan timeout) + { + if (!InterpretExistsResult(Dependencies.RawSqlCommandBuilder.Build(CreateExistsSql(LockTableName)) + .ExecuteScalar(CreateRelationalCommandParameters()))) + { + CreateLockTableCommand().ExecuteNonQuery(CreateRelationalCommandParameters()); + } + + var retryDelay = _retryDelay; + var startTime = DateTimeOffset.UtcNow; + while (DateTimeOffset.UtcNow - startTime < timeout) + { + var dbLock = CreateMigrationDatabaseLock(); + var insertCount = CreateInsertLockCommand(DateTimeOffset.UtcNow) + .ExecuteScalar(CreateRelationalCommandParameters()); + if ((long)insertCount! == 1) + { + return dbLock; + } + + using var reader = CreateGetLockCommand().ExecuteReader(CreateRelationalCommandParameters()); + if (reader.Read()) + { + var timestamp = reader.DbDataReader.GetFieldValue(1); + if (DateTimeOffset.UtcNow - timestamp > timeout) + { + var id = reader.DbDataReader.GetFieldValue(0); + CreateDeleteLockCommand(id).ExecuteNonQuery(CreateRelationalCommandParameters()); + } + } + + using var waitEvent = new ManualResetEventSlim(false); + waitEvent.WaitHandle.WaitOne(retryDelay); + if (retryDelay < TimeSpan.FromMinutes(1)) + { + retryDelay = retryDelay.Add(retryDelay); + } + } + + throw new TimeoutException(); + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override async Task GetDatabaseLockAsync(TimeSpan timeout, CancellationToken cancellationToken = default) + { + if (!InterpretExistsResult(await Dependencies.RawSqlCommandBuilder.Build(CreateExistsSql(LockTableName)) + .ExecuteScalarAsync(CreateRelationalCommandParameters(), cancellationToken).ConfigureAwait(false))) + { + await CreateLockTableCommand().ExecuteNonQueryAsync(CreateRelationalCommandParameters(), cancellationToken).ConfigureAwait(false); + } + + var retryDelay = _retryDelay; + var startTime = DateTimeOffset.UtcNow; + while (DateTimeOffset.UtcNow - startTime < timeout) + { + var dbLock = CreateMigrationDatabaseLock(); + var insertCount = await CreateInsertLockCommand(DateTimeOffset.UtcNow) + .ExecuteScalarAsync(CreateRelationalCommandParameters(), cancellationToken) + .ConfigureAwait(false); + if ((long)insertCount! == 1) + { + return dbLock; + } + + using var reader = await CreateGetLockCommand().ExecuteReaderAsync(CreateRelationalCommandParameters(), cancellationToken) + .ConfigureAwait(false); + if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var timestamp = await reader.DbDataReader.GetFieldValueAsync(1).ConfigureAwait(false); + if (DateTimeOffset.UtcNow - timestamp > timeout) + { + var id = await reader.DbDataReader.GetFieldValueAsync(0).ConfigureAwait(false); + await CreateDeleteLockCommand(id).ExecuteNonQueryAsync(CreateRelationalCommandParameters(), cancellationToken) + .ConfigureAwait(false); + } + } + + await Task.Delay(_retryDelay, cancellationToken).ConfigureAwait(true); + if (retryDelay < TimeSpan.FromMinutes(1)) + { + retryDelay = retryDelay.Add(retryDelay); + } + } + + throw new TimeoutException(); + } + + private IRelationalCommand CreateLockTableCommand() + => Dependencies.RawSqlCommandBuilder.Build($""" +CREATE TABLE IF NOT EXISTS "{LockTableName}" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK___EFLock" PRIMARY KEY, + "Timestamp" TEXT NOT NULL +); +"""); + + private IRelationalCommand CreateInsertLockCommand(DateTimeOffset timestamp) + { + var timestampLiteral = Dependencies.TypeMappingSource.GetMapping(typeof(DateTimeOffset)).GenerateSqlLiteral(timestamp); + + return Dependencies.RawSqlCommandBuilder.Build($""" +INSERT OR IGNORE INTO "{LockTableName}"("Id", "Timestamp") VALUES(1, {timestampLiteral}); +SELECT changes(); +"""); + } + + private IRelationalCommand CreateGetLockCommand() + => Dependencies.RawSqlCommandBuilder.Build($""" +SELECT "Id", "Timestamp" FROM "{LockTableName}" LIMIT 1; +"""); + + private IRelationalCommand CreateDeleteLockCommand(int? id = null) + { + var sql = $""" +DELETE FROM "{LockTableName}" +"""; + if (id != null) + { + sql += $""" +WHERE "Id" = {id} +"""; + } + sql += ";"; + return Dependencies.RawSqlCommandBuilder.Build(sql); + } + + private SqliteMigrationDatabaseLock CreateMigrationDatabaseLock() + => new(CreateDeleteLockCommand(), CreateRelationalCommandParameters()); + + private string GetLockInsertScript(HistoryRow row) + { + var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string)); + + return new StringBuilder().Append("INSERT INTO ") + .Append(SqlGenerationHelper.DelimitIdentifier(TableName, TableSchema)) + .Append(" (") + .Append(SqlGenerationHelper.DelimitIdentifier(MigrationIdColumnName)) + .Append(", ") + .Append(SqlGenerationHelper.DelimitIdentifier(ProductVersionColumnName)) + .AppendLine(")") + .Append("VALUES (") + .Append(stringTypeMapping.GenerateSqlLiteral(row.MigrationId)) + .Append(", ") + .Append(stringTypeMapping.GenerateSqlLiteral(row.ProductVersion)) + .Append(')') + .AppendLine(SqlGenerationHelper.StatementTerminator) + .ToString(); + } + + private RelationalCommandParameterObject CreateRelationalCommandParameters() + => new( + Dependencies.Connection, + null, + null, + Dependencies.CurrentContext.Context, + Dependencies.CommandLogger, CommandSource.Migrations); } diff --git a/src/EFCore.Sqlite.Core/Migrations/Internal/SqliteMigrationDatabaseLock.cs b/src/EFCore.Sqlite.Core/Migrations/Internal/SqliteMigrationDatabaseLock.cs new file mode 100644 index 00000000000..8063aa4fb6c --- /dev/null +++ b/src/EFCore.Sqlite.Core/Migrations/Internal/SqliteMigrationDatabaseLock.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Sqlite.Migrations.Internal; + +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +public class SqliteMigrationDatabaseLock + (IRelationalCommand relationalCommand, + RelationalCommandParameterObject relationalCommandParameters, + CancellationToken cancellationToken = default) + : IMigrationDatabaseLock +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public void Dispose() + => relationalCommand.ExecuteScalar(relationalCommandParameters); + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public async ValueTask DisposeAsync() + => await relationalCommand.ExecuteScalarAsync(relationalCommandParameters, cancellationToken).ConfigureAwait(false); +} diff --git a/src/EFCore/AutoTransactionBehavior.cs b/src/EFCore/AutoTransactionBehavior.cs index 25d6e11db23..bd34420276e 100644 --- a/src/EFCore/AutoTransactionBehavior.cs +++ b/src/EFCore/AutoTransactionBehavior.cs @@ -4,7 +4,7 @@ namespace Microsoft.EntityFrameworkCore; /// -/// Indicates whether or not a transaction will be created automatically by if a user transaction +/// Indicates whether a transaction will be created automatically by if a user transaction /// wasn't created via 'BeginTransaction' or provided via 'UseTransaction'. /// public enum AutoTransactionBehavior diff --git a/src/EFCore/Diagnostics/CoreLoggerExtensions.cs b/src/EFCore/Diagnostics/CoreLoggerExtensions.cs index a41f9621ec1..cda0afbff27 100644 --- a/src/EFCore/Diagnostics/CoreLoggerExtensions.cs +++ b/src/EFCore/Diagnostics/CoreLoggerExtensions.cs @@ -2855,7 +2855,7 @@ private static string StateChangedSensitive(EventDefinitionBase definition, Even /// The internal entity entry. /// The property. /// The value generated. - /// Indicates whether or not the value is a temporary or permanent value. + /// Indicates whether the value is a temporary or permanent value. public static void ValueGenerated( this IDiagnosticsLogger diagnostics, InternalEntityEntry internalEntityEntry, diff --git a/src/EFCore/Diagnostics/ExecutionStrategyEventData.cs b/src/EFCore/Diagnostics/ExecutionStrategyEventData.cs index 2557b05a788..d93f36a7afa 100644 --- a/src/EFCore/Diagnostics/ExecutionStrategyEventData.cs +++ b/src/EFCore/Diagnostics/ExecutionStrategyEventData.cs @@ -24,7 +24,7 @@ public class ExecutionStrategyEventData : EventData /// /// The delay before retrying the operation. /// - /// Indicates whether or not the command was executed asynchronously. + /// Indicates whether the command was executed asynchronously. /// public ExecutionStrategyEventData( EventDefinitionBase eventDefinition, @@ -50,7 +50,7 @@ public ExecutionStrategyEventData( public virtual TimeSpan Delay { get; } /// - /// Indicates whether or not the operation is being executed asynchronously. + /// Indicates whether the operation is being executed asynchronously. /// public virtual bool IsAsync { get; } } diff --git a/src/EFCore/Diagnostics/IDbContextLogger.cs b/src/EFCore/Diagnostics/IDbContextLogger.cs index 797242a4d3f..04857fb4e82 100644 --- a/src/EFCore/Diagnostics/IDbContextLogger.cs +++ b/src/EFCore/Diagnostics/IDbContextLogger.cs @@ -36,7 +36,7 @@ public interface IDbContextLogger void Log(EventData eventData); /// - /// Determines whether or not the given event should be logged. + /// Determines whether the given event should be logged. /// /// The ID of the event. /// The level of the event. diff --git a/src/EFCore/Diagnostics/IDiagnosticsLogger.cs b/src/EFCore/Diagnostics/IDiagnosticsLogger.cs index 7f969ce3afa..a06e739d24c 100644 --- a/src/EFCore/Diagnostics/IDiagnosticsLogger.cs +++ b/src/EFCore/Diagnostics/IDiagnosticsLogger.cs @@ -61,7 +61,7 @@ public interface IDiagnosticsLogger IInterceptors? Interceptors { get; } /// - /// Checks whether or not the message should be sent to the . + /// Checks whether the message should be sent to the . /// /// The definition of the event to log. /// @@ -104,7 +104,7 @@ void DispatchEventData( } /// - /// Determines whether or not an instance is needed based on whether or + /// Determines whetheran instance is needed based on whether or /// not there is a or an enabled for /// the given event. /// @@ -136,7 +136,7 @@ bool NeedsEventData( } /// - /// Determines whether or not an instance is needed based on whether or + /// Determines whether an instance is needed based on whether or /// not there is a , an , or an enabled for /// the given event. /// diff --git a/src/EFCore/Diagnostics/ILoggingOptions.cs b/src/EFCore/Diagnostics/ILoggingOptions.cs index 82b5881a2fb..8eae16e3809 100644 --- a/src/EFCore/Diagnostics/ILoggingOptions.cs +++ b/src/EFCore/Diagnostics/ILoggingOptions.cs @@ -44,6 +44,6 @@ public interface ILoggingOptions : ISingletonOptions /// Returns if a warning about string values for the given enum type has not yet been performed. /// /// The type to check. - /// Whether or not a warning has been issued. + /// Whether a warning has been issued. bool ShouldWarnForStringEnumValueInJson(Type enumType); } diff --git a/src/Microsoft.Data.Sqlite.Core/SqliteConnection.cs b/src/Microsoft.Data.Sqlite.Core/SqliteConnection.cs index a59d762fce6..547d35b39d9 100644 --- a/src/Microsoft.Data.Sqlite.Core/SqliteConnection.cs +++ b/src/Microsoft.Data.Sqlite.Core/SqliteConnection.cs @@ -347,14 +347,8 @@ public override void Close() // NB: Calls RemoveCommand() command.Dispose(); } - else - { - _commands.Remove(reference); - } } - Debug.Assert(_commands.Count == 0); - _commands.Clear(); _innerConnection!.Close(); _innerConnection = null; @@ -450,7 +444,9 @@ internal void RemoveCommand(SqliteCommand command) { for (var i = _commands.Count - 1; i >= 0; i--) { - if (_commands[i].TryGetTarget(out var item) + var reference = _commands[i]; + if (reference != null + && reference.TryGetTarget(out var item) && item == command) { _commands.RemoveAt(i); diff --git a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsInfrastructureTestBase.cs b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsInfrastructureTestBase.cs index c1ce90789ee..a75f37bd012 100644 --- a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsInfrastructureTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsInfrastructureTestBase.cs @@ -3,6 +3,8 @@ // ReSharper disable InconsistentNaming +using Microsoft.EntityFrameworkCore.TestUtilities; + namespace Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -22,6 +24,8 @@ protected MigrationsInfrastructureTestBase(TFixture fixture) protected string ActiveProvider { get; private set; } + public static IEnumerable IsAsyncData = [[false], [true]]; + // Database deletion can happen as async file operation and SQLClient // doesn't account for this, so give some time for it to happen on slow C.I. machines protected virtual void GiveMeSomeTime(DbContext db) @@ -178,6 +182,92 @@ await history.GetAppliedMigrationsAsync(), x => Assert.Equal("00000000000007_Migration7", x.MigrationId)); } + [ConditionalFact] + public virtual void Can_apply_one_migration_in_parallel() + { + using var db = Fixture.CreateContext(); + db.Database.EnsureDeleted(); + GiveMeSomeTime(db); + db.GetService().Create(); + + Parallel.For(0, Environment.ProcessorCount, i => + { + using var context = Fixture.CreateContext(); + var migrator = context.GetService(); + migrator.Migrate("Migration1"); + }); + + var history = db.GetService(); + Assert.Collection( + history.GetAppliedMigrations(), + x => Assert.Equal("00000000000001_Migration1", x.MigrationId)); + } + + [ConditionalFact] + public virtual async Task Can_apply_one_migration_in_parallel_async() + { + using var db = Fixture.CreateContext(); + await db.Database.EnsureDeletedAsync(); + await GiveMeSomeTimeAsync(db); + await db.GetService().CreateAsync(); + + await Parallel.ForAsync(0, Environment.ProcessorCount, async (i, _) => + { + using var context = Fixture.CreateContext(); + var migrator = context.GetService(); + await migrator.MigrateAsync("Migration1"); + }); + + var history = db.GetService(); + Assert.Collection( + await history.GetAppliedMigrationsAsync(), + x => Assert.Equal("00000000000001_Migration1", x.MigrationId)); + } + + [ConditionalFact] + public virtual void Can_apply_second_migration_in_parallel() + { + using var db = Fixture.CreateContext(); + db.Database.EnsureDeleted(); + GiveMeSomeTime(db); + db.GetService().Migrate("Migration1"); + + Parallel.For(0, Environment.ProcessorCount, i => + { + using var context = Fixture.CreateContext(); + var migrator = context.GetService(); + migrator.Migrate("Migration2"); + }); + + var history = db.GetService(); + Assert.Collection( + history.GetAppliedMigrations(), + x => Assert.Equal("00000000000001_Migration1", x.MigrationId), + x => Assert.Equal("00000000000002_Migration2", x.MigrationId)); + } + + [ConditionalFact] + public virtual async Task Can_apply_second_migration_in_parallel_async() + { + using var db = Fixture.CreateContext(); + await db.Database.EnsureDeletedAsync(); + await GiveMeSomeTimeAsync(db); + await db.GetService().MigrateAsync("Migration1"); + + await Parallel.ForAsync(0, Environment.ProcessorCount, async (i, _) => + { + using var context = Fixture.CreateContext(); + var migrator = context.GetService(); + await migrator.MigrateAsync("Migration2"); + }); + + var history = db.GetService(); + Assert.Collection( + await history.GetAppliedMigrationsAsync(), + x => Assert.Equal("00000000000001_Migration1", x.MigrationId), + x => Assert.Equal("00000000000002_Migration2", x.MigrationId)); + } + [ConditionalFact] public virtual void Can_generate_no_migration_script() { @@ -343,14 +433,20 @@ private void SetSql(string value) => Sql = value.Replace(ProductInfo.GetVersion(), "7.0.0-test"); } -public abstract class - MigrationsInfrastructureFixtureBase : SharedStoreFixtureBase +public abstract class MigrationsInfrastructureFixtureBase + : SharedStoreFixtureBase { public static string ActiveProvider { get; set; } public new RelationalTestStore TestStore => (RelationalTestStore)base.TestStore; + protected override IServiceCollection AddServices(IServiceCollection serviceCollection) + { + TestStore.UseConnectionString = true; + return base.AddServices(serviceCollection); + } + protected override string StoreName => "MigrationsTest"; diff --git a/test/EFCore.Relational.Specification.Tests/TestUtilities/RelationalTestStore.cs b/test/EFCore.Relational.Specification.Tests/TestUtilities/RelationalTestStore.cs index 54404949421..84b476f631a 100644 --- a/test/EFCore.Relational.Specification.Tests/TestUtilities/RelationalTestStore.cs +++ b/test/EFCore.Relational.Specification.Tests/TestUtilities/RelationalTestStore.cs @@ -9,6 +9,8 @@ public abstract class RelationalTestStore(string name, bool shared, DbConnection { public virtual string ConnectionString { get; } = connection.ConnectionString; + public virtual bool UseConnectionString { get; set; } + public ConnectionState ConnectionState => Connection.State; diff --git a/test/EFCore.Specification.Tests/TestUtilities/TestStore.cs b/test/EFCore.Specification.Tests/TestUtilities/TestStore.cs index c8888f2fa65..6884b6ac5d3 100644 --- a/test/EFCore.Specification.Tests/TestUtilities/TestStore.cs +++ b/test/EFCore.Specification.Tests/TestUtilities/TestStore.cs @@ -20,10 +20,7 @@ public virtual async Task InitializeAsync( Func? clean = null) { ServiceProvider = serviceProvider; - if (createContext == null) - { - createContext = CreateDefaultContext; - } + createContext ??= CreateDefaultContext; if (Shared) { @@ -56,8 +53,8 @@ public virtual Task InitializeAsync( serviceProvider, () => createContext(this), // ReSharper disable twice RedundantCast - seed == null ? (Func?)null : c => seed((TContext)c), - clean == null ? (Func?)null : c => clean((TContext)c)); + seed == null ? null : c => seed((TContext)c), + clean == null ? null : c => clean((TContext)c)); protected virtual async Task InitializeAsync(Func createContext, Func? seed, Func? clean) { diff --git a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerTestStore.cs b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerTestStore.cs index 1e481d2622d..6ad3e77667b 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerTestStore.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerTestStore.cs @@ -111,8 +111,9 @@ protected override async Task InitializeAsync(Func createContext, Fun } public override DbContextOptionsBuilder AddProviderOptions(DbContextOptionsBuilder builder) - => builder - .UseSqlServer(Connection, b => b.ApplyConfiguration()) + => (UseConnectionString + ? builder.UseSqlServer(ConnectionString, b => b.ApplyConfiguration()) + : builder.UseSqlServer(Connection, b => b.ApplyConfiguration())) .ConfigureWarnings(b => b.Ignore(SqlServerEventId.SavepointsDisabledBecauseOfMARS)); private async Task CreateDatabase(Func? clean) diff --git a/test/EFCore.Sqlite.FunctionalTests/TestUtilities/SqliteTestStore.cs b/test/EFCore.Sqlite.FunctionalTests/TestUtilities/SqliteTestStore.cs index 080d01758c8..823452e857e 100644 --- a/test/EFCore.Sqlite.FunctionalTests/TestUtilities/SqliteTestStore.cs +++ b/test/EFCore.Sqlite.FunctionalTests/TestUtilities/SqliteTestStore.cs @@ -33,13 +33,21 @@ private SqliteTestStore(string name, bool seed = true, bool sharedCache = false, public virtual DbContextOptionsBuilder AddProviderOptions( DbContextOptionsBuilder builder, Action? configureSqlite) - => builder.UseSqlite( - Connection, b => - { - b.CommandTimeout(CommandTimeout); - b.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery); - configureSqlite?.Invoke(b); - }); + => UseConnectionString + ? builder.UseSqlite( + ConnectionString, b => + { + b.CommandTimeout(CommandTimeout); + b.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery); + configureSqlite?.Invoke(b); + }) + : builder.UseSqlite( + Connection, b => + { + b.CommandTimeout(CommandTimeout); + b.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery); + configureSqlite?.Invoke(b); + }); public override DbContextOptionsBuilder AddProviderOptions(DbContextOptionsBuilder builder) => AddProviderOptions(builder, configureSqlite: null);