C#: a type listing an id registers beside the type that owns it; the owner wins
Batch 9 refused any overlap between two types' ids, which forbade the migration CompatibleSchemaIds exists for: v2 declares v1's id while v1's class stays registered (Soul F8). The rule is now owner beats lister. A type owns its own schema id; any number of types may list an id as compatible beside its owner, registering in either order. A record under an id resolves to its owner, else to the one type listing it. An id no type owns and several list names no single type, so a record under it is refused, typed (CultSchemaConflictException naming the id and the listers), where it resolves: refusing at registration would depend on whether the owner had registered yet. A second owner of one id is still refused at registration, typed. A schema id hashes the schema's name and version, so the only second owner is a type of the same name and version, which the name-and-version check already meets; the separate own-id lookup could never fire and is gone. Tests: an owner and a lister register in both orders, the owner's id resolves to the owner, the lister's own id to the lister, and without the owner to the lister; two listers register in both orders, a record under their id is refused typed with no owner and resolves to the owner with one, registered before or after them; a cache with v1 beside v2 reads each version's records as its own type, in both registration orders. The emitted-fixture note says why a fixture is emitted now that v1/v2 pairs no longer need hiding from the shared registry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
188d4ad88d
commit
9629aa119b
4 changed files with 90 additions and 29 deletions
|
|
@ -628,9 +628,18 @@ namespace GameCult.Caching
|
|||
throw new InvalidOperationException($"Persisted schema '{schemaId}' is not present in the embedded catalog.");
|
||||
}
|
||||
|
||||
if (indexes.ByCompatibleSchemaId.TryGetValue(schemaId, out var declared))
|
||||
// No type owns the id, so the one type that lists it is the record's type. Two listers and no owner cannot say which.
|
||||
if (indexes.ByCompatibleSchemaId.TryGetValue(schemaId, out var listers))
|
||||
{
|
||||
return BuildCompatibleResolutionResult(persisted, declared);
|
||||
if (listers.Length > 1)
|
||||
throw new CultSchemaConflictException(
|
||||
$"CultCache schema id '{schemaId}' is owned by no registered type and declared compatible by several " +
|
||||
$"({string.Join(", ", listers.Select(lister => lister.DocumentType.FullName))}), so a record under it resolves to none. " +
|
||||
"Register the type that owns the id, or declare it on one type.",
|
||||
schemaId,
|
||||
listers.Select(lister => lister.SchemaName).ToArray(),
|
||||
string.Empty);
|
||||
return BuildCompatibleResolutionResult(persisted, listers[0]);
|
||||
}
|
||||
|
||||
foreach (var compatibleSchemaId in persisted.CompatibleSchemaIds.Where(candidate => !string.IsNullOrWhiteSpace(candidate)))
|
||||
|
|
@ -699,18 +708,16 @@ namespace GameCult.Caching
|
|||
descriptor.DocumentType);
|
||||
}
|
||||
|
||||
// One type carries each schema id, its own or declared compatible, so which type a record resolves to never
|
||||
// depends on the order types were registered in.
|
||||
foreach (var carried in descriptor.CompatibleSchemaIds.Prepend(descriptor.SchemaId))
|
||||
{
|
||||
if (indexes.BySchemaId.TryGetValue(carried, out var claimant) || indexes.ByCompatibleSchemaId.TryGetValue(carried, out claimant))
|
||||
throw SchemaIdClaimedTwice(carried, claimant, descriptor);
|
||||
}
|
||||
|
||||
// A schema id hashes the schema's name and version, so the one other type that could own this id is the one refused or
|
||||
// aliased above. Other types may list the id as compatible beside its owner, as a v2 class does that keeps v1's class
|
||||
// registered: a record under the id resolves to its owner, and to a lister only when nothing owns it. Neither depends on
|
||||
// the order types were registered in.
|
||||
indexes.ByType[descriptor.DocumentType] = descriptor;
|
||||
indexes.BySchemaId[descriptor.SchemaId] = descriptor;
|
||||
foreach (var compatibleSchemaId in descriptor.CompatibleSchemaIds)
|
||||
indexes.ByCompatibleSchemaId[compatibleSchemaId] = descriptor;
|
||||
indexes.ByCompatibleSchemaId[compatibleSchemaId] = indexes.ByCompatibleSchemaId.TryGetValue(compatibleSchemaId, out var listers)
|
||||
? [.. listers, descriptor]
|
||||
: [descriptor];
|
||||
indexes.BySchemaName[descriptor.SchemaName] = schemaNameVersions == null
|
||||
? [descriptor]
|
||||
: [.. schemaNameVersions, descriptor];
|
||||
|
|
@ -736,7 +743,7 @@ namespace GameCult.Caching
|
|||
new(
|
||||
$"CultCache schema id '{schemaId}' is already carried by CLR type '{existing.DocumentType.FullName}' " +
|
||||
$"(schema '{existing.SchemaName}') and cannot also be claimed by '{claimed.DocumentType.FullName}' " +
|
||||
$"(schema '{claimed.SchemaName}'). A schema id, owned or declared compatible, belongs to one registered type.",
|
||||
$"(schema '{claimed.SchemaName}'). A schema id is owned by one registered type; others may only declare it compatible.",
|
||||
schemaId,
|
||||
new[] { existing.SchemaName, claimed.SchemaName },
|
||||
string.Empty);
|
||||
|
|
@ -756,7 +763,7 @@ namespace GameCult.Caching
|
|||
{
|
||||
ByType = new Dictionary<Type, CultDocumentDescriptor>();
|
||||
BySchemaId = new Dictionary<string, CultDocumentDescriptor>(StringComparer.Ordinal);
|
||||
ByCompatibleSchemaId = new Dictionary<string, CultDocumentDescriptor>(StringComparer.Ordinal);
|
||||
ByCompatibleSchemaId = new Dictionary<string, CultDocumentDescriptor[]>(StringComparer.Ordinal);
|
||||
BySchemaName = new Dictionary<string, CultDocumentDescriptor[]>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
|
@ -764,13 +771,14 @@ namespace GameCult.Caching
|
|||
{
|
||||
ByType = new Dictionary<Type, CultDocumentDescriptor>(source.ByType);
|
||||
BySchemaId = new Dictionary<string, CultDocumentDescriptor>(source.BySchemaId, StringComparer.Ordinal);
|
||||
ByCompatibleSchemaId = new Dictionary<string, CultDocumentDescriptor>(source.ByCompatibleSchemaId, StringComparer.Ordinal);
|
||||
ByCompatibleSchemaId = new Dictionary<string, CultDocumentDescriptor[]>(source.ByCompatibleSchemaId, StringComparer.Ordinal);
|
||||
BySchemaName = new Dictionary<string, CultDocumentDescriptor[]>(source.BySchemaName, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public Dictionary<Type, CultDocumentDescriptor> ByType { get; }
|
||||
public Dictionary<string, CultDocumentDescriptor> BySchemaId { get; }
|
||||
public Dictionary<string, CultDocumentDescriptor> ByCompatibleSchemaId { get; }
|
||||
// Every type declaring the id compatible, in registration order; resolution never depends on that order.
|
||||
public Dictionary<string, CultDocumentDescriptor[]> ByCompatibleSchemaId { get; }
|
||||
public Dictionary<string, CultDocumentDescriptor[]> BySchemaName { get; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -559,6 +559,27 @@ namespace GameCult.Caching.Tests
|
|||
Assert.That(Derive(held, current, unchangedOnly: false), Is.EqualTo(CultCommitOutcome.Committed));
|
||||
}
|
||||
|
||||
// The migration CompatibleSchemaIds exists for: v2 declares v1's id and v1's class stays registered beside it, in either
|
||||
// order. A record under v1's id is v1's, its owner; one under v2's id is v2's.
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public void AVersionKeptBesideTheVersionDeclaringItsIdReadsItsOwnRecords(bool reversed)
|
||||
{
|
||||
using (var older = OpenRoll(RollV1))
|
||||
older.Commit(batch => batch.Upsert(RollV1, New(RollV1, ("Name", "k")), RollK));
|
||||
using (var newer = OpenRoll(RollV2))
|
||||
newer.Commit(batch =>
|
||||
{
|
||||
batch.Expect(RollY, null);
|
||||
batch.Upsert(RollV2, New(RollV2, ("Name", "y"), ("Extra", "e")), RollY);
|
||||
});
|
||||
Assert.That(Read(RollPath).Records.Select(record => record.SchemaId), Is.EquivalentTo(new[] { RollV1Id, CultDocumentRegistry.ForTypes(new[] { RollV2 }).GetRequired(RollV2).SchemaId }));
|
||||
|
||||
using var both = Open(RollPath, CultDocumentRegistry.ForTypes(reversed ? new[] { RollV2, RollV1 } : new[] { RollV1, RollV2 }));
|
||||
Assert.That(both.Get(RollK)!.GetType(), Is.EqualTo(RollV1));
|
||||
Assert.That(both.Get(RollY)!.GetType(), Is.EqualTo(RollV2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ADeclarationIsRegisteredWithoutItsOwnIdAndItsEntryListsTheOwnIdFirst()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -122,8 +122,8 @@ public sealed class CultDocumentRegistryTests
|
|||
});
|
||||
}
|
||||
|
||||
// One type carries each schema id, owned or declared compatible, whichever order the types register in: a second claimant
|
||||
// is refused, typed, naming both, and the registry keeps what it had.
|
||||
// A second type owning a schema id is refused, typed, naming both, whichever order the two register in, and the registry
|
||||
// keeps what it had.
|
||||
private static void AssertSecondClaimRefused(Type first, Type second, string schemaId)
|
||||
{
|
||||
var refusal = Assert.Throws<CultSchemaConflictException>(() => CultDocumentRegistry.ForTypes(new[] { first, second }))!;
|
||||
|
|
@ -138,25 +138,57 @@ public sealed class CultDocumentRegistryTests
|
|||
Assert.That(registry.AllDescriptors.Select(descriptor => descriptor.DocumentType), Is.EqualTo(new[] { first }));
|
||||
}
|
||||
|
||||
private static CultSchemaCatalogEntry[] CatalogOf(params Type[] types) =>
|
||||
types.Select(type => CultDocumentRegistry.ForTypes(new[] { type }).GetRequired(type).ToCatalogEntry()).ToArray();
|
||||
|
||||
// Owner beats lister: a type that owns an id and one that lists it register together in either order (the v1 class kept
|
||||
// beside a v2 class declaring v1's id). A record under the id resolves to its owner; one under the lister's own id to the
|
||||
// lister; without the owner registered, the id resolves to the lister.
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public void TwoTypesDeclaringOneCompatibleIdAreRefused(bool reversed)
|
||||
{
|
||||
var a = Emit("declares_a", "tests.registry.declares_a", "v1", compatibleSchemaIds: new[] { "tests.registry.shared" });
|
||||
var b = Emit("declares_b", "tests.registry.declares_b", "v1", compatibleSchemaIds: new[] { "tests.registry.shared" });
|
||||
|
||||
AssertSecondClaimRefused(reversed ? b : a, reversed ? a : b, "tests.registry.shared");
|
||||
}
|
||||
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public void ATypeDeclaringAnIdAnotherTypeOwnsIsRefused(bool reversed)
|
||||
public void AnOwnerAndATypeListingItsIdRegisterTogetherAndTheOwnerWins(bool reversed)
|
||||
{
|
||||
var owner = Emit("owns", "tests.registry.owns", "v1");
|
||||
var ownerId = CultDocumentRegistry.ForTypes(new[] { owner }).GetRequired(owner).SchemaId;
|
||||
var lister = Emit("lists", "tests.registry.lists", "v1", compatibleSchemaIds: new[] { ownerId });
|
||||
var lister = Emit("lists", "tests.registry.owns", "v2", new[] { new Field("Extra", typeof(string), 0) }, new[] { ownerId });
|
||||
var listerId = CultDocumentRegistry.ForTypes(new[] { lister }).GetRequired(lister).SchemaId;
|
||||
var catalog = CatalogOf(owner, lister);
|
||||
|
||||
AssertSecondClaimRefused(reversed ? lister : owner, reversed ? owner : lister, ownerId);
|
||||
var registry = CultDocumentRegistry.ForTypes(reversed ? new[] { lister, owner } : new[] { owner, lister });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(registry.AllDescriptors.Select(descriptor => descriptor.DocumentType), Is.EquivalentTo(new[] { owner, lister }));
|
||||
Assert.That(registry.ResolvePersistedSchema(ownerId, catalog).DocumentType, Is.EqualTo(owner));
|
||||
Assert.That(registry.ResolvePersistedSchema(listerId, catalog).DocumentType, Is.EqualTo(lister));
|
||||
Assert.That(CultDocumentRegistry.ForTypes(new[] { lister }).ResolvePersistedSchema(ownerId, catalog).DocumentType, Is.EqualTo(lister));
|
||||
});
|
||||
}
|
||||
|
||||
// Two types listing one id register together in either order. With an owner registered, a record under the id is the
|
||||
// owner's; with none, the id names no single type, and a record under it is refused, typed, naming the listers.
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public void TwoTypesListingOneIdRegisterAndARecordUnderItNeedsAnOwner(bool reversed)
|
||||
{
|
||||
var owner = Emit("shared_owner", "tests.registry.shared", "v1");
|
||||
var sharedId = CultDocumentRegistry.ForTypes(new[] { owner }).GetRequired(owner).SchemaId;
|
||||
var a = Emit("declares_a", "tests.registry.declares_a", "v1", compatibleSchemaIds: new[] { sharedId });
|
||||
var b = Emit("declares_b", "tests.registry.declares_b", "v1", compatibleSchemaIds: new[] { sharedId });
|
||||
var listers = reversed ? new[] { b, a } : new[] { a, b };
|
||||
var catalog = CatalogOf(owner);
|
||||
|
||||
var ambiguous = CultDocumentRegistry.ForTypes(listers);
|
||||
var refusal = Assert.Throws<CultSchemaConflictException>(() => ambiguous.ResolvePersistedSchema(sharedId, catalog))!;
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(refusal.SchemaId, Is.EqualTo(sharedId));
|
||||
Assert.That(refusal.SchemaNames, Is.EquivalentTo(new[] { "tests.registry.declares_a", "tests.registry.declares_b" }));
|
||||
Assert.That(refusal.Message, Does.Contain(a.FullName).And.Contain(b.FullName));
|
||||
});
|
||||
|
||||
Assert.That(CultDocumentRegistry.ForTypes(listers.Append(owner)).ResolvePersistedSchema(sharedId, catalog).DocumentType, Is.EqualTo(owner));
|
||||
Assert.That(CultDocumentRegistry.ForTypes(listers.Prepend(owner)).ResolvePersistedSchema(sharedId, catalog).DocumentType, Is.EqualTo(owner));
|
||||
}
|
||||
|
||||
// The same schema declaring different compatible ids is not an alias: whichever registered second would lose its declaration.
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ using MessagePack;
|
|||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
// Document types built at run time, each in its own dynamic assembly. CultDocumentRegistry.Shared discovers every
|
||||
// [CultDocument] type in the loaded non-dynamic assemblies and refuses two that claim one schema id, so a fixture that
|
||||
// needs such a pair (a type and its alias, two versions that share an id) emits them here instead of declaring them.
|
||||
// Document types built at run time, each in its own dynamic assembly, which CultDocumentRegistry.Shared never scans. A
|
||||
// fixture emits a type here when declaring it would change what Shared holds: a pair Shared refuses (one schema declared
|
||||
// two ways), an alias whose resolved type would depend on scan order, or a type a test needs to itself.
|
||||
internal static class EmittedDocumentTypes
|
||||
{
|
||||
internal readonly record struct Field(string Name, Type Type, int Key, bool IsName = false);
|
||||
|
|
|
|||
Loading…
Reference in a new issue