Add model-owned record grouping: declaration, tree, create-in-group, ref labels
[CultInspectorGroupBy(nameof(A), nameof(B))] on a document class names the members its record list groups by, outermost first. CultInspectorModel owns the rest: GroupingOf validates the declaration (a refused one gives a flat list and a notice), GroupRecords partitions RecordCandidates by value identity with occupied values only, CreateInGroup builds the record a node holds, RecordRefLabel owns None and Missing <key>. A variant groups by its resolved Document. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
8ad0691bdd
commit
79e9318698
4 changed files with 687 additions and 0 deletions
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace GameCult.Caching
|
||||
{
|
||||
|
|
@ -78,6 +79,21 @@ namespace GameCult.Caching
|
|||
public Type? AssetType { get; }
|
||||
}
|
||||
|
||||
// Groups a document type's record list by the named members, outermost first: the argument order is the nesting order.
|
||||
// The nearest declaration up the inheritance chain wins, so a derived type may re-declare, and an empty declaration
|
||||
// opts out of a base's. A member must be a visible, editable string, integer, bool, non-[Flags] enum or CultRecordRef;
|
||||
// anything else gives a flat list and a notice. Presentation only: no schema, index or stored byte changes.
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = true, AllowMultiple = false)]
|
||||
public sealed class CultInspectorGroupByAttribute : Attribute
|
||||
{
|
||||
public CultInspectorGroupByAttribute(params string[] members)
|
||||
{
|
||||
Members = members ?? throw new ArgumentNullException(nameof(members));
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> Members { 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
|
||||
|
|
|
|||
60
src/GameCult.Caching/CultInspectorGrouping.cs
Normal file
60
src/GameCult.Caching/CultInspectorGrouping.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace GameCult.Caching
|
||||
{
|
||||
// Which members group a listed document type's records, outermost first.
|
||||
public sealed class CultInspectorGrouping
|
||||
{
|
||||
internal CultInspectorGrouping(Type listedType, IReadOnlyList<CultInspectorMember> members, string? notice)
|
||||
{
|
||||
ListedType = listedType;
|
||||
Members = members;
|
||||
Notice = notice;
|
||||
}
|
||||
|
||||
public Type ListedType { get; }
|
||||
|
||||
// Empty when nothing groups: no declaration, an opt-out, or a refused declaration (then Notice says why).
|
||||
public IReadOnlyList<CultInspectorMember> Members { get; }
|
||||
|
||||
public string? Notice { get; }
|
||||
}
|
||||
|
||||
// One node of a grouped record list. Interior nodes hold Children; leaves hold Records.
|
||||
public sealed class CultInspectorRecordGroup
|
||||
{
|
||||
internal CultInspectorRecordGroup(
|
||||
string id,
|
||||
string label,
|
||||
int depth,
|
||||
IReadOnlyList<object?> values,
|
||||
IReadOnlyList<CultInspectorRecordGroup> children,
|
||||
IReadOnlyList<CultStoredDocument> records,
|
||||
int count)
|
||||
{
|
||||
Id = id;
|
||||
Label = label;
|
||||
Depth = depth;
|
||||
Values = values;
|
||||
Children = children;
|
||||
Records = records;
|
||||
Count = count;
|
||||
}
|
||||
|
||||
// Value identity along the path, not the label: stable across renames, so a lowering keys its foldout state on it.
|
||||
public string Id { get; }
|
||||
public string Label { get; }
|
||||
|
||||
// 0 for the outermost level.
|
||||
public int Depth { get; }
|
||||
|
||||
// The grouped member values on the path to this node, outermost first.
|
||||
public IReadOnlyList<object?> Values { get; }
|
||||
public IReadOnlyList<CultInspectorRecordGroup> Children { get; }
|
||||
public IReadOnlyList<CultStoredDocument> Records { get; }
|
||||
|
||||
// The records under this node, at any depth.
|
||||
public int Count { get; }
|
||||
}
|
||||
}
|
||||
|
|
@ -294,6 +294,7 @@ namespace GameCult.Caching
|
|||
|
||||
private readonly CultCodec _codec;
|
||||
private readonly Dictionary<Type, CultInspectorShape> _shapes = new Dictionary<Type, CultInspectorShape>();
|
||||
private readonly Dictionary<Type, CultInspectorGrouping> _groupings = new Dictionary<Type, CultInspectorGrouping>();
|
||||
private readonly Dictionary<MemberInfo, CultInspectorMetadata> _metadata = new Dictionary<MemberInfo, CultInspectorMetadata>();
|
||||
|
||||
// The codec is the store's (CultCacheMessagePack.CreateInspectorModel for .cc stores), the one a CultCache resolves
|
||||
|
|
@ -538,6 +539,183 @@ namespace GameCult.Caching
|
|||
return Activator.CreateInstance(recordRefType, new CultRecordKey(key))!;
|
||||
}
|
||||
|
||||
// How a listed document type's records group, from the nearest [CultInspectorGroupBy] up its inheritance chain. Members
|
||||
// is empty when nothing groups, with a Notice when a declaration was refused.
|
||||
public CultInspectorGrouping GroupingOf(Type listed)
|
||||
{
|
||||
if (listed == null) throw new ArgumentNullException(nameof(listed));
|
||||
lock (_groupings)
|
||||
{
|
||||
if (!_groupings.TryGetValue(listed, out var grouping))
|
||||
_groupings[listed] = grouping = ResolveGrouping(listed);
|
||||
return grouping;
|
||||
}
|
||||
}
|
||||
|
||||
private CultInspectorGrouping ResolveGrouping(Type listed)
|
||||
{
|
||||
var declared = (CultInspectorGroupByAttribute?)Attribute.GetCustomAttribute(listed, typeof(CultInspectorGroupByAttribute), true);
|
||||
if (declared == null || declared.Members.Count == 0)
|
||||
return new CultInspectorGrouping(listed, Array.Empty<CultInspectorMember>(), null);
|
||||
|
||||
CultInspectorGrouping Refused(string reason) =>
|
||||
new CultInspectorGrouping(listed, Array.Empty<CultInspectorMember>(), $"[CultInspectorGroupBy] on {listed.Name}: {reason} The list is not grouped.");
|
||||
|
||||
if (listed.GetCustomAttribute<CultGlobalAttribute>(true) != null)
|
||||
return Refused("a global document is never grouped.");
|
||||
var members = new List<CultInspectorMember>();
|
||||
foreach (var name in declared.Members)
|
||||
{
|
||||
var member = MembersOf(listed).FirstOrDefault(candidate => candidate.Name == name);
|
||||
if (member == null) return Refused($"{name} is not an inspector member.");
|
||||
if (members.Contains(member)) return Refused($"{name} is named twice.");
|
||||
if (member.Metadata.Hidden) return Refused($"{name} is hidden.");
|
||||
if (member.IsReadOnly) return Refused($"{name} is read-only.");
|
||||
var kind = ShapeOf(member.ValueType).Kind;
|
||||
var groupable = kind is CultInspectorValueKind.String or CultInspectorValueKind.Integer or CultInspectorValueKind.Bool or CultInspectorValueKind.RecordRef
|
||||
|| kind == CultInspectorValueKind.Enum && !member.ValueType.IsDefined(typeof(FlagsAttribute), false);
|
||||
if (!groupable) return Refused($"{name} is a {member.ValueType.Name}, which does not group.");
|
||||
members.Add(member);
|
||||
}
|
||||
|
||||
return new CultInspectorGrouping(listed, members, null);
|
||||
}
|
||||
|
||||
// The tree of listed's candidate records: one level per grouped member, occupied values only. Leaves hold the records,
|
||||
// in candidate order, and together exactly RecordCandidates(CultRecordRef<listed>, records); a variant groups by its
|
||||
// resolved values. Empty when GroupingOf(listed) groups nothing: the lowering lists the candidates flat.
|
||||
public IReadOnlyList<CultInspectorRecordGroup> GroupRecords(Type listed, IEnumerable<CultStoredDocument> records)
|
||||
{
|
||||
var grouping = GroupingOf(listed);
|
||||
if (grouping.Members.Count == 0)
|
||||
return Array.Empty<CultInspectorRecordGroup>();
|
||||
var all = records.ToArray();
|
||||
var targets = grouping.Members
|
||||
.Where(member => ShapeOf(member.ValueType).Kind == CultInspectorValueKind.RecordRef)
|
||||
.ToDictionary(member => member, member => RefTargets(member.ValueType, all));
|
||||
var candidates = RecordCandidates(typeof(CultRecordRef<>).MakeGenericType(listed), all);
|
||||
return Partition(grouping, candidates, 0, Array.Empty<object?>(), targets);
|
||||
}
|
||||
|
||||
private IReadOnlyList<CultInspectorRecordGroup> Partition(
|
||||
CultInspectorGrouping grouping,
|
||||
IReadOnlyList<CultStoredDocument> records,
|
||||
int depth,
|
||||
IReadOnlyList<object?> path,
|
||||
IReadOnlyDictionary<CultInspectorMember, Dictionary<string, CultStoredDocument>> targets)
|
||||
{
|
||||
var member = grouping.Members[depth];
|
||||
targets.TryGetValue(member, out var refTargets);
|
||||
return records
|
||||
.Select(record => (Record: record, Key: LevelOf(member, member.GetValue(record.Document), refTargets)))
|
||||
.GroupBy(entry => entry.Key.Identity)
|
||||
.Select(bucket => (Key: bucket.First().Key, Records: bucket.Select(entry => entry.Record).ToArray()))
|
||||
.OrderBy(bucket => bucket.Key, GroupLevel.Order)
|
||||
.Select(bucket =>
|
||||
{
|
||||
var values = path.Append(bucket.Key.Value).ToArray();
|
||||
var children = depth + 1 < grouping.Members.Count
|
||||
? Partition(grouping, bucket.Records, depth + 1, values, targets)
|
||||
: Array.Empty<CultInspectorRecordGroup>();
|
||||
return new CultInspectorRecordGroup(
|
||||
GroupId(grouping.Members, values), bucket.Key.Label, depth, values, children,
|
||||
children.Count == 0 ? bucket.Records : Array.Empty<CultStoredDocument>(), bucket.Records.Length);
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
// A new listed document set to every grouped value on group's path, so it lands in group. Null with a notice when
|
||||
// listed cannot be created. The group must come from GroupRecords for this listed type.
|
||||
public object? CreateInGroup(Type listed, CultInspectorRecordGroup group, out string? notice)
|
||||
{
|
||||
if (group == null) throw new ArgumentNullException(nameof(group));
|
||||
var members = GroupingOf(listed).Members;
|
||||
if (group.Values.Count == 0 || group.Values.Count > members.Count || group.Id != GroupId(members, group.Values))
|
||||
throw new ArgumentException($"Group {group.Id} is not a node of {listed.Name}'s grouping.", nameof(group));
|
||||
var created = CreateElement(listed, listed, out notice);
|
||||
if (created == null)
|
||||
return null;
|
||||
for (var i = 0; i < group.Values.Count; i++)
|
||||
members[i].SetValue(created, group.Values[i]);
|
||||
return created;
|
||||
}
|
||||
|
||||
// What a CultRecordRef<T> value shows: None, the target's label, or Missing <key> when the key names no candidate.
|
||||
public string RecordRefLabel(Type recordRefType, object? value, IEnumerable<CultStoredDocument> records) =>
|
||||
RefLabel(RecordKey(value), RefTargets(recordRefType, records));
|
||||
|
||||
private Dictionary<string, CultStoredDocument> RefTargets(Type recordRefType, IEnumerable<CultStoredDocument> records) =>
|
||||
RecordCandidates(recordRefType, records).ToDictionary(record => record.Key.Value, StringComparer.Ordinal);
|
||||
|
||||
private static string RefLabel(string key, IReadOnlyDictionary<string, CultStoredDocument> targets) =>
|
||||
key.Length == 0 ? "None" : targets.TryGetValue(key, out var target) ? RecordLabel(target) : "Missing " + key;
|
||||
|
||||
// A node's id is its members' value identities, so it survives relabelling: a ref by key, an enum by its underlying
|
||||
// value, anything else by the value's invariant string.
|
||||
private static string GroupId(IReadOnlyList<CultInspectorMember> members, IReadOnlyList<object?> values) =>
|
||||
string.Join("/", values.Select((value, i) => members[i].Name + "=" + Uri.EscapeDataString(IdentityOf(value))));
|
||||
|
||||
private static string IdentityOf(object? value) =>
|
||||
value switch
|
||||
{
|
||||
null => string.Empty,
|
||||
ICultRecordRef reference => reference.Key.Value,
|
||||
Enum enumeration => Convert.ToString(Convert.ChangeType(enumeration, Enum.GetUnderlyingType(enumeration.GetType())), System.Globalization.CultureInfo.InvariantCulture)!,
|
||||
_ => Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)!
|
||||
};
|
||||
|
||||
private GroupLevel LevelOf(CultInspectorMember member, object? value, IReadOnlyDictionary<string, CultStoredDocument>? refTargets)
|
||||
{
|
||||
var identity = IdentityOf(value);
|
||||
switch (ShapeOf(member.ValueType).Kind)
|
||||
{
|
||||
case CultInspectorValueKind.RecordRef:
|
||||
var label = RefLabel(identity, refTargets!);
|
||||
var rank = identity.Length == 0 ? 0 : refTargets!.ContainsKey(identity) ? 1 : 2;
|
||||
return new GroupLevel(identity, label, CreateRecordRef(member.ValueType, identity), rank, 0m, label, identity);
|
||||
case CultInspectorValueKind.Enum:
|
||||
var number = Convert.ToDecimal(Convert.ChangeType(value, Enum.GetUnderlyingType(member.ValueType)), System.Globalization.CultureInfo.InvariantCulture);
|
||||
return new GroupLevel(identity, Enum.GetName(member.ValueType, value!) ?? identity, value, 1, number, string.Empty, string.Empty);
|
||||
case CultInspectorValueKind.Integer:
|
||||
return new GroupLevel(identity, identity, value, 1, Convert.ToDecimal(value, System.Globalization.CultureInfo.InvariantCulture), string.Empty, string.Empty);
|
||||
case CultInspectorValueKind.Bool:
|
||||
return new GroupLevel(identity, identity, value, 1, (bool)value! ? 1m : 0m, string.Empty, string.Empty);
|
||||
default:
|
||||
return new GroupLevel(identity, identity.Length == 0 ? "(empty)" : identity, identity, 1, 0m, identity, identity);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class GroupLevel
|
||||
{
|
||||
public GroupLevel(string identity, string label, object? value, int rank, decimal number, string text, string tie)
|
||||
{
|
||||
Identity = identity;
|
||||
Label = label;
|
||||
Value = value;
|
||||
Rank = rank;
|
||||
Number = number;
|
||||
Text = text;
|
||||
Tie = tie;
|
||||
}
|
||||
|
||||
public string Identity { get; }
|
||||
public string Label { get; }
|
||||
public object? Value { get; }
|
||||
private int Rank { get; }
|
||||
private decimal Number { get; }
|
||||
private string Text { get; }
|
||||
private string Tie { get; }
|
||||
|
||||
// None first and Missing last among refs; then the value, the text ignoring case, then the text exactly.
|
||||
public static readonly IComparer<GroupLevel> Order = Comparer<GroupLevel>.Create((a, b) =>
|
||||
{
|
||||
var order = a.Rank.CompareTo(b.Rank);
|
||||
if (order == 0) order = a.Number.CompareTo(b.Number);
|
||||
if (order == 0) order = StringComparer.OrdinalIgnoreCase.Compare(a.Text, b.Text);
|
||||
return order != 0 ? order : StringComparer.Ordinal.Compare(a.Tie, b.Tie);
|
||||
});
|
||||
}
|
||||
|
||||
private CultInspectorShape Classify(Type type)
|
||||
{
|
||||
if (type == typeof(string)) return new CultInspectorShape(type, CultInspectorValueKind.String);
|
||||
|
|
|
|||
Loading…
Reference in a new issue