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
42 changes: 26 additions & 16 deletions src/ProjGraph.Lib.EntityFramework/Infrastructure/EfModelAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,24 @@ public class EfModelAnalyzer(
IFileSystem fileSystem,
IEntityFileDiscovery entityFileDiscovery) : IEfModelAnalyzer
{
/// <summary>
/// The state threaded through owned-navigation resolution: the caches augmented in place as owned
/// types are resolved, plus the search scope used to locate an owned type's file.
/// </summary>
/// <param name="KeyToClrTypeName">Maps an owner key (a bare CLR type name, or a resolved <c>{Owner}.{Nav}</c> owned key) to its CLR type name; augmented in place.</param>
/// <param name="ClassDeclsByName">Cache of CLR type name to its type declaration, loaded lazily; augmented in place.</param>
/// <param name="EntityFiles">The entity files discovered so far.</param>
/// <param name="Discovered">The owned type files newly discovered; augmented in place.</param>
/// <param name="SearchDirectories">The directories to search for an owned type's file.</param>
/// <param name="ContextPath">The context file path, excluded from the search.</param>
private sealed record OwnedTypeResolutionContext(
Dictionary<string, string> KeyToClrTypeName,
Dictionary<string, TypeDeclarationSyntax> ClassDeclsByName,
Dictionary<string, string> EntityFiles,
Dictionary<string, string> Discovered,
IReadOnlyList<string> SearchDirectories,
string ContextPath);

/// <summary>
/// Discovers all DbContext classes in the provided syntax tree.
/// </summary>
Expand Down Expand Up @@ -284,39 +302,31 @@ private async Task<Dictionary<string, string>> DiscoverOwnedNavigationFilesAsync
}

var discovered = new Dictionary<string, string>(StringComparer.Ordinal);
var resolutionContext = new OwnedTypeResolutionContext(
keyToClrTypeName, classDeclsByName, entityFiles, discovered, searchDirectories, contextPath);

foreach (var (scope, ambientEntity) in scopes)
{
await ResolveOwnedNavigationTypesAsync(
scope, ambientEntity, keyToClrTypeName, classDeclsByName, entityFiles, discovered,
searchDirectories, contextPath);
await ResolveOwnedNavigationTypesAsync(scope, ambientEntity, resolutionContext);
}

return discovered;
}

/// <summary>
/// Resolves every <c>OwnsOne</c>/<c>OwnsMany</c> navigation found in <paramref name="scope"/>, owner-
/// before-owned, updating <paramref name="keyToClrTypeName"/> and <paramref name="discovered"/> in place.
/// before-owned, updating the key-to-CLR-type and discovered-file caches on <paramref name="context"/> in place.
/// </summary>
/// <param name="scope">The configuring method (<c>OnModelCreating</c> or a config class's <c>Configure</c>).</param>
/// <param name="ambientEntity">The owning entity to fall back to when the chain has no <c>Entity&lt;T&gt;()</c> call.</param>
/// <param name="keyToClrTypeName">Maps an owner key (a bare CLR type name, or a resolved <c>{Owner}.{Nav}</c> owned key) to its CLR type name; augmented in place.</param>
/// <param name="classDeclsByName">Cache of CLR type name to its type declaration, loaded lazily; augmented in place.</param>
/// <param name="entityFiles">The entity files discovered so far.</param>
/// <param name="discovered">The owned type files newly discovered; augmented in place.</param>
/// <param name="searchDirectories">The directories to search for an owned type's file.</param>
/// <param name="contextPath">The context file path, excluded from the search.</param>
/// <param name="context">The resolution state shared across every scope, augmented in place.</param>
private async Task ResolveOwnedNavigationTypesAsync(
SyntaxNode scope,
string? ambientEntity,
Dictionary<string, string> keyToClrTypeName,
Dictionary<string, TypeDeclarationSyntax> classDeclsByName,
Dictionary<string, string> entityFiles,
Dictionary<string, string> discovered,
IReadOnlyList<string> searchDirectories,
string contextPath)
OwnedTypeResolutionContext context)
{
var (keyToClrTypeName, classDeclsByName, entityFiles, discovered, searchDirectories, contextPath) = context;

var ownsOneRoots = FluentSyntax.FindConfigRoots(scope, EfAnalysisConstants.EfMethods.OwnsOne)
.Select(inv => (Invocation: inv, IsCollection: false));
var ownsManyRoots = FluentSyntax.FindConfigRoots(scope, EfAnalysisConstants.EfMethods.OwnsMany)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ namespace ProjGraph.Lib.EntityFramework.Infrastructure;
/// </summary>
internal static class FluentOwnedTypeWalker
{
/// <summary>
/// Identifies the owned navigation an <c>OwnsOne</c>/<c>OwnsMany</c> call configures.
/// </summary>
/// <param name="Key">The owned entity's dictionary key (<c>{Owner}.{Nav}</c>).</param>
/// <param name="Owner">The owning entity.</param>
/// <param name="Navigation">The owner's navigation property name for the owned type.</param>
/// <param name="IsCollection">Whether the owned type is a collection (<c>OwnsMany</c>).</param>
private sealed record OwnedTarget(string Key, EfEntity Owner, string Navigation, bool IsCollection);

/// <summary>
/// Captures every owned type configured within <paramref name="scope"/>.
/// </summary>
Expand Down Expand Up @@ -78,7 +87,7 @@ private static void Capture(
}

var key = $"{ownerKey}.{navigation}";
GetOrCreateOwned(key, owns, owner, navigation, isCollection, entities, model, compilation);
GetOrCreateOwned(new OwnedTarget(key, owner, navigation, isCollection), owns, entities, model, compilation);

// The owned builder's own lambda configuration (e.g. OwnsOne(o => o.Address, a => a.Property(...))).
// Calls chained onto the OwnsOne invocation instead (the form with no builder lambda) are resolved
Expand All @@ -101,29 +110,25 @@ private static void Capture(
}

/// <summary>
/// Creates the owned entity for <paramref name="key"/> on first sight, adding it to
/// Creates the owned entity for <paramref name="target"/> on first sight, adding it to
/// <paramref name="entities"/> and <paramref name="model"/>. Repeated calls targeting the same
/// navigation (as in the chained form spread across statements) are no-ops, so they merge into one
/// entity rather than duplicating it.
/// </summary>
/// <param name="key">The owned entity's dictionary key (<c>{Owner}.{Nav}</c>).</param>
/// <param name="target">The owned navigation being created.</param>
/// <param name="owns">The <c>OwnsOne</c>/<c>OwnsMany</c> invocation.</param>
/// <param name="owner">The owning entity.</param>
/// <param name="navigation">The owner's navigation property name for the owned type.</param>
/// <param name="isCollection">Whether the owned type is a collection (<c>OwnsMany</c>).</param>
/// <param name="entities">The known entities, augmented in place.</param>
/// <param name="model">The model whose <see cref="EfModel.Entities"/> collection is augmented.</param>
/// <param name="compilation">The compilation for owned-type symbol resolution.</param>
private static void GetOrCreateOwned(
string key,
OwnedTarget target,
InvocationExpressionSyntax owns,
EfEntity owner,
string navigation,
bool isCollection,
Dictionary<string, EfEntity> entities,
EfModel model,
Compilation compilation)
{
var (key, owner, navigation, isCollection) = target;

if (entities.ContainsKey(key))
{
return;
Expand Down Expand Up @@ -271,6 +276,19 @@ private static IEnumerable<string> ForeignKeyPropertyNames(InvocationExpressionS

var navProperty = ownerSymbol?.GetMembers(navigation).OfType<IPropertySymbol>().FirstOrDefault();

// An OwnsMany collection can be an array (Address[], an IArrayTypeSymbol) rather than a generic
// List<T>/ICollection<T>. Its element type carries the members to seed, so unwrap it up front —
// matching the DbContext-path file discovery, which likewise unwraps T[] (see
// EfModelAnalyzer.OwnedClrTypeName). Without this the `is not INamedTypeSymbol` guard below would
// reject the array and the owned entity would seed with zero columns.
if (isCollection && navProperty?.Type is IArrayTypeSymbol arrayType)
{
return arrayType.ElementType as INamedTypeSymbol is { } arrayElement
&& arrayElement is not IErrorTypeSymbol
? arrayElement
: null;
}

// An IErrorTypeSymbol still satisfies `is INamedTypeSymbol` — Roslyn synthesizes one whenever the
// navigation's CLR type could not be resolved (e.g. its file never made it into the compilation).
// Treating it as resolved would seed the owned entity from a symbol with no members, capturing it
Expand Down
65 changes: 56 additions & 9 deletions src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentSyntax.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,28 @@ private static bool IsNestedBuilderFence(InvocationExpressionSyntax invocation,
/// <param name="ambientEntity">The owning entity to fall back to when no enclosing <c>Entity&lt;T&gt;()</c> is found.</param>
public static string? ResolveOwningEntity(InvocationExpressionSyntax configInvocation, string? ambientEntity)
{
return ResolveFromReceiverChain(configInvocation, ambientEntity, out var resolved)
? resolved
: ResolveFromAncestors(configInvocation, ambientEntity);
}

/// <summary>
/// Walks the receiver chain of <paramref name="configInvocation"/> looking for the builder call that
/// determines the owning entity. Returns <see langword="true"/> when the chain settles the question —
/// with <paramref name="resolved"/> holding the answer, which is <see langword="null"/> for an
/// out-of-scope (join-entity) or unresolvable owned builder — and <see langword="false"/> when the
/// chain runs out without a verdict, leaving the ancestor search to decide.
/// </summary>
/// <param name="configInvocation">The configuration invocation whose receiver chain to walk.</param>
/// <param name="ambientEntity">The owning entity to fall back to when resolving a nested owned builder.</param>
/// <param name="resolved">The entity key the chain resolved to, when this returns <see langword="true"/>.</param>
private static bool ResolveFromReceiverChain(
InvocationExpressionSyntax configInvocation,
string? ambientEntity,
out string? resolved)
{
resolved = null;

for (var receiver = ChainReceiver(configInvocation);
receiver is not null;
receiver = ChainReceiver(receiver))
Expand All @@ -116,24 +138,50 @@ private static bool IsNestedBuilderFence(InvocationExpressionSyntax invocation,
// configuration lands there — and never on the outer entity the receiver chain reaches.
if (callName is EfAnalysisConstants.EfMethods.OwnsOne or EfAnalysisConstants.EfMethods.OwnsMany)
{
var ownerKey = ResolveOwningEntity(receiver, ambientEntity);
var navigation = OwnedNavigationName(receiver);
return ownerKey is not null && navigation is not null ? $"{ownerKey}.{navigation}" : null;
resolved = ComposeOwnedKey(receiver, ambientEntity);
return true;
}

// A join-entity builder (UsingEntity) is out of scope: stop rather than leak onto the owner.
if (callName == EfAnalysisConstants.EfMethods.UsingEntity)
{
return null;
return true;
}

if (callName == EfAnalysisConstants.EfMethods.Entity)
{
return EntityNameFromInvocation(receiver);
resolved = EntityNameFromInvocation(receiver);
return true;
}
}

InvocationExpressionSyntax? enclosingEntity = null;
return false;
}

/// <summary>
/// Composes the <c>{Owner}.{Nav}</c> key an <c>OwnsOne</c>/<c>OwnsMany</c> builder resolves to, or
/// <see langword="null"/> when either half is unresolvable.
/// </summary>
/// <param name="owns">The <c>OwnsOne</c>/<c>OwnsMany</c> invocation.</param>
/// <param name="ambientEntity">The owning entity to fall back to when resolving the owner.</param>
private static string? ComposeOwnedKey(InvocationExpressionSyntax owns, string? ambientEntity)
{
var ownerKey = ResolveOwningEntity(owns, ambientEntity);
var navigation = OwnedNavigationName(owns);
return ownerKey is not null && navigation is not null ? $"{ownerKey}.{navigation}" : null;
}

/// <summary>
/// Finds the enclosing <c>Entity&lt;T&gt;(e =&gt; ...)</c> configuration lambda for
/// <paramref name="configInvocation"/>, falling back to <paramref name="ambientEntity"/> when the
/// search stops at a nested-builder fence or finds none.
/// </summary>
/// <param name="configInvocation">The configuration invocation whose ancestors to search.</param>
/// <param name="ambientEntity">The owning entity to fall back to when no enclosing <c>Entity&lt;T&gt;()</c> is found.</param>
private static string? ResolveFromAncestors(
InvocationExpressionSyntax configInvocation,
string? ambientEntity)
{
foreach (var ancestor in configInvocation.Ancestors().OfType<InvocationExpressionSyntax>())
{
// Climbing past an owned-type / join-entity builder fence (e.g. the OwnsOne call whose
Expand All @@ -148,12 +196,11 @@ private static bool IsNestedBuilderFence(InvocationExpressionSyntax invocation,
if (ancestor.Expression is MemberAccessExpressionSyntax ma
&& SimpleName(ma.Name) == EfAnalysisConstants.EfMethods.Entity)
{
enclosingEntity = ancestor;
break;
return EntityNameFromInvocation(ancestor);
}
}

return enclosingEntity is null ? ambientEntity : EntityNameFromInvocation(enclosingEntity);
return ambientEntity;
}

/// <summary>Returns the invocation on the receiver side of a member-access invocation, or <see langword="null"/>.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ namespace ProjGraph.Lib.EntityFramework.Infrastructure;
/// </summary>
internal static class SqlColumnTypeMapper
{
private const string Decimal = "decimal";

private static readonly IReadOnlyDictionary<string, string> SqlToClr =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["decimal"] = "decimal",
["numeric"] = "decimal",
["money"] = "decimal",
["smallmoney"] = "decimal",
[Decimal] = Decimal,
["numeric"] = Decimal,
["money"] = Decimal,
["smallmoney"] = Decimal,
["int"] = "int",
["integer"] = "int",
["bigint"] = "long",
Expand Down
Loading
Loading