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

Information
Class: ZhEaIsNsAaBn.DbSeeder.Design.SeedDesigner
Assembly: ZhEaIsNsAaBn.DbSeeder.Design
File(s): /home/runner/work/ZhEaIsNsAaBn.Libraries.V1/ZhEaIsNsAaBn.Libraries.V1/src/DataBasePackages/ZhEaIsNsAaBn.DbSeeder.Design/SeedDesigner.cs
Line coverage
96%
Covered lines: 174
Uncovered lines: 7
Coverable lines: 181
Total lines: 467
Line coverage: 96.1%
Branch coverage
75%
Covered branches: 98
Total branches: 130
Branch coverage: 75.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Root()100%11100%
ProfileList()100%44100%
ProfileAdd(...)80%1010100%
ProfileEdit(...)58.33%1212100%
ProfileRemove(...)50%22100%
BaselineAsync()66.66%1212100%
AddAsync()78.57%1414100%
RebuildSnapshotAsync()66.66%66100%
Validate(...)83.33%121285.71%
Check(...)100%111081.81%
IsRequired(...)100%66100%
WriteMigrationAsync()83.33%66100%
NewId(...)50%22100%
ExportAsync()75%44100%
Resolve(...)100%6691.66%
Profile(...)25%8883.33%
FolderConfiguration(...)62.5%8887.5%
Existing(...)100%11100%
Folder(...)100%11100%
ReadFolder(...)100%66100%
Write(...)100%11100%
Done(...)100%11100%
Describe(...)50%22100%

File(s)

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

#LineLine coverage
 1#region Usings
 2using System.Text.RegularExpressions;
 3#endregion
 4
 5namespace ZhEaIsNsAaBn.DbSeeder.Design;
 6
 7/// <summary>
 8/// The design-time work behind the CLI commands, on one context and one project folder: profiles, capture (<c>baseline<
 9/// <c>add</c>), <c>snapshot rebuild</c> and <c>validate</c>. Reuses the runtime's shapes, file parser and binder, so wh
 10/// exactly what the runtime accepts.
 11/// </summary>
 12internal sealed partial class SeedDesigner
 13{
 14    private readonly DbContext         _dbContext;
 15    private readonly SeedConfiguration _configuration;
 16    private readonly string            _root;
 17    private readonly SeedProfileStore  _profiles;
 18
 19
 41120    public SeedDesigner( DbContext dbContext, SeedConfiguration configuration, string root )
 21    {
 41122        _dbContext     = dbContext;
 41123        _configuration = configuration;
 41124        _root          = Path.GetFullPath( root );
 41125        _profiles      = new SeedProfileStore( _root );
 41126    }
 27
 28
 29    /// The project folder (full path).
 330    public string Root => _root;
 31
 32
 33    // ---- profiles ----
 34
 35    public DesignResult ProfileList()
 36    {
 637        var result = new DesignResult();
 38
 2139        foreach ( var profile in _profiles.Load() ) { result.Messages.Add( $"{profile.Name}: {string.Join( ", ", profile
 40
 941        if ( result.Messages.Count == 0 ) { result.Messages.Add( "No profiles. Add one with: profile add <name> --table 
 42
 43
 644        return result;
 45    }
 46
 47    public DesignResult ProfileAdd( string name, IReadOnlyList<string> tables, string? outputFolder )
 48    {
 9049        var profiles = _profiles.Load();
 50
 9351        if ( profiles.Any( p => p.Name == name ) ) { return DesignResult.Failed( $"Profile {name} already exists; use pr
 52
 8753        if ( tables.Count == 0 ) { return DesignResult.Failed( "A profile needs at least one --table." ); }
 54
 55
 8756        var profile = new SeedProfile { Name = name, Tables = tables, OutputFolder = outputFolder ?? SeedProfile.Default
 57
 9658        if ( Resolve( profile ) is { IsFailure: true } invalid ) { return DesignResult.Failed( invalid.Errors[ 0 ].Messa
 59
 60
 7861        _profiles.Save( [ ..profiles, profile ] );
 62
 7863        return Done( $"Profile {name} added." );
 64    }
 65
 66    public DesignResult ProfileEdit( string name, IReadOnlyList<string>? tables, string? outputFolder )
 67    {
 368        var profiles = _profiles.Load();
 69
 370        if ( profiles.FirstOrDefault( p => p.Name == name ) is not { } old ) { return DesignResult.Failed( $"Profile {na
 71
 72
 373        var profile = new SeedProfile
 374                          {
 375                              Name         = name,
 376                              Tables       = tables is { Count: > 0 } ? tables : old.Tables,
 377                              OutputFolder = outputFolder ?? old.OutputFolder
 378                          };
 79
 380        if ( Resolve( profile ) is { IsFailure: true } invalid ) { return DesignResult.Failed( invalid.Errors[ 0 ].Messa
 81
 82
 383        _profiles.Save( profiles.Where( p => p.Name != name ).Append( profile ) );
 84
 385        return Done( $"Profile {name} updated." );
 86    }
 87
 88    public DesignResult ProfileRemove( string name )
 89    {
 390        var profiles = _profiles.Load();
 91
 392        if ( profiles.RemoveAll( p => p.Name == name ) == 0 ) { return DesignResult.Failed( $"Profile {name} doesn't exi
 93
 94
 395        _profiles.Save( profiles );
 96
 397        return Done( $"Profile {name} removed." );
 98    }
 99
 100
 101    // ---- capture ----
 102
 103    /// The first migration for an existing database: an <c>Upsert</c> of every row of the profile's tables (plus <c>Sof
 104    /// soft-deleted now), no <c>Before</c> (nothing seeded before it). Applied to the database it came from, it changes
 105    public async Task<DesignResult> BaselineAsync( string? profileName, string name, CancellationToken cancellationToken
 106    {
 72107        var selected = Profile( profileName );
 108
 72109        if ( selected.IsFailure ) { return DesignResult.Failed( selected.Errors[ 0 ].Message ); }
 110
 111
 72112        var profile = selected.Result!;
 113
 72114        var folder   = Folder( profile );
 72115        var existing = Existing( profile );
 116
 72117        if ( existing.IsFailure ) { return DesignResult.Failed( Describe( existing.Errors[ 0 ] ) ); }
 118
 75119        if ( existing.Result!.Count > 0 ) { return DesignResult.Failed( $"{profile.OutputFolder} already has seed migrat
 120
 121
 69122        var exports = await ExportAsync( profile, cancellationToken ).ConfigureAwait( false );
 123
 69124        if ( exports.IsFailure ) { return DesignResult.Failed( exports.Errors[ 0 ].Message ); }
 125
 126
 69127        var tables = new List<(string File, DesignTable Table)>();
 128
 690129        foreach ( var export in exports.Result!.Where( e => e.Rows.Count > 0 ) )
 130        {
 276131            var table = SeedSnapshot.ToFile( export );   // every row + SoftDelete keys: exactly the baseline
 132
 276133            tables.Add( ( export.File, table ) );
 134        }
 135
 69136        if ( tables.Count == 0 ) { return DesignResult.Failed( "The profile's tables have no rows; nothing to baseline."
 137
 138
 69139        return await WriteMigrationAsync( profile, folder, name, [], tables, exports.Result!, [], cancellationToken ).Co
 72140    }
 141
 142    /// A migration from the changes in the dev database since the snapshot; writes it and updates the snapshot. No chan
 143    public async Task<DesignResult> AddAsync( string name, string? profileName, CancellationToken cancellationToken )
 144    {
 57145        var selected = Profile( profileName );
 146
 57147        if ( selected.IsFailure ) { return DesignResult.Failed( selected.Errors[ 0 ].Message ); }
 148
 149
 57150        var profile = selected.Result!;
 151
 57152        var existing = Existing( profile );
 153
 57154        if ( existing.IsFailure ) { return DesignResult.Failed( Describe( existing.Errors[ 0 ] ) ); }
 155
 156
 57157        var exports = await ExportAsync( profile, cancellationToken ).ConfigureAwait( false );
 158
 57159        if ( exports.IsFailure ) { return DesignResult.Failed( exports.Errors[ 0 ].Message ); }
 160
 161
 57162        var folder   = Folder( profile );
 57163        var tables   = new List<(string File, DesignTable Table)>();
 57164        var warnings = new List<string>();
 165
 537166        foreach ( var current in exports.Result! )
 167        {
 213168            if ( SeedSnapshot.Read( folder, current ) is not { } snapshot )
 169            {
 3170                return DesignResult.Failed( $"No snapshot for {current.Shape.EntityType.ClrType.Name}: run baseline (fir
 171            }
 172
 173
 210174            var (table, tableWarnings) = SeedDiff.Compute( snapshot, current );
 175
 210176            warnings.AddRange( tableWarnings );
 177
 267178            if ( !table.IsEmpty ) { tables.Add( ( current.File, table ) ); }
 179        }
 180
 181
 54182        if ( tables.Count == 0 )
 183        {
 6184            var nothing = Done( "No changes since the snapshot; no migration written." );
 6185            nothing.Warnings.AddRange( warnings );
 186
 6187            return nothing;
 188        }
 189
 190
 48191        return await WriteMigrationAsync( profile, folder, name, existing.Result!.Select( m => m.Id ).ToList(), tables, 
 57192    }
 193
 194    /// Regenerates <c>.snapshot/</c> from the database (e.g. after a merge conflict).
 195    public async Task<DesignResult> RebuildSnapshotAsync( string? profileName, CancellationToken cancellationToken )
 196    {
 3197        var selected = Profile( profileName );
 198
 3199        if ( selected.IsFailure ) { return DesignResult.Failed( selected.Errors[ 0 ].Message ); }
 200
 201
 3202        var profile = selected.Result!;
 203
 204
 3205        var exports = await ExportAsync( profile, cancellationToken ).ConfigureAwait( false );
 206
 3207        if ( exports.IsFailure ) { return DesignResult.Failed( exports.Errors[ 0 ].Message ); }
 208
 209
 42210        foreach ( var export in exports.Result! ) { SeedSnapshot.Write( Folder( profile ), export ); }
 211
 3212        return Done( $"Snapshot of {exports.Result!.Count} table(s) rebuilt in {Path.Combine( profile.OutputFolder, Seed
 3213    }
 214
 215
 216    // ---- validate ----
 217
 218    /// Every data file against the model, as the runtime would bind it, plus what only a tool can check up front: <c>De
 219    /// without a <c>Before</c> row and <c>Upsert</c> rows missing a required property. Errors → exit code 1 (for CI).
 220    public DesignResult Validate( string? folder )
 221    {
 9222        var result  = new DesignResult();
 9223        var folders = folder is not null ? [ folder ] : _profiles.Load().Select( p => p.OutputFolder ).Distinct( StringC
 9224        var count   = 0;
 225
 226
 36227        foreach ( var relative in folders )
 228        {
 9229            var parsed = SeedMigrationSource.Parse( ReadFolder( relative ), relative, _configuration.ContextKey );
 230
 9231            if ( parsed.IsFailure )
 232            {
 0233                result.Errors.Add( Describe( parsed.Errors[ 0 ] ) );
 234
 0235                continue;
 236            }
 237
 238
 60239            foreach ( var migration in parsed.Result! )
 240            {
 21241                count++;
 242
 78243                foreach ( var error in Check( migration ) ) { result.Errors.Add( $"{migration.Id}: {error}" ); }
 244            }
 245        }
 246
 247
 9248        result.Warnings.AddRange( _configuration.Warnings );
 9249        result.Messages.Add( result.Errors.Count == 0 ? $"{count} seed migration(s) valid." : $"{result.Errors.Count} er
 250
 9251        return result;
 252    }
 253
 254
 255    // ---- helpers ----
 256
 257    private List<string> Check( SeedMigrationFiles migration )
 258    {
 259        LayerResponse<IReadOnlyList<SeedBoundTable>> bound;
 260
 42261        try { bound = SeedMigrationBinder.Bind( _dbContext.Model, _configuration, migration ); }
 0262        catch ( InvalidOperationException ex ) { return [ ex.Message ]; }   // a seed configuration that can never work
 263
 27264        if ( bound.IsFailure ) { return [ Describe( bound.Errors[ 0 ] ) ]; }
 265
 266
 15267        var errors = new List<string>();
 268
 18269        if ( SeedRollback.MissingBefore( bound.Result! ) is { } missing ) { errors.Add( Describe( missing ) ); }
 270
 96271        foreach ( var table in bound.Result!.Where( t => t.UpsertRows.Count > 0 ) )
 272        {
 33273            var written = table.UpsertColumns.Select( c => c.PropertyName ).ToHashSet( StringComparer.Ordinal );
 274
 72275            foreach ( var column in SeedEntityShape.For( table.EntityType, _configuration ).DataColumns.Where( c => IsRe
 276            {
 3277                errors.Add( $"{table.File}: Upsert rows miss the required property {column.PropertyName}." );
 278            }
 279        }
 280
 281
 15282        return errors;
 0283    }
 284
 285    private static bool IsRequired( SeedColumn column )
 105286        => !column.IsNullable && column.Property.ValueGenerated == ValueGenerated.Never && column.Property.GetDefaultVal
 105287        && column.Property.GetComputedColumnSql() is null;
 288
 289    private async Task<DesignResult> WriteMigrationAsync(
 290            SeedProfile profile,
 291            string folder,
 292            string name,
 293            IReadOnlyList<string> existingIds,
 294            List<(string File, DesignTable Table)> tables,
 295            IReadOnlyList<ExportedTable> exports,
 296            IReadOnlyList<string> warnings,
 297            CancellationToken cancellationToken
 298        )
 299    {
 117300        if ( !NamePattern().IsMatch( name ) ) { return DesignResult.Failed( $"'{name}' isn't a valid migration name (let
 301
 302
 117303        var id       = NewId( name, existingIds );
 117304        var applied  = await _dbContext.Database.GetAppliedMigrationsAsync( cancellationToken ).ConfigureAwait( false );
 117305        var manifest = new SeedManifest
 117306                           {
 117307                               Id                      = id,
 117308                               ContextType             = _configuration.ContextKey,
 117309                               RequiresSchemaMigration = applied.LastOrDefault(),
 117310                               Tables                  = tables.Select( t => t.File ).Order( StringComparer.Ordinal ).To
 117311                           };
 312
 117313        var directory = Path.Combine( folder, id );
 117314        Directory.CreateDirectory( directory );
 315
 117316        Write( Path.Combine( directory, SeedChecksum.ManifestFile ), SeedFileWriter.Manifest( manifest ) );
 317
 1233318        foreach ( var (file, table) in tables ) { Write( Path.Combine( directory, file ), SeedFileWriter.Table( table ) 
 319
 1638320        foreach ( var export in exports ) { SeedSnapshot.Write( folder, export ); }
 321
 322
 117323        var result = new DesignResult { MigrationId = id };
 117324        result.Warnings.AddRange( warnings );
 117325        result.Warnings.AddRange( _configuration.Warnings );
 117326        result.Messages.Add( $"Seed migration {id} written to {Path.Combine( profile.OutputFolder, id )}:" );
 117327        result.Messages.AddRange( tables.Select( t => $"  {t.File}: {t.Table.Upsert.Count} upsert, {t.Table.Delete.Count
 328
 117329        return result;
 117330    }
 331
 332    /// <c>yyyyMMddHHmmss_Name</c> from the clock (UTC, like EF); always after the newest existing id.
 333    private string NewId( string name, IReadOnlyList<string> existingIds )
 334    {
 117335        var now    = _configuration.Options.TimeProvider.GetUtcNow().UtcDateTime;
 117336        var newest = existingIds.Select( i => DateTime.ParseExact( i[ ..14 ], "yyyyMMddHHmmss", CultureInfo.InvariantCul
 337
 117338        if ( now <= newest ) { now = newest.AddSeconds( 1 ); }
 339
 340
 117341        return $"{now.ToString( "yyyyMMddHHmmss", CultureInfo.InvariantCulture )}_{name}";
 342    }
 343
 344    private async Task<LayerResponse<IReadOnlyList<ExportedTable>>> ExportAsync( SeedProfile profile, CancellationToken 
 345    {
 129346        var shapes = Resolve( profile );
 347
 129348        if ( shapes.IsFailure ) { return LayerResponse<IReadOnlyList<ExportedTable>>.Failure( shapes ); }
 349
 350
 129351        var exports = new List<ExportedTable>();
 352
 1788353        foreach ( var shape in shapes.Result! ) { exports.Add( await SeedExporter.ExportAsync( _dbContext, shape, cancel
 354
 129355        return exports;
 129356    }
 357
 358    /// The profile's tables → entity shapes (by full or short entity name, or table name; case-insensitive).
 359    private LayerResponse<IReadOnlyList<SeedEntityShape>> Resolve( SeedProfile profile )
 360    {
 219361        var shapes = new List<SeedEntityShape>();
 362
 2103363        foreach ( var name in profile.Tables )
 364        {
 837365            var matches = _dbContext.Model.GetEntityTypes()
 837366                                    .Where( t => !t.IsOwned()
 837367                                              && ( string.Equals( t.Name, name, StringComparison.OrdinalIgnoreCase ) || 
 837368                                                || string.Equals( t.GetTableName(), name, StringComparison.OrdinalIgnore
 837369                                    .ToList();
 370
 846371            if ( matches.Count != 1 ) { return Fail( matches.Count == 0 ? $"Table {name} isn't in the model." : $"Table 
 372
 373
 1656374            try { shapes.Add( SeedEntityShape.For( matches[ 0 ], _configuration ) ); }
 0375            catch ( InvalidOperationException ex ) { return Fail( $"{name}: {ex.Message}" ); }
 376        }
 377
 378
 210379        return shapes;
 380
 381
 382        static Error Fail( string message ) => new( "InvalidProfile", message, message );
 9383    }
 384
 385    private LayerResponse<SeedProfile> Profile( string? name )
 386    {
 132387        var profiles = _profiles.Load();
 388
 132389        if ( name is not null )
 390        {
 0391            return profiles.FirstOrDefault( p => p.Name == name ) is { } found ? found : new Error( "ProfileNotFound", $
 392        }
 393
 132394        return profiles.Count == 1
 132395                   ? profiles[ 0 ]
 132396                   : new Error( "ProfileRequired", "Choose a profile with --profile.", profiles.Count == 0 ? "No profile
 397    }
 398
 399    /// <summary>
 400    /// The seed configuration reading its migrations from a folder on disk (the operate commands work on the files, not
 401    /// build's embedded copy). Folder: <paramref name="folder"/>, else the profiles' one folder, else <c>SeedMigrations
 402    /// </summary>
 403    public LayerResponse<SeedConfiguration> FolderConfiguration( string? folder )
 404    {
 168405        var folders = _profiles.Load().Select( p => p.OutputFolder ).Distinct( StringComparer.Ordinal ).ToList();
 406
 168407        if ( folder is null && folders.Count > 1 )
 408        {
 0409            return new Error( "FolderRequired", "Choose a folder with --folder.", $"Profiles use several folders ({strin
 410        }
 411
 412
 168413        var relative = folder ?? folders.SingleOrDefault() ?? SeedProfile.DefaultOutputFolder;
 168414        var prefix   = _configuration.Options.MigrationsFolder.Replace( '\\', '/' ).Trim( '/' );
 168415        var source   = relative.Replace( '\\', '/' ).Trim( '/' );
 416
 417        // Keyed under the configured folder name, as the embedded resources are.
 168418        var files = ReadFolder( relative ).ToDictionary( f => prefix + f.Key[ source.Length.. ], f => f.Value, StringCom
 419
 168420        return _configuration.WithFiles( files );
 421    }
 422
 423    private LayerResponse<IReadOnlyList<SeedMigrationFiles>> Existing( SeedProfile profile )
 129424        => SeedMigrationSource.Parse( ReadFolder( profile.OutputFolder ), profile.OutputFolder, _configuration.ContextKe
 425
 141426    private string Folder( SeedProfile profile ) => Path.Combine( _root, profile.OutputFolder );
 427
 428    /// The migration files of a folder, keyed as the runtime names embedded resources (<c>{folder}/{id}/{file}</c>); <c
 429    private Dictionary<string, string> ReadFolder( string relative )
 430    {
 306431        var folder = Path.Combine( _root, relative );
 306432        var prefix = relative.Replace( '\\', '/' ).Trim( '/' );
 306433        var files  = new Dictionary<string, string>( StringComparer.Ordinal );
 434
 378435        if ( !Directory.Exists( folder ) ) { return files; }
 436
 437
 5082438        foreach ( var path in Directory.EnumerateFiles( folder, "*.json", SearchOption.AllDirectories ) )
 439        {
 2307440            var name = Path.GetRelativePath( folder, path ).Replace( '\\', '/' );
 441
 3696442            if ( !name.StartsWith( SeedSnapshot.Folder + "/", StringComparison.Ordinal ) ) { files[ $"{prefix}/{name}" ]
 443        }
 444
 445
 234446        return files;
 447    }
 448
 450449    private static void Write( string path, string text ) => File.WriteAllText( path, text, new UTF8Encoding( false ) );
 450
 451    private static DesignResult Done( string message )
 452    {
 93453        var result = new DesignResult();
 93454        result.Messages.Add( message );
 455
 93456        return result;
 457    }
 458
 459    /// <c>Code: key=value, …</c> from an error's data, for the console.
 460    public static string Describe( Error error )
 21461        => error.AdditionalData.Count == 0
 21462               ? $"{error.Code}: {error.Message}"
 21463               : $"{error.Code}: {string.Join( ", ", error.AdditionalData.Select( d => $"{d.Key}={d.Value}" ) )}";
 464
 465    [ GeneratedRegex( "^[A-Za-z][A-Za-z0-9_]*$", RegexOptions.CultureInvariant ) ]
 466    private static partial Regex NamePattern();
 467}