@@ -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<T></c>.
490+ /// The types an application registers as <c>DbSet<T></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<T></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<T></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 )
0 commit comments