diff --git a/CHANGELOG.md b/CHANGELOG.md index 6387eed..5160a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- **`ExtractionOptions.BaseTypeNames` is the single source of the list.** The CLI, the MCP server + and the test harness each passed their own copy, so the default in `Core` was four names while + every caller passed five — four copies with three chances to disagree about what an entity is. + The callers now use the default. + ### Fixed - **`Unknown` now reaches every place the ORM is reported.** The agent files learned it; the HTML @@ -40,6 +47,15 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `OutlookInspiredDemo.NET.EFCore` 23 → 24 — the last of which is an EF Core application, where an entity that is not registered as a `DbSet` had no fallback either. +- **`PersistentBase` and `XPBaseObject` are recognised as persistent bases.** The XPO hierarchy is + `PersistentBase` → `XPBaseObject` → `XPCustomObject` → `XPObject`, with `XPLiteObject` also under + `XPBaseObject`. The list held the three leaves and neither of the classes above them, so a hole + sat in the middle of a documented API — DevExpress names all five as bases a persistent class may + derive from, and recommends `PersistentBase`. Deriving from the higher bases is what you do when + the table brings its own key, which is the same population as the legacy schemas the DbSet roster + was added for. `FeatureCenter.NET.XPO` gains `OidGenerator`, `NoKeyPropertyNamedBaseObject` and + `LayoutDemoObject`. + ## [0.12.1] — 2026-08-13 Entities the application declares, rather than the ones that inherit from the right class. diff --git a/README.md b/README.md index c139065..ba0cde3 100644 --- a/README.md +++ b/README.md @@ -317,7 +317,7 @@ applications. The agent-facing surface is what is landing now, in the open. | ✅ | Pluggable publishing targets (`IDocumentationSink`) | | ✅ | **MCP server** — 10 tools, live against your source | | ✅ | **Installable Claude Code plugin** with skill and MCP server | -| ✅ | **289 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed | +| ✅ | **290 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed | | ✅ | **DevExpress ground-truth catalog**, generated locally by licensees | PeopleWorks Copilot, where this tool grew up, is now one sink among several rather than the diff --git a/src/XafLogicExplainer.Cli/Program.cs b/src/XafLogicExplainer.Cli/Program.cs index 002944d..37fc6b3 100644 --- a/src/XafLogicExplainer.Cli/Program.cs +++ b/src/XafLogicExplainer.Cli/Program.cs @@ -1838,7 +1838,6 @@ static ExtractionOptions BuildExtractionOptions(string language, string? orm = n { var options = new ExtractionOptions { - BaseTypeNames = ["XPCustomObject", "BaseObject", "XPObject", "XPLiteObject", "PermissionPolicyUser"], IncludeSourceCode = true, IncludeMethodBodies = true, IncludeComments = true, diff --git a/src/XafLogicExplainer.CopilotSync/Services/IncrementalSyncService.cs b/src/XafLogicExplainer.CopilotSync/Services/IncrementalSyncService.cs index 4a4532b..87744fe 100644 --- a/src/XafLogicExplainer.CopilotSync/Services/IncrementalSyncService.cs +++ b/src/XafLogicExplainer.CopilotSync/Services/IncrementalSyncService.cs @@ -68,7 +68,6 @@ public async Task SyncAsync(SyncConfiguration config, ExtractionOpti onProgress?.Invoke("Extracting project logic..."); extractionOptions ??= new ExtractionOptions { - BaseTypeNames = ["XPCustomObject", "BaseObject", "XPObject", "XPLiteObject", "PermissionPolicyUser"], IncludeSourceCode = true, IncludeMethodBodies = true, IncludeComments = true, diff --git a/src/XafLogicExplainer.Core/Models/ExtractionOptions.cs b/src/XafLogicExplainer.Core/Models/ExtractionOptions.cs index c713f97..222cec8 100644 --- a/src/XafLogicExplainer.Core/Models/ExtractionOptions.cs +++ b/src/XafLogicExplainer.Core/Models/ExtractionOptions.cs @@ -64,11 +64,28 @@ public class ExtractionOptions /// /// Known business object base types used to classify entity classes. /// + /// + /// The XPO names are the whole documented hierarchy — + /// PersistentBaseXPBaseObjectXPCustomObjectXPObject, with + /// XPLiteObject also under XPBaseObject — because DevExpress names all five as + /// bases a persistent class may derive from, and recommends PersistentBase. Listing the + /// leaves and not the two classes above them leaves a hole in the middle of an API that is + /// used: mapping onto a table that brings its own key is exactly when the higher bases are the + /// right choice. + /// + /// Callers are expected to use this list rather than pass their own. It was previously + /// duplicated in the CLI, the MCP server and the test harness, which is three chances for the + /// four copies to disagree about what an entity is. + /// + /// public string[] BaseTypeNames { get; set; } = [ + "PersistentBase", + "XPBaseObject", "XPCustomObject", - "BaseObject", "XPObject", - "XPLiteObject" + "XPLiteObject", + "BaseObject", + "PermissionPolicyUser", ]; /// diff --git a/src/XafLogicExplainer.Mcp/XafProjectContext.cs b/src/XafLogicExplainer.Mcp/XafProjectContext.cs index 9d8f08a..964f780 100644 --- a/src/XafLogicExplainer.Mcp/XafProjectContext.cs +++ b/src/XafLogicExplainer.Mcp/XafProjectContext.cs @@ -159,7 +159,6 @@ private static ExtractionOptions BuildOptions(XafProjectSource source) { var options = new ExtractionOptions { - BaseTypeNames = ["XPCustomObject", "BaseObject", "XPObject", "XPLiteObject", "PermissionPolicyUser"], IncludeSourceCode = true, IncludeMethodBodies = true, IncludeComments = true, diff --git a/tests/XafLogicExplainer.Tests/ExtractionTests.cs b/tests/XafLogicExplainer.Tests/ExtractionTests.cs index c41f17e..0bd3ea3 100644 --- a/tests/XafLogicExplainer.Tests/ExtractionTests.cs +++ b/tests/XafLogicExplainer.Tests/ExtractionTests.cs @@ -12,7 +12,7 @@ public void FindsEveryEntity() { var names = SampleProjects.Xpo.Entities.Select(e => e.ClassName).OrderBy(n => n).ToList(); - Assert.Equal(["Customer", "Order", "OrderLine"], names); + Assert.Equal(["AuditEntry", "Customer", "Order", "OrderLine", "SequenceCounter"], names); } [Fact] diff --git a/tests/XafLogicExplainer.Tests/Fixtures/XpoSolution/SampleApp.Module/BusinessObjects/AuditEntry.cs b/tests/XafLogicExplainer.Tests/Fixtures/XpoSolution/SampleApp.Module/BusinessObjects/AuditEntry.cs new file mode 100644 index 0000000..ce13664 --- /dev/null +++ b/tests/XafLogicExplainer.Tests/Fixtures/XpoSolution/SampleApp.Module/BusinessObjects/AuditEntry.cs @@ -0,0 +1,30 @@ +using System; +using System.ComponentModel; +using DevExpress.Persistent.Base; +using DevExpress.Xpo; + +namespace SampleApp.Module.BusinessObjects; + +/// +/// A row written when something changes. Derived from XPBaseObject, which carries no key +/// of its own, so the class supplies one. +/// +/// +/// XPBaseObject is the ancestor of XPCustomObject and a documented base for +/// persistent classes in its own right — mapping onto a table that already has its own key is +/// exactly when it is the right choice. +/// +[DefaultClassOptions] +[NavigationItem("Audit")] +public class AuditEntry : XPBaseObject +{ + public AuditEntry(Session session) : base(session) { } + + [Key(AutoGenerate = true), Browsable(false)] + public int Oid { get; set; } + + [Size(80)] + public string ChangedBy { get; set; } + + public DateTime ChangedOn { get; set; } +} diff --git a/tests/XafLogicExplainer.Tests/Fixtures/XpoSolution/SampleApp.Module/BusinessObjects/SequenceCounter.cs b/tests/XafLogicExplainer.Tests/Fixtures/XpoSolution/SampleApp.Module/BusinessObjects/SequenceCounter.cs new file mode 100644 index 0000000..b9f44c6 --- /dev/null +++ b/tests/XafLogicExplainer.Tests/Fixtures/XpoSolution/SampleApp.Module/BusinessObjects/SequenceCounter.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using DevExpress.Persistent.Base; +using DevExpress.Xpo; + +namespace SampleApp.Module.BusinessObjects; + +/// +/// The next number to hand out for a document type. Derived from PersistentBase, the root +/// of the XPO hierarchy and the class DevExpress recommends as a base for persistent classes. +/// +[DefaultClassOptions] +public class SequenceCounter : PersistentBase +{ + public SequenceCounter(Session session) : base(session) { } + + [Key, Size(60)] + public string TypeName { get; set; } + + public long NextNumber { get; set; } +} diff --git a/tests/XafLogicExplainer.Tests/HtmlExplainerTests.cs b/tests/XafLogicExplainer.Tests/HtmlExplainerTests.cs index d13a391..f680209 100644 --- a/tests/XafLogicExplainer.Tests/HtmlExplainerTests.cs +++ b/tests/XafLogicExplainer.Tests/HtmlExplainerTests.cs @@ -123,7 +123,7 @@ public void GraphPlacesEveryEntity() { var graph = EntityGraph.Build(SampleProjects.Xpo); - Assert.Equal(3, graph.Nodes.Count); + Assert.Equal(5, graph.Nodes.Count); Assert.All(graph.Nodes, n => Assert.InRange(n.X, 0, graph.Width)); Assert.All(graph.Nodes, n => Assert.InRange(n.Y, 0, graph.Height)); } diff --git a/tests/XafLogicExplainer.Tests/McpToolTests.cs b/tests/XafLogicExplainer.Tests/McpToolTests.cs index 372b01a..1a2b36a 100644 --- a/tests/XafLogicExplainer.Tests/McpToolTests.cs +++ b/tests/XafLogicExplainer.Tests/McpToolTests.cs @@ -105,7 +105,7 @@ public async Task AbsentEntityReturnsTheCompleteInventoryAndSaysItDoesNotExist() var result = await Detail.EntityAsync("PurchaseOrder", cancellationToken: TestContext.Current.CancellationToken); Assert.Contains("There is no entity called 'PurchaseOrder'", result); - Assert.Contains("complete list of 3 entities", result); + Assert.Contains("complete list of 5 entities", result); Assert.Contains("Customer", result); Assert.Contains("has not been created yet", result); } diff --git a/tests/XafLogicExplainer.Tests/OrmDetectionTests.cs b/tests/XafLogicExplainer.Tests/OrmDetectionTests.cs index 4b8f246..57635f6 100644 --- a/tests/XafLogicExplainer.Tests/OrmDetectionTests.cs +++ b/tests/XafLogicExplainer.Tests/OrmDetectionTests.cs @@ -14,6 +14,19 @@ public class OrmDetectionTests public void DetectsXpo() => Assert.Equal("XPO", SampleProjects.Xpo.OrmType, ignoreCase: true); + [Fact] + public void FindsEntitiesOnEveryDocumentedXpoBase() + { + // The hierarchy is PersistentBase -> XPBaseObject -> XPCustomObject -> XPObject, with + // XPLiteObject also under XPBaseObject. Recognising the leaves and not the two classes + // above them leaves a hole in the middle of a documented API: DevExpress names all five + // as bases you may derive persistent classes from, and recommends PersistentBase. + var names = SampleProjects.Xpo.Entities.Select(entity => entity.ClassName); + + Assert.Contains("AuditEntry", names); // : XPBaseObject + Assert.Contains("SequenceCounter", names); // : PersistentBase + } + [Fact] public void DetectsEfCoreFromItsNamespace() => Assert.Contains("EF", SampleProjects.EfCore.OrmType, StringComparison.OrdinalIgnoreCase); diff --git a/tests/XafLogicExplainer.Tests/SampleProjects.cs b/tests/XafLogicExplainer.Tests/SampleProjects.cs index 293e3a4..ae4b7cd 100644 --- a/tests/XafLogicExplainer.Tests/SampleProjects.cs +++ b/tests/XafLogicExplainer.Tests/SampleProjects.cs @@ -159,7 +159,6 @@ private static ExtractedProject Extract(string path) var options = new ExtractionOptions { - BaseTypeNames = ["XPCustomObject", "BaseObject", "XPObject", "XPLiteObject", "PermissionPolicyUser"], IncludeSourceCode = true, IncludeMethodBodies = true, IncludeComments = true,