Skip to content

Find EF Core entities by their DbSet registration - #3

Merged
peopleworks merged 2 commits into
peopleworks:mainfrom
MBrekhof:fix/efcore-entity-discovery
Aug 13, 2026
Merged

Find EF Core entities by their DbSet registration#3
peopleworks merged 2 commits into
peopleworks:mainfrom
MBrekhof:fix/efcore-entity-discovery

Conversation

@MBrekhof

@MBrekhof MBrekhof commented Aug 13, 2026

Copy link
Copy Markdown

What this changes

EntityAnalyzer now also classifies a class as an entity when the application registers it as a DbSet<T>, instead of only when its direct base list matches one of the four names in ExtractionOptions.BaseTypeNames.

Why

An XAF application mapped onto an existing schema rarely derives from BaseObject: the tables bring their own primary keys, so the project writes its own base class or maps a plain POCO. None of those were being seen, and — as the SelectControllers remark already puts it for the controller case — a class that is never extracted cannot be reported as missing. AGENTS.md then goes on to tell the agent the inventory is complete and that anything absent does not exist.

Under EF Core the application already declares what it persists, in the one place that has to be correct for it to run at all. Reading that roster needs no compilation and no new heuristic.

Only classes declared in the analyzed source are eligible, so the framework tables a DbContext also registers — ModuleInfo, FileData, ModelDifference — drop out on their own rather than through a list of excluded names that would need maintaining. There's a test pinning that, since over-reporting here would be worse than the gap it fixes.

Measured on a real 221-entity XAF application over a legacy LIMS schema (the one that motivated #1):

before after
Business entities 3 210
*_Entities.md 1.9 KB 231 KB
*_BusinessRules.md 469 B 8.5 KB

The remaining 11 of 221 are the framework-declared types above, correctly excluded.

Closes #1

The pattern, if this is an extraction change

// The base class an application writes when its tables already exist: it carries the XAF
// contract through interfaces, because a legacy table brings its own key and the Oid that
// BaseObject insists on does not fit it. A transitive base walk would not find this either --
// the chain terminates at the interfaces, not at a known base type.
public abstract class BaseEntity : IXafEntityObject, IObjectSpaceLink
{
    protected IObjectSpace ObjectSpace;

    IObjectSpace IObjectSpaceLink.ObjectSpace
    {
        get => ObjectSpace;
        set => ObjectSpace = value;
    }

    public virtual void OnCreated() { }
    public virtual void OnLoaded() { }
    public virtual void OnSaving() { }
}

[Table("invoice")]
[DefaultClassOptions]
[NavigationItem("Sales")]
[XafDisplayName("Invoices")]
[XafDefaultProperty(nameof(Number))]
[Appearance("InvoicePaid", TargetItems = "*", Criteria = "IsPaid", BackColor = "LightGreen")]
public partial class Invoice : BaseEntity
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    [Column("Id")]
    public virtual int Id { get; set; }

    [Column("Number")]
    [StringLength(20)]
    [RuleRequiredField("Invoice_Number_Required", DefaultContexts.Save)]
    public virtual string Number { get; set; }
}

// A scaffolded legacy table, with no XAF base class and no XAF interface at all.
[Table("warehouse")]
[DefaultClassOptions]
public partial class Warehouse
{
    [Key, Column("Code"), StringLength(10)]
    public virtual string Code { get; set; }
}

public class LegacyDbContext : DbContext
{
    public DbSet<ModuleInfo> ModulesInfo { get; set; }   // framework: must NOT become an entity
    public DbSet<FileData> FileData { get; set; }        // framework: must NOT become an entity
    public DbSet<Invoice> Invoices { get; set; }
    public DbSet<Warehouse> Warehouses { get; set; }
}

The fixture lives in its own solution folder as LegacyEfSolution, rather than joining EfCoreSolution, for two reasons: OrmDetectionTests.FindsEfCoreEntitiesDespiteTheSharedBaseClassName asserts that fixture's exact entity set, and the two are genuinely different applications — one a greenfield model on BaseObject, one mapped onto tables that were already there.

Deliberately not in this PR

  • A transitive base-class walk for the XPO side, mirroring SelectControllers. Real, but a separate pattern and a separate change.
  • IXafEntityObject / [DefaultClassOptions] as independent signals. They would catch an entity that is somehow not in any DbContext; the roster covers every case I can currently demonstrate, so this keeps to the smaller rule.
  • The ORM misdetection filed as [gap] EF Core project reported as XPO when entities do not use BaseImpl.EF #2, which shares this fixture but has an unrelated cause in DetectOrmType. Happy to send that separately if you want it.

Checklist

  • dotnet build XAFLogicExplainer.slnx is clean (CI treats warnings as errors) — 0 warnings, 0 errors
  • No DevExpress reference was added to XafLogicExplainer.Core
  • Extraction still works on a project that does not compile — the new fixture is never compiled, like the others
  • An unrecognized variation of this pattern is skipped, not thrown on — a DbSet<T> whose type is not declared in the source is ignored, not faulted
  • CHANGELOG.md updated under [Unreleased]

275 tests pass, 270 before. Four of the five new tests fail on main with The fixture has no entity 'Invoice'. Extracted: — the fifth is DoesNotReportFrameworkTypesRegisteredInTheDbContext, which passes there vacuously because nothing is extracted at all, and only becomes load-bearing once the roster is read. The README's tested test-count claim is updated to match.

Brekhof and others added 2 commits August 13, 2026 21:25
An XAF application mapped onto an existing schema rarely derives from
BaseObject: the tables bring their own keys, so the project writes its own
base class or maps a plain POCO. IsXafBusinessObject matched only the class's
direct base list against four XPO-era names, so none of those were seen, and
the loss was silent -- an entity that is never extracted cannot be reported
as missing, and AGENTS.md goes on to tell the agent its inventory is complete.

Under EF Core the application already states what it persists, in the one
place that must be right for it to run: the DbSet<T> properties of its
DbContext. Classifying a class as an entity when it is registered there finds
these applications without giving up the existing base-type rule.

Only classes declared in the analyzed source are eligible, so the framework
tables a DbContext also registers -- ModuleInfo, FileData, ModelDifference --
drop out on their own, with no list of names to maintain.

On a real 221-entity XAF application over a legacy LIMS schema this moves
extraction from 3 entities to 210, and its business rules from 469 bytes to
8.5 KB.

Fixes peopleworks#1

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@peopleworks peopleworks left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this. Three things before anything else, because they are not the usual first contribution:

You read the code before filing. You separated #1 and #2 by cause rather than by symptom, even though one fixture produced both. And you found the argument this project is actually built on — "a class that is never seen cannot be reported as missing" — and applied it to a place I had not applied it myself. That last one is the part I care about most. The controller fix in 0.12.0 and this one are the same defect wearing different clothes, and I did not notice.

You are also right about the thing that makes this severe rather than annoying. A missing entity is not a hole in a document. AGENTS.md goes on to tell the agent that its inventories are complete and that anything absent does not exist in the application — so a silent gap does not make the agent uninformed, it makes it confidently wrong. Three entities out of 221, with a header promising the full set, is close to the worst output this tool can produce.

What I verified

Built and ran the branch locally on Windows, .NET 10, Release:

Passed!  Failed: 0, Passed: 275, Skipped: 0     0 warnings, 0 errors

I also confirmed the four new tests fail on main with the message you quoted, and that DoesNotReportFrameworkTypesRegisteredInTheDbContext passes there vacuously — your read of that was exact, and saying so in the PR body instead of letting the green tick speak for itself is the reason this review took me an hour instead of a day.

Three things I need changed first

I built throwaway projects and put them through EntityAnalyzer rather than reasoning from the diff. All three come from one root: registeredTypes is a set of bare simple names, matched globally, so it matches more than the roster you documented.

1. A partial class split across files is reported twice — blocking

// Ctx.cs
public class LegacyDbContext : DbContext { public DbSet<Invoice> Invoices { get; set; } }
// Invoice.cs
public partial class Invoice { public virtual int Id { get; set; } }
// Invoice.Generated.cs
public partial class Invoice { public virtual string Number { get; set; } }
Probe.Module.BusinessObjects.Invoice  props=1  file=Invoice.cs
Probe.Module.BusinessObjects.Invoice  props=1  file=Invoice.Generated.cs

Two entities, and neither one is complete — the properties are split between the copies, so the agent is told Invoice has Id and, separately, that Invoice has Number.

main does not do this: IsXafBusinessObject returns false on BaseList == null, so only the part carrying the base class ever matched. The roster matches every part by name, so this arrives with the fix. And it arrives precisely in the population the fix is for — a scaffolded legacy schema splits partials as a matter of routine. Your own fixture writes public partial class Invoice; it just happens to be one file, so the suite never asks the question.

2. An unrelated class sharing the simple name becomes an entity

Probe.Module.BusinessObjects.Invoice  props=1  file=Invoice.cs
Probe.Module.Contracts.Invoice        props=1  file=InvoiceDto.cs

A DTO that merely shares a name joins the business inventory.

3. The roster is wider than "the DbContext's registrations"

public void Work(DbContext db) { DbSet<AuditLog> local = db.Set<AuditLog>(); }

AuditLog is registered as an entity. CollectDbSetTypeNames walks every GenericNameSyntax named DbSet anywhere in the source, so a local, a parameter or a return type counts the same as a property on a context.

Why I am asking rather than merging and patching

The argument you wrote for this change is the reason. Over-reporting is worse than under-reporting here, because a fabricated entity is indistinguishable from a real one to the agent reading the file, while a missing one at least stays missing. You knew that — it is why DoesNotReportFrameworkTypesRegisteredInTheDbContext is in the PR. That guard is name-based, and these three walk under it.

The direction is right and I want this in. What it needs is a narrower roster:

  • read it only from property declarations whose declaring type derives from DbContext, which is what the remark already claims and would resolve (3) on its own;
  • key entities on (namespace, class name) and merge the parts of a partial rather than emitting one per declaration — that fixes (1), and (2) reduces to picking the right namespace, for which the context file's using directives are a reasonable syntax-only signal. If that turns out to be ugly without a semantic model, say so and we will take same-namespace-as-the-context first with a documented fallback. I would rather have an honest heuristic with its limits written down than a clever one that cannot say when it is guessing.

A test for each, please — the split partial especially, since it is the one that would ship.

Two other things

#2 — yes, send it. Separately, as you suggested. And I agree with your instinct there over my own default: where no ORM signal is found at all, "unknown" is the right thing to render. That fallback is emitting a guess in the voice of a fact, which is the same failure as the empty inventory that promises completeness. If you would rather I take that one so you are not carrying two, say the word.

The transitive base walk on the XPO side — you were right to leave it out, and right that it exists. Open an issue for it when you have a moment and it is yours if you want it.

Last thing. I have been building this on my own, and you have improved it twice in a day, in a style that reads like you have maintained something before. If you would like to be more involved than one PR — commit rights, a say in where the extractor goes next, your name on the project — I am interested, and I would rather ask now than after you have done the work for free. Email is in my profile.

Either way: thank you. This is the contribution I hoped the extraction-gap template would bring in, and I did not expect it in the first week.

Comment on lines +499 to +509
var argument = generic.TypeArgumentList.Arguments[0].ToString();
var simpleName = argument[(argument.LastIndexOf('.') + 1)..];

if (simpleName.Length > 0)
names.Add(simpleName);
}
}

return names;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[gap] EF Core entities on a hand-written base class are not discovered

4 participants