Skip to content

Commit ae15039

Browse files
authored
Merge pull request peopleworks#4 from peopleworks/fix/dbset-roster-precision
Narrow the DbSet roster to what the application actually registers
2 parents 91c75d4 + 06c0cc7 commit ae15039

12 files changed

Lines changed: 456 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,32 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1616
complete. Only classes declared in the analyzed source qualify, so the framework tables a
1717
DbContext also registers (`ModuleInfo`, `FileData`, `ModelDifference`) stay out. On a
1818
221-entity application over a legacy LIMS schema this moves extraction from 3 entities to 210.
19+
Thanks to [@MBrekhof](https://github.com/MBrekhof).
20+
21+
- **A `partial` class is one entity, not one per file.** Matching by base class could only ever
22+
match once, because one part declares the base list; matching by the `DbSet` roster matches on
23+
the name, so every part matched — and the scaffolded split that produces two parts is exactly
24+
what the roster is for. The class came out twice, each copy holding half its columns: two
25+
incomplete truths with nothing to say they were the same class. The parts are now folded into
26+
one entity, which also recovers the members XPO extraction had always dropped where a
27+
hand-written part carries `: BaseObject` and a generated part carries the mapping.
28+
29+
- **The `DbSet` roster no longer matches on a bare name.** A name is not an identity: an
30+
application may keep a `Contracts.Invoice` DTO beside its `BusinessObjects.Invoice` entity, and
31+
the roster turned the DTO into a table. Registrations now carry the namespaces they could have
32+
been naming — the registering file's usings, its own namespace, and the namespaces enclosing it
33+
— which is ordinary C# lookup, the part of it syntax can see.
34+
35+
- **Business object files are read in a fixed order.** The directory hands them over in whatever
36+
order the file system keeps them, and that is not the same order on two machines: NTFS compares
37+
names without case, ext4 by byte, so `Shipment.Generated.cs` sorts after `Shipment.cs` on one and
38+
before it on the other. Extraction is now ordered by path, so a document regenerated on a laptop
39+
and in CI can be compared — which is most of what regenerating it is for.
40+
41+
- **Only a `DbContext`'s own properties count as registrations.** `DbSet<T>` written as a local or
42+
a parameter is a type name in a method body, not the application declaring a table. Contexts are
43+
found through their base chain as well, so an application whose contexts derive from a shared
44+
`AuditedDbContext` still registers everything.
1945

2046
## [0.12.0] — 2026-08-11
2147

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-
|| **275 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
320+
|| **279 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/Analyzers/EntityAnalyzer.cs

Lines changed: 224 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,26 +30,37 @@ public List<ExtractedEntity> AnalyzeEntities(string sourceDirectory, ExtractionO
3030

3131
// Parsed once and kept, because the DbSet roster has to be known before the first class is
3232
// classified and re-parsing every file to build it costs more than holding the trees.
33+
//
34+
// Ordered, because the directory hands them over in whatever order the file system keeps
35+
// them, and that is not the same order on two machines: NTFS compares names without case,
36+
// ext4 by byte, so `Shipment.Generated.cs` sorts after `Shipment.cs` on one and before it
37+
// on the other. Anything downstream that takes the first of something then answers
38+
// differently on a laptop than in CI, in a document whose value is that it can be
39+
// regenerated and compared.
3340
var parsedFiles = csFiles
41+
.OrderBy(file => file, StringComparer.Ordinal)
3442
.Select(file => (File: file, Root: CSharpSyntaxTree.ParseText(File.ReadAllText(file), path: file).GetRoot()))
3543
.ToList();
3644

37-
var registeredTypes = CollectDbSetTypeNames(parsedFiles.Select(parsed => parsed.Root));
45+
var roster = DbSetRoster.Read(parsedFiles.Select(parsed => parsed.Root));
3846

3947
foreach (var (file, root) in parsedFiles)
4048
{
4149
var classDeclarations = root.DescendantNodes().OfType<ClassDeclarationSyntax>();
4250
foreach (var classDecl in classDeclarations)
4351
{
4452
if (IsXafBusinessObject(classDecl, options.BaseTypeNames)
45-
|| registeredTypes.Contains(classDecl.Identifier.Text))
53+
|| roster.Registers(GetNamespace(classDecl), classDecl.Identifier.Text))
4654
{
4755
var entity = ExtractEntity(classDecl, file, options);
4856
entities.Add(entity);
4957
}
5058
}
5159
}
5260

61+
// One entity per class, not per declaration -- a partial split across files is one thing.
62+
entities = MergePartialDeclarations(entities);
63+
5364
// Post-extraction: infer EF Core relationships from navigation properties
5465
if (ormType == OrmType.EfCore)
5566
InferEfCoreRelationships(entities);
@@ -476,7 +487,8 @@ private static OrmType DetectOrmType(IEnumerable<string> csFiles)
476487
}
477488

478489
/// <summary>
479-
/// Collects the type names an application registers as <c>DbSet&lt;T&gt;</c>.
490+
/// The types an application registers as <c>DbSet&lt;T&gt;</c> on a <c>DbContext</c>, and the
491+
/// namespaces each registration could have been naming.
480492
/// </summary>
481493
/// <remarks>
482494
/// Under EF Core this is the application's own statement of what it persists, and it has to be
@@ -489,27 +501,226 @@ private static OrmType DetectOrmType(IEnumerable<string> csFiles)
489501
/// are declared in DevExpress assemblies and are therefore never seen here, so they drop out
490502
/// without needing a list of names to exclude.
491503
/// </para>
504+
/// <para>
505+
/// The roster carries a namespace scope rather than a bare name because a bare name is not an
506+
/// identity: an application is free to have a <c>Contracts.Invoice</c> DTO beside its
507+
/// <c>BusinessObjects.Invoice</c> entity, and telling an agent the DTO is persistent is worse
508+
/// than the gap this roster exists to close. What is modelled here is ordinary C# lookup, the
509+
/// part of it syntax can see: the registering file's usings, its own namespace, and the
510+
/// namespaces enclosing it. Aliases, <c>using static</c> and extern aliases are not modelled —
511+
/// a registration that needs one of those finds no class and is dropped, which is the safe
512+
/// direction.
513+
/// </para>
492514
/// </remarks>
493-
private static HashSet<string> CollectDbSetTypeNames(IEnumerable<SyntaxNode> roots)
515+
private sealed class DbSetRoster
494516
{
495-
var names = new HashSet<string>(StringComparer.Ordinal);
517+
private readonly List<(string Argument, HashSet<string> Scopes)> _registrations = [];
496518

497-
foreach (var root in roots)
519+
/// <summary>
520+
/// Reads every <c>DbSet&lt;T&gt;</c> property declared on a context in the parsed source.
521+
/// </summary>
522+
public static DbSetRoster Read(IEnumerable<SyntaxNode> roots)
498523
{
499-
foreach (var generic in root.DescendantNodes().OfType<GenericNameSyntax>())
524+
var roster = new DbSetRoster();
525+
var trees = roots.ToList();
526+
527+
// `global using` reaches every file, so it has to be gathered before any one file is
528+
// read -- including from a GlobalUsings.cs that declares nothing else.
529+
var globalUsings = trees
530+
.SelectMany(root => root.DescendantNodes().OfType<UsingDirectiveSyntax>())
531+
.Where(directive => !directive.GlobalKeyword.IsKind(SyntaxKind.None))
532+
.Select(directive => directive.Name?.ToString())
533+
.Where(name => name is not null)
534+
.Select(name => name!)
535+
.ToHashSet(StringComparer.Ordinal);
536+
537+
var contexts = FindContextClasses(trees);
538+
539+
foreach (var context in contexts)
500540
{
501-
if (generic.Identifier.Text != "DbSet" || generic.TypeArgumentList.Arguments.Count != 1)
541+
var root = context.SyntaxTree.GetRoot();
542+
var scopes = new HashSet<string>(globalUsings, StringComparer.Ordinal);
543+
544+
foreach (var directive in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
545+
{
546+
if (directive.Alias is null && directive.Name is not null)
547+
scopes.Add(directive.Name.ToString());
548+
}
549+
550+
// The context's own namespace, and every namespace enclosing it: C# resolves an
551+
// unqualified name outwards, so an entity one level up needs no using directive.
552+
var contextNamespace = GetNamespace(context);
553+
for (var scope = contextNamespace; ; )
554+
{
555+
scopes.Add(scope);
556+
var dot = scope.LastIndexOf('.');
557+
if (dot < 0) break;
558+
scope = scope[..dot];
559+
}
560+
561+
foreach (var property in context.Members.OfType<PropertyDeclarationSyntax>())
562+
{
563+
if (property.Type is not GenericNameSyntax generic) continue;
564+
if (generic.Identifier.Text != "DbSet") continue;
565+
if (generic.TypeArgumentList.Arguments.Count != 1) continue;
566+
567+
var argument = generic.TypeArgumentList.Arguments[0].ToString();
568+
if (argument.Length > 0)
569+
roster._registrations.Add((argument, scopes));
570+
}
571+
}
572+
573+
return roster;
574+
}
575+
576+
/// <summary>
577+
/// Whether the application registers this class -- by name, and from somewhere that could
578+
/// actually have been naming this one.
579+
/// </summary>
580+
public bool Registers(string @namespace, string className)
581+
{
582+
foreach (var (argument, scopes) in _registrations)
583+
{
584+
var dot = argument.LastIndexOf('.');
585+
var simpleName = dot < 0 ? argument : argument[(dot + 1)..];
586+
if (!string.Equals(simpleName, className, StringComparison.Ordinal)) continue;
587+
588+
if (dot < 0)
589+
{
590+
if (scopes.Contains(@namespace)) return true;
502591
continue;
592+
}
593+
594+
// A qualified registration -- DbSet<Contracts.Invoice> -- names its own namespace
595+
// tail, and that is more specific than anything the usings could tell us.
596+
var qualifier = argument[..dot];
597+
if (@namespace.Equals(qualifier, StringComparison.Ordinal)
598+
|| @namespace.EndsWith("." + qualifier, StringComparison.Ordinal))
599+
return true;
600+
}
601+
602+
return false;
603+
}
604+
605+
/// <summary>
606+
/// The classes that are a <c>DbContext</c>, following base classes declared in the source.
607+
/// </summary>
608+
/// <remarks>
609+
/// Following the chain matters because an application that writes its own
610+
/// <c>AuditedDbContext : DbContext</c> and derives every real context from it would
611+
/// otherwise register nothing -- the same silent-empty failure this roster is here to fix.
612+
/// <para>
613+
/// The chain is walked by simple name, which cannot distinguish two same-named classes in
614+
/// different namespaces. The cost of being wrong is small in this direction: a class only
615+
/// contributes to the roster if it also declares <c>DbSet&lt;T&gt;</c> properties, which
616+
/// something that is not a context does not do.
617+
/// </para>
618+
/// </remarks>
619+
private static List<ClassDeclarationSyntax> FindContextClasses(List<SyntaxNode> trees)
620+
{
621+
var declared = trees
622+
.SelectMany(root => root.DescendantNodes().OfType<ClassDeclarationSyntax>())
623+
.ToList();
624+
625+
var contextNames = new HashSet<string>(StringComparer.Ordinal) { "DbContext" };
626+
627+
// A fixed point, because a base class may be read after the class deriving from it.
628+
bool grew;
629+
do
630+
{
631+
grew = false;
632+
foreach (var candidate in declared)
633+
{
634+
if (contextNames.Contains(candidate.Identifier.Text)) continue;
635+
if (!GetBaseTypeNames(candidate).Any(contextNames.Contains)) continue;
636+
637+
contextNames.Add(candidate.Identifier.Text);
638+
grew = true;
639+
}
640+
} while (grew);
641+
642+
return declared
643+
.Where(candidate => GetBaseTypeNames(candidate).Any(contextNames.Contains))
644+
.ToList();
645+
}
646+
}
503647

504-
var argument = generic.TypeArgumentList.Arguments[0].ToString();
505-
var simpleName = argument[(argument.LastIndexOf('.') + 1)..];
648+
/// <summary>
649+
/// Folds the parts of a <c>partial</c> class into the one entity they describe.
650+
/// </summary>
651+
/// <remarks>
652+
/// A class matched by its base list could only ever match once, because only one part declares
653+
/// the base list. A class matched by the DbSet roster matches on its name, so every part
654+
/// matches -- and a scaffolded legacy schema, which is exactly what the roster is for, splits
655+
/// its classes as a matter of routine. Left alone that reports the entity twice, each copy
656+
/// holding half its properties: two incomplete truths, and no way for a reader to tell they
657+
/// are the same class.
658+
/// <para>
659+
/// Merging also recovers the members XPO extraction has always dropped, where a hand-written
660+
/// part carries <c>: BaseObject</c> and a generated part carries half the columns.
661+
/// </para>
662+
/// </remarks>
663+
private static List<ExtractedEntity> MergePartialDeclarations(List<ExtractedEntity> entities)
664+
{
665+
var parts = new Dictionary<(string Namespace, string ClassName), List<ExtractedEntity>>();
666+
var order = new List<(string Namespace, string ClassName)>();
506667

507-
if (simpleName.Length > 0)
508-
names.Add(simpleName);
668+
foreach (var entity in entities)
669+
{
670+
var key = (entity.Namespace, entity.ClassName);
671+
if (!parts.TryGetValue(key, out var group))
672+
{
673+
parts[key] = group = [];
674+
order.Add(key);
509675
}
676+
group.Add(entity);
510677
}
511678

512-
return names;
679+
var merged = new List<ExtractedEntity>();
680+
681+
foreach (var key in order)
682+
{
683+
var group = parts[key];
684+
685+
// The part declaring the base list is the hand-written one, and it is where the class
686+
// attributes live. Taking whichever half came first instead would make the reported
687+
// file, and the order of the columns, depend on the file system doing the listing.
688+
var primary = group.Find(part => part.BaseTypes.Count > 0) ?? group[0];
689+
merged.Add(primary);
690+
691+
foreach (var entity in group)
692+
{
693+
if (ReferenceEquals(entity, primary)) continue;
694+
695+
primary.Description ??= entity.Description;
696+
primary.NavigationGroup ??= entity.NavigationGroup;
697+
primary.DefaultProperty ??= entity.DefaultProperty;
698+
primary.ModelCaption ??= entity.ModelCaption;
699+
primary.SourceProject ??= entity.SourceProject;
700+
primary.IsDefaultClassOptions |= entity.IsDefaultClassOptions;
701+
primary.IsCloneable |= entity.IsCloneable;
702+
703+
// Non-persistent anywhere means non-persistent: one part saying so is the class.
704+
primary.IsPersistent &= entity.IsPersistent;
705+
706+
if (primary.BaseType is "object" or "" && entity.BaseType is not ("object" or ""))
707+
primary.BaseType = entity.BaseType;
708+
709+
foreach (var baseType in entity.BaseTypes.Where(name => !primary.BaseTypes.Contains(name)))
710+
primary.BaseTypes.Add(baseType);
711+
712+
var known = primary.Properties.Select(property => property.Name).ToHashSet(StringComparer.Ordinal);
713+
primary.Properties.AddRange(entity.Properties.Where(property => known.Add(property.Name)));
714+
715+
primary.Relationships.AddRange(entity.Relationships);
716+
primary.ValidationRules.AddRange(entity.ValidationRules);
717+
primary.AppearanceRules.AddRange(entity.AppearanceRules);
718+
primary.InferredBusinessRules.AddRange(entity.InferredBusinessRules);
719+
primary.SourceComments.AddRange(entity.SourceComments);
720+
}
721+
}
722+
723+
return merged;
513724
}
514725

515726
private static bool IsXafBusinessObject(ClassDeclarationSyntax classDecl, string[] baseTypeNames)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace SampleLegacy.Module.BusinessObjects;
4+
5+
/// <summary>
6+
/// The context every other context in this application derives from.
7+
/// </summary>
8+
/// <remarks>
9+
/// A shared base is how an application puts auditing, soft delete or a connection convention in
10+
/// one place. It means the contexts that actually register entities do not name
11+
/// <c>DbContext</c> anywhere in their own declaration.
12+
/// </remarks>
13+
public abstract class AuditedDbContext : DbContext
14+
{
15+
protected AuditedDbContext(DbContextOptions options) : base(options) { }
16+
17+
public override int SaveChanges() => base.SaveChanges();
18+
}
19+
20+
/// <summary>
21+
/// Closed invoices, moved off the live tables. Registers through the shared base.
22+
/// </summary>
23+
public class ArchiveDbContext : AuditedDbContext
24+
{
25+
public ArchiveDbContext(DbContextOptions<ArchiveDbContext> options) : base(options) { }
26+
27+
public DbSet<ArchivedInvoice> ArchivedInvoices { get; set; }
28+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using DevExpress.Persistent.Base;
2+
using System.ComponentModel.DataAnnotations;
3+
using System.ComponentModel.DataAnnotations.Schema;
4+
5+
namespace SampleLegacy.Module.BusinessObjects;
6+
7+
/// <summary>
8+
/// An invoice that has been closed out. Reachable only through a context that never names
9+
/// <c>DbContext</c> itself.
10+
/// </summary>
11+
[Table("invoice_archive")]
12+
[DefaultClassOptions]
13+
public partial class ArchivedInvoice : BaseEntity
14+
{
15+
[Key]
16+
[Column("Id")]
17+
public virtual int Id { get; set; }
18+
19+
[Column("ClosedOn")]
20+
public virtual DateTime ClosedOn { get; set; }
21+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace SampleLegacy.Module.BusinessObjects.Contracts;
2+
3+
/// <summary>
4+
/// The shape a warehouse takes on the wire. Nothing persists it, and the DbContext -- one
5+
/// namespace out, importing nothing from here -- could not be naming it: C# resolves an
6+
/// unqualified name outwards, never down into a child namespace.
7+
/// </summary>
8+
/// <remarks>
9+
/// It exists to share a simple name with a real entity. A roster of bare names cannot tell the two
10+
/// apart, and would tell an agent this is a table.
11+
/// </remarks>
12+
public class Warehouse
13+
{
14+
public string Code { get; set; } = string.Empty;
15+
16+
public string Payload { get; set; } = string.Empty;
17+
}

0 commit comments

Comments
 (0)