// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREDOTNETTOOL
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.EntityFrameworkCore;
using Aspire.Hosting.Pipelines;
using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting;
/// <summary>
/// Provides extension methods for adding EF Core migration management to projects.
/// </summary>
public static class EFResourceBuilderExtensions
{
private static string GetShortTypeName(string? fullTypeName)
{
if (string.IsNullOrEmpty(fullTypeName))
{
return string.Empty;
}
var lastDotIndex = fullTypeName.LastIndexOf('.');
return lastDotIndex >= 0 ? fullTypeName[(lastDotIndex + 1)..] : fullTypeName;
}
/// <summary>
/// Adds EF Core migration management for a specific DbContext type.
/// </summary>
/// <param name="builder">The resource builder for the project.</param>
/// <param name="name">The name of the migration resource.</param>
/// <param name="dbContextTypeName">The fully qualified name of the DbContext type to manage migrations for.</param>
/// <returns>An EF migration resource builder for chaining additional configuration.</returns>
/// <exception cref="InvalidOperationException">Thrown if migrations for this context type have already been added.</exception>
/// <remarks>
/// Multiple calls to this method with different context types are supported, allowing you to manage
/// migrations for multiple DbContexts in the same project.
/// </remarks>
[AspireExportIgnore(Reason = "Polyglot AppHosts use the internal addEFMigrations dispatcher export.")]
public static IResourceBuilder<EFMigrationResource> AddEFMigrations(
this IResourceBuilder<ProjectResource> builder,
[ResourceName] string name,
string dbContextTypeName)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(dbContextTypeName);
return AddEFMigrationsCore(builder, name, dbContextTypeName, configureToolResource: null);
}
/// <summary>
/// Adds EF Core migration management for a specific DbContext type.
/// </summary>
/// <param name="builder">The resource builder for the project.</param>
/// <param name="name">The name of the migration resource.</param>
/// <param name="dbContextTypeName">The fully qualified name of the DbContext type to manage migrations for.</param>
/// <param name="configureToolResource">Optional callback to configure the dotnet-ef tool resource used for migrations.</param>
/// <returns>An EF migration resource builder for chaining additional configuration.</returns>
/// <exception cref="InvalidOperationException">Thrown if migrations for this context type have already been added.</exception>
/// <remarks>
/// Multiple calls to this method with different context types are supported, allowing you to manage
/// migrations for multiple DbContexts in the same project.
/// </remarks>
[AspireExportIgnore(Reason = "Action<IResourceBuilder<DotnetToolResource>> callbacks are not ATS-compatible.")]
public static IResourceBuilder<EFMigrationResource> AddEFMigrations(
this IResourceBuilder<ProjectResource> builder,
[ResourceName] string name,
string dbContextTypeName,
Action<IResourceBuilder<DotnetToolResource>>? configureToolResource)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(dbContextTypeName);
return AddEFMigrationsCore(builder, name, dbContextTypeName, configureToolResource);
}
/// <summary>
/// Adds EF Core migration management for the only DbContext type in the target project.
/// </summary>
/// <param name="builder">The resource builder for the project.</param>
/// <param name="name">The name of the migration resource.</param>
/// <returns>An EF migration resource builder for chaining additional configuration.</returns>
/// <exception cref="InvalidOperationException">Thrown if migrations have already been added for any DbContext type on this project.</exception>
[AspireExportIgnore(Reason = "Polyglot AppHosts use the internal addEFMigrations dispatcher export.")]
public static IResourceBuilder<EFMigrationResource> AddEFMigrations(
this IResourceBuilder<ProjectResource> builder,
[ResourceName] string name)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
return AddEFMigrationsCore(builder, name, dbContextTypeName: null, configureToolResource: null);
}
/// <summary>
/// Adds EF Core migration management for polyglot app hosts.
/// </summary>
[AspireExport("addEFMigrations")]
internal static IResourceBuilder<EFMigrationResource> AddEFMigrationsForPolyglot(
this IResourceBuilder<ProjectResource> builder,
[ResourceName] string name,
string? dbContextTypeName = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
if (dbContextTypeName is not null)
{
ArgumentException.ThrowIfNullOrEmpty(dbContextTypeName);
}
return AddEFMigrationsCore(builder, name, dbContextTypeName, configureToolResource: null);
}
/// <summary>
/// Adds EF Core migration management for the only DbContext type in the target project.
/// </summary>
/// <param name="builder">The resource builder for the project.</param>
/// <param name="name">The name of the migration resource.</param>
/// <param name="configureToolResource">Optional callback to configure the dotnet-ef tool resource used for migrations.</param>
/// <returns>An EF migration resource builder for chaining additional configuration.</returns>
/// <exception cref="InvalidOperationException">Thrown if migrations have already been added for any DbContext type on this project.</exception>
[AspireExportIgnore(Reason = "Action<IResourceBuilder<DotnetToolResource>> callbacks are not ATS-compatible.")]
public static IResourceBuilder<EFMigrationResource> AddEFMigrations(
this IResourceBuilder<ProjectResource> builder,
[ResourceName] string name,
Action<IResourceBuilder<DotnetToolResource>>? configureToolResource)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
return AddEFMigrationsCore(builder, name, dbContextTypeName: null, configureToolResource);
}
private static IResourceBuilder<EFMigrationResource> AddEFMigrationsCore(
IResourceBuilder<ProjectResource> builder,
string name,
string? dbContextTypeName,
Action<IResourceBuilder<DotnetToolResource>>? configureToolResource)
{
var existingMigrationResources = builder.ApplicationBuilder.Resources
.OfType<EFMigrationResource>()
.Where(r => r.ProjectResource == builder.Resource)
.ToList();
if (dbContextTypeName != null)
{
if (existingMigrationResources.Any(r => r.DbContextTypeName == dbContextTypeName))
{
throw new InvalidOperationException(
$"The DbContext type '{GetShortTypeName(dbContextTypeName)}' has already been registered for EF migrations on resource '{builder.Resource.Name}'.");
}
if (existingMigrationResources.Any(r => r.DbContextTypeName == null))
{
throw new InvalidOperationException(
$"Cannot register a specific DbContext type for migrations when they have already been registered without a context type on resource '{builder.Resource.Name}'.");
}
}
else if (existingMigrationResources.Count != 0)
{
if (existingMigrationResources.Any(r => r.DbContextTypeName == null))
{
throw new InvalidOperationException(
$"Cannot register migrations without a context type when they have already been registered without a context type on resource '{builder.Resource.Name}'.");
}
throw new InvalidOperationException(
$"Cannot register migrations without a context type when they have already been registered for specific DbContext types on resource '{builder.Resource.Name}'.");
}
var migrationResource = new EFMigrationResource(name, builder.Resource, dbContextTypeName)
{
ConfigureToolResource = configureToolResource
};
var innerBuilder = builder.ApplicationBuilder
.AddResource(migrationResource)
.WithParentRelationship(builder)
.WithInitialState(new CustomResourceSnapshot
{
ResourceType = "EFMigration",
Properties = [],
State = new ResourceStateSnapshot(KnownResourceStates.NotStarted, KnownResourceStateStyles.Info)
})
.WithIconName("Database")
.WithPipelineStepFactory(CreateMigrationPipelineStep);
AddEFMigrationCommands(innerBuilder, migrationResource, dbContextTypeName);
return innerBuilder;
}
internal static IEnumerable<PipelineStep> CreateMigrationPipelineStep(PipelineStepFactoryContext context)
{
if (context.PipelineContext.ExecutionContext.IsRunMode
|| context.Resource is not EFMigrationResource migrationResource
|| (!migrationResource.PublishAsMigrationScript && !migrationResource.PublishAsMigrationBundle))
{
return [];
}
var steps = new List<PipelineStep>();
var scriptStepName = migrationResource.PublishAsMigrationScript
? $"{migrationResource.Name}-generate-migration-script"
: null;
var bundleStepName = migrationResource.PublishAsMigrationBundle
? $"{migrationResource.Name}-generate-migration-bundle"
: null;
// Serialize publish-time generate steps across every EFMigrationResource in the model.
// Each `dotnet-ef` invocation triggers a `dotnet build` (the bundle step explicitly runs
// without `--no-build` because the bundle command needs the build to target a specific
// runtime). Two concurrent `dotnet-ef` runs can race on:
// - the shared obj/bin output when two migrations target the same startup project,
// - the per-user `dotnet tool exec` cache (NuGet install + extract) used by every
// DotnetToolResource regardless of project, and
// - the per-user MSBuild node-reuse / NuGet restore caches under %USERPROFILE%.
// None of those are safe under concurrent `dotnet-ef` invocations, so we chain ALL
// migration generate steps in the model — not just the ones sharing a startup project.
//
// The chain is built by deterministically ordering sibling migrations by name and pointing
// the first step of each migration at the last step of the previous migration. The
// graph is therefore: <m1>-script -> <m1>-bundle -> <m2>-script -> <m2>-bundle -> ...
// which is acyclic (the per-migration script -> bundle edge already exists and the
// cross-migration edge only flows forward in the deterministic ordering).
var crossMigrationPredecessor = GetPreviousMigrationLastStepName(context.PipelineContext.Model, migrationResource);
if (migrationResource.PublishAsMigrationScript)
{
List<string> scriptDependsOn = crossMigrationPredecessor is not null ? [crossMigrationPredecessor] : [];
steps.Add(new PipelineStep
{
Name = scriptStepName!,
Description = $"Generate EF Core migration SQL script for {migrationResource.Name}",
Resource = migrationResource,
DependsOnSteps = scriptDependsOn,
RequiredBySteps = [WellKnownPipelineSteps.Publish],
Action = stepContext => ExecutePublishPipelineOperationAsync(
stepContext, migrationResource, "migration script",
(executor, outputDir) =>
{
var outputPath = outputDir is not null
? Path.Combine(outputDir, migrationResource.Name + ".sql")
: null;
return executor.GenerateMigrationScriptAsync(
outputPath,
migrationResource.ScriptIdempotent,
migrationResource.ScriptNoTransactions);
})
});
}
if (migrationResource.PublishAsMigrationBundle)
{
var publishesContainer = migrationResource.PublishBundleContainer
&& context.PipelineContext.ExecutionContext.IsPublishMode;
List<string> requiredBy = publishesContainer
? [WellKnownPipelineSteps.Publish, $"build-{migrationResource.Name}"]
: [WellKnownPipelineSteps.Publish];
// Prefer the per-migration script step as the dependency when present (the cross-migration
// edge is already attached to the script step in that case). Only attach the cross-migration
// edge directly to the bundle step when this migration produces no script step.
List<string> bundleDependsOn = [];
if (scriptStepName is not null)
{
bundleDependsOn.Add(scriptStepName);
}
else if (crossMigrationPredecessor is not null)
{
bundleDependsOn.Add(crossMigrationPredecessor);
}
steps.Add(new PipelineStep
{
Name = bundleStepName!,
Description = $"Generate EF Core migration bundle for {migrationResource.Name}",
Resource = migrationResource,
DependsOnSteps = bundleDependsOn,
RequiredBySteps = requiredBy,
Action = stepContext => ExecutePublishPipelineOperationAsync(
stepContext, migrationResource, "migration bundle",
(executor, outputDir) =>
{
var outputPath = outputDir is not null
? Path.Combine(outputDir, GetBundleFileName(migrationResource))
: null;
return executor.GenerateMigrationBundleAsync(
outputPath,
migrationResource.BundleTargetRuntime,
migrationResource.BundleSelfContained);
})
});
}
return steps;
}
// Returns the name of the last publish-time step produced by the migration that immediately
// precedes <paramref name="current"/> in a stable ordering of all migrations in the model.
// Returns null when <paramref name="current"/> is the first such migration (no predecessor)
// or the only one.
private static string? GetPreviousMigrationLastStepName(DistributedApplicationModel model, EFMigrationResource current)
{
EFMigrationResource? predecessor = null;
foreach (var sibling in model.Resources.OfType<EFMigrationResource>())
{
if (ReferenceEquals(sibling, current) ||
(!sibling.PublishAsMigrationScript && !sibling.PublishAsMigrationBundle))
{
continue;
}
// Stable ordinal ordering by resource name keeps the chain deterministic regardless
// of model traversal order. Only siblings whose name sorts before this one can
// possibly act as a predecessor.
if (StringComparer.Ordinal.Compare(sibling.Name, current.Name) >= 0)
{
continue;
}
if (predecessor is null || StringComparer.Ordinal.Compare(sibling.Name, predecessor.Name) > 0)
{
predecessor = sibling;
}
}
if (predecessor is null)
{
return null;
}
// The bundle step always follows the script step within the same migration, so it is the
// last step when present.
return predecessor.PublishAsMigrationBundle
? $"{predecessor.Name}-generate-migration-bundle"
: $"{predecessor.Name}-generate-migration-script";
}
private static async Task ExecutePublishPipelineOperationAsync(
PipelineStepContext stepContext,
EFMigrationResource migrationResource,
string operationName,
Func<EFCoreOperationExecutor, string?, Task<EFOperationResult>> executeOperation)
{
var logger = stepContext.Logger;
#pragma warning disable ASPIREPIPELINES004 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var pipelineOutputService = stepContext.Services.GetRequiredService<IPipelineOutputService>();
#pragma warning restore ASPIREPIPELINES004 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
using var executor = new EFCoreOperationExecutor(
migrationResource.ProjectResource,
migrationResource.MigrationsProjectPath,
migrationResource.DbContextTypeName,
logger,
stepContext.CancellationToken,
stepContext.Services,
migrationResource.ToolResource);
var outputDir = Path.Combine(pipelineOutputService.GetOutputDirectory(), "efmigrations");
Directory.CreateDirectory(outputDir);
logger.LogInformation("Generating {Operation} for '{ResourceName}'...", operationName, migrationResource.Name);
var result = await executeOperation(executor, outputDir).ConfigureAwait(false);
// Flow the resolved target framework back to the resource so the Dockerfile generation
// step can pick the matching base image tag without re-parsing the project file.
if (executor.ResolvedFramework is not null && migrationResource.ResolvedFramework is null)
{
migrationResource.ResolvedFramework = executor.ResolvedFramework;
}
if (result.Success)
{
logger.LogInformation("{Operation} generated successfully for '{ResourceName}'.", operationName, migrationResource.Name);
}
else
{
throw new InvalidOperationException($"Failed to generate {operationName} for '{migrationResource.Name}': {result.ErrorMessage}");
}
}
internal static string GetBundleFileName(EFMigrationResource migrationResource)
{
// The bundle is produced for a specific target runtime, so its extension follows that
// runtime's conventions — not the host OS running `aspire publish`. When no explicit
// runtime was requested the runtime defaults to the host, so fall back to the OS check.
var runtime = migrationResource.BundleTargetRuntime;
var isWindowsBundle = runtime is not null
? runtime.StartsWith("win", StringComparison.OrdinalIgnoreCase)
: OperatingSystem.IsWindows();
return migrationResource.Name + (isWindowsBundle ? ".exe" : string.Empty);
}
private static async Task<ExecuteCommandResult> StartEfToolResourceAsync(ExecuteCommandContext context, DotnetToolResource toolResource)
{
var notificationService = context.Services.GetRequiredService<ResourceNotificationService>();
var resourceStarted = false;
Process? process = null;
try
{
var executableAnnotation = toolResource.Annotations.OfType<ExecutableAnnotation>().LastOrDefault();
if (executableAnnotation is null)
{
return new ExecuteCommandResult
{
Success = false,
Message = $"Executable configuration was not found for EF tool resource '{context.ResourceName}'."
};
}
var executionContext = context.Services.GetService<DistributedApplicationExecutionContext>()
?? new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run);
var executionConfiguration = await ExecutionConfigurationBuilder.Create(toolResource)
.WithEnvironmentVariablesConfig()
.BuildAsync(executionContext, context.Logger, context.CancellationToken).ConfigureAwait(false);
if (executionConfiguration.Exception is not null)
{
await notificationService.PublishUpdateAsync(toolResource, s => s with
{
State = KnownResourceStates.FailedToStart
}).ConfigureAwait(false);
return new ExecuteCommandResult
{
Success = false,
Message = executionConfiguration.Exception.Message
};
}
var startInfo = new ProcessStartInfo(executableAnnotation.Command)
{
WorkingDirectory = executableAnnotation.WorkingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
var toolArguments = await GatherToolArgumentsAsync(
toolResource,
executionContext,
context.Logger,
context.CancellationToken).ConfigureAwait(false);
foreach (var argument in toolArguments)
{
startInfo.ArgumentList.Add(argument.ToString()!);
}
foreach (var kvp in GetToolEnvironmentVariables(executionConfiguration, executionContext.IsPublishMode))
{
startInfo.Environment[kvp.Key] = kvp.Value;
}
await notificationService.PublishUpdateAsync(toolResource, s => s with
{
State = KnownResourceStates.Starting,
StartTimeStamp = DateTime.UtcNow,
StopTimeStamp = null
}).ConfigureAwait(false);
resourceStarted = true;
process = Process.Start(startInfo);
if (process is null)
{
await notificationService.PublishUpdateAsync(toolResource, s => s with
{
State = KnownResourceStates.FailedToStart
}).ConfigureAwait(false);
return new ExecuteCommandResult
{
Success = false,
Message = $"Failed to start EF tool resource '{context.ResourceName}'."
};
}
await notificationService.PublishUpdateAsync(toolResource, s => s with
{
State = KnownResourceStates.Running
}).ConfigureAwait(false);
var resourceLoggerService = context.Services.GetRequiredService<ResourceLoggerService>();
var resourceLogger = resourceLoggerService.GetLogger(toolResource);
var stderrBuilder = new StringBuilder();
var stdoutTask = EFCoreOperationExecutor.StreamOutputAsync(
process.StandardOutput, resourceLogger, isErrorOutput: false, captureBuilder: null, context.CancellationToken);
var stderrTask = EFCoreOperationExecutor.StreamOutputAsync(
process.StandardError, resourceLogger, isErrorOutput: true, captureBuilder: stderrBuilder, context.CancellationToken);
await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
await process.WaitForExitAsync(context.CancellationToken).ConfigureAwait(false);
var finalState = process.ExitCode == 0 ? KnownResourceStates.Finished : KnownResourceStates.FailedToStart;
await notificationService.PublishUpdateAsync(toolResource, s => s with
{
State = finalState,
StopTimeStamp = DateTime.UtcNow,
ExitCode = process.ExitCode
}).ConfigureAwait(false);
if (process.ExitCode != 0)
{
var errorMessage = stderrBuilder.ToString();
return new ExecuteCommandResult
{
Success = false,
Message = string.IsNullOrWhiteSpace(errorMessage)
? $"EF tool resource '{context.ResourceName}' exited with code {process.ExitCode}."
: errorMessage
};
}
return CommandResults.Success();
}
catch (OperationCanceledException)
{
if (resourceStarted)
{
try
{
if (process is not null && !process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
// The process has already exited or has not started; continue publishing the terminal state.
}
await notificationService.PublishUpdateAsync(toolResource, s => s with
{
State = KnownResourceStates.FailedToStart,
StopTimeStamp = DateTime.UtcNow
}).ConfigureAwait(false);
}
return CommandResults.Canceled();
}
catch (Exception ex)
{
if (resourceStarted)
{
try
{
if (process is not null && !process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
// The process has already exited or has not started; continue publishing the terminal state.
}
await notificationService.PublishUpdateAsync(toolResource, s => s with
{
State = KnownResourceStates.FailedToStart,
StopTimeStamp = DateTime.UtcNow
}).ConfigureAwait(false);
}
return new ExecuteCommandResult
{
Success = false,
Message = ex.Message
};
}
finally
{
process?.Dispose();
}
}
/// <summary>
/// Composes the EF tool arguments without cached callback results so sequential commands remain independent.
/// </summary>
internal static ValueTask<List<object>> GatherToolArgumentsAsync(
DotnetToolResource toolResource,
DistributedApplicationExecutionContext executionContext,
ILogger logger,
CancellationToken cancellationToken)
{
return toolResource.GatherArgumentValuesWithoutCachingAsync(
executionContext,
logger,
cancellationToken);
}
// Selects the environment variables to apply to the EF tool process. In publish mode connection
// string references resolve to manifest placeholder expressions (e.g. "{postgres.connectionString}")
// rather than real values because the target resources aren't provisioned yet. Passing such a
// placeholder to the EF tool makes design-time DbContext creation fail with "Format of the
// initialization string does not conform to specification". Generating the migration script/bundle
// doesn't require a live database, so omit connection strings entirely in publish mode and let the
// bundle receive the real connection string at deploy time.
internal static IEnumerable<KeyValuePair<string, string>> GetToolEnvironmentVariables(
IExecutionConfigurationResult executionConfiguration, bool isPublishMode)
{
foreach (var kvp in executionConfiguration.EnvironmentVariablesWithUnprocessed)
{
if (isPublishMode && kvp.Value.Unprocessed is ConnectionStringReference)
{
continue;
}
yield return new KeyValuePair<string, string>(kvp.Key, kvp.Value.Processed);
}
}
private const string EFToolPackageId = "dotnet-ef";
private static void AddEFMigrationCommands(
IResourceBuilder<EFMigrationResource> migrationBuilder,
EFMigrationResource migrationResource,
string? dbContextTypeName)
{
var contextShortName = GetShortTypeName(dbContextTypeName);
// Create hidden DotnetToolResource for running EF commands
var toolName = $"ef-tool-{migrationResource.Name}";
var startupProjectDir = Path.GetDirectoryName(migrationResource.ProjectResource.GetProjectMetadata().ProjectPath)!;
var toolBuilder = migrationBuilder.ApplicationBuilder.AddDotnetTool(toolName, EFToolPackageId)
.WithParentRelationship(migrationBuilder)
.WithWorkingDirectory(startupProjectDir)
.WithExplicitStart()
.WithHidden()
.WithInitialState(new CustomResourceSnapshot
{
ResourceType = "Tool",
Properties = []
});
// Register the EF-specific start command. The tool resource is captured by the closure
// so it works in both run mode (via resource commands) and publish mode (via pipeline steps).
var toolResource = toolBuilder.Resource;
toolBuilder.WithCommand(
name: EFCoreOperationExecutor.ToolStartCommandName,
displayName: "Start",
executeCommand: context => StartEfToolResourceAsync(context, toolResource));
migrationResource.ConfigureToolResource?.Invoke(toolBuilder);
// Copy environment annotations from project resource to tool resource
if (migrationResource.ProjectResource.TryGetAnnotationsOfType<EnvironmentCallbackAnnotation>(out var envCallbacks))
{
foreach (var callback in envCallbacks)
{
toolBuilder.WithAnnotation(callback);
}
}
// Forward the migration resource's own environment to the tool resource at start time.
// The connection string the user declares via `.WithReference(<db>)` lands on the migration
// resource as an EnvironmentCallbackAnnotation *after* this method runs, so the annotations
// cannot be copied eagerly like the project ones above. Evaluating them lazily here ensures the
// dotnet-ef process receives `ConnectionStrings__<db>`; without it the design-time DbContext
// has no connection string and fails with "The ConnectionString property has not been initialized."
toolBuilder.WithEnvironment(async context =>
{
if (migrationResource.TryGetAnnotationsOfType<EnvironmentCallbackAnnotation>(out var migrationEnvCallbacks))
{
foreach (var callback in migrationEnvCallbacks)
{
await callback.Callback(context).ConfigureAwait(false);
}
}
});
migrationResource.ToolResource = toolBuilder.Resource;
migrationBuilder.WithCommand(
name: "ef-database-update",
displayName: "Update Database",
executeCommand: context => ExecuteEFCommandAsync(
context,
"Update Database",
migrationResource,
executor => executor.UpdateDatabaseAsync()),
commandOptions: new CommandOptions
{
Description = "Apply pending migrations to the database",
IconName = "ArrowSync",
IconVariant = IconVariant.Regular,
UpdateState = context => GetCommandState(context, migrationResource)
});
migrationBuilder.WithCommand(
name: "ef-database-drop",
displayName: "Drop Database",
executeCommand: context => ExecuteEFCommandAsync(
context,
"Drop Database",
migrationResource,
executor => executor.DropDatabaseAsync()),
commandOptions: new CommandOptions
{
Description = "Delete the database",
IconName = "Delete",
IconVariant = IconVariant.Regular,
ConfirmationMessage = "Are you sure you want to drop the database? This action cannot be undone.",
UpdateState = context => GetCommandState(context, migrationResource)
});
migrationBuilder.WithCommand(
name: "ef-database-reset",
displayName: "Reset Database",
executeCommand: context => ExecuteEFCommandAsync(
context,
"Reset Database",
migrationResource,
executor => executor.ResetDatabaseAsync()),
commandOptions: new CommandOptions
{
Description = "Drop and recreate the database with all migrations applied",
IconName = "ArrowReset",
IconVariant = IconVariant.Regular,
ConfirmationMessage = "Are you sure you want to reset the database? This will delete all data and cannot be undone.",
UpdateState = context => GetCommandState(context, migrationResource)
});
migrationBuilder.WithCommand(
name: "ef-migrations-add",
displayName: "Add Migration...",
executeCommand: context => ExecuteAddMigrationCommandAsync(context, migrationResource),
commandOptions: new CommandOptions
{
Description = "Create a new migration. Note: The target project will need to be recompiled after adding a migration.",
IconName = "Add",
IconVariant = IconVariant.Regular,
UpdateState = context => GetCommandState(context, migrationResource),
Arguments =
[
new InteractionInput
{
Name = "name",
InputType = InputType.Text,
Label = "Migration Name",
Required = true,
Placeholder = "e.g. InitialCreate"
}
]
});
migrationBuilder.WithCommand(
name: "ef-migrations-remove",
displayName: "Remove Migration",
executeCommand: context => ExecuteRemoveMigrationCommandAsync(context, migrationResource),
commandOptions: new CommandOptions
{
Description = "Remove the last migration. Note: The target project will need to be recompiled after removing a migration.",
IconName = "Subtract",
IconVariant = IconVariant.Regular,
UpdateState = context => GetCommandState(context, migrationResource)
});
migrationBuilder.WithCommand(
name: "ef-database-status",
displayName: "Get Database Status",
executeCommand: context => ExecuteGetStatusCommandAsync(context, migrationResource),
commandOptions: new CommandOptions
{
Description = "Show the current migration status of the database",
IconName = "Info",
IconVariant = IconVariant.Regular,
UpdateState = context => GetCommandState(context, migrationResource)
});
}
private static ResourceCommandState GetCommandState(UpdateCommandStateContext _, EFMigrationResource migrationResource)
{
if (migrationResource.RequiresRebuild || migrationResource.IsExecutingCommand)
{
return ResourceCommandState.Disabled;
}
return ResourceCommandState.Enabled;
}
private static Task<ExecuteCommandResult> ExecuteEFCommandAsync(
ExecuteCommandContext context,
string operationDisplayName,
EFMigrationResource migrationResource,
Func<EFCoreOperationExecutor, Task<EFOperationResult>> executeOperation) =>
ExecuteWithStateManagementAsync(
context,
operationDisplayName,
migrationResource,
waitForDependencies: true,
async (executor, logger, _) =>
{
var result = await executeOperation(executor).ConfigureAwait(false);
if (result.Success)
{
logger.LogInformation("EF Core {Operation} command completed successfully.", operationDisplayName);
return CommandResults.Success();
}
logger.LogError("EF Core {Operation} command failed: {Error}", operationDisplayName, result.ErrorMessage);
return CommandResults.Failure(result.ErrorMessage);
});
/// <summary>
/// Common wrapper that handles state management and exception handling for EF commands.
/// </summary>
private static async Task<ExecuteCommandResult> ExecuteWithStateManagementAsync(
ExecuteCommandContext context,
string operationDisplayName,
EFMigrationResource migrationResource,
bool waitForDependencies,
Func<EFCoreOperationExecutor, ILogger, IInteractionService?, Task<ExecuteCommandResult>> executeOperation)
{
var resourceLoggerService = context.Services.GetRequiredService<ResourceLoggerService>();
var resourceNotificationService = context.Services.GetRequiredService<ResourceNotificationService>();
var interactionService = context.Services.GetService<IInteractionService>();
var logger = resourceLoggerService.GetLogger(migrationResource);
if (migrationResource.IsExecutingCommand)
{
return CommandResults.Failure($"Another command is already running on this resource.");
}
migrationResource.IsExecutingCommand = true;
try
{
if (waitForDependencies)
{
await UpdateStateAsync(resourceNotificationService, migrationResource, KnownResourceStates.Waiting, KnownResourceStateStyles.Info).ConfigureAwait(false);
await resourceNotificationService.WaitForDependenciesAsync(migrationResource, context.CancellationToken).ConfigureAwait(false);
}
await UpdateStateAsync(resourceNotificationService, migrationResource, KnownResourceStates.Running, KnownResourceStateStyles.Info).ConfigureAwait(false);
logger.LogInformation("Executing EF Core {Operation} command...", operationDisplayName);
using var executor = new EFCoreOperationExecutor(
migrationResource.ProjectResource,
migrationResource.MigrationsProjectPath,
migrationResource.DbContextTypeName,
logger,
context.CancellationToken,
context.Services,
migrationResource.ToolResource);
var result = await executeOperation(executor, logger, interactionService).ConfigureAwait(false);
migrationResource.IsExecutingCommand = false;
if (result.Success)
{
await UpdateStateAsync(resourceNotificationService, migrationResource, KnownResourceStates.Finished, KnownResourceStateStyles.Info).ConfigureAwait(false);
}
else
{
await UpdateStateAsync(resourceNotificationService, migrationResource, KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error).ConfigureAwait(false);
}
return result;
}
catch (OperationCanceledException)
{
migrationResource.IsExecutingCommand = false;
await UpdateStateAsync(resourceNotificationService, migrationResource, KnownResourceStates.NotStarted, KnownResourceStateStyles.Info).ConfigureAwait(false);
logger.LogWarning("EF Core {Operation} command was cancelled.", operationDisplayName);
return CommandResults.Canceled();
}
catch (Exception ex)
{
migrationResource.IsExecutingCommand = false;
await UpdateStateAsync(resourceNotificationService, migrationResource, KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error).ConfigureAwait(false);
logger.LogError(ex, "EF Core {Operation} command failed with exception.", operationDisplayName);
return CommandResults.Failure(ex);
}
}
private static Task UpdateStateAsync(
ResourceNotificationService resourceNotificationService,
EFMigrationResource migrationResource,
string state,
string style) =>
resourceNotificationService.PublishUpdateAsync(migrationResource, s => s with
{
State = new ResourceStateSnapshot(state, style)
});
private static Task<ExecuteCommandResult> ExecuteAddMigrationCommandAsync(
ExecuteCommandContext context,
EFMigrationResource migrationResource) =>
ExecuteWithStateManagementAsync(
context,
"Add Migration",
migrationResource,
waitForDependencies: false,
async (executor, logger, interaction) =>
{
var migrationName = context.Arguments.GetString("name")!;
var result = await executor.AddMigrationAsync(
migrationName,
migrationResource.MigrationOutputDirectory,
migrationResource.MigrationNamespace).ConfigureAwait(false);
if (result.Success)
{
logger.LogInformation("Migration '{MigrationName}' created successfully.", migrationName);
migrationResource.RequiresRebuild = true;
if (interaction != null && interaction.IsAvailable)
{
await interaction.PromptNotificationAsync(
title: "Migration Created",
message: $"Migration '{migrationName}' was added successfully.\n\nThe target project needs to be recompiled before the migration can be applied.",
options: new NotificationInteractionOptions
{
Intent = MessageIntent.Warning,
ShowSecondaryButton = false
},
cancellationToken: context.CancellationToken).ConfigureAwait(false);
}
else
{
logger.LogWarning("Migration '{MigrationName}' was added successfully. The target project needs to be recompiled before the migration can be applied.", migrationName);
}
return CommandResults.Success();
}
logger.LogError("Add Migration command failed: {Error}", result.ErrorMessage);
return CommandResults.Failure(result.ErrorMessage);
});
private static Task<ExecuteCommandResult> ExecuteRemoveMigrationCommandAsync(
ExecuteCommandContext context,
EFMigrationResource migrationResource) =>
ExecuteWithStateManagementAsync(
context,
"Remove Migration",
migrationResource,
waitForDependencies: false,
async (executor, logger, interactionService) =>
{
var result = await executor.RemoveMigrationAsync().ConfigureAwait(false);
if (result.Success)
{
logger.LogInformation("Migration removed successfully.");
migrationResource.RequiresRebuild = true;
if (interactionService != null && interactionService.IsAvailable)
{
await interactionService.PromptNotificationAsync(
title: "Migration Removed",
message: "The last migration was removed successfully.\n\nThe target project needs to be recompiled.",
options: new NotificationInteractionOptions
{
Intent = MessageIntent.Warning,
ShowSecondaryButton = false
},
cancellationToken: context.CancellationToken).ConfigureAwait(false);
}
else
{
logger.LogWarning("The last migration was removed successfully. The target project needs to be recompiled.");
}
return CommandResults.Success();
}
logger.LogError("Remove Migration command failed: {Error}", result.ErrorMessage);
return CommandResults.Failure(result.ErrorMessage);
});
private static Task<ExecuteCommandResult> ExecuteGetStatusCommandAsync(
ExecuteCommandContext context,
EFMigrationResource migrationResource) =>
ExecuteWithStateManagementAsync(
context,
"Get Database Status",
migrationResource,
waitForDependencies: false,
async (executor, logger, interactionService) =>
{
var result = await executor.GetDatabaseStatusAsync().ConfigureAwait(false);
if (result.Success)
{
if (interactionService != null && interactionService.IsAvailable)
{
await interactionService.PromptMessageBoxAsync(
title: "Database Migration Status",
message: result.Output ?? "No migration information available.",
options: new MessageBoxInteractionOptions
{
Intent = MessageIntent.Information,
ShowSecondaryButton = false,
EnableMessageMarkdown = true
},
cancellationToken: context.CancellationToken).ConfigureAwait(false);
}
else
{
logger.LogInformation("Database status:\n{Status}", result.Output);
}
return CommandResults.Success();
}
logger.LogError("Get Database Status command failed: {Error}", result.ErrorMessage);
return CommandResults.Failure(result.ErrorMessage);
});
}