< Summary - ZhEaIsNsAaBn.Libraries coverage (affected: 23 packages)

Information
Class: ZhEaIsNsAaBn.DbSeeder.Design.SeedOperator
Assembly: ZhEaIsNsAaBn.DbSeeder.Design
File(s): /home/runner/work/ZhEaIsNsAaBn.Libraries.V1/ZhEaIsNsAaBn.Libraries.V1/src/DataBasePackages/ZhEaIsNsAaBn.DbSeeder.Design/Operate/SeedOperator.cs
Line coverage
87%
Covered lines: 123
Uncovered lines: 17
Coverable lines: 140
Total lines: 333
Line coverage: 87.8%
Branch coverage
78%
Covered branches: 103
Total branches: 132
Branch coverage: 78%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
For(...)50%2260%
ListAsync()87.5%2424100%
UpdateAsync()75%44100%
Script(...)50%22100%
DownAsync()70.83%382470.83%
ForceAsync()92.85%1414100%
ProductionRefusal()85.71%141490%
ConfirmAndRollBackAsync()87.5%88100%
PlanAsync()90%101089.47%
ForcePanel(...)100%22100%
RolledBack(...)100%11100%
Invalid(...)54.54%1052244.44%
Steps(...)50%22100%
Warnings(...)100%22100%
Errors(...)100%11100%
Errors(...)100%22100%

File(s)

/home/runner/work/ZhEaIsNsAaBn.Libraries.V1/ZhEaIsNsAaBn.Libraries.V1/src/DataBasePackages/ZhEaIsNsAaBn.DbSeeder.Design/Operate/SeedOperator.cs

#LineLine coverage
 1namespace ZhEaIsNsAaBn.DbSeeder.Design;
 2
 3/// <summary>
 4/// The operate commands on the dev database, from the migrations folder on disk: <c>list</c>, <c>update</c>, <c>down</c
 5/// blockers offer, forced delete) and <c>script</c>. Exit code 0 = done, 1 = error, refused or cancelled.
 6/// </summary>
 7internal sealed class SeedOperator
 8{
 9    private const string Production = nameof( Production );
 10
 11    private readonly DesignSession     _session;
 12    private readonly SeedDesigner      _designer;
 13    private readonly SeedConfiguration _configuration;
 14    private readonly IAnsiConsole      _console;
 15
 16
 16817    private SeedOperator( DesignSession session, SeedDesigner designer, SeedConfiguration configuration )
 18    {
 16819        _session       = session;
 16820        _designer      = designer;
 16821        _configuration = configuration;
 16822        _console       = session.Console;
 16823    }
 24
 25
 26    /// The operator for the settings' folder; <see langword="null"/> (error printed) when the folder can't be chosen.
 27    public static SeedOperator? For( DesignSession session, FolderSettings settings )
 28    {
 16829        var designer      = session.Designer( settings );
 16830        var configuration = designer.FolderConfiguration( settings.Folder );
 31
 33632        if ( configuration.IsSuccess ) { return new SeedOperator( session, designer, configuration.Result! ); }
 33
 34
 035        session.Report( DesignResult.Failed( configuration.Errors[ 0 ].Message ) );
 36
 037        return null;
 38    }
 39
 40
 41    // ---- list / update / script ----
 42
 43    public async Task<int> ListAsync( CancellationToken cancellationToken )
 44    {
 945        var list = await SeedEngine.ListAsync( _session.DbContext, _configuration, _session.Provider, async: true, cance
 46
 947        if ( list.IsFailure ) { return Errors( list.Errors ); }
 48
 49
 950        var inCode = _configuration.Migrations.Result!.Select( m => m.Id ).ToHashSet( StringComparer.Ordinal );
 951        var newest = list.Result!.Where( m => m.IsApplied ).Select( m => m.Id ).Max( StringComparer.Ordinal );
 952        var table  = new Table().AddColumns( "Migration", "Status", "Applied on", "Checksum", "Note" );
 53
 4254        foreach ( var migration in list.Result! )
 55        {
 1256            var checksum = !migration.IsApplied ? "" : !inCode.Contains( migration.Id ) ? "not in code" : migration.Chec
 1257            var note     = !migration.IsApplied && newest is not null && string.CompareOrdinal( migration.Id, newest ) <
 58
 1259            table.AddRow( Markup.Escape( migration.Id ), migration.IsApplied ? "applied" : "pending",
 1260                          migration.AppliedOn?.ToString( "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture ) ?? "", ch
 61        }
 62
 963        if ( list.Result!.Count == 0 ) { _console.WriteLine( "No seed migrations." ); }
 964        else { _console.Write( table ); }
 65
 66
 967        return 0;
 968    }
 69
 70    public async Task<int> UpdateAsync( string? to, CancellationToken cancellationToken )
 71    {
 10272        var result = await SeedEngine.UpAsync( _session.DbContext, _configuration, _session.Provider, to, takeLock: true
 73
 10274        if ( result.IsFailure ) { return Errors( result.Errors ); }
 75
 76
 10277        _console.WriteLine( result.Result!.Ids.Count == 0 ? "Nothing pending." : $"Applied: {string.Join( ", ", result.R
 10278        Warnings( result.Result.Warnings );
 79
 10280        return 0;
 10281    }
 82
 83    public int Script( string? from, string? to, string output )
 84    {
 385        var migrations = SeedScriptBuilder.Build( _session.DbContext.Model, _configuration, from, to );
 86
 387        if ( migrations.IsFailure ) { return Errors( migrations.Errors ); }
 88
 89
 390        var path = Path.Combine( _designer.Root, output );
 91
 392        Directory.CreateDirectory( Path.GetDirectoryName( path )! );
 393        File.WriteAllText( path, _session.Provider.Script( migrations.Result! ), new UTF8Encoding( false ) );
 94
 395        _console.WriteLine( $"Script of {migrations.Result!.Count} seed migration(s) written to {output}." );
 96
 397        return 0;
 98    }
 99
 100
 101    // ---- down ----
 102
 103    public async Task<int> DownAsync( DownSettings settings, CancellationToken cancellationToken )
 104    {
 54105        if ( Invalid( settings ) is { } problem ) { return Errors( [ problem ] ); }
 106
 107
 54108        var mode = settings.Soft ? SeedDeleteMode.Soft : settings.Hard ? SeedDeleteMode.Hard : (SeedDeleteMode?) null;
 54109        var plan = await PlanAsync( settings, mode, force: settings.Force, cancellationToken ).ConfigureAwait( false );
 110
 54111        if ( plan is null ) { return 1; }
 112
 54113        if ( plan.Migrations.Count == 0 )
 114        {
 0115            _console.WriteLine( "Nothing to roll back." );
 116
 0117            return 0;
 118        }
 119
 120
 84121        if ( settings.Force ) { return await ForceAsync( settings, mode, plan, cancellationToken ).ConfigureAwait( false
 122
 39123        if ( plan.CanRollBack ) { return await ConfirmAndRollBackAsync( settings, mode, settings.Yes, cancellationToken 
 124
 125
 126        // Blocked: offer a soft delete, cancel, or a forced dry run (executing needs --force --execute).
 12127        if ( !_console.Profile.Capabilities.Interactive ) { return Errors( [ "Seed rows are still referenced. Run intera
 128
 6129        var choice = _console.Prompt( new TextPrompt<string>( "Rows are still referenced. Choose:" ).AddChoices( [ "soft
 130
 131        switch ( choice )
 132        {
 133            case "soft":
 3134                var soft = await PlanAsync( settings, SeedDeleteMode.Soft, force: false, cancellationToken ).ConfigureAw
 135
 3136                if ( soft is null ) { return 1; }
 137
 3138                return soft.CanRollBack
 3139                           ? await ConfirmAndRollBackAsync( settings, SeedDeleteMode.Soft, skipConfirm: false, cancellat
 3140                           : Errors( [ "Still blocked with soft deletes." ] );
 141
 142            case "force":
 0143                var forced = await PlanAsync( settings, mode, force: true, cancellationToken ).ConfigureAwait( false );
 144
 0145                if ( forced is null ) { return 1; }
 146
 0147                ForcePanel( forced );
 0148                _console.WriteLine( "Dry run: nothing changed. Run again with --force --execute to delete." );
 149
 0150                return 0;
 151
 152            default:
 3153                _console.WriteLine( "Cancelled: nothing changed." );
 154
 3155                return 1;
 156        }
 54157    }
 158
 159    private async Task<int> ForceAsync( DownSettings settings, SeedDeleteMode? mode, SeedRollbackPlan plan, Cancellation
 160    {
 30161        if ( plan.ForceRefusal is not null ) { return Errors( [ $"A forced delete is refused: {plan.ForceRefusal}" ] ); 
 162
 30163        ForcePanel( plan );
 164
 30165        if ( !settings.Execute )
 166        {
 3167            _console.WriteLine( "Dry run: nothing changed. Add --execute to delete." );
 168
 3169            return 0;
 170        }
 171
 172
 173        // The guards: never in Production, never in a protected database, only a person typing the database name.
 27174        var database = _session.DbContext.Database.GetDbConnection().Database;
 175
 36176        if ( ProductionRefusal() is { } refusal ) { return Errors( [ refusal ] ); }
 177
 21178        if ( _configuration.Options.ProtectedDatabases.Contains( database ) ) { return Errors( [ $"Refused: {database} i
 179
 21180        if ( !_console.Profile.Capabilities.Interactive ) { return Errors( [ "Refused: a forced delete needs an interact
 181
 182
 9183        var typed = _console.Prompt( new TextPrompt<string>( $"Type the database name ([red]{Markup.Escape( database )}[
 184
 12185        if ( !string.Equals( typed, database, StringComparison.Ordinal ) ) { return Errors( [ "Refused: the typed name d
 186
 187
 6188        var result = await SeedRollback.DownAsync( _session.DbContext, _configuration, _session.Provider, Steps( setting
 6189                                       .ConfigureAwait( false );
 190
 6191        return result.IsFailure ? Errors( result.Errors ) : RolledBack( result.Result! );
 30192    }
 193
 194    /// Production anywhere refuses: either variable, or the host's IHostEnvironment. None of them set counts as Product
 195    /// with no environment (dotnet dbseeder sets Development when neither variable is set, so this only bites in-app ru
 196    private string? ProductionRefusal()
 197    {
 27198        var any = false;
 199
 159200        foreach ( var variable in new[] { "DOTNET_ENVIRONMENT", "ASPNETCORE_ENVIRONMENT" } )
 201        {
 54202            var value = Environment.GetEnvironmentVariable( variable );
 203
 54204            if ( string.IsNullOrEmpty( value ) ) { continue; }
 205
 30206            if ( string.Equals( value, Production, StringComparison.OrdinalIgnoreCase ) ) { return $"Refused: {variable}
 207
 24208            any = true;
 209        }
 210
 24211        if ( _session.HostEnvironmentName() is { Length: > 0 } host )
 212        {
 6213            if ( string.Equals( host, Production, StringComparison.OrdinalIgnoreCase ) ) { return "Refused: the host's e
 214
 0215            any = true;
 216        }
 217
 21218        return any ? null : "Refused: no environment is set (DOTNET_ENVIRONMENT, ASPNETCORE_ENVIRONMENT), so it counts a
 219    }
 220
 221    private async Task<int> ConfirmAndRollBackAsync( DownSettings settings, SeedDeleteMode? mode, bool skipConfirm, Canc
 222    {
 18223        if ( !skipConfirm )
 224        {
 21225            if ( !_console.Profile.Capabilities.Interactive ) { return Errors( [ "Confirm with --yes when the console is
 226
 9227            if ( !_console.Confirm( "Roll back?", defaultValue: false ) )
 228            {
 3229                _console.WriteLine( "Cancelled: nothing changed." );
 230
 3231                return 1;
 232            }
 233        }
 234
 235
 9236        var result = await SeedRollback.DownAsync( _session.DbContext, _configuration, _session.Provider, Steps( setting
 9237                                       .ConfigureAwait( false );
 238
 9239        return result.IsFailure ? Errors( result.Errors ) : RolledBack( result.Result! );
 18240    }
 241
 242    /// The plan (a dry run), printed: steps, then blockers.
 243    private async Task<SeedRollbackPlan?> PlanAsync( DownSettings settings, SeedDeleteMode? mode, bool force, Cancellati
 244    {
 57245        var plan = await SeedRollback.PlanAsync( _session.DbContext, _configuration, _session.Provider, Steps( settings 
 57246                                     .ConfigureAwait( false );
 247
 57248        if ( plan.IsFailure )
 249        {
 0250            Errors( plan.Errors );
 251
 0252            return null;
 253        }
 254
 255
 57256        var result = plan.Result!;
 257
 57258        if ( result.Migrations.Count > 0 )
 259        {
 57260            _console.WriteLine( $"Roll back: {string.Join( ", ", result.Migrations )}" );
 261
 57262            var steps = new Table().AddColumns( "Migration", "Table", "Operation", "Rows" );
 263
 285264            foreach ( var step in result.Steps ) { steps.AddRow( Markup.Escape( step.MigrationId ), Markup.Escape( step.
 265
 57266            _console.Write( steps );
 267        }
 268
 57269        if ( result.Blockers.Count > 0 )
 270        {
 39271            var blockers = new Table().AddColumns( "Referencing table", "Column", "Rows" ).BorderColor( Color.Yellow );
 272
 195273            foreach ( var blocker in result.Blockers ) { blockers.AddRow( Markup.Escape( blocker.Table ), Markup.Escape(
 274
 39275            _console.MarkupLine( "[yellow]Blocked:[/] these rows still reference seed rows a real delete would remove." 
 39276            _console.Write( blockers );
 277        }
 278
 57279        Warnings( result.Warnings );
 280
 57281        return result;
 57282    }
 283
 284    /// The red dry-run panel: the full recursive tree, in delete order (deepest first), then the seed rows themselves.
 285    private void ForcePanel( SeedRollbackPlan plan )
 286    {
 30287        var tree = new Table().AddColumns( "Order", "Depth", "Table", "Column(s)", "References", "Rows" );
 30288        var i    = 0;
 289
 180290        foreach ( var node in plan.ForceTree )
 291        {
 60292            tree.AddRow( ( ++i ).ToString( CultureInfo.InvariantCulture ), node.Depth.ToString( CultureInfo.InvariantCul
 60293                         Markup.Escape( node.ReferencedTable ), node.Rows.ToString( CultureInfo.InvariantCulture ) );
 294        }
 295
 30296        _console.Write( new Panel( tree ) { Header = new PanelHeader( "[red bold]FORCED DELETE — every row below is dele
 30297    }
 298
 299    private int RolledBack( SeedRunResult result )
 300    {
 12301        _console.WriteLine( $"Rolled back: {string.Join( ", ", result.Ids )}" );
 12302        Warnings( result.Warnings );
 303
 12304        return 0;
 305    }
 306
 307    private static string? Invalid( DownSettings settings )
 54308        => settings switch
 54309               {
 0310                   { Soft: true, Hard: true }               => "Choose --soft or --hard, not both.",
 0311                   { Execute: true, Force: false }          => "--execute needs --force.",
 0312                   { Force: true, Soft: true }              => "--force deletes for real; it can't be combined with --so
 0313                   { Steps: not null, To: not null }        => "Choose --steps or --to, not both.",
 0314                   { Steps: < 1 }                           => "--steps must be at least 1.",
 54315                   _                                        => null
 54316               };
 317
 72318    private static int? Steps( DownSettings settings ) => settings.To is null ? settings.Steps ?? 1 : null;
 319
 320    private void Warnings( IEnumerable<Error> warnings )
 321    {
 369322        foreach ( var warning in warnings ) { _console.MarkupLine( $"[yellow]warning:[/] {Markup.Escape( SeedDesigner.De
 171323    }
 324
 3325    private int Errors( IEnumerable<Error> errors ) => Errors( errors.Select( SeedDesigner.Describe ) );
 326
 327    private int Errors( IEnumerable<string> errors )
 328    {
 165329        foreach ( var error in errors ) { _console.MarkupLine( $"[red]error:[/] {Markup.Escape( error )}" ); }
 330
 33331        return 1;
 332    }
 333}