| | | 1 | | #region Usings |
| | | 2 | | using System.Reflection; |
| | | 3 | | using Microsoft.EntityFrameworkCore.Design; |
| | | 4 | | using ZhEaIsNsAaBn.DbSeeder.Registrations; |
| | | 5 | | #endregion |
| | | 6 | | |
| | | 7 | | namespace ZhEaIsNsAaBn.DbSeeder.Design; |
| | | 8 | | |
| | | 9 | | /// <summary> |
| | | 10 | | /// The entry point of the <c>dotnet dbseeder</c> tool (it runs this inside the app's <c>deps.json</c> / <c>runtimeconfi |
| | | 11 | | /// Finds the context, then runs the <see cref="DbSeederCli"/> commands on it: |
| | | 12 | | /// <list type="number"> |
| | | 13 | | /// <item><c>IDesignTimeDbContextFactory<TContext></c> classes in the startup and project assemblies create the co |
| | | 14 | | /// <item>the seed registrations come from an <see cref="IDbSeederDesignTimeServices"/> class, else from the app's host |
| | | 15 | | /// runs until the host is built); without a factory the host also gives the context.</item> |
| | | 16 | | /// </list> |
| | | 17 | | /// More than one context: pick one with <c>--context</c> (short or full name). |
| | | 18 | | /// </summary> |
| | | 19 | | public static class DbSeederDesignTime |
| | | 20 | | { |
| | | 21 | | /// <summary>Exit code when the tool can't start the command (no context, several, no seed registration, the host fa |
| | | 22 | | public const int ToolError = 2; |
| | | 23 | | |
| | | 24 | | /// <summary>How long the app's entry point may take to build its host.</summary> |
| | | 25 | | public static readonly TimeSpan HostTimeout = TimeSpan.FromMinutes( 5 ); |
| | | 26 | | |
| | | 27 | | |
| | | 28 | | /// <summary>Finds the context and runs one command on it, on the process console.</summary> |
| | | 29 | | /// <param name="startupAssembly">The startup project's assembly (its host and factories).</param> |
| | | 30 | | /// <param name="projectAssembly">The <c>--project</c> assembly (factories, design-time services); may be <paramref |
| | | 31 | | /// <param name="projectDirectory">The project folder (<c>.dbseeder/</c>, migrations): the commands' <c>--dir</c> un |
| | | 32 | | /// <param name="contextName">The context's short or full type name; <see langword="null"/> when there is only one.< |
| | | 33 | | /// <param name="applicationArgs">Arguments for the app's entry point and for the factories' <c>CreateDbContext</c>. |
| | | 34 | | /// <param name="commandArgs">The DbSeeder command line, e.g. <c>[ "update", "--to", "20260301100000_Baseline" ]</c> |
| | | 35 | | /// <param name="cancellationToken">Token to cancel the command.</param> |
| | | 36 | | /// <returns>The command's exit code (0, 1, Spectre's for a bad command line) or <see cref="ToolError"/>.</returns> |
| | | 37 | | /// <exception cref="ArgumentNullException">An argument other than <paramref name="contextName"/> is <see langword=" |
| | | 38 | | public static Task<int> RunAsync( Assembly startupAssembly, Assembly projectAssembly, string projectDirectory, strin |
| | | 39 | | string[] commandArgs, CancellationToken cancellationToken ) |
| | | 40 | | => RunAsync( startupAssembly, projectAssembly, projectDirectory, contextName, applicationArgs, commandArgs, Ansi |
| | | 41 | | |
| | | 42 | | |
| | | 43 | | // errors: tool errors ("error: …"), plain and unwrapped like the tool's own (stderr in a real run). |
| | | 44 | | internal static async Task<int> RunAsync( Assembly startupAssembly, Assembly projectAssembly, string projectDirector |
| | | 45 | | string[] commandArgs, IAnsiConsole console, TextWriter errors, Cancellatio |
| | | 46 | | { |
| | | 47 | | ArgumentNullException.ThrowIfNull( errors ); |
| | | 48 | | |
| | | 49 | | ArgumentNullException.ThrowIfNull( startupAssembly ); |
| | | 50 | | ArgumentNullException.ThrowIfNull( projectAssembly ); |
| | | 51 | | ArgumentNullException.ThrowIfNull( projectDirectory ); |
| | | 52 | | ArgumentNullException.ThrowIfNull( applicationArgs ); |
| | | 53 | | ArgumentNullException.ThrowIfNull( commandArgs ); |
| | | 54 | | ArgumentNullException.ThrowIfNull( console ); |
| | | 55 | | |
| | | 56 | | Assembly[] assemblies = startupAssembly == projectAssembly ? [ startupAssembly ] : [ startupAssembly, projectAss |
| | | 57 | | var app = startupAssembly.GetName().Name; |
| | | 58 | | |
| | | 59 | | DesignTimeHost? host = null; |
| | | 60 | | ServiceProvider? services = null; |
| | | 61 | | |
| | | 62 | | try |
| | | 63 | | { |
| | | 64 | | var factories = DesignTimeDiscovery.Factories( assemblies ); |
| | | 65 | | var configurers = DesignTimeDiscovery.ServiceConfigurers( assemblies ); |
| | | 66 | | |
| | | 67 | | if ( configurers.Count > 1 ) { return Fail( errors,$"More than one IDbSeederDesignTimeServices: {string.Join |
| | | 68 | | |
| | | 69 | | IServiceProvider? provider; |
| | | 70 | | |
| | | 71 | | if ( configurers.Count == 1 ) |
| | | 72 | | { |
| | | 73 | | var collection = new ServiceCollection(); |
| | | 74 | | ( (IDbSeederDesignTimeServices) Activator.CreateInstance( configurers[ 0 ] )! ).ConfigureServices( colle |
| | | 75 | | provider = services = collection.BuildServiceProvider(); |
| | | 76 | | } |
| | | 77 | | else |
| | | 78 | | { |
| | | 79 | | try { host = await DesignTimeHost.BuildAsync( startupAssembly, applicationArgs, HostTimeout ).ConfigureA |
| | | 80 | | catch ( InvalidOperationException exception ) { return Fail( errors,$"The app's host could not be built: |
| | | 81 | | |
| | | 82 | | provider = host?.Services; |
| | | 83 | | } |
| | | 84 | | |
| | | 85 | | |
| | | 86 | | var registrations = provider?.GetServices<SeedContextRegistration>().ToList() ?? []; |
| | | 87 | | var candidates = factories.Keys.Concat( registrations.Select( r => r.ContextType ) ).Distinct().OrderBy( |
| | | 88 | | var contextType = DesignTimeDiscovery.Select( candidates, contextName, app, out var error ); |
| | | 89 | | |
| | | 90 | | if ( contextType is null ) { return Fail( errors,error! ); } |
| | | 91 | | |
| | | 92 | | if ( provider is null || registrations.All( r => r.ContextType != contextType ) ) |
| | | 93 | | { |
| | | 94 | | return Fail( errors,$"{contextType.Name} has no AddDbSeeder<{contextType.Name}>()…Build() registration ( |
| | | 95 | | } |
| | | 96 | | |
| | | 97 | | |
| | | 98 | | DbContext? context = null; |
| | | 99 | | |
| | | 100 | | if ( factories.TryGetValue( contextType, out var factory ) ) |
| | | 101 | | { |
| | | 102 | | try { context = DesignTimeDiscovery.Create( factory, applicationArgs ); } |
| | | 103 | | catch ( InvalidOperationException exception ) { return Fail( errors,exception.Message ); } |
| | | 104 | | } |
| | | 105 | | |
| | | 106 | | var args = WithDirectory( commandArgs, projectDirectory ); |
| | | 107 | | var run = typeof( DbSeederCli ).GetMethod( nameof( DbSeederCli.RunWithContextAsync ), BindingFlags.NonPu |
| | | 108 | | |
| | | 109 | | return await ( (Task<int>) run.Invoke( null, [ args, provider, console, context, cancellationToken ] )! ).Co |
| | | 110 | | } |
| | | 111 | | finally |
| | | 112 | | { |
| | | 113 | | if ( services is not null ) { await services.DisposeAsync().ConfigureAwait( false ); } |
| | | 114 | | |
| | | 115 | | if ( host is not null ) { await host.DisposeAsync().ConfigureAwait( false ); } |
| | | 116 | | } |
| | | 117 | | } |
| | | 118 | | |
| | | 119 | | |
| | | 120 | | /// The project folder as --dir, unless the command line names one (or there's no command: help, version). |
| | | 121 | | private static string[] WithDirectory( string[] commandArgs, string projectDirectory ) |
| | | 122 | | => commandArgs.Length == 0 || commandArgs[ 0 ].StartsWith( '-' ) || commandArgs.Any( a => a is "-d" or "--dir" | |
| | | 123 | | ? commandArgs |
| | | 124 | | : [ ..commandArgs, "--dir", projectDirectory ]; |
| | | 125 | | |
| | | 126 | | private static int Fail( TextWriter errors, string message ) |
| | | 127 | | { |
| | | 128 | | errors.WriteLine( $"error: {message}" ); |
| | | 129 | | |
| | | 130 | | return ToolError; |
| | | 131 | | } |
| | | 132 | | } |
| | | 133 | | |
| | | 134 | | /// Finding factories, design-time services and the one context. |
| | | 135 | | internal static class DesignTimeDiscovery |
| | | 136 | | { |
| | | 137 | | /// Context type → its IDesignTimeDbContextFactory class (public, parameterless). |
| | | 138 | | public static Dictionary<Type, Type> Factories( IEnumerable<Assembly> assemblies ) |
| | | 139 | | { |
| | 3 | 140 | | var factories = new Dictionary<Type, Type>(); |
| | | 141 | | |
| | 558 | 142 | | foreach ( var type in assemblies.SelectMany( LoadableTypes ).Where( IsCreatable ) ) |
| | | 143 | | { |
| | 552 | 144 | | foreach ( var contract in type.GetInterfaces().Where( i => i.IsGenericType && i.GetGenericTypeDefinition() = |
| | | 145 | | { |
| | 0 | 146 | | factories.TryAdd( contract.GetGenericArguments()[ 0 ], type ); |
| | | 147 | | } |
| | | 148 | | } |
| | | 149 | | |
| | 3 | 150 | | return factories; |
| | | 151 | | } |
| | | 152 | | |
| | | 153 | | public static List<Type> ServiceConfigurers( IEnumerable<Assembly> assemblies ) |
| | 3 | 154 | | => assemblies.SelectMany( LoadableTypes ).Where( t => IsCreatable( t ) && typeof( IDbSeederDesignTimeServices ). |
| | | 155 | | |
| | | 156 | | /// The context named <paramref name="name"/> (short or full), or the only one; <see langword="null"/> with the erro |
| | | 157 | | public static Type? Select( IReadOnlyList<Type> candidates, string? name, string? app, out string? error ) |
| | | 158 | | { |
| | 24 | 159 | | error = null; |
| | | 160 | | |
| | 24 | 161 | | if ( candidates.Count == 0 ) |
| | | 162 | | { |
| | 6 | 163 | | error = $"No DbContext found in {app}. Add an IDesignTimeDbContextFactory<TContext> (with an IDbSeederDesign |
| | 6 | 164 | | + "or register the context and AddDbSeeder<TContext>()…Build() in the app's host."; |
| | | 165 | | |
| | 6 | 166 | | return null; |
| | | 167 | | } |
| | | 168 | | |
| | 18 | 169 | | var found = string.Join( ", ", candidates.Select( t => t.Name ) ); |
| | | 170 | | |
| | 18 | 171 | | if ( name is null ) |
| | | 172 | | { |
| | 9 | 173 | | if ( candidates.Count == 1 ) { return candidates[ 0 ]; } |
| | | 174 | | |
| | 3 | 175 | | error = $"More than one DbContext found: {found}. Pick one with --context."; |
| | | 176 | | |
| | 3 | 177 | | return null; |
| | | 178 | | } |
| | | 179 | | |
| | 15 | 180 | | if ( candidates.FirstOrDefault( t => t.FullName == name ) is { } full ) { return full; } |
| | | 181 | | |
| | 9 | 182 | | var matches = candidates.Where( t => t.Name == name ).ToList(); |
| | | 183 | | |
| | 9 | 184 | | switch ( matches.Count ) |
| | | 185 | | { |
| | 3 | 186 | | case 1: return matches[ 0 ]; |
| | 6 | 187 | | case 0: error = $"No DbContext named '{name}'. Found: {found}."; return null; |
| | | 188 | | default: |
| | 3 | 189 | | error = $"'{name}' matches {string.Join( " and ", matches.Select( t => t.FullName ) )}. Use the full nam |
| | | 190 | | |
| | 3 | 191 | | return null; |
| | | 192 | | } |
| | | 193 | | } |
| | | 194 | | |
| | | 195 | | public static DbContext Create( Type factory, string[] args ) |
| | | 196 | | { |
| | 0 | 197 | | var instance = Activator.CreateInstance( factory )!; |
| | 0 | 198 | | var method = factory.GetInterfaces().First( i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof( ID |
| | | 199 | | |
| | 0 | 200 | | try { return (DbContext) method.Invoke( instance, [ args ] )!; } |
| | 0 | 201 | | catch ( TargetInvocationException exception ) when ( exception.InnerException is not null ) |
| | | 202 | | { |
| | 0 | 203 | | throw new InvalidOperationException( $"{factory.Name}.CreateDbContext failed: {exception.InnerException.Mess |
| | | 204 | | } |
| | 0 | 205 | | } |
| | | 206 | | |
| | | 207 | | |
| | | 208 | | private static bool IsCreatable( Type type ) |
| | 4614 | 209 | | => type is { IsClass: true, IsAbstract: false, ContainsGenericParameters: false } && ( type.IsPublic || type.IsN |
| | | 210 | | |
| | | 211 | | private static IEnumerable<Type> LoadableTypes( Assembly assembly ) |
| | | 212 | | { |
| | 6 | 213 | | try { return assembly.GetTypes(); } |
| | 0 | 214 | | catch ( ReflectionTypeLoadException exception ) { return exception.Types.Where( t => t is not null )!; } |
| | 6 | 215 | | } |
| | | 216 | | } |