diff --git a/docs/start/README.md b/docs/start/README.md index e033c935..c388f8b6 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -44,7 +44,7 @@ using MessagePack; public sealed class Item { [Key(0)] [CultName] public string Name = string.Empty; - [Key(1)] [CultIndex] public string Category = string.Empty; + [Key(1)] public string Category = string.Empty; [Key(2)] public int Value; } diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 5a0626a6..66332b5c 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -885,6 +885,10 @@ namespace GameCult.Caching return indexes.BySchemaId.ContainsKey(schemaId) || indexes.ByCompatibleSchemaId.ContainsKey(schemaId); } + // Whether the type registered under this schema id declares a [CultIndex]: its records are written through a cache, which judges uniqueness. + internal bool HoldsUniqueIndex(string schemaId) => + _indexes.BySchemaId.TryGetValue(schemaId, out var descriptor) && descriptor.IndexAccessors.Count > 0; + // A persisted entry a local entry reads whole: identical content, or compatible shapes that leave no persisted slot ignored. internal static bool ReadsWhole(CultSchemaCatalogEntry persisted, CultSchemaCatalogEntry local) => string.Equals(persisted.ContentHash, local.ContentHash, StringComparison.Ordinal) || @@ -1006,7 +1010,7 @@ namespace GameCult.Caching throw new InvalidOperationException(ids.Problems[0]); var nameMember = members.FirstOrDefault(member => member.IsName); var indexAccessors = members - .Where(member => member.IndexAlias != null) + .Where(member => member.IsUnique) .ToDictionary( member => member.IndexAlias!, member => member.Getter, @@ -1481,6 +1485,13 @@ namespace GameCult.Caching rejections.Add(HiddenMemberMessage(type.Name, Qualified(pair[0].Member), Qualified(pair[1].Member))); } + // A member names its role once: [CultIndex] already is a role. + foreach (var entry in keyed.OrderBy(entry => entry.Member.Name, StringComparer.Ordinal)) + { + if (entry.Member.GetCustomAttribute() != null && entry.Member.GetCustomAttribute() != null) + rejections.Add($"Cult document {type.Name} member {Qualified(entry.Member)} declares both [CultIndex] and [CultRole]; [CultIndex] already names its role."); + } + // D10: two differently-named members that independently declare the same index alias. A // hiding member sharing one slot is already caught above; this is two distinct members whose // alias map entry (BuildDescriptor's indexAccessors) would otherwise collide silently. @@ -1580,14 +1591,19 @@ namespace GameCult.Caching private static string Qualified(MemberInfo member) => member.DeclaringType!.Name.Split('`')[0] + "." + member.Name; + // The member's role alias, from [CultIndex] or [CultRole] (the member name when the alias is blank). // Shared by DiscoverMembers' D10/D11 rejections and PersistedMember.FromMember, so registration // refuses exactly the shapes the accessor it builds afterward could not have walked anyway. private static string? ResolveIndexAlias(MemberInfo member) { - var indexAttribute = member.GetCustomAttribute(); - if (indexAttribute == null) + string? alias; + if (member.GetCustomAttribute() is { } index) + alias = index.Alias; + else if (member.GetCustomAttribute() is { } role) + alias = role.Alias; + else return null; - return string.IsNullOrWhiteSpace(indexAttribute.Alias) ? member.Name : indexAttribute.Alias; + return string.IsNullOrWhiteSpace(alias) ? member.Name : alias; } private static bool IsCultRecordRefType(Type type) => @@ -1854,6 +1870,7 @@ namespace GameCult.Caching public int Slot { get; set; } public bool IsName { get; set; } public string? IndexAlias { get; set; } + public bool IsUnique { get; set; } public bool IsReference { get; set; } public bool IsMany { get; set; } public string? TargetSchemaName { get; set; } @@ -1896,6 +1913,7 @@ namespace GameCult.Caching Slot = slot, IsName = member.GetCustomAttribute() != null, IndexAlias = ResolveIndexAlias(member), + IsUnique = member.GetCustomAttribute() != null, IsReference = isReference, IsMany = many, TargetSchemaName = targetSchemaName, @@ -2200,8 +2218,8 @@ namespace GameCult.Caching private readonly List<(CacheBackingStore Store, Type[] Homes)> _stores = new(); private readonly Dictionary _entries = new(StringComparer.Ordinal); private readonly Dictionary> _names = new(); - // Every key holding a value (unordered): the index is truthful about duplicates, so removing one holder never hides another. - private readonly Dictionary<(Type Type, string Alias), Dictionary>> _indexes = new(); + // One key per value: RefuseIndexSharing decides it, Apply only records it. + private readonly Dictionary<(Type Type, string Alias), Dictionary> _indexes = new(); private readonly Dictionary _globals = new(); private readonly CultCodec? _codec; // Keys holding a variant: derived from the entries by Apply, never decided anywhere else. @@ -2671,16 +2689,12 @@ namespace GameCult.Caching .SelectMany(pair => pair.Value.TryGetValue(name, out var key) ? new[] { key } : Array.Empty())); } - // A value held by one record finds it. Records of one type sharing a value (plain duplicates) find the holder with the - // smallest key (ordinal), whatever order they were written or loaded in: the choice is a function of the held set, so - // it is the same live, after a flush and after a reopen, and removing that holder finds the next. + // A [CultIndex] value names one record of its type; blank values are not indexed. public T? GetByIndex(string alias, string value) where T : class { return Single($"index {alias}='{value}'", () => _indexes .Where(pair => pair.Key.Alias == alias && typeof(T).IsAssignableFrom(pair.Key.Type)) - .SelectMany(pair => pair.Value.TryGetValue(value, out var keys) - ? new[] { keys.Aggregate((best, next) => string.CompareOrdinal(next, best) < 0 ? next : best) } - : Array.Empty())); + .SelectMany(pair => pair.Value.TryGetValue(value, out var key) ? new[] { key } : Array.Empty())); } public void Dispose() @@ -3270,12 +3284,10 @@ namespace GameCult.Caching { var plan = ResolveVariants(admitted, evicted, source); Project(plan); - // The variant rules are judged only when a variant is involved. + RefuseIndexSharing(plan, evicted); + // The variant name rule is judged only when a variant is involved. if (_variantKeys.Count != 0 || admitted.Any(stored => stored.Variant != null)) - { RefuseVariantNameSharing(plan); - RefuseVariantIndexSharing(plan, evicted); - } return plan; } @@ -3472,62 +3484,41 @@ namespace GameCult.Caching } } - // The one index rule, judged on the commit's result whatever the write order: a variant may not share an indexed - // value with another record. It reads the truthful held index (every holder of a value), minus the records this - // commit replaces or removes, plus the records it lands. Plain-vs-plain sharing is not this rule's business. - // A unique index maps a value to one key at lookup; a variant sharing it would be found in place of its - // holder or lose the value, and the store would refuse to reopen. - private void RefuseVariantIndexSharing(VariantPlan plan, IReadOnlyList evicted) + // The one index rule, judged on the commit's result whatever the write order: within one document type and one + // [CultIndex] alias, no two records hold one non-blank value. The holders of a value are the held key (unless this + // admission replaces or evicts it) plus every landing key projecting it; two distinct holders refuse, naming both + // keys and the index and never the value. One pass over what lands, one lookup per (index, value). + private void RefuseIndexSharing(VariantPlan plan, IReadOnlyList evicted) { var landing = plan.Admitted.Concat(plan.Dependents).ToList(); if (landing.All(stored => stored.Descriptor.IndexAccessors.Count == 0)) return; var replaced = new HashSet( plan.Admitted.Concat(evicted).Concat(plan.Dependents).Select(stored => stored.Key.Value), StringComparer.Ordinal); - var landingByKey = landing.ToDictionary(stored => stored.Key.Value, StringComparer.Ordinal); - - bool IsVariant(string key) => - landingByKey.TryGetValue(key, out var landed) ? landed.Variant != null : _variantKeys.Contains(key); - - // One pass over what lands, one lookup per (index, value): the rule is linear in the records landing. - var landedValues = new List<(CultStoredDocument Stored, (Type Type, string Alias) Index, string Value)>(); var landed = new Dictionary<((Type Type, string Alias) Index, string Value), List>(); + var schemaNames = landing.Select(stored => stored.Descriptor).GroupBy(descriptor => descriptor.DocumentType) + .ToDictionary(group => group.Key, group => group.First().SchemaName); foreach (var stored in landing) { foreach (var (alias, value) in plan.Projections[stored.Key.Value].Indexes) { - var index = (stored.Descriptor.DocumentType, alias); - landedValues.Add((stored, index, value)); - if (!landed.TryGetValue((index, value), out var landedKeys)) - landed[(index, value)] = landedKeys = new List(); - landedKeys.Add(stored.Key.Value); + var slot = ((stored.Descriptor.DocumentType, alias), value); + if (!landed.TryGetValue(slot, out var keys)) + landed[slot] = keys = new List(); + keys.Add(stored.Key.Value); } } - var holdersOf = new Dictionary<((Type Type, string Alias) Index, string Value), (string[] All, string[] Variants)>(); - foreach (var (stored, index, value) in landedValues) + foreach (var ((index, value), landedKeys) in landed.OrderBy(pair => pair.Key.Index.Alias, StringComparer.Ordinal)) { - if (!holdersOf.TryGetValue((index, value), out var known)) - { - var all = (_indexes.TryGetValue(index, out var held) && held.TryGetValue(value, out var heldKeys) - ? heldKeys.Where(heldKey => !replaced.Contains(heldKey)) - : Enumerable.Empty()) - .Concat(landed[(index, value)]) - .Distinct(StringComparer.Ordinal) - .ToArray(); - holdersOf[(index, value)] = known = (all, all.Where(IsVariant).ToArray()); - } - - var self = stored.Key.Value; - var holder = known.All.FirstOrDefault(candidate => candidate != self); - if (stored.Variant != null && holder != null) + var holders = landedKeys.AsEnumerable(); + if (_indexes.TryGetValue(index, out var held) && held.TryGetValue(value, out var heldKey) && !replaced.Contains(heldKey)) + holders = holders.Append(heldKey); + var distinct = holders.Distinct(StringComparer.Ordinal).OrderBy(key => key, StringComparer.Ordinal).Take(2).ToArray(); + if (distinct.Length > 1) throw new InvalidOperationException( - $"Variant {self} and {holder} both hold index {index.Alias} '{value}'; a variant must override its indexed member."); - var variantHolder = known.Variants.FirstOrDefault(candidate => candidate != self); - if (stored.Variant == null && variantHolder != null) - throw new InvalidOperationException( - $"Record {self} and variant {variantHolder} both hold index {index.Alias} '{value}'; " + - "a variant may not share an indexed value with another record."); + $"Records {distinct[0]} and {distinct[1]} ({schemaNames[index.Type]}) " + + $"both hold unique index '{index.Alias}'; an index value names one record."); } } @@ -3633,7 +3624,7 @@ namespace GameCult.Caching if (projection.Name != null) MapOf(_names, type)[projection.Name] = stored.Key.Value; foreach (var (alias, value) in projection.Indexes) - AddHolder(_indexes, (type, alias), value, stored.Key.Value); + MapOf(_indexes, (type, alias))[value] = stored.Key.Value; } // Documents are mutable, so a name or index value may have changed since it was indexed: drop by key. @@ -3645,26 +3636,7 @@ namespace GameCult.Caching if (_names.TryGetValue(type, out var names)) RemoveKey(names, stored.Key.Value); foreach (var pair in _indexes.Where(pair => pair.Key.Type == type)) - RemoveHolder(pair.Value, stored.Key.Value); - } - - private static void AddHolder(Dictionary<(Type Type, string Alias), Dictionary>> indexes, (Type, string) index, string value, string key) - { - if (!indexes.TryGetValue(index, out var map)) - indexes[index] = map = new Dictionary>(StringComparer.Ordinal); - if (!map.TryGetValue(value, out var keys)) - map[value] = keys = new List(); - keys.Remove(key); - keys.Add(key); - } - - private static void RemoveHolder(Dictionary> map, string key) - { - foreach (var pair in map.ToArray()) - { - if (pair.Value.Remove(key) && pair.Value.Count == 0) - map.Remove(pair.Key); - } + RemoveKey(pair.Value, stored.Key.Value); } private static Dictionary MapOf(Dictionary> maps, TKey key) @@ -4648,13 +4620,17 @@ namespace GameCult.Caching /// Whether the record's payload holds element ids, or null when the caller cannot tell. /// The registry the payload was encoded with. Given, the write decides the header for copied records that registry can read. It never decides ownership: the answer is the same lent or not. alone owns the record at the key when it declares the stored id (its own or one it lists compatible), or when it is the type that reads the stored entry and reads it whole, so an older id of its own schema name it reads whole is its own and is restamped. Everything else is refused with the file left as it was: a record of another registered type included, whatever the registry holds, and a record it would read only by dropping persisted slots. Retyping a key is a removal and an add. It serves this call only: the store keeps no registry. /// The call holds the store's gate while it runs. User code it runs must not enter a cache or another store; that throws . - /// The store is attached to a cache; write through the cache. + /// The store is attached to a cache; write through the cache. Also thrown when the record's type declares a (when the registry holds that type): its records are written through a cache. /// The file at the path is not a store this runtime can read. public void WriteRecord(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds = null, CultDocumentRegistry? registry = null) { if (record == null) throw new ArgumentNullException(nameof(record)); if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry)); - Cacheless(registry, () => WriteOne(record, catalogEntry, holdsIds)); + Cacheless(registry, () => + { + RefuseUniqueIndexedCacheless(AttachedRegistry, catalogEntry.SchemaId); + WriteOne(record, catalogEntry, holdsIds); + }); } /// @@ -4671,7 +4647,7 @@ namespace GameCult.Caching /// The stored-at stamp, or null for now. /// The registry that describes and encodes the document, or null for . The document type's entry is taken from it, and the registry decides the header for copied records it can read. It never decides ownership, so the answer is the same for any registry that holds the type: the document's entry owns a record whose id it declares (its own or one it lists compatible), and a record of an older id of its own schema name that it reads whole; everything else is refused, another registered type's record included, and a record it would read only by dropping persisted slots. Retyping a key is a removal and an add. It serves this call only: the store keeps no registry. /// The call holds the store's gate while it runs. User code it runs (property getters while the document is encoded, constructors while a copied record is read) must not enter a cache or another store; that throws , carried by the encoder's wrapper when the code ran inside it. - /// The store is attached to a cache; write through the cache. + /// The store is attached to a cache; write through the cache. Also thrown when the record's type declares a (when the registry holds that type): its records are written through a cache. /// The file at the path is not a store this runtime can read. /// The document holds an element id or list a cache write refuses. public void WriteDocument(CultRecordKey key, TDocument document, string? storedAt = null, CultDocumentRegistry? registry = null) @@ -4680,6 +4656,7 @@ namespace GameCult.Caching if (document == null) throw new ArgumentNullException(nameof(document)); var resolvedRegistry = registry ?? CultDocumentRegistry.Shared; var catalogEntry = resolvedRegistry.GetRequired().ToCatalogEntry(); + RefuseUniqueIndexedCacheless(resolvedRegistry, catalogEntry.SchemaId); Cacheless(resolvedRegistry, () => { var ids = CultElementIds.Plan(typeof(TDocument), document, key.Value, deterministic: false); @@ -4705,6 +4682,14 @@ namespace GameCult.Caching }); } + // A unique index is judged against every held record, which a cacheless write does not hold. + private static void RefuseUniqueIndexedCacheless(CultDocumentRegistry? registry, string schemaId) + { + if (registry != null && registry.HoldsUniqueIndex(schemaId)) + throw new InvalidOperationException( + "A record of a type with a unique index is written through a cache; a cacheless write cannot judge uniqueness."); + } + private void WriteOne(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds) { using (AcquireLock(wait: true)) @@ -4727,7 +4712,7 @@ namespace GameCult.Caching RefuseUndeclaredOnDisk(durable, upserts, removals, checks); if (upserts.Count == 0 && removals.Count == 0 && (checks.Any() || StagedChecks.Count > 0)) return Array.Empty<(CultStagedRecord, string)>(); - RefuseWriteIntoMovedVariantStore(durable, upserts, removals); + RefuseWriteIntoMovedStore(durable, upserts, removals); var durableRecords = durable?.Snapshot.Records ?? Array.Empty(); // A record written over one the store holds is stored at a storedAt later than the one it replaces, whoever wrote that. var replaced = durableRecords.ToDictionary(record => record.Key, record => record.StoredAt, StringComparer.Ordinal); @@ -4812,10 +4797,11 @@ namespace GameCult.Caching return false; } - private void RefuseWriteIntoMovedVariantStore(Durable? durable, IReadOnlyCollection upserts, IReadOnlyCollection removals) + private void RefuseWriteIntoMovedStore(Durable? durable, IReadOnlyCollection upserts, IReadOnlyCollection removals) { var records = durable?.Snapshot.Records ?? Array.Empty(); - if (records.All(record => record.Variant == null) && upserts.All(staged => staged.Record.Variant == null)) + if (records.All(record => record.Variant == null) && upserts.All(staged => staged.Record.Variant == null) && + !upserts.Any(staged => AttachedRegistry?.HoldsUniqueIndex(staged.Entry.SchemaId) == true)) return; var changed = new HashSet(StringComparer.Ordinal); var versions = records.ToDictionary(record => record.Key, record => durable!.Version(record.Key), StringComparer.Ordinal); @@ -4852,9 +4838,9 @@ namespace GameCult.Caching }, inner => new CultWriteConflictException( Cache == null - ? $"{this} holds a variant and was written by a writer with no CultCache, which cannot resolve variants; nothing was written. " + + ? $"{this} holds a variant or a unique index and was written by a writer with no CultCache, which cannot resolve variants; nothing was written. " + $"Records it would have written or removed: {string.Join(", ", recordKeys.Select(key => $"'{key}'"))}; records that differ from what the writer has read: {string.Join(", ", changedKeys.Select(key => $"'{key}'"))}." - : $"{this} changed since this cache last read it, and it holds a variant, which resolves against other records; nothing was written. " + + : $"{this} changed since this cache last read it, and it holds a variant or a record with a unique index, which resolve against other records; nothing was written. " + $"Records this write would have written or removed: {string.Join(", ", recordKeys.Select(key => $"'{key}'"))}; records that changed: {string.Join(", ", changedKeys.Select(key => $"'{key}'"))}. " + (inner == null ? "The cache reloaded them; retry the write against what the store holds now." diff --git a/src/GameCult.Caching/CultDocumentContracts.cs b/src/GameCult.Caching/CultDocumentContracts.cs index b439ac9f..0f8235ce 100644 --- a/src/GameCult.Caching/CultDocumentContracts.cs +++ b/src/GameCult.Caching/CultDocumentContracts.cs @@ -38,6 +38,7 @@ namespace GameCult.Caching { } + // A unique lookup: within one document type, a non-blank value names one record. Also filterable by CultNet selection. [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)] public sealed class CultIndexAttribute : Attribute { @@ -49,6 +50,19 @@ namespace GameCult.Caching public string? Alias { get; } } + // Names the member's selection and reference role (the alias, or the member name). Implies nothing about uniqueness: + // many records may hold one value, and a role member is never indexed for lookup. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)] + public sealed class CultRoleAttribute : Attribute + { + public CultRoleAttribute(string? alias = null) + { + Alias = alias; + } + + public string? Alias { get; } + } + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] public sealed class CultReferenceAttribute : Attribute { diff --git a/src/GameCult.Geometry/CultGeometryDocuments.cs b/src/GameCult.Geometry/CultGeometryDocuments.cs index 169b9ae1..6756d39a 100644 --- a/src/GameCult.Geometry/CultGeometryDocuments.cs +++ b/src/GameCult.Geometry/CultGeometryDocuments.cs @@ -32,7 +32,7 @@ namespace GameCult.Geometry public string DomainId { get; set; } = string.Empty; [Key(1)] - [CultIndex] + [CultRole] public string RootKey { get; set; } = string.Empty; [Key(2)] @@ -164,7 +164,7 @@ namespace GameCult.Geometry public CultRecordRef DomainKey { get; set; } [Key(2)] - [CultIndex] + [CultRole] public string WorkerGroup { get; set; } = string.Empty; [Key(3)] @@ -326,7 +326,7 @@ namespace GameCult.Geometry public CultRecordRef CutKey { get; set; } [Key(2)] - [CultIndex] + [CultRole] public string SelectedCutId { get; set; } = string.Empty; [Key(3)] diff --git a/src/GameCult.Mesh/CultMeshBodyPublications.cs b/src/GameCult.Mesh/CultMeshBodyPublications.cs index 2358c277..86e49365 100644 --- a/src/GameCult.Mesh/CultMeshBodyPublications.cs +++ b/src/GameCult.Mesh/CultMeshBodyPublications.cs @@ -34,7 +34,7 @@ namespace GameCult.Mesh public sealed class CultMeshBodyPublicationDocument { [Key(0), CultName] public string BodyId { get; set; } = string.Empty; - [Key(1), CultIndex] public string ProducerId { get; set; } = string.Empty; + [Key(1), CultRole] public string ProducerId { get; set; } = string.Empty; [Key(2)] public string SchemaId { get; set; } = string.Empty; [Key(3)] public int LayoutVersion { get; set; } [Key(4)] public long ByteSize { get; set; } diff --git a/src/GameCult.Mesh/CultMeshCdn.cs b/src/GameCult.Mesh/CultMeshCdn.cs index 7f47f291..25650f1c 100644 --- a/src/GameCult.Mesh/CultMeshCdn.cs +++ b/src/GameCult.Mesh/CultMeshCdn.cs @@ -142,17 +142,17 @@ namespace GameCult.Mesh /// Artifact kind, such as asset, build, or package. [Key(1)] - [CultIndex] + [CultRole] public string Kind { get; set; } = CultMeshCdnArtifactKinds.Asset; /// Caller-defined artifact version. [Key(2)] - [CultIndex] + [CultRole] public string Version { get; set; } = string.Empty; /// SHA-256 hash of the full materialized artifact. [Key(3)] - [CultIndex] + [CultRole] public string ContentHash { get; set; } = string.Empty; /// Full materialized artifact size. diff --git a/src/GameCult.Mesh/CultMeshEntityPrefabs.cs b/src/GameCult.Mesh/CultMeshEntityPrefabs.cs index afb0b0bf..8d3f26ef 100644 --- a/src/GameCult.Mesh/CultMeshEntityPrefabs.cs +++ b/src/GameCult.Mesh/CultMeshEntityPrefabs.cs @@ -193,12 +193,12 @@ namespace GameCult.Mesh /// Caller-defined package version. [Key(1)] - [CultIndex] + [CultRole] public string Version { get; set; } = string.Empty; /// Tool that authored the package, such as Brokkr Blender. [Key(2)] - [CultIndex] + [CultRole] public string SourceTool { get; set; } = string.Empty; /// Source scene name or path. @@ -207,12 +207,12 @@ namespace GameCult.Mesh /// Source Blender collection name. [Key(4)] - [CultIndex] + [CultRole] public string SourceCollection { get; set; } = string.Empty; /// Stable hash of graph structure and referenced assets. [Key(5)] - [CultIndex] + [CultRole] public string ContentHash { get; set; } = string.Empty; /// Package creation timestamp in round-trip UTC format. diff --git a/src/GameCult.Mesh/CultMeshSimulationFacts.cs b/src/GameCult.Mesh/CultMeshSimulationFacts.cs index 11c7d296..562be44a 100644 --- a/src/GameCult.Mesh/CultMeshSimulationFacts.cs +++ b/src/GameCult.Mesh/CultMeshSimulationFacts.cs @@ -20,14 +20,14 @@ namespace GameCult.Mesh /// Stable fact identifier. /// [Key(0)] - [CultIndex] + [CultRole] public string FactId { get; set; } = string.Empty; /// /// Shard containing the simulation. /// [Key(1)] - [CultIndex] + [CultRole] public string ShardId { get; set; } = string.Empty; /// @@ -40,21 +40,21 @@ namespace GameCult.Mesh /// Simulation frame or tick. /// [Key(3)] - [CultIndex] + [CultRole] public long Frame { get; set; } /// /// Subject the fact is about. /// [Key(4)] - [CultIndex] + [CultRole] public string SubjectId { get; set; } = string.Empty; /// /// Fact kind, such as hit, collision, or ownership. /// [Key(5)] - [CultIndex] + [CultRole] public string ClaimKind { get; set; } = string.Empty; /// diff --git a/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs b/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs index daa71c81..ed77bb90 100644 --- a/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs +++ b/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs @@ -226,8 +226,8 @@ namespace GameCult.Caching.Tests public void DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName() { var type = Document("DupAlias"); - Field(type, "First", typeof(string), Key(0), IndexAttr("shared")); - Field(type, "Second", typeof(string), Key(1), IndexAttr("shared")); + Field(type, "First", typeof(string), Key(0), RoleAttr("shared")); + Field(type, "Second", typeof(string), Key(1), RoleAttr("shared")); var message = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message; Assert.That(message, Does.Contain("DupAlias")); @@ -236,7 +236,7 @@ namespace GameCult.Caching.Tests Assert.That(message, Does.Contain("shared")); } - // R-AL/SM-6: a plain data member's bare name (it declares no [CultIndex], so its role in + // R-AL/SM-6: a plain data member's bare name (it declares no [CultRole], so its role in // ReferencesOf/ReferenceMembers falls back to its own MemberName) colliding with a reference // member's explicit alias. D10 above only groups members that declare an alias and misses // this; before the fix this registered without error and then every hop through the @@ -246,7 +246,7 @@ namespace GameCult.Caching.Tests { var type = Document("OwnerCollision"); Field(type, "Owner", typeof(string), Key(0)); - Field(type, "Ref", typeof(CultRecordRef), Key(1), IndexAttr("Owner")); + Field(type, "Ref", typeof(CultRecordRef), Key(1), RoleAttr("Owner")); var message = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message; Assert.That(message, Does.Contain("OwnerCollision")); @@ -295,8 +295,8 @@ namespace GameCult.Caching.Tests private static CustomAttributeBuilder Key(int slot) => new(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { slot }); - private static CustomAttributeBuilder IndexAttr(string alias) => - new(typeof(CultIndexAttribute).GetConstructor(new[] { typeof(string) })!, new object?[] { alias }); + private static CustomAttributeBuilder RoleAttr(string alias) => + new(typeof(CultRoleAttribute).GetConstructor(new[] { typeof(string) })!, new object?[] { alias }); private static CustomAttributeBuilder ReferenceAttr(Type? targetType, bool many) => new(typeof(CultReferenceAttribute).GetConstructor(new[] { typeof(Type), typeof(bool) })!, new object?[] { targetType, many }); @@ -308,7 +308,7 @@ namespace GameCult.Caching.Tests public string Name = string.Empty; [Key(1)] - [CultIndex("mass")] + [CultRole("mass")] public float Mass; } @@ -325,7 +325,7 @@ namespace GameCult.Caching.Tests public sealed class FixtureLeafB : FixtureMiddle { [Key(2)] - [CultIndex("weight")] + [CultRole("weight")] public decimal? Weight; } @@ -341,19 +341,19 @@ namespace GameCult.Caching.Tests public string Name = string.Empty; [Key(1)] - [CultIndex("big_integer")] + [CultRole("big_integer")] public long BigInteger; [Key(2)] - [CultIndex("trimmed_decimal")] + [CultRole("trimmed_decimal")] public decimal TrimmedDecimal; [Key(3)] - [CultIndex("wide_double")] + [CultRole("wide_double")] public double WideDouble; [Key(4)] - [CultIndex("tiny_double")] + [CultRole("tiny_double")] public double TinyDouble; } diff --git a/tests/GameCult.Caching.Tests/DocumentShapeTests.cs b/tests/GameCult.Caching.Tests/DocumentShapeTests.cs index c34834ca..0d473a5a 100644 --- a/tests/GameCult.Caching.Tests/DocumentShapeTests.cs +++ b/tests/GameCult.Caching.Tests/DocumentShapeTests.cs @@ -394,7 +394,7 @@ namespace GameCult.Caching.Tests public class Gear { [Key(0)] [CultName] public string Name { get; set; } = ""; - [Key(1)] [CultIndex("slot")] public string Slot { get; set; } = ""; + [Key(1)] [CultRole("slot")] public string Slot { get; set; } = ""; } [CultDocument("shape.weapon", "shape.weapon.v1")] diff --git a/tests/GameCult.Caching.Tests/EmittedDocumentTypes.cs b/tests/GameCult.Caching.Tests/EmittedDocumentTypes.cs index ac6a6dad..5ca50c69 100644 --- a/tests/GameCult.Caching.Tests/EmittedDocumentTypes.cs +++ b/tests/GameCult.Caching.Tests/EmittedDocumentTypes.cs @@ -12,7 +12,7 @@ namespace GameCult.Caching.Tests // 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); + internal readonly record struct Field(string Name, Type Type, int Key, bool IsName = false, string? Index = null, string? Role = null); internal static Type Emit(string typeName, string schemaName, string schemaVersion, Field[]? fields = null, string[]? compatibleSchemaIds = null) { @@ -37,6 +37,10 @@ namespace GameCult.Caching.Tests builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { field.Key })); if (field.IsName) builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(CultNameAttribute).GetConstructor(Type.EmptyTypes)!, Array.Empty())); + if (field.Index != null) + builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(CultIndexAttribute).GetConstructor(new[] { typeof(string) })!, new object[] { field.Index })); + if (field.Role != null) + builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(CultRoleAttribute).GetConstructor(new[] { typeof(string) })!, new object[] { field.Role })); } return type.CreateType()!; diff --git a/tests/GameCult.Caching.Tests/FirstCallTests.cs b/tests/GameCult.Caching.Tests/FirstCallTests.cs index 8540d9d0..8fad1229 100644 --- a/tests/GameCult.Caching.Tests/FirstCallTests.cs +++ b/tests/GameCult.Caching.Tests/FirstCallTests.cs @@ -14,7 +14,7 @@ using MessagePack; public sealed class Item { [Key(0)] [CultName] public string Name = string.Empty; - [Key(1)] [CultIndex] public string Category = string.Empty; + [Key(1)] public string Category = string.Empty; [Key(2)] public int Value; } diff --git a/tests/GameCult.Caching.Tests/UniqueIndexTests.cs b/tests/GameCult.Caching.Tests/UniqueIndexTests.cs new file mode 100644 index 00000000..4808cea2 --- /dev/null +++ b/tests/GameCult.Caching.Tests/UniqueIndexTests.cs @@ -0,0 +1,313 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // variants:ruling:unique-indexes. Within one document type and one [CultIndex] alias, no two held records hold one + // non-blank value; CultCache.Resolve judges every commit and every load, and a refusal never echoes the value. + public class UniqueIndexTests + { + private const string Canary = "CANARY-7f3a"; + + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] + { + typeof(UqItem), typeof(UqTwin), typeof(UqTagged) + }); + + // Emitted, so CultDocumentRegistry.Shared never scans them (one schema declared two ways, and a member Shared would refuse). + // The same schema name, version and shape, once as a role and once as a unique index: a role store holds the pair, the index type refuses it. + private static readonly Type UqLoadRole = EmittedDocumentTypes.Emit( + "UqLoadRole", "tests.uq_load", "tests.uq_load.v1", new[] { new EmittedDocumentTypes.Field("Code", typeof(string), 0, Role: "code") }); + private static readonly Type UqLoadIndex = EmittedDocumentTypes.Emit( + "UqLoadIndex", "tests.uq_load", "tests.uq_load.v1", new[] { new EmittedDocumentTypes.Field("Code", typeof(string), 0, Index: "code") }); + private static readonly Type UqBoth = EmittedDocumentTypes.Emit( + "UqBoth", "tests.uq_both", "tests.uq_both.v1", new[] { new EmittedDocumentTypes.Field("Both", typeof(string), 0, Index: "x", Role: "x") }); + + private static object Load(Type type, string code) => EmittedDocumentTypes.New(type, ("Code", code)); + + private string _directory = string.Empty; + + [SetUp] + public void CreateDirectory() + { + _directory = Path.Combine(Path.GetTempPath(), $"cultlib-uidx-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void DeleteDirectory() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + + private string PathOf(string name) => Path.Combine(_directory, name); + + private static CultCache Open(string path, CultDocumentRegistry? registry = null) => + CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry ?? Registry }); + + private static CultRecordKey Key(string name) => new(name); + + private static UqItem Item(string? sku) => new() { Sku = sku }; + + private static InvalidOperationException Refused(TestDelegate write) => Assert.Throws(write)!; + + [Test] + public void ABatchLeavingTwoRecordsWithOneValueIsRefusedNamingBothKeysAndTheIndex() + { + var path = PathOf("items.cc"); + using var cache = Open(path); + cache.Commit(batch => batch.Upsert(typeof(UqItem), Item("keep"), Key("seed"))); + var before = File.ReadAllBytes(path); + + var refused = Refused(() => cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item(Canary), Key("zeta")); + batch.Upsert(typeof(UqItem), Item(Canary), Key("alpha")); + })); + + Assert.That(refused.Message, Does.Contain("alpha").And.Contain("zeta").And.Contain("sku").And.Contain("tests.uq_item")); + Assert.That(refused.Message.IndexOf("alpha", StringComparison.Ordinal), Is.LessThan(refused.Message.IndexOf("zeta", StringComparison.Ordinal)), "keys in ordinal order"); + Assert.That(refused.Message, Does.Not.Contain(Canary), "the value is never echoed"); + Assert.That(cache.Get(Key("alpha")), Is.Null); + Assert.That(cache.Get(Key("zeta")), Is.Null); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the store is byte-identical"); + Assert.That(cache.GetByIndex("sku", Canary), Is.Null); + } + + [Test] + public async Task AWriteOfAHeldValueAtAnotherKeyIsRefused() + { + using var cache = Open(PathOf("items.cc")); + await cache.UpsertAsync(typeof(UqItem), Item(Canary), Key("first")); + + var single = Assert.ThrowsAsync(() => cache.UpsertAsync(typeof(UqItem), Item(Canary), Key("second")))!; + Assert.That(single.Message, Does.Contain("first").And.Contain("second").And.Contain("sku").And.Not.Contain(Canary)); + var batched = Refused(() => cache.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("third")))); + Assert.That(batched.Message, Does.Contain("first").And.Contain("third")); + + Assert.That(cache.Get(Key("second")), Is.Null); + Assert.That(cache.Get(Key("third")), Is.Null); + Assert.That(cache.GetByIndex("sku", Canary), Is.Not.Null); + + await cache.UpsertAsync(typeof(UqItem), Item(Canary), Key("first")); + } + + [Test] + public void SwappingTwoValuesInOneCommitLandsAndBothAreFound() + { + var path = PathOf("items.cc"); + using (var cache = Open(path)) + { + cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item("one"), Key("a")); + batch.Upsert(typeof(UqItem), Item("two"), Key("b")); + }); + cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item("two"), Key("a")); + batch.Upsert(typeof(UqItem), Item("one"), Key("b")); + }); + + Assert.That(cache.GetStored(Key("a"))!.Document, Is.SameAs(cache.GetByIndex("sku", "two"))); + Assert.That(cache.GetStored(Key("b"))!.Document, Is.SameAs(cache.GetByIndex("sku", "one"))); + } + + using var reopened = Open(path); + Assert.That(reopened.GetStored(Key("a"))!.Document, Is.SameAs(reopened.GetByIndex("sku", "two"))); + Assert.That(reopened.GetStored(Key("b"))!.Document, Is.SameAs(reopened.GetByIndex("sku", "one"))); + } + + [Test] + public void AValueMovesWhenItsHolderIsRemovedOrChangedInTheSameCommit() + { + using var cache = Open(PathOf("items.cc")); + cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item("one"), Key("a")); + batch.Upsert(typeof(UqItem), Item("two"), Key("b")); + }); + + cache.Commit(batch => + { + batch.Remove(Key("a")); + batch.Upsert(typeof(UqItem), Item("one"), Key("c")); + }); + Assert.That(cache.GetStored(Key("c"))!.Document, Is.SameAs(cache.GetByIndex("sku", "one")), "removed holder released its value"); + + cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item("three"), Key("b")); + batch.Upsert(typeof(UqItem), Item("two"), Key("d")); + }); + Assert.That(cache.GetStored(Key("d"))!.Document, Is.SameAs(cache.GetByIndex("sku", "two")), "changed holder released its value"); + Assert.That(cache.GetStored(Key("b"))!.Document, Is.SameAs(cache.GetByIndex("sku", "three"))); + Assert.That(cache.GetByIndex("sku", "one")!.Sku, Is.EqualTo("one")); + } + + [Test] + public void BlankValuesNeverConflict() + { + var path = PathOf("items.cc"); + using (var cache = Open(path)) + { + cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item(null), Key("a")); + batch.Upsert(typeof(UqItem), Item(""), Key("b")); + batch.Upsert(typeof(UqItem), Item(" "), Key("c")); + batch.Upsert(typeof(UqItem), Item(null), Key("d")); + }); + Assert.That(cache.GetByIndex("sku", ""), Is.Null); + Assert.That(cache.GetByIndex("sku", " "), Is.Null); + } + + using var reopened = Open(path); + Assert.That(reopened.Get(Key("d")), Is.Not.Null, "a store of blank values reopens"); + } + + [Test] + public void TwoTypesMayShareAnAliasAndAValue() + { + using var cache = Open(PathOf("items.cc")); + cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item("same"), Key("item")); + batch.Upsert(typeof(UqTwin), new UqTwin { Sku = "same" }, Key("twin")); + }); + + Assert.That(cache.GetByIndex("sku", "same"), Is.Not.Null); + Assert.That(cache.GetByIndex("sku", "same"), Is.Not.Null); + Assert.That(Registry.GetRequired().DeclaredMembers.Single().IndexAlias, Is.EqualTo("sku"), "a unique index addresses selection as a role does"); + } + + [Test] + public void RoleMembersMayShareValuesAndStaySelectable() + { + var path = PathOf("tagged.cc"); + using (var cache = Open(path)) + { + cache.Commit(batch => + { + batch.Upsert(typeof(UqTagged), new UqTagged { Kind = "weapon" }, Key("a")); + batch.Upsert(typeof(UqTagged), new UqTagged { Kind = "weapon" }, Key("b")); + }); + Assert.That(cache.GetByIndex("kind", "weapon"), Is.Null, "a role is not a lookup"); + } + + using var reopened = Open(path); + Assert.That(reopened.Get(Key("b")), Is.Not.Null, "a role pair reopens"); + var member = Registry.GetRequired().DeclaredMembers.Single(candidate => candidate.MemberName == nameof(UqTagged.Kind)); + Assert.That(member.IndexAlias, Is.EqualTo("kind"), "the role still supplies the selection address"); + } + + [Test] + public void AStoreHoldingDuplicatesRefusesToOpenNamingBothKeysAndTheIndex() + { + var path = PathOf("load.cc"); + var roleRegistry = CultDocumentRegistry.ForTypes(new[] { UqLoadRole }); + using (var writer = Open(path, roleRegistry)) + { + writer.Commit(batch => + { + batch.Upsert(UqLoadRole, Load(UqLoadRole, Canary), Key("m")); + batch.Upsert(UqLoadRole, Load(UqLoadRole, Canary), Key("k")); + }); + } + + var before = File.ReadAllBytes(path); + var indexRegistry = CultDocumentRegistry.ForTypes(new[] { UqLoadIndex }); + var refused = Refused(() => Open(path, indexRegistry).Dispose()); + + Assert.That(refused.Message, Does.Contain("k").And.Contain("m").And.Contain("code").And.Not.Contain(Canary)); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the file is untouched"); + + using var cache = new CultCache(indexRegistry); + var store = new SingleFileMessagePackBackingStore(path); + Refused(() => cache.AddBackingStore(store)); + Assert.That(cache.BackingStores, Is.Empty, "the refused store is not attached"); + Assert.That(cache.Get(Key("k")), Is.Null); + } + + [Test] + public void AWriteOntoAMovedFileIsAConflictAndTheRetryIsRefused() + { + var path = PathOf("items.cc"); + using var seed = Open(path); + seed.Commit(batch => batch.Upsert(typeof(UqItem), Item("base"), Key("a"))); + using var second = Open(path); + using (var first = Open(path)) + first.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("b"))); + + var conflict = Assert.Throws( + () => second.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("c"))))!; + Assert.That(conflict.Message, Does.Contain("unique index")); + Assert.That(second.Get(Key("b")), Is.Not.Null, "the moved record is loaded"); + + var retry = Refused(() => second.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("c")))); + Assert.That(retry.Message, Does.Contain("b").And.Contain("c").And.Not.Contain(Canary)); + + using var reopened = Open(path); + Assert.That(reopened.Get(Key("c")), Is.Null, "the file never holds the pair"); + Assert.That(reopened.Get(Key("b")), Is.Not.Null); + } + + [Test] + public void CachelessWritesOfAUniqueIndexedTypeAreRefused() + { + var path = PathOf("items.cc"); + using (var cache = Open(path)) + cache.Commit(batch => batch.Upsert(typeof(UqItem), Item("a"), Key("a"))); + var before = File.ReadAllBytes(path); + var store = new SingleFileMessagePackBackingStore(path); + + Refused(() => store.WriteDocument(Key("x"), Item(Canary), registry: Registry)); + var entry = Registry.GetRequired().ToCatalogEntry(); + Refused(() => store.WriteRecord( + new CultPersistedRecord { Key = "y", SchemaId = entry.SchemaId, StoredAt = DateTimeOffset.UtcNow.ToString("O"), Payload = new byte[] { 0x90 } }, + entry, + registry: Registry)); + + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the file is byte-identical"); + store.WriteDocument(Key("t"), new UqTagged { Kind = "k" }, registry: Registry); + using var reopened = Open(path); + Assert.That(reopened.Get(Key("t")), Is.Not.Null, "a role-only type still writes cacheless"); + } + + [Test] + public void AMemberWithBothAttributesIsRefusedAtRegistration() + { + var refused = Refused(() => CultDocumentRegistry.ForTypes(new[] { UqBoth })); + Assert.That(refused.Message, Does.Contain("Both").And.Contain("[CultIndex]").And.Contain("[CultRole]")); + } + + [CultDocument("tests.uq_item", "tests.uq_item.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class UqItem + { + [Key(0)] [CultIndex("sku")] public string? Sku { get; set; } + } + + [CultDocument("tests.uq_twin", "tests.uq_twin.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class UqTwin + { + [Key(0)] [CultIndex("sku")] public string? Sku { get; set; } + } + + [CultDocument("tests.uq_tagged", "tests.uq_tagged.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class UqTagged + { + [Key(0)] [CultRole("kind")] public string? Kind { get; set; } + } + } +} diff --git a/tests/GameCult.Caching.Tests/VariantTests.cs b/tests/GameCult.Caching.Tests/VariantTests.cs index 8f574cd1..17f9ba26 100644 --- a/tests/GameCult.Caching.Tests/VariantTests.cs +++ b/tests/GameCult.Caching.Tests/VariantTests.cs @@ -580,7 +580,7 @@ namespace GameCult.Caching.Tests { cache.Override(nameof(VariantGear.Name), "laser big") }))); - Assert.That(inherited.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("code").And.Contain("l1")); + Assert.That(inherited.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("code").And.Not.Contain("l1"), "the value is not echoed"); var explicitly = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] { @@ -631,7 +631,7 @@ namespace GameCult.Caching.Tests SeedBig(cache); var refused = Refused(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("laser big"), other))); - Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("laser big")); + Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Not.Contain("laser big")); var single = Assert.ThrowsAsync( () => cache.UpsertAsync(Other("laser big"), new CultRecordHandle(other)))!; @@ -660,7 +660,7 @@ namespace GameCult.Caching.Tests cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("laser big"), other)); var refused = Refused(() => SeedBig(cache)); - Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain(other.Value).And.Contain("code").And.Contain("laser big")); + Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain(other.Value).And.Contain("code").And.Not.Contain("laser big")); Assert.That(cache.Get(BigKey), Is.Null, "nothing landed"); // The same batch, either staging order: the result is what is judged. @@ -685,8 +685,9 @@ namespace GameCult.Caching.Tests })); Assert.That(sameBatchVariantFirst.Message, Does.Contain(BigKey.Value).And.Contain("taker")); - // Plain-vs-plain sharing is not this rule's business. - Assert.DoesNotThrow(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("l1"), new CultRecordKey("twin")))); + // Plain-vs-plain sharing is the same rule. + var plain = Refused(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("l1"), new CultRecordKey("twin")))); + Assert.That(plain.Message, Does.Contain("twin").And.Contain(BaseKey.Value)); } using var reopened = Open(path); @@ -819,7 +820,7 @@ namespace GameCult.Caching.Tests using var a = Open(path); using (var b = Open(path)) b.Commit(batch => batch.Upsert(typeof(VariantGear), Other("zc"), z)); - a.Commit(batch => batch.Upsert(typeof(VariantGear), Other("yc"), new CultRecordKey("y"))); + a.Commit(batch => batch.Upsert(typeof(VariantOther), new VariantOther { Name = "yc" }, new CultRecordKey("y"))); var conflict = Assert.Throws(() => SeedBig(a))!; @@ -1100,22 +1101,8 @@ namespace GameCult.Caching.Tests Assert.That(reloaded.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" })); } - // ---- the held index is truthful: every holder of a value, so no rule judges a variant against a lossy view ---- - private static VariantGear Named(string name, string code) => new() { Name = name, Power = 1, Code = code }; - private static CultRecordKey[] SharedCodePair(CultCache cache) - { - var p1 = new CultRecordKey("p1"); - var p2 = new CultRecordKey("p2"); - cache.Commit(batch => - { - batch.Upsert(typeof(VariantGear), Named("p1", "x"), p1); - batch.Upsert(typeof(VariantGear), Named("p2", "x"), p2); - }); - return new[] { p1, p2 }; - } - private static void VariantOnCode(CultCache cache, string code) => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] { @@ -1123,73 +1110,6 @@ namespace GameCult.Caching.Tests cache.Override(nameof(VariantGear.Code), code) })); - [Test] - public void RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable() - { - var path = PathOf("gear.cc"); - using (var cache = Open(path)) - { - var pair = SharedCodePair(cache); - Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p1"), "the smallest key"); - - cache.Remove(pair[0]); - Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p2"), "removing the holder finds the other one"); - - cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("p1", "x"), pair[0])); - Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p1")); - cache.Remove(pair[1]); - Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p1"), "removing the other changes nothing"); - cache.Remove(pair[0]); - Assert.That(cache.GetByIndex("code", "x"), Is.Null); - cache.BackingStores[0].PushAll(); - } - - using var reopened = Open(path); - Assert.That(reopened.GetByIndex("code", "x"), Is.Null); - } - - [Test] - public void AVariantOnAValueAPlainRecordStillHoldsAfterItsTwinWasRemovedIsRefusedAndTheStoreReopens() - { - var path = PathOf("gear.cc"); - using (var cache = Open(path)) - { - SeedBase(cache); - var pair = SharedCodePair(cache); - cache.Remove(pair[1]); - - var refused = Refused(() => VariantOnCode(cache, "x")); - Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain("p1").And.Contain("code").And.Contain("'x'")); - Assert.That(cache.Get(BigKey), Is.Null, "nothing landed"); - cache.BackingStores[0].PushAll(); - } - - using var reopened = Open(path); - Assert.That(reopened.Get(BigKey), Is.Null); - Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("p1")); - } - - [Test] - public void AVariantOnAValueAPlainRecordStillHoldsAfterItsTwinChangedItsValueIsRefusedAndTheStoreReopens() - { - var path = PathOf("gear.cc"); - using (var cache = Open(path)) - { - SeedBase(cache); - var pair = SharedCodePair(cache); - cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("p2", "y"), pair[1])); - - var refused = Refused(() => VariantOnCode(cache, "x")); - Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain("p1").And.Contain("'x'")); - Assert.DoesNotThrow(() => VariantOnCode(cache, "z"), "an unshared value is fine"); - cache.BackingStores[0].PushAll(); - } - - using var reopened = Open(path); - Assert.That(reopened.GetByIndex("code", "z")!.Name, Is.EqualTo("laser big")); - Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("p1")); - } - // ---- a merge onto the file is judged on the set the file will hold ---- [Test] @@ -1218,7 +1138,7 @@ namespace GameCult.Caching.Tests var conflict = Assert.Throws(Write)!; Assert.That(conflict.ChangedKeys, Does.Contain(BigKey.Value)); var refused = Refused(Write); - Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("shared"), "the retry is judged by Resolve"); + Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Not.Contain("shared"), "the retry is judged by Resolve"); Assert.That(b.Get(other), Is.Null, "nothing landed in memory"); } @@ -1408,24 +1328,24 @@ namespace GameCult.Caching.Tests { var path = PathOf("gear.cc"); var other = new CultRecordKey("other"); - var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(VariantGear) }); + var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(VariantOther) }); using (var seed = Open(path)) SeedBase(seed); using (var a = Open(path)) using (var b = Open(path, registry: narrow)) { - a.Commit(batch => batch.Upsert(typeof(VariantOther), new VariantOther { Name = "foreign" }, new CultRecordKey("foreign"))); + a.Commit(batch => batch.Upsert(typeof(VariantGear), Named("foreign", "foreign-code"), new CultRecordKey("foreign"))); Assert.That(b.Commit(batch => { batch.Expect(other, null); - batch.Upsert(typeof(VariantGear), Named("other", "fine"), other); + batch.Upsert(typeof(VariantOther), new VariantOther { Name = "fine" }, other); }), Is.True); } using var reopened = Open(path); - Assert.That(reopened.Get(other), Is.Not.Null); - Assert.That(reopened.Get(new CultRecordKey("foreign")), Is.Not.Null, "the other writer's record survives the merge"); + Assert.That(reopened.Get(other), Is.Not.Null); + Assert.That(reopened.Get(new CultRecordKey("foreign")), Is.Not.Null, "the other writer's record survives the merge"); } [Test] @@ -1448,28 +1368,6 @@ namespace GameCult.Caching.Tests } } - [Test] - public void PlainDuplicatesFindTheSmallestKeyLiveAfterAFlushAndAfterAReopen() - { - var path = PathOf("gear.cc"); - using (var cache = Open(path)) - { - cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("nb", "x"), new CultRecordKey("b"))); - cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("na", "x"), new CultRecordKey("a"))); - Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "live"); - cache.BackingStores[0].PushAll(); - Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "after a flush"); - } - - using (var reopened = Open(path)) - { - Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "after a reopen"); - reopened.Remove(new CultRecordKey("a")); - reopened.Commit(batch => batch.Upsert(typeof(VariantGear), Named("na", "x"), new CultRecordKey("a"))); - Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "rewriting the smaller key does not change the choice"); - } - } - [Test] public void ADroppedSlotIsReportedOncePerRecord() { diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs index b2dfcf3c..86b966ee 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs @@ -493,11 +493,11 @@ namespace GameCult.Networking.Tests public string Name = string.Empty; [Key(1)] - [CultIndex("mass")] + [CultRole("mass")] public float Mass; [Key(2)] - [CultIndex("kind")] + [CultRole("kind")] public string Kind = string.Empty; } diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs index 9ecc4556..030ae100 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs @@ -581,7 +581,7 @@ namespace GameCult.Networking.Tests return instance; } - // A plain data member "Owner" with no [CultIndex] (its role falls back to its bare name) + // A plain data member "Owner" with no [CultRole] (its role falls back to its bare name) // and a reference member whose alias is explicitly "Owner" - the exact shape Soul probed. var ownerMember = MakeMember(typeof(GuardProbeShape).GetField(nameof(GuardProbeShape.Owner))!, typeof(string), 0, indexAlias: null, isReference: false, targetType: null); diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs index 0f1f20a9..845f9468 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs @@ -130,7 +130,7 @@ namespace GameCult.Networking.Tests public string Name = string.Empty; [Key(1)] - [CultIndex("kind")] + [CultRole("kind")] public string Kind = string.Empty; } diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs index a03b62d2..e625c134 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs @@ -62,7 +62,7 @@ namespace GameCult.Networking.Tests public sealed class SurvivorNumericLeaf { [Key(0)] [CultName] public string Name = string.Empty; - [Key(1)] [CultIndex("level")] public int Level; + [Key(1)] [CultRole("level")] public int Level; } [CultDocument("cultnet.selection-tests.survivor_string_leaf", "cultnet.selection-tests.survivor_string_leaf.v1")] @@ -70,7 +70,7 @@ namespace GameCult.Networking.Tests public sealed class SurvivorStringLeaf { [Key(0)] [CultName] public string Name = string.Empty; - [Key(1)] [CultIndex("level")] public string Level = string.Empty; + [Key(1)] [CultRole("level")] public string Level = string.Empty; } // --------------------------------------------------------------------------------------------- @@ -173,7 +173,7 @@ namespace GameCult.Networking.Tests // The declared index alias ("linkRole") differs from the member name ("Link") - the exact // shape section 2's role-naming rule exists for, and no prior fixture had one. [Key(1)] - [CultIndex("linkRole")] + [CultRole("linkRole")] [CultReference(typeof(SurvivorAliasTarget))] public CultRecordRef Link; } @@ -408,7 +408,7 @@ namespace GameCult.Networking.Tests [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] - [CultIndex("hopRole")] + [CultRole("hopRole")] [CultReference(typeof(SurvivorAliasTarget))] public CultRecordRef Link; } @@ -431,12 +431,12 @@ namespace GameCult.Networking.Tests [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] - [CultIndex("zLastRole")] + [CultRole("zLastRole")] [CultReference(typeof(SurvivorAliasTarget))] public CultRecordRef RefZ; [Key(2)] - [CultIndex("aFirstRole")] + [CultRole("aFirstRole")] [CultReference(typeof(SurvivorAliasTarget))] public CultRecordRef RefA; } diff --git a/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs b/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs index e9a070fc..3a076d16 100644 --- a/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs +++ b/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs @@ -586,11 +586,11 @@ namespace GameCult.Networking.Tests public string Name = string.Empty; [Key(1)] - [CultIndex("kind")] + [CultRole("kind")] public string Kind = string.Empty; [Key(2)] - [CultIndex("mass")] + [CultRole("mass")] public float Mass; } @@ -631,7 +631,7 @@ namespace GameCult.Networking.Tests public string Name = string.Empty; [Key(1)] - [CultIndex("narrow_ref")] + [CultRole("narrow_ref")] [CultReference(typeof(ParityLeafA))] public CultRecordRef NarrowRef; }