Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/ProjGraph.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ public static int Main(string[] args)
{
config.SetApplicationName("projgraph");

// Spectre's default parser silently ignores unrecognized long options: a typo like
// `--owned-mod classic` (missing 'e') exits 0 and renders with defaults, giving the user
// no signal their option was dropped. Strict parsing turns any unknown option into a
// parse error with a non-zero exit code instead.
config.Settings.StrictParsing = true;

config.AddCommand<VisualizeCommand>(packageDiagramCommandName)
.WithDescription("Visualize the dependency graph of a solution or project")
.WithExample(packageDiagramCommandName, "MySolution.sln")
Expand Down
8 changes: 8 additions & 0 deletions src/ProjGraph.Core/Models/EfModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ public class EfEntity
/// </summary>
public string EffectiveKey => string.IsNullOrEmpty(Key) ? Name : Key;

/// <summary>
/// Gets the table this entity effectively maps to: its explicit <see cref="TableName"/>, or its
/// <see cref="Name"/> when unmapped (EF's default). The single source of the table-sharing rule —
/// an owned type shares its owner's table exactly when their effective tables are equal — used by
/// both the capture side (owned-type table resolution) and the render side (inline-vs-box).
/// </summary>
public string EffectiveTable => string.IsNullOrEmpty(TableName) ? Name : TableName;

/// <summary>
/// Gets or initializes a value indicating whether the entity is an EF Core owned type
/// (configured via <c>OwnsOne</c>/<c>OwnsMany</c>) rather than a root entity.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -348,9 +348,8 @@ private async Task ResolveOwnedNavigationTypesAsync(
classDeclsByName.TryGetValue(ownerClrType, out ownerDecl);
}

var propertyDecl = ownerDecl?.Members.OfType<PropertyDeclarationSyntax>()
.FirstOrDefault(p => p.Identifier.Text == navigation);
var ownedTypeName = propertyDecl is null ? null : OwnedClrTypeName(propertyDecl.Type, isCollection);
var navigationType = ownerDecl is null ? null : OwnedNavigationTypeSyntax(ownerDecl, navigation);
var ownedTypeName = navigationType is null ? null : OwnedClrTypeName(navigationType, isCollection);
if (ownedTypeName is null)
{
continue;
Expand All @@ -373,6 +372,28 @@ private async Task ResolveOwnedNavigationTypesAsync(
}
}

/// <summary>
/// Returns the type syntax of the owner's navigation named <paramref name="navigation"/>: a member
/// property declaration, or — for a positional record like <c>record Product(int Id, Money Price)</c> —
/// the primary-constructor parameter of that name, whose synthesized property is a real EF navigation
/// but never appears as a <see cref="PropertyDeclarationSyntax"/> member. Returns <see langword="null"/>
/// when the owner declares no such navigation.
/// </summary>
/// <param name="ownerDecl">The owner's type declaration.</param>
/// <param name="navigation">The navigation property name.</param>
private static TypeSyntax? OwnedNavigationTypeSyntax(TypeDeclarationSyntax ownerDecl, string navigation)
{
var propertyDecl = ownerDecl.Members.OfType<PropertyDeclarationSyntax>()
.FirstOrDefault(p => p.Identifier.Text == navigation);
if (propertyDecl is not null)
{
return propertyDecl.Type;
}

return (ownerDecl as RecordDeclarationSyntax)?.ParameterList?.Parameters
.FirstOrDefault(p => p.Identifier.Text == navigation)?.Type;
}

/// <summary>
/// Reduces a property's type syntax to the owned CLR type's simple name: unwraps a nullable
/// annotation, and — for an <c>OwnsMany</c> navigation — the collection's element type
Expand Down
53 changes: 40 additions & 13 deletions src/ProjGraph.Lib.EntityFramework/Infrastructure/EntityAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,25 +167,28 @@ private static void ExtractPrimaryKeysFromSyntax(INamedTypeSymbol type, HashSet<
{
foreach (var syntaxRef in type.DeclaringSyntaxReferences)
{
if (syntaxRef.GetSyntax() is not ClassDeclarationSyntax classSyntax)
// TypeDeclarationSyntax, not ClassDeclarationSyntax: a record-declared entity's [PrimaryKey]
// and [Key] attributes live on a RecordDeclarationSyntax, which is not a class declaration —
// the same hazard family that made record-declared owners invisible to owned-nav discovery.
if (syntaxRef.GetSyntax() is not TypeDeclarationSyntax typeSyntax)
{
continue;
}

ExtractPrimaryKeysFromClassAttributes(classSyntax, primaryKeyNames);
ExtractPrimaryKeysFromPropertySyntax(classSyntax, primaryKeyNames);
ExtractPrimaryKeysFromClassAttributes(typeSyntax, primaryKeyNames);
ExtractPrimaryKeysFromPropertySyntax(typeSyntax, primaryKeyNames);
}
}

/// <summary>
/// Extracts primary key names from class-level attribute syntax.
/// Extracts primary key names from type-level attribute syntax.
/// </summary>
/// <param name="classSyntax">The class declaration syntax to analyze.</param>
/// <param name="typeSyntax">The type declaration syntax (class or record) to analyze.</param>
/// <param name="primaryKeyNames">The set to populate with primary key names.</param>
private static void ExtractPrimaryKeysFromClassAttributes(ClassDeclarationSyntax classSyntax,
private static void ExtractPrimaryKeysFromClassAttributes(TypeDeclarationSyntax typeSyntax,
HashSet<string> primaryKeyNames)
{
foreach (var attr in classSyntax.AttributeLists.SelectMany(al => al.Attributes))
foreach (var attr in typeSyntax.AttributeLists.SelectMany(al => al.Attributes))
{
var name = attr.Name.ToString();
if (name is not (EfAnalysisConstants.EfAttributes.PrimaryKey
Expand All @@ -208,22 +211,46 @@ private static void ExtractPrimaryKeysFromClassAttributes(ClassDeclarationSyntax
}

/// <summary>
/// Extracts primary key names from property syntax with Key attributes.
/// Extracts primary key names from property syntax with Key attributes. For a positional record,
/// a key declared as <c>[property: Key]</c> lives on a primary-constructor parameter (whose
/// synthesized property carries the attribute), not on a <see cref="PropertyDeclarationSyntax"/>
/// member, so the parameter list is scanned as well.
/// </summary>
/// <param name="classSyntax">The class declaration syntax to analyze.</param>
/// <param name="typeSyntax">The type declaration syntax (class or record) to analyze.</param>
/// <param name="primaryKeyNames">The set to populate with primary key names.</param>
private static void ExtractPrimaryKeysFromPropertySyntax(ClassDeclarationSyntax classSyntax,
private static void ExtractPrimaryKeysFromPropertySyntax(TypeDeclarationSyntax typeSyntax,
HashSet<string> primaryKeyNames)
{
foreach (var prop in classSyntax.Members.OfType<PropertyDeclarationSyntax>()
foreach (var prop in typeSyntax.Members.OfType<PropertyDeclarationSyntax>()
.Where(p => p.AttributeLists.SelectMany(al => al.Attributes)
.Any(a => a.Name.ToString() is EfAnalysisConstants.EfAttributes.Key
or EfAnalysisConstants.EfAttributes.KeyAttribute)))
.Any(IsKeyAttribute)))
{
primaryKeyNames.Add(prop.Identifier.Text);
}

if (typeSyntax is not RecordDeclarationSyntax { ParameterList: not null } record)
{
return;
}

// Only property-targeted attributes count: EF reads the attribute off the synthesized
// property, and a bare [Key] on a parameter targets the parameter itself, which EF ignores.
foreach (var parameter in record.ParameterList.Parameters
.Where(p => p.AttributeLists
.Where(al => al.Target?.Identifier.Text == "property")
.SelectMany(al => al.Attributes)
.Any(IsKeyAttribute)))
{
primaryKeyNames.Add(parameter.Identifier.Text);
}
}

/// <summary>Determines whether an attribute syntax names the EF <c>[Key]</c> attribute.</summary>
/// <param name="attribute">The attribute syntax.</param>
private static bool IsKeyAttribute(AttributeSyntax attribute)
=> attribute.Name.ToString() is EfAnalysisConstants.EfAttributes.Key
or EfAnalysisConstants.EfAttributes.KeyAttribute;


private static void CollectNamesFromConstant(TypedConstant constant, HashSet<string> names)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,10 @@ private static IEnumerable<ConfigClass> FindConfigClasses(Compilation compilatio
/// <param name="root">The syntax root to scan.</param>
internal static IEnumerable<ConfigClass> FindConfigClassesInRoot(SyntaxNode root)
{
foreach (var declaration in root.DescendantNodes().OfType<ClassDeclarationSyntax>())
// TypeDeclarationSyntax, not ClassDeclarationSyntax: a record (or struct) can implement
// IEntityTypeConfiguration<T> too, and a class-only scan would silently skip its Configure body —
// the same hazard family that made record-declared owners invisible to owned-nav discovery.
foreach (var declaration in root.DescendantNodes().OfType<TypeDeclarationSyntax>())
{
if (AsConfigClass(declaration) is { } configClass)
{
Expand All @@ -152,10 +155,18 @@ internal static IEnumerable<ConfigClass> FindConfigClassesInRoot(SyntaxNode root
}
}

/// <summary>Interprets a class declaration as a config class, or returns <see langword="null"/> if it is not one.</summary>
/// <param name="declaration">The class declaration.</param>
private static ConfigClass? AsConfigClass(ClassDeclarationSyntax declaration)
/// <summary>Interprets a type declaration as a config class, or returns <see langword="null"/> if it is not one.</summary>
/// <param name="declaration">The type declaration.</param>
private static ConfigClass? AsConfigClass(TypeDeclarationSyntax declaration)
{
// EF's ApplyConfigurationsFromAssembly only instantiates concrete types (a record compiles to
// a class, so it qualifies); an interface extending IEntityTypeConfiguration<T> with a
// default-implemented Configure is never applied at runtime and must not be folded in here.
if (declaration is InterfaceDeclarationSyntax)
{
return null;
}

var configInterface = declaration.BaseList?.Types
.Select(baseType => baseType.Type)
.OfType<GenericNameSyntax>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,9 +396,18 @@ private async Task ProcessConfigurationFileAsync(string filePath, Dictionary<str
var syntaxTree = CSharpSyntaxTree.ParseText(fileCode);
var root = await syntaxTree.GetRootAsync();

foreach (var classDecl in root.DescendantNodes().OfType<ClassDeclarationSyntax>())
// TypeDeclarationSyntax (minus interfaces), not ClassDeclarationSyntax: a record can implement
// IEntityTypeConfiguration<T> too, and this cross-file discovery feeds the same walker that was
// widened for exactly that reason (EntityConfigurationWalker.FindConfigClassesInRoot) — a
// class-only scan here would keep a separate-file record config invisible end-to-end.
foreach (var typeDecl in root.DescendantNodes().OfType<TypeDeclarationSyntax>())
{
var implementsInterface = classDecl.BaseList?.Types
if (typeDecl is InterfaceDeclarationSyntax)
{
continue;
}

var implementsInterface = typeDecl.BaseList?.Types
.Select(baseType => baseType.Type)
.OfType<GenericNameSyntax>()
.Any(generic =>
Expand All @@ -407,7 +416,7 @@ private async Task ProcessConfigurationFileAsync(string filePath, Dictionary<str

if (implementsInterface)
{
configFiles.TryAdd(classDecl.Identifier.Text, filePath);
configFiles.TryAdd(typeDecl.Identifier.Text, filePath);
}
}
}
Expand Down Expand Up @@ -447,16 +456,17 @@ private async Task<HashSet<string>> ExtractBaseClassNamesAsync(Dictionary<string
/// <param name="root">The root <see cref="SyntaxNode"/> of the syntax tree to analyze.</param>
/// <param name="baseClassNames">A <see cref="HashSet{T}"/> to store the extracted base class names.</param>
/// <remarks>
/// This method traverses the syntax tree to find all class declarations with a base list.
/// This method traverses the syntax tree to find all class and record declarations with a base list
/// (interfaces are skipped: their base lists name other interfaces, never an entity base type).
/// It then extracts the names of the base types using the <see cref="ExtractBaseTypeName"/> method
/// and filters them using the <see cref="IsValidBaseClassName"/> method before adding them to the set.
/// </remarks>
public void ExtractBaseClassNamesFromSyntax(SyntaxNode root, HashSet<string> baseClassNames)
{
foreach (var baseTypeName in root.DescendantNodes()
.OfType<ClassDeclarationSyntax>()
.Where(classDecl => classDecl.BaseList is not null)
.SelectMany(classDecl => classDecl.BaseList!.Types)
.OfType<TypeDeclarationSyntax>()
.Where(typeDecl => typeDecl is not InterfaceDeclarationSyntax && typeDecl.BaseList is not null)
.SelectMany(typeDecl => typeDecl.BaseList!.Types)
.Select(ExtractBaseTypeName)
.Where(IsValidBaseClassName))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,12 @@ private static void ApplyOwnedForeignKey(SyntaxNode ownedScope, Dictionary<strin
}
}

/// <summary>Extracts the property names from a <c>HasForeignKey</c> call (lambda member access or string literals).</summary>
/// <summary>
/// Extracts the property names from a <c>HasForeignKey</c> call. Both branches are live:
/// snapshots emit the <c>params string[]</c> form, while on the DbContext path the generic
/// <c>OwnershipBuilder&lt;TEntity,TDependentEntity&gt;</c> a builder-lambda's <c>WithOwner()</c>
/// returns also has an <c>Expression</c> overload (<c>HasForeignKey(x =&gt; x.OwnerId)</c>).
/// </summary>
/// <param name="invocation">The <c>HasForeignKey</c> invocation.</param>
private static IEnumerable<string> ForeignKeyPropertyNames(InvocationExpressionSyntax invocation)
{
Expand Down Expand Up @@ -331,7 +336,7 @@ public static void ResolveTables(Dictionary<string, EfEntity> entities, EfModel
continue;
}

var ownerTable = EffectiveTable(owner);
var ownerTable = owner.EffectiveTable;
var table = owned.IsCollection ? $"{ownerTable}_{owned.NavigationName}" : ownerTable;

var updated = EfEntityFactory.CopyWith(owned, table);
Expand Down Expand Up @@ -365,7 +370,7 @@ public static void StripShadowKeys(Dictionary<string, EfEntity> entities, EfMode
{
var sharesOwnerTable = owned.OwnerEntity is not null
&& entities.TryGetValue(owned.OwnerEntity, out var owner)
&& EffectiveTable(owner) == EffectiveTable(owned);
&& owner.EffectiveTable == owned.EffectiveTable;

var stripped = EfEntityFactory.CopyWith(owned);
stripped.Properties.Clear();
Expand All @@ -386,9 +391,4 @@ public static void StripShadowKeys(Dictionary<string, EfEntity> entities, EfMode
EfEntityFactory.ReplaceModelSlot(model, stripped);
}
}

/// <summary>Returns an entity's effective table: its explicit table name, or its entity name when unmapped.</summary>
/// <param name="entity">The entity.</param>
private static string EffectiveTable(EfEntity entity)
=> string.IsNullOrEmpty(entity.TableName) ? entity.Name : entity.TableName;
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@ private static bool IsInsideUsingEntity(SyntaxNode node)
}

// Entity<T>(e => e.HasMany(...)): the Has call is inside the Entity configuration lambda.
//
// CAUTION: this ancestor walk is unbounded — it climbs past owned-type builder fences without
// stopping, unlike FluentSyntax.ResolveOwningEntity. FindRelationshipRoots fences only
// UsingEntity, so a HasOne/HasMany declared INSIDE an OwnsOne/OwnsMany builder lambda IS
// yielded, reaches this walk, and gets attributed to the Entity<T>() enclosing the builder —
// the owner, not the owned type. That coarse attribution is a known limitation (this walker
// has no way to produce an owned {Owner}.{Nav} source key). If owned-builder relationships
// are ever modelled properly, this walk must stop at nested-builder fences the way
// FluentSyntax.ResolveOwningEntity does, or the relationship will silently leak to the owner.
var enclosingEntity = chain.HasNode.Ancestors()
.OfType<InvocationExpressionSyntax>()
.FirstOrDefault(inv => inv.Expression is MemberAccessExpressionSyntax ma
Expand Down
Loading
Loading