diff --git a/src/GameCult.Caching/CultDocumentContracts.cs b/src/GameCult.Caching/CultDocumentContracts.cs index e601f0bc..0d4f1643 100644 --- a/src/GameCult.Caching/CultDocumentContracts.cs +++ b/src/GameCult.Caching/CultDocumentContracts.cs @@ -85,7 +85,13 @@ namespace GameCult.Caching public override string ToString() => Key.ToString(); } - public readonly struct CultRecordRef + // The key of any CultRecordRef, for code that holds one without knowing T. + public interface ICultRecordRef + { + CultRecordKey Key { get; } + } + + public readonly struct CultRecordRef : ICultRecordRef { public CultRecordRef(CultRecordKey key) { diff --git a/src/GameCult.Caching/CultInspectorAttributes.cs b/src/GameCult.Caching/CultInspectorAttributes.cs new file mode 100644 index 00000000..881208ea --- /dev/null +++ b/src/GameCult.Caching/CultInspectorAttributes.cs @@ -0,0 +1,96 @@ +using System; + +namespace GameCult.Caching +{ + // Inspection metadata for document members. Engine-free: headless models carry it, and every inspector lowering + // (the Unity editor Studio, a runtime CultUI panel) reads it through CultInspectorModel. + + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Class, Inherited = true)] + public sealed class CultInspectorLabelAttribute : Attribute + { + public CultInspectorLabelAttribute(string label) + { + Label = label ?? string.Empty; + } + + public string Label { get; } + } + + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] + public sealed class CultInspectorHiddenAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] + public sealed class CultInspectorReadOnlyAttribute : Attribute + { + } + + // Lower values appear first; members without it keep their slot order. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] + public sealed class CultInspectorOrderAttribute : Attribute + { + public CultInspectorOrderAttribute(int order) + { + Order = order; + } + + public int Order { get; } + } + + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] + public sealed class CultInspectorTextAreaAttribute : Attribute + { + public CultInspectorTextAreaAttribute(int minLines = 3, int maxLines = 12) + { + MinLines = minLines; + MaxLines = maxLines; + } + + public int MinLines { get; } + public int MaxLines { get; } + } + + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] + public sealed class CultInspectorRangeAttribute : Attribute + { + public CultInspectorRangeAttribute(float min, float max) + { + Min = min; + Max = max; + } + + public float Min { get; } + public float Max { get; } + } + + // A string member holding an engine asset path. AssetType is the engine's asset type, named by the consumer + // (typeof(UnityEngine.Texture2D) in a Unity assembly); a lowering that does not know the type ignores it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] + public sealed class CultInspectorAssetPathAttribute : Attribute + { + public CultInspectorAssetPathAttribute(Type? assetType = null) + { + AssetType = assetType; + } + + public Type? AssetType { get; } + } + + // Marks a lowering's drawer class as the drawer for a claimed type. Claimed is either an Attribute subclass (the + // drawer draws every member carrying that attribute) or a value type (every value of that type, or of any closed + // form when Claimed is an open generic definition). Attribute claims win over type claims, which win over the + // built-in kinds. A claim two drawers make is used by neither. + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class CultInspectorDrawerAttribute : Attribute + { + public CultInspectorDrawerAttribute(Type claimed) + { + Claimed = claimed ?? throw new ArgumentNullException(nameof(claimed)); + } + + public Type Claimed { get; } + + public bool ClaimsAttribute => typeof(Attribute).IsAssignableFrom(Claimed); + } +} diff --git a/src/GameCult.Caching/CultInspectorModel.cs b/src/GameCult.Caching/CultInspectorModel.cs new file mode 100644 index 00000000..90e4cb25 --- /dev/null +++ b/src/GameCult.Caching/CultInspectorModel.cs @@ -0,0 +1,588 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MessagePack; + +namespace GameCult.Caching +{ + // The engine-free inspection model: what an inspector draws for a document and which edits it may make. Lowerings + // (the Unity editor Studio's IMGUI, a runtime CultUI panel) own only widgets and layout; members, metadata, value + // shapes, drawer claims and edit rules come from here. + + public enum CultInspectorValueKind + { + Unsupported, + String, + Integer, + Float, + Bool, + Enum, + // An unkeyed value type rebuilt through its constructor (CultMath vectors, rect, Color32). + Composite, + RecordRef, + List, + Dictionary, + Union, + Nested + } + + // What the inspection attributes on one member say. A bare value has none. + public sealed class CultInspectorMetadata + { + internal static readonly CultInspectorMetadata None = new CultInspectorMetadata(null); + + internal CultInspectorMetadata(MemberInfo? member) + { + Attributes = member == null ? Array.Empty() : member.GetCustomAttributes(true).OfType().ToArray(); + var label = Find()?.Label; + Label = string.IsNullOrWhiteSpace(label) ? null : label; + Hidden = Find() != null; + ReadOnly = Find() != null; + Order = Find()?.Order; + Range = Find(); + TextArea = Find(); + AssetPath = Find(); + } + + public IReadOnlyList Attributes { get; } + + // The declared label, or null; the lowering derives one from the member name. + public string? Label { get; } + public bool Hidden { get; } + public bool ReadOnly { get; } + public int? Order { get; } + public CultInspectorRangeAttribute? Range { get; } + public CultInspectorTextAreaAttribute? TextArea { get; } + public CultInspectorAssetPathAttribute? AssetPath { get; } + + public T? Find() where T : Attribute => Attributes.OfType().FirstOrDefault(); + } + + public sealed class CultInspectorMember + { + internal CultInspectorMember(MemberInfo member, Type valueType, int slot, bool assignable, bool component, CultInspectorMetadata metadata) + { + Member = member; + ValueType = valueType; + Slot = slot; + IsAssignable = assignable; + Metadata = metadata; + IsReadOnly = metadata.ReadOnly || !assignable && !component; + } + + public MemberInfo Member { get; } + public string Name => Member.Name; + public Type ValueType { get; } + public int Slot { get; } + public CultInspectorMetadata Metadata { get; } + + // SetValue works: MessagePack's own rule, the one the registry persists by (a public setter or non-readonly field, or + // any setter or readonly field under AllowPrivate). A composite component is never assignable; its owner is composed. + public bool IsAssignable { get; } + + // The lowering offers no edit: declared [CultInspectorReadOnly], or a member that can be neither assigned nor composed. + public bool IsReadOnly { get; } + + public object? GetValue(object target) => + Member is FieldInfo field ? field.GetValue(target) : ((PropertyInfo)Member).GetValue(target); + + public void SetValue(object target, object? value) + { + if (!IsAssignable) + throw new InvalidOperationException($"{Member.DeclaringType?.Name}.{Name} is not assignable."); + if (Member is FieldInfo field) field.SetValue(target, value); + else ((PropertyInfo)Member).SetValue(target, value); + } + } + + public sealed class CultInspectorShape + { + internal CultInspectorShape(Type type, CultInspectorValueKind kind) + { + Type = type; + Kind = kind; + } + + public Type Type { get; } + public CultInspectorValueKind Kind { get; } + + // List + public Type? ElementType { get; internal set; } + + // Dictionary + public Type? KeyType { get; internal set; } + public Type? ValueType { get; internal set; } + + // RecordRef: the T of CultRecordRef. + public Type? RecordTarget { get; internal set; } + + // Union: the declared [Union] subtypes in key order, and nothing else. + public IReadOnlyList UnionChoices { get; internal set; } = Array.Empty(); + + // Nested: the members in inspection order. Composite: the components in constructor order. + public IReadOnlyList Members { get; internal set; } = Array.Empty(); + + // Unsupported: why. + public string? Reason { get; internal set; } + + internal Type? BuildType { get; set; } + internal ConstructorInfo? Composer { get; set; } + } + + public readonly struct CultInspectorClaim + { + internal CultInspectorClaim(Type? drawer, string? conflict) + { + Drawer = drawer; + Conflict = conflict; + } + + // The claiming drawer class, or null for the lowering's built-in drawing. + public Type? Drawer { get; } + + // Set when the claim is contested; the lowering shows it instead of drawing. + public string? Conflict { get; } + } + + // Which lowering drawer draws a value: a claim on one of the member's attributes, then a claim on the value's type + // (exact, then its open generic definition), then none. A lowering builds this once from the drawer classes it + // discovers; an invalid drawer or a contested claim is reported in Errors and draws nothing. An attribute claim + // reaches every value drawn under the member, list elements and dictionary keys and values included, so the drawer + // decides by the value type it is handed. + public sealed class CultInspectorDrawerClaims + { + private readonly Dictionary _claims = new Dictionary(); + private readonly Dictionary _contested = new Dictionary(); + private readonly List _errors = new List(); + private readonly Dictionary<(Type, MemberInfo?), CultInspectorClaim> _resolved = new Dictionary<(Type, MemberInfo?), CultInspectorClaim>(); + + public CultInspectorDrawerClaims(IEnumerable drawerTypes, Type drawerContract) + { + if (drawerTypes == null) throw new ArgumentNullException(nameof(drawerTypes)); + if (drawerContract == null) throw new ArgumentNullException(nameof(drawerContract)); + foreach (var drawer in drawerTypes) + { + var attribute = drawer.GetCustomAttribute(false); + if (attribute == null) + continue; + if (!drawerContract.IsAssignableFrom(drawer) || drawer.IsAbstract || drawer.GetConstructor(Type.EmptyTypes) == null) + { + _errors.Add($"{drawer.FullName} is marked [CultInspectorDrawer] but is not a concrete {drawerContract.Name} with a parameterless constructor."); + continue; + } + + var claimed = attribute.Claimed; + if (_contested.TryGetValue(claimed, out var claimants)) + { + _contested[claimed] = claimants + ", " + drawer.FullName; + } + else if (_claims.TryGetValue(claimed, out var holder)) + { + _claims.Remove(claimed); + _contested[claimed] = holder.FullName + ", " + drawer.FullName; + } + else + { + _claims[claimed] = drawer; + } + } + + foreach (var pair in _contested.OrderBy(pair => pair.Key.FullName, StringComparer.Ordinal)) + _errors.Add($"{pair.Value} all claim {pair.Key.FullName}; none of them draws it."); + } + + public IReadOnlyList Errors => _errors; + + public CultInspectorClaim Resolve(Type valueType, MemberInfo? member) + { + if (valueType == null) throw new ArgumentNullException(nameof(valueType)); + lock (_resolved) + { + if (!_resolved.TryGetValue((valueType, member), out var claim)) + _resolved[(valueType, member)] = claim = ResolveCore(valueType, member); + return claim; + } + } + + private CultInspectorClaim ResolveCore(Type valueType, MemberInfo? member) + { + Type? drawer = null; + foreach (var attribute in member?.GetCustomAttributes(true).OfType() ?? Enumerable.Empty()) + { + for (var type = attribute.GetType(); type != null && type != typeof(Attribute); type = type.BaseType) + { + if (_contested.TryGetValue(type, out var claimants)) + return new CultInspectorClaim(null, $"[{type.Name}] is claimed by {claimants}"); + if (!_claims.TryGetValue(type, out var claimant)) + continue; + if (drawer != null && drawer != claimant) + return new CultInspectorClaim(null, $"{member!.Name} carries attributes claimed by {drawer.FullName} and {claimant.FullName}"); + drawer = claimant; + break; + } + } + + if (drawer != null) + return new CultInspectorClaim(drawer, null); + foreach (var type in valueType.IsGenericType ? new[] { valueType, valueType.GetGenericTypeDefinition() } : new[] { valueType }) + { + if (_contested.TryGetValue(type, out var claimants)) + return new CultInspectorClaim(null, $"{type.FullName} is claimed by {claimants}"); + if (_claims.TryGetValue(type, out var claimant)) + return new CultInspectorClaim(claimant, null); + } + + return default; + } + } + + // A private copy of one stored document. A lowering draws into Document; the cached object is never touched. + public sealed class CultInspectorEdit + { + internal CultInspectorEdit(CultStoredDocument source, object document) + { + Source = source; + Document = document; + } + + public CultStoredDocument Source { get; } + public object Document { get; } + public bool IsSpent { get; private set; } + + public bool IsFor(CultStoredDocument record) => !IsSpent && ReferenceEquals(Source, record); + + // Upserts the copy under the source key. Committed, the cache holds the copy; refused, the cached object is + // unchanged. Either way the edit is spent and the copy belongs to nobody: begin a new edit from the cache. + public bool Commit(CultCache cache, out string? error) + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + if (IsSpent) throw new InvalidOperationException("This edit was already committed; begin a new edit."); + IsSpent = true; + try + { + cache.UpsertAsync(Source.Descriptor.DocumentType, Document, Source.Key).GetAwaiter().GetResult(); + error = null; + return true; + } + catch (Exception exception) + { + error = exception.GetBaseException().Message; + return false; + } + } + } + + public sealed class CultInspectorModel + { + private const string RecordRefIdentity = "ref:"; + private const BindingFlags Public = BindingFlags.Public | BindingFlags.Instance; + + private static readonly HashSet IntegerTypes = new HashSet + { + typeof(int), typeof(long), typeof(uint), typeof(short), typeof(ushort), typeof(byte), typeof(sbyte) + }; + + private readonly Func _serialize; + private readonly Func _deserialize; + private readonly Dictionary _shapes = new Dictionary(); + private readonly Dictionary _metadata = new Dictionary(); + + // serialize and deserialize are the store's document codec (CultDocumentMessagePackSerialization for .cc stores); + // edits clone through it and dictionary keys compare by it. + public CultInspectorModel(CultDocumentRegistry registry, Func serialize, Func deserialize) + { + Registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _serialize = serialize ?? throw new ArgumentNullException(nameof(serialize)); + _deserialize = deserialize ?? throw new ArgumentNullException(nameof(deserialize)); + } + + public CultDocumentRegistry Registry { get; } + + public static string RecordLabel(CultStoredDocument record) + { + var name = record.Descriptor.NameAccessor?.Invoke(record.Document); + return string.IsNullOrWhiteSpace(name) ? record.Key.Value : name!; + } + + // The key a CultRecordRef holds; null and "" are both the empty key. + public static string RecordKey(object? recordRef) => (recordRef as ICultRecordRef)?.Key.Value ?? string.Empty; + + public CultInspectorShape ShapeOf(Type type) + { + if (type == null) throw new ArgumentNullException(nameof(type)); + lock (_shapes) + { + if (!_shapes.TryGetValue(type, out var shape)) + _shapes[type] = shape = Classify(type); + return shape; + } + } + + public IReadOnlyList MembersOf(Type type) + { + var shape = ShapeOf(type); + return shape.Kind == CultInspectorValueKind.Nested ? shape.Members : Array.Empty(); + } + + public CultInspectorMetadata MetadataOf(MemberInfo? member) + { + if (member == null) + return CultInspectorMetadata.None; + lock (_metadata) + { + if (!_metadata.TryGetValue(member, out var metadata)) + _metadata[member] = metadata = new CultInspectorMetadata(member); + return metadata; + } + } + + public object Clone(object document, Type type) => _deserialize(type, _serialize(document, type)); + + public CultInspectorEdit BeginEdit(CultStoredDocument record) + { + if (record == null) throw new ArgumentNullException(nameof(record)); + return new CultInspectorEdit(record, Clone(record.Document, record.Descriptor.DocumentType)); + } + + public object? CreateDefault(Type type) + { + if (type == typeof(string)) return string.Empty; + if (type.IsValueType) return Activator.CreateInstance(type); + var shape = ShapeOf(type); + if (shape.Kind == CultInspectorValueKind.List && type.IsArray) return Array.CreateInstance(shape.ElementType!, 0); + if (shape.Kind == CultInspectorValueKind.Dictionary) return Activator.CreateInstance(shape.BuildType!); + return type.IsAbstract || type.IsInterface || type.GetConstructor(Type.EmptyTypes) == null ? null : Activator.CreateInstance(type); + } + + public object CreateUnionValue(Type unionType, Type subtype) + { + if (!ShapeOf(unionType).UnionChoices.Contains(subtype)) + throw new InvalidOperationException($"{subtype.Name} is not a declared [Union] subtype of {unionType.Name}."); + if (subtype.GetConstructor(Type.EmptyTypes) == null) + throw new InvalidOperationException($"{subtype.Name} has no parameterless constructor."); + return Activator.CreateInstance(subtype)!; + } + + public object Compose(CultInspectorShape composite, IReadOnlyList components) + { + if (composite?.Composer == null) + throw new ArgumentException("Only a composite shape is composed.", nameof(composite)); + return composite.Composer.Invoke(components.ToArray()); + } + + // A new collection holding items; the value it replaces is never mutated. + public object BuildList(Type listType, IReadOnlyList items) + { + var shape = ShapeOf(listType); + if (shape.Kind != CultInspectorValueKind.List) + throw new ArgumentException($"{listType.Name} is not a list the inspector builds.", nameof(listType)); + if (listType.IsArray) + { + var array = Array.CreateInstance(shape.ElementType!, items.Count); + for (var i = 0; i < items.Count; i++) array.SetValue(items[i], i); + return array; + } + + var list = (IList)Activator.CreateInstance(listType)!; + foreach (var item in items) list.Add(item); + return list; + } + + public object BuildDictionary(Type dictionaryType, IEnumerable> entries) + { + var shape = ShapeOf(dictionaryType); + if (shape.Kind != CultInspectorValueKind.Dictionary) + throw new ArgumentException($"{dictionaryType.Name} is not a dictionary the inspector builds.", nameof(dictionaryType)); + var dictionary = (IDictionary)Activator.CreateInstance(shape.BuildType!)!; + foreach (var entry in entries) dictionary.Add(entry.Key!, entry.Value); + return dictionary; + } + + // Keys are the same key when they serialize the same. A CultRecordRef key is its key string, null and "" alike. + public string? KeyIdentity(object? key) + { + if (key == null) return null; + if (key is ICultRecordRef reference) return RecordRefIdentity + reference.Key.Value; + try + { + return "value:" + Convert.ToBase64String(_serialize(key, key.GetType())); + } + catch (Exception) + { + return key.GetType().FullName + ":" + key; + } + } + + // Why candidate may not replace current among otherKeys, or null when it may (an unchanged key always may). + public string? RefuseKey(object? candidate, object? current, IEnumerable otherKeys) + { + var identity = KeyIdentity(candidate); + if (identity == KeyIdentity(current)) return null; + if (identity == null) return "a null key"; + if (identity == RecordRefIdentity) return "an empty record reference as a key"; + return otherKeys.Any(key => KeyIdentity(key) == identity) ? "a duplicate key" : null; + } + + // A key to add: a record-reference key takes the first candidate record not already used; any other key is the + // type's default. Null when that key is taken or cannot be made. + public object? FreshKey(Type keyType, IEnumerable existingKeys, IEnumerable records) + { + var used = new HashSet(existingKeys.Select(KeyIdentity)); + if (ShapeOf(keyType).Kind == CultInspectorValueKind.RecordRef) + { + var record = RecordCandidates(keyType, records).FirstOrDefault(candidate => !used.Contains(RecordRefIdentity + candidate.Key.Value)); + return record == null ? null : CreateRecordRef(keyType, record.Key.Value); + } + + var fresh = CreateDefault(keyType); + return fresh == null || used.Contains(KeyIdentity(fresh)) ? null : fresh; + } + + // The records a CultRecordRef may point at: every record whose document is a T, by label. + public IReadOnlyList RecordCandidates(Type recordRefType, IEnumerable records) + { + var target = ShapeOf(recordRefType).RecordTarget + ?? throw new ArgumentException($"{recordRefType.Name} is not a CultRecordRef.", nameof(recordRefType)); + return records + .Where(record => target.IsInstanceOfType(record.Document) && record.Key.Value.Length > 0) + .OrderBy(RecordLabel, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public object CreateRecordRef(Type recordRefType, string key) + { + if (ShapeOf(recordRefType).Kind != CultInspectorValueKind.RecordRef) + throw new ArgumentException($"{recordRefType.Name} is not a CultRecordRef.", nameof(recordRefType)); + return Activator.CreateInstance(recordRefType, new CultRecordKey(key))!; + } + + private CultInspectorShape Classify(Type type) + { + if (type == typeof(string)) return new CultInspectorShape(type, CultInspectorValueKind.String); + if (type == typeof(bool)) return new CultInspectorShape(type, CultInspectorValueKind.Bool); + if (type.IsEnum) return new CultInspectorShape(type, CultInspectorValueKind.Enum); + if (IntegerTypes.Contains(type)) return new CultInspectorShape(type, CultInspectorValueKind.Integer); + if (type == typeof(float) || type == typeof(double)) return new CultInspectorShape(type, CultInspectorValueKind.Float); + if (type.IsArray && type.GetArrayRank() != 1) + return Unsupported(type, $"{type.Name} is a multi-dimensional array, which the inspector does not edit."); + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>)) + return new CultInspectorShape(type, CultInspectorValueKind.RecordRef) { RecordTarget = type.GetGenericArguments()[0] }; + + var dictionary = type.GetInterfaces().Prepend(type) + .FirstOrDefault(candidate => candidate.IsGenericType && candidate.GetGenericTypeDefinition() == typeof(IDictionary<,>)); + if (dictionary != null) + { + var arguments = dictionary.GetGenericArguments(); + var build = type.IsInterface || type.IsAbstract ? typeof(Dictionary<,>).MakeGenericType(arguments) : type; + return typeof(IDictionary).IsAssignableFrom(build) && build.GetConstructor(Type.EmptyTypes) != null + ? new CultInspectorShape(type, CultInspectorValueKind.Dictionary) { KeyType = arguments[0], ValueType = arguments[1], BuildType = build } + : Unsupported(type, $"{type.Name} is a dictionary the inspector cannot rebuild."); + } + + if (type.IsArray) + return new CultInspectorShape(type, CultInspectorValueKind.List) { ElementType = type.GetElementType() }; + if (type.IsGenericType && !type.IsAbstract && !type.IsInterface && typeof(IList).IsAssignableFrom(type) && type.GetConstructor(Type.EmptyTypes) != null) + return new CultInspectorShape(type, CultInspectorValueKind.List) { ElementType = type.GetGenericArguments()[0] }; + + if (type.IsAbstract || type.IsInterface) + { + var choices = type.GetCustomAttributes(false).OrderBy(union => union.Key).Select(union => union.SubType).ToArray(); + return choices.Length == 0 + ? Unsupported(type, $"{type.Name} declares no [Union] subtypes.") + : new CultInspectorShape(type, CultInspectorValueKind.Union) { UnionChoices = choices }; + } + + var keyed = KeyedMembers(type); + if (keyed.Length > 0) + return new CultInspectorShape(type, CultInspectorValueKind.Nested) { Members = keyed }; + if (type.IsValueType) + { + var composite = Composite(type); + if (composite != null) + return composite; + var fields = type.GetFields(Public).Where(field => !field.IsLiteral) + .OrderBy(field => field.MetadataToken) + .Select((field, slot) => new CultInspectorMember(field, field.FieldType, slot, !field.IsInitOnly, false, MetadataOf(field))) + .ToArray(); + if (fields.Length > 0) + return new CultInspectorShape(type, CultInspectorValueKind.Nested) { Members = fields }; + } + + return Unsupported(type, "no drawer for " + type.FullName); + } + + // Documents take their members and slots from the registry's catalog; other MessagePack objects from their [Key]s. + private CultInspectorMember[] KeyedMembers(Type type) + { + var allowPrivate = type.GetCustomAttribute(true)?.AllowPrivate == true; + var candidates = type.GetMembers(Public) + .Where(member => member is FieldInfo field && !field.IsLiteral || + member is PropertyInfo property && property.GetGetMethod() != null && property.GetIndexParameters().Length == 0) + .Where(member => !member.IsDefined(typeof(IgnoreMemberAttribute), true)); + IEnumerable<(MemberInfo Member, int Slot)> slotted; + if (type.IsDefined(typeof(CultDocumentAttribute), false)) + { + var slots = Registry.GetRequired(type).ToCatalogEntry().Members.ToDictionary(member => member.MemberName, member => member.Slot, StringComparer.Ordinal); + slotted = candidates.Where(member => slots.ContainsKey(member.Name)).Select(member => (member, slots[member.Name])); + } + else + { + slotted = candidates + .Select(member => (Member: member, Slot: member.GetCustomAttribute(true)?.IntKey)) + .Where(entry => entry.Slot != null) + .Select(entry => (entry.Member, entry.Slot!.Value)); + } + + return slotted + .Select(entry => new CultInspectorMember(entry.Member, TypeOf(entry.Member), entry.Slot, Assignable(entry.Member, allowPrivate), false, MetadataOf(entry.Member))) + .OrderBy(member => member.Metadata.Order ?? member.Slot) + .ThenBy(member => member.Slot) + .ToArray(); + } + + // The longest public constructor whose every parameter takes a readable member of the same name and type. Public + // fields it does not take are state a rebuild would drop, so such a type is not composite. + private CultInspectorShape? Composite(Type type) + { + var readable = type.GetFields(Public).Where(field => !field.IsLiteral).Cast() + .Concat(type.GetProperties(Public).Where(property => property.GetGetMethod() != null && property.GetIndexParameters().Length == 0)) + .ToArray(); + foreach (var constructor in type.GetConstructors().OrderByDescending(constructor => constructor.GetParameters().Length)) + { + var parameters = constructor.GetParameters(); + if (parameters.Length == 0) + break; + var components = parameters + .Select(parameter => readable.FirstOrDefault(member => + string.Equals(member.Name, parameter.Name, StringComparison.OrdinalIgnoreCase) && TypeOf(member) == parameter.ParameterType)) + .ToArray(); + if (components.Any(component => component == null) || components.Distinct().Count() != components.Length) + continue; + if (readable.OfType().Any(field => !components.Contains(field))) + continue; + return new CultInspectorShape(type, CultInspectorValueKind.Composite) + { + Members = components.Select((component, slot) => new CultInspectorMember(component!, TypeOf(component!), slot, false, true, MetadataOf(component))).ToArray(), + Composer = constructor + }; + } + + return null; + } + + private static CultInspectorShape Unsupported(Type type, string reason) => + new CultInspectorShape(type, CultInspectorValueKind.Unsupported) { Reason = reason }; + + private static Type TypeOf(MemberInfo member) => member is FieldInfo field ? field.FieldType : ((PropertyInfo)member).PropertyType; + + private static bool Assignable(MemberInfo member, bool allowPrivate) + { + if (member is FieldInfo field) + return !field.IsInitOnly || allowPrivate; + var setter = ((PropertyInfo)member).GetSetMethod(true); + return setter != null && (setter.IsPublic || allowPrivate); + } + } +} diff --git a/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs b/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs new file mode 100644 index 00000000..97d09c56 --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs @@ -0,0 +1,292 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + public class CultInspectorModelTests + { + private static readonly CultDocumentRegistry Registry = + CultDocumentRegistry.ForTypes(new[] { typeof(InspectItem), typeof(InspectOther), typeof(InspectFixed) }); + + private static CultInspectorModel Model() => new CultInspectorModel( + Registry, + (value, type) => CultDocumentMessagePackSerialization.SerializeUntyped(value, type, Registry), + (type, bytes) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, bytes, Registry)); + + private static CultRecordRef Ref(string key) => new CultRecordRef(new CultRecordKey(key)); + + [Test] + public void MembersFollowTheCatalogWithTheirMetadata() + { + var members = Model().MembersOf(typeof(InspectItem)); + + Assert.That(members.Select(member => member.Name), + Is.EqualTo(new[] { "Value", "Name", "Secret", "Locked", "Shape", "Links", "Numbers" }), "order first, then slot"); + Assert.That(members.Single(member => member.Name == "Name").Metadata.Label, Is.EqualTo("Display Name")); + Assert.That(members.Single(member => member.Name == "Name").IsReadOnly, Is.False); + Assert.That(members.Single(member => member.Name == "Value").Metadata.Range!.Max, Is.EqualTo(10f)); + Assert.That(members.Single(member => member.Name == "Secret").Metadata.Hidden, Is.True); + Assert.That(members.Single(member => member.Name == "Locked").IsReadOnly, Is.True); + + var id = Model().MembersOf(typeof(InspectFixed)).Single(); + Assert.That(id.IsAssignable, Is.False, "a get-only member its constructor fills cannot be assigned"); + Assert.That(id.IsReadOnly, Is.True); + } + + [Test] + public void ClaimsResolveAttributeThenTypeThenBuiltIn() + { + var claims = new CultInspectorDrawerClaims( + new[] { typeof(MarkDrawer), typeof(IntDrawer), typeof(ListDrawer), typeof(StringDrawerA), typeof(StringDrawerB), typeof(NotADrawer) }, + typeof(IFakeDrawer)); + FieldInfo Field(string name) => typeof(ClaimHost).GetField(name)!; + + Assert.That(claims.Resolve(typeof(int), Field(nameof(ClaimHost.Marked))).Drawer, Is.EqualTo(typeof(MarkDrawer))); + Assert.That(claims.Resolve(typeof(int), Field(nameof(ClaimHost.Plain))).Drawer, Is.EqualTo(typeof(IntDrawer)), + "an invalid drawer claiming int does not contest the valid one"); + Assert.That(claims.Resolve(typeof(List), null).Drawer, Is.EqualTo(typeof(ListDrawer))); + Assert.That(claims.Resolve(typeof(float), null).Drawer, Is.Null); + Assert.That(claims.Resolve(typeof(float), null).Conflict, Is.Null); + + var contested = claims.Resolve(typeof(string), null); + Assert.That(contested.Drawer, Is.Null); + Assert.That(contested.Conflict, Does.Contain(nameof(StringDrawerA)).And.Contain(nameof(StringDrawerB))); + Assert.That(claims.Errors.Any(error => error.Contains(nameof(NotADrawer))), Is.True); + Assert.That(claims.Errors.Any(error => error.Contains(nameof(StringDrawerA)) && error.Contains(nameof(StringDrawerB))), Is.True); + } + + [Test] + public void UnionChoicesAreTheDeclaredSubtypesOnly() + { + var model = Model(); + var shape = model.ShapeOf(typeof(InspectShape)); + + Assert.That(shape.Kind, Is.EqualTo(CultInspectorValueKind.Union)); + Assert.That(shape.UnionChoices, Is.EqualTo(new[] { typeof(InspectCircle) })); + Assert.That(model.CreateUnionValue(typeof(InspectShape), typeof(InspectCircle)), Is.InstanceOf()); + Assert.That(() => model.CreateUnionValue(typeof(InspectShape), typeof(InspectSquare)), Throws.InvalidOperationException); + } + + [Test] + public async Task RecordRefCandidatesAreRecordsAssignableToTheTarget() + { + using var cache = new CultCache(Registry); + var a = await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "a" }); + var b = await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "b" }); + await cache.UpsertAsync(typeof(InspectOther), new InspectOther { Name = "o" }); + + var candidates = Model().RecordCandidates(typeof(CultRecordRef), cache.AllStoredDocuments); + + Assert.That(candidates.Select(candidate => candidate.Key), Is.EqualTo(new[] { a, b })); + Assert.That(CultInspectorModel.RecordKey(default(CultRecordRef)), Is.EqualTo(string.Empty)); + Assert.That(CultInspectorModel.RecordKey(new CultRecordRef(a)), Is.EqualTo(a.Value)); + } + + [Test] + public async Task DictionaryKeysRefuseNullEmptyReferencesAndDuplicates() + { + var model = Model(); + var others = new object?[] { Ref("b") }; + + Assert.That(model.KeyIdentity(default(CultRecordRef)), Is.EqualTo(model.KeyIdentity(Ref("")))); + Assert.That(model.RefuseKey(Ref(""), Ref("a"), others), Does.Contain("empty")); + Assert.That(model.RefuseKey(default(CultRecordRef), Ref("a"), others), Does.Contain("empty")); + Assert.That(model.RefuseKey(Ref("b"), Ref("a"), others), Does.Contain("duplicate")); + Assert.That(model.RefuseKey(null, Ref("a"), others), Does.Contain("null")); + Assert.That(model.RefuseKey(Ref("c"), Ref("a"), others), Is.Null); + Assert.That(model.RefuseKey(Ref("a"), Ref("a"), others), Is.Null, "an unchanged key is never refused"); + Assert.That(model.RefuseKey("x", "y", new object?[] { "x" }), Does.Contain("duplicate")); + + using var cache = new CultCache(Registry); + var first = await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "first" }); + var second = await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "second" }); + var fresh = model.FreshKey(typeof(CultRecordRef), new object?[] { new CultRecordRef(first) }, cache.AllStoredDocuments); + Assert.That(CultInspectorModel.RecordKey(fresh), Is.EqualTo(second.Value)); + Assert.That(model.FreshKey(typeof(CultRecordRef), + new object?[] { new CultRecordRef(first), new CultRecordRef(second) }, cache.AllStoredDocuments), Is.Null); + } + + [Test] + public async Task RefusedCommitLeavesTheCachedDocumentUnchanged() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); + try + { + using (var seed = CultCacheMessagePack.Create(filePath, new CultCacheOpenOptions { Registry = Registry })) + { + await seed.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "original", Numbers = { 1, 2 } }); + await seed.FlushAsync(); + } + + var model = Model(); + using var readOnly = CultCacheMessagePack.Create(filePath, new CultCacheOpenOptions { Registry = Registry, ReadOnly = true }); + var record = readOnly.AllStoredDocuments.Single(); + var edit = model.BeginEdit(record); + Assert.That(edit.Document, Is.Not.SameAs(record.Document)); + ((InspectItem)edit.Document).Name = "changed"; + ((InspectItem)edit.Document).Numbers.Add(3); + + Assert.That(edit.Commit(readOnly, out var error), Is.False); + Assert.That(error, Is.Not.Null.And.Not.Empty); + Assert.That(readOnly.Get(record.Key)!.Name, Is.EqualTo("original")); + Assert.That(readOnly.Get(record.Key)!.Numbers, Is.EqualTo(new[] { 1, 2 })); + Assert.That(edit.IsFor(record), Is.False, "a commit spends the edit"); + + using var writable = new CultCache(Registry); + var key = await writable.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "original" }); + var stored = writable.AllStoredDocuments.Single(); + var accepted = model.BeginEdit(stored); + ((InspectItem)accepted.Document).Name = "changed"; + Assert.That(accepted.Commit(writable, out _), Is.True); + Assert.That(writable.Get(key)!.Name, Is.EqualTo("changed")); + Assert.That(((InspectItem)stored.Document).Name, Is.EqualTo("original"), "the replaced object was never mutated"); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + } + } + + [Test] + public void ShapesClassifyCollectionsCompositesAndMultiDimensionalArrays() + { + var model = Model(); + + Assert.That(model.ShapeOf(typeof(float[,])).Kind, Is.EqualTo(CultInspectorValueKind.Unsupported)); + Assert.That(model.ShapeOf(typeof(float[,])).Reason, Does.Contain("multi-dimensional")); + Assert.That(model.ShapeOf(typeof(List)).ElementType, Is.EqualTo(typeof(int))); + Assert.That(model.BuildList(typeof(float[]), new object?[] { 1f, 2f }), Is.EqualTo(new[] { 1f, 2f })); + + var pair = model.ShapeOf(typeof(InspectPair)); + Assert.That(pair.Kind, Is.EqualTo(CultInspectorValueKind.Composite)); + Assert.That(pair.Members.Select(member => member.Name), Is.EqualTo(new[] { "x", "y" })); + Assert.That(model.Compose(pair, new object?[] { 1f, 2f }), Is.EqualTo(new InspectPair(1f, 2f))); + + Assert.That(model.ShapeOf(typeof(IDictionary)).Kind, Is.EqualTo(CultInspectorValueKind.Dictionary)); + Assert.That(model.BuildDictionary(typeof(IDictionary), new[] { new KeyValuePair("a", 1) }), + Is.EqualTo(new Dictionary { ["a"] = 1 })); + } + + [CultDocument("tests.inspect_item", "tests.inspect_item.v1")] + [MessagePackObject] + public sealed class InspectItem + { + [Key(0)] [CultName] [CultInspectorLabel("Display Name")] + public string Name = string.Empty; + + [Key(1)] [CultInspectorRange(0, 10)] [CultInspectorOrder(-1)] + public int Value; + + [Key(2)] [CultInspectorHidden] + public string Secret = string.Empty; + + [Key(3)] [CultInspectorReadOnly] + public string Locked = string.Empty; + + [Key(4)] + public InspectShape? Shape; + + [Key(5)] + public Dictionary, int> Links = new Dictionary, int>(); + + [Key(6)] + public List Numbers = new List(); + } + + [CultDocument("tests.inspect_other", "tests.inspect_other.v1")] + [MessagePackObject] + public sealed class InspectOther + { + [Key(0)] [CultName] + public string Name = string.Empty; + } + + [CultDocument("tests.inspect_fixed", "tests.inspect_fixed.v1")] + [MessagePackObject] + public sealed class InspectFixed + { + [SerializationConstructor] + public InspectFixed(string id) + { + Id = id; + } + + [Key(0)] + public string Id { get; } + } + + [Union(0, typeof(InspectCircle))] + public abstract class InspectShape + { + } + + [MessagePackObject] + public sealed class InspectCircle : InspectShape + { + [Key(0)] + public float Radius; + } + + [MessagePackObject] + public sealed class InspectSquare : InspectShape + { + [Key(0)] + public float Side; + } + + public readonly record struct InspectPair(float x, float y); + + public interface IFakeDrawer + { + } + + [AttributeUsage(AttributeTargets.Field)] + public sealed class InspectMarkAttribute : Attribute + { + } + + public sealed class ClaimHost + { + [InspectMark] public int Marked; + public int Plain; + } + + [CultInspectorDrawer(typeof(InspectMarkAttribute))] + public sealed class MarkDrawer : IFakeDrawer + { + } + + [CultInspectorDrawer(typeof(int))] + public sealed class IntDrawer : IFakeDrawer + { + } + + [CultInspectorDrawer(typeof(List<>))] + public sealed class ListDrawer : IFakeDrawer + { + } + + [CultInspectorDrawer(typeof(string))] + public sealed class StringDrawerA : IFakeDrawer + { + } + + [CultInspectorDrawer(typeof(string))] + public sealed class StringDrawerB : IFakeDrawer + { + } + + [CultInspectorDrawer(typeof(int))] + public sealed class NotADrawer + { + } + } +}