Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,14 @@ contributor's code, and a hole takes only `Identifier`, `TableRef`, `QualifiedNa
- **Order:** drops, EF Core's operations untouched, in-place statements, creates; contributors by
placement, and a `BeforeTables` contributor the other way round (created before, dropped after,
never rebuilt).
- **A rename in place whose name is held when it runs is a rebuild** (`KeepInPlace`, #99). A
change in place runs after EF Core's operations: an index EF Core creates under the name a
constraint gives up fails with 42P07. Both names are compared, through `NameClaims`, with what
holds a name at that moment: EF Core's objects as the target has them, the declared objects as
EF Core's operations leave them but for those dropped before or lost, and the earlier changes in
place under their new names. A name EF Core gives up is free by then, and the server agrees. The
contributor's statements go into a builder of their own and reach the migration only if the
change stays in place. It is a point fix; the ledger over ordered actions is #115.
- **A declaration whose owner has no contributor is refused,** on either side. #22 adds the
explicit way out.
- **Which model is the consumer's is told by reference, never by where it stands in a diff**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,11 @@ public static string RebuildBeforeTables(Type contributor, QualifiedName name)
=> $"'{contributor.FullName}'.Plan answered Rebuild for '{name}', and its placement is BeforeTables: tables are " +
"made of its objects, so one cannot be dropped and created again around them. Answer InPlace, or change the object under another name.";

public static string RenameBeforeTablesCollides(Type contributor, QualifiedName from, QualifiedName to)
=> $"'{contributor.FullName}'.Plan answered InPlace for '{from}' becoming '{to}', and the migration has something else " +
"hold one of the two names while the change runs. An object of another kind would be rebuilt around it, and this " +
"one's placement is BeforeTables: tables are made of it. Move the rename to a migration of its own.";

public static string UnknownOperation(Type operation)
=> $"The migration holds an operation of type '{operation.FullName}', which SchemaKit does not know. What it does " +
"to the schema objects this model declares cannot be told, and one of them could be dropped or left broken " +
Expand Down
46 changes: 35 additions & 11 deletions src/CodoMetis.EFCore.SchemaKit/Migrations/Internal/NameClaims.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ namespace CodoMetis.EFCore.SchemaKit.Migrations.Internal;
internal static class NameClaims
{
/// <summary>A name taken, by whom, and how that party's names compare.</summary>
private sealed record Taken(NameClaim Claim, string By, object Party, bool Ours, SchemaNameComparison Comparison);
/// <param name="Claim">The name, and the namespace it is taken in.</param>
/// <param name="By">The party, as a message names it.</param>
/// <param name="Party">The party itself: two claims of one party never collide with each other.</param>
/// <param name="Ours">Whether the party is a declaration, not one of EF Core's own objects.</param>
/// <param name="Comparison">How the party's names compare.</param>
internal sealed record Taken(NameClaim Claim, string By, object Party, bool Ours, SchemaNameComparison Comparison);

/// <summary>Throws with every collision between the declared objects, and between one of them and one of EF Core's.</summary>
public static void Require(
Expand All @@ -31,11 +36,7 @@ public static void Require(
{
List<Taken> taken = [];
foreach (var (contributor, descriptor, entityType, declaration) in declared)
{
var by = $"{declaration.Owner} {declaration.Kind} '{descriptor.Name}' on {entityType.ShortName()}";
foreach (var claim in contributor.Claims(descriptor).Distinct())
taken.Add(new Taken(claim, by, descriptor, Ours: true, declaration.NameComparison));
}
taken.AddRange(Of(contributor, descriptor, entityType, declaration, party: descriptor));

var ours = taken.Count;
taken.AddRange(OfTheModel(model, dialect));
Expand All @@ -47,10 +48,7 @@ public static void Require(
for (var j = i + 1; j < taken.Count; j++)
{
var (first, second) = (taken[i], taken[j]);
var comparison = first.Comparison == SchemaNameComparison.IgnoreCase || second.Comparison == SchemaNameComparison.IgnoreCase
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
if (!Same(first.Claim, second.Claim, comparison, dialect.DefaultSchema)) continue;
if (!Collide(first, second, dialect)) continue;

// Two objects that collide in two namespaces, as two exclusion constraints of one
// name do, are one finding.
Expand All @@ -63,8 +61,34 @@ public static void Require(
if (collisions.Count > 0) throw new SchemaValidationException(collisions);
}

/// <summary>What a declared object takes, as its contributor says.</summary>
/// <param name="contributor">The object's contributor, which says what it claims.</param>
/// <param name="descriptor">The object.</param>
/// <param name="entityType">The entity type it is declared on, for the message.</param>
/// <param name="declaration">Its declaration, for the message and how its names compare.</param>
/// <param name="party">What stands for the object: claims of one party do not collide with each other.</param>
public static IEnumerable<Taken> Of(ISchemaContributor contributor, ISchemaDescriptor descriptor, IReadOnlyEntityType entityType, ISchemaObject declaration, object party)
{
var by = $"{declaration.Owner} {declaration.Kind} '{descriptor.Name}' on {entityType.ShortName()}";
return contributor.Claims(descriptor).Distinct().Select(claim => new Taken(claim, by, party, Ours: true, declaration.NameComparison));
}

/// <summary>
/// Whether two parties' claims are one name in one namespace: under the stricter comparison of
/// the two, unless either party's names compare without regard to case.
/// </summary>
public static bool Collide(Taken first, Taken second, ISchemaDialect dialect)
{
if (ReferenceEquals(first.Party, second.Party)) return false;

var comparison = first.Comparison == SchemaNameComparison.IgnoreCase || second.Comparison == SchemaNameComparison.IgnoreCase
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
return Same(first.Claim, second.Claim, comparison, dialect.DefaultSchema);
}

/// <summary>What EF Core's own objects take, each asked of the dialect.</summary>
private static IEnumerable<Taken> OfTheModel(IRelationalModel model, ISchemaDialect dialect)
public static IEnumerable<Taken> OfTheModel(IRelationalModel model, ISchemaDialect dialect)
{
foreach (var table in model.Tables)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ public SchemaOperationPlan Plan(IRelationalModel? source, IRelationalModel? targ
changed.AddRange(Pair(from, to, (s, t) => s.SameButForTheNameAs(t)));

var inPlace = new SchemaSql(Dialect);
List<(Resolved Was, Resolved Becomes, SchemaSql Statements)> inPlaceChanges = [];
HashSet<Resolved> dropped = new(from, ReferenceEqualityComparer.Instance);
HashSet<Resolved> created = new(to, ReferenceEqualityComparer.Instance);

Expand Down Expand Up @@ -172,10 +173,16 @@ public SchemaOperationPlan Plan(IRelationalModel? source, IRelationalModel? targ
continue;
}

switch (was.Contributor.Plan(was.Descriptor, becomes.Descriptor, inPlace))
// The answer's statements are kept only if the change stays in place: the planner can
// still turn it into a rebuild below, and then they must not reach the migration.
var statements = new SchemaSql(Dialect);
switch (was.Contributor.Plan(was.Descriptor, becomes.Descriptor, statements))
{
case SchemaChange.None:
break;

case SchemaChange.InPlace:
inPlaceChanges.Add((was, becomes, statements));
break;

case SchemaChange.Rebuild:
Expand All @@ -190,6 +197,11 @@ public SchemaOperationPlan Plan(IRelationalModel? source, IRelationalModel? targ
}
}

// A change in place runs after EF Core's operations, where a name it gives up or takes may
// be held by something else; such a change is rebuilt instead (#99).
if (inPlaceChanges.Count > 0)
KeepInPlace(inPlaceChanges, sourceOrder, dropped, created, target!, inPlace);

List<Resolved> drops = [.. sourceOrder.Where(dropped.Contains)];
List<Resolved> creates = [.. targetOrder.Where(created.Contains)];

Expand Down Expand Up @@ -227,6 +239,98 @@ public SchemaOperationPlan Plan(IRelationalModel? source, IRelationalModel? targ
return new SchemaOperationPlan(before.Operations, [.. inPlace.Operations, .. after.Operations]);
}

/// <summary>
/// Writes the changes in place whose names are free when they run, in the order they run, and
/// makes each of the others a rebuild (#99).
/// </summary>
/// <remarks>
/// <para>
/// A change in place runs after EF Core's operations and before the creates. When it renames an
/// object, the name it gives up and the name it takes are compared, through the claims and the
/// namespace rules of <see cref="NameClaims" />, with what holds a name at that moment: EF Core's
/// objects as the target has them, since all of EF Core's operations have run; every declared
/// object of the source as EF Core's operations leave it, but for those dropped before them or
/// lost with them (what tables are made of is dropped last, so it is still there); and the
/// changes in place written before this one, under their new names. A name EF Core takes is the
/// failure of #99: <c>CREATE INDEX</c> under the name a constraint gives up runs while the
/// constraint still holds it. A name another rename still holds is the same failure among
/// declarations, which is how two constraints on two tables of one schema swapping names
/// failed.
/// </para>
/// <para>
/// A change that collides is rebuilt: the drop frees the old name before EF Core's operations,
/// the create takes the new one after them and after every change in place, and the create
/// ensures its extensions like any other. No temporary name is used. A name EF Core gives up is
/// no collision: its operation has run, which is why a rename into the name of an index EF Core
/// drops or renames in the same migration stays a rename, as the server confirms.
/// </para>
/// <para>
/// This is a point fix. It reasons about one moment, the changes in place, with everything else
/// in its phase as a whole: a name an object's create takes against EF Core's operations, a
/// <c>BeforeTables</c> create against an <c>EnsureSchema</c>, and an order among creates or among
/// drops are not looked at here. The general answer is a ledger over the ordered actions (#115).
/// </para>
/// </remarks>
private void KeepInPlace(
List<(Resolved Was, Resolved Becomes, SchemaSql Statements)> changes,
List<Resolved> sources,
HashSet<Resolved> dropped,
HashSet<Resolved> created,
IRelationalModel target,
SchemaSql inPlace)
{
List<NameClaims.Taken>? held = null;
foreach (var (was, becomes, statements) in changes)
{
List<NameClaims.Taken> gives = [.. Claims(was)];
List<NameClaims.Taken> takes = [.. NameClaims.Of(becomes.Contributor, becomes.Descriptor, becomes.EntityType, becomes.Declaration, party: was)];

// A change that names nothing anew holds what it held: nothing to compare.
if (gives.Select(taken => taken.Claim).ToHashSet().SetEquals(takes.Select(taken => taken.Claim)))
{
inPlace.Append(statements);
continue;
}

held ??= Held(sources, dropped, target);
if (gives.Concat(takes).Any(claim => held.Any(other => NameClaims.Collide(claim, other, Dialect))))
{
if (was.Contributor.Placement.IsBeforeTables)
throw new InvalidOperationException(SchemaKitStrings.RenameBeforeTablesCollides(was.Contributor.GetType(), was.Descriptor.Name, becomes.Descriptor.Name));

dropped.Add(was);
created.Add(becomes);
held.RemoveAll(taken => ReferenceEquals(taken.Party, was));
continue;
}

inPlace.Append(statements);
held.RemoveAll(taken => ReferenceEquals(taken.Party, was));
held.AddRange(takes);
}
}

/// <summary>What holds a name when the changes in place begin: EF Core's objects as the target has them, and the declared objects still there.</summary>
private List<NameClaims.Taken> Held(List<Resolved> sources, HashSet<Resolved> dropped, IRelationalModel target)
{
List<NameClaims.Taken> held = [.. NameClaims.OfTheModel(target, Dialect)];
foreach (var source in sources)
{
// Gone with an operation of EF Core's, or dropped before them. What tables are made of
// is dropped after everything else, so it holds its name throughout.
if (source.Impact is Impact.Lost) continue;
if (dropped.Contains(source) && !source.Contributor.Placement.IsBeforeTables) continue;

held.AddRange(Claims(source));
}

return held;
}

/// <summary>What an object of the source takes as EF Core's operations leave it: under the new names of its table and columns, and its own name still.</summary>
private static IEnumerable<NameClaims.Taken> Claims(Resolved source)
=> NameClaims.Of(source.Contributor, source.Descriptor, source.EntityType, source.Declaration, party: source);

/// <summary>
/// An object of the source side as EF Core's operations leave it. The contributor is asked
/// about each change that touches what the object depends on, with the object as it is now;
Expand Down
3 changes: 3 additions & 0 deletions src/CodoMetis.EFCore.SchemaKit/Sql/SchemaSql.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ public sealed partial class SchemaSql
/// <summary>The statements so far, as the operations a migration carries.</summary>
internal IReadOnlyList<SqlOperation> Operations => _operations;

/// <summary>Adds another builder's statements, in their order: a contributor's answer the planner keeps.</summary>
internal void Append(SchemaSql other) => _operations.AddRange(other._operations);

/// <summary>
/// Adds one statement. The provider's statement terminator is appended; do not write one.
/// </summary>
Expand Down
Loading