diff --git a/src/ProjGraph.Core/Models/EfModel.cs b/src/ProjGraph.Core/Models/EfModel.cs index 1c0f538..5099917 100644 --- a/src/ProjGraph.Core/Models/EfModel.cs +++ b/src/ProjGraph.Core/Models/EfModel.cs @@ -136,6 +136,13 @@ public class EfProperty /// public bool IsExplicitlyRequired { get; init; } + /// + /// Gets or initializes a value indicating whether was guessed from the property's + /// name rather than read from a CLR declaration or an explicit column type. Only a guessed type may be + /// corrected by later evidence, such as a maximum length that no value type could carry. + /// + public bool IsTypeInferred { get; init; } + /// /// Gets or initializes the maximum length of the property value, if applicable. /// diff --git a/src/ProjGraph.Lib.ClassDiagram/README.md b/src/ProjGraph.Lib.ClassDiagram/README.md index 81ad62a..ca2d9a7 100644 --- a/src/ProjGraph.Lib.ClassDiagram/README.md +++ b/src/ProjGraph.Lib.ClassDiagram/README.md @@ -34,21 +34,21 @@ services.AddProjGraphCore(); services.AddProjGraphClassDiagram(); ``` -### Generate a class diagram from a project +### Generate a class diagram from a directory ```csharp +using ProjGraph.Core.Models; using ProjGraph.Lib.ClassDiagram.Application; -using ProjGraph.Lib.ClassDiagram.Application.UseCases; +using ProjGraph.Lib.Core.Abstractions; var analysisService = provider.GetRequiredService(); -var result = await analysisService.AnalyzeAsync("src/MyProject/MyProject.csproj", new AnalysisOptions -{ - IncludePrivateMembers = false, - IncludeInternalTypes = false -}); +var result = await analysisService.AnalyzeDirectoryAsync("src/MyProject", new AnalysisOptions( + MaxDepth: 1, + IncludeInheritance: true, + IncludeDependencies: true)); -var renderer = provider.GetRequiredService(); +var renderer = provider.GetRequiredService>(); string diagram = renderer.Render(result); ``` @@ -70,11 +70,13 @@ classDiagram ### Analyze a single file or directory ```csharp +using ProjGraph.Lib.ClassDiagram.Application.UseCases; + var analyzeFile = provider.GetRequiredService(); -var result = await analyzeFile.ExecuteAsync("src/Domain/Order.cs"); +var fileModel = await analyzeFile.ExecuteAsync("src/Domain/Order.cs"); var analyzeDirectory = provider.GetRequiredService(); -var result = await analyzeDirectory.ExecuteAsync("src/Domain/"); +var directoryModel = await analyzeDirectory.ExecuteAsync("src/Domain/"); ``` ## Key Services diff --git a/src/ProjGraph.Lib.Core/Infrastructure/WorkspaceRootResolver.cs b/src/ProjGraph.Lib.Core/Infrastructure/WorkspaceRootResolver.cs index f325131..68a4906 100644 --- a/src/ProjGraph.Lib.Core/Infrastructure/WorkspaceRootResolver.cs +++ b/src/ProjGraph.Lib.Core/Infrastructure/WorkspaceRootResolver.cs @@ -61,6 +61,65 @@ public static DirectoryInfo FindSolutionRoot(string startDirectory, int maxLevel return solutionRoot; } + /// + /// Traverses up from the start directory looking for the enclosing *solution* root — a directory + /// holding a .sln/.slnx file or a .git directory. Unlike this ignores + /// .csproj markers, which would stop the walk inside the starting project and never reach the sibling + /// projects of a layered solution. Never walks above the system temp directory, so an isolated tree + /// created under temp is bounded by its own marker rather than by the shared temp root. + /// + /// The directory path from which to begin searching upward. + /// The maximum number of directory levels to traverse upward. + /// The full path to the enclosing solution root if one is found; otherwise, null. + public static string? FindEnclosingSolutionRoot(string startDirectory, int maxLevels) + { + var current = new DirectoryInfo(startDirectory); + var tempPath = GetNormalizedTempPath(); + + for (var level = 0; current != null && level <= maxLevels; level++) + { + if (IsSolutionRoot(current)) + { + return current.FullName; + } + + // Stop *at* the temp root rather than anywhere beneath it: an isolated tree created under temp + // still gets to be bounded by its own marker, but the shared temp directory is never scanned. + if (IsSameDirectory(current, tempPath)) + { + break; + } + + current = current.Parent; + } + + return null; + } + + /// + /// Determines whether the specified directory is exactly the given path (ignoring trailing separators). + /// + /// The directory to check. + /// The normalized path to compare against. + /// True if the directory is that same directory; otherwise, false. + private static bool IsSameDirectory(DirectoryInfo directory, string path) + { + return directory.FullName.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Equals(path, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines whether the specified directory holds a solution-level marker (.sln, .slnx or .git). + /// + /// The directory to check. + /// True if the directory contains a solution-level marker; otherwise, false. + private static bool IsSolutionRoot(DirectoryInfo directory) + { + return directory.GetFiles("*.sln").Length > 0 || + directory.GetFiles("*.slnx").Length > 0 || + directory.GetDirectories(DirectoryFilters.Git).Length > 0; + } + /// /// Determines whether the specified directory is a workspace root by checking for /// workspace marker files (.sln, .slnx, .csproj) or directories (.git). diff --git a/src/ProjGraph.Lib.Core/README.md b/src/ProjGraph.Lib.Core/README.md index c74ac4a..5cb2803 100644 --- a/src/ProjGraph.Lib.Core/README.md +++ b/src/ProjGraph.Lib.Core/README.md @@ -44,23 +44,24 @@ services.AddProjGraphCore(); ```csharp using Microsoft.Extensions.DependencyInjection; using ProjGraph.Lib.Core; -using ProjGraph.Core.Models; +using ProjGraph.Lib.Core.Abstractions; var services = new ServiceCollection(); services.AddProjGraphCore(); var provider = services.BuildServiceProvider(); -var parser = provider.GetRequiredService(); -IReadOnlyList projects = await parser.ParseAsync("MySolution.slnx"); +// Resolve the parser matching the solution format: ISlnxParser for .slnx, ISlnParser for .sln. +var parser = provider.GetRequiredService(); +IEnumerable projectPaths = parser.GetProjectPaths("MySolution.slnx"); ``` ## Key Abstractions | Interface | Description | |----------------------------|---------------------------------------------------------------------| -| `ISolutionParser` | Parses `.sln` / `.slnx` into a list of `Project` models | -| `IProjectParser` | Parses a single `.csproj` file | -| `IProjectDiscoveryService` | Discovers all projects under a directory | +| `ISolutionParser` | Extracts project file paths from a solution (`ISlnParser` / `ISlnxParser`) | +| `IProjectParser` | Parses a single `.csproj` into a `Project` plus its references | +| `IProjectDiscoveryService` | Discovers referenced projects recursively and resolves their paths | | `ICompilationFactory` | Creates Roslyn `Compilation` objects from project files | | `IDiagramRenderer` | Generic renderer producing a string diagram from a model | | `IFileSystem` | Abstraction over `System.IO` for testability | diff --git a/src/ProjGraph.Lib.Dependencies/README.md b/src/ProjGraph.Lib.Dependencies/README.md index 79149a2..c69d112 100644 --- a/src/ProjGraph.Lib.Dependencies/README.md +++ b/src/ProjGraph.Lib.Dependencies/README.md @@ -62,9 +62,15 @@ graph TD var statsService = provider.GetRequiredService(); var stats = await statsService.ComputeStatsAsync("MySolution.slnx"); -Console.WriteLine($"Projects : {stats.ProjectCount}"); -Console.WriteLine($"Packages : {stats.PackageCount}"); -Console.WriteLine($"Dependencies: {stats.DependencyCount}"); +Console.WriteLine($"Solution : {stats.SolutionName}"); +Console.WriteLine($"Projects : {stats.TotalProjectCount}"); +Console.WriteLine($"Max depth: {stats.DepthStats.Max}"); +Console.WriteLine($"Cycles : {stats.HasCycles}"); + +foreach (var hotspot in stats.HotspotProjects) +{ + Console.WriteLine($" {hotspot.Name} ← {hotspot.InDegree} projects"); +} ``` ## Key Services diff --git a/src/ProjGraph.Lib.EntityFramework/Application/IEntityFileDiscovery.cs b/src/ProjGraph.Lib.EntityFramework/Application/IEntityFileDiscovery.cs index 4ece298..f1da87a 100644 --- a/src/ProjGraph.Lib.EntityFramework/Application/IEntityFileDiscovery.cs +++ b/src/ProjGraph.Lib.EntityFramework/Application/IEntityFileDiscovery.cs @@ -43,6 +43,13 @@ Task> DiscoverBaseClassFilesAsync(DictionaryThe directory containing the context file. IReadOnlyList BuildSearchDirectories(string contextDirectory); + /// + /// Builds the narrow search list — the context directory and its immediate parent — for discovery that + /// collects by shape rather than by name and would over-collect over a wider radius. + /// + /// The directory containing the context file. + IReadOnlyList BuildLocalSearchDirectories(string contextDirectory); + /// /// Extracts entity type names from a DbContext class declaration. /// diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/Constants/EfAnalysisConstants.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/Constants/EfAnalysisConstants.cs index fa87352..f76584f 100644 --- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/Constants/EfAnalysisConstants.cs +++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/Constants/EfAnalysisConstants.cs @@ -157,6 +157,8 @@ internal static class CommonNames public const string ModelSnapshot = "ModelSnapshot"; public const string System = "System"; public const string Nullable = "Nullable"; + public const string Migration = "Migration"; + public const string MigrationAttribute = "MigrationAttribute"; } /// diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfModelAnalyzer.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfModelAnalyzer.cs index 603bf1f..eb6277c 100644 --- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfModelAnalyzer.cs +++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfModelAnalyzer.cs @@ -217,7 +217,10 @@ private async Task> BuildSyntaxTreesAsync( // Slice 4: pull separate IEntityTypeConfiguration files into the compilation so config classes // that live in their own files are visible to EntityConfigurationWalker. - var configFiles = await entityFileDiscovery.DiscoverConfigurationFilesAsync(searchDirectories, contextPath); + // Deliberately the narrow radius: config classes are collected by shape, not by name, so a wider + // one pulls in entities from unrelated solutions nested in the same repository. + var configFiles = await entityFileDiscovery.DiscoverConfigurationFilesAsync( + entityFileDiscovery.BuildLocalSearchDirectories(contextDirectory), contextPath); MergeFileDictionaries(entityFiles, configFiles); // An OwnsOne/OwnsMany navigation's CLR type is never named by a DbSet, so nothing above finds diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyFactory.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyFactory.cs index b7af4b4..a79bd59 100644 --- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyFactory.cs +++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyFactory.cs @@ -26,6 +26,7 @@ public static EfProperty CopyWith(EfProperty source, EfPropertyOverrides overrid IsRequired = overrides.IsRequired ?? source.IsRequired, IsValueType = overrides.IsValueType ?? source.IsValueType, IsExplicitlyRequired = overrides.IsExplicitlyRequired ?? source.IsExplicitlyRequired, + IsTypeInferred = overrides.IsTypeInferred ?? source.IsTypeInferred, MaxLength = overrides.MaxLength ?? source.MaxLength, Precision = overrides.Precision ?? source.Precision, Scale = overrides.Scale ?? source.Scale, @@ -50,6 +51,7 @@ public static EfProperty Rename(EfProperty source, string name) IsRequired = source.IsRequired, IsValueType = source.IsValueType, IsExplicitlyRequired = source.IsExplicitlyRequired, + IsTypeInferred = source.IsTypeInferred, MaxLength = source.MaxLength, Precision = source.Precision, Scale = source.Scale, @@ -70,7 +72,8 @@ public static EfProperty GetOrCreateProperty(EfEntity entity, string propName, s if (property is null) { var detectedType = type; - if (string.IsNullOrEmpty(detectedType)) + var isTypeInferred = string.IsNullOrEmpty(detectedType); + if (isTypeInferred) { detectedType = propName.EndsWith(EfAnalysisConstants.Suffixes.IdSuffix, StringComparison.OrdinalIgnoreCase) @@ -82,7 +85,8 @@ public static EfProperty GetOrCreateProperty(EfEntity entity, string propName, s { Name = propName, Type = detectedType, - IsValueType = IsValueTypeString(detectedType) + IsValueType = IsValueTypeString(detectedType), + IsTypeInferred = isTypeInferred }; entity.Properties.Add(property); } diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyOverrides.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyOverrides.cs index 405bb0b..7d09229 100644 --- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyOverrides.cs +++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EfPropertyOverrides.cs @@ -24,6 +24,9 @@ internal sealed record EfPropertyOverrides /// Override for the IsExplicitlyRequired property. public bool? IsExplicitlyRequired { get; init; } + /// Override for the IsTypeInferred property. + public bool? IsTypeInferred { get; init; } + /// Override for the MaxLength property. public int? MaxLength { get; init; } diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EntityFileDiscovery.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EntityFileDiscovery.cs index 7d76a50..75b5c26 100644 --- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/EntityFileDiscovery.cs +++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/EntityFileDiscovery.cs @@ -26,6 +26,13 @@ internal sealed class EntityFileDiscovery(IFileSystem fileSystem) : IEntityFileD /// private const int MaxSearchDepth = 10; + /// + /// Maximum number of directory levels walked upward when looking for the enclosing solution root. + /// Deep enough for the conventional <root>/src/<Project>/<Folder> layouts, bounded so a + /// context outside any solution never triggers a walk toward the filesystem root. + /// + private const int MaxWorkspaceWalkLevels = 6; + /// /// Discovers the file paths of entity files within the specified search directories. /// @@ -126,9 +133,13 @@ public async Task> DiscoverBaseClassFilesAsync( /// A list of directories to search for entity files, including the context directory and its parent directory. /// /// - /// This method starts with the context directory and then its parent directory. - /// Since the parent directory scan is recursive, it will naturally include the context directory - /// and all siblings through the recursive search. + /// The context directory comes first so that a type declared next to the DbContext always wins over a + /// same-named type elsewhere (matches are recorded with TryAdd, so the first hit sticks). + /// The enclosing solution root is added next: in a layered solution the entities live in a sibling + /// project (src/Core) while the context sits deeper in its own (src/Infrastructure/Data), + /// so a one-parent radius never reaches them. When no solution marker is found the search falls back + /// to the context directory plus its immediate parent, and never escapes into the shared system temp + /// directory, where it could pick up files from parallel test runs. /// public IReadOnlyList BuildSearchDirectories( string contextDirectory) @@ -137,6 +148,55 @@ public IReadOnlyList BuildSearchDirectories( { contextDirectory }; + + var solutionRoot = WorkspaceRootResolver.FindEnclosingSolutionRoot(contextDirectory, MaxWorkspaceWalkLevels); + if (solutionRoot is not null) + { + // Walk outward one level at a time instead of jumping straight to the root: each ancestor's scan + // is recursive, so the nearest declaration of a type name is recorded first and a same-named type + // in an unrelated project further out can never displace it. + for (var current = Directory.GetParent(contextDirectory); + current is not null; + current = current.Parent) + { + searchDirectories.Add(current.FullName); + + if (current.FullName.Equals(solutionRoot, StringComparison.OrdinalIgnoreCase)) + { + break; + } + } + + return [.. searchDirectories.Distinct()]; + } + + return BuildLocalSearchDirectories(contextDirectory); + } + + /// + /// Builds the narrow search list — the context directory and its immediate parent — used where a wider + /// radius would over-collect rather than fill gaps. + /// + /// The directory containing the context file. + /// The context directory, plus its parent when one exists outside the system temp directory. + /// + /// Entity discovery is driven by names the context actually declares, so widening it can only fill in + /// missing types. Configuration-class discovery has no such guard: every + /// IEntityTypeConfiguration<T> in range contributes its entity to the model once the context + /// calls ApplyConfigurationsFromAssembly. Scanning a whole repository therefore pulls in entities + /// from unrelated solutions nested inside it — on ardalis/CleanArchitecture the six config classes of the + /// nested MinimalClean solution added Cart, CartItem, GuestUser, Order, OrderItem and Product to an ERD + /// of a context whose only DbSet is Contributor. Configuration classes live beside their DbContext by + /// convention (Data/Config/), so this narrower radius still finds the ones that belong. + /// + public IReadOnlyList BuildLocalSearchDirectories( + string contextDirectory) + { + var searchDirectories = new List + { + contextDirectory + }; + var parentDir = Directory.GetParent(contextDirectory); if (parentDir is null) @@ -185,12 +245,40 @@ public HashSet ExtractEntityTypeNames(ClassDeclarationSyntax contextClas ]; } - /// Reduces a type-argument syntax to its simple identifier (last segment of a qualified name). - /// The type-argument syntax from a DbSet<T> property. + /// + /// Determines whether a type declaration is an EF Core migration rather than an entity. + /// + /// The type declaration to classify. + /// when the declaration is a migration; otherwise, . + /// + /// dotnet ef migrations add PhoneNumber emits public partial class PhoneNumber : Migration + /// beside the DbContext, so a migration named after the change it makes collides with the entity or value + /// object of that name — and sits nearer to the context than the real declaration does. Detection is by + /// shape (a Migration base type or the [Migration] attribute EF generates on the designer + /// half of the partial class) rather than by folder name, which is configurable and often renamed. + /// Both are matched on the right-most identifier: migrations are generated code, so neither the base + /// type nor the attribute is guaranteed to be written unqualified. + /// + private static bool IsMigrationClass(TypeDeclarationSyntax typeDecl) + { + var derivesFromMigration = typeDecl.BaseList?.Types + .Any(baseType => SimpleTypeName(baseType.Type) is EfAnalysisConstants.CommonNames.Migration) == true; + + var hasMigrationAttribute = typeDecl.AttributeLists + .SelectMany(list => list.Attributes) + .Any(attribute => SimpleTypeName(attribute.Name) is EfAnalysisConstants.CommonNames.Migration + or EfAnalysisConstants.CommonNames.MigrationAttribute); + + return derivesFromMigration || hasMigrationAttribute; + } + + /// Reduces a type syntax to its simple identifier (the right-most segment of a qualified name). + /// The type syntax to reduce — a DbSet<T> type argument, a base type or an attribute name. /// The simple type name. private static string SimpleTypeName(TypeSyntax type) => type switch { - QualifiedNameSyntax qualified => qualified.Right.Identifier.Text, + QualifiedNameSyntax qualified => SimpleTypeName(qualified.Right), + AliasQualifiedNameSyntax alias => SimpleTypeName(alias.Name), SimpleNameSyntax simple => simple.Identifier.Text, _ => type.ToString() }; @@ -322,7 +410,8 @@ private async Task ProcessSourceFileAsync( foreach (var typeDecl in root.DescendantNodes() .OfType() - .Where(typeDecl => entityTypeNames.Contains(typeDecl.Identifier.Text))) + .Where(typeDecl => entityTypeNames.Contains(typeDecl.Identifier.Text) + && !IsMigrationClass(typeDecl))) { entityFiles.TryAdd(typeDecl.Identifier.Text, filePath); } diff --git a/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentPropertyWalker.cs b/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentPropertyWalker.cs index 289ea3d..5cf47a5 100644 --- a/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentPropertyWalker.cs +++ b/src/ProjGraph.Lib.EntityFramework/Infrastructure/FluentPropertyWalker.cs @@ -232,15 +232,25 @@ private static EfProperty ApplyIsRequiredConfiguration(EfProperty property, stri private static EfProperty ApplyMaxLengthConfiguration(EfProperty property, string configArg) { - if (int.TryParse(configArg, out var maxLen)) + if (!int.TryParse(configArg, out var maxLen)) { - return EfPropertyFactory.CopyWith(property, new EfPropertyOverrides - { - MaxLength = maxLen - }); + return property; } - return property; + // A maximum length only applies to string (and byte[]) columns, so it is proof that a guessed type + // is wrong: an unresolvable property whose name ends in "Id" is guessed as a Guid, which would + // otherwise render as the self-contradictory `Guid BuyerId "max:256"`. Only a guessed type is + // corrected — a CLR-declared or column-type-derived type stays exactly as declared. + var correctsGuessedType = property.IsTypeInferred && + !property.Type.Equals(EfAnalysisConstants.DataTypes.StringTypeName, + StringComparison.OrdinalIgnoreCase); + + return EfPropertyFactory.CopyWith(property, new EfPropertyOverrides + { + MaxLength = maxLen, + Type = correctsGuessedType ? EfAnalysisConstants.DataTypes.StringTypeName : null, + IsValueType = correctsGuessedType ? false : null + }); } /// @@ -256,14 +266,20 @@ private static EfProperty ApplyColumnTypeConfiguration(EfProperty property, stri // The SQL column type is authoritative. When the CLR type is only the guessed string fallback, // recover a more accurate value type (e.g. decimal, Guid, bool) from the column type. + // Applies to any guessed type, not just the string fallback: a property named `*Id` is guessed as a + // Guid, and the column type is the better evidence for it too. var inferredType = SqlColumnTypeMapper.ToClrType(configArg); if (inferredType is not null && - updated.Type.Equals(EfAnalysisConstants.DataTypes.StringTypeName, StringComparison.OrdinalIgnoreCase)) + (updated.IsTypeInferred || + updated.Type.Equals(EfAnalysisConstants.DataTypes.StringTypeName, StringComparison.OrdinalIgnoreCase))) { updated = EfPropertyFactory.CopyWith(updated, new EfPropertyOverrides { Type = inferredType, - IsValueType = EfPropertyFactory.IsValueTypeString(inferredType) + IsValueType = EfPropertyFactory.IsValueTypeString(inferredType), + // The column type is authoritative: the type is no longer a name-based guess, so a max + // length chained after it must not overwrite it the way it corrects a guessed type. + IsTypeInferred = false }); } diff --git a/src/ProjGraph.Lib.EntityFramework/README.md b/src/ProjGraph.Lib.EntityFramework/README.md index 7c37692..c308823 100644 --- a/src/ProjGraph.Lib.EntityFramework/README.md +++ b/src/ProjGraph.Lib.EntityFramework/README.md @@ -38,14 +38,15 @@ services.AddProjGraphEntityFramework(); ### Generate an ERD from a DbContext file ```csharp +using ProjGraph.Core.Models; +using ProjGraph.Lib.Core.Abstractions; using ProjGraph.Lib.EntityFramework.Application; -using ProjGraph.Lib.EntityFramework.Application.UseCases; var efAnalysisService = provider.GetRequiredService(); var model = await efAnalysisService.AnalyzeContextAsync("src/Data/AppDbContext.cs"); -var renderer = provider.GetRequiredService(); +var renderer = provider.GetRequiredService>(); string diagram = renderer.Render(model); ``` @@ -70,6 +71,8 @@ erDiagram ### Generate an ERD from a ModelSnapshot ```csharp +using ProjGraph.Lib.EntityFramework.Application.UseCases; + var analyzeSnapshot = provider.GetRequiredService(); var model = await analyzeSnapshot.ExecuteAsync("src/Migrations/AppDbContextModelSnapshot.cs"); ``` diff --git a/src/ProjGraph.Lib/README.md b/src/ProjGraph.Lib/README.md index 880841a..e8f36c9 100644 --- a/src/ProjGraph.Lib/README.md +++ b/src/ProjGraph.Lib/README.md @@ -46,7 +46,7 @@ using ProjGraph.Lib.Dependencies.Application; using ProjGraph.Lib.Dependencies.Rendering; var graphService = provider.GetRequiredService(); -var graph = await graphService.GetGraphAsync("MySolution.slnx", includePackages: false); +var graph = await graphService.BuildGraphAsync("MySolution.slnx", includePackages: false); var renderer = provider.GetRequiredService(); Console.WriteLine(renderer.Render(graph)); @@ -55,26 +55,28 @@ Console.WriteLine(renderer.Render(graph)); ### Class diagram ```csharp +using ProjGraph.Core.Models; using ProjGraph.Lib.ClassDiagram.Application; -using ProjGraph.Lib.ClassDiagram.Rendering; +using ProjGraph.Lib.Core.Abstractions; var classAnalysis = provider.GetRequiredService(); -var result = await classAnalysis.AnalyzeAsync("src/MyProject/MyProject.csproj"); +var result = await classAnalysis.AnalyzeDirectoryAsync("src/MyProject"); -var renderer = provider.GetRequiredService(); +var renderer = provider.GetRequiredService>(); Console.WriteLine(renderer.Render(result)); ``` ### Entity Relationship Diagram ```csharp +using ProjGraph.Core.Models; +using ProjGraph.Lib.Core.Abstractions; using ProjGraph.Lib.EntityFramework.Application; -using ProjGraph.Lib.EntityFramework.Rendering; var efAnalysis = provider.GetRequiredService(); var model = await efAnalysis.AnalyzeContextAsync("src/Data/AppDbContext.cs"); -var renderer = provider.GetRequiredService(); +var renderer = provider.GetRequiredService>(); Console.WriteLine(renderer.Render(model)); ``` diff --git a/tests/ProjGraph.Tests.Unit.EntityFramework/ColumnTypeInferenceTests.cs b/tests/ProjGraph.Tests.Unit.EntityFramework/ColumnTypeInferenceTests.cs index bb7d025..60de97d 100644 --- a/tests/ProjGraph.Tests.Unit.EntityFramework/ColumnTypeInferenceTests.cs +++ b/tests/ProjGraph.Tests.Unit.EntityFramework/ColumnTypeInferenceTests.cs @@ -83,6 +83,96 @@ public async Task HasColumnType_StringColumn_KeepsStringAndMaxLength() property.MaxLength.Should().Be(200); } + [Fact] + public async Task HasMaxLength_OnUnresolvedIdNamedProperty_InfersStringNotGuid() + { + // A property whose name ends in "Id" and whose CLR type is unresolvable is guessed as a Guid. + // A max length is meaningless for a Guid column, so the constraint is proof the column is a + // string: eShopOnWeb configures `BuyerId` with HasMaxLength(256) and it is a string, but the + // ERD rendered the self-contradictory `Guid BuyerId "required, max:256"`. + using var temp = new TestDirectory(); + const string content = """ + using Microsoft.EntityFrameworkCore; + namespace Test; + public class AppContext : DbContext + { + public DbSet Orders { get; set; } = null!; + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity().Property(o => o.BuyerId) + .IsRequired() + .HasMaxLength(256); + } + } + """; + var filePath = temp.CreateFile("Context.cs", content); + + var model = await _service.AnalyzeContextAsync(filePath, "AppContext"); + var property = model.Entities.Single(e => e.Name == "Order").Properties.Single(p => p.Name == "BuyerId"); + + property.Type.Should().Be("string"); + property.MaxLength.Should().Be(256); + } + + [Fact] + public async Task HasMaxLength_AfterExplicitColumnType_KeepsColumnTypesClrType() + { + // HasColumnType is authoritative, so a max length chained after it must not undo the recovered + // CLR type the way it corrects a name-guessed one. + using var temp = new TestDirectory(); + const string content = """ + using Microsoft.EntityFrameworkCore; + namespace Test; + public class AppContext : DbContext + { + public DbSet Orders { get; set; } = null!; + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity().Property(o => o.BuyerId) + .HasColumnType("uniqueidentifier") + .HasMaxLength(36); + } + } + """; + var filePath = temp.CreateFile("Context.cs", content); + + var model = await _service.AnalyzeContextAsync(filePath, "AppContext"); + var property = model.Entities.Single(e => e.Name == "Order").Properties.Single(p => p.Name == "BuyerId"); + + property.Type.Should().Be("Guid"); + } + + [Fact] + public async Task HasMaxLength_OnResolvedGuidProperty_KeepsDeclaredType() + { + // The inference must not rewrite a type the CLR actually declares. + using var temp = new TestDirectory(); + const string content = """ + using System; + using Microsoft.EntityFrameworkCore; + namespace Test; + public class Order + { + public int Id { get; set; } + public Guid BuyerId { get; set; } + } + public class AppContext : DbContext + { + public DbSet Orders { get; set; } = null!; + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity().Property(o => o.BuyerId).HasMaxLength(256); + } + } + """; + var filePath = temp.CreateFile("Context.cs", content); + + var model = await _service.AnalyzeContextAsync(filePath, "AppContext"); + var property = model.Entities.Single(e => e.Name == "Order").Properties.Single(p => p.Name == "BuyerId"); + + property.Type.Should().Be("Guid"); + } + [Theory] [InlineData("decimal(18)")] [InlineData("numeric(18)")] diff --git a/tests/ProjGraph.Tests.Unit.EntityFramework/CrossProjectEntityDiscoveryTests.cs b/tests/ProjGraph.Tests.Unit.EntityFramework/CrossProjectEntityDiscoveryTests.cs new file mode 100644 index 0000000..1eff0e6 --- /dev/null +++ b/tests/ProjGraph.Tests.Unit.EntityFramework/CrossProjectEntityDiscoveryTests.cs @@ -0,0 +1,296 @@ +using ProjGraph.Lib.Core.Infrastructure; +using ProjGraph.Lib.EntityFramework.Application; +using ProjGraph.Lib.EntityFramework.Application.UseCases; +using ProjGraph.Lib.EntityFramework.Infrastructure; +using ProjGraph.Tests.Shared.Helpers; + +namespace ProjGraph.Tests.Unit.EntityFramework; + +/// +/// Regression tests for a live-found gap: entity-file discovery searched only the context directory and +/// its immediate parent, while base-class discovery already walked up to the workspace root. In the +/// standard layered layout — entities in src/Core, the DbContext in src/Infrastructure/Data/ — +/// the entity CLR files sit two or more levels above the context directory, so none of them were ever read +/// and every column had to come from an explicit fluent Property() call. Found by validating against +/// ardalis/CleanArchitecture, where Contributor.PhoneNumber (a bare OwnsOne whose CLR type +/// lives in the Core project) rendered as an empty PhoneNumber {} box, and against eShopOnWeb, where +/// Order.OrderDate and CatalogItem.Description were missing entirely. +/// +[Trait("Category", "EntityFramework")] +public sealed class CrossProjectEntityDiscoveryTests +{ + private static EfAnalysisService CreateService() + { + var fs = new PhysicalFileSystem(); + var analyzer = new EfModelAnalyzer(new CompilationFactory(), fs, new EntityFileDiscovery(fs)); + return new EfAnalysisService( + new AnalyzeContextUseCase(analyzer), + new DiscoverContextsUseCase(analyzer, fs), + new AnalyzeSnapshotUseCase(analyzer), + new DiscoverSnapshotsUseCase(analyzer, fs)); + } + + [Fact] + public async Task AnalyzeContextAsync_EntityInSiblingProject_ClrPropertiesCaptured() + { + using var temp = new TestDirectory(); + + // Layered layout: the solution marker sits at the root, the entity lives in one project and the + // DbContext in another, nested a directory deeper (src/Infrastructure/Data) exactly as EF templates + // scaffold it. Customer carries no fluent configuration at all, so its columns can only reach the + // model by reading Customer.cs — which the old one-parent search radius never reached. + temp.CreateFile("App.slnx", ""); + temp.CreateFile("src/Core/Customer.cs", """ + namespace Test; + + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Email { get; set; } = ""; + } + """); + var contextPath = temp.CreateFile("src/Infrastructure/Data/ShopContext.cs", """ + using Microsoft.EntityFrameworkCore; + namespace Test; + + public class ShopContext : DbContext + { + public DbSet Customers { get; set; } = null!; + } + """); + + var model = await CreateService().AnalyzeContextAsync(contextPath, "ShopContext"); + + var customer = model.Entities.SingleOrDefault(e => e.Name == "Customer"); + customer.Should().NotBeNull("the DbSet entity must be discovered across the project boundary"); + customer.Properties.Select(p => p.Name).Should().BeEquivalentTo(["Id", "Name", "Email"], + "Customer has no fluent configuration, so these columns prove Customer.cs was read"); + } + + [Fact] + public async Task AnalyzeContextAsync_OwnedTypeInSiblingProject_ColumnsCaptured() + { + using var temp = new TestDirectory(); + + // The ardalis/CleanArchitecture shape: a bare OwnsOne with no property configuration, whose CLR + // type lives in another project. Without the widened radius the owned entity is captured with zero + // properties and renders as an empty box. + temp.CreateFile("App.slnx", ""); + // The navigation is declared nullable (`PhoneNumber?`), exactly as ardalis/CleanArchitecture + // declares it: the owned CLR type must be resolved through the nullable annotation rather than + // being read as the type `Nullable`. + temp.CreateFile("src/Core/Contributor.cs", """ + namespace Test; + + public class Contributor + { + public int Id { get; set; } + public PhoneNumber? PhoneNumber { get; private set; } + } + """); + temp.CreateFile("src/Core/PhoneNumber.cs", """ + namespace Test; + + public class PhoneNumber(string countryCode, string number) + { + public string CountryCode { get; private set; } = countryCode; + public string Number { get; private set; } = number; + } + """); + var contextPath = temp.CreateFile("src/Infrastructure/Data/AppDbContext.cs", """ + using Microsoft.EntityFrameworkCore; + namespace Test; + + public class AppDbContext : DbContext + { + public DbSet Contributors { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().OwnsOne(c => c.PhoneNumber); + } + } + """); + + var model = await CreateService().AnalyzeContextAsync(contextPath, "AppDbContext"); + + var owned = model.Entities.SingleOrDefault(e => e.Key == "Contributor.PhoneNumber"); + owned.Should().NotBeNull("the owned navigation must be captured"); + owned.Properties.Select(p => p.Name).Should().BeEquivalentTo(["CountryCode", "Number"], + "the owned type's columns can only come from CLR seeding of PhoneNumber.cs in the sibling project"); + } + + [Fact] + public async Task AnalyzeContextAsync_ConfigurationInUnrelatedSolution_DoesNotLeakEntities() + { + using var temp = new TestDirectory(); + + // A repository holding more than one solution — the shape of ardalis/CleanArchitecture, which + // carries `sample/` and `MinimalClean/` trees alongside the main one. Widening entity discovery to + // the repository root must not drag in IEntityTypeConfiguration classes from a sibling solution: + // those configure entities this DbContext never declares. + temp.CreateFile("App.slnx", ""); + temp.CreateFile("src/Core/Customer.cs", """ + namespace Test; + + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + """); + var contextPath = temp.CreateFile("src/Infrastructure/Data/ShopContext.cs", """ + using Microsoft.EntityFrameworkCore; + namespace Test; + + public class ShopContext : DbContext + { + public DbSet Customers { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ShopContext).Assembly); + } + } + """); + + // A second, self-contained solution nested inside the same repository, declaring a context class + // of the SAME name. This is what leaked on ardalis/CleanArchitecture: the nested MinimalClean + // solution's AppDbContext contributed its DbSets (Cart, GuestUser, Order, …) to an ERD of the main + // solution's AppDbContext, which declares only DbSet. + temp.CreateFile("sample/Sample.slnx", ""); + temp.CreateFile("sample/src/Widget.cs", """ + namespace Other; + + public class Widget + { + public int Id { get; set; } + public string Label { get; set; } = ""; + } + """); + temp.CreateFile("sample/src/Web/Infrastructure/Data/Config/WidgetConfiguration.cs", """ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + namespace Other; + + public class WidgetConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.Property(w => w.Label).IsRequired().HasMaxLength(50); + } + } + """); + + var model = await CreateService().AnalyzeContextAsync(contextPath, "ShopContext"); + + model.Entities.Select(e => e.Name).Should().NotContain("Widget", + "Widget belongs to a different solution nested in the same repository and is not declared by this DbContext"); + model.Entities.Select(e => e.Name).Should().Contain("Customer", + "the analysed solution's own entity must still be found"); + } + + [Fact] + public async Task AnalyzeContextAsync_MigrationClassSharesEntityName_ResolvesTheRealType() + { + using var temp = new TestDirectory(); + + // EF names a migration class after the change it makes, so `dotnet ef migrations add PhoneNumber` + // produces `public partial class PhoneNumber : Migration` right beside the DbContext — nearer than + // the value object it is named for. Matching it made the owned navigation resolve to a type with no + // columns (rendered as an empty box on ardalis/CleanArchitecture). Migration classes are never + // entities and must be skipped regardless of how close they sit. + temp.CreateFile("App.slnx", ""); + temp.CreateFile("src/Core/Contributor.cs", """ + namespace Test; + + public class Contributor + { + public int Id { get; set; } + public PhoneNumber? PhoneNumber { get; private set; } + } + """); + temp.CreateFile("src/Core/PhoneNumber.cs", """ + namespace Test; + + public class PhoneNumber + { + public string CountryCode { get; private set; } = ""; + public string Number { get; private set; } = ""; + } + """); + temp.CreateFile("src/Infrastructure/Data/Migrations/20231218143922_PhoneNumber.cs", """ + using Microsoft.EntityFrameworkCore.Migrations; + + namespace Test.Migrations; + + public partial class PhoneNumber : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) { } + protected override void Down(MigrationBuilder migrationBuilder) { } + } + """); + // The designer half of the same partial class, written with fully qualified names: migrations are + // generated code, so neither the base type nor the attribute is guaranteed to be unqualified. + temp.CreateFile("src/Infrastructure/Data/Migrations/20231218143922_PhoneNumber.Designer.cs", """ + namespace Test.Migrations; + + [Microsoft.EntityFrameworkCore.Migrations.Migration("20231218143922_PhoneNumber")] + public partial class PhoneNumber : Microsoft.EntityFrameworkCore.Migrations.Migration + { + } + """); + var contextPath = temp.CreateFile("src/Infrastructure/Data/AppDbContext.cs", """ + using Microsoft.EntityFrameworkCore; + namespace Test; + + public class AppDbContext : DbContext + { + public DbSet Contributors { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().OwnsOne(c => c.PhoneNumber); + } + } + """); + + var model = await CreateService().AnalyzeContextAsync(contextPath, "AppDbContext"); + + var owned = model.Entities.SingleOrDefault(e => e.Key == "Contributor.PhoneNumber"); + owned.Should().NotBeNull(); + owned.Properties.Select(p => p.Name).Should().BeEquivalentTo(["CountryCode", "Number"], + "the value object must win over the identically named migration class"); + } + + [Fact] + public void BuildSearchDirectories_ContextNestedUnderSolutionRoot_IncludesThatRoot() + { + using var temp = new TestDirectory(); + + temp.CreateFile("App.slnx", ""); + var contextPath = temp.CreateFile("src/Infrastructure/Data/AppDbContext.cs", "// context"); + var contextDirectory = Path.GetDirectoryName(contextPath)!; + + var result = new EntityFileDiscovery(new PhysicalFileSystem()).BuildSearchDirectories(contextDirectory); + + result.Should().Contain(contextDirectory, "the context directory is always searched first"); + result.Should().Contain(temp.DirectoryPath, + "the enclosing solution root bounds the search, so sibling projects are reachable"); + } + + [Fact] + public void BuildSearchDirectories_NoWorkspaceMarkerAnywhere_DoesNotEscapeToTemp() + { + using var temp = new TestDirectory(); + + var contextPath = temp.CreateFile("src/Infrastructure/Data/AppDbContext.cs", "// context"); + var contextDirectory = Path.GetDirectoryName(contextPath)!; + + var result = new EntityFileDiscovery(new PhysicalFileSystem()).BuildSearchDirectories(contextDirectory); + + result.Should().NotContain(Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar), + "with no marker to bound it the walk must stop rather than scan the whole temp directory"); + } +}