| | | 1 | | namespace ZhEaIsNsAaBn.DbSeeder; |
| | | 2 | | |
| | | 3 | | /// <summary> |
| | | 4 | | /// Binds a parsed migration to a context's EF model: finds each entity and table, resolves the match key and the manage |
| | | 5 | | /// checks every row against the model and converts values to provider values, then orders the tables (§6.4). |
| | | 6 | | /// </summary> |
| | | 7 | | internal static class SeedMigrationBinder |
| | | 8 | | { |
| | | 9 | | /// <returns>The tables in upsert order (principals first); otherwise <c>InvalidDataFile</c>, <c>UnknownProperty</c> |
| | | 10 | | /// <exception cref="InvalidOperationException">A configured <c>MatchBy</c> isn't the primary key or a unique index. |
| | | 11 | | public static LayerResponse<IReadOnlyList<SeedBoundTable>> Bind( IModel model, SeedConfiguration configuration, Seed |
| | | 12 | | { |
| | 557 | 13 | | var tables = new List<SeedBoundTable>(); |
| | | 14 | | |
| | | 15 | | |
| | 3715 | 16 | | foreach ( var (_, path, file) in migration.Tables ) |
| | | 17 | | { |
| | 1326 | 18 | | var bound = BindTable( model, configuration, path, file ); |
| | | 19 | | |
| | 1371 | 20 | | if ( bound.IsFailure ) { return LayerResponse<IReadOnlyList<SeedBoundTable>>.Failure( bound ); } |
| | | 21 | | |
| | 1275 | 22 | | if ( tables.Any( t => t.EntityType == bound.Result!.EntityType ) ) |
| | | 23 | | { |
| | 0 | 24 | | return SeedErrors.InvalidDataFile( path, $"{file.Entity} has more than one table file in {migration.Id}. |
| | | 25 | | } |
| | | 26 | | |
| | 1275 | 27 | | tables.Add( bound.Result! ); |
| | | 28 | | } |
| | | 29 | | |
| | | 30 | | |
| | | 31 | | // v1: a table matched by a business key can't have seed children in the same migration (no key remapping). |
| | 1033 | 32 | | foreach ( var table in tables.Where( t => t.MatchesByBusinessKey ) ) |
| | | 33 | | { |
| | 12 | 34 | | if ( tables.FirstOrDefault( t => t != table && t.EntityType.GetForeignKeys().Any( k => k.PrincipalEntityType |
| | | 35 | | { |
| | 3 | 36 | | return SeedErrors.InvalidDataFile( table.File, $"{table.EntityType.Name} is matched by MatchBy but {chil |
| | | 37 | | } |
| | | 38 | | } |
| | | 39 | | |
| | | 40 | | |
| | 503 | 41 | | var (order, cycle) = SeedTableOrder.Sort( tables.Select( t => t.EntityType ).ToList() ); |
| | | 42 | | |
| | 503 | 43 | | return order is null ? SeedErrors.CycleDetected( migration.Id, cycle ) : order.Select( e => tables.Single( t => |
| | 51 | 44 | | } |
| | | 45 | | |
| | | 46 | | |
| | | 47 | | internal static LayerResponse<SeedBoundTable> BindTable( IModel model, SeedConfiguration configuration, string path, |
| | | 48 | | { |
| | 2310 | 49 | | if ( model.FindEntityType( file.Entity ) is not { } entityType ) { return SeedErrors.InvalidDataFile( path, $"{f |
| | | 50 | | |
| | 2304 | 51 | | if ( entityType.IsOwned() || entityType.HasSharedClrType || entityType.BaseType is not null || entityType.GetDer |
| | | 52 | | { |
| | 0 | 53 | | return SeedErrors.InvalidDataFile( path, $"{file.Entity}: owned, shared-type and inheritance entities aren't |
| | | 54 | | } |
| | | 55 | | |
| | 2304 | 56 | | if ( entityType.GetTableName() is not { } tableName ) { return SeedErrors.InvalidDataFile( path, $"{file.Entity} |
| | | 57 | | |
| | | 58 | | |
| | 2304 | 59 | | var store = StoreObjectIdentifier.Table( tableName, entityType.GetSchema() ); |
| | 2304 | 60 | | var roles = configuration.RolesFor( entityType.ClrType ); |
| | 2304 | 61 | | var managed = new Dictionary<SeedRole, SeedColumn>(); |
| | | 62 | | |
| | 20670 | 63 | | foreach ( var (role, name) in roles.Roles ) |
| | | 64 | | { |
| | 16062 | 65 | | if ( entityType.FindProperty( name ) is { } property ) { managed[ role ] = Column( property, store ); } |
| | | 66 | | } |
| | | 67 | | |
| | | 68 | | |
| | 2304 | 69 | | var match = MatchColumns( entityType, store, roles, file, path ); |
| | | 70 | | |
| | 2307 | 71 | | if ( match.IsFailure ) { return LayerResponse<SeedBoundTable>.Failure( match ); } |
| | | 72 | | |
| | | 73 | | |
| | 2295 | 74 | | var table = new SeedTable |
| | 2295 | 75 | | { |
| | 2295 | 76 | | EntityType = entityType, |
| | 2295 | 77 | | Schema = store.Schema, |
| | 2295 | 78 | | Name = store.Name, |
| | 2295 | 79 | | MatchColumns = match.Result!.Columns, |
| | 2295 | 80 | | SoftDeleteFlag = managed.GetValueOrDefault( SeedRole.SoftDeleteFlag ) |
| | 2295 | 81 | | }; |
| | | 82 | | |
| | 2295 | 83 | | var upsert = DataRows( entityType, store, roles, managed, table, file.Upsert, nameof( SeedTableFile.Upsert ), pa |
| | | 84 | | |
| | 2328 | 85 | | if ( upsert.IsFailure ) { return LayerResponse<SeedBoundTable>.Failure( upsert ); } |
| | | 86 | | |
| | | 87 | | |
| | 2262 | 88 | | var deletes = Keys( table, file.Delete, nameof( SeedTableFile.Delete ), path ); |
| | | 89 | | |
| | 2265 | 90 | | if ( deletes.IsFailure ) { return LayerResponse<SeedBoundTable>.Failure( deletes ); } |
| | | 91 | | |
| | | 92 | | |
| | 2259 | 93 | | var softDeletes = Keys( table, file.SoftDelete, nameof( SeedTableFile.SoftDelete ), path ); |
| | | 94 | | |
| | 2259 | 95 | | if ( softDeletes.IsFailure ) { return LayerResponse<SeedBoundTable>.Failure( softDeletes ); } |
| | | 96 | | |
| | | 97 | | |
| | 2259 | 98 | | var restores = Keys( table, file.Restore, nameof( SeedTableFile.Restore ), path ); |
| | | 99 | | |
| | 2259 | 100 | | if ( restores.IsFailure ) { return LayerResponse<SeedBoundTable>.Failure( restores ); } |
| | | 101 | | |
| | | 102 | | |
| | 2259 | 103 | | var before = DataRows( entityType, store, roles, managed, table, file.Before, nameof( SeedTableFile.Before ), pa |
| | | 104 | | |
| | 2262 | 105 | | if ( before.IsFailure ) { return LayerResponse<SeedBoundTable>.Failure( before ); } |
| | | 106 | | |
| | | 107 | | |
| | 2256 | 108 | | var (columns, rows) = upsert.Result!; |
| | 2256 | 109 | | var written = columns.Select( c => c.PropertyName ).ToHashSet( StringComparer.Ordinal ); |
| | | 110 | | |
| | 2256 | 111 | | return new SeedBoundTable |
| | 2256 | 112 | | { |
| | 2256 | 113 | | File = path, |
| | 2256 | 114 | | Table = table, |
| | 2256 | 115 | | Roles = roles, |
| | 2256 | 116 | | Managed = managed, |
| | 2256 | 117 | | UpsertColumns = columns, |
| | 2256 | 118 | | UpsertRows = rows, |
| | 2256 | 119 | | DeleteKeys = deletes.Result!, |
| | 2256 | 120 | | SoftDeleteKeys = softDeletes.Result!, |
| | 2256 | 121 | | RestoreKeys = restores.Result!, |
| | 2256 | 122 | | BeforeColumns = before.Result!.Columns, |
| | 2256 | 123 | | BeforeRows = before.Result.Rows, |
| | 2256 | 124 | | MatchesByBusinessKey = match.Result.IsBusinessKey, |
| | 2256 | 125 | | SelfReferences = entityType.GetForeignKeys() |
| | 2256 | 126 | | .Where( k => k.PrincipalEntityType == entityType |
| | 2256 | 127 | | && k.Properties.Concat( k.PrincipalKey.Properties ).All( p = |
| | 2256 | 128 | | .ToList() |
| | 2256 | 129 | | }; |
| | | 130 | | } |
| | | 131 | | |
| | | 132 | | private static LayerResponse<MatchKey> MatchColumns( |
| | | 133 | | IEntityType entityType, |
| | | 134 | | StoreObjectIdentifier store, |
| | | 135 | | SeedEntityRoles roles, |
| | | 136 | | SeedTableFile file, |
| | | 137 | | string path |
| | | 138 | | ) |
| | | 139 | | { |
| | 2304 | 140 | | var primaryKey = entityType.FindPrimaryKey(); |
| | 2304 | 141 | | var names = file.MatchBy.Count > 0 ? file.MatchBy : roles.MatchBy ?? primaryKey?.Properties.Select( p => p. |
| | | 142 | | |
| | 2304 | 143 | | if ( names.Count == 0 ) { return SeedErrors.InvalidDataFile( path, $"{entityType.Name} has no primary key and no |
| | | 144 | | |
| | | 145 | | |
| | 2304 | 146 | | var properties = new List<IProperty>(); |
| | | 147 | | |
| | 9216 | 148 | | foreach ( var name in names ) |
| | | 149 | | { |
| | 2304 | 150 | | if ( entityType.FindProperty( name ) is not { } property ) { return SeedErrors.UnknownProperty( path, entity |
| | | 151 | | |
| | 2304 | 152 | | properties.Add( property ); |
| | | 153 | | } |
| | | 154 | | |
| | | 155 | | |
| | 2304 | 156 | | var set = properties.ToHashSet(); |
| | 2304 | 157 | | var isPrimaryKey = primaryKey is not null && primaryKey.Properties.ToHashSet().SetEquals( set ); |
| | 2304 | 158 | | var isUniqueIndex = entityType.GetIndexes().Any( i => i.IsUnique && i.GetFilter() is null && i.Properties.ToHash |
| | | 159 | | |
| | 2304 | 160 | | if ( !isPrimaryKey && !isUniqueIndex ) |
| | | 161 | | { |
| | 9 | 162 | | var message = $"MatchBy ( {string.Join( ", ", names )} ) of {entityType.Name} is neither the primary key nor |
| | | 163 | | |
| | | 164 | | // A data file can be wrong (error); a configuration that can never work is a programmer error. |
| | 9 | 165 | | return file.MatchBy.Count > 0 ? SeedErrors.InvalidDataFile( path, message ) : throw new InvalidOperationExce |
| | | 166 | | } |
| | | 167 | | |
| | | 168 | | |
| | 2295 | 169 | | return new MatchKey( properties.Select( p => Column( p, store ) ).ToList(), !isPrimaryKey ); |
| | 0 | 170 | | } |
| | | 171 | | |
| | | 172 | | /// Full data rows (<c>Upsert</c> or <c>Before</c>): same properties in every row, the match key included, no manage |
| | | 173 | | private static LayerResponse<UpsertData> DataRows( |
| | | 174 | | IEntityType entityType, |
| | | 175 | | StoreObjectIdentifier store, |
| | | 176 | | SeedEntityRoles roles, |
| | | 177 | | Dictionary<SeedRole, SeedColumn> managed, |
| | | 178 | | SeedTable table, |
| | | 179 | | IReadOnlyList<IReadOnlyDictionary<string, JsonElement>> source, |
| | | 180 | | string operation, |
| | | 181 | | string path |
| | | 182 | | ) |
| | | 183 | | { |
| | 7626 | 184 | | if ( source.Count == 0 ) { return new UpsertData( [], [] ); } |
| | | 185 | | |
| | | 186 | | |
| | 1482 | 187 | | var names = source[ 0 ].Keys.ToHashSet( StringComparer.Ordinal ); |
| | | 188 | | |
| | 1485 | 189 | | if ( source.Any( r => !names.SetEquals( r.Keys ) ) ) { return SeedErrors.InvalidDataFile( path, $"Every {operati |
| | | 190 | | |
| | 1479 | 191 | | if ( table.MatchColumns.FirstOrDefault( c => !names.Contains( c.PropertyName ) ) is { } missing ) |
| | | 192 | | { |
| | 3 | 193 | | return SeedErrors.InvalidDataFile( path, $"{operation} rows must include the match key property {missing.Pro |
| | | 194 | | } |
| | | 195 | | |
| | | 196 | | |
| | 1476 | 197 | | var columns = new List<SeedColumn>(); |
| | | 198 | | |
| | 12366 | 199 | | foreach ( var property in entityType.GetProperties().Where( p => names.Contains( p.Name ) ) ) |
| | | 200 | | { |
| | 4716 | 201 | | if ( managed.Values.Any( m => m.Property == property ) ) |
| | | 202 | | { |
| | 9 | 203 | | return SeedErrors.InvalidDataFile( path, $"{property.Name} is an audit / soft-delete field, set by the r |
| | | 204 | | } |
| | | 205 | | |
| | 4707 | 206 | | if ( IsSkipped( property, store, roles ) ) |
| | | 207 | | { |
| | 9 | 208 | | return SeedErrors.InvalidDataFile( path, $"{property.Name} is ignored or generated by the database; it c |
| | | 209 | | } |
| | | 210 | | |
| | 4698 | 211 | | columns.Add( Column( property, store ) ); |
| | | 212 | | } |
| | | 213 | | |
| | 1464 | 214 | | if ( names.FirstOrDefault( n => entityType.FindProperty( n ) is null ) is { } unknown ) { return SeedErrors.Unkn |
| | | 215 | | |
| | | 216 | | |
| | 1452 | 217 | | var rows = new List<IReadOnlyList<object?>>( source.Count ); |
| | | 218 | | |
| | 8566 | 219 | | foreach ( var row in source ) |
| | | 220 | | { |
| | 2834 | 221 | | var values = new object?[ columns.Count ]; |
| | | 222 | | |
| | 23688 | 223 | | for ( var i = 0; i < columns.Count; i++ ) |
| | | 224 | | { |
| | 9016 | 225 | | var value = Convert( row[ columns[ i ].PropertyName ], columns[ i ], path ); |
| | | 226 | | |
| | 9022 | 227 | | if ( value.IsFailure ) { return LayerResponse<UpsertData>.Failure( value ); } |
| | | 228 | | |
| | 9010 | 229 | | values[ i ] = value.Result; |
| | | 230 | | } |
| | | 231 | | |
| | 2828 | 232 | | rows.Add( values ); |
| | | 233 | | } |
| | | 234 | | |
| | | 235 | | |
| | 1446 | 236 | | return new UpsertData( columns, rows ); |
| | 24 | 237 | | } |
| | | 238 | | |
| | | 239 | | private static LayerResponse<IReadOnlyList<IReadOnlyList<object?>>> Keys( SeedTable table, IReadOnlyList<IReadOnlyDi |
| | | 240 | | { |
| | 6780 | 241 | | var keys = new List<IReadOnlyList<object?>>( rows.Count ); |
| | | 242 | | |
| | | 243 | | |
| | 14205 | 244 | | foreach ( var row in rows ) |
| | | 245 | | { |
| | 324 | 246 | | var values = new object?[ table.MatchColumns.Count ]; |
| | | 247 | | |
| | 1290 | 248 | | for ( var i = 0; i < values.Length; i++ ) |
| | | 249 | | { |
| | 324 | 250 | | var column = table.MatchColumns[ i ]; |
| | | 251 | | |
| | 324 | 252 | | if ( !row.TryGetValue( column.PropertyName, out var element ) ) |
| | | 253 | | { |
| | 3 | 254 | | return SeedErrors.InvalidDataFile( path, $"{operation} rows must include the match key property {col |
| | | 255 | | } |
| | | 256 | | |
| | | 257 | | |
| | 321 | 258 | | var value = Convert( element, column, path ); |
| | | 259 | | |
| | 321 | 260 | | if ( value.IsFailure ) { return LayerResponse<IReadOnlyList<IReadOnlyList<object?>>>.Failure( value ); } |
| | | 261 | | |
| | 321 | 262 | | values[ i ] = value.Result; |
| | | 263 | | } |
| | | 264 | | |
| | 321 | 265 | | keys.Add( values ); |
| | | 266 | | } |
| | | 267 | | |
| | | 268 | | |
| | 6777 | 269 | | return keys; |
| | 3 | 270 | | } |
| | | 271 | | |
| | | 272 | | /// Computed, row-version / temporal (generated on update), <c>[SeedIgnore]</c> and <c>Ignore(…)</c> properties are |
| | | 273 | | internal static bool IsSkipped( IProperty property, StoreObjectIdentifier store, SeedEntityRoles roles ) |
| | 7887 | 274 | | => property.GetComputedColumnSql( store ) is not null |
| | 7887 | 275 | | || property.ValueGenerated.HasFlag( ValueGenerated.OnUpdate ) |
| | 7887 | 276 | | || roles.Ignored.Contains( property.Name ); |
| | | 277 | | |
| | | 278 | | internal static SeedColumn Column( IProperty property, StoreObjectIdentifier store ) |
| | | 279 | | { |
| | 18204 | 280 | | var mapping = property.GetRelationalTypeMapping(); |
| | | 281 | | |
| | 18204 | 282 | | return new SeedColumn |
| | 18204 | 283 | | { |
| | 18204 | 284 | | Property = property, |
| | 18204 | 285 | | ColumnName = property.GetColumnName( store ) ?? property.GetColumnName(), |
| | 18204 | 286 | | ProviderClrType = mapping.Converter?.ProviderClrType ?? mapping.ClrType, |
| | 18204 | 287 | | StoreType = property.GetColumnType( store ) ?? mapping.StoreType |
| | 18204 | 288 | | }; |
| | | 289 | | } |
| | | 290 | | |
| | | 291 | | |
| | 2295 | 292 | | private sealed record MatchKey( IReadOnlyList<SeedColumn> Columns, bool IsBusinessKey ); |
| | | 293 | | |
| | 4518 | 294 | | private sealed record UpsertData( IReadOnlyList<SeedColumn> Columns, IReadOnlyList<IReadOnlyList<object?>> Rows ); |
| | | 295 | | |
| | | 296 | | /// A JSON value → the column's provider value. JSON <c>null</c> only for a nullable column. |
| | | 297 | | private static LayerResponse<object?> Convert( JsonElement element, SeedColumn column, string path ) |
| | | 298 | | { |
| | 9337 | 299 | | if ( element.ValueKind == JsonValueKind.Null ) |
| | | 300 | | { |
| | 184 | 301 | | return column.IsNullable ? LayerResponse<object?>.Success() : SeedErrors.InvalidDataFile( path, $"{column.Pr |
| | | 302 | | } |
| | | 303 | | |
| | | 304 | | |
| | 9153 | 305 | | var type = Nullable.GetUnderlyingType( column.ProviderClrType ) ?? column.ProviderClrType; |
| | | 306 | | |
| | 9153 | 307 | | try { return LayerResponse<object?>.Success( element.Deserialize( type, SeedMigrationSource.JsonOptions ) ); } |
| | 3 | 308 | | catch ( Exception ex ) when ( ex is JsonException or NotSupportedException or FormatException or InvalidOperatio |
| | | 309 | | { |
| | 3 | 310 | | return SeedErrors.InvalidDataFile( path, $"{column.PropertyName}: '{element.GetRawText()}' isn't a {type.Nam |
| | | 311 | | } |
| | 9153 | 312 | | } |
| | | 313 | | } |