CultNet C#: a second type for one schema id is refused, typed
Soul F5. F7 (operator, 2026-09-30): "Schema aliasing sounds like a really
bad idea, I support retiring it." CultNetDocumentRegistry.Register wrote
both maps unconditionally, so the last binding for a schema id won.
- Register refuses a binding whose type already holds another schema id,
or whose schema id another type holds, with the reference runtime's
CultSchemaConflictException: the new binding's schema id, both document
types, an empty record key. Nothing is bound on refusal. Binding a type
to the schema id it holds again replaces the binding. This matches Rust
(fc3f3ee5) and Kotlin.
- Tests: both claimant shapes in both orders with two bindings ahead of
the holder (F6/M11), the first binding intact afterwards, and a same-type
rebind replacing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
852ea0fe1a
commit
fa266e5829
2 changed files with 113 additions and 1 deletions
|
|
@ -165,10 +165,31 @@ namespace GameCult.Networking
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a document binding.
|
||||
/// Registers a document binding. One binding owns a document type and a schema id: a binding claiming a type or a schema
|
||||
/// id another binding already holds is refused with <see cref="CultSchemaConflictException"/>, naming the new binding's
|
||||
/// schema id and both document types, and nothing is bound. Binding a type to the schema id it already holds replaces
|
||||
/// that binding.
|
||||
/// </summary>
|
||||
public CultNetDocumentRegistry Register(CultNetDocumentBinding binding)
|
||||
{
|
||||
var holder =
|
||||
_bindingsByType.TryGetValue(binding.DocumentType, out var sameType) &&
|
||||
!string.Equals(sameType.SchemaId, binding.SchemaId, StringComparison.Ordinal)
|
||||
? sameType
|
||||
: _bindingsBySchemaId.TryGetValue(binding.SchemaId, out var sameSchema) &&
|
||||
sameSchema.DocumentType != binding.DocumentType
|
||||
? sameSchema
|
||||
: null;
|
||||
if (holder != null)
|
||||
{
|
||||
throw new CultSchemaConflictException(
|
||||
$"CultNet schema id \"{holder.SchemaId}\" is already bound to type \"{holder.DocumentType.FullName}\"; " +
|
||||
$"type \"{binding.DocumentType.FullName}\" (schema id \"{binding.SchemaId}\") cannot also be bound.",
|
||||
binding.SchemaId,
|
||||
new[] { holder.DocumentType.FullName ?? holder.DocumentType.Name, binding.DocumentType.FullName ?? binding.DocumentType.Name },
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
_bindingsBySchemaId[binding.SchemaId] = binding;
|
||||
_bindingsByType[binding.DocumentType] = binding;
|
||||
return this;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Networking.Tests
|
||||
{
|
||||
// F7 (operator, 2026-09-30): "Schema aliasing sounds like a really bad idea, I support retiring it." One binding owns a
|
||||
// document type and a schema id, in the reference runtime as in Rust, Kotlin and TypeScript.
|
||||
public sealed class CultNetDocumentRegistryConflictTests
|
||||
{
|
||||
private static CultNetDocumentBinding Bind<T>(string? schemaId = null) where T : class =>
|
||||
CultNetDocumentBinding.ForDocument<T>(CultDocumentRegistry.Shared, schemaId);
|
||||
|
||||
// Two bindings stand ahead of the holder, so a conflict check that looks only at the first holder misses it.
|
||||
private static CultNetDocumentRegistry RegistryHolding(CultNetDocumentBinding holder) =>
|
||||
new CultNetDocumentRegistry(CultDocumentRegistry.Shared)
|
||||
.Register(Bind<ConflictFillerA>())
|
||||
.Register(Bind<ConflictFillerB>())
|
||||
.Register(holder);
|
||||
|
||||
[Test]
|
||||
public void A_second_type_for_one_schema_id_is_refused_typed_in_either_order()
|
||||
{
|
||||
var first = Bind<ConflictNote>("f5.shared");
|
||||
var second = Bind<ConflictOtherNote>("f5.shared");
|
||||
foreach (var (held, claimant) in new[] { (first, second), (second, first) })
|
||||
{
|
||||
var registry = RegistryHolding(held);
|
||||
var error = Assert.Throws<CultSchemaConflictException>(() => registry.Register(claimant))!;
|
||||
Assert.That(error.SchemaId, Is.EqualTo("f5.shared"));
|
||||
Assert.That(error.SchemaNames, Is.EqualTo(new[] { held.DocumentType.FullName, claimant.DocumentType.FullName }));
|
||||
Assert.That(error.RecordKey, Is.EqualTo(string.Empty));
|
||||
Assert.That(error.Message, Does.Contain($"type \"{claimant.DocumentType.FullName}\""));
|
||||
// Nothing was bound: the schema id still resolves to the first type, and the claimant's type to nothing.
|
||||
Assert.That(registry.GetBySchemaId("f5.shared"), Is.SameAs(held));
|
||||
Assert.That(registry.GetByDocumentType(claimant.DocumentType), Is.Null);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_type_bound_to_one_schema_id_cannot_be_bound_to_another()
|
||||
{
|
||||
var own = Bind<ConflictNote>();
|
||||
var registry = RegistryHolding(own);
|
||||
var error = Assert.Throws<CultSchemaConflictException>(() => registry.Register(Bind<ConflictNote>("f5.elsewhere")))!;
|
||||
Assert.That(error.SchemaId, Is.EqualTo("f5.elsewhere"));
|
||||
Assert.That(registry.GetBySchemaId("f5.elsewhere"), Is.Null);
|
||||
Assert.That(registry.GetByDocumentType(typeof(ConflictNote)), Is.SameAs(own));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Binding_a_type_to_the_schema_id_it_holds_again_replaces_the_binding()
|
||||
{
|
||||
var registry = RegistryHolding(Bind<ConflictNote>());
|
||||
var again = Bind<ConflictNote>();
|
||||
registry.Register(again);
|
||||
Assert.That(registry.GetByDocumentType(typeof(ConflictNote)), Is.SameAs(again));
|
||||
Assert.That(registry.GetBySchemaId(again.SchemaId), Is.SameAs(again));
|
||||
}
|
||||
|
||||
[CultDocument("f5.conflict-note", "f5.conflict-note.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
public sealed class ConflictNote
|
||||
{
|
||||
[Key(0)] public string Body = string.Empty;
|
||||
}
|
||||
|
||||
[CultDocument("f5.conflict-other-note", "f5.conflict-other-note.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
public sealed class ConflictOtherNote
|
||||
{
|
||||
[Key(0)] public string Body = string.Empty;
|
||||
}
|
||||
|
||||
[CultDocument("f5.conflict-filler-a", "f5.conflict-filler-a.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
public sealed class ConflictFillerA
|
||||
{
|
||||
[Key(0)] public string Body = string.Empty;
|
||||
}
|
||||
|
||||
[CultDocument("f5.conflict-filler-b", "f5.conflict-filler-b.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
public sealed class ConflictFillerB
|
||||
{
|
||||
[Key(0)] public string Body = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue