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

Information
Line coverage
99%
Covered lines: 189
Uncovered lines: 1
Coverable lines: 190
Total lines: 530
Line coverage: 99.4%
Branch coverage
86%
Covered branches: 93
Total branches: 108
Branch coverage: 86.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
DownAsync()100%22100%
PlanAsync(...)100%11100%
RunAsync(...)75%44100%
BodyAsync()83.33%303094.87%
RollBackOneAsync()85.48%6262100%
MissingBefore(...)100%44100%
Keys(...)100%11100%
MatchValues(...)100%11100%
Audit(...)100%11100%
WithDetails(...)100%22100%
Block(...)50%44100%
Reset()100%11100%

File(s)

/home/runner/work/ZhEaIsNsAaBn.Libraries.V1/ZhEaIsNsAaBn.Libraries.V1/src/DataBasePackages/ZhEaIsNsAaBn.DbSeeder/Running/SeedRollback.cs

#LineLine coverage
 1#region Usings
 2using System.Data;
 3using System.Diagnostics;
 4#endregion
 5
 6namespace ZhEaIsNsAaBn.DbSeeder;
 7
 8/// <summary>
 9/// Rolling seed migrations back (§6.3), newest first, from their <c>Before</c> images. All selected migrations run in <
 10/// transaction: a blocker anywhere (rows still referenced) means nothing changes. A plan is the same run, always rolled
 11/// <list type="number">
 12/// <item>Per table, principals first: rows the migration changed or deleted get their <c>Before</c> values back (update
 13/// again); rows it deleted or soft-deleted lose the flag.</item>
 14/// <item>Per table, dependents first: rows it inserted are removed (delete mode: run override → entity setting → <c>Aut
 15/// delete is FK-checked); rows it restored are soft-deleted again.</item>
 16/// <item>The history row is removed; an audit row (<c>Down</c>, or <c>SoftDown</c> when inserted rows were soft-deleted
 17/// </list>
 18/// </summary>
 19internal static class SeedRollback
 20{
 21    private const string Down     = nameof( Down );
 22    private const string SoftDown = nameof( SoftDown );
 23    private const string ForceDown = nameof( ForceDown );
 24
 25
 26    /// Rolls back <paramref name="steps"/> migrations, or every migration newer than <paramref name="targetMigrationId"
 27    public static async Task<LayerResponse<SeedRunResult>> DownAsync(
 28            DbContext dbContext,
 29            SeedConfiguration configuration,
 30            ISeedDatabaseProvider provider,
 31            int? steps,
 32            string? targetMigrationId,
 33            SeedDeleteMode? deleteMode,
 34            bool async,
 35            CancellationToken cancellationToken,
 36            bool force = false
 37        )
 38    {
 5239        var run = await RunAsync( dbContext, configuration, provider, steps, targetMigrationId, deleteMode, dryRun: fals
 40
 4941        return run.IsFailure ? LayerResponse<SeedRunResult>.Failure( run ) : new SeedRunResult { Ids = run.Result!.Migra
 4942    }
 43
 44    /// What <see cref="DownAsync"/> would do (a dry run, rolled back).
 45    public static Task<LayerResponse<SeedRollbackPlan>> PlanAsync(
 46            DbContext dbContext,
 47            SeedConfiguration configuration,
 48            ISeedDatabaseProvider provider,
 49            int? steps,
 50            string? targetMigrationId,
 51            SeedDeleteMode? deleteMode,
 52            bool async,
 53            CancellationToken cancellationToken,
 54            bool force = false
 55        )
 6556        => RunAsync( dbContext, configuration, provider, steps, targetMigrationId, deleteMode, dryRun: true, force, asyn
 57
 58
 59    private static Task<LayerResponse<SeedRollbackPlan>> RunAsync(
 60            DbContext dbContext,
 61            SeedConfiguration configuration,
 62            ISeedDatabaseProvider provider,
 63            int? steps,
 64            string? targetMigrationId,
 65            SeedDeleteMode? deleteMode,
 66            bool dryRun,
 67            bool force,
 68            bool async,
 69            CancellationToken cancellationToken
 70        )
 71    {
 11772        if ( dbContext.Database.CurrentTransaction is not null )
 73        {
 374            throw new InvalidOperationException( "Seed rollbacks run in their own transaction: call them with no transac
 75        }
 76
 11477        if ( configuration.Migrations.IsFailure ) { return Task.FromResult( LayerResponse<SeedRollbackPlan>.Failure( con
 78
 79
 11480        return SeedEngine.SessionAsync( dbContext,
 11481                                        configuration,
 11482                                        provider,
 11483                                        takeLock: true,
 11484                                        () => BodyAsync( dbContext, configuration, provider, steps, targetMigrationId, d
 11485                                        async,
 11486                                        cancellationToken );
 87    }
 88
 89    private static async Task<LayerResponse<SeedRollbackPlan>> BodyAsync(
 90            DbContext dbContext,
 91            SeedConfiguration configuration,
 92            ISeedDatabaseProvider provider,
 93            int? steps,
 94            string? targetMigrationId,
 95            SeedDeleteMode? deleteMode,
 96            bool dryRun,
 97            bool force,
 98            bool async,
 99            CancellationToken cancellationToken
 100        )
 101    {
 114102        var embedded = configuration.Migrations.Result!.ToDictionary( m => m.Id, StringComparer.Ordinal );
 114103        var history  = await provider.ReadHistoryAsync( dbContext, configuration.ContextKey, async, cancellationToken ).
 114104        var applied  = history.OrderByDescending( h => h.MigrationId, StringComparer.Ordinal ).ToList();
 105
 106
 114107        if ( targetMigrationId is not null && !embedded.ContainsKey( targetMigrationId ) && applied.All( h => h.Migratio
 108        {
 4109            return SeedErrors.TargetNotFound( targetMigrationId );
 110        }
 111
 110112        var selected = targetMigrationId is null
 110113                           ? applied.Take( steps ?? 0 ).ToList()
 110114                           : applied.Where( h => string.CompareOrdinal( h.MigrationId, targetMigrationId ) > 0 ).ToList(
 115
 116
 117        // Bind everything before touching data: a missing migration or a file that can't be rolled back stops here.
 110118        var migrations = new List<(SeedMigrationFiles Files, IReadOnlyList<SeedBoundTable> Tables)>();
 110119        var warnings   = new List<Error>();
 120
 433121        foreach ( var row in selected )
 122        {
 114123            if ( !embedded.TryGetValue( row.MigrationId, out var files ) ) { return SeedErrors.MigrationNotFound( row.Mi
 124
 106125            if ( files.Checksum != row.Checksum ) { warnings.Add( SeedErrors.ChecksumMismatch( files.Id ) ); }
 126
 127
 106128            var bound = SeedMigrationBinder.Bind( dbContext.Model, configuration, files );
 129
 106130            if ( bound.IsFailure ) { return LayerResponse<SeedRollbackPlan>.Failure( bound ); }
 131
 109132            if ( MissingBefore( bound.Result! ) is { } missing ) { return missing; }
 133
 103134            migrations.Add( ( files, bound.Result! ) );
 135        }
 136
 137
 103138        var run = new RunState();
 139
 103140        if ( migrations.Count > 0 )
 141        {
 142            try
 143            {
 103144                var strategy = dbContext.Database.CreateExecutionStrategy();
 103145                var result = async
 103146                                 ? await strategy.ExecuteAsync( token => InTransactionAsync( true, token ), cancellation
 103147                                 : strategy.Execute( () => SyncPath.Result( InTransactionAsync( false, cancellationToken
 148
 104149                if ( result.IsFailure ) { return await FailedAsync( result.Errors ).ConfigureAwait( false ); }
 96150            }
 0151            catch ( OperationCanceledException ) { throw; }
 6152            catch ( Exception ex ) { return await FailedAsync( [ provider.Classify( ex ) ?? Error.Unknown( ex ) ] ).Conf
 153
 96154            if ( !dryRun && run.Blockers.Count > 0 )
 155            {
 4156                return await FailedAsync( [ SeedErrors.RowsReferenced( run.BlockedTable!, run.Blockers ) ] ).ConfigureAw
 157            }
 158        }
 159
 160
 92161        return new SeedRollbackPlan
 92162                   {
 92163                       Migrations   = migrations.Select( m => m.Files.Id ).ToList(),
 92164                       Steps        = run.Steps,
 92165                       Blockers     = run.Blockers,
 92166                       Warnings     = warnings,
 92167                       ForceTree    = run.ForceTree,
 92168                       ForceRefusal = run.ForceRefusal
 92169                   };
 170
 171
 172        async Task<LayerResponse> InTransactionAsync( bool runAsync, CancellationToken token )
 173        {
 174            run.Reset();
 175
 176            var transaction = runAsync
 177                                  ? await dbContext.Database.BeginTransactionAsync( IsolationLevel.ReadCommitted, token 
 178                                  : dbContext.Database.BeginTransaction( IsolationLevel.ReadCommitted );
 179
 180            try
 181            {
 182                foreach ( var (files, tables) in migrations )
 183                {
 184                    run.Current = files.Id;
 185
 186                    var one = await RollBackOneAsync( dbContext, configuration, provider, files, tables, deleteMode, run
 187
 188                    if ( one.IsFailure ) { return one; }
 189                }
 190
 191
 192                // A plan and a blocked rollback change nothing: disposing the transaction rolls it back.
 193                if ( dryRun || run.Blockers.Count > 0 ) { return LayerResponse.Success(); }
 194
 195                if ( runAsync ) { await transaction.CommitAsync( token ).ConfigureAwait( false ); }
 196                else { transaction.Commit(); }
 197
 198                return LayerResponse.Success();
 199            }
 200            finally
 201            {
 202                if ( runAsync ) { await transaction.DisposeAsync().ConfigureAwait( false ); }
 203                else { transaction.Dispose(); }
 204            }
 205        }
 206
 207        // The transaction was rolled back; a real rollback records the failure of the migration it stopped at.
 208        async Task<LayerResponse<SeedRollbackPlan>> FailedAsync( List<Error> errors )
 209        {
 210            if ( dryRun || ( run.BlockedMigration ?? run.Current ) is not { } failed ) { return errors; }
 211
 212
 213            var audit = await SeedEngine.TryAsync( provider,
 214                                                   () => provider.WriteAuditAsync( dbContext, Audit( configuration, fail
 215                                                                                   async, cancellationToken ) )
 216                                        .ConfigureAwait( false );
 217
 218            if ( audit is not null ) { errors.Add( audit ); }
 219
 220            return errors;
 221        }
 114222    }
 223
 224    /// One migration, newest first. Blockers are collected (no delete for them) so a plan lists them all.
 225    private static async Task<LayerResponse> RollBackOneAsync(
 226            DbContext dbContext,
 227            SeedConfiguration configuration,
 228            ISeedDatabaseProvider provider,
 229            SeedMigrationFiles files,
 230            IReadOnlyList<SeedBoundTable> tables,
 231            SeedDeleteMode? deleteMode,
 232            RunState run,
 233            bool dryRun,
 234            bool force,
 235            bool async,
 236            CancellationToken cancellationToken
 237        )
 238    {
 103239        using var activity = DbSeederDiagnostics.ActivitySource.StartActivity( "Roll back seed migration" );
 103240        activity?.SetTag( "MigrationId", files.Id );
 241
 103242        var counts    = new MigrationCounts();
 103243        var writer    = new SeedRowWriter( configuration.Options, SeedEngine.Now( configuration ) );
 103244        var startedOn = SeedEngine.Now( configuration );
 103245        var usedSoft  = false;
 103246        var forced    = new List<SeedForceDeleted>();
 247
 103248        run.StartedOn = startedOn;
 249
 250
 251        // 1. Put back changed / deleted rows, principals first; then clear the flag of rows the migration (soft-)delete
 526252        foreach ( var table in tables )
 253        {
 160254            var touched = table.KeysOf( table.UpsertColumns, table.UpsertRows ).Concat( table.KeysOf( table.DeleteKeys )
 160255            var putBack = table.BeforeRows.Zip( table.KeysOf( table.BeforeColumns, table.BeforeRows ) ).Where( b => touc
 256
 160257            if ( putBack.Count > 0 )
 258            {
 78259                var writes = writer.Upserts( table, table.BeforeColumns, putBack, files.Id );
 260
 78261                if ( writes.IsFailure ) { return writes; }
 262
 263
 78264                var rows = 0;
 265
 312266                foreach ( var write in writes.Result! )
 267                {
 78268                    var written = await provider.UpsertAsync( dbContext, write, async, cancellationToken ).ConfigureAwai
 269
 78270                    counts.Inserted += written.Inserted;
 78271                    counts.Updated  += written.Updated;
 78272                    rows            += written.Inserted + written.Updated;
 273                }
 274
 78275                run.Steps.Add( new SeedRollbackStep( files.Id, table.Table.DisplayName, SeedRollbackOperation.PutBack, r
 276            }
 277
 278
 160279            var undelete = table.DeleteKeys.Concat( table.SoftDeleteKeys ).ToList();
 280
 160281            if ( undelete.Count > 0 && table.Table.SoftDeleteFlag is not null )
 282            {
 27283                var values = writer.RestoreValues( table );
 284
 27285                if ( values.IsFailure ) { return values; }
 286
 287
 27288                var restored = await provider.RestoreAsync( dbContext, Keys( table, undelete ), values.Result!, async, c
 289
 27290                counts.Updated += restored;
 27291                run.Steps.Add( new SeedRollbackStep( files.Id, table.Table.DisplayName, SeedRollbackOperation.Undelete, 
 292            }
 160293        }
 294
 295
 296        // 2. Remove inserted rows and soft-delete restored rows, dependents first.
 519297        foreach ( var table in tables.Reverse() )
 298        {
 160299            var before   = table.KeysOf( table.BeforeColumns, table.BeforeRows ).ToHashSet( StringComparer.Ordinal );
 160300            var inserted = table.UpsertRows.Zip( table.KeysOf( table.UpsertColumns, table.UpsertRows ) ).Where( r => !be
 301
 160302            if ( inserted.Count > 0 )
 303            {
 126304                var mode = deleteMode ?? table.Roles.DeleteMode;
 126305                var soft = mode == SeedDeleteMode.Soft || ( mode == SeedDeleteMode.Auto && table.Table.SoftDeleteFlag is
 306
 126307                if ( soft )
 308                {
 43309                    var removed = await SoftDeleteAsync( table, inserted ).ConfigureAwait( false );
 310
 47311                    if ( removed.IsFailure ) { return removed; }
 312
 39313                    usedSoft = true;
 314                }
 315                else
 316                {
 83317                    var keys     = Keys( table, inserted );
 83318                    var blockers = await provider.CountReferencesAsync( dbContext, keys, async, cancellationToken ).Conf
 319
 83320                    if ( blockers.Count > 0 && force && !dryRun )
 321                    {
 322                        // Design-time force: the dependent rows go first, bottom-up, in this same transaction.
 6323                        forced.AddRange( await provider.ForceDeleteAsync( dbContext, keys, async, cancellationToken ).Co
 6324                        blockers = [];
 325                    }
 326
 83327                    if ( blockers.Count > 0 )
 328                    {
 47329                        run.Block( table.Table.DisplayName, blockers );
 47330                        run.Steps.Add( new SeedRollbackStep( files.Id, table.Table.DisplayName, SeedRollbackOperation.De
 331
 47332                        if ( force )
 333                        {
 30334                            var tree = await provider.PlanForceDeleteAsync( dbContext, keys, async, cancellationToken ).
 335
 30336                            run.ForceTree.AddRange( tree.Nodes );
 30337                            run.ForceRefusal ??= tree.Refusal;
 338                        }
 339                    }
 340                    else
 341                    {
 36342                        var deleted = await provider.DeleteAsync( dbContext, keys, async, cancellationToken ).ConfigureA
 343
 33344                        counts.Deleted += deleted;
 33345                        run.Steps.Add( new SeedRollbackStep( files.Id, table.Table.DisplayName, SeedRollbackOperation.De
 346                    }
 80347                }
 348            }
 349
 350
 153351            if ( table.RestoreKeys.Count > 0 )
 352            {
 7353                var again = await SoftDeleteAsync( table, table.RestoreKeys ).ConfigureAwait( false );
 354
 7355                if ( again.IsFailure ) { return again; }
 356            }
 153357        }
 358
 359
 96360        await provider.DeleteHistoryAsync( dbContext, files.Id, configuration.ContextKey, async, cancellationToken ).Con
 361
 96362        if ( !dryRun && run.Blockers.Count == 0 )
 363        {
 27364            var audit = Audit( configuration, files.Id, forced.Count > 0 ? ForceDown : usedSoft ? SoftDown : Down, start
 365
 27366            await provider.WriteAuditAsync( dbContext, forced.Count > 0 ? WithDetails( audit, forced ) : audit, async, c
 367        }
 368
 369
 96370        activity?.SetTag( "RowsInserted", counts.Inserted );
 96371        activity?.SetTag( "RowsUpdated", counts.Updated );
 96372        activity?.SetTag( "RowsDeleted", counts.Deleted );
 96373        activity?.SetStatus( ActivityStatusCode.Ok );
 374
 96375        return LayerResponse.Success();
 376
 377
 378        async Task<LayerResponse> SoftDeleteAsync( SeedBoundTable table, IReadOnlyList<IReadOnlyList<object?>> keyRows )
 379        {
 380            if ( table.Table.SoftDeleteFlag is null ) { return SeedErrors.SoftDeleteNotSupported( table.EntityType.Name 
 381
 382
 383            var values = writer.SoftDeleteValues( table );
 384
 385            if ( values.IsFailure ) { return values; }
 386
 387
 388            var removed = await provider.SoftDeleteAsync( dbContext, Keys( table, keyRows ), values.Result!, async, canc
 389
 390            counts.Deleted += removed;
 391            run.Steps.Add( new SeedRollbackStep( files.Id, table.Table.DisplayName, SeedRollbackOperation.SoftDelete, re
 392
 393            return LayerResponse.Success();
 394        }
 100395    }
 396
 397    /// A <c>Delete</c> key with no <c>Before</c> row can't be put back.
 398    internal static Error? MissingBefore( IReadOnlyList<SeedBoundTable> tables )
 399    {
 350400        foreach ( var table in tables.Where( t => t.DeleteKeys.Count > 0 ) )
 401        {
 57402            var before = table.KeysOf( table.BeforeColumns, table.BeforeRows ).ToHashSet( StringComparer.Ordinal );
 403
 57404            if ( table.KeysOf( table.DeleteKeys ).Any( k => !before.Contains( k ) ) )
 405            {
 6406                return SeedErrors.InvalidDataFile( table.File, "A Delete row has no Before row, so the migration can't b
 407            }
 408        }
 409
 115410        return null;
 6411    }
 412
 156413    private static SeedTableKeys Keys( SeedBoundTable table, IReadOnlyList<IReadOnlyList<object?>> keyRows ) => new() { 
 414
 415    /// The match-key values of a full upsert row.
 416    private static List<object?> MatchValues( SeedBoundTable table, IReadOnlyList<object?> row )
 126417        => table.Table.MatchColumns.Select( m => row[ table.UpsertColumns.Select( c => c.ColumnName ).ToList().IndexOf( 
 418
 419    private static SeedAuditRow Audit( SeedConfiguration configuration, string migrationId, string operation, DateTime s
 38420        => new()
 38421               {
 38422                   MigrationId  = migrationId,
 38423                   ContextKey   = configuration.ContextKey,
 38424                   Operation    = operation,
 38425                   StartedOn    = startedOn,
 38426                   FinishedOn   = SeedEngine.Now( configuration ),
 38427                   Succeeded    = errorCode is null,
 38428                   ErrorCode    = errorCode,
 38429                   RowsInserted = counts.Inserted,
 38430                   RowsUpdated  = counts.Updated,
 38431                   RowsDeleted  = counts.Deleted,
 38432                   OsUser       = Environment.UserName,
 38433                   MachineName  = Environment.MachineName,
 38434                   ToolVersion  = DbSeederDiagnostics.Version
 38435               };
 436
 437
 438    /// The audit row of a forced rollback carries the deleted dependent rows (JSON; may hold personal data).
 439    private static SeedAuditRow WithDetails( SeedAuditRow audit, IReadOnlyList<SeedForceDeleted> forced )
 440    {
 3441        using var stream = new MemoryStream();
 442
 3443        using ( var writer = new Utf8JsonWriter( stream ) )
 444        {
 3445            writer.WriteStartObject();
 3446            writer.WriteStartArray( "Tables" );
 447
 18448            foreach ( var table in forced )
 449            {
 6450                writer.WriteStartObject();
 6451                writer.WriteString( "Table", table.Table );
 6452                writer.WriteNumber( "Rows", table.Rows );
 6453                writer.WritePropertyName( "Deleted" );
 6454                writer.WriteRawValue( table.RowsJson );
 6455                writer.WriteEndObject();
 456            }
 457
 3458            writer.WriteEndArray();
 3459            writer.WriteEndObject();
 3460        }
 461
 462
 3463        return new SeedAuditRow
 3464                   {
 3465                       MigrationId  = audit.MigrationId,
 3466                       ContextKey   = audit.ContextKey,
 3467                       Operation    = audit.Operation,
 3468                       StartedOn    = audit.StartedOn,
 3469                       FinishedOn   = audit.FinishedOn,
 3470                       Succeeded    = audit.Succeeded,
 3471                       ErrorCode    = audit.ErrorCode,
 3472                       RowsInserted = audit.RowsInserted,
 3473                       RowsUpdated  = audit.RowsUpdated,
 3474                       RowsDeleted  = audit.RowsDeleted + forced.Sum( f => f.Rows ),
 3475                       OsUser       = audit.OsUser,
 3476                       MachineName  = audit.MachineName,
 3477                       ToolVersion  = audit.ToolVersion,
 3478                       Details      = System.Text.Encoding.UTF8.GetString( stream.ToArray() )
 3479                   };
 3480    }
 481
 482
 483    private sealed class MigrationCounts
 484    {
 485        public int Inserted { get; set; }
 486
 487        public int Updated { get; set; }
 488
 489        public int Deleted { get; set; }
 490    }
 491
 492    /// Steps and blockers of the whole run (restart with a retried transaction).
 493    private sealed class RunState
 494    {
 495        public List<SeedRollbackStep> Steps { get; } = [];
 496
 497        public List<SeedBlocker> Blockers { get; } = [];
 498
 499        public List<SeedForceNode> ForceTree { get; } = [];
 500
 501        public string? ForceRefusal { get; set; }
 502
 503        public string? BlockedTable { get; private set; }
 504
 505        public string? BlockedMigration { get; private set; }
 506
 507        public string? Current { get; set; }
 508
 509        public DateTime StartedOn { get; set; }
 510
 511
 512        public void Block( string table, IEnumerable<SeedBlocker> blockers )
 513        {
 47514            BlockedTable     ??= table;
 47515            BlockedMigration ??= Current;
 47516            Blockers.AddRange( blockers );
 47517        }
 518
 519        public void Reset()
 520        {
 103521            Steps.Clear();
 103522            Blockers.Clear();
 103523            ForceTree.Clear();
 103524            ForceRefusal     = null;
 103525            BlockedTable     = null;
 103526            BlockedMigration = null;
 103527            Current          = null;
 103528        }
 529    }
 530}