Skip to content

Commit 2cbf193

Browse files
authored
Merge pull request #16 from peopleworks/fix/own-properties-first
Name an entity's own columns before the ones it inherits
2 parents fd7d5ff + e365a4f commit 2cbf193

9 files changed

Lines changed: 245 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,26 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5656
was added for. `FeatureCenter.NET.XPO` gains `OidGenerator`, `NoKeyPropertyNamedBaseObject` and
5757
`LayoutDemoObject`.
5858

59+
- **An entity carries the properties it inherits.** A class found through a base declared in the
60+
same project was reported with only the columns it declares itself: `PriorityOrder` listed
61+
`Rank` and omitted the `Name` and `Number` it persists. Finding those classes at all is what
62+
0.12.1 was about, and it converted a silent omission into a stated one — the entity now appeared
63+
under a heading presenting the application's tables, with two thirds of its columns absent, in a
64+
document that tells an agent its inventories are complete. At scale it is the shared base that
65+
hurts: an application on an `AuditedObject` lost whatever that base holds from *every* entity,
66+
which is normally the audit fields an agent most needs to know it must not set by hand. Each
67+
entity now folds in its ancestors' properties in declaration order from the root down, each
68+
marked with the class that declared it; a property the class redeclares stays its own, and the
69+
abstract base is marked as abstract. `FeatureCenter.NET.XPO` folds 151 properties over 146
70+
entities, `MainDemo.NET.XPO` 26 over 17 — where `Employee` reaches `Photo` through `Person` and
71+
is correctly told it comes from `Party`.
72+
73+
Summaries of fixed width name an entity's **own** columns first. The full listings read root
74+
down, the way the class does, but a five-slot table sharing its width with a six-column audit
75+
base spends every slot on the base — and then every row of the entity table names the same
76+
columns and none of the ones that tell one entity from another. Rules and associations an entity
77+
inherits are still listed only under the class that declares them (#14).
78+
5979
## [0.12.1] — 2026-08-13
6080

6181
Entities the application declares, rather than the ones that inherit from the right class.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ applications. The agent-facing surface is what is landing now, in the open.
317317
|| Pluggable publishing targets (`IDocumentationSink`) |
318318
|| **MCP server** — 10 tools, live against your source |
319319
|| **Installable Claude Code plugin** with skill and MCP server |
320-
|| **295 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
320+
|| **299 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
321321
|| **DevExpress ground-truth catalog**, generated locally by licensees |
322322

323323
PeopleWorks Copilot, where this tool grew up, is now one sink among several rather than the

src/XafLogicExplainer.Core/Generators/AgentContextGenerator.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,15 @@ private static void WriteEntityInventory(StringBuilder sb, ExtractedProject proj
278278

279279
foreach (var entity in project.Entities.OrderBy(e => e.ClassName, StringComparer.Ordinal))
280280
{
281+
// What the entity declares comes first. The full list keeps the order the class reads
282+
// in, root down, but five slots shared with a shared base spend them all on the base:
283+
// every row of an audited application then names the same audit columns and none of
284+
// the columns that tell one entity from another. A row that cannot discriminate is
285+
// what this table exists to do.
281286
var notable = entity.Properties
282287
.Where(p => !p.IsCollection)
283-
.OrderByDescending(p => p.IsKey)
288+
.OrderByDescending(p => p.InheritedFrom is null)
289+
.ThenByDescending(p => p.IsKey)
284290
.ThenByDescending(p => p.IsRequired)
285291
.ThenByDescending(p => p.IsComputed)
286292
.Take(5)

src/XafLogicExplainer.Core/Generators/MarkdownDocumentationGenerator.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,7 +782,16 @@ private DocumentSection GenerateNavigationSection(ExtractedProject project)
782782
if (!string.IsNullOrEmpty(entity.Description))
783783
sb.AppendLine($"*{entity.Description}*");
784784
sb.AppendLine();
785-
sb.AppendLine($"- **{_l.MainProperties}:** {string.Join(", ", entity.Properties.Where(p => !p.IsCollection && p.VisibleInListView).Take(8).Select(p => p.Name))}");
785+
786+
// Own columns first: eight slots shared with a base the whole application derives
787+
// from would otherwise name the base's columns under every entity in the menu.
788+
var main = entity.Properties
789+
.Where(p => !p.IsCollection && p.VisibleInListView)
790+
.OrderByDescending(p => p.InheritedFrom is null)
791+
.Take(8)
792+
.Select(p => p.Name);
793+
794+
sb.AppendLine($"- **{_l.MainProperties}:** {string.Join(", ", main)}");
786795
if (entity.Relationships.Count > 0)
787796
sb.AppendLine($"- **{_l.Relationships}:** {string.Join(", ", entity.Relationships.Select(r => $"{r.PropertyName}{r.RelatedEntity}"))}");
788797
sb.AppendLine();
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using DevExpress.Persistent.BaseImpl;
2+
using DevExpress.Xpo;
3+
4+
namespace SampleAudited.Module.BusinessObjects;
5+
6+
/// <summary>
7+
/// The audit base an application writes once and derives every entity from.
8+
/// </summary>
9+
/// <remarks>
10+
/// Wider than the columns of the entities below it, which is the ordinary shape: audit bases
11+
/// accumulate. It is what makes a fixed-size summary a decision about whose columns get named.
12+
/// </remarks>
13+
public abstract class AuditedObject : BaseObject
14+
{
15+
public AuditedObject(Session session) : base(session) { }
16+
17+
public string CreatedBy
18+
{
19+
get => GetPropertyValue<string>(nameof(CreatedBy));
20+
set => SetPropertyValue(nameof(CreatedBy), value);
21+
}
22+
23+
public DateTime CreatedOn
24+
{
25+
get => GetPropertyValue<DateTime>(nameof(CreatedOn));
26+
set => SetPropertyValue(nameof(CreatedOn), value);
27+
}
28+
29+
public string ChangedBy
30+
{
31+
get => GetPropertyValue<string>(nameof(ChangedBy));
32+
set => SetPropertyValue(nameof(ChangedBy), value);
33+
}
34+
35+
public DateTime ChangedOn
36+
{
37+
get => GetPropertyValue<DateTime>(nameof(ChangedOn));
38+
set => SetPropertyValue(nameof(ChangedOn), value);
39+
}
40+
41+
public int RowVersion
42+
{
43+
get => GetPropertyValue<int>(nameof(RowVersion));
44+
set => SetPropertyValue(nameof(RowVersion), value);
45+
}
46+
47+
public string AuditNotes
48+
{
49+
get => GetPropertyValue<string>(nameof(AuditNotes));
50+
set => SetPropertyValue(nameof(AuditNotes), value);
51+
}
52+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using DevExpress.Persistent.Base;
2+
using DevExpress.Persistent.Validation;
3+
using DevExpress.Xpo;
4+
5+
namespace SampleAudited.Module.BusinessObjects;
6+
7+
/// <summary>An entity whose own columns are outnumbered by the ones it inherits.</summary>
8+
[DefaultClassOptions]
9+
[NavigationItem("Billing")]
10+
public class Invoice : AuditedObject
11+
{
12+
public Invoice(Session session) : base(session) { }
13+
14+
[RuleRequiredField("Invoice_Number_Required", DefaultContexts.Save)]
15+
public string Number
16+
{
17+
get => GetPropertyValue<string>(nameof(Number));
18+
set => SetPropertyValue(nameof(Number), value);
19+
}
20+
21+
public DateTime IssuedOn
22+
{
23+
get => GetPropertyValue<DateTime>(nameof(IssuedOn));
24+
set => SetPropertyValue(nameof(IssuedOn), value);
25+
}
26+
27+
public decimal Total
28+
{
29+
get => GetPropertyValue<decimal>(nameof(Total));
30+
set => SetPropertyValue(nameof(Total), value);
31+
}
32+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using DevExpress.Persistent.Base;
2+
using DevExpress.Xpo;
3+
4+
namespace SampleAudited.Module.BusinessObjects;
5+
6+
/// <summary>
7+
/// The same shape without a required column.
8+
/// </summary>
9+
/// <remarks>
10+
/// <c>Invoice</c> keeps one of its own columns in a five-slot summary by accident, because a
11+
/// required property outranks the rest. An entity with no such column keeps none — which is the
12+
/// case that shows the ranking was never the thing holding the row together.
13+
/// </remarks>
14+
[DefaultClassOptions]
15+
[NavigationItem("Billing")]
16+
public class Receipt : AuditedObject
17+
{
18+
public Receipt(Session session) : base(session) { }
19+
20+
public string Reference
21+
{
22+
get => GetPropertyValue<string>(nameof(Reference));
23+
set => SetPropertyValue(nameof(Reference), value);
24+
}
25+
26+
public decimal Amount
27+
{
28+
get => GetPropertyValue<decimal>(nameof(Amount));
29+
set => SetPropertyValue(nameof(Amount), value);
30+
}
31+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using System.Text.RegularExpressions;
2+
using XafLogicExplainer.Core.Generators;
3+
4+
namespace XafLogicExplainer.Tests;
5+
6+
/// <summary>
7+
/// What a summary of fixed width names once an entity carries its base's columns.
8+
/// </summary>
9+
/// <remarks>
10+
/// Folding inherited properties (<see cref="InheritedPropertyFoldTests"/>) gives every entity the
11+
/// columns it persists, in the order the class reads them — root down. Two summaries then take the
12+
/// first few of that list, and on an application with a shared audit base the first few are the
13+
/// base's for every entity at once: the tables that exist to tell entities apart end up naming the
14+
/// same columns in every row, and none of the ones that differ.
15+
/// <para>
16+
/// The full listings are unaffected and still read root down. This is only about the summaries,
17+
/// where the width is the whole constraint.
18+
/// </para>
19+
/// </remarks>
20+
public class InheritedPropertySummaryTests
21+
{
22+
private static string Index =>
23+
new AgentContextGenerator("0.9.0").GenerateIndex(SampleProjects.AuditedXpo, []);
24+
25+
[Fact]
26+
public void AnEntitysOwnColumnsSurviveAFixedWidthSummary()
27+
{
28+
// Receipt declares two columns and inherits six. Ranked by nothing but position, the six
29+
// fill all five slots and the reader learns that a receipt has a CreatedBy.
30+
Assert.Equal(["Reference", "Amount", "CreatedBy", "CreatedOn", "ChangedBy"], Notable("Receipt"));
31+
}
32+
33+
[Fact]
34+
public void RequiredColumnsStillOutrankTheRestOfTheEntitysOwn()
35+
{
36+
// Own-before-inherited is the outer sort; the existing ranking still orders each group.
37+
Assert.Equal(["Number", "IssuedOn", "Total", "CreatedBy", "CreatedOn"], Notable("Invoice"));
38+
}
39+
40+
[Fact]
41+
public void TheBaseItselfStillNamesItsOwnColumns()
42+
{
43+
// The base declares all six, so the rule must not be "hide inherited" -- it is "declare
44+
// first". A base whose row went blank would be the same defect facing the other way.
45+
Assert.Equal(["CreatedBy", "CreatedOn", "ChangedBy", "ChangedOn", "RowVersion"], Notable("AuditedObject"));
46+
}
47+
48+
[Fact]
49+
public void TheNavigationSummaryNamesTheEntitysOwnColumnsFirstToo()
50+
{
51+
// The same eight-slot summary, in the documents published to a Copilot rather than to a
52+
// coding agent -- one fix per reader, or the reader who gets missed keeps the defect.
53+
// Generated in the default language, which is Spanish; the ordering is not translated.
54+
var sections = new MarkdownDocumentationGenerator().GenerateSections(SampleProjects.AuditedXpo);
55+
var navigation = sections.Single(section => section.Content.Contains("#### Receipt"));
56+
57+
var line = navigation.Content
58+
.Split('\n')
59+
.SkipWhile(text => !text.StartsWith("#### Receipt", StringComparison.Ordinal))
60+
.First(text => text.Contains("Propiedades principales", StringComparison.Ordinal));
61+
62+
Assert.StartsWith(
63+
"- **Propiedades principales:** Reference, Amount, CreatedBy",
64+
line.Trim(),
65+
StringComparison.Ordinal);
66+
}
67+
68+
/// <summary>The property names in one row of the agent context's entity table.</summary>
69+
private static List<string> Notable(string className)
70+
{
71+
var row = Index
72+
.Split('\n')
73+
.Single(line => line.StartsWith($"| **{className}** ", StringComparison.Ordinal));
74+
75+
return [.. Regex.Matches(row.Split('|')[3], "`([^`]+)`").Select(match => match.Groups[1].Value)];
76+
}
77+
}

tests/XafLogicExplainer.Tests/SampleProjects.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ internal static class SampleProjects
4040
/// <summary>Path to the XPO fixture whose entities derive through a shared base.</summary>
4141
public static string DeepXpoPath => Path.Combine(FixturesRoot, "DeepXpoSolution", "SampleDeep.Module");
4242

43+
/// <summary>Path to the XPO fixture whose audit base is wider than its entities.</summary>
44+
public static string AuditedXpoPath =>
45+
Path.Combine(FixturesRoot, "AuditedXpoSolution", "SampleAudited.Module");
46+
4347
/// <summary>Path to the fourteen-entity demo module.</summary>
4448
public static string DemoPath => Path.Combine(FixturesRoot, "DemoSolution", "PharmacyDemo.Module");
4549

@@ -96,6 +100,7 @@ private static string FixturesRoot
96100
private static readonly Lazy<ExtractedProject> LazyNoOrm = new(() => Extract(NoOrmPath));
97101

98102
private static readonly Lazy<ExtractedProject> LazyDeepXpo = new(() => Extract(DeepXpoPath));
103+
private static readonly Lazy<ExtractedProject> LazyAuditedXpo = new(() => Extract(AuditedXpoPath));
99104

100105
/// <summary>The XPO sample: Customer, Order, OrderLine, one controller, seed data, xafml.</summary>
101106
public static ExtractedProject Xpo => LazyXpo.Value;
@@ -122,6 +127,16 @@ private static string FixturesRoot
122127
/// <summary>An XPO application whose entities reach BaseObject through a shared base.</summary>
123128
public static ExtractedProject DeepXpo => LazyDeepXpo.Value;
124129

130+
/// <summary>
131+
/// An XPO application on an audit base wider than the entities that derive from it.
132+
/// </summary>
133+
/// <remarks>
134+
/// Kept apart from <see cref="DeepXpo"/>, which asserts exact property lists on a base holding
135+
/// a single column. What this fixture is for is the opposite proportion — the one where a
136+
/// summary of fixed width has to choose between an entity's columns and its base's.
137+
/// </remarks>
138+
public static ExtractedProject AuditedXpo => LazyAuditedXpo.Value;
139+
125140
/// <summary>
126141
/// The fourteen-entity demo, with a custom editor in a sibling platform project.
127142
/// </summary>

0 commit comments

Comments
 (0)