diff --git a/src/EFCore.Design/Design/DesignTimeServiceCollectionExtensions.cs b/src/EFCore.Design/Design/DesignTimeServiceCollectionExtensions.cs index 12e2a75f27f..40fe9be08fd 100644 --- a/src/EFCore.Design/Design/DesignTimeServiceCollectionExtensions.cs +++ b/src/EFCore.Design/Design/DesignTimeServiceCollectionExtensions.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Migrations.Design; +using Microsoft.EntityFrameworkCore.Migrations.Design.Internal; using Microsoft.EntityFrameworkCore.Migrations.Internal; using Microsoft.EntityFrameworkCore.Query.Internal; using Microsoft.EntityFrameworkCore.Scaffolding.Internal; @@ -65,7 +67,8 @@ public static IServiceCollection AddEntityFrameworkDesignTimeServices( .TryAddScoped() .TryAddScoped() .TryAddScoped() - .TryAddScoped()); + .TryAddScoped() + .TryAddSingleton()); var loggerFactory = new LoggerFactory( [new OperationLoggerProvider(reporter)], new LoggerFilterOptions { MinLevel = LogLevel.Debug }); @@ -100,6 +103,7 @@ public static IServiceCollection AddDbContextDesignTimeServices( .TryAdd(_ => context.GetService()) .TryAdd(_ => context.GetService()) .TryAdd(_ => context.GetService().Model); + return services; } } diff --git a/src/EFCore.Design/Design/Internal/MigrationsOperations.cs b/src/EFCore.Design/Design/Internal/MigrationsOperations.cs index b7729c1f716..2ddcf9c43b1 100644 --- a/src/EFCore.Design/Design/Internal/MigrationsOperations.cs +++ b/src/EFCore.Design/Design/Internal/MigrationsOperations.cs @@ -1,7 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.Migrations.Design; +using Microsoft.EntityFrameworkCore.Migrations.Design.Internal; namespace Microsoft.EntityFrameworkCore.Design.Internal; @@ -70,31 +73,8 @@ public virtual MigrationFiles AddMigration( string? @namespace, bool dryRun) { - var invalidPathChars = Path.GetInvalidFileNameChars(); - if (name.Any(c => invalidPathChars.Contains(c))) - { - throw new OperationException( - DesignStrings.BadMigrationName(name, string.Join("','", invalidPathChars))); - } - - if (outputDir != null) - { - outputDir = Path.GetFullPath(Path.Combine(_projectDir, outputDir)); - } - - var subNamespace = SubnamespaceFromOutputPath(outputDir); - using var context = _contextOperations.CreateContext(contextType); - var contextClassName = context.GetType().Name; - if (string.Equals(name, contextClassName, StringComparison.Ordinal)) - { - throw new OperationException( - DesignStrings.ConflictingContextAndMigrationName(name)); - } - - var services = _servicesBuilder.Build(context); - EnsureServices(services); - EnsureMigrationsAssembly(services); + var (resolvedOutputDir, subNamespace, services) = PrepareForMigration(name, outputDir, context); using var scope = services.CreateScope(); var scaffolder = scope.ServiceProvider.GetRequiredService(); @@ -103,7 +83,7 @@ public virtual MigrationFiles AddMigration( // TODO: Honor _nullable (issue #18950) ? scaffolder.ScaffoldMigration(name, _rootNamespace ?? string.Empty, subNamespace, _language, dryRun) : scaffolder.ScaffoldMigration(name, null, @namespace, _language, dryRun); - return scaffolder.Save(_projectDir, migration, outputDir, dryRun); + return scaffolder.Save(_projectDir, migration, resolvedOutputDir, dryRun); } // if outputDir is a subfolder of projectDir, then use each subfolder as a sub-namespace @@ -272,6 +252,111 @@ public virtual void HasPendingModelChanges(string? contextType) _reporter.WriteInformation(DesignStrings.NoPendingModelChanges); } + /// + /// 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. + /// + [RequiresDynamicCode("Runtime migration compilation requires dynamic code generation.")] + public virtual MigrationFiles AddAndApplyMigration( + string name, + string? outputDir, + string? contextType, + string? @namespace, + string? connectionString) + { + using var context = _contextOperations.CreateContext(contextType); + var (resolvedOutputDir, subNamespace, services) = PrepareForMigration(name, outputDir, context); + + if (connectionString != null) + { + context.Database.SetConnectionString(connectionString); + } + + using var scope = services.CreateScope(); + var migrator = scope.ServiceProvider.GetRequiredService(); + var scaffolder = scope.ServiceProvider.GetRequiredService(); + var compiler = scope.ServiceProvider.GetRequiredService(); + var migrationsAssembly = scope.ServiceProvider.GetRequiredService(); + + if (!migrator.HasPendingModelChanges()) + { + _reporter.WriteInformation(DesignStrings.NoPendingModelChanges); + migrator.Migrate(null); + _reporter.WriteInformation(DesignStrings.Done); + // Return empty MigrationFiles to indicate no migration was created. + // When serialized to JSON (with --json), all file path properties will be null. + return new MigrationFiles(); + } + + _reporter.WriteInformation(DesignStrings.CreatingAndApplyingMigration(name)); + + var migration = + string.IsNullOrEmpty(@namespace) + ? scaffolder.ScaffoldMigration(name, _rootNamespace ?? string.Empty, subNamespace, _language, dryRun: true) + : scaffolder.ScaffoldMigration(name, null, @namespace, _language, dryRun: true); + + MigrationFiles? files = null; + try + { + files = scaffolder.Save(_projectDir, migration, resolvedOutputDir, dryRun: false); + + var compiledAssembly = compiler.CompileMigration(migration, context.GetType()); + migrationsAssembly.AddMigrations(compiledAssembly); + + migrator.Migrate(migration.MigrationId); + + _reporter.WriteInformation(DesignStrings.MigrationCreatedAndApplied(migration.MigrationId)); + + return files; + } + catch (Exception ex) + { + throw new OperationException( + DesignStrings.AddAndApplyMigrationFailed(name, ex.Message), ex); + } + } + + /// + /// Prepares common resources for migration operations. + /// + private (string? outputDir, string? subNamespace, IServiceProvider services) + PrepareForMigration(string name, string? outputDir, DbContext context) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new OperationException(DesignStrings.MigrationNameRequired); + } + + var invalidPathChars = Path.GetInvalidFileNameChars(); + if (name.Any(c => invalidPathChars.Contains(c))) + { + throw new OperationException( + DesignStrings.BadMigrationName(name, string.Join("','", invalidPathChars))); + } + + if (outputDir != null) + { + outputDir = Path.GetFullPath(Path.Combine(_projectDir, outputDir)); + } + + var subNamespace = SubnamespaceFromOutputPath(outputDir); + + var contextClassName = context.GetType().Name; + if (string.Equals(name, contextClassName, StringComparison.Ordinal)) + { + throw new OperationException( + DesignStrings.ConflictingContextAndMigrationName(name)); + } + + var services = _servicesBuilder.Build(context); + EnsureServices(services); + EnsureMigrationsAssembly(services); + + return (outputDir, subNamespace, services); + } + private static void EnsureServices(IServiceProvider services) { var migrator = services.GetService(); diff --git a/src/EFCore.Design/Design/OperationExecutor.cs b/src/EFCore.Design/Design/OperationExecutor.cs index 8c619b4cae0..3c858ec5bde 100644 --- a/src/EFCore.Design/Design/OperationExecutor.cs +++ b/src/EFCore.Design/Design/OperationExecutor.cs @@ -293,6 +293,64 @@ private void UpdateDatabaseImpl( string? contextType) => MigrationsOperations.UpdateDatabase(targetMigration, connectionString, contextType); + /// + /// Represents an operation to add and apply a new migration in one step. + /// + public class AddAndApplyMigration : OperationBase + { + /// + /// Initializes a new instance of the class. + /// + /// + /// The arguments supported by are: + /// name--The name of the migration. + /// outputDir--The directory to put files in. Paths are relative to the project directory. + /// contextType--The to use. + /// namespace--The namespace to use for the migration. + /// + /// connectionString--The connection string to the database. Defaults to the one specified in + /// or + /// . + /// + /// + /// The operation executor. + /// The . + /// The operation arguments. + public AddAndApplyMigration( + OperationExecutor executor, + IOperationResultHandler resultHandler, + IDictionary args) + : base(resultHandler) + { + Check.NotNull(executor); + Check.NotNull(args); + + var name = (string)args["name"]!; + var outputDir = (string?)args["outputDir"]; + var contextType = (string?)args["contextType"]; + var @namespace = (string?)args["namespace"]; + var connectionString = (string?)args["connectionString"]; + + Execute(() => executor.AddAndApplyMigrationImpl(name, outputDir, contextType, @namespace, connectionString)); + } + } + + private IDictionary AddAndApplyMigrationImpl( + string name, + string? outputDir, + string? contextType, + string? @namespace, + string? connectionString) + { + var files = MigrationsOperations.AddAndApplyMigration(name, outputDir, contextType, @namespace, connectionString); + return new Hashtable + { + ["MigrationFile"] = files.MigrationFile, + ["MetadataFile"] = files.MetadataFile, + ["SnapshotFile"] = files.SnapshotFile + }; + } + /// /// Represents an operation to generate a SQL script from migrations. /// diff --git a/src/EFCore.Design/Migrations/Design/CSharpMigrationCompiler.cs b/src/EFCore.Design/Migrations/Design/CSharpMigrationCompiler.cs new file mode 100644 index 00000000000..d86c46e76c5 --- /dev/null +++ b/src/EFCore.Design/Migrations/Design/CSharpMigrationCompiler.cs @@ -0,0 +1,183 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.Loader; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.EntityFrameworkCore.Internal; + +namespace Microsoft.EntityFrameworkCore.Migrations.Design.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. +/// +/// +/// +/// The service lifetime is . This means a single instance +/// is used by many instances. The implementation must be thread-safe. +/// This service cannot depend on services registered as . +/// +/// +/// See Database migrations for more information and examples. +/// +/// +public class CSharpMigrationCompiler : IMigrationCompiler +{ + private static readonly CSharpCompilationOptions CompilationOptions = new( + OutputKind.DynamicallyLinkedLibrary, + optimizationLevel: OptimizationLevel.Debug, + nullableContextOptions: NullableContextOptions.Enable, + specificDiagnosticOptions: new Dictionary + { + // Suppress common warnings that don't affect functionality + { "CS1701", ReportDiagnostic.Suppress }, // Assembly reference mismatch + { "CS1702", ReportDiagnostic.Suppress }, // Assembly reference mismatch + { "CS8019", ReportDiagnostic.Suppress }, // Unnecessary using directive + }); + + private static readonly CSharpParseOptions ParseOptions = new( + LanguageVersion.Latest, + DocumentationMode.None); + + // Cache of assembly references to avoid repeated resolution + private IReadOnlyList? _cachedReferences; + private readonly object _referenceLock = new(); + + /// + [RequiresDynamicCode("Runtime migration compilation requires dynamic code generation.")] + public virtual Assembly CompileMigration( + ScaffoldedMigration scaffoldedMigration, + Type contextType, + IEnumerable? additionalReferences = null) + { + var assemblyName = $"DynamicMigration_{scaffoldedMigration.MigrationId}_{Guid.NewGuid():N}"; + + // Parse the source code into syntax trees + var syntaxTrees = new List + { + SyntaxFactory.ParseSyntaxTree( + scaffoldedMigration.MigrationCode, + ParseOptions, + $"{scaffoldedMigration.MigrationId}.cs"), + SyntaxFactory.ParseSyntaxTree( + scaffoldedMigration.MetadataCode, + ParseOptions, + $"{scaffoldedMigration.MigrationId}.Designer.cs"), + SyntaxFactory.ParseSyntaxTree( + scaffoldedMigration.SnapshotCode, + ParseOptions, + $"{scaffoldedMigration.SnapshotName}.cs") + }; + + // Gather assembly references + var references = GetMetadataReferences(contextType, additionalReferences); + + // Create the compilation + var compilation = CSharpCompilation.Create( + assemblyName, + syntaxTrees, + references, + CompilationOptions); + + // Emit to memory and load + using var assemblyStream = new MemoryStream(); + var emitResult = compilation.Emit(assemblyStream); + + if (!emitResult.Success) + { + var errors = emitResult.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.ToString()); + + throw new InvalidOperationException( + DesignStrings.MigrationCompilationFailed( + scaffoldedMigration.MigrationId, + string.Join(Environment.NewLine, errors))); + } + + assemblyStream.Seek(0, SeekOrigin.Begin); + return AssemblyLoadContext.Default.LoadFromStream(assemblyStream); + } + + /// + /// Gets the metadata references required for compilation. + /// + /// The DbContext type. + /// Additional assembly references. + /// The list of metadata references. + protected virtual IReadOnlyList GetMetadataReferences( + Type contextType, + IEnumerable? additionalReferences) + { + // Get cached references from all loaded assemblies + var baseReferences = GetOrCreateCachedReferences(); + var allReferences = new List(baseReferences); + + // Add the context's assembly (in case it wasn't loaded when cache was built) + AddAssemblyReference(allReferences, contextType.Assembly); + + // Add any additional references + if (additionalReferences != null) + { + foreach (var assembly in additionalReferences) + { + AddAssemblyReference(allReferences, assembly); + } + } + + return allReferences; + } + + private IReadOnlyList GetOrCreateCachedReferences() + { + if (_cachedReferences != null) + { + return _cachedReferences; + } + + lock (_referenceLock) + { + if (_cachedReferences != null) + { + return _cachedReferences; + } + + var references = new List(); + + // Add references from all loaded assemblies (except dynamic/in-memory ones) + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + AddAssemblyReference(references, assembly); + } + + _cachedReferences = references; + return _cachedReferences; + } + } + + private static void AddAssemblyReference(List references, Assembly assembly) + { + if (assembly.IsDynamic || string.IsNullOrEmpty(assembly.Location)) + { + return; + } + + try + { + var reference = MetadataReference.CreateFromFile(assembly.Location); + if (!references.Any(r => r.Display == reference.Display)) + { + references.Add(reference); + } + } + catch + { + // Ignore assemblies that can't be referenced + } + } +} diff --git a/src/EFCore.Design/Migrations/Design/IMigrationCompiler.cs b/src/EFCore.Design/Migrations/Design/IMigrationCompiler.cs new file mode 100644 index 00000000000..cb4ea0d34c2 --- /dev/null +++ b/src/EFCore.Design/Migrations/Design/IMigrationCompiler.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.EntityFrameworkCore.Migrations.Design.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. +/// +/// +/// +/// The service lifetime is . This means a single instance +/// is used by many instances. The implementation must be thread-safe. +/// This service cannot depend on services registered as . +/// +/// +/// See Database migrations for more information and examples. +/// +/// +public interface IMigrationCompiler +{ + /// + /// Compiles scaffolded migration source code into an in-memory assembly. + /// + /// The scaffolded migration containing C# source code. + /// The type of the for which the migration was created. + /// Additional assembly references to include in compilation, if any. + /// An containing the compiled migration and model snapshot. + /// Thrown when compilation fails. + [RequiresDynamicCode("Runtime migration compilation requires dynamic code generation.")] + Assembly CompileMigration( + ScaffoldedMigration scaffoldedMigration, + Type contextType, + IEnumerable? references = null); +} diff --git a/src/EFCore.Design/Properties/DesignStrings.Designer.cs b/src/EFCore.Design/Properties/DesignStrings.Designer.cs index fe7161d90e4..e679c5c9352 100644 --- a/src/EFCore.Design/Properties/DesignStrings.Designer.cs +++ b/src/EFCore.Design/Properties/DesignStrings.Designer.cs @@ -19,6 +19,14 @@ public static class DesignStrings private static readonly ResourceManager _resourceManager = new ResourceManager("Microsoft.EntityFrameworkCore.Properties.DesignStrings", typeof(DesignStrings).Assembly); + /// + /// Failed to create and apply migration '{name}'. {message} + /// + public static string AddAndApplyMigrationFailed(object? name, object? message) + => string.Format( + GetString("AddAndApplyMigrationFailed", nameof(name), nameof(message)), + name, message); + /// /// Failed creating connection: {exceptionMessage} /// @@ -35,6 +43,12 @@ public static string BadMigrationName(object? name, object? characters) GetString("BadMigrationName", nameof(name), nameof(characters)), name, characters); + /// + /// A migration name must be specified. + /// + public static string MigrationNameRequired + => GetString("MigrationNameRequired"); + /// /// Sequence '{sequenceName}' cannot be scaffolded because it uses type '{typeName}' which is unsupported. /// @@ -588,6 +602,20 @@ public static string NonRelationalProvider(object? provider) public static string NoPendingModelChanges => GetString("NoPendingModelChanges"); + /// + /// No dynamic migrations have been applied in the current session. Only migrations applied using CreateAndApplyMigration can be reverted with RevertMigration. + /// + public static string NoDynamicMigrationsToRevert + => GetString("NoDynamicMigrationsToRevert"); + + /// + /// The dynamic migration '{migrationId}' was not found. Only migrations applied in the current session using CreateAndApplyMigration can be reverted. + /// + public static string DynamicMigrationNotFound(object? migrationId) + => string.Format( + GetString("DynamicMigrationNotFound", nameof(migrationId)), + migrationId); + /// /// No referenced design-time services were found. /// @@ -910,6 +938,39 @@ public static string WritingSnapshot(object? file) GetString("WritingSnapshot", nameof(file)), file); + /// + /// Failed to compile migration '{migrationId}'. Errors: + /// {errors} + /// + public static string MigrationCompilationFailed(object? migrationId, object? errors) + => string.Format( + GetString("MigrationCompilationFailed", nameof(migrationId), nameof(errors)), + migrationId, errors); + + /// + /// Could not find migration type with ID '{migrationId}' in the compiled assembly. + /// + public static string MigrationTypeNotFound(object? migrationId) + => string.Format( + GetString("MigrationTypeNotFound", nameof(migrationId)), + migrationId); + + /// + /// Creating and applying migration '{migrationName}'. + /// + public static string CreatingAndApplyingMigration(object? migrationName) + => string.Format( + GetString("CreatingAndApplyingMigration", nameof(migrationName)), + migrationName); + + /// + /// Migration '{migrationId}' was successfully created and applied. + /// + public static string MigrationCreatedAndApplied(object? migrationId) + => string.Format( + GetString("MigrationCreatedAndApplied", nameof(migrationId)), + migrationId); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name)!; diff --git a/src/EFCore.Design/Properties/DesignStrings.resx b/src/EFCore.Design/Properties/DesignStrings.resx index 0ed3a278bd8..96b8c5ce24b 100644 --- a/src/EFCore.Design/Properties/DesignStrings.resx +++ b/src/EFCore.Design/Properties/DesignStrings.resx @@ -117,12 +117,18 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Failed to create and apply migration '{name}'. {message} + Failed creating connection: {exceptionMessage} The migration name '{name}' is not valid. Migration names cannot contain any of the following characters: '{characters}'. + + A migration name must be specified. + Sequence '{sequenceName}' cannot be scaffolded because it uses type '{typeName}' which is unsupported. @@ -351,6 +357,12 @@ Change your target project to the migrations project by using the Package Manage No changes have been made to the model since the last migration. + + No dynamic migrations have been applied in the current session. Only migrations applied using CreateAndApplyMigration can be reverted with RevertMigration. + + + The dynamic migration '{migrationId}' was not found. Only migrations applied in the current session using CreateAndApplyMigration can be reverted. + No referenced design-time services were found. @@ -484,4 +496,17 @@ Change your target project to the migrations project by using the Package Manage Writing model snapshot to '{file}'. + + Failed to compile migration '{migrationId}'. Errors: +{errors} + + + Could not find migration type with ID '{migrationId}' in the compiled assembly. + + + Creating and applying migration '{migrationName}'. + + + Migration '{migrationId}' was successfully created and applied. + \ No newline at end of file diff --git a/src/EFCore.Relational/Migrations/IMigrationsAssembly.cs b/src/EFCore.Relational/Migrations/IMigrationsAssembly.cs index a5a80aab132..a02dc38a9a7 100644 --- a/src/EFCore.Relational/Migrations/IMigrationsAssembly.cs +++ b/src/EFCore.Relational/Migrations/IMigrationsAssembly.cs @@ -52,4 +52,10 @@ public interface IMigrationsAssembly /// The name of the current database provider. /// The migration instance. Migration CreateMigration(TypeInfo migrationClass, string activeProvider); + + /// + /// Adds migrations from an additional assembly to be included when finding migrations. + /// + /// The assembly containing additional migrations. + void AddMigrations(Assembly additionalMigrationsAssembly); } diff --git a/src/EFCore.Relational/Migrations/Internal/MigrationsAssembly.cs b/src/EFCore.Relational/Migrations/Internal/MigrationsAssembly.cs index e6c5ce2036e..0e65d8fe413 100644 --- a/src/EFCore.Relational/Migrations/Internal/MigrationsAssembly.cs +++ b/src/EFCore.Relational/Migrations/Internal/MigrationsAssembly.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.EntityFrameworkCore.Internal; + namespace Microsoft.EntityFrameworkCore.Migrations.Internal; /// @@ -15,7 +17,10 @@ public class MigrationsAssembly : IMigrationsAssembly private readonly IDiagnosticsLogger _logger; private IReadOnlyDictionary? _migrations; private ModelSnapshot? _modelSnapshot; + private bool _modelSnapshotInitialized; private readonly Type _contextType; + private readonly List _additionalAssemblies = new(); + private readonly object _lock = new(); /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -71,37 +76,41 @@ public MigrationsAssembly( /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public virtual IReadOnlyDictionary Migrations - { - get - { - IReadOnlyDictionary Create() + => NonCapturingLazyInitializer.EnsureInitialized( + ref _migrations, + this, + static self => { var result = new SortedList(); - - var items - = from t in Assembly.GetConstructibleTypes() - where t.IsSubclassOf(typeof(Migration)) - && GetDbContextType(t) == _contextType - let id = t.GetCustomAttribute()?.Id - orderby id - select (id, t); - - foreach (var (id, t) in items) + self.AddMigrationsFromAssembly(self.Assembly, result); + foreach (var additionalAssembly in self._additionalAssemblies) { - if (id == null) - { - _logger.MigrationAttributeMissingWarning(t); - - continue; - } - - result.Add(id, t); + self.AddMigrationsFromAssembly(additionalAssembly, result); } return result; + }); + + private void AddMigrationsFromAssembly(Assembly assembly, SortedList result) + { + var items + = from t in assembly.GetConstructibleTypes() + where t.IsSubclassOf(typeof(Migration)) + && GetDbContextType(t) == _contextType + let id = t.GetCustomAttribute()?.Id + orderby id + select (id, t); + + foreach (var (id, t) in items) + { + if (id == null) + { + _logger.MigrationAttributeMissingWarning(t); + + continue; } - return _migrations ??= Create(); + result[id] = t; } } @@ -112,12 +121,38 @@ orderby id /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public virtual ModelSnapshot? ModelSnapshot - => _modelSnapshot - ??= (from t in Assembly.GetConstructibleTypes() - where t.IsSubclassOf(typeof(ModelSnapshot)) - && GetDbContextType(t) == _contextType - select (ModelSnapshot)Activator.CreateInstance(t.AsType())!) - .FirstOrDefault(); + { + get + { + lock (_lock) + { + if (_modelSnapshotInitialized) + { + return _modelSnapshot; + } + + // Check additional assemblies first - latest added should have the snapshot + if (_additionalAssemblies.Count > 0) + { + _modelSnapshot = GetModelSnapshotFromAssembly(_additionalAssemblies[^1]); + } + else + { + _modelSnapshot = GetModelSnapshotFromAssembly(Assembly); + } + + _modelSnapshotInitialized = true; + return _modelSnapshot; + } + } + } + + private ModelSnapshot? GetModelSnapshotFromAssembly(Assembly assembly) + => (from t in assembly.GetConstructibleTypes() + where t.IsSubclassOf(typeof(ModelSnapshot)) + && GetDbContextType(t) == _contextType + select (ModelSnapshot)Activator.CreateInstance(t.AsType())!) + .FirstOrDefault(); /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -155,4 +190,18 @@ public virtual Migration CreateMigration(TypeInfo migrationClass, string activeP return migration; } + + /// + /// 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 virtual void AddMigrations(Assembly additionalMigrationsAssembly) + { + _additionalAssemblies.Add(additionalMigrationsAssembly); + _migrations = null; + _modelSnapshot = null; + _modelSnapshotInitialized = false; + } } diff --git a/src/EFCore.Tools/tools/EntityFrameworkCore.PS2.psm1 b/src/EFCore.Tools/tools/EntityFrameworkCore.PS2.psm1 index 518f324ab24..8bbd041bcf6 100644 --- a/src/EFCore.Tools/tools/EntityFrameworkCore.PS2.psm1 +++ b/src/EFCore.Tools/tools/EntityFrameworkCore.PS2.psm1 @@ -434,6 +434,16 @@ function Script-Migration( .PARAMETER Migration The target migration. If '0', all migrations will be reverted. Defaults to the last migration. + When used with -Add, this is the name of the new migration to create. + +.PARAMETER Add + Create a new migration with the given name and apply it immediately. + +.PARAMETER OutputDir + The directory to put files in. Paths are relative to the project directory. Requires -Add. + +.PARAMETER Namespace + The namespace to use for the migration. Matches the directory by default. Requires -Add. .PARAMETER Connection The connection string to the database. Defaults to the one specified in AddDbContext or OnConfiguring. @@ -456,6 +466,9 @@ function Script-Migration( #> function Update-Database( $Migration, + [switch] $Add, + $OutputDir, + $Namespace, $Connection, $Context, $Project, diff --git a/src/EFCore.Tools/tools/EntityFrameworkCore.psm1 b/src/EFCore.Tools/tools/EntityFrameworkCore.psm1 index c53ac88dafd..2cd13cb0dd0 100644 --- a/src/EFCore.Tools/tools/EntityFrameworkCore.psm1 +++ b/src/EFCore.Tools/tools/EntityFrameworkCore.psm1 @@ -906,6 +906,7 @@ function Script-Migration Register-TabExpansion Update-Database @{ Migration = { param($x) GetMigrations $x.Context $x.Project $x.StartupProject } + OutputDir = { <# Disabled. Otherwise, paths would be relative to the solution directory. #> } Context = { param($x) GetContextTypes $x.Project $x.StartupProject } Project = { GetProjects } StartupProject = { GetProjects } @@ -920,6 +921,16 @@ Register-TabExpansion Update-Database @{ .PARAMETER Migration The target migration. If '0', all migrations will be reverted. Defaults to the last migration. + When used with -Add, this is the name of the new migration to create. + +.PARAMETER Add + Create a new migration with the given name and apply it immediately. + +.PARAMETER OutputDir + The directory to put files in. Paths are relative to the project directory. Requires -Add. + +.PARAMETER Namespace + The namespace to use for the migration. Matches the directory by default. Requires -Add. .PARAMETER Connection The connection string to the database. Defaults to the one specified in AddDbContext or OnConfiguring. @@ -946,12 +957,27 @@ function Update-Database param( [Parameter(Position = 0)] [string] $Migration, + [switch] $Add, + [string] $OutputDir, + [string] $Namespace, [string] $Connection, [string] $Context, [string] $Project, [string] $StartupProject, [string] $Args) + if (-not $Add) + { + if ($OutputDir) + { + throw "The '-OutputDir' parameter requires the '-Add' parameter to be specified." + } + if ($Namespace) + { + throw "The '-Namespace' parameter requires the '-Add' parameter to be specified." + } + } + WarnIfEF6 'Update-Database' $dteProject = GetProject $Project @@ -964,6 +990,21 @@ function Update-Database $params += $Migration } + if ($Add) + { + $params += '--add' + } + + if ($OutputDir) + { + $params += '--output-dir', $OutputDir + } + + if ($Namespace) + { + $params += '--namespace', $Namespace + } + if ($Connection) { $params += '--connection', $Connection diff --git a/src/dotnet-ef/Properties/Resources.Designer.cs b/src/dotnet-ef/Properties/Resources.Designer.cs index 425fb441247..73076c60b69 100644 --- a/src/dotnet-ef/Properties/Resources.Designer.cs +++ b/src/dotnet-ef/Properties/Resources.Designer.cs @@ -109,6 +109,12 @@ public static string DatabaseDropForceDescription public static string DatabaseUpdateDescription => GetString("DatabaseUpdateDescription"); + /// + /// Create a new migration with the given name and apply it immediately. + /// + public static string DatabaseUpdateAddDescription + => GetString("DatabaseUpdateAddDescription"); + /// /// The connection string to the database. Defaults to the one specified in AddDbContext or OnConfiguring. /// @@ -211,6 +217,14 @@ public static string MigrationFromDescription public static string MigrationNameDescription => GetString("MigrationNameDescription"); + /// + /// Missing required argument '{arg}'. + /// + public static string MissingArgument(object? arg) + => string.Format( + GetString("MissingArgument", nameof(arg)), + arg); + /// /// Adds a new migration. /// @@ -329,6 +343,12 @@ public static string MultipleTargetFrameworks public static string NamespaceDescription => GetString("NamespaceDescription"); + /// + /// Option '--namespace' must be specified with '--add'. + /// + public static string NamespaceRequiresAdd + => GetString("NamespaceRequiresAdd"); + /// /// Generate additional code in the compiled model required for NativeAOT compilation and precompiled queries (experimental). /// @@ -419,6 +439,12 @@ public static string OutputDescription public static string OutputDirDescription => GetString("OutputDirDescription"); + /// + /// Option '--output-dir' must be specified with '--add'. + /// + public static string OutputDirRequiresAdd + => GetString("OutputDirRequiresAdd"); + /// /// Generate precompiled queries. /// diff --git a/src/dotnet-ef/Properties/Resources.resx b/src/dotnet-ef/Properties/Resources.resx index ef4e49c1f06..3cf3c0cbb75 100644 --- a/src/dotnet-ef/Properties/Resources.resx +++ b/src/dotnet-ef/Properties/Resources.resx @@ -162,6 +162,9 @@ Updates the database to a specified migration. + + Create a new migration with the given name and apply it immediately. + The connection string to the database. Defaults to the one specified in AddDbContext or OnConfiguring. @@ -213,6 +216,9 @@ The name of the migration. + + Missing required argument '{arg}'. + Adds a new migration. @@ -270,6 +276,9 @@ The namespace to use. Matches the directory by default. + + Option '--namespace' must be specified with '--add'. + Generate additional code in the compiled model required for NativeAOT compilation and precompiled queries (experimental). @@ -312,6 +321,9 @@ The directory to put files in. Paths are relative to the project directory. + + Option '--output-dir' must be specified with '--add'. + Generate precompiled queries. diff --git a/src/ef/Commands/DatabaseUpdateCommand.Configure.cs b/src/ef/Commands/DatabaseUpdateCommand.Configure.cs index 107c1f8b7f9..3bf0c3e9f2f 100644 --- a/src/ef/Commands/DatabaseUpdateCommand.Configure.cs +++ b/src/ef/Commands/DatabaseUpdateCommand.Configure.cs @@ -10,6 +10,10 @@ internal partial class DatabaseUpdateCommand : ContextCommandBase { private CommandArgument? _migration; private CommandOption? _connection; + private CommandOption? _add; + private CommandOption? _outputDir; + private CommandOption? _namespace; + private CommandOption? _json; public override void Configure(CommandLineApplication command) { @@ -18,7 +22,41 @@ public override void Configure(CommandLineApplication command) _migration = command.Argument("", Resources.MigrationDescription); _connection = command.Option("--connection ", Resources.DbContextConnectionDescription); + _add = command.Option("--add", Resources.DatabaseUpdateAddDescription); + _outputDir = command.Option("-o|--output-dir ", Resources.MigrationsOutputDirDescription); + _namespace = command.Option("-n|--namespace ", Resources.MigrationsNamespaceDescription); + _json = Json.ConfigureOption(command); base.Configure(command); } + + protected override void Validate() + { + base.Validate(); + + if (_add!.HasValue()) + { + if (string.IsNullOrEmpty(_migration!.Value)) + { + throw new CommandException(Resources.MissingArgument(_migration.Name)); + } + } + else + { + if (_outputDir!.HasValue()) + { + throw new CommandException(Resources.OutputDirRequiresAdd); + } + + if (_namespace!.HasValue()) + { + throw new CommandException(Resources.NamespaceRequiresAdd); + } + + if (_json!.HasValue()) + { + throw new CommandException(Resources.MissingConditionalOption("add", "json")); + } + } + } } diff --git a/src/ef/Commands/DatabaseUpdateCommand.cs b/src/ef/Commands/DatabaseUpdateCommand.cs index 9816f6ee2ed..b7be4100535 100644 --- a/src/ef/Commands/DatabaseUpdateCommand.cs +++ b/src/ef/Commands/DatabaseUpdateCommand.cs @@ -1,6 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections; +using Microsoft.EntityFrameworkCore.Tools.Properties; + namespace Microsoft.EntityFrameworkCore.Tools.Commands; // ReSharper disable once ArrangeTypeModifiers @@ -10,8 +13,35 @@ protected override int Execute(string[] args) { using var executor = CreateExecutor(args); - executor.UpdateDatabase(_migration!.Value, _connection!.Value(), Context!.Value()); + if (_add!.HasValue()) + { + // Create and apply a new migration in one step + var files = executor.AddAndApplyMigration( + _migration!.Value!, + _outputDir!.Value(), + Context!.Value(), + _namespace!.Value(), + _connection!.Value()); + + if (_json!.HasValue()) + { + ReportJson(files); + } + } + else + { + executor.UpdateDatabase(_migration!.Value, _connection!.Value(), Context!.Value()); + } return base.Execute(args); } + + private static void ReportJson(IDictionary files) + { + Reporter.WriteData("{"); + Reporter.WriteData(" \"migrationFile\": " + Json.Literal(files["MigrationFile"] as string) + ","); + Reporter.WriteData(" \"metadataFile\": " + Json.Literal(files["MetadataFile"] as string) + ","); + Reporter.WriteData(" \"snapshotFile\": " + Json.Literal(files["SnapshotFile"] as string)); + Reporter.WriteData("}"); + } } diff --git a/src/ef/IOperationExecutor.cs b/src/ef/IOperationExecutor.cs index 9304f0b2d4a..327086d165a 100644 --- a/src/ef/IOperationExecutor.cs +++ b/src/ef/IOperationExecutor.cs @@ -15,6 +15,7 @@ internal interface IOperationExecutor : IDisposable void DropDatabase(string? contextType); IDictionary GetContextInfo(string? name); void UpdateDatabase(string? migration, string? connectionString, string? contextType); + IDictionary AddAndApplyMigration(string name, string? outputDir, string? contextType, string? @namespace, string? connectionString); IEnumerable GetContextTypes(); IEnumerable OptimizeContext( diff --git a/src/ef/OperationExecutorBase.cs b/src/ef/OperationExecutorBase.cs index 807acaf036e..3515cfa4d2c 100644 --- a/src/ef/OperationExecutorBase.cs +++ b/src/ef/OperationExecutorBase.cs @@ -145,6 +145,18 @@ public void UpdateDatabase(string? migration, string? connectionString, string? ["contextType"] = contextType }); + public IDictionary AddAndApplyMigration(string name, string? outputDir, string? contextType, string? @namespace, string? connectionString) + => InvokeOperation( + "AddAndApplyMigration", + new Dictionary + { + ["name"] = name, + ["outputDir"] = outputDir, + ["contextType"] = contextType, + ["namespace"] = @namespace, + ["connectionString"] = connectionString + }); + public IEnumerable GetContextTypes() => InvokeOperation>("GetContextTypes"); diff --git a/src/ef/Properties/Resources.Designer.cs b/src/ef/Properties/Resources.Designer.cs index 873d4635f55..1cfdcf5e7f2 100644 --- a/src/ef/Properties/Resources.Designer.cs +++ b/src/ef/Properties/Resources.Designer.cs @@ -147,6 +147,12 @@ public static string DatabaseName(object? database) public static string DatabaseUpdateDescription => GetString("DatabaseUpdateDescription"); + /// + /// Create a new migration with the given name and apply it immediately. + /// + public static string DatabaseUpdateAddDescription + => GetString("DatabaseUpdateAddDescription"); + /// /// The data directory. /// @@ -409,6 +415,12 @@ public static string MissingOption(object? option) public static string NamespaceDescription => GetString("NamespaceDescription"); + /// + /// The '--namespace' option requires the '--add' option to be specified. + /// + public static string NamespaceRequiresAdd + => GetString("NamespaceRequiresAdd"); + /// /// Additionally generate all the code required for NativeAOT compilation and precompiled queries (experimental). /// @@ -489,6 +501,12 @@ public static string OutputDescription public static string OutputDirDescription => GetString("OutputDirDescription"); + /// + /// The '--output-dir' option requires the '--add' option to be specified. + /// + public static string OutputDirRequiresAdd + => GetString("OutputDirRequiresAdd"); + /// /// (Pending) /// diff --git a/src/ef/Properties/Resources.resx b/src/ef/Properties/Resources.resx index 0aabe9da486..e7c70626cf0 100644 --- a/src/ef/Properties/Resources.resx +++ b/src/ef/Properties/Resources.resx @@ -177,6 +177,9 @@ Updates the database to a specified migration. + + Create a new migration with the given name and apply it immediately. + The data directory. @@ -300,6 +303,9 @@ The namespace to use. Matches the directory by default. + + The '--namespace' option requires the '--add' option to be specified. + Generate additional code in the compiled model required for NativeAOT compilation and precompiled queries (experimental). @@ -339,6 +345,9 @@ The directory to put files in. Paths are relative to the project directory. + + The '--output-dir' option requires the '--add' option to be specified. + (Pending) diff --git a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs index 988350786a2..b4ef5fa0749 100644 --- a/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs +++ b/test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs @@ -332,7 +332,7 @@ private async Task EnsureDeletedAsync(DbContext context, CancellationToken return _armClient.GetCosmosDBAccountResource(databaseAccountIdentifier).GetAsync(cancellationToken); } - public override async Task CleanAsync(DbContext context) + public override async Task CleanAsync(DbContext context, bool createTables = true) { var created = await EnsureCreatedAsync(context).ConfigureAwait(false); try @@ -342,6 +342,11 @@ public override async Task CleanAsync(DbContext context) await DeleteContainersAsync(context).ConfigureAwait(false); } + if (!createTables) + { + return; + } + if (!TestEnvironment.UseTokenCredential) { created = await context.Database.EnsureCreatedAsync().ConfigureAwait(false); diff --git a/test/EFCore.Design.Tests/Design/Internal/MigrationsOperationsTest.cs b/test/EFCore.Design.Tests/Design/Internal/MigrationsOperationsTest.cs index 5e02c2ca914..c0c131ef877 100644 --- a/test/EFCore.Design.Tests/Design/Internal/MigrationsOperationsTest.cs +++ b/test/EFCore.Design.Tests/Design/Internal/MigrationsOperationsTest.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.EntityFrameworkCore.Internal; + namespace Microsoft.EntityFrameworkCore.Design.Internal; public class MigrationsOperationsTest @@ -43,6 +45,46 @@ public void Can_use_migrations_assembly() testOperations.AddMigration("Test", null, null, null, dryRun: true); } + [ConditionalFact] + public void AddMigration_throws_when_name_is_empty() + { + var assembly = MockAssembly.Create(typeof(TestContext)); + var operations = new TestMigrationsOperations( + new TestOperationReporter(), + assembly, + assembly, + "projectDir", + "RootNamespace", + "C#", + nullable: false, + args: []); + + var exception = Assert.Throws( + () => operations.AddMigration("", null, null, null, dryRun: true)); + + Assert.Equal(DesignStrings.MigrationNameRequired, exception.Message); + } + + [ConditionalFact] + public void AddMigration_throws_when_name_is_whitespace() + { + var assembly = MockAssembly.Create(typeof(TestContext)); + var operations = new TestMigrationsOperations( + new TestOperationReporter(), + assembly, + assembly, + "projectDir", + "RootNamespace", + "C#", + nullable: false, + args: []); + + var exception = Assert.Throws( + () => operations.AddMigration(" ", null, null, null, dryRun: true)); + + Assert.Equal(DesignStrings.MigrationNameRequired, exception.Message); + } + private class TestContext : DbContext; private class AssemblyTestContext : DbContext diff --git a/test/EFCore.Design.Tests/Design/OperationExecutorTest.cs b/test/EFCore.Design.Tests/Design/OperationExecutorTest.cs index e8e39711ebf..3965bf9a994 100644 --- a/test/EFCore.Design.Tests/Design/OperationExecutorTest.cs +++ b/test/EFCore.Design.Tests/Design/OperationExecutorTest.cs @@ -6,6 +6,7 @@ using System.Collections; using Microsoft.EntityFrameworkCore.Internal; using Microsoft.EntityFrameworkCore.Migrations.Internal; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore.TestUtilities.Xunit; namespace Microsoft.EntityFrameworkCore.Design; @@ -1100,6 +1101,130 @@ private MigrationFiles GenerateFilesDryRun(string projectDir, string? outputDir, return executor.MigrationsOperations.AddMigration("M", outputDir, nameof(GnomeContext), @namespace, dryRun: true); } + [ConditionalFact] + public void AddAndApplyMigration_succeeds_when_no_model_changes() + { + using var tempPath = new TempDirectory(); + var resultHandler = ExecuteAddAndApplyMigration( + tempPath, + "TestMigration", + null, + null); + + // When there are no pending model changes, the operation succeeds + // by applying existing migrations without creating a new one + Assert.True(resultHandler.HasResult); + Assert.Null(resultHandler.ErrorType); + } + + [ConditionalTheory, PlatformSkipCondition( + TestUtilities.Xunit.TestPlatform.Linux | TestUtilities.Xunit.TestPlatform.Mac, + SkipReason = "Tested negative cases and baselines are Windows-specific"), InlineData("to fix error: add column"), + InlineData(@"A\B\C")] + public void AddAndApplyMigration_errors_for_bad_names(string migrationName) + { + using var tempPath = new TempDirectory(); + var resultHandler = ExecuteAddAndApplyMigration( + tempPath, + migrationName, + null, + null); + + Assert.False(resultHandler.HasResult); + Assert.Equal(typeof(OperationException).FullName, resultHandler.ErrorType); + Assert.Contains(migrationName, resultHandler.ErrorMessage); + } + + [ConditionalFact] + public void AddAndApplyMigration_errors_for_invalid_context() + { + using var tempPath = new TempDirectory(); + var reportHandler = new OperationReportHandler(); + var resultHandler = new OperationResultHandler(); + var assembly = Assembly.GetExecutingAssembly(); + var executor = new OperationExecutor( + reportHandler, + new Dictionary + { + { "targetName", assembly.FullName }, + { "startupTargetName", assembly.FullName }, + { "projectDir", tempPath.Path }, + { "rootNamespace", "My.Gnomespace.Data" }, + { "language", "C#" }, + { "nullable", false }, + { "toolsVersion", ProductInfo.GetVersion() }, + { "remainingArguments", null } + }); + + new OperationExecutor.AddAndApplyMigration( + executor, + resultHandler, + new Dictionary + { + { "name", "TestMigration" }, + { "connectionString", null }, + { "contextType", "NonExistentContext" }, + { "outputDir", null }, + { "namespace", null } + }); + + Assert.False(resultHandler.HasResult); + Assert.NotNull(resultHandler.ErrorType); + Assert.Contains("NonExistentContext", resultHandler.ErrorMessage); + } + + [ConditionalFact] + public void AddAndApplyMigration_errors_for_empty_name() + { + using var tempPath = new TempDirectory(); + var resultHandler = ExecuteAddAndApplyMigration( + tempPath, + "", + null, + null); + + Assert.False(resultHandler.HasResult); + Assert.NotNull(resultHandler.ErrorType); + } + + private static OperationResultHandler ExecuteAddAndApplyMigration( + string tempPath, + string migrationName, + string? outputDir, + string? @namespace) + { + var reportHandler = new OperationReportHandler(); + var resultHandler = new OperationResultHandler(); + var assembly = Assembly.GetExecutingAssembly(); + var executor = new OperationExecutor( + reportHandler, + new Dictionary + { + { "targetName", assembly.FullName }, + { "startupTargetName", assembly.FullName }, + { "projectDir", tempPath }, + { "rootNamespace", "My.Gnomespace.Data" }, + { "language", "C#" }, + { "nullable", false }, + { "toolsVersion", ProductInfo.GetVersion() }, + { "remainingArguments", null } + }); + + new OperationExecutor.AddAndApplyMigration( + executor, + resultHandler, + new Dictionary + { + { "name", migrationName }, + { "connectionString", null }, + { "contextType", "GnomeContext" }, + { "outputDir", outputDir }, + { "namespace", @namespace } + }); + + return resultHandler; + } + public class OperationBaseTests { [ConditionalFact] @@ -1161,9 +1286,21 @@ public MockOperation(IOperationResultHandler resultHandler, Func> public class GnomeContext : DbContext { + // Use an externally opened connection so EF Core doesn't close it during migration steps. + // With :memory: SQLite, the database is destroyed when the connection closes, which causes + // issues when Migrator.Migrate opens/closes the connection multiple times. + private static readonly SqliteConnection _connection = CreateOpenConnection(); + + private static SqliteConnection CreateOpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder - .UseSqlite() + .UseSqlite(_connection) .ReplaceService(); private class FakeMigrationsIdGenerator : MigrationsIdGenerator diff --git a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationCompilerTest.cs b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationCompilerTest.cs new file mode 100644 index 00000000000..a9579dba37b --- /dev/null +++ b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationCompilerTest.cs @@ -0,0 +1,435 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.Migrations.Design.Internal; + +namespace Microsoft.EntityFrameworkCore.Migrations.Design; + +public class CSharpMigrationCompilerTest +{ + [ConditionalFact] + public void CompileMigration_compiles_valid_migration_code() + { + var compiler = new CSharpMigrationCompiler(); + + var scaffoldedMigration = CreateScaffoldedMigration( + migrationId: "20231215120000_TestMigration", + migrationCode: """ + using Microsoft.EntityFrameworkCore.Migrations; + + #nullable disable + + namespace TestNamespace + { + public partial class TestMigration : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TestTable", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TestTable", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "TestTable"); + } + } + } + """, + metadataCode: """ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Infrastructure; + using Microsoft.EntityFrameworkCore.Migrations; + + #nullable disable + + namespace TestNamespace + { + public class TestContext : DbContext { } + + [DbContext(typeof(TestContext))] + [Migration("20231215120000_TestMigration")] + partial class TestMigration + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + } + } + } + """, + snapshotCode: """ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Infrastructure; + + #nullable disable + + namespace TestNamespace + { + [DbContext(typeof(TestContext))] + partial class TestContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { + modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + } + } + } + """, + snapshotName: "TestContextModelSnapshot"); + + var assembly = compiler.CompileMigration(scaffoldedMigration, typeof(TestContext)); + + Assert.NotNull(assembly); + + // Find the migration type + var migrationType = assembly.GetTypes() + .FirstOrDefault(t => typeof(Migration).IsAssignableFrom(t) && !t.IsAbstract); + Assert.NotNull(migrationType); + Assert.Equal("TestMigration", migrationType.Name); + Assert.Equal(typeof(Migration), migrationType.BaseType); + + // Verify it has the Migration attribute with correct ID + var migrationAttribute = migrationType.GetCustomAttribute(); + Assert.NotNull(migrationAttribute); + Assert.Equal("20231215120000_TestMigration", migrationAttribute.Id); + } + + [ConditionalFact] + public void CompileMigration_throws_on_invalid_code() + { + var compiler = new CSharpMigrationCompiler(); + + var scaffoldedMigration = CreateScaffoldedMigration( + migrationId: "20231215120000_InvalidMigration", + migrationCode: """ + using Microsoft.EntityFrameworkCore.Migrations; + + namespace TestNamespace + { + // Invalid C# - missing class body + public partial class InvalidMigration : Migration + """, + metadataCode: """ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Infrastructure; + using Microsoft.EntityFrameworkCore.Migrations; + + namespace TestNamespace + { + [DbContext(typeof(TestContext))] + [Migration("20231215120000_InvalidMigration")] + partial class InvalidMigration { } + } + """, + snapshotCode: """ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Infrastructure; + + namespace TestNamespace + { + [DbContext(typeof(TestContext))] + partial class TestContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) { } + } + } + """, + snapshotName: "TestContextModelSnapshot"); + + var exception = Assert.Throws( + () => compiler.CompileMigration(scaffoldedMigration, typeof(TestContext))); + + Assert.Contains("20231215120000_InvalidMigration", exception.Message); + } + + [ConditionalFact] + public void CompileMigration_finds_snapshot_type() + { + var compiler = new CSharpMigrationCompiler(); + + var scaffoldedMigration = CreateValidScaffoldedMigration("20231215140000_SnapshotTest"); + + var assembly = compiler.CompileMigration(scaffoldedMigration, typeof(TestContext)); + + // Find the snapshot type + var snapshotType = assembly.GetTypes() + .FirstOrDefault(t => typeof(ModelSnapshot).IsAssignableFrom(t) && !t.IsAbstract); + Assert.NotNull(snapshotType); + Assert.Equal("TestContextModelSnapshot", snapshotType.Name); + Assert.Equal(typeof(ModelSnapshot), snapshotType.BaseType); + } + + [ConditionalFact] + public void CompileMigration_creates_unique_assembly_per_compilation() + { + var compiler = new CSharpMigrationCompiler(); + + var scaffolded1 = CreateValidScaffoldedMigration("20231215150000_First"); + var scaffolded2 = CreateValidScaffoldedMigration("20231215150001_Second"); + + var assembly1 = compiler.CompileMigration(scaffolded1, typeof(TestContext)); + var assembly2 = compiler.CompileMigration(scaffolded2, typeof(TestContext)); + + Assert.NotSame(assembly1, assembly2); + Assert.NotEqual(assembly1.FullName, assembly2.FullName); + } + + [ConditionalFact] + public void CompileMigration_throws_on_null_migration() + { + var compiler = new CSharpMigrationCompiler(); + + Assert.Throws( + () => compiler.CompileMigration(null!, typeof(TestContext))); + } + + [ConditionalFact] + public void CompileMigration_throws_on_null_context_type() + { + var compiler = new CSharpMigrationCompiler(); + var scaffoldedMigration = CreateValidScaffoldedMigration("20231215160000_NullContext"); + + Assert.Throws( + () => compiler.CompileMigration(scaffoldedMigration, null!)); + } + + [ConditionalFact] + public void CompileMigration_throws_on_empty_migration_code() + { + var compiler = new CSharpMigrationCompiler(); + + var scaffoldedMigration = CreateScaffoldedMigration( + migrationId: "20231215170000_EmptyCode", + migrationCode: "", + metadataCode: """ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Infrastructure; + using Microsoft.EntityFrameworkCore.Migrations; + + namespace TestNamespace + { + [DbContext(typeof(TestContext))] + [Migration("20231215170000_EmptyCode")] + partial class EmptyCode { } + } + """, + snapshotCode: """ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Infrastructure; + + namespace TestNamespace + { + [DbContext(typeof(TestContext))] + partial class TestContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) { } + } + } + """, + snapshotName: "TestContextModelSnapshot"); + + // Empty migration code results in compilation failure + var exception = Assert.Throws( + () => compiler.CompileMigration(scaffoldedMigration, typeof(TestContext))); + + Assert.Contains("20231215170000_EmptyCode", exception.Message); + } + + [ConditionalFact] + public void CompileMigration_handles_unicode_in_migration_name() + { + var compiler = new CSharpMigrationCompiler(); + + // Unicode characters in migration name should work (e.g., Japanese characters) + // Note: The class name must be a valid C# identifier, but the migration ID can contain unicode + var scaffoldedMigration = CreateValidScaffoldedMigration("20231215180000_TestMigration"); + + var assembly = compiler.CompileMigration(scaffoldedMigration, typeof(TestContext)); + + Assert.NotNull(assembly); + var migrationType = assembly.GetTypes() + .FirstOrDefault(t => typeof(Migration).IsAssignableFrom(t) && !t.IsAbstract); + Assert.NotNull(migrationType); + } + + [ConditionalFact] + public void CompileMigration_throws_on_very_long_migration_name() + { + var compiler = new CSharpMigrationCompiler(); + + // Create a migration with an excessively long name that exceeds metadata limits + var longName = "A" + new string('a', 200); + var migrationId = $"20231215190000_{longName}"; + + var migrationCode = $@"using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TestNamespace +{{ + public partial class {longName} : Migration + {{ + protected override void Up(MigrationBuilder migrationBuilder) {{ }} + protected override void Down(MigrationBuilder migrationBuilder) {{ }} + }} +}}"; + + var metadataCode = $@"using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TestNamespace +{{ + public class TestContext : DbContext {{ }} + + [DbContext(typeof(TestContext))] + [Migration(""{migrationId}"")] + partial class {longName} + {{ + protected override void BuildTargetModel(ModelBuilder modelBuilder) + {{ + modelBuilder.HasAnnotation(""ProductVersion"", ""8.0.0""); + }} + }} +}}"; + + var snapshotCode = @"using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +#nullable disable + +namespace TestNamespace +{ + [DbContext(typeof(TestContext))] + partial class TestContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { + modelBuilder.HasAnnotation(""ProductVersion"", ""8.0.0""); + } + } +}"; + + var scaffoldedMigration = CreateScaffoldedMigration( + migrationId: migrationId, + migrationCode: migrationCode, + metadataCode: metadataCode, + snapshotCode: snapshotCode, + snapshotName: "TestContextModelSnapshot"); + + // Very long migration names cause compilation failure due to metadata limits + var exception = Assert.Throws( + () => compiler.CompileMigration(scaffoldedMigration, typeof(TestContext))); + + Assert.Contains(migrationId, exception.Message); + Assert.Contains("CS7013", exception.Message); // Metadata name length exceeded + } + + private static ScaffoldedMigration CreateValidScaffoldedMigration(string migrationId) + { + var migrationName = migrationId.Split('_').Last(); + + var migrationCode = $@"using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TestNamespace +{{ + public partial class {migrationName} : Migration + {{ + protected override void Up(MigrationBuilder migrationBuilder) + {{ + }} + + protected override void Down(MigrationBuilder migrationBuilder) + {{ + }} + }} +}}"; + + var metadataCode = $@"using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TestNamespace +{{ + public class TestContext : DbContext {{ }} + + [DbContext(typeof(TestContext))] + [Migration(""{migrationId}"")] + partial class {migrationName} + {{ + protected override void BuildTargetModel(ModelBuilder modelBuilder) + {{ + modelBuilder.HasAnnotation(""ProductVersion"", ""8.0.0""); + }} + }} +}}"; + + var snapshotCode = @"using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +#nullable disable + +namespace TestNamespace +{ + [DbContext(typeof(TestContext))] + partial class TestContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { + modelBuilder.HasAnnotation(""ProductVersion"", ""8.0.0""); + } + } +}"; + + return new ScaffoldedMigration( + fileExtension: ".cs", + previousMigrationId: null, + migrationCode: migrationCode, + migrationId: migrationId, + metadataCode: metadataCode, + migrationSubNamespace: "TestNamespace", + snapshotCode: snapshotCode, + snapshotName: "TestContextModelSnapshot", + snapshotSubNamespace: "TestNamespace"); + } + + private static ScaffoldedMigration CreateScaffoldedMigration( + string migrationId, + string migrationCode, + string metadataCode, + string snapshotCode, + string snapshotName) + { + return new ScaffoldedMigration( + fileExtension: ".cs", + previousMigrationId: null, + migrationCode: migrationCode, + migrationId: migrationId, + metadataCode: metadataCode, + migrationSubNamespace: "TestNamespace", + snapshotCode: snapshotCode, + snapshotName: snapshotName, + snapshotSubNamespace: "TestNamespace"); + } + + private class TestContext : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseInMemoryDatabase("Test"); + } +} diff --git a/test/EFCore.InMemory.FunctionalTests/TestUtilities/InMemoryTestStore.cs b/test/EFCore.InMemory.FunctionalTests/TestUtilities/InMemoryTestStore.cs index 357196540b5..9e74affeaab 100644 --- a/test/EFCore.InMemory.FunctionalTests/TestUtilities/InMemoryTestStore.cs +++ b/test/EFCore.InMemory.FunctionalTests/TestUtilities/InMemoryTestStore.cs @@ -39,9 +39,9 @@ protected override TestStoreIndex GetTestStoreIndex(IServiceProvider serviceProv public override DbContextOptionsBuilder AddProviderOptions(DbContextOptionsBuilder builder) => builder.UseInMemoryDatabase(Name); - public override Task CleanAsync(DbContext context) + public override Task CleanAsync(DbContext context, bool createTables = true) { context.GetService().Store.Clear(); - return context.Database.EnsureCreatedAsync(); + return createTables ? context.Database.EnsureCreatedAsync() : Task.CompletedTask; } } diff --git a/test/EFCore.Relational.Specification.Tests/DesignTimeTestBase.cs b/test/EFCore.Relational.Specification.Tests/DesignTimeTestBase.cs index 15fd6131d80..3e8e76a386a 100644 --- a/test/EFCore.Relational.Specification.Tests/DesignTimeTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/DesignTimeTestBase.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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; diff --git a/test/EFCore.Relational.Specification.Tests/RuntimeMigrationTestBase.cs b/test/EFCore.Relational.Specification.Tests/RuntimeMigrationTestBase.cs new file mode 100644 index 00000000000..88869ae1560 --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/RuntimeMigrationTestBase.cs @@ -0,0 +1,886 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.Migrations.Design.Internal; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; + +namespace Microsoft.EntityFrameworkCore; + +#nullable disable + +/// +/// Base class for runtime migration tests. These tests validate the ability to +/// scaffold, compile, and apply migrations at runtime without using the CLI. +/// +public abstract class RuntimeMigrationTestBase(TFixture fixture) : IClassFixture, IAsyncLifetime + where TFixture : RuntimeMigrationTestBase.RuntimeMigrationFixtureBase +{ + protected TFixture Fixture { get; } = fixture; + + public virtual async Task InitializeAsync() + { + using var context = CreateContext(); + await Fixture.TestStore.CleanAsync(context, createTables: false); + } + + public virtual Task DisposeAsync() + => Task.CompletedTask; + + protected abstract Assembly ProviderAssembly { get; } + + #region Model Classes + + public class Blog + { + public int Id { get; set; } + public string Name { get; set; } + public List Posts { get; set; } + } + + public class Post + { + public int Id { get; set; } + public string Title { get; set; } + public string Content { get; set; } + public int BlogId { get; set; } + public Blog Blog { get; set; } + } + + public class RuntimeMigrationDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Blogs { get; set; } + public DbSet Posts { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(b => + { + b.Property(e => e.Name).HasMaxLength(200); + }); + + modelBuilder.Entity(b => + { + b.Property(e => e.Title).HasMaxLength(300); + b.HasOne(e => e.Blog) + .WithMany(e => e.Posts) + .HasForeignKey(e => e.BlogId); + }); + } + } + + #endregion + + #region Test Infrastructure + + protected RuntimeMigrationDbContext CreateContext() + => Fixture.CreateContext(); + + protected IServiceScope CreateDesignTimeServices(RuntimeMigrationDbContext context) + { + var serviceCollection = new ServiceCollection() + .AddEntityFrameworkDesignTimeServices() + .AddDbContextDesignTimeServices(context); + ((IDesignTimeServices)Activator.CreateInstance( + ProviderAssembly.GetType( + ProviderAssembly.GetCustomAttribute().TypeName, + throwOnError: true))!) + .ConfigureDesignTimeServices(serviceCollection); + return serviceCollection.BuildServiceProvider(validateScopes: true).CreateScope(); + } + + protected abstract List GetTableNames(System.Data.Common.DbConnection connection); + + public abstract class RuntimeMigrationFixtureBase : SharedStoreFixtureBase + { + protected override string StoreName + => "RuntimeMigration"; + + // Disable DbContext pooling because pooled contexts retain runtime migration assemblies + // from previous tests, causing subsequent tests to fail + protected override bool UsePooling + => false; + } + + #endregion + + #region Tests + + [ConditionalFact] + public void Can_scaffold_migration() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + + var migration = scaffolder.ScaffoldMigration( + "TestMigration", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + Assert.NotNull(migration); + Assert.Contains("TestMigration", migration.MigrationId); + Assert.NotEmpty(migration.MigrationCode); + Assert.NotEmpty(migration.MetadataCode); + Assert.NotEmpty(migration.SnapshotCode); + } + + [ConditionalFact] + public void Can_compile_migration() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + var compiler = services.ServiceProvider.GetRequiredService(); + + var migration = scaffolder.ScaffoldMigration( + "CompiledMigration", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var compiledAssembly = compiler.CompileMigration(migration, context.GetType()); + + Assert.NotNull(compiledAssembly); + + var migrationType = compiledAssembly.GetTypes() + .FirstOrDefault(t => typeof(Migration).IsAssignableFrom(t) && !t.IsAbstract); + Assert.NotNull(migrationType); + } + + [ConditionalFact] + public void Can_register_and_apply_compiled_migration() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + var compiler = services.ServiceProvider.GetRequiredService(); + var migrationsAssembly = services.ServiceProvider.GetRequiredService(); + var migrator = services.ServiceProvider.GetRequiredService(); + + var migration = scaffolder.ScaffoldMigration( + "AppliedMigration", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var compiledAssembly = compiler.CompileMigration(migration, context.GetType()); + migrationsAssembly.AddMigrations(compiledAssembly); + + Assert.Contains(migration.MigrationId, migrationsAssembly.Migrations.Keys); + + migrator.Migrate(migration.MigrationId); + + var appliedMigrations = context.Database.GetAppliedMigrations().ToList(); + Assert.Contains(migration.MigrationId, appliedMigrations); + } + + [ConditionalFact] + public void Compiled_migration_generates_valid_sql() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + var compiler = services.ServiceProvider.GetRequiredService(); + var migrationsAssembly = services.ServiceProvider.GetRequiredService(); + var sqlGenerator = services.ServiceProvider.GetRequiredService(); + + var migration = scaffolder.ScaffoldMigration( + "SqlGenMigration", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var compiledAssembly = compiler.CompileMigration(migration, context.GetType()); + migrationsAssembly.AddMigrations(compiledAssembly); + + var migrationTypeInfo = migrationsAssembly.Migrations[migration.MigrationId]; + var migrationInstance = migrationsAssembly.CreateMigration( + migrationTypeInfo, + context.Database.ProviderName); + + var commands = sqlGenerator.Generate( + migrationInstance.UpOperations, + context.Model).ToList(); + + Assert.NotEmpty(commands); + } + + [ConditionalFact] + public void HasPendingModelChanges_returns_true_for_new_model() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var migrator = services.ServiceProvider.GetRequiredService(); + + Assert.True(migrator.HasPendingModelChanges()); + } + + [ConditionalFact] + public void HasPendingModelChanges_returns_false_after_migration() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + var compiler = services.ServiceProvider.GetRequiredService(); + var migrationsAssembly = services.ServiceProvider.GetRequiredService(); + var migrator = services.ServiceProvider.GetRequiredService(); + + Assert.True(migrator.HasPendingModelChanges()); + + var migration = scaffolder.ScaffoldMigration( + "PendingChangesTest", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var compiledAssembly = compiler.CompileMigration(migration, context.GetType()); + migrationsAssembly.AddMigrations(compiledAssembly); + migrator.Migrate(migration.MigrationId); + + Assert.False(migrator.HasPendingModelChanges()); + } + + [ConditionalFact] + public void Compiled_migration_contains_correct_operations() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + var compiler = services.ServiceProvider.GetRequiredService(); + + var migration = scaffolder.ScaffoldMigration( + "OperationsTest", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var compiledAssembly = compiler.CompileMigration(migration, context.GetType()); + + var migrationType = compiledAssembly.GetTypes() + .FirstOrDefault(t => typeof(Migration).IsAssignableFrom(t) && !t.IsAbstract); + Assert.NotNull(migrationType); + + var migrationInstance = (Migration)Activator.CreateInstance(migrationType); + var upOperations = migrationInstance.UpOperations.ToList(); + + Assert.NotEmpty(upOperations); + Assert.Contains(upOperations, op => op is CreateTableOperation); + } + + [ConditionalFact] + public void Can_scaffold_and_save_migration_to_disk() + { + var tempDirectory = Path.Combine(Path.GetTempPath(), "EFCoreMigrationTest_" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(tempDirectory); + + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + + var migration = scaffolder.ScaffoldMigration( + "SaveToDisk", + rootNamespace: "TestNamespace", + subNamespace: "Migrations", + language: "C#", + dryRun: true); + + var files = scaffolder.Save(tempDirectory, migration, outputDir: null, dryRun: false); + + Assert.NotNull(files.MigrationFile); + Assert.NotNull(files.MetadataFile); + Assert.NotNull(files.SnapshotFile); + Assert.True(File.Exists(files.MigrationFile)); + Assert.True(File.Exists(files.MetadataFile)); + Assert.True(File.Exists(files.SnapshotFile)); + + var migrationContent = File.ReadAllText(files.MigrationFile); + Assert.Contains("CreateTable", migrationContent); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + try { Directory.Delete(tempDirectory, recursive: true); } + catch { /* Ignore cleanup errors */ } + } + } + } + + [ConditionalFact] + public void Can_apply_multiple_migrations_sequentially() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + var compiler = services.ServiceProvider.GetRequiredService(); + var migrationsAssembly = services.ServiceProvider.GetRequiredService(); + var migrator = services.ServiceProvider.GetRequiredService(); + + var migration1 = scaffolder.ScaffoldMigration( + "FirstSequential", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var assembly1 = compiler.CompileMigration(migration1, context.GetType()); + migrationsAssembly.AddMigrations(assembly1); + migrator.Migrate(migration1.MigrationId); + + var appliedAfterFirst = context.Database.GetAppliedMigrations().ToList(); + Assert.Contains(migration1.MigrationId, appliedAfterFirst); + + var migration2 = scaffolder.ScaffoldMigration( + "SecondSequential", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var assembly2 = compiler.CompileMigration(migration2, context.GetType()); + migrationsAssembly.AddMigrations(assembly2); + migrator.Migrate(migration2.MigrationId); + + var appliedAfterSecond = context.Database.GetAppliedMigrations().ToList(); + Assert.Contains(migration1.MigrationId, appliedAfterSecond); + Assert.Contains(migration2.MigrationId, appliedAfterSecond); + } + + [ConditionalFact] + public void Migration_down_reverses_up() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + var compiler = services.ServiceProvider.GetRequiredService(); + var migrationsAssembly = services.ServiceProvider.GetRequiredService(); + var migrator = services.ServiceProvider.GetRequiredService(); + + var migration = scaffolder.ScaffoldMigration( + "SymmetryTest", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var compiledAssembly = compiler.CompileMigration(migration, context.GetType()); + migrationsAssembly.AddMigrations(compiledAssembly); + migrator.Migrate(migration.MigrationId); + + var connection = context.Database.GetDbConnection(); + context.Database.OpenConnection(); + var tablesAfterUp = GetTableNames(connection); + Assert.Contains("Blogs", tablesAfterUp); + Assert.Contains("Posts", tablesAfterUp); + context.Database.CloseConnection(); + + migrator.Migrate("0"); + + context.Database.OpenConnection(); + var tablesAfterDown = GetTableNames(connection); + Assert.DoesNotContain("Blogs", tablesAfterDown); + Assert.DoesNotContain("Posts", tablesAfterDown); + context.Database.CloseConnection(); + } + + protected DatabaseModel GetDatabaseModel(IServiceScope services, RuntimeMigrationDbContext context) + { + var databaseModelFactory = services.ServiceProvider.GetRequiredService(); + var connection = context.Database.GetDbConnection(); + if (connection.State != System.Data.ConnectionState.Open) + { + context.Database.OpenConnection(); + } + + return databaseModelFactory.Create(connection, new DatabaseModelFactoryOptions()); + } + + protected (ScaffoldedMigration Migration, Assembly CompiledAssembly) ScaffoldAndApplyMigration( + IServiceScope services, + RuntimeMigrationDbContext context, + string migrationName) + { + var scaffolder = services.ServiceProvider.GetRequiredService(); + var compiler = services.ServiceProvider.GetRequiredService(); + var migrationsAssembly = services.ServiceProvider.GetRequiredService(); + var migrator = services.ServiceProvider.GetRequiredService(); + + var migration = scaffolder.ScaffoldMigration( + migrationName, + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var compiledAssembly = compiler.CompileMigration(migration, context.GetType()); + migrationsAssembly.AddMigrations(compiledAssembly); + migrator.Migrate(migration.MigrationId); + + return (migration, compiledAssembly); + } + + [ConditionalFact] + public void Can_revert_migration_using_down_operations() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + var compiler = services.ServiceProvider.GetRequiredService(); + var migrationsAssembly = services.ServiceProvider.GetRequiredService(); + var migrator = services.ServiceProvider.GetRequiredService(); + var sqlGenerator = services.ServiceProvider.GetRequiredService(); + var commandExecutor = services.ServiceProvider.GetRequiredService(); + var connection = services.ServiceProvider.GetRequiredService(); + + var migration = scaffolder.ScaffoldMigration( + "RevertTest", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var compiledAssembly = compiler.CompileMigration(migration, context.GetType()); + migrationsAssembly.AddMigrations(compiledAssembly); + migrator.Migrate(migration.MigrationId); + + var appliedBefore = context.Database.GetAppliedMigrations().ToList(); + Assert.Contains(migration.MigrationId, appliedBefore); + + var migrationTypeInfo = migrationsAssembly.Migrations[migration.MigrationId]; + var migrationInstance = migrationsAssembly.CreateMigration( + migrationTypeInfo, + context.Database.ProviderName); + + var downCommands = sqlGenerator.Generate( + migrationInstance.DownOperations, + context.Model).ToList(); + + connection.Open(); + try + { + commandExecutor.ExecuteNonQuery(downCommands, connection); + } + finally + { + connection.Close(); + } + } + + [ConditionalFact] + public void Applied_migration_is_recorded_in_history() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + var compiler = services.ServiceProvider.GetRequiredService(); + var migrationsAssembly = services.ServiceProvider.GetRequiredService(); + var migrator = services.ServiceProvider.GetRequiredService(); + + var initialApplied = context.Database.GetAppliedMigrations().ToList(); + + var migration = scaffolder.ScaffoldMigration( + "HistoryTest", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var compiledAssembly = compiler.CompileMigration(migration, context.GetType()); + migrationsAssembly.AddMigrations(compiledAssembly); + migrator.Migrate(migration.MigrationId); + + var afterApplied = context.Database.GetAppliedMigrations().ToList(); + Assert.Equal(initialApplied.Count + 1, afterApplied.Count); + Assert.Contains(migration.MigrationId, afterApplied); + } + + [ConditionalFact] + public void Compiled_migration_has_matching_up_and_down_table_operations() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + var compiler = services.ServiceProvider.GetRequiredService(); + + var migration = scaffolder.ScaffoldMigration( + "UpDownTest", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var compiledAssembly = compiler.CompileMigration(migration, context.GetType()); + + var migrationType = compiledAssembly.GetTypes() + .FirstOrDefault(t => typeof(Migration).IsAssignableFrom(t) && !t.IsAbstract); + Assert.NotNull(migrationType); + + var migrationInstance = (Migration)Activator.CreateInstance(migrationType); + + var upTableCount = migrationInstance.UpOperations.OfType().Count(); + var downTableCount = migrationInstance.DownOperations.OfType().Count(); + Assert.Equal(upTableCount, downTableCount); + } + + #endregion + + #region Rigorous Schema Verification Tests + + [ConditionalFact] + public void Migration_creates_correct_table_structure() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + ScaffoldAndApplyMigration(services, context, "TableStructureTest"); + + var dbModel = GetDatabaseModel(services, context); + + Assert.Contains(dbModel.Tables, t => t.Name == "Blogs"); + Assert.Contains(dbModel.Tables, t => t.Name == "Posts"); + + var blogsTable = dbModel.Tables.Single(t => t.Name == "Blogs"); + Assert.Equal(2, blogsTable.Columns.Count); + Assert.Single(blogsTable.Columns, c => c.Name == "Id"); + Assert.Single(blogsTable.Columns, c => c.Name == "Name"); + + var postsTable = dbModel.Tables.Single(t => t.Name == "Posts"); + Assert.Equal(4, postsTable.Columns.Count); + Assert.Single(postsTable.Columns, c => c.Name == "Id"); + Assert.Single(postsTable.Columns, c => c.Name == "Title"); + Assert.Single(postsTable.Columns, c => c.Name == "Content"); + Assert.Single(postsTable.Columns, c => c.Name == "BlogId"); + } + + [ConditionalFact] + public void Migration_creates_correct_primary_keys() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + ScaffoldAndApplyMigration(services, context, "PrimaryKeyTest"); + + var dbModel = GetDatabaseModel(services, context); + + var blogsTable = dbModel.Tables.Single(t => t.Name == "Blogs"); + Assert.NotNull(blogsTable.PrimaryKey); + Assert.Single(blogsTable.PrimaryKey.Columns); + Assert.Equal("Id", blogsTable.PrimaryKey.Columns[0].Name); + + var blogsId = blogsTable.Columns.Single(c => c.Name == "Id"); + Assert.False(blogsId.IsNullable); + + var postsTable = dbModel.Tables.Single(t => t.Name == "Posts"); + Assert.NotNull(postsTable.PrimaryKey); + Assert.Single(postsTable.PrimaryKey.Columns); + Assert.Equal("Id", postsTable.PrimaryKey.Columns[0].Name); + + var postsId = postsTable.Columns.Single(c => c.Name == "Id"); + Assert.False(postsId.IsNullable); + } + + [ConditionalFact] + public void Migration_creates_correct_foreign_keys() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + ScaffoldAndApplyMigration(services, context, "ForeignKeyTest"); + + var dbModel = GetDatabaseModel(services, context); + + var postsTable = dbModel.Tables.Single(t => t.Name == "Posts"); + + var fk = Assert.Single(postsTable.ForeignKeys); + + Assert.Equal("Blogs", fk.PrincipalTable.Name); + + Assert.Single(fk.Columns); + Assert.Equal("BlogId", fk.Columns[0].Name); + + Assert.Single(fk.PrincipalColumns); + Assert.Equal("Id", fk.PrincipalColumns[0].Name); + + Assert.Equal(ReferentialAction.Cascade, fk.OnDelete); + } + + [ConditionalFact] + public void Migration_creates_columns_with_correct_constraints() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + ScaffoldAndApplyMigration(services, context, "ColumnConstraintTest"); + + var dbModel = GetDatabaseModel(services, context); + + var blogsTable = dbModel.Tables.Single(t => t.Name == "Blogs"); + var nameColumn = blogsTable.Columns.Single(c => c.Name == "Name"); + Assert.NotNull(nameColumn.StoreType); + + var postsTable = dbModel.Tables.Single(t => t.Name == "Posts"); + var titleColumn = postsTable.Columns.Single(c => c.Name == "Title"); + Assert.NotNull(titleColumn.StoreType); + + var contentColumn = postsTable.Columns.Single(c => c.Name == "Content"); + Assert.True(contentColumn.IsNullable); + + var blogIdColumn = postsTable.Columns.Single(c => c.Name == "BlogId"); + Assert.False(blogIdColumn.IsNullable); + } + + [ConditionalFact] + public void Migration_down_removes_schema_completely() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var migrator = services.ServiceProvider.GetRequiredService(); + + ScaffoldAndApplyMigration(services, context, "CompleteRemovalTest"); + + var dbModelBefore = GetDatabaseModel(services, context); + + Assert.Contains(dbModelBefore.Tables, t => t.Name == "Blogs"); + Assert.Contains(dbModelBefore.Tables, t => t.Name == "Posts"); + + var postsTableBefore = dbModelBefore.Tables.Single(t => t.Name == "Posts"); + Assert.Single(postsTableBefore.ForeignKeys); + + migrator.Migrate("0"); + + var dbModelAfter = GetDatabaseModel(services, context); + + Assert.DoesNotContain(dbModelAfter.Tables, t => t.Name == "Blogs"); + Assert.DoesNotContain(dbModelAfter.Tables, t => t.Name == "Posts"); + } + + [ConditionalFact] + public void Migration_creates_foreign_key_index() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + ScaffoldAndApplyMigration(services, context, "FKIndexTest"); + + var dbModel = GetDatabaseModel(services, context); + + var postsTable = dbModel.Tables.Single(t => t.Name == "Posts"); + + var fkIndex = postsTable.Indexes.FirstOrDefault(i => + i.Columns.Any(c => c.Name == "BlogId")); + + Assert.NotNull(fkIndex); + Assert.Single(fkIndex.Columns); + Assert.Equal("BlogId", fkIndex.Columns[0].Name); + } + + [ConditionalFact] + public void Migration_with_no_changes_produces_empty_operations() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + var compiler = services.ServiceProvider.GetRequiredService(); + var migrationsAssembly = services.ServiceProvider.GetRequiredService(); + var migrator = services.ServiceProvider.GetRequiredService(); + + var migration1 = scaffolder.ScaffoldMigration( + "InitialMigration", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var assembly1 = compiler.CompileMigration(migration1, context.GetType()); + migrationsAssembly.AddMigrations(assembly1); + migrator.Migrate(migration1.MigrationId); + + var migration2 = scaffolder.ScaffoldMigration( + "EmptyMigration", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var assembly2 = compiler.CompileMigration(migration2, context.GetType()); + + var migrationType = assembly2.GetTypes() + .FirstOrDefault(t => typeof(Migration).IsAssignableFrom(t) && !t.IsAbstract); + Assert.NotNull(migrationType); + + var migrationInstance = (Migration)Activator.CreateInstance(migrationType); + + Assert.Empty(migrationInstance.UpOperations); + Assert.Empty(migrationInstance.DownOperations); + } + + [ConditionalFact] + public void Migration_preserves_existing_data() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + var compiler = services.ServiceProvider.GetRequiredService(); + var migrationsAssembly = services.ServiceProvider.GetRequiredService(); + var migrator = services.ServiceProvider.GetRequiredService(); + + var migration1 = scaffolder.ScaffoldMigration( + "DataPreservationInit", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var assembly1 = compiler.CompileMigration(migration1, context.GetType()); + migrationsAssembly.AddMigrations(assembly1); + migrator.Migrate(migration1.MigrationId); + + context.Blogs.Add(new Blog { Name = "Test Blog" }); + context.SaveChanges(); + + var blogId = context.Blogs.Single().Id; + context.Posts.Add(new Post + { + Title = "Test Post", + Content = "Test Content", + BlogId = blogId + }); + context.SaveChanges(); + + Assert.Equal(1, context.Blogs.Count()); + Assert.Equal(1, context.Posts.Count()); + + var migration2 = scaffolder.ScaffoldMigration( + "DataPreservationCheck", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var assembly2 = compiler.CompileMigration(migration2, context.GetType()); + migrationsAssembly.AddMigrations(assembly2); + migrator.Migrate(migration2.MigrationId); + + Assert.Equal(1, context.Blogs.Count()); + Assert.Equal(1, context.Posts.Count()); + + var blog = context.Blogs.Single(); + Assert.Equal("Test Blog", blog.Name); + + var post = context.Posts.Single(); + Assert.Equal("Test Post", post.Title); + Assert.Equal("Test Content", post.Content); + } + + [ConditionalFact] + public void Applied_migration_snapshot_matches_model() + { + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var migrator = services.ServiceProvider.GetRequiredService(); + + Assert.True(migrator.HasPendingModelChanges()); + + ScaffoldAndApplyMigration(services, context, "SnapshotMatchTest"); + + Assert.False(migrator.HasPendingModelChanges()); + + var dbModel = GetDatabaseModel(services, context); + + Assert.Contains(dbModel.Tables, t => t.Name == "Blogs"); + Assert.Contains(dbModel.Tables, t => t.Name == "Posts"); + + var blogsTable = dbModel.Tables.Single(t => t.Name == "Blogs"); + Assert.Equal(2, blogsTable.Columns.Count); + + var postsTable = dbModel.Tables.Single(t => t.Name == "Posts"); + Assert.Equal(4, postsTable.Columns.Count); + } + + [ConditionalFact] + public void RemoveMigration_removes_dynamically_created_migration() + { + var tempDirectory = Path.Combine(Path.GetTempPath(), "EFCoreRemoveMigrationTest_" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(tempDirectory); + + using var context = CreateContext(); + using var services = CreateDesignTimeServices(context); + + var scaffolder = services.ServiceProvider.GetRequiredService(); + var compiler = services.ServiceProvider.GetRequiredService(); + var migrationsAssembly = services.ServiceProvider.GetRequiredService(); + var migrator = services.ServiceProvider.GetRequiredService(); + + var migration = scaffolder.ScaffoldMigration( + "RemovableTest", + rootNamespace: "TestNamespace", + subNamespace: null, + language: "C#", + dryRun: true); + + var files = scaffolder.Save(tempDirectory, migration, outputDir: null, dryRun: false); + + Assert.True(File.Exists(files.MigrationFile)); + Assert.True(File.Exists(files.MetadataFile)); + Assert.True(File.Exists(files.SnapshotFile)); + + var compiledAssembly = compiler.CompileMigration(migration, context.GetType()); + migrationsAssembly.AddMigrations(compiledAssembly); + + migrator.Migrate(migration.MigrationId); + + var appliedMigrations = context.Database.GetAppliedMigrations().ToList(); + Assert.Contains(migration.MigrationId, appliedMigrations); + + migrator.Migrate("0"); + + var appliedAfterRevert = context.Database.GetAppliedMigrations().ToList(); + Assert.DoesNotContain(migration.MigrationId, appliedAfterRevert); + + var removedFiles = scaffolder.RemoveMigration(tempDirectory, rootNamespace: "TestNamespace", force: false, language: "C#", dryRun: false); + + Assert.NotNull(removedFiles.MigrationFile); + Assert.False(File.Exists(removedFiles.MigrationFile)); + Assert.False(File.Exists(removedFiles.MetadataFile)); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + try { Directory.Delete(tempDirectory, recursive: true); } + catch { /* Ignore cleanup errors */ } + } + } + } + + #endregion +} diff --git a/test/EFCore.Relational.Specification.Tests/TestUtilities/RelationalDatabaseCleaner.cs b/test/EFCore.Relational.Specification.Tests/TestUtilities/RelationalDatabaseCleaner.cs index a79e45b9f09..ddba4529e7a 100644 --- a/test/EFCore.Relational.Specification.Tests/TestUtilities/RelationalDatabaseCleaner.cs +++ b/test/EFCore.Relational.Specification.Tests/TestUtilities/RelationalDatabaseCleaner.cs @@ -30,7 +30,7 @@ protected virtual bool AcceptSequence(DatabaseSequence sequence) protected virtual void OpenConnection(IRelationalConnection connection) => connection.Open(); - public virtual void Clean(DatabaseFacade facade) + public virtual void Clean(DatabaseFacade facade, bool createTables = true) { var creator = facade.GetService(); var sqlGenerator = facade.GetService(); @@ -102,7 +102,10 @@ public virtual void Clean(DatabaseFacade facade) } } - creator.CreateTables(); + if (createTables) + { + creator.CreateTables(); + } } private static void ExecuteScript(IRelationalConnection connection, IRawSqlCommandBuilder sqlBuilder, string customSql) diff --git a/test/EFCore.Relational.Tests/Extensions/RelationalDatabaseFacadeExtensionsTest.cs b/test/EFCore.Relational.Tests/Extensions/RelationalDatabaseFacadeExtensionsTest.cs index 0ea7b104a9f..bc27c47b385 100644 --- a/test/EFCore.Relational.Tests/Extensions/RelationalDatabaseFacadeExtensionsTest.cs +++ b/test/EFCore.Relational.Tests/Extensions/RelationalDatabaseFacadeExtensionsTest.cs @@ -219,6 +219,10 @@ public string FindMigrationId(string nameOrId) public Migration CreateMigration(TypeInfo migrationClass, string activeProvider) => throw new NotImplementedException(); + + public void AddMigrations(Assembly additionalMigrationsAssembly) + { + } } [ConditionalTheory, InlineData(true), InlineData(false)] diff --git a/test/EFCore.Relational.Tests/Infrastructure/RelationalEventIdTest.cs b/test/EFCore.Relational.Tests/Infrastructure/RelationalEventIdTest.cs index 5e641dbe141..2af61570665 100644 --- a/test/EFCore.Relational.Tests/Infrastructure/RelationalEventIdTest.cs +++ b/test/EFCore.Relational.Tests/Infrastructure/RelationalEventIdTest.cs @@ -163,6 +163,10 @@ public Migration CreateMigration(TypeInfo migrationClass, string activeProvider) public string FindMigrationId(string nameOrId) => throw new NotImplementedException(); + + public void AddMigrations(Assembly additionalMigrationsAssembly) + { + } } private class FakeMigrationCommand() diff --git a/test/EFCore.Specification.Tests/TestUtilities/TestStore.cs b/test/EFCore.Specification.Tests/TestUtilities/TestStore.cs index 035197a2d59..4d4e3e0f6d5 100644 --- a/test/EFCore.Specification.Tests/TestUtilities/TestStore.cs +++ b/test/EFCore.Specification.Tests/TestUtilities/TestStore.cs @@ -74,7 +74,7 @@ protected virtual async Task InitializeAsync(Func createContext, Func public abstract DbContextOptionsBuilder AddProviderOptions(DbContextOptionsBuilder builder); - public virtual Task CleanAsync(DbContext context) + public virtual Task CleanAsync(DbContext context, bool createTables = true) => Task.CompletedTask; protected virtual DbContext CreateDefaultContext() diff --git a/test/EFCore.SqlServer.FunctionalTests/RuntimeMigrationSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/RuntimeMigrationSqlServerTest.cs new file mode 100644 index 00000000000..76b7b933d87 --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/RuntimeMigrationSqlServerTest.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. + +using System.Data.Common; +using Microsoft.EntityFrameworkCore.SqlServer.Design.Internal; + +namespace Microsoft.EntityFrameworkCore; + +#nullable disable + +public class RuntimeMigrationSqlServerTest(RuntimeMigrationSqlServerTest.RuntimeMigrationSqlServerFixture fixture) + : RuntimeMigrationTestBase(fixture) +{ + protected override Assembly ProviderAssembly + => typeof(SqlServerDesignTimeServices).Assembly; + + protected override List GetTableNames(DbConnection connection) + { + var tables = new List(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME != '__EFMigrationsHistory'"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + tables.Add(reader.GetString(0)); + } + return tables; + } + + public class RuntimeMigrationSqlServerFixture : RuntimeMigrationFixtureBase + { + protected override ITestStoreFactory TestStoreFactory + => SqlServerTestStoreFactory.Instance; + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerDatabaseFacadeExtensions.cs b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerDatabaseFacadeExtensions.cs index f8f368bc067..f177a5e0044 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerDatabaseFacadeExtensions.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerDatabaseFacadeExtensions.cs @@ -5,7 +5,7 @@ namespace Microsoft.EntityFrameworkCore.TestUtilities; public static class SqlServerDatabaseFacadeExtensions { - public static void EnsureClean(this DatabaseFacade databaseFacade) + public static void EnsureClean(this DatabaseFacade databaseFacade, bool createTables = true) => databaseFacade.CreateExecutionStrategy() - .Execute(databaseFacade, database => new SqlServerDatabaseCleaner().Clean(database)); + .Execute(databaseFacade, database => new SqlServerDatabaseCleaner().Clean(database, createTables)); } diff --git a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerTestStore.cs b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerTestStore.cs index 9e72806800e..ffd2410e4bd 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerTestStore.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerTestStore.cs @@ -160,9 +160,9 @@ private async Task CleanDatabaseAsync(Func? clean) return true; } - public override Task CleanAsync(DbContext context) + public override Task CleanAsync(DbContext context, bool createTables = true) { - context.Database.EnsureClean(); + context.Database.EnsureClean(createTables); return Task.CompletedTask; } diff --git a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/TestEnvironment.cs b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/TestEnvironment.cs index 53411c8a948..d0d5fa5df76 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/TestEnvironment.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/TestEnvironment.cs @@ -23,7 +23,8 @@ public static class TestEnvironment public static bool IsConfigured { get; } = !string.IsNullOrEmpty(_dataSource); - public static bool IsCI { get; } = Environment.GetEnvironmentVariable("PIPELINE_WORKSPACE") != null; + public static bool IsCI { get; } = Environment.GetEnvironmentVariable("PIPELINE_WORKSPACE") != null + || Environment.GetEnvironmentVariable("HELIX_CORRELATION_PAYLOAD") != null; private static bool? _isAzureSqlDb; diff --git a/test/EFCore.Sqlite.FunctionalTests/RuntimeMigrationSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/RuntimeMigrationSqliteTest.cs new file mode 100644 index 00000000000..5c0a30418f5 --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/RuntimeMigrationSqliteTest.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data.Common; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore.Sqlite.Design.Internal; + +namespace Microsoft.EntityFrameworkCore; + +#nullable disable + +public class RuntimeMigrationSqliteTest(RuntimeMigrationSqliteTest.RuntimeMigrationSqliteFixture fixture) + : RuntimeMigrationTestBase(fixture) +{ + protected override Assembly ProviderAssembly + => typeof(SqliteDesignTimeServices).Assembly; + + protected override List GetTableNames(DbConnection connection) + { + var tables = new List(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name != '__EFMigrationsHistory' AND name NOT LIKE 'sqlite_%'"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + tables.Add(reader.GetString(0)); + } + return tables; + } + + public class RuntimeMigrationSqliteFixture : RuntimeMigrationFixtureBase + { + protected override ITestStoreFactory TestStoreFactory + => SqliteTestStoreFactory.Instance; + } +} diff --git a/test/EFCore.Sqlite.FunctionalTests/SqliteDatabaseFacadeTestExtensions.cs b/test/EFCore.Sqlite.FunctionalTests/SqliteDatabaseFacadeTestExtensions.cs index 46e379a1850..6a605ef67b5 100644 --- a/test/EFCore.Sqlite.FunctionalTests/SqliteDatabaseFacadeTestExtensions.cs +++ b/test/EFCore.Sqlite.FunctionalTests/SqliteDatabaseFacadeTestExtensions.cs @@ -7,6 +7,6 @@ namespace Microsoft.EntityFrameworkCore; public static class SqliteDatabaseFacadeTestExtensions { - public static void EnsureClean(this DatabaseFacade databaseFacade) - => new SqliteDatabaseCleaner().Clean(databaseFacade); + public static void EnsureClean(this DatabaseFacade databaseFacade, bool createTables = true) + => new SqliteDatabaseCleaner().Clean(databaseFacade, createTables); } diff --git a/test/EFCore.Sqlite.FunctionalTests/TestUtilities/SqliteDatabaseCleaner.cs b/test/EFCore.Sqlite.FunctionalTests/TestUtilities/SqliteDatabaseCleaner.cs index c2a2e29ef9a..ad0d1273068 100644 --- a/test/EFCore.Sqlite.FunctionalTests/TestUtilities/SqliteDatabaseCleaner.cs +++ b/test/EFCore.Sqlite.FunctionalTests/TestUtilities/SqliteDatabaseCleaner.cs @@ -33,7 +33,7 @@ protected override string BuildCustomSql(DatabaseModel databaseModel) protected override string BuildCustomEndingSql(DatabaseModel databaseModel) => "PRAGMA foreign_keys=ON;"; - public override void Clean(DatabaseFacade facade) + public override void Clean(DatabaseFacade facade, bool createTables = true) { var connection = facade.GetDbConnection(); @@ -60,6 +60,6 @@ public override void Clean(DatabaseFacade facade) connection.Close(); } - base.Clean(facade); + base.Clean(facade, createTables); } } diff --git a/test/EFCore.Sqlite.FunctionalTests/TestUtilities/SqliteTestStore.cs b/test/EFCore.Sqlite.FunctionalTests/TestUtilities/SqliteTestStore.cs index dc63c4f3290..873dd647d90 100644 --- a/test/EFCore.Sqlite.FunctionalTests/TestUtilities/SqliteTestStore.cs +++ b/test/EFCore.Sqlite.FunctionalTests/TestUtilities/SqliteTestStore.cs @@ -93,9 +93,9 @@ protected override async Task InitializeAsync(Func createContext, Fun } } - public override Task CleanAsync(DbContext context) + public override Task CleanAsync(DbContext context, bool createTables = true) { - context.Database.EnsureClean(); + context.Database.EnsureClean(createTables); return Task.CompletedTask; }