diff --git a/.gitignore b/.gitignore index 5f9d10c..d417e0b 100644 --- a/.gitignore +++ b/.gitignore @@ -492,3 +492,5 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +.sonarlint/ \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index e2eaf38..984be2c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,6 @@ net10.0 enable enable - 0.3.3 latest @@ -14,6 +13,7 @@ $(NoWarn);NU1901;NU1902;NU1903;NU1904 + 0.3.3 HandyS11 HandyS11 icon.png diff --git a/samples/erd/simple-context/EntityFramework/MyDbContext.cs b/samples/erd/simple-context/EntityFramework/MyDbContext.cs index 8ecf512..b6055ff 100644 --- a/samples/erd/simple-context/EntityFramework/MyDbContext.cs +++ b/samples/erd/simple-context/EntityFramework/MyDbContext.cs @@ -1,5 +1,10 @@ using Microsoft.EntityFrameworkCore; +// ReSharper disable UnusedMember.Global +// ReSharper disable PropertyCanBeMadeInitOnly.Global +// ReSharper disable InconsistentNaming +// ReSharper disable EntityFramework.ModelValidation.UnlimitedStringLength + namespace EntityFramework; public class MyDbContext : DbContext @@ -9,43 +14,43 @@ public class MyDbContext : DbContext public DbSet Categories { get; set; } public DbSet Publishers { get; set; } public DbSet Reviews { get; set; } + public DbSet Profiles { get; set; } + public DbSet BookDetails { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - // Author configuration + // One-to-One Optional: Author -> Profile modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.Property(e => e.Name).IsRequired().HasMaxLength(200); entity.Property(e => e.Bio).HasMaxLength(1000); - }); - // Publisher configuration - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.Id); - entity.Property(e => e.Name).IsRequired().HasMaxLength(200); - entity.Property(e => e.Country).HasMaxLength(100); - }); + entity.HasOne(e => e.Profile) + .WithOne(p => p.Author) + .HasForeignKey(p => p.AuthorId) + .IsRequired(false); - // Category configuration - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.Id); - entity.Property(e => e.Name).IsRequired().HasMaxLength(100); - entity.Property(e => e.Description).HasMaxLength(500); + entity.HasOne(e => e.Mentor) + .WithMany() + .HasForeignKey(e => e.MentorId); }); - // Book configuration + // One-to-One Required: Book -> BookDetail modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.Property(e => e.Title).IsRequired().HasMaxLength(300); entity.Property(e => e.ISBN).HasMaxLength(13); - // One-to-Many: Publisher -> Books + entity.HasOne(e => e.Detail) + .WithOne(d => d.Book) + .HasForeignKey(d => d.BookId) + .IsRequired(); + + // One-to-Many: Publisher -> Books (Required) entity.HasOne(e => e.Publisher) .WithMany(p => p.Books) .HasForeignKey(e => e.PublisherId) @@ -68,18 +73,34 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) j => j.HasOne().WithMany().HasForeignKey("BookId")); }); - // Review configuration + // Publisher configuration + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(200); + entity.Property(e => e.Country).HasMaxLength(100); + }); + + // Category configuration + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(100); + entity.Property(e => e.Description).HasMaxLength(500); + }); + + // Review configuration: One-to-Many Optional (Nullable BookId) modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.Property(e => e.Rating).IsRequired(); entity.Property(e => e.Comment).HasMaxLength(2000); - // One-to-Many: Book -> Reviews entity.HasOne(e => e.Book) .WithMany(b => b.Reviews) .HasForeignKey(e => e.BookId) - .OnDelete(DeleteBehavior.Cascade); + .IsRequired(false) + .OnDelete(DeleteBehavior.SetNull); }); } } @@ -90,10 +111,25 @@ public class Author public string Name { get; set; } = string.Empty; public string? Bio { get; set; } public DateTime? BirthDate { get; set; } + public bool IsActive { get; set; } + + public int? MentorId { get; set; } + public Author? Mentor { get; set; } + public Profile? Profile { get; set; } public ICollection Books { get; set; } = []; } +public class Profile +{ + public int Id { get; set; } + public string BioData { get; set; } = string.Empty; + public string AvatarUrl { get; set; } = string.Empty; + + public int AuthorId { get; set; } + public Author Author { get; set; } = null!; +} + public class Publisher { public int Id { get; set; } @@ -124,11 +160,22 @@ public class Book public int PublisherId { get; set; } public Publisher Publisher { get; set; } = null!; + public BookDetail Detail { get; set; } = null!; public ICollection Authors { get; set; } = []; public ICollection Categories { get; set; } = []; public ICollection Reviews { get; set; } = []; } +public class BookDetail +{ + public int Id { get; set; } + public string Summary { get; set; } = string.Empty; + public string Notes { get; set; } = string.Empty; + + public int BookId { get; set; } + public Book Book { get; set; } = null!; +} + public class Review { public int Id { get; set; } @@ -136,6 +183,6 @@ public class Review public string? Comment { get; set; } public DateTime ReviewDate { get; set; } - public int BookId { get; set; } - public Book Book { get; set; } = null!; + public int? BookId { get; set; } + public Book? Book { get; set; } } \ No newline at end of file diff --git a/samples/erd/simple-context/README.md b/samples/erd/simple-context/README.md index 3ad9305..94b891e 100644 --- a/samples/erd/simple-context/README.md +++ b/samples/erd/simple-context/README.md @@ -7,9 +7,10 @@ relationships. This example includes: -- **5 entities**: Author, Book, Category, Publisher, Review +- **7 entities**: Author, Book, Category, Publisher, Review, Profile, BookDetail - **2 many-to-many relationships**: Book ↔ Author, Book ↔ Category -- **2 one-to-many relationships**: Publisher → Book, Book → Review +- **2 one-to-many relationships**: Publisher → Book (Required), Book → Review (Optional) +- **2 one-to-one relationships**: Author ↔ Profile (Optional), Book ↔ BookDetail (Required) ## Usage @@ -40,53 +41,73 @@ The tool generates a **Mermaid ERD diagram** showing: ### Example Output ```mermaid +--- +title: MyDbContext +--- erDiagram - Author { - int Id PK - string Name "required, max:200" - string Bio "string? | max:1000" - DateTime BirthDate "DateTime?" - } - Book { - int Id PK - string Title "required, max:300" - string ISBN "string? | max:13" - DateTime PublishedDate - int PageCount - int PublisherId FK - } - Category { - int Id PK - string Name "required, max:100" - string Description "string? | max:500" - } - Publisher { - int Id PK - string Name "required, max:200" - string Country "string? | max:100" - DateTime FoundedDate "DateTime?" - } - Review { - int Id PK - int Rating "required" - string Comment "string? | max:2000" - DateTime ReviewDate - int BookId FK - } - AuthorBook { - int AuthorId PK,FK - int BookId PK,FK - } - BookCategory { - int BookId PK,FK - int CategoryId PK,FK - } - Publisher ||--o{ Book : "Books" - Book ||--o{ Review : "Reviews" - Author ||--o{ AuthorBook : "" - Book ||--o{ AuthorBook : "" - Book ||--o{ BookCategory : "" - Category ||--o{ BookCategory : "" + Author { + int Id PK + int MentorId FK + string Bio "max:1000" + DateTime BirthDate + bool IsActive + string Name "required, max:200" + } + AuthorBook { + int AuthorId PK,FK + int BookId PK,FK + } + Book { + int Id PK + int PublisherId FK + string ISBN "max:13" + int PageCount + DateTime PublishedDate + string Title "required, max:300" + } + BookCategory { + int BookId PK,FK + int CategoryId PK,FK + } + BookDetail { + int Id PK + int BookId FK + string Notes "required" + string Summary "required" + } + Category { + int Id PK + string Description "max:500" + string Name "required, max:100" + } + Profile { + int Id PK + int AuthorId FK + string AvatarUrl "required" + string BioData "required" + } + Publisher { + int Id PK + string Country "max:100" + DateTime FoundedDate + string Name "required, max:200" + } + Review { + int Id PK + int BookId FK + string Comment "max:2000" + int Rating "required" + DateTime ReviewDate + } + Author ||--o{ Author : "" + Author ||--o{ AuthorBook : "" + Author |o--|| Profile : "" + Book ||--o{ AuthorBook : "" + Book ||--o{ BookCategory : "" + Book ||--|| BookDetail : "" + Book ||--o{ Review : "" + Category ||--o{ BookCategory : "" + Publisher ||--o{ Book : "" ``` ### Rendered Diagram @@ -98,6 +119,7 @@ The Mermaid diagram renders as a visual ERD showing: - `||--o{` = One-to-Many (required) - `|o--o{` = One-to-Many (optional) - `||--||` = One-to-One (required) + - `||--o|` = One-to-One (optional) - `}|--|{` = Many-to-Many (shown as two One-to-Many via join table) ## Key Features @@ -160,33 +182,3 @@ The output is **GitHub/GitLab compatible** Mermaid syntax, so you can: 1. Copy the output directly into your `README.md` 2. Commit it to version control 3. It will render automatically in GitHub, GitLab, and other platforms - -## Example Workflow - -```bash -# 1. Generate ERD from your DbContext -projgraph erd MyProject/Data/ApplicationDbContext.cs > docs/database-erd.md - -# 2. Commit to repository -git add docs/database-erd.md -git commit -m "docs: Add database ERD diagram" - -# 3. Push - diagram will render automatically on GitHub! -git push -``` - -## Tips - -💡 **Nullable Types**: Original C# types (including `?` for nullable) are preserved in comments - -💡 **Join Tables**: Many-to-many relationships create explicit join table entities for clarity - -💡 **Inheritance**: Base class properties are automatically included (e.g., `Id`, `CreatedAt` from `AuditEntity`) - -💡 **MaxLength Constraints**: `[MaxLength(N)]` attributes are extracted and displayed as `max:N` - -💡 **Complex Schemas**: Works with large DbContexts containing dozens of entities - -💡 **Entity Framework Core**: Supports EF Core 6.0+ including fluent API configurations - -💡 **Documentation**: Perfect for maintaining up-to-date database schema documentation diff --git a/specs/002-dbcontext-erd/data-model.md b/specs/002-dbcontext-erd/data-model.md index e32cc7c..8fb2348 100644 --- a/specs/002-dbcontext-erd/data-model.md +++ b/specs/002-dbcontext-erd/data-model.md @@ -40,7 +40,6 @@ Represents a link between two entities. - `TargetEntity`: String - `Type`: Enum (`OneToOne`, `OneToMany`, `ManyToMany`) - `IsRequired`: Boolean -- `Label`: String (usually the navigation property name) ## State Transitions diff --git a/src/ProjGraph.Core/Models/EfModel.cs b/src/ProjGraph.Core/Models/EfModel.cs index bc874e2..74e66c4 100644 --- a/src/ProjGraph.Core/Models/EfModel.cs +++ b/src/ProjGraph.Core/Models/EfModel.cs @@ -77,6 +77,16 @@ public class EfProperty /// public bool IsRequired { get; set; } + /// + /// Gets or sets a value indicating whether the property is a value type. + /// + public bool IsValueType { get; set; } + + /// + /// Gets or sets a value indicating whether the property was explicitly marked as required. + /// + public bool IsExplicitlyRequired { get; set; } + /// /// Gets or sets the maximum length of the property value, if applicable. /// @@ -122,11 +132,6 @@ public class EfRelationship /// Gets or sets a value indicating whether the relationship is required. /// public bool IsRequired { get; set; } - - /// - /// Gets or sets the label associated with the relationship. - /// - public string Label { get; set; } = string.Empty; } /// diff --git a/src/ProjGraph.Lib/Rendering/MermaidErdRenderer.cs b/src/ProjGraph.Lib/Rendering/MermaidErdRenderer.cs index d668c32..30303ba 100644 --- a/src/ProjGraph.Lib/Rendering/MermaidErdRenderer.cs +++ b/src/ProjGraph.Lib/Rendering/MermaidErdRenderer.cs @@ -20,6 +20,14 @@ public static string Render(EfModel model) { var sb = new StringBuilder(); sb.AppendLine("```mermaid"); + + if (!string.IsNullOrEmpty(model.ContextName)) + { + sb.AppendLine("---"); + sb.AppendLine($"title: {model.ContextName}"); + sb.AppendLine("---"); + } + sb.AppendLine("erDiagram"); RenderEntities(model, sb); @@ -40,7 +48,7 @@ private static void RenderEntities(EfModel model, StringBuilder sb) foreach (var entity in sortedEntities) { - sb.AppendLine($" {entity.Name} {{"); + sb.AppendLine($" {entity.Name} {{"); var orderedProperties = entity.Properties .OrderByDescending(p => p.IsPrimaryKey) @@ -49,10 +57,10 @@ private static void RenderEntities(EfModel model, StringBuilder sb) foreach (var propertyLine in orderedProperties.Select(RenderProperty)) { - sb.AppendLine($" {propertyLine}"); + sb.AppendLine($" {propertyLine}"); } - sb.AppendLine(" }"); + sb.AppendLine(" }"); } } @@ -114,9 +122,8 @@ private static void RenderRelationships(EfModel model, StringBuilder sb) var relSyntax = GetRelationshipSyntax(rel); var sourceEntity = rel.SourceEntity.Trim(); var targetEntity = rel.TargetEntity.Trim(); - var label = string.IsNullOrWhiteSpace(rel.Label) ? "" : rel.Label; - sb.AppendLine($" {sourceEntity} {relSyntax} {targetEntity} : \"{label}\""); + sb.AppendLine($" {sourceEntity} {relSyntax} {targetEntity} : \"\""); } } @@ -129,7 +136,7 @@ private static string GetRelationshipSyntax(EfRelationship rel) { return rel.Type switch { - EfRelationshipType.OneToOne => "||--||", + EfRelationshipType.OneToOne => rel.IsRequired ? "||--||" : "|o--||", EfRelationshipType.OneToMany => rel.IsRequired ? "||--o{" : "|o--o{", EfRelationshipType.ManyToMany => "}|--|{", _ => "--" @@ -152,7 +159,8 @@ private static string BuildConstraintComment(EfProperty prop) // Add constraints var constraints = new List(); - if (prop is { IsRequired: true, IsPrimaryKey: false }) + if (prop is { IsPrimaryKey: false } && + (prop.IsExplicitlyRequired || prop is { IsRequired: true, IsValueType: false })) { constraints.Add("required"); } diff --git a/src/ProjGraph.Lib/Services/ClassAnalysis/WorkspaceTypeDiscovery.cs b/src/ProjGraph.Lib/Services/ClassAnalysis/WorkspaceTypeDiscovery.cs index 6ae2531..f4480dd 100644 --- a/src/ProjGraph.Lib/Services/ClassAnalysis/WorkspaceTypeDiscovery.cs +++ b/src/ProjGraph.Lib/Services/ClassAnalysis/WorkspaceTypeDiscovery.cs @@ -10,6 +10,13 @@ namespace ProjGraph.Lib.Services.ClassAnalysis; /// public static class WorkspaceTypeDiscovery { + /// + /// Directories to skip during recursive file search for better performance. + /// + private static readonly HashSet ExcludedDirectories = new(StringComparer.OrdinalIgnoreCase) + { + "bin", "obj", ".git", "node_modules" + }; /// /// Finds the file containing the definition of a specific type within a given directory or its subdirectories. /// The method first attempts to search in common subdirectories for better performance, and if not found, @@ -58,8 +65,22 @@ public static class WorkspaceTypeDiscovery /// private static async Task SearchDirectoryForTypeAsync(string directory, string typeName) { - var files = Directory.GetFiles(directory, "*.cs", SearchOption.AllDirectories); - foreach (var file in files) + return await SearchDirectoryRecursiveAsync(directory, typeName); + } + + /// + /// Recursively searches a directory and its subdirectories for a C# file containing a specific type definition. + /// This method manually handles recursion to avoid descending into common non-source directories for better performance. + /// + /// The path of the directory to search. + /// The name of the type to search for. + /// + /// The full path of the file containing the type definition if found; otherwise, null. + /// + private static async Task SearchDirectoryRecursiveAsync(string directory, string typeName) + { + // Search files in the current directory + foreach (var file in Directory.EnumerateFiles(directory, "*.cs", new EnumerationOptions { IgnoreInaccessible = true })) { // Simple string check first for performance var content = await File.ReadAllTextAsync(file); @@ -85,6 +106,22 @@ public static class WorkspaceTypeDiscovery } } + // Recursively search subdirectories, skipping excluded directories + foreach (var subDir in Directory.EnumerateDirectories(directory, "*", new EnumerationOptions { IgnoreInaccessible = true })) + { + var dirName = Path.GetFileName(subDir); + if (ExcludedDirectories.Contains(dirName)) + { + continue; + } + + var result = await SearchDirectoryRecursiveAsync(subDir, typeName); + if (result != null) + { + return result; + } + } + return null; } diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/CompilationFactory.cs b/src/ProjGraph.Lib/Services/EfAnalysis/CompilationFactory.cs index df7488c..007115a 100644 --- a/src/ProjGraph.Lib/Services/EfAnalysis/CompilationFactory.cs +++ b/src/ProjGraph.Lib/Services/EfAnalysis/CompilationFactory.cs @@ -23,7 +23,11 @@ public static class CompilationFactory public static CSharpCompilation CreateCompilation(IEnumerable syntaxTrees) { var references = BuildMetadataReferences(); + var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithNullableContextOptions(NullableContextOptions.Enable); + return CSharpCompilation.Create("AdHoc") + .WithOptions(options) .AddReferences(references) .AddSyntaxTrees(syntaxTrees); } diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/Constants/EfAnalysisConstants.cs b/src/ProjGraph.Lib/Services/EfAnalysis/Constants/EfAnalysisConstants.cs new file mode 100644 index 0000000..3a04b31 --- /dev/null +++ b/src/ProjGraph.Lib/Services/EfAnalysis/Constants/EfAnalysisConstants.cs @@ -0,0 +1,192 @@ +namespace ProjGraph.Lib.Services.EfAnalysis.Constants; + +/// +/// Contains constant values used throughout the EF Analysis services. +/// +public static class EfAnalysisConstants +{ + /// + /// Contains constant values for .NET type names. + /// + public static class DataTypes + { + public const string Int = "int"; + public const string Int32 = "Int32"; + public const string Int64 = "Int64"; + public const string String = "string"; + public const string Bool = "bool"; + public const string Boolean = "Boolean"; + public const string Guid = "Guid"; + public const string DateTime = "DateTime"; + public const string DateTimeOffset = "DateTimeOffset"; + public const string TimeSpan = "TimeSpan"; + public const string Decimal = "decimal"; + public const string Double = "double"; + public const string Float = "float"; + public const string Long = "long"; + public const string Single = "Single"; + public const string Short = "short"; + public const string Byte = "byte"; + public const string SByte = "sbyte"; + public const string UShort = "ushort"; + public const string UInt = "uint"; + public const string UInt32 = "UInt32"; + public const string ULong = "ulong"; + public const string UInt64 = "UInt64"; + public const string Char = "char"; + + /// + /// A set of common .NET value types that are treated as having a default value in EF. + /// + public static readonly HashSet ValueTypes = new(StringComparer.OrdinalIgnoreCase) + { + Int, + Int32, + Int64, + Long, + Bool, + Boolean, + Guid, + DateTime, + DateTimeOffset, + TimeSpan, + Decimal, + Double, + Float, + Single, + Short, + Byte, + SByte, + UShort, + UInt, + UInt32, + ULong, + UInt64, + Char + }; + } + + /// + /// Contains constant values for Entity Framework method names. + /// + public static class EfMethods + { + // Relationship methods + public const string HasOne = "HasOne"; + public const string HasMany = "HasMany"; + public const string WithOne = "WithOne"; + public const string WithMany = "WithMany"; + + // Configuration methods + public const string Entity = "Entity"; + public const string ToTable = "ToTable"; + public const string Property = "Property"; + public const string HasKey = "HasKey"; + public const string HasForeignKey = "HasForeignKey"; + public const string IsRequired = "IsRequired"; + public const string HasMaxLength = "HasMaxLength"; + public const string HasPrecision = "HasPrecision"; + public const string HasColumnType = "HasColumnType"; + public const string HasDefaultValue = "HasDefaultValue"; + public const string HasDefaultValueSql = "HasDefaultValueSql"; + public const string UsingEntity = "UsingEntity"; + + // DbContext methods + public const string OnModelCreating = "OnModelCreating"; + public const string BuildModel = "BuildModel"; + } + + /// + /// Contains constant values for Entity Framework attribute names. + /// + public static class EfAttributes + { + public const string KeyAttribute = "KeyAttribute"; + public const string Key = "Key"; + public const string PrimaryKeyAttribute = "PrimaryKeyAttribute"; + public const string PrimaryKey = "PrimaryKey"; + public const string RequiredAttribute = "RequiredAttribute"; + public const string MaxLengthAttribute = "MaxLengthAttribute"; + public const string StringLengthAttribute = "StringLengthAttribute"; + public const string ColumnAttribute = "ColumnAttribute"; + public const string DbContextAttribute = "DbContextAttribute"; + } + + /// + /// Contains constant values for common property and method names. + /// + public static class CommonNames + { + public const string Id = "Id"; + public const string TypeName = "TypeName"; + public const string Nameof = "nameof"; + public const string DbSet = "DbSet"; + public const string DbContext = "DbContext"; + public const string ModelSnapshot = "ModelSnapshot"; + public const string System = "System"; + public const string Nullable = "Nullable"; + } + + /// + /// Contains constant values for collection type names. + /// + public static class CollectionTypes + { + public const string ICollection = "ICollection"; + public const string IList = "IList"; + public const string List = "List"; + public const string HashSet = "HashSet"; + public const string ISet = "ISet"; + public const string IEnumerable = "IEnumerable"; + } + + /// + /// Contains constant values for string suffixes and patterns. + /// + public static class Suffixes + { + public const string IdSuffix = "Id"; + } + + /// + /// Contains constant values for relationship type keys and delimiters. + /// + public static class RelationshipKeys + { + public const string Delimiter = "-"; + } + + /// + /// Contains constant values for file patterns and extensions. + /// + public static class FilePatterns + { + public const string CSharpFiles = "*.cs"; + public const string CSharpExtension = ".cs"; + } + + /// + /// Contains constant values for SQL type patterns and formats. + /// + public static class SqlTypePatterns + { + public const string DecimalPattern = @"decimal\((\d+),\s*(\d+)\)"; + } + + /// + /// Contains constant values for regex patterns used in Fluent API parsing. + /// + public static class FluentApiPatterns + { + public const string EntityPattern = """Entity(?:<([^>]+)>|\("([^"]+)"(?:,\s*[^)]+)?\))"""; + public const string EntitySplitPattern = @"\.Entity(?=[<(])"; + public const string EntityMatchPattern = """\.Entity\s*(?:<([^>]+)>|\(\s*"([^"]+)"\s*)"""; + public const string ShadowRelationshipPattern = @"(HasOne|HasMany)<(\w+)>\(\s*\)\s*\.(WithOne|WithMany)\(\s*\)"; + public const string LambdaPropertyPattern = @"^\s*\(?\s*(\w+)\s*\)?\s*=>\s*\1\.(\w+)\s*$"; + public const string MethodCallPattern = @"\.(\w+(?:<[^>]+>)?)\(([^()]*(?:\([^()]*\)[^()]*)*)\)"; + public const string ToTablePattern = """\.ToTable\(\"([^\"]+)\"\)"""; + public const string StringLiteralPattern = "\"([^\"]+)\""; + public const string MethodNamePattern = @"\.\s*(\w+)"; + public const string NumericArgumentPattern = @"\((\d+)\)"; + } +} \ No newline at end of file diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/DbContextIdentifier.cs b/src/ProjGraph.Lib/Services/EfAnalysis/DbContextIdentifier.cs index 595f7c9..4c9aa41 100644 --- a/src/ProjGraph.Lib/Services/EfAnalysis/DbContextIdentifier.cs +++ b/src/ProjGraph.Lib/Services/EfAnalysis/DbContextIdentifier.cs @@ -1,4 +1,5 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; +using ProjGraph.Lib.Services.EfAnalysis.Constants; namespace ProjGraph.Lib.Services.EfAnalysis; @@ -16,7 +17,8 @@ public static class DbContextIdentifier /// public static bool IsDbContext(ClassDeclarationSyntax @class) { - return @class.BaseList?.Types.Any(t => t.ToString().Contains("DbContext")) ?? false; + return @class.BaseList?.Types.Any(t => t.ToString().Contains(EfAnalysisConstants.CommonNames.DbContext)) ?? + false; } /// @@ -28,7 +30,8 @@ public static bool IsDbContext(ClassDeclarationSyntax @class) /// public static bool IsModelSnapshot(ClassDeclarationSyntax @class) { - return @class.BaseList?.Types.Any(t => t.ToString().Contains("ModelSnapshot")) ?? false; + return @class.BaseList?.Types.Any(t => t.ToString().Contains(EfAnalysisConstants.CommonNames.ModelSnapshot)) ?? + false; } /// diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/EfAnalysisService.cs b/src/ProjGraph.Lib/Services/EfAnalysis/EfAnalysisService.cs index 7707b76..ed33e6d 100644 --- a/src/ProjGraph.Lib/Services/EfAnalysis/EfAnalysisService.cs +++ b/src/ProjGraph.Lib/Services/EfAnalysis/EfAnalysisService.cs @@ -3,14 +3,15 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using ProjGraph.Core.Models; using ProjGraph.Lib.Interfaces; -using System.Text.RegularExpressions; +using ProjGraph.Lib.Services.EfAnalysis.Constants; +using ProjGraph.Lib.Services.EfAnalysis.Patterns; namespace ProjGraph.Lib.Services.EfAnalysis; /// /// Service for analyzing Entity Framework DbContext classes and their models. /// -public partial class EfAnalysisService : IEfAnalysisService +public class EfAnalysisService : IEfAnalysisService { /// /// Discovers all DbContext classes within a specified C# file and returns their names as a list of strings. @@ -126,9 +127,8 @@ private static async Task> BuildSnapshotSyntaxTreesAsync( SyntaxTree snapshotSyntaxTree) { var root = await snapshotSyntaxTree.GetRootAsync(); - var entityNamespaces = EntityFileDiscovery.ExtractEntityNamespaces(root); var entityTypeNames = ExtractEntityTypeNamesFromSnapshot(snapshotClass); - var searchDirectories = EntityFileDiscovery.BuildSearchDirectories(snapshotDirectory, entityNamespaces); + var searchDirectories = EntityFileDiscovery.BuildSearchDirectories(snapshotDirectory); var entityFiles = await EntityFileDiscovery.DiscoverEntityFilesAsync( searchDirectories, @@ -156,7 +156,7 @@ private static HashSet ExtractEntityTypeNamesFromSnapshot(ClassDeclarati { var entityTypeNames = new HashSet(); var buildModelMethod = snapshotClass.Members.OfType() - .FirstOrDefault(m => m.Identifier.Text == "BuildModel"); + .FirstOrDefault(m => m.Identifier.Text == EfAnalysisConstants.EfMethods.BuildModel); if (buildModelMethod?.Body == null) { @@ -166,7 +166,7 @@ private static HashSet ExtractEntityTypeNamesFromSnapshot(ClassDeclarati var methodText = buildModelMethod.ToString(); // Match .Entity or .Entity("Namespace.T") - var entityMatches = EntityMatchRegex().Matches(methodText); + var entityMatches = EfAnalysisRegexPatterns.EntityMatchRegex().Matches(methodText); var shortNames = entityMatches .Select(match => match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value) @@ -192,9 +192,10 @@ private static HashSet ExtractEntityTypeNamesFromSnapshot(ClassDeclarati /// private static void ValidateCsFilePath(string path) { - if (!path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + if (!path.EndsWith(EfAnalysisConstants.FilePatterns.CSharpExtension, StringComparison.OrdinalIgnoreCase)) { - throw new ArgumentException("Only .cs files are supported", nameof(path)); + throw new ArgumentException($"Only {EfAnalysisConstants.FilePatterns.CSharpExtension} files are supported", + nameof(path)); } } @@ -297,9 +298,8 @@ private static async Task> BuildSyntaxTreesAsync( SyntaxTree contextSyntaxTree) { var root = await contextSyntaxTree.GetRootAsync(); - var entityNamespaces = EntityFileDiscovery.ExtractEntityNamespaces(root); var entityTypeNames = EntityFileDiscovery.ExtractEntityTypeNames(contextClass); - var searchDirectories = EntityFileDiscovery.BuildSearchDirectories(contextDirectory, entityNamespaces); + var searchDirectories = EntityFileDiscovery.BuildSearchDirectories(contextDirectory); var entityFiles = await EntityFileDiscovery.DiscoverEntityFilesAsync( searchDirectories, @@ -398,8 +398,6 @@ private static EfModel BuildEfModel(INamedTypeSymbol contextType, Compilation co /// /// Removes duplicate entities and relationships from the model. - /// Prefers relationships with labels (from Fluent API) over auto-discovered ones. - /// For self-referencing relationships with the same label, keeps only one (preferring OneToMany). /// private static void DeduplicateModelContent(EfModel model) { @@ -412,28 +410,25 @@ private static void DeduplicateModelContent(EfModel model) model.Entities.AddRange(uniqueEntities); // Deduplicate relationships by generating unique keys - // Prefer relationships with non-empty labels (from Fluent API) over auto-discovered ones var uniqueRelationships = model.Relationships - .GroupBy(r => GenerateRelationshipKey(r)) - .Select(g => - { - // If there are multiple, prefer the one with a label - var withLabel = g.FirstOrDefault(r => !string.IsNullOrEmpty(r.Label)); - return withLabel ?? g.First(); - }) + .GroupBy(GenerateRelationshipKey) + .Select(g => g.First()) .ToList(); - // Additional deduplication for self-referencing relationships with same label - // This handles cases like PermissionEntry -> PermissionEntry with "ParentPermission" + // Additional deduplication for self-referencing relationships + // This handles cases like PermissionEntry -> PermissionEntry // appearing as both OneToOne and OneToMany var finalRelationships = uniqueRelationships - .GroupBy(r => new { r.SourceEntity, r.TargetEntity, r.Label }) + .GroupBy(r => new { r.SourceEntity, r.TargetEntity }) .Select(g => { // If only one, return it - if (g.Count() == 1) return g.First(); - - // If multiple with same source, target, and label, prefer OneToMany over OneToOne + if (g.Count() == 1) + { + return g.First(); + } + + // If multiple with same source and target, prefer OneToMany over OneToOne // for self-referencing (parent-child hierarchies) var oneToMany = g.FirstOrDefault(r => r.Type == EfRelationshipType.OneToMany); return oneToMany ?? g.First(); @@ -455,11 +450,13 @@ private static string GenerateRelationshipKey(EfRelationship relationship) var entitiesSorted = new[] { relationship.SourceEntity, relationship.TargetEntity } .OrderBy(e => e) .ToArray(); - return $"{entitiesSorted[0]}-{entitiesSorted[1]}-{relationship.Type}"; + return + $"{entitiesSorted[0]}{EfAnalysisConstants.RelationshipKeys.Delimiter}{entitiesSorted[1]}{EfAnalysisConstants.RelationshipKeys.Delimiter}{relationship.Type}"; } // For OneToMany, direction matters - return $"{relationship.SourceEntity}-{relationship.TargetEntity}-{relationship.Type}"; + return + $"{relationship.SourceEntity}{EfAnalysisConstants.RelationshipKeys.Delimiter}{relationship.TargetEntity}{EfAnalysisConstants.RelationshipKeys.Delimiter}{relationship.Type}"; } /// @@ -478,7 +475,10 @@ private static Dictionary DiscoverEntitiesFromDbSets(INamedTyp foreach (var member in contextType.GetMembers().OfType()) { - if (member.Type is not INamedTypeSymbol { Name: "DbSet", TypeArguments.Length: 1 } dbSetType) + if (member.Type is not INamedTypeSymbol + { + Name: EfAnalysisConstants.CommonNames.DbSet, TypeArguments.Length: 1 + } dbSetType) { continue; } @@ -492,7 +492,4 @@ private static Dictionary DiscoverEntitiesFromDbSets(INamedTyp return entities; } - - [GeneratedRegex("""\.Entity\s*(?:<([^>]+)>|\(\s*"([^"]+)"\s*)""")] - private static partial Regex EntityMatchRegex(); } \ No newline at end of file diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/EntityAnalyzer.cs b/src/ProjGraph.Lib/Services/EfAnalysis/EntityAnalyzer.cs index 6e8cd88..b736e47 100644 --- a/src/ProjGraph.Lib/Services/EfAnalysis/EntityAnalyzer.cs +++ b/src/ProjGraph.Lib/Services/EfAnalysis/EntityAnalyzer.cs @@ -2,8 +2,9 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using ProjGraph.Core.Models; +using ProjGraph.Lib.Services.EfAnalysis.Constants; using ProjGraph.Lib.Services.EfAnalysis.Extensions; -using System.Text.RegularExpressions; +using ProjGraph.Lib.Services.EfAnalysis.Patterns; namespace ProjGraph.Lib.Services.EfAnalysis; @@ -12,25 +13,8 @@ namespace ProjGraph.Lib.Services.EfAnalysis; /// primary keys, and constraints. This class is implemented as a partial class to allow for /// extension in other files. /// -public static partial class EntityAnalyzer +public static class EntityAnalyzer { - // Attribute name constants - private const string PrimaryKeyAttribute = "PrimaryKeyAttribute"; - private const string PrimaryKey = "PrimaryKey"; - private const string KeyAttribute = "KeyAttribute"; - private const string Key = "Key"; - private const string RequiredAttribute = "RequiredAttribute"; - private const string MaxLengthAttribute = "MaxLengthAttribute"; - private const string StringLengthAttribute = "StringLengthAttribute"; - private const string ColumnAttribute = "ColumnAttribute"; - - // Property name constants - private const string TypeName = "TypeName"; - private const string Id = "Id"; - - // Method name constants - private const string Nameof = "nameof"; - /// /// Analyzes the specified entity type symbol and extracts its properties, primary keys, and constraints. /// @@ -50,7 +34,7 @@ public static EfEntity AnalyzeEntity(INamedTypeSymbol type) var primaryKeyNames = ExtractPrimaryKeyNames(type); var currentType = type; - while (currentType != null && currentType.SpecialType != SpecialType.System_Object) + while (currentType is not null && currentType.SpecialType is not SpecialType.System_Object) { foreach (var prop in currentType.GetMembers().OfType()) { @@ -85,9 +69,9 @@ public static EfEntity AnalyzeEntity(INamedTypeSymbol type) /// public static INamedTypeSymbol? FindEntitySymbol(EfEntity entity, Compilation compilation) { - return compilation.GlobalNamespace - .GetAllNamedTypes() - .FirstOrDefault(t => t.Name == entity.Name); + return compilation.GetSymbolsWithName(entity.Name, SymbolFilter.Type) + .OfType() + .FirstOrDefault(); } /// @@ -107,7 +91,7 @@ private static HashSet ExtractPrimaryKeyNames(INamedTypeSymbol type) var primaryKeyNames = new HashSet(); var currentType = type; - while (currentType != null && currentType.SpecialType != SpecialType.System_Object) + while (currentType is not null && currentType.SpecialType is not SpecialType.System_Object) { ExtractPrimaryKeysFromSemanticModel(currentType, primaryKeyNames); ExtractPrimaryKeysFromSyntax(currentType, primaryKeyNames); @@ -138,7 +122,8 @@ private static void ExtractPrimaryKeysFromTypeAttributes(INamedTypeSymbol type, foreach (var attribute in type.GetAttributes()) { var attrName = attribute.AttributeClass?.Name; - if (attrName is not (PrimaryKeyAttribute or PrimaryKey)) + if (attrName is not (EfAnalysisConstants.EfAttributes.PrimaryKeyAttribute + or EfAnalysisConstants.EfAttributes.PrimaryKey)) { continue; } @@ -158,7 +143,9 @@ private static void ExtractPrimaryKeysFromTypeAttributes(INamedTypeSymbol type, private static void ExtractPrimaryKeysFromPropertyAttributes(INamedTypeSymbol type, HashSet primaryKeyNames) { foreach (var prop in type.GetMembers().OfType() - .Where(p => p.GetAttributes().Any(a => a.AttributeClass?.Name is KeyAttribute or Key))) + .Where(p => p.GetAttributes().Any(a => + a.AttributeClass?.Name is EfAnalysisConstants.EfAttributes.KeyAttribute + or EfAnalysisConstants.EfAttributes.Key))) { primaryKeyNames.Add(prop.Name); } @@ -194,7 +181,8 @@ private static void ExtractPrimaryKeysFromClassAttributes(ClassDeclarationSyntax foreach (var attr in classSyntax.AttributeLists.SelectMany(al => al.Attributes)) { var name = attr.Name.ToString(); - if (name is not (PrimaryKey or PrimaryKeyAttribute)) + if (name is not (EfAnalysisConstants.EfAttributes.PrimaryKey + or EfAnalysisConstants.EfAttributes.PrimaryKeyAttribute)) { continue; } @@ -222,7 +210,8 @@ private static void ExtractPrimaryKeysFromPropertySyntax(ClassDeclarationSyntax { foreach (var prop in classSyntax.Members.OfType() .Where(p => p.AttributeLists.SelectMany(al => al.Attributes) - .Any(a => a.Name.ToString() is Key or KeyAttribute))) + .Any(a => a.Name.ToString() is EfAnalysisConstants.EfAttributes.Key + or EfAnalysisConstants.EfAttributes.KeyAttribute))) { primaryKeyNames.Add(prop.Identifier.Text); } @@ -231,7 +220,7 @@ private static void ExtractPrimaryKeysFromPropertySyntax(ClassDeclarationSyntax private static void CollectNamesFromConstant(TypedConstant constant, HashSet names) { - if (constant.Kind == TypedConstantKind.Array) + if (constant.Kind is TypedConstantKind.Array) { foreach (var value in constant.Values) { @@ -250,7 +239,7 @@ private static void CollectNamesFromConstant(TypedConstant constant, HashSet 0 && attribute.ConstructorArguments[0].Value is int maxLength) { @@ -411,7 +410,7 @@ private static void ApplyAttributeConstraint(AttributeData attribute, EfProperty break; - case ColumnAttribute: + case EfAnalysisConstants.EfAttributes.ColumnAttribute: ExtractColumnTypeNameConstraints(attribute, efProperty); break; } @@ -431,12 +430,12 @@ private static void ExtractColumnTypeNameConstraints(AttributeData attribute, Ef { foreach (var namedArg in attribute.NamedArguments) { - if (namedArg is not { Key: TypeName, Value.Value: string typeName }) + if (namedArg is not { Key: EfAnalysisConstants.CommonNames.TypeName, Value.Value: string typeName }) { continue; } - var match = DecimalPrecisionRegex().Match(typeName); + var match = EfAnalysisRegexPatterns.DecimalPrecisionRegex().Match(typeName); if (!match.Success) { continue; @@ -446,22 +445,4 @@ private static void ExtractColumnTypeNameConstraints(AttributeData attribute, Ef efProperty.Scale = int.Parse(match.Groups[2].Value); } } - - /// - /// Defines a regular expression to match a decimal type with precision and scale in the format "decimal(precision, scale)". - /// - /// - /// The regular expression captures two groups: - /// - /// - /// The first group captures the precision (number of total digits). - /// - /// - /// The second group captures the scale (number of digits after the decimal point). - /// - /// - /// - /// A object that matches the specified decimal format. - [GeneratedRegex(@"decimal\((\d+),\s*(\d+)\)")] - private static partial Regex DecimalPrecisionRegex(); } \ No newline at end of file diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/EntityFileDiscovery.cs b/src/ProjGraph.Lib/Services/EfAnalysis/EntityFileDiscovery.cs index e1b76b4..73fe503 100644 --- a/src/ProjGraph.Lib/Services/EfAnalysis/EntityFileDiscovery.cs +++ b/src/ProjGraph.Lib/Services/EfAnalysis/EntityFileDiscovery.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using ProjGraph.Lib.Services.EfAnalysis.Constants; namespace ProjGraph.Lib.Services.EfAnalysis; @@ -15,6 +16,11 @@ namespace ProjGraph.Lib.Services.EfAnalysis; /// public static class EntityFileDiscovery { + /// + /// Maximum recursion depth when searching for base class files to prevent infinite recursion + /// and limit search scope to reasonable project structures. + /// + private const int MaxSearchDepth = 10; /// /// Discovers the file paths of entity files within the specified search directories. /// @@ -41,6 +47,12 @@ public static async Task> DiscoverEntityFilesAsync( foreach (var searchDir in searchDirectories.Where(Directory.Exists)) { await SearchDirectoryForEntitiesAsync(searchDir, entityTypeNames, normalizedContextPath, entityFiles); + + // Optimization: stop searching if we've found all entities + if (entityTypeNames.All(entityFiles.ContainsKey)) + { + break; + } } return entityFiles; @@ -73,30 +85,25 @@ public static async Task> DiscoverBaseClassFilesAsync return []; } - var solutionRoot = FindSolutionRoot(contextDirectory, 4); + // Search in context directory and its parents for base classes + var solutionRoot = FindSolutionRoot(contextDirectory, 3); return SearchForBaseClassFiles(baseClassNames, solutionRoot); } /// - /// Builds a list of directories to search for entity files based on the provided context directory - /// and a list of entity namespaces. + /// Builds a list of directories to search for entity files based on the provided context directory. /// /// The directory containing the context file. - /// A list of namespaces associated with the entity types. /// - /// A list of directories to search for entity files, including the context directory, its parent directory, - /// and any sibling directories that are likely to contain entity files. + /// 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 its parent directory (if it exists), - /// then adds sibling directories that are likely to contain entity files based on their names - /// or their match with the provided entity namespaces. - /// If the context directory is within the system temp directory, the parent temp directory is excluded - /// from the search to avoid finding files from other processes or parallel tests. + /// 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. /// public static List BuildSearchDirectories( - string contextDirectory, - List entityNamespaces) + string contextDirectory) { var searchDirectories = new List { contextDirectory }; var parentDir = Directory.GetParent(contextDirectory); @@ -108,18 +115,16 @@ public static List BuildSearchDirectories( // Avoid searching outside the temp directory if we are in one, // to prevent finding files from parallel test runs. - // Security: Path.GetTempPath() is used only for read-only path comparison to detect - // if we're in a test sandbox. No files are written to or read from the temp directory itself. var tempPath = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (contextDirectory.StartsWith(tempPath, StringComparison.OrdinalIgnoreCase)) { return searchDirectories; } + // Adding the parent directory handles most cases as it encompasses siblings and the context dir itself. searchDirectories.Add(parentDir.FullName); - AddSiblingEntityDirectories(parentDir.FullName, entityNamespaces, searchDirectories); - return searchDirectories; + return searchDirectories.Distinct().ToList(); } /// @@ -141,7 +146,7 @@ public static HashSet ExtractEntityTypeNames(ClassDeclarationSyntax cont { if (member.Type is not GenericNameSyntax { - Identifier.Text: "DbSet", TypeArgumentList.Arguments.Count: 1 + Identifier.Text: EfAnalysisConstants.CommonNames.DbSet, TypeArgumentList.Arguments.Count: 1 } genericType) { continue; @@ -154,31 +159,6 @@ public static HashSet ExtractEntityTypeNames(ClassDeclarationSyntax cont return entityTypeNames; } - /// - /// Extracts the namespaces of entity types from the given syntax tree root node. - /// - /// The root of the syntax tree to analyze. - /// - /// A list of strings representing the namespaces of entity types found in the syntax tree. - /// Only namespaces that do not start with "System" or "Microsoft" are included. - /// - /// - /// This method traverses the syntax tree to find all using directives. It filters out namespaces - /// that are null or start with "System" or "Microsoft", and returns the remaining namespaces as a list of strings. - /// - public static List ExtractEntityNamespaces(SyntaxNode root) - { - return - [ - .. root.DescendantNodes() - .OfType() - .Where(u => u.Name is not null && - !u.Name.ToString().StartsWith("System") && - !u.Name.ToString().StartsWith("Microsoft")) - .Select(u => u.Name!.ToString()) - ]; - } - /// /// Searches a directory and its subdirectories for C# files containing entity type definitions /// and adds their file paths to the provided dictionary. @@ -196,23 +176,69 @@ .. root.DescendantNodes() /// For each file, it calls to process the file and add matching /// entity type names and their file paths to the dictionary. /// Any access errors encountered during directory traversal are ignored. + /// Directories like bin, obj, .git, and node_modules are skipped during traversal for performance. /// private static async Task SearchDirectoryForEntitiesAsync( string searchDir, HashSet entityTypeNames, string normalizedContextPath, Dictionary entityFiles) + { + await SearchDirectoryRecursiveAsync(searchDir, entityTypeNames, normalizedContextPath, entityFiles); + } + + /// + /// Recursively searches a directory for C# files, skipping common build and version control directories. + /// + /// The current directory to search. + /// A set of entity type names to search for in the C# files. + /// The normalized file path of the context file to exclude from the search. + /// + /// A dictionary where the keys are entity type names and the values are the corresponding file paths. + /// + /// A task that represents the asynchronous operation. + /// + /// This method implements manual recursion to avoid descending into directories that typically + /// contain build artifacts or dependencies (bin, obj, .git, node_modules), improving performance + /// for large projects. + /// + private static async Task SearchDirectoryRecursiveAsync( + string currentDir, + HashSet entityTypeNames, + string normalizedContextPath, + Dictionary entityFiles) { try { - foreach (var csFile in Directory.GetFiles(searchDir, "*.cs", SearchOption.AllDirectories)) + // Process files in the current directory + var options = new EnumerationOptions + { + RecurseSubdirectories = false, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.System + }; + + foreach (var csFile in Directory.EnumerateFiles(currentDir, EfAnalysisConstants.FilePatterns.CSharpFiles, options)) { - if (Path.GetFullPath(csFile).Equals(normalizedContextPath, StringComparison.OrdinalIgnoreCase)) + var fullPath = Path.GetFullPath(csFile); + if (fullPath.Equals(normalizedContextPath, StringComparison.OrdinalIgnoreCase)) { continue; } - await ProcessSourceFileAsync(csFile, entityTypeNames, entityFiles); + await ProcessSourceFileAsync(fullPath, entityTypeNames, entityFiles); + } + + // Recursively process subdirectories, skipping excluded directories + foreach (var subDir in Directory.EnumerateDirectories(currentDir, "*", options)) + { + var dirName = Path.GetFileName(subDir); + if (dirName is "bin" or "obj" or ".git" or "node_modules") + { + continue; + } + + await SearchDirectoryRecursiveAsync(subDir, entityTypeNames, normalizedContextPath, entityFiles); } } catch @@ -242,6 +268,13 @@ private static async Task ProcessSourceFileAsync( Dictionary entityFiles) { var fileCode = await File.ReadAllTextAsync(filePath); + + // Performance optimization: skip heavy parsing if none of the entity names are present in the text + if (!entityTypeNames.Any(name => fileCode.Contains(name, StringComparison.Ordinal))) + { + return; + } + var syntaxTree = CSharpSyntaxTree.ParseText(fileCode); var root = await syntaxTree.GetRootAsync(); @@ -388,103 +421,91 @@ private static DirectoryInfo FindSolutionRoot(string startDirectory, int maxLeve /// if the files are found; otherwise, the dictionary will be empty. /// /// - /// This method iterates through the provided base class names and attempts to locate their corresponding - /// file paths by calling the method. If a file is found, it is added - /// to the resulting dictionary. If no file is found for a base class name, it is skipped. + /// This method recursively searches for base class files while skipping common build and version control + /// directories (bin, obj, .git, node_modules) to improve performance for large projects. /// public static Dictionary SearchForBaseClassFiles( HashSet baseClassNames, DirectoryInfo solutionRoot) { var baseClassFiles = new Dictionary(); - - foreach (var baseClassName in baseClassNames) - { - var foundFile = TryFindBaseClassFile(baseClassName, solutionRoot); - if (foundFile != null) - { - baseClassFiles[baseClassName] = foundFile; - } - } - + SearchForBaseClassFilesRecursive(solutionRoot.FullName, baseClassNames, baseClassFiles, 0, MaxSearchDepth); return baseClassFiles; } /// - /// Attempts to find the file path of a base class file within the specified solution root directory. + /// Recursively searches for base class files, skipping common build and version control directories. /// - /// The name of the base class to search for. - /// The root directory of the solution to search within. - /// - /// The full file path of the base class file if found; otherwise, null. - /// + /// The current directory to search. + /// A set of base class names to search for. + /// + /// A dictionary to store the found base class files where the keys are base class names + /// and the values are the corresponding file paths. + /// + /// The current recursion depth. + /// The maximum recursion depth to prevent infinite recursion. /// - /// This method searches for a file matching the base class name with a ".cs" extension - /// in the specified solution root directory and its subdirectories, up to a maximum recursion depth of 10. - /// If any access errors occur during the directory enumeration, they are ignored. + /// This method implements manual recursion to avoid descending into directories that typically + /// contain build artifacts or dependencies (bin, obj, .git, node_modules), improving performance + /// for large projects. /// - private static string? TryFindBaseClassFile(string baseClassName, DirectoryInfo solutionRoot) + private static void SearchForBaseClassFilesRecursive( + string currentDir, + HashSet baseClassNames, + Dictionary baseClassFiles, + int currentDepth, + int maxDepth) { - try - { - return Directory.EnumerateFiles( - solutionRoot.FullName, - $"{baseClassName}.cs", - new EnumerationOptions - { - IgnoreInaccessible = true, RecurseSubdirectories = true, MaxRecursionDepth = 10 - }) - .FirstOrDefault(); - } - catch + if (currentDepth >= maxDepth || baseClassFiles.Count == baseClassNames.Count) { - return null; + return; } - } - /// - /// Adds sibling directories that are likely to contain entity files to the search directories list. - /// - /// The path of the parent directory to search for sibling directories. - /// A list of entity namespaces to compare against the directory names. - /// The list of directories to which the sibling directories will be added. - /// - /// This method attempts to find sibling directories in the parent directory and checks if they are likely - /// to represent entity directories based on their names or the provided entity namespaces. If access errors - /// occur while enumerating directories, they are ignored. - /// - private static void AddSiblingEntityDirectories( - string parentPath, - List entityNamespaces, - List searchDirectories) - { try { - searchDirectories.AddRange( - from siblingDir in Directory.GetDirectories(parentPath, "*", SearchOption.TopDirectoryOnly) - let dirName = Path.GetFileName(siblingDir) - where IsLikelyEntityDirectory(dirName, entityNamespaces) - select siblingDir); + var options = new EnumerationOptions + { + RecurseSubdirectories = false, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.System + }; + + // Process files in the current directory + foreach (var file in Directory.EnumerateFiles(currentDir, "*.cs", options)) + { + var fileName = Path.GetFileNameWithoutExtension(file); + if (baseClassNames.Contains(fileName)) + { + var fullPath = Path.GetFullPath(file); + baseClassFiles.TryAdd(fileName, fullPath); + + if (baseClassFiles.Count == baseClassNames.Count) + { + return; + } + } + } + + // Recursively process subdirectories, skipping excluded directories + foreach (var subDir in Directory.EnumerateDirectories(currentDir, "*", options)) + { + var dirName = Path.GetFileName(subDir); + if (dirName is "bin" or "obj" or ".git" or "node_modules") + { + continue; + } + + SearchForBaseClassFilesRecursive(subDir, baseClassNames, baseClassFiles, currentDepth + 1, maxDepth); + + if (baseClassFiles.Count == baseClassNames.Count) + { + return; + } + } } catch { // Ignore access errors } } - - /// - /// Determines whether the specified directory name is likely to represent an entity directory. - /// - /// The name of the directory to evaluate. - /// A list of entity namespaces to compare against the directory name. - /// - /// true if the directory name contains "Entities" or "Models" (case-insensitive), - /// or if it matches any part of the provided entity namespaces; otherwise, false. - /// - private static bool IsLikelyEntityDirectory(string dirName, List entityNamespaces) - { - return dirName.Contains("Entities", StringComparison.OrdinalIgnoreCase) || - dirName.Contains("Models", StringComparison.OrdinalIgnoreCase) || - entityNamespaces.Any(ns => ns.Contains(dirName, StringComparison.OrdinalIgnoreCase)); - } } \ No newline at end of file diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/Extensions/TypeSymbolExtensions.cs b/src/ProjGraph.Lib/Services/EfAnalysis/Extensions/TypeSymbolExtensions.cs index 0929a39..233d022 100644 --- a/src/ProjGraph.Lib/Services/EfAnalysis/Extensions/TypeSymbolExtensions.cs +++ b/src/ProjGraph.Lib/Services/EfAnalysis/Extensions/TypeSymbolExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.CodeAnalysis; +using ProjGraph.Lib.Services.EfAnalysis.Constants; namespace ProjGraph.Lib.Services.EfAnalysis.Extensions; @@ -16,8 +17,55 @@ public static class TypeSymbolExtensions /// public static bool IsNullable(this ITypeSymbol type) { - return type.NullableAnnotation == NullableAnnotation.Annotated || - type.Name == "Nullable"; + switch (type.NullableAnnotation) + { + case NullableAnnotation.Annotated: + return true; + case NullableAnnotation.NotAnnotated: + return false; + } + + if (type.Name is EfAnalysisConstants.CommonNames.Nullable) + { + return true; + } + + // Without NRT enabled, reference types are nullable + if (type.IsReferenceType) + { + return true; + } + + return false; + } + + /// + /// Determines whether the specified represents a value type for EF purposes. + /// + /// The type symbol to check. + /// + /// true if the type is a value type; otherwise, false. + /// + public static bool IsEfValueType(this ITypeSymbol type) + { + if (type.IsValueType) + { + return true; + } + + // Fallback for unresolved types or types where semantic info is incomplete + var typeString = type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat).TrimEnd('?'); + if (typeString.Contains('.')) + { + typeString = typeString[(typeString.LastIndexOf('.') + 1)..]; + } + + if (EfAnalysisConstants.DataTypes.ValueTypes.Contains(typeString)) + { + return true; + } + + return false; } /// @@ -29,7 +77,7 @@ public static bool IsNullable(this ITypeSymbol type) /// public static bool IsSystemOrPrimitiveType(this INamedTypeSymbol type) { - if (type.SpecialType != SpecialType.None) + if (type.SpecialType is not SpecialType.None) { return true; } @@ -37,7 +85,7 @@ public static bool IsSystemOrPrimitiveType(this INamedTypeSymbol type) var ns = type.ContainingNamespace; while (ns is { IsGlobalNamespace: false }) { - if (ns.Name == "System") + if (ns.Name is EfAnalysisConstants.CommonNames.System) { return true; } @@ -46,7 +94,9 @@ public static bool IsSystemOrPrimitiveType(this INamedTypeSymbol type) } var typeName = type.Name; - return typeName is "String" or "Guid" or "DateTime" or "DateTimeOffset" or "TimeSpan" or "Decimal"; + return typeName is EfAnalysisConstants.DataTypes.String or EfAnalysisConstants.DataTypes.Guid + or EfAnalysisConstants.DataTypes.DateTime or EfAnalysisConstants.DataTypes.DateTimeOffset + or EfAnalysisConstants.DataTypes.TimeSpan or EfAnalysisConstants.DataTypes.Decimal; } /// @@ -59,7 +109,11 @@ public static bool IsSystemOrPrimitiveType(this INamedTypeSymbol type) public static bool IsCollectionType(this INamedTypeSymbol type) { var typeName = type.Name; - return typeName is "ICollection" or "IList" or "List" or "HashSet" or "ISet" || - type.AllInterfaces.Any(i => i.Name is "ICollection" or "IEnumerable"); + return typeName is EfAnalysisConstants.CollectionTypes.ICollection or EfAnalysisConstants.CollectionTypes.IList + or EfAnalysisConstants.CollectionTypes.List or EfAnalysisConstants.CollectionTypes.HashSet + or EfAnalysisConstants.CollectionTypes.ISet || + type.AllInterfaces.Any(i => + i.Name is EfAnalysisConstants.CollectionTypes.ICollection + or EfAnalysisConstants.CollectionTypes.IEnumerable); } } \ No newline at end of file diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/FluentApiConfigurationParser.cs b/src/ProjGraph.Lib/Services/EfAnalysis/FluentApiConfigurationParser.cs index f62128d..ffd5398 100644 --- a/src/ProjGraph.Lib/Services/EfAnalysis/FluentApiConfigurationParser.cs +++ b/src/ProjGraph.Lib/Services/EfAnalysis/FluentApiConfigurationParser.cs @@ -1,7 +1,8 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using ProjGraph.Core.Models; -using ProjGraph.Lib.Services.EfAnalysis.Extensions; +using ProjGraph.Lib.Services.EfAnalysis.Constants; +using ProjGraph.Lib.Services.EfAnalysis.Patterns; using System.Text.RegularExpressions; namespace ProjGraph.Lib.Services.EfAnalysis; @@ -16,28 +17,8 @@ namespace ProjGraph.Lib.Services.EfAnalysis; /// Fluent API configurations. It includes methods for ensuring unique relationships and applying /// property configurations to entities. /// -public static partial class FluentApiConfigurationParser +public static class FluentApiConfigurationParser { - // EF Relationship Method Names - private const string HasOne = "HasOne"; - private const string HasMany = "HasMany"; - private const string WithOne = "WithOne"; - private const string WithMany = "WithMany"; - - // EF Configuration Method Names - private const string Entity = "Entity"; - private const string ToTable = "ToTable"; - private const string Property = "Property"; - private const string HasKey = "HasKey"; - private const string HasForeignKey = "HasForeignKey"; - private const string IsRequired = "IsRequired"; - private const string HasMaxLength = "HasMaxLength"; - private const string HasPrecision = "HasPrecision"; - private const string HasColumnType = "HasColumnType"; - private const string HasDefaultValue = "HasDefaultValue"; - private const string HasDefaultValueSql = "HasDefaultValueSql"; - private const string UsingEntity = "UsingEntity"; - /// /// Applies Fluent API constraints to the specified Entity Framework model by parsing the "OnModelCreating" method /// of the provided context type and processing each entity configuration section. @@ -90,7 +71,7 @@ public static void ApplyConstraintsFromMethod( } var methodText = methodSyntax.ToString(); - var entityConfigSections = EntitySplitRegex().Split(methodText); + var entityConfigSections = EfAnalysisRegexPatterns.EntitySplitRegex().Split(methodText); // Skip the first part (before the first .Entity) for (var i = 1; i < entityConfigSections.Length; i++) @@ -113,7 +94,7 @@ public static void ApplyConstraintsFromMethod( /// private static MethodDeclarationSyntax? FindOnModelCreatingMethod(INamedTypeSymbol contextType) { - var onModelCreating = contextType.GetMembers("OnModelCreating") + var onModelCreating = contextType.GetMembers(EfAnalysisConstants.EfMethods.OnModelCreating) .OfType() .FirstOrDefault(); @@ -142,10 +123,10 @@ private static void ProcessEntityConfigSection( Compilation compilation) { // Add back "Entity" which was removed by the split - var section = Entity + sectionContent; + var section = EfAnalysisConstants.EfMethods.Entity + sectionContent; // Extract just this entity's configuration (up to the next .Entity) - var entityConfigEnd = EntitySplitRegex().Match(section, 7).Index; + var entityConfigEnd = EfAnalysisRegexPatterns.EntitySplitRegex().Match(section, 7).Index; if (entityConfigEnd > 0) { section = section[..entityConfigEnd]; @@ -169,14 +150,10 @@ private static void AddUniqueRelationships(List relationships, E { var existingKeys = model.Relationships.Select(GenerateRelationshipKey).ToHashSet(); - foreach (var relationship in relationships) - { - var key = GenerateRelationshipKey(relationship); - if (existingKeys.Add(key)) - { - model.Relationships.Add(relationship); - } - } + var uniqueRelationships = relationships + .Where(relationship => existingKeys.Add(GenerateRelationshipKey(relationship))); + + model.Relationships.AddRange(uniqueRelationships); } /// @@ -193,11 +170,13 @@ private static string GenerateRelationshipKey(EfRelationship relationship) var entitiesSorted = new[] { relationship.SourceEntity, relationship.TargetEntity } .OrderBy(e => e) .ToArray(); - return $"{entitiesSorted[0]}-{entitiesSorted[1]}-{relationship.Type}"; + return + $"{entitiesSorted[0]}{EfAnalysisConstants.RelationshipKeys.Delimiter}{entitiesSorted[1]}{EfAnalysisConstants.RelationshipKeys.Delimiter}{relationship.Type}"; } // For OneToMany, direction matters - return $"{relationship.SourceEntity}-{relationship.TargetEntity}-{relationship.Type}"; + return + $"{relationship.SourceEntity}{EfAnalysisConstants.RelationshipKeys.Delimiter}{relationship.TargetEntity}{EfAnalysisConstants.RelationshipKeys.Delimiter}{relationship.Type}"; } /// @@ -226,7 +205,7 @@ private static List ParseEntityConfiguration( { var shadowRelationships = new List(); - var entityMatch = EntityNameRegex().Match(configSection); + var entityMatch = EfAnalysisRegexPatterns.EntityNameRegex().Match(configSection); if (!entityMatch.Success) { return shadowRelationships; @@ -246,8 +225,9 @@ private static List ParseEntityConfiguration( if (!entities.TryGetValue(entityName, out var entity)) { - var symbol = compilation.GlobalNamespace.GetAllNamedTypes() - .FirstOrDefault(t => t.Name == entityName); + var symbol = compilation.GetSymbolsWithName(entityName, SymbolFilter.Type) + .OfType() + .FirstOrDefault(); entity = symbol != null ? EntityAnalyzer.AnalyzeEntity(symbol) @@ -266,10 +246,10 @@ private static List ParseEntityConfiguration( ParseShadowRelationships(configSection, entityName, entities, shadowRelationships); ParseExplicitRelationships(configSection, entityName, entities, shadowRelationships, compilation); - ParsePropertyConfigurations(configSection, entity); + ParsePropertyConfigurations(configSection, entity, compilation); // Parse table mapping - var tableMatch = ToTableRegex().Match(configSection); + var tableMatch = EfAnalysisRegexPatterns.ToTableRegex().Match(configSection); if (tableMatch.Success) { entity.TableName = tableMatch.Groups[1].Value; @@ -296,7 +276,7 @@ private static void ParseShadowRelationships( Dictionary entities, List shadowRelationships) { - var shadowMatches = ShadowRelationshipRegex().Matches(configSection); + var shadowMatches = EfAnalysisRegexPatterns.ShadowRelationshipRegex().Matches(configSection); foreach (Match shadowMatch in shadowMatches) { @@ -309,11 +289,13 @@ private static void ParseShadowRelationships( var targetEntityName = shadowMatch.Groups[2].Value; var withMethod = shadowMatch.Groups[3].Value; - if (entities.ContainsKey(targetEntityName)) + if (!entities.ContainsKey(targetEntityName)) { - var rel = CreateShadowRelationship(entityName, targetEntityName, hasMethod, withMethod); - shadowRelationships.Add(rel); + continue; } + + var rel = CreateShadowRelationship(entityName, targetEntityName, hasMethod, withMethod); + shadowRelationships.Add(rel); } } @@ -328,14 +310,21 @@ private static void ParseExplicitRelationships( List relationships, Compilation compilation) { - var matches = MethodCallRegex().Matches(configSection); + var matches = EfAnalysisRegexPatterns.MethodCallRegex().Matches(configSection); for (var i = 0; i < matches.Count; i++) { var match = matches[i]; + + if (IsInsideUsingEntityBlock(configSection, match.Index)) + { + continue; + } + var methodName = match.Groups[1].Value; var args = match.Groups[2].Value; - if (!methodName.StartsWith(HasOne) && !methodName.StartsWith(HasMany)) + if (!methodName.StartsWith(EfAnalysisConstants.EfMethods.HasOne) && + !methodName.StartsWith(EfAnalysisConstants.EfMethods.HasMany)) { continue; } @@ -364,7 +353,7 @@ private static void ParseExplicitRelationships( Dictionary entities, Compilation compilation) { - var (targetEntityName, label) = ExtractTargetInfo(args); + var targetEntityName = ExtractTargetName(args); if (string.IsNullOrEmpty(targetEntityName)) { @@ -375,9 +364,6 @@ private static void ParseExplicitRelationships( // If we got a navigation property name, try to resolve it to an entity type if (!string.IsNullOrEmpty(targetEntityName) && !entities.ContainsKey(targetEntityName)) { - // Save the navigation property name to use as label - var navigationPropertyName = targetEntityName; - // Try to find the entity by checking if any entity has a property with a type matching this name // This handles cases like HasMany(a => a.Options) where Options is List var resolvedName = @@ -385,11 +371,6 @@ private static void ParseExplicitRelationships( if (resolvedName is not null) { targetEntityName = resolvedName; - // If no explicit label was provided, use the navigation property name - if (string.IsNullOrEmpty(label)) - { - label = navigationPropertyName; - } } } @@ -398,36 +379,14 @@ private static void ParseExplicitRelationships( return null; } - var (method, arg) = FindWithMethodInfo(matches, startIndex); + var method = FindWithMethodInfo(matches, startIndex); if (method is null) { return null; } var isRequired = IsRelationshipRequired(matches, startIndex); - var rel = CreateShadowRelationship(entityName, targetEntityName, methodName, method, isRequired); - - SetRelationshipLabel(rel, label, arg); - return rel; - } - - /// - /// Sets the relationship label from available sources. - /// - private static void SetRelationshipLabel(EfRelationship relationship, string? label, string? withMethodArg) - { - if (label != null) - { - relationship.Label = label; - } - else if (withMethodArg != null) - { - var inverseLabel = ExtractFirstStringArg(withMethodArg); - if (inverseLabel != null) - { - relationship.Label = inverseLabel; - } - } + return CreateShadowRelationship(entityName, targetEntityName, methodName, method, isRequired); } /// @@ -450,8 +409,9 @@ private static void SetRelationshipLabel(EfRelationship relationship, string? la Compilation compilation) { // Find the source entity symbol using semantic analysis - var sourceSymbol = compilation.GlobalNamespace.GetAllNamedTypes() - .FirstOrDefault(t => t.Name == sourceEntityName); + var sourceSymbol = compilation.GetSymbolsWithName(sourceEntityName, SymbolFilter.Type) + .OfType() + .FirstOrDefault(); // Look for a property with a matching name (case-insensitive) var navProperty = sourceSymbol?.GetMembers().OfType() @@ -464,7 +424,7 @@ private static void SetRelationshipLabel(EfRelationship relationship, string? la // Check if this is a navigation property and extract the target type if (NavigationPropertyAnalyzer.IsNavigationProperty(navProperty, out var targetType, out _) && - targetType != null && entities.ContainsKey(targetType.Name)) + targetType is not null && entities.ContainsKey(targetType.Name)) { return targetType.Name; } @@ -484,7 +444,7 @@ private static void ApplyForeignKeyConfiguration( Dictionary entities) { var (fkEntityNameOverride, fkPropNames) = FindForeignKeyInfo(matches, startIndex); - if (fkPropNames.Count == 0) + if (fkPropNames.Count is 0) { return; } @@ -514,7 +474,7 @@ private static string DetermineDependentEntity( } // Default: HasOne -> current entity, HasMany -> target entity - return methodName.StartsWith(HasOne) ? sourceEntityName : targetEntityName; + return methodName.StartsWith(EfAnalysisConstants.EfMethods.HasOne) ? sourceEntityName : targetEntityName; } /// @@ -522,9 +482,8 @@ private static string DetermineDependentEntity( /// private static void MarkPropertiesAsForeignKeys(EfEntity entity, List propertyNames) { - foreach (var propName in propertyNames) + foreach (var prop in propertyNames.Select(propName => GetOrCreateProperty(entity, propName, ""))) { - var prop = GetOrCreateProperty(entity, propName, ""); prop.IsForeignKey = true; } } @@ -534,7 +493,7 @@ private static bool IsRelationshipRequired(MatchCollection matches, int startInd for (var j = startIndex + 1; j < Math.Min(startIndex + 10, matches.Count); j++) { var nextMethod = matches[j].Groups[1].Value; - if (nextMethod != IsRequired) + if (nextMethod is not EfAnalysisConstants.EfMethods.IsRequired) { continue; } @@ -549,16 +508,19 @@ private static bool IsRelationshipRequired(MatchCollection matches, int startInd /// /// Finds the corresponding HasForeignKey method call following a HasOne or HasMany call. /// - private static (string? EntityNameOverride, List PropertyNames) FindForeignKeyInfo(MatchCollection matches, + private static (string? EntityNameOverride, List PropertyNames) FindForeignKeyInfo( + MatchCollection matches, int startIndex) { for (var j = startIndex + 1; j < Math.Min(startIndex + 10, matches.Count); j++) { var nextMethodMatch = matches[j].Groups[1].Value; - if (!nextMethodMatch.StartsWith(HasForeignKey)) + if (!nextMethodMatch.StartsWith(EfAnalysisConstants.EfMethods.HasForeignKey)) { - if (nextMethodMatch.Contains(Entity) || nextMethodMatch.StartsWith(HasOne) || - nextMethodMatch.StartsWith(HasMany) || nextMethodMatch.StartsWith(ToTable)) + if (nextMethodMatch.Contains(EfAnalysisConstants.EfMethods.Entity) || + nextMethodMatch.StartsWith(EfAnalysisConstants.EfMethods.HasOne) || + nextMethodMatch.StartsWith(EfAnalysisConstants.EfMethods.HasMany) || + nextMethodMatch.StartsWith(EfAnalysisConstants.EfMethods.ToTable)) { // Boundary of the relationship chain break; @@ -576,12 +538,12 @@ private static (string? EntityNameOverride, List PropertyNames) FindFore } /// - /// Extracts target information (entity name and optional label) from method arguments. + /// Extracts the target entity name from method arguments. /// - private static (string? Name, string? Label) ExtractTargetInfo(string args) + private static string? ExtractTargetName(string args) { // First try string literals (common in ModelSnapshots) - var stringMatches = StringLiteralRegex().Matches(args); + var stringMatches = EfAnalysisRegexPatterns.StringLiteralRegex().Matches(args); if (stringMatches.Count > 0) { var name = stringMatches[0].Groups[1].Value; @@ -590,47 +552,40 @@ private static (string? Name, string? Label) ExtractTargetInfo(string args) name = name.Split('.')[^1]; } - var label = stringMatches.Count > 1 ? stringMatches[1].Groups[1].Value : null; - return (name, label); + return name; } // Try lambda expression: e => e.NavigationProperty (common in DbContext fluent API) if (!args.Contains("=>")) { - return (null, null); + return null; } - var lambdaMatch = PropertyLambdaRegex().Match(args); + var lambdaMatch = EfAnalysisRegexPatterns.PropertyLambdaRegex().Match(args); if (!lambdaMatch.Success) { - return (null, null); + return null; } - var propertyName = lambdaMatch.Groups[2].Value; - return (propertyName, null); + return lambdaMatch.Groups[2].Value; } /// /// Finds the corresponding WithOne or WithMany method call following a HasOne or HasMany call. /// - private static (string? Method, string? Arg) FindWithMethodInfo(MatchCollection matches, int startIndex) + private static string? FindWithMethodInfo(MatchCollection matches, int startIndex) { for (var j = startIndex + 1; j < Math.Min(startIndex + 10, matches.Count); j++) { var nextMethod = matches[j].Groups[1].Value; - if (nextMethod.StartsWith(WithOne) || nextMethod.StartsWith(WithMany)) + if (nextMethod.StartsWith(EfAnalysisConstants.EfMethods.WithOne) || + nextMethod.StartsWith(EfAnalysisConstants.EfMethods.WithMany)) { - return (nextMethod, matches[j].Groups[2].Value); + return nextMethod; } } - return (null, null); - } - - private static string? ExtractFirstStringArg(string args) - { - var match = StringLiteralRegex().Match(args); - return match.Success ? match.Groups[1].Value : null; + return null; } private static EfRelationship CreateShadowRelationship(string sourceEntity, string targetEntity, string hasMethod, @@ -638,36 +593,32 @@ private static EfRelationship CreateShadowRelationship(string sourceEntity, stri { return (hasMethod, withMethod) switch { - (HasOne, WithMany) => new EfRelationship + (EfAnalysisConstants.EfMethods.HasOne, EfAnalysisConstants.EfMethods.WithMany) => new EfRelationship { SourceEntity = targetEntity, TargetEntity = sourceEntity, Type = EfRelationshipType.OneToMany, - Label = "", IsRequired = true // OneToMany defaults to required }, - (HasMany, WithOne) => new EfRelationship + (EfAnalysisConstants.EfMethods.HasMany, EfAnalysisConstants.EfMethods.WithOne) => new EfRelationship { SourceEntity = sourceEntity, TargetEntity = targetEntity, Type = EfRelationshipType.OneToMany, - Label = "", IsRequired = true // OneToMany defaults to required }, - (HasOne, WithOne) => new EfRelationship + (EfAnalysisConstants.EfMethods.HasOne, EfAnalysisConstants.EfMethods.WithOne) => new EfRelationship { SourceEntity = sourceEntity, TargetEntity = targetEntity, Type = EfRelationshipType.OneToOne, - Label = "", IsRequired = isRequired }, - (HasMany, WithMany) => new EfRelationship + (EfAnalysisConstants.EfMethods.HasMany, EfAnalysisConstants.EfMethods.WithMany) => new EfRelationship { SourceEntity = sourceEntity, TargetEntity = targetEntity, Type = EfRelationshipType.ManyToMany, - Label = "", IsRequired = isRequired }, _ => new EfRelationship @@ -675,7 +626,6 @@ private static EfRelationship CreateShadowRelationship(string sourceEntity, stri SourceEntity = targetEntity, TargetEntity = sourceEntity, Type = EfRelationshipType.OneToMany, - Label = "", IsRequired = true // OneToMany defaults to required } }; @@ -697,7 +647,8 @@ private static EfRelationship CreateShadowRelationship(string sourceEntity, stri private static bool IsInsideUsingEntityBlock(string configSection, int matchIndex) { var textBeforeMatch = configSection[..matchIndex]; - var lastUsingEntity = textBeforeMatch.LastIndexOf(UsingEntity, StringComparison.Ordinal); + var lastUsingEntity = + textBeforeMatch.LastIndexOf(EfAnalysisConstants.EfMethods.UsingEntity, StringComparison.Ordinal); if (lastUsingEntity < 0) { @@ -716,35 +667,51 @@ private static bool IsInsideUsingEntityBlock(string configSection, int matchInde /// /// The configuration section containing property configuration details. /// The object representing the entity to which the property configurations will be applied. + /// The used to resolve symbols for default values. /// /// This method extracts property configuration details from the provided configuration section. /// It identifies the property name using either a lambda expression or a string argument, /// and then parses all subsequent method calls (e.g., IsRequired, HasMaxLength) /// to apply the corresponding configurations to the entity's property. /// - private static void ParsePropertyConfigurations(string configSection, EfEntity entity) + private static void ParsePropertyConfigurations(string configSection, EfEntity entity, Compilation compilation) { EfProperty? currentProperty = null; - var matches = MethodCallRegex().Matches(configSection); - foreach (var groups in matches.Select(match => match.Groups)) + var matches = EfAnalysisRegexPatterns.MethodCallRegex().Matches(configSection); + for (var i = 0; i < matches.Count; i++) { + var match = matches[i]; + if (IsInsideUsingEntityBlock(configSection, match.Index)) + { + continue; + } + + var groups = match.Groups; var methodName = groups[1].Value; var args = groups[2].Value; - if (methodName == Property || methodName.StartsWith(Property + "<")) + if (methodName == EfAnalysisConstants.EfMethods.Property || + methodName.StartsWith(EfAnalysisConstants.EfMethods.Property + "<")) { currentProperty = ProcessPropertyDeclaration(entity, methodName, args); } - else if (methodName == HasKey) + else if (methodName == EfAnalysisConstants.EfMethods.HasKey || + methodName == EfAnalysisConstants.EfMethods.ToTable || + methodName.StartsWith(EfAnalysisConstants.EfMethods.HasOne) || + methodName.StartsWith(EfAnalysisConstants.EfMethods.HasMany)) { - ApplyKeyConfiguration(entity, args); + if (methodName == EfAnalysisConstants.EfMethods.HasKey) + { + ApplyKeyConfiguration(entity, args); + } + currentProperty = null; } else if (currentProperty != null) { - ApplyPropertyConfiguration(currentProperty, methodName, args); + ApplyPropertyConfiguration(currentProperty, methodName, args, compilation); } } } @@ -755,9 +722,8 @@ private static void ParsePropertyConfigurations(string configSection, EfEntity e private static void ApplyKeyConfiguration(EfEntity entity, string args) { var propNames = ExtractPropertyNamesFromArgs(args); - foreach (var propName in propNames) + foreach (var prop in propNames.Select(propName => GetOrCreateProperty(entity, propName, ""))) { - var prop = GetOrCreateProperty(entity, propName, ""); prop.IsPrimaryKey = true; } } @@ -772,13 +738,13 @@ private static List ExtractPropertyNamesFromArgs(string args) // Handle lambda: e => new { e.P1, e.P2 } or e => e.P1 if (args.Contains("=>")) { - var matches = MethodChainRegex().Matches(args); + var matches = EfAnalysisRegexPatterns.MethodChainRegex().Matches(args); result.AddRange(matches.Select(match => match.Groups[1].Value)); } else { // Handle string list: "P1", "P2" - var matches = StringLiteralRegex().Matches(args); + var matches = EfAnalysisRegexPatterns.StringLiteralRegex().Matches(args); result.AddRange(matches.Select(match => match.Groups[1].Value)); if (result.Count != 0 || string.IsNullOrWhiteSpace(args)) @@ -823,7 +789,7 @@ private static List ExtractPropertyNamesFromArgs(string args) /// The extracted property name. private static string ExtractPropertyName(string args) { - var lambdaMatch = PropertyLambdaRegex().Match(args); + var lambdaMatch = EfAnalysisRegexPatterns.PropertyLambdaRegex().Match(args); return lambdaMatch.Success ? lambdaMatch.Groups[2].Value : args.Trim('"', ' '); } @@ -852,31 +818,50 @@ private static string ExtractGenericType(string methodName) private static EfProperty GetOrCreateProperty(EfEntity entity, string propName, string type) { var property = entity.Properties.FirstOrDefault(p => p.Name == propName); - if (property == null) + if (property is null) { var detectedType = type; if (string.IsNullOrEmpty(detectedType)) { - detectedType = propName.EndsWith("Id", StringComparison.OrdinalIgnoreCase) ? "Guid" : "string"; + detectedType = + propName.EndsWith(EfAnalysisConstants.Suffixes.IdSuffix, StringComparison.OrdinalIgnoreCase) + ? EfAnalysisConstants.DataTypes.Guid + : EfAnalysisConstants.DataTypes.String; } - property = new EfProperty { Name = propName, Type = detectedType }; + property = new EfProperty + { + Name = propName, Type = detectedType, IsValueType = IsValueTypeString(detectedType) + }; entity.Properties.Add(property); } else if (!string.IsNullOrEmpty(type)) { property.Type = type; + property.IsValueType = IsValueTypeString(type); } return property; } + private static bool IsValueTypeString(string type) + { + var typeName = type.TrimEnd('?'); + if (typeName.Contains('.')) + { + typeName = typeName[(typeName.LastIndexOf('.') + 1)..]; + } + + return EfAnalysisConstants.DataTypes.ValueTypes.Contains(typeName); + } + /// /// Applies a specific configuration to a given property based on the provided configuration method and argument. /// /// The object representing the property to configure. /// The name of the configuration method to apply (e.g., "IsRequired", "HasMaxLength"). /// The argument for the configuration method, if applicable (e.g., max length, precision). + /// The used to resolve symbols for default values. /// /// This method applies various property configurations based on the provided method name: /// - "IsRequired": Marks the property as required. @@ -884,21 +869,29 @@ private static EfProperty GetOrCreateProperty(EfEntity entity, string propName, /// - "HasPrecision": Configures the precision and scale of the property using the method. /// - "HasDefaultValue": Sets the default value of the property using the provided argument. /// - private static void ApplyPropertyConfiguration(EfProperty property, string configMethod, string configArg) + private static void ApplyPropertyConfiguration(EfProperty property, string configMethod, string configArg, + Compilation compilation) { - var configActions = new Dictionary> - { - [IsRequired] = ApplyIsRequiredConfiguration, - [HasMaxLength] = ApplyMaxLengthConfiguration, - [HasPrecision] = ApplyPrecisionConfiguration, - [HasColumnType] = ApplyColumnTypeConfiguration, - [HasDefaultValue] = ApplyDefaultValueConfiguration, - [HasDefaultValueSql] = ApplyDefaultValueSqlConfiguration - }; - - if (configActions.TryGetValue(configMethod, out var action)) + switch (configMethod) { - action(property, configArg); + case EfAnalysisConstants.EfMethods.IsRequired: + ApplyIsRequiredConfiguration(property, configArg); + break; + case EfAnalysisConstants.EfMethods.HasMaxLength: + ApplyMaxLengthConfiguration(property, configArg); + break; + case EfAnalysisConstants.EfMethods.HasPrecision: + ApplyPrecisionConfiguration(property, configArg); + break; + case EfAnalysisConstants.EfMethods.HasColumnType: + ApplyColumnTypeConfiguration(property, configArg); + break; + case EfAnalysisConstants.EfMethods.HasDefaultValue: + ApplyDefaultValueConfiguration(property, configArg, compilation); + break; + case EfAnalysisConstants.EfMethods.HasDefaultValueSql: + ApplyDefaultValueSqlConfiguration(property, configArg); + break; } } @@ -909,8 +902,14 @@ private static void ApplyPropertyConfiguration(EfProperty property, string confi /// The configuration argument. private static void ApplyIsRequiredConfiguration(EfProperty property, string configArg) { - property.IsRequired = string.IsNullOrEmpty(configArg) || - configArg.Equals("true", StringComparison.OrdinalIgnoreCase); + var isRequired = string.IsNullOrEmpty(configArg) || + configArg.Equals("true", StringComparison.OrdinalIgnoreCase); + property.IsRequired = isRequired; + + if (isRequired) + { + property.IsExplicitlyRequired = true; + } } /// @@ -931,9 +930,10 @@ private static void ApplyMaxLengthConfiguration(EfProperty property, string conf /// /// The object representing the property to configure. /// The configuration argument containing the default value. - private static void ApplyDefaultValueConfiguration(EfProperty property, string configArg) + /// The used to resolve symbols for default values. + private static void ApplyDefaultValueConfiguration(EfProperty property, string configArg, Compilation compilation) { - property.DefaultValue = ParseDefaultValue(configArg); + property.DefaultValue = ParseDefaultValue(configArg, compilation); } /// @@ -958,36 +958,52 @@ private static void ApplyDefaultValueSqlConfiguration(EfProperty property, strin private static void ApplyColumnTypeConfiguration(EfProperty property, string configArg) { // If it's something like "nvarchar(30)", we can infer max length if not already set - if (property.MaxLength is null) + if (property.MaxLength is not null) { - var match = NumberInParensRegex().Match(configArg); - if (match.Success && int.TryParse(match.Groups[1].Value, out var len)) - { - property.MaxLength = len; - } + return; + } + + var match = EfAnalysisRegexPatterns.NumberInParensRegex().Match(configArg); + if (match.Success && int.TryParse(match.Groups[1].Value, out var len)) + { + property.MaxLength = len; } } /// - /// Parses a default value argument and returns a simplified string representation. + /// Parses the default value from a configuration argument, resolving constant or enum values if possible. /// - /// The configuration argument containing the default value. + /// The configuration argument to parse. + /// The used to resolve symbols for default values. /// /// A string representing the default value, with quotes removed and qualified names shortened - /// to their simple name when appropriate. + /// to their simple name when appropriate, or their constant value if resolvable. /// /// /// This method handles quoted strings and attempts to simplify fully qualified names /// (e.g., "MyNamespace.MyEnum.Value" becomes "Value") unless the value appears to be numeric. + /// It also attempts to resolve enum values to their underlying constant values if a compilation is provided. /// - private static string ParseDefaultValue(string configArg) + private static string ParseDefaultValue(string configArg, Compilation compilation) { var trimmedArg = configArg.Trim(); var isQuoted = (trimmedArg.StartsWith('\"') && trimmedArg.EndsWith('\"')) || (trimmedArg.StartsWith('\'') && trimmedArg.EndsWith('\'')); var val = trimmedArg.Trim('\"', '\''); - if (isQuoted || !val.Contains('.')) + if (isQuoted) + { + return val; + } + + // Try to resolve as a constant/enum value using Roslyn + var resolvedValue = ResolveConstantValue(val, compilation); + if (resolvedValue != null) + { + return resolvedValue; + } + + if (!val.Contains('.')) { return val; } @@ -1002,6 +1018,67 @@ private static string ParseDefaultValue(string configArg) return val; } + /// + /// Attempts to resolve a constant or enum value from an expression string using the provided compilation. + /// + /// The expression string to resolve (e.g., "UserStatus.Active"). + /// The used to resolve symbols. + /// The constant value as a string if resolved; otherwise, null. + private static string? ResolveConstantValue(string expression, Compilation compilation) + { + var cleaned = expression.Trim(); + // Remove casts like (string) or (int?) + if (cleaned.StartsWith('(') && cleaned.Contains(')') && cleaned.LastIndexOf(')') < cleaned.Length - 1) + { + var afterCast = cleaned[(cleaned.LastIndexOf(')') + 1)..].Trim(); + if (!string.IsNullOrEmpty(afterCast) && !afterCast.Contains(' ')) + { + cleaned = afterCast; + } + } + + if (string.IsNullOrEmpty(cleaned) || cleaned.Contains('(') || cleaned.Contains(' ')) + { + return null; + } + + var parts = cleaned.Split('.'); + + // Case 1: Simple identifier (e.g., "MyConst") + if (parts.Length == 1) + { + var name = parts[0]; + // Search for any constant field with this name in the compilation + // This might be slow, but it's a fallback. + // Better: search in the current context (but we don't have it easily here) + return compilation.GetSymbolsWithName(name, SymbolFilter.Member) + .OfType() + .FirstOrDefault(f => f.HasConstantValue)?.ConstantValue?.ToString(); + } + + // Case 2: Qualified name (e.g., "MyClass.MyConst" or "Namespace.MyClass.MyConst") + // We start from the right and try to find a type + for (var i = parts.Length - 1; i > 0; i--) + { + var typeName = string.Join(".", parts[..i]); + var memberName = parts[i]; + + // Try searching by name if not fully qualified + var typeSymbol = compilation.GetTypeByMetadataName(typeName) ?? compilation + .GetSymbolsWithName(parts[i - 1], SymbolFilter.Type) + .OfType() + .FirstOrDefault(); + + var member = typeSymbol?.GetMembers(memberName).FirstOrDefault(); + if (member is IFieldSymbol { HasConstantValue: true } field) + { + return field.ConstantValue?.ToString(); + } + } + + return null; + } + /// /// Configures the precision and scale of a given property based on the provided configuration argument. /// @@ -1029,84 +1106,4 @@ private static void ApplyPrecisionConfiguration(EfProperty property, string conf property.Scale = scale; } } - - /// - /// A regex pattern to match entity type names in the format "Entity<TypeName>" or "Entity(\"TypeName\")". - /// - /// A compiled instance for matching entity type names. - [GeneratedRegex("""Entity(?:<([^>]+)>|\("([^"]+)"(?:,\s*[^)]+)?\))""")] - private static partial Regex EntityNameRegex(); - - /// - /// A regex pattern to split a string by occurrences of ".Entity<" or ".Entity(". - /// - /// A compiled instance for splitting strings by ".Entity". - [GeneratedRegex(@"\.Entity(?=[<(])")] - private static partial Regex EntitySplitRegex(); - - /// - /// A regex pattern to match shadow relationships in the format "(HasOne|HasMany)<TypeName>().(WithOne|WithMany)()". - /// - /// A compiled instance for matching shadow relationships. - [GeneratedRegex(@"(HasOne|HasMany)<(\w+)>\(\s*\)\s*\.(WithOne|WithMany)\(\s*\)")] - private static partial Regex ShadowRelationshipRegex(); - - /// - /// A regex pattern to match property lambda expressions in the format "e => e.PropertyName". - /// - /// A compiled instance for matching property lambda expressions. - [GeneratedRegex(@"^\s*\(?\s*(\w+)\s*\)?\s*=>\s*\1\.(\w+)\s*$")] - private static partial Regex PropertyLambdaRegex(); - - /// - /// A regex pattern to match fluent method calls in the format ".MethodName(arguments)". - /// Supports one level of nested parentheses and generic arguments. - /// - /// A compiled instance for matching fluent method calls. - [GeneratedRegex(@"\.(\w+(?:<[^>]+>)?)\(([^()]*(?:\([^()]*\)[^()]*)*)\)")] - private static partial Regex MethodCallRegex(); - - /// - /// A regex pattern to match ToTable configuration in the format ".ToTable("TableName")". - /// - /// A compiled instance for matching ToTable configurations. - [GeneratedRegex("""\.ToTable\(\"([^\"]+)\"\)""")] - private static partial Regex ToTableRegex(); - - /// - /// A regex pattern to match string literals enclosed in double quotes. - /// - /// A compiled instance for matching quoted strings. - /// - /// This pattern captures the content within double quotes, excluding the quotes themselves. - /// Example: In "Hello World", it captures Hello World. - /// - [GeneratedRegex(""" - "([^"]+)" - """)] - private static partial Regex StringLiteralRegex(); - - /// - /// A regex pattern to match method names in a method chain, preceded by a dot. - /// - /// A compiled instance for matching method names in chains. - /// - /// This pattern matches a dot followed by optional whitespace and a word (method name). - /// Example: In .HasMaxLength or . IsRequired, it captures HasMaxLength and IsRequired. - /// Used to parse Fluent API method chains like entity.Property(x => x.Name).HasMaxLength(100).IsRequired(). - /// - [GeneratedRegex(@"\.\s*(\w+)")] - private static partial Regex MethodChainRegex(); - - /// - /// A regex pattern to match numbers enclosed in parentheses. - /// - /// A compiled instance for matching numbers in parentheses. - /// - /// This pattern captures numeric values within parentheses. - /// Example: In nvarchar(30) or decimal(18,2), it captures 30 from the first match. - /// Used to extract length specifications from column type definitions. - /// - [GeneratedRegex(@"\((\d+)\)")] - private static partial Regex NumberInParensRegex(); } \ No newline at end of file diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/ModelSnapshotParser.cs b/src/ProjGraph.Lib/Services/EfAnalysis/ModelSnapshotParser.cs index d3ad189..a144584 100644 --- a/src/ProjGraph.Lib/Services/EfAnalysis/ModelSnapshotParser.cs +++ b/src/ProjGraph.Lib/Services/EfAnalysis/ModelSnapshotParser.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using ProjGraph.Core.Models; +using ProjGraph.Lib.Services.EfAnalysis.Constants; namespace ProjGraph.Lib.Services.EfAnalysis; @@ -10,8 +11,14 @@ namespace ProjGraph.Lib.Services.EfAnalysis; public static class ModelSnapshotParser { /// - /// Parses a ModelSnapshot class to build an EfModel. + /// Parses a ModelSnapshot class to extract the Entity Framework model. /// + /// The representing the ModelSnapshot class. + /// The representing the type of the ModelSnapshot class. + /// The object used for Roslyn analysis. + /// + /// An object representing the parsed Entity Framework model, including its context name and entities. + /// public static EfModel Parse(ClassDeclarationSyntax snapshotClass, INamedTypeSymbol snapshotType, Compilation compilation) { @@ -19,7 +26,7 @@ public static EfModel Parse(ClassDeclarationSyntax snapshotClass, INamedTypeSymb var entities = new Dictionary(); var buildModelMethod = snapshotClass.Members.OfType() - .FirstOrDefault(m => m.Identifier.Text == "BuildModel"); + .FirstOrDefault(m => m.Identifier.Text == EfAnalysisConstants.EfMethods.BuildModel); if (buildModelMethod?.Body is null) { @@ -38,10 +45,19 @@ public static EfModel Parse(ClassDeclarationSyntax snapshotClass, INamedTypeSymb return model; } + /// + /// Extracts the name of the DbContext associated with the given ModelSnapshot type. + /// + /// The representing the ModelSnapshot type. + /// + /// A string representing the name of the DbContext associated with the ModelSnapshot. + /// If the DbContextAttribute is present, its constructor argument is used to determine the name. + /// Otherwise, the method derives the name by removing "ModelSnapshot" from the type name. + /// private static string ExtractContextName(INamedTypeSymbol snapshotType) { var dbContextAttr = snapshotType.GetAttributes() - .FirstOrDefault(a => a.AttributeClass?.Name == "DbContextAttribute"); + .FirstOrDefault(a => a.AttributeClass?.Name == EfAnalysisConstants.EfAttributes.DbContextAttribute); if (dbContextAttr?.ConstructorArguments.Length > 0 && dbContextAttr.ConstructorArguments[0].Value is INamedTypeSymbol contextType) diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/NavigationPropertyAnalyzer.cs b/src/ProjGraph.Lib/Services/EfAnalysis/NavigationPropertyAnalyzer.cs index cd87ff1..92a181e 100644 --- a/src/ProjGraph.Lib/Services/EfAnalysis/NavigationPropertyAnalyzer.cs +++ b/src/ProjGraph.Lib/Services/EfAnalysis/NavigationPropertyAnalyzer.cs @@ -75,34 +75,12 @@ public static bool HasInverseCollection(IPropertySymbol prop, INamedTypeSymbol t var sourceTypeName = prop.ContainingType.Name; return targetType.GetMembers() .OfType() - .Any(p => IsNavigationProperty(p, out var t, out var isColl) && + .Any(p => !SymbolEqualityComparer.Default.Equals(p, prop) && + IsNavigationProperty(p, out var t, out var isColl) && isColl && t?.Name == sourceTypeName); } - /// - /// Finds the name of the inverse collection navigation property in the target type for the specified property. - /// - /// The representing the property to check. - /// The representing the target type to search for an inverse collection. - /// - /// The name of the inverse collection navigation property if found; otherwise, null. - /// - /// - /// This method searches the members of the target type for a property that is a collection navigation property - /// and references the source type of the provided property. If such a property is found, its name is returned. - /// - public static string? FindInverseCollectionName(IPropertySymbol prop, INamedTypeSymbol targetType) - { - var sourceTypeName = prop.ContainingType.Name; - return targetType.GetMembers() - .OfType() - .FirstOrDefault(p => IsNavigationProperty(p, out var t, out var isColl) && - isColl && - t?.Name == sourceTypeName) - ?.Name; - } - /// /// Determines whether the specified property has an inverse reference navigation property in the target type. /// @@ -120,7 +98,8 @@ public static bool HasInverseReference(IPropertySymbol prop, INamedTypeSymbol ta var sourceTypeName = prop.ContainingType.Name; return targetType.GetMembers() .OfType() - .Any(p => IsNavigationProperty(p, out var t, out var isColl) && + .Any(p => !SymbolEqualityComparer.Default.Equals(p, prop) && + IsNavigationProperty(p, out var t, out var isColl) && !isColl && t?.Name == sourceTypeName); } diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/Patterns/EfAnalysisRegexPatterns.cs b/src/ProjGraph.Lib/Services/EfAnalysis/Patterns/EfAnalysisRegexPatterns.cs new file mode 100644 index 0000000..1ee6055 --- /dev/null +++ b/src/ProjGraph.Lib/Services/EfAnalysis/Patterns/EfAnalysisRegexPatterns.cs @@ -0,0 +1,155 @@ +using ProjGraph.Lib.Services.EfAnalysis.Constants; +using System.Text.RegularExpressions; + +namespace ProjGraph.Lib.Services.EfAnalysis.Patterns; + +/// +/// Provides centralized compiled regex patterns for Entity Framework analysis operations. +/// All regex patterns used throughout the EF analysis services are consolidated here for +/// better maintainability and reusability. +/// +public static partial class EfAnalysisRegexPatterns +{ + #region Entity Configuration Patterns + + /// + /// A regex pattern to extract entity names from Entity configuration calls. + /// + /// + /// This pattern matches both generic and string-based entity declarations: + /// - Generic: Entity<EntityName> + /// - String: Entity("EntityName") + /// + [GeneratedRegex(EfAnalysisConstants.FluentApiPatterns.EntityPattern)] + public static partial Regex EntityNameRegex(); + + /// + /// A regex pattern to split configuration sections by Entity calls. + /// + /// + /// This pattern identifies the start of new entity configuration sections. + /// Used to parse OnModelCreating and BuildModel method content. + /// + [GeneratedRegex(EfAnalysisConstants.FluentApiPatterns.EntitySplitPattern)] + public static partial Regex EntitySplitRegex(); + + /// + /// A regex pattern to match Entity configuration calls in EfAnalysisService. + /// + /// + /// This pattern is used to extract entity information from configuration text. + /// Supports both generic and string literal entity declarations. + /// + [GeneratedRegex(EfAnalysisConstants.FluentApiPatterns.EntityMatchPattern)] + public static partial Regex EntityMatchRegex(); + + #endregion + + #region Relationship Patterns + + /// + /// A regex pattern to match shadow relationships in Fluent API configurations. + /// + /// + /// Matches patterns like: HasOne<TypeName>().WithMany() or HasMany<TypeName>().WithOne() + /// Used to identify relationships that don't have explicit navigation properties. + /// + [GeneratedRegex(EfAnalysisConstants.FluentApiPatterns.ShadowRelationshipPattern)] + public static partial Regex ShadowRelationshipRegex(); + + #endregion + + #region Property and Method Patterns + + /// + /// A regex pattern to extract property names from lambda expressions. + /// + /// + /// Matches lambda expressions in the format: (entity) => entity.PropertyName + /// Used to parse Property() method calls in Fluent API configurations. + /// + [GeneratedRegex(EfAnalysisConstants.FluentApiPatterns.LambdaPropertyPattern)] + public static partial Regex PropertyLambdaRegex(); + + /// + /// A regex pattern to match method calls with arguments in method chains. + /// + /// + /// Captures method names and their arguments from Fluent API method chains. + /// Example: .HasMaxLength(100) captures "HasMaxLength" and "100" + /// + [GeneratedRegex(EfAnalysisConstants.FluentApiPatterns.MethodCallPattern)] + public static partial Regex MethodCallRegex(); + + /// + /// A regex pattern to match method names in a method chain, preceded by a dot. + /// + /// + /// This pattern matches a dot followed by optional whitespace and a word (method name). + /// Example: In .HasMaxLength or . IsRequired, it captures HasMaxLength and IsRequired. + /// Used to parse Fluent API method chains like entity.Property(x => x.Name).HasMaxLength(100).IsRequired() + /// + [GeneratedRegex(EfAnalysisConstants.FluentApiPatterns.MethodNamePattern)] + public static partial Regex MethodChainRegex(); + + #endregion + + #region Table and Column Patterns + + /// + /// A regex pattern to extract table names from ToTable() method calls. + /// + /// + /// Matches .ToTable("TableName") calls and extracts the table name. + /// Used to identify custom table names in Fluent API configurations. + /// + [GeneratedRegex(EfAnalysisConstants.FluentApiPatterns.ToTablePattern)] + public static partial Regex ToTableRegex(); + + #endregion + + #region String and Literal Patterns + + /// + /// A regex pattern to extract string literals from configuration text. + /// + /// + /// This pattern captures the content within double quotes, excluding the quotes themselves. + /// Example: In "Hello World", it captures Hello World. + /// + [GeneratedRegex(EfAnalysisConstants.FluentApiPatterns.StringLiteralPattern)] + public static partial Regex StringLiteralRegex(); + + #endregion + + #region Numeric and Argument Patterns + + /// + /// A regex pattern to extract numeric arguments from method calls. + /// + /// + /// Captures numeric values within parentheses. + /// Example: In nvarchar(30) or decimal(18,2), it captures 30 from the first match. + /// + [GeneratedRegex(EfAnalysisConstants.FluentApiPatterns.NumericArgumentPattern)] + public static partial Regex NumberInParensRegex(); + + #endregion + + #region SQL Type Patterns + + /// + /// A regex pattern to match decimal types with precision and scale. + /// + /// + /// Matches decimal(precision, scale) format and captures both precision and scale values. + /// Used to extract precision and scale constraints from ColumnAttribute TypeName arguments. + /// The regex captures two groups: + /// - Group 1: The precision (number of total digits) + /// - Group 2: The scale (number of digits after the decimal point) + /// + [GeneratedRegex(EfAnalysisConstants.SqlTypePatterns.DecimalPattern)] + public static partial Regex DecimalPrecisionRegex(); + + #endregion +} \ No newline at end of file diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/RelationshipAnalyzer.cs b/src/ProjGraph.Lib/Services/EfAnalysis/RelationshipAnalyzer.cs index db0bcb9..f4c1f3a 100644 --- a/src/ProjGraph.Lib/Services/EfAnalysis/RelationshipAnalyzer.cs +++ b/src/ProjGraph.Lib/Services/EfAnalysis/RelationshipAnalyzer.cs @@ -1,5 +1,6 @@ using Microsoft.CodeAnalysis; using ProjGraph.Core.Models; +using ProjGraph.Lib.Services.EfAnalysis.Constants; using ProjGraph.Lib.Services.EfAnalysis.Extensions; namespace ProjGraph.Lib.Services.EfAnalysis; @@ -47,7 +48,7 @@ public static void AnalyzeRelationships( } ConvertManyToManyToJoinTables(model); - + // Remove direct relationships when join tables exist RemoveDirectRelationshipsWithJoinTables(model); } @@ -119,7 +120,7 @@ private static void AnalyzeEntityRelationships( /// /// /// This method initializes a new object with default values, such as the source entity name, - /// target entity name, relationship type, and label. It also determines the specific type of the relationship + /// target entity name and relationship type. It also determines the specific type of the relationship /// (e.g., One-to-One, One-to-Many, Many-to-Many) by delegating to the method. /// private static EfRelationship CreateRelationship( @@ -134,7 +135,6 @@ private static EfRelationship CreateRelationship( SourceEntity = sourceEntity.Name, TargetEntity = targetEntity.Name, Type = EfRelationshipType.OneToOne, - Label = prop.Name, IsRequired = !prop.Type.IsNullable() }; @@ -147,7 +147,8 @@ private static void MarkConventionForeignKey(EfEntity entity, string navigationN { var potentialNames = new HashSet(StringComparer.OrdinalIgnoreCase) { - $"{navigationName}Id", $"{targetEntityName}Id" + $"{navigationName}{EfAnalysisConstants.Suffixes.IdSuffix}", + $"{targetEntityName}{EfAnalysisConstants.Suffixes.IdSuffix}" }; foreach (var prop in entity.Properties.Where(prop => potentialNames.Contains(prop.Name))) @@ -217,7 +218,7 @@ private static void HandleCollectionNavigation( /// The representing the type of the target entity. /// /// This method determines the type of relationship (e.g., One-to-One, One-to-Many) based on the navigation property - /// and its inverse. It updates the relationship object with the appropriate type, source, target, and label. + /// and its inverse. It updates the relationship object with the appropriate type, source, and target. /// private static void HandleReferenceNavigation( EfRelationship relationship, @@ -232,10 +233,10 @@ private static void HandleReferenceNavigation( relationship.Type = EfRelationshipType.OneToMany; relationship.SourceEntity = targetEntity.Name; relationship.TargetEntity = sourceEntity.Name; - relationship.Label = NavigationPropertyAnalyzer.FindInverseCollectionName(prop, targetType) ?? ""; } else if (NavigationPropertyAnalyzer.HasInverseReference(prop, targetType)) { + // If it has an inverse reference (including self-references with an explicit inverse), treat as One-to-One relationship.Type = EfRelationshipType.OneToOne; } else @@ -263,11 +264,12 @@ private static string GenerateRelationshipKey(EfRelationship relationship) var entitiesSorted = new[] { relationship.SourceEntity, relationship.TargetEntity } .OrderBy(e => e) .ToArray(); - return $"{entitiesSorted[0]}-{entitiesSorted[1]}-{relationship.Type}"; + return + $"{entitiesSorted[0]}{EfAnalysisConstants.RelationshipKeys.Delimiter}{entitiesSorted[1]}{EfAnalysisConstants.RelationshipKeys.Delimiter}{relationship.Type}"; } - // For OneToMany, direction matters - return $"{relationship.SourceEntity}-{relationship.TargetEntity}-{relationship.Type}"; + return + $"{relationship.SourceEntity}{EfAnalysisConstants.RelationshipKeys.Delimiter}{relationship.TargetEntity}{EfAnalysisConstants.RelationshipKeys.Delimiter}{relationship.Type}"; } /// @@ -309,13 +311,13 @@ private static void ConvertManyToManyToJoinTables(EfModel model) private static string GetPrimaryKeyType(EfEntity? entity) { - if (entity == null) + if (entity is null) { - return "int"; + return EfAnalysisConstants.DataTypes.Int; } var pk = entity.Properties.FirstOrDefault(p => p.IsPrimaryKey); - return pk?.Type ?? "int"; + return pk?.Type ?? EfAnalysisConstants.DataTypes.Int; } /// @@ -343,11 +345,19 @@ private static EfEntity CreateJoinEntity(string joinTableName, EfRelationship m2 [ new EfProperty { - Name = $"{m2m.SourceEntity}Id", Type = sourcePkType, IsPrimaryKey = true, IsForeignKey = true + Name = $"{m2m.SourceEntity}{EfAnalysisConstants.Suffixes.IdSuffix}", + Type = sourcePkType, + IsPrimaryKey = true, + IsForeignKey = true, + IsValueType = true // ID fields are always value types (int, Guid, etc.) }, new EfProperty { - Name = $"{m2m.TargetEntity}Id", Type = targetPkType, IsPrimaryKey = true, IsForeignKey = true + Name = $"{m2m.TargetEntity}{EfAnalysisConstants.Suffixes.IdSuffix}", + Type = targetPkType, + IsPrimaryKey = true, + IsForeignKey = true, + IsValueType = true } ] }; @@ -370,8 +380,7 @@ private static void AddJoinTableRelationships(EfModel model, string joinTableNam SourceEntity = m2m.SourceEntity, TargetEntity = joinTableName, Type = EfRelationshipType.OneToMany, - IsRequired = true, - Label = "" + IsRequired = true }); model.Relationships.Add(new EfRelationship @@ -379,76 +388,69 @@ private static void AddJoinTableRelationships(EfModel model, string joinTableNam SourceEntity = m2m.TargetEntity, TargetEntity = joinTableName, Type = EfRelationshipType.OneToMany, - IsRequired = true, - Label = "" + IsRequired = true }); } /// - /// Removes direct relationships between entities when a join table exists for that relationship. - /// For example, if Group has Permissions navigation but GroupPermissionEntry join table exists, - /// remove the direct Group->PermissionEntry relationship. + /// Removes direct relationships between entities that are already connected through join tables. /// + /// The representing the entity framework model. + /// + /// This method identifies join tables in the model, determines the entities they connect, and removes any direct relationships + /// between those entities. A join table is identified as an entity with exactly two foreign key properties, which are also primary keys. + /// private static void RemoveDirectRelationshipsWithJoinTables(EfModel model) { var joinTables = model.Entities.Where(e => e.IsJoinEntity || IsJoinTable(e)).ToList(); - var relationshipsToRemove = new List(); - - foreach (var joinTable in joinTables) - { - // Get the two FK properties of the join table - var fkProperties = joinTable.Properties.Where(p => p.IsForeignKey).ToList(); - if (fkProperties.Count != 2) - { - continue; - } - // Extract entity names from FK property names (e.g., "GroupId" -> "Group") - var entityNames = fkProperties - .Select(fk => fk.Name.EndsWith("Id", StringComparison.OrdinalIgnoreCase) - ? fk.Name.Substring(0, fk.Name.Length - 2) - : null) + var relationshipsToRemove = joinTables + .Select(joinTable => joinTable.Properties.Where(p => p.IsForeignKey).ToList()) + .Where(fkProperties => fkProperties.Count == 2) + .Select(fkProperties => fkProperties + .Select(fk => + fk.Name.EndsWith(EfAnalysisConstants.Suffixes.IdSuffix, StringComparison.OrdinalIgnoreCase) + ? fk.Name[..^2] + : null) .Where(name => name != null) - .ToList(); - - if (entityNames.Count != 2) + .ToList()) + .Where(entityNames => entityNames.Count == 2) + .Select(entityNames => { - continue; - } - - var entity1 = entityNames[0]!; - var entity2 = entityNames[1]!; - - // Find and mark for removal any direct relationships between these entities - var directRelationships = model.Relationships - .Where(r => + var entity1 = entityNames[0]!; + var entity2 = entityNames[1]!; + return model.Relationships.Where(r => (r.SourceEntity == entity1 && r.TargetEntity == entity2) || - (r.SourceEntity == entity2 && r.TargetEntity == entity1)) - .ToList(); - - relationshipsToRemove.AddRange(directRelationships); - } + (r.SourceEntity == entity2 && r.TargetEntity == entity1)); + }) + .SelectMany(relationships => relationships) + .Distinct() + .ToList(); // Remove the direct relationships - foreach (var rel in relationshipsToRemove.Distinct()) + foreach (var rel in relationshipsToRemove) { model.Relationships.Remove(rel); } } /// - /// Determines if an entity is a join table based on naming convention and structure. - /// A join table typically has exactly 2 FK properties that are also PKs. + /// Determines if the given entity is a join table. /// + /// The to evaluate. + /// + /// A boolean value indicating whether the entity is a join table. + /// A join table is defined as an entity that has exactly two foreign key properties, + /// which are also primary keys. + /// private static bool IsJoinTable(EfEntity entity) { var fkProperties = entity.Properties.Where(p => p.IsForeignKey).ToList(); var pkProperties = entity.Properties.Where(p => p.IsPrimaryKey).ToList(); // A join table should have exactly 2 FKs that are also PKs - return fkProperties.Count == 2 && + return fkProperties.Count == 2 && pkProperties.Count == 2 && fkProperties.All(fk => fk.IsPrimaryKey); } -} - +} \ No newline at end of file diff --git a/src/ProjGraph.Lib/Services/GraphService.cs b/src/ProjGraph.Lib/Services/GraphService.cs index 725c202..e73d8fe 100644 --- a/src/ProjGraph.Lib/Services/GraphService.cs +++ b/src/ProjGraph.Lib/Services/GraphService.cs @@ -189,12 +189,12 @@ private sealed class PathEqualityComparer : IEqualityComparer /// public bool Equals(string? x, string? y) { - if (x == null && y == null) + if (x is null && y is null) { return true; } - if (x == null || y == null) + if (x is null || y is null) { return false; } diff --git a/tests/ProjGraph.Tests.Integration/Cli/ErdCommandTests.cs b/tests/ProjGraph.Tests.Integration/Cli/ErdCommandTests.cs index 4cae622..f155a17 100644 --- a/tests/ProjGraph.Tests.Integration/Cli/ErdCommandTests.cs +++ b/tests/ProjGraph.Tests.Integration/Cli/ErdCommandTests.cs @@ -57,6 +57,41 @@ public void ErdCommand_SimpleContext_WithContextName_ShouldSucceed() capturedOutput.Should().Contain("Book"); } + [Fact] + public void ErdCommand_SimpleContext_ShouldNotContainIrrelevantFields() + { + // Arrange + var app = CliTestHelpers.CreateApp(); + var contextPath = CliTestHelpers.GetSamplePath(@"erd\simple-context\EntityFramework\MyDbContext.cs"); + + // Act + var capturedOutput = CliTestHelpers.CaptureConsoleOutput(() => + { + var result = app.Run(["erd", contextPath]); + result.Should().Be(0); + }); + + // Assert + // Irrelevant fields should not be present in Book entity + capturedOutput.Should().NotContain("Guid AuthorId FK"); + capturedOutput.Should().NotContain("Guid BookId FK"); + capturedOutput.Should().NotContain("Guid CategoryId FK"); + + // Ensure standard fields are still there + capturedOutput.Should().Contain("int PublisherId FK"); + + // Ensure no self-referencing Book which was caused by misparsing UsingEntity + capturedOutput.Should().NotContain("Book ||--o{ Book : \"\""); + + // Ensure junction tables and their relationships are present + capturedOutput.Should().Contain("AuthorBook {"); + capturedOutput.Should().Contain("BookCategory {"); + capturedOutput.Should().Contain("Author ||--o{ AuthorBook : \"\""); + capturedOutput.Should().Contain("Book ||--o{ AuthorBook : \"\""); + capturedOutput.Should().Contain("Book ||--o{ BookCategory : \"\""); + capturedOutput.Should().Contain("Category ||--o{ BookCategory : \"\""); + } + [Fact] public void ErdCommand_NonCsFile_ShouldFail() { diff --git a/tests/ProjGraph.Tests.Integration/Cli/MarkdownErdTests.cs b/tests/ProjGraph.Tests.Integration/Cli/MarkdownErdTests.cs new file mode 100644 index 0000000..94b8e33 --- /dev/null +++ b/tests/ProjGraph.Tests.Integration/Cli/MarkdownErdTests.cs @@ -0,0 +1,54 @@ +using FluentAssertions; +using ProjGraph.Tests.Integration.Helpers; +using System.Text.RegularExpressions; + +namespace ProjGraph.Tests.Integration.Cli; + +[Collection("CLI Tests")] +public partial class MarkdownErdTests +{ + [Fact] + public void ErdCommand_SimpleContext_ReadmeOutput_ShouldMatchActualOutput() + { + // Arrange + var app = CliTestHelpers.CreateApp(); + var contextPath = CliTestHelpers.GetSamplePath(@"erd\simple-context\EntityFramework\MyDbContext.cs"); + var readmePath = CliTestHelpers.GetSamplePath(@"erd\simple-context\README.md"); + + // Act + var capturedOutput = CliTestHelpers.CaptureConsoleOutput(() => + { + var result = app.Run(["erd", contextPath]); + result.Should().Be(0); + }); + + // Parse README for expected mermaid block + var readmeContent = File.ReadAllText(readmePath); + var expectedMermaid = ExtractMermaidBlock(readmeContent); + + // Normalize line endings for comparison + var normalizedActual = Normalize(capturedOutput); + var normalizedExpected = Normalize(expectedMermaid); + + // Assert + normalizedActual.Should().Contain(normalizedExpected, + "The generated ERD should match the example documentation in README.md"); + } + + private static string ExtractMermaidBlock(string content) + { + var match = ExtractMermaidRegex().Match(content); + return match.Success ? match.Groups[1].Value : string.Empty; + } + + private static string Normalize(string input) + { + return NormalizeRegex().Replace(input, "\n").Trim(); + } + + [GeneratedRegex(@"```mermaid\s+([\s\S]*?)\s+```")] + private static partial Regex ExtractMermaidRegex(); + + [GeneratedRegex(@"\r\n|\n|\r")] + private static partial Regex NormalizeRegex(); +} \ No newline at end of file diff --git a/tests/ProjGraph.Tests.Unit/Rendering/MermaidErdRendererTests.cs b/tests/ProjGraph.Tests.Unit/Rendering/MermaidErdRendererTests.cs index a5a6134..dfded70 100644 --- a/tests/ProjGraph.Tests.Unit/Rendering/MermaidErdRendererTests.cs +++ b/tests/ProjGraph.Tests.Unit/Rendering/MermaidErdRendererTests.cs @@ -129,7 +129,7 @@ public void Render_ShouldIncludeRequiredConstraint() Properties = [ new EfProperty { Name = "Id", Type = "int", IsPrimaryKey = true }, - new EfProperty { Name = "Email", Type = "string", IsRequired = true } + new EfProperty { Name = "Email", Type = "string", IsRequired = true, IsExplicitlyRequired = true } ] }; @@ -277,7 +277,7 @@ public void Render_ShouldRenderOneToOneRelationship() SourceEntity = "User", TargetEntity = "Profile", Type = EfRelationshipType.OneToOne, - Label = "has" + IsRequired = true } ] }; @@ -286,7 +286,7 @@ public void Render_ShouldRenderOneToOneRelationship() var result = MermaidErdRenderer.Render(model); // Assert - result.Should().Contain("User ||--|| Profile"); + result.Should().Contain("User ||--|| Profile : \"\""); } [Fact] @@ -308,7 +308,6 @@ public void Render_ShouldRenderOneToManyRelationship() SourceEntity = "Customer", TargetEntity = "Order", Type = EfRelationshipType.OneToMany, - Label = "places", IsRequired = true } ] @@ -318,7 +317,7 @@ public void Render_ShouldRenderOneToManyRelationship() var result = MermaidErdRenderer.Render(model); // Assert - result.Should().Contain("Customer ||--o{ Order"); + result.Should().Contain("Customer ||--o{ Order : \"\""); } [Fact] @@ -340,7 +339,6 @@ public void Render_ShouldRenderOptionalOneToManyRelationship() SourceEntity = "Category", TargetEntity = "Product", Type = EfRelationshipType.OneToMany, - Label = "contains", IsRequired = false } ] @@ -350,7 +348,7 @@ public void Render_ShouldRenderOptionalOneToManyRelationship() var result = MermaidErdRenderer.Render(model); // Assert - result.Should().Contain("Category |o--o{ Product"); + result.Should().Contain("Category |o--o{ Product : \"\""); } [Fact] @@ -371,8 +369,7 @@ public void Render_ShouldRenderManyToManyRelationship() { SourceEntity = "Student", TargetEntity = "Course", - Type = EfRelationshipType.ManyToMany, - Label = "enrolls" + Type = EfRelationshipType.ManyToMany } ] }; @@ -381,7 +378,7 @@ public void Render_ShouldRenderManyToManyRelationship() var result = MermaidErdRenderer.Render(model); // Assert - result.Should().Contain("Student }|--|{ Course"); + result.Should().Contain("Student }|--|{ Course : \"\""); } [Fact] @@ -421,7 +418,6 @@ public void Render_ShouldHandleMultipleEntitiesAndRelationships() SourceEntity = "User", TargetEntity = "Post", Type = EfRelationshipType.OneToMany, - Label = "creates", IsRequired = true }, @@ -430,7 +426,6 @@ public void Render_ShouldHandleMultipleEntitiesAndRelationships() SourceEntity = "Post", TargetEntity = "Comment", Type = EfRelationshipType.OneToMany, - Label = "has", IsRequired = true } ] @@ -443,7 +438,7 @@ public void Render_ShouldHandleMultipleEntitiesAndRelationships() result.Should().Contain("User {"); result.Should().Contain("Post {"); result.Should().Contain("Comment {"); - result.Should().Contain("User ||--o{ Post"); - result.Should().Contain("Post ||--o{ Comment"); + result.Should().Contain("User ||--o{ Post : \"\""); + result.Should().Contain("Post ||--o{ Comment : \"\""); } } \ No newline at end of file diff --git a/tests/ProjGraph.Tests.Unit/Services/EfAnalysis/ConstStringDefaultValueTests.cs b/tests/ProjGraph.Tests.Unit/Services/EfAnalysis/ConstStringDefaultValueTests.cs new file mode 100644 index 0000000..dd9ff69 --- /dev/null +++ b/tests/ProjGraph.Tests.Unit/Services/EfAnalysis/ConstStringDefaultValueTests.cs @@ -0,0 +1,181 @@ +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.EntityFrameworkCore; +using ProjGraph.Core.Models; +using ProjGraph.Lib.Services.EfAnalysis; + +namespace ProjGraph.Tests.Unit.Services.EfAnalysis; + +public class ConstStringDefaultValueTests +{ + [Fact] + public void AnalyzeContextAsync_ShouldUseConstStringValueForDefaultValue() + { + // Arrange + const string content = """ + using Microsoft.EntityFrameworkCore; + using System; + + namespace TestNamespace + { + public static class AppConstants + { + public const string DefaultRole = "GuestUser"; + } + + public class User + { + public Guid Id { get; set; } + public string Role { get; set; } + } + + public class AppDbContext : DbContext + { + public DbSet Users { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .Property(u => u.Role) + .HasDefaultValue(AppConstants.DefaultRole); + } + } + } + """; + + var syntaxTree = CSharpSyntaxTree.ParseText(content); + var compilation = CSharpCompilation.Create("TestAssembly") + .AddReferences(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)) + .AddReferences( + MetadataReference.CreateFromFile(typeof(DbContext).Assembly.Location)) + .AddSyntaxTrees(syntaxTree); + + var contextType = compilation.GetTypeByMetadataName("TestNamespace.AppDbContext"); + contextType.Should().NotBeNull(); + var entities = new Dictionary(); + var model = new EfModel { ContextName = "AppDbContext" }; + + // Act + FluentApiConfigurationParser.ApplyFluentApiConstraints(contextType, entities, model, compilation); + + // Assert + var user = model.Entities.Should().Contain(e => e.Name == "User").Which; + var role = user.Properties.Should().Contain(p => p.Name == "Role").Which; + + role.DefaultValue.Should().Be("GuestUser"); + } + + [Fact] + public void AnalyzeContextAsync_ShouldUseConstStringValueForNestedConstDefaultValue() + { + // Arrange + const string content = """ + using Microsoft.EntityFrameworkCore; + using System; + + namespace TestNamespace + { + public static class Outer + { + public static class Inner + { + public const string DefaultRole = "NestedGuest"; + } + } + + public class User + { + public Guid Id { get; set; } + public string Role { get; set; } + } + + public class AppDbContext : DbContext + { + public DbSet Users { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .Property(u => u.Role) + .HasDefaultValue(Outer.Inner.DefaultRole); + } + } + } + """; + + var syntaxTree = CSharpSyntaxTree.ParseText(content); + var compilation = CSharpCompilation.Create("TestAssembly") + .AddReferences(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)) + .AddReferences( + MetadataReference.CreateFromFile(typeof(DbContext).Assembly.Location)) + .AddSyntaxTrees(syntaxTree); + + var contextType = compilation.GetTypeByMetadataName("TestNamespace.AppDbContext"); + contextType.Should().NotBeNull(); + var entities = new Dictionary(); + var model = new EfModel { ContextName = "AppDbContext" }; + + // Act + FluentApiConfigurationParser.ApplyFluentApiConstraints(contextType, entities, model, compilation); + + // Assert + var user = model.Entities.Should().Contain(e => e.Name == "User").Which; + var role = user.Properties.Should().Contain(p => p.Name == "Role").Which; + + role.DefaultValue.Should().Be("NestedGuest"); + } + + [Fact] + public void AnalyzeContextAsync_ShouldUseConstStringValueForSimpleIdentifierDefaultValue() + { + // Arrange + const string content = """ + using Microsoft.EntityFrameworkCore; + using System; + + namespace TestNamespace + { + public class User + { + public Guid Id { get; set; } + public string Role { get; set; } + } + + public class AppDbContext : DbContext + { + public const string DefaultRole = "SimpleGuest"; + public DbSet Users { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .Property(u => u.Role) + .HasDefaultValue(DefaultRole); + } + } + } + """; + + var syntaxTree = CSharpSyntaxTree.ParseText(content); + var compilation = CSharpCompilation.Create("TestAssembly") + .AddReferences(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)) + .AddReferences( + MetadataReference.CreateFromFile(typeof(DbContext).Assembly.Location)) + .AddSyntaxTrees(syntaxTree); + + var contextType = compilation.GetTypeByMetadataName("TestNamespace.AppDbContext"); + contextType.Should().NotBeNull(); + var entities = new Dictionary(); + var model = new EfModel { ContextName = "AppDbContext" }; + + // Act + FluentApiConfigurationParser.ApplyFluentApiConstraints(contextType, entities, model, compilation); + + // Assert + var user = model.Entities.Should().Contain(e => e.Name == "User").Which; + var role = user.Properties.Should().Contain(p => p.Name == "Role").Which; + + role.DefaultValue.Should().Be("SimpleGuest"); + } +} \ No newline at end of file diff --git a/tests/ProjGraph.Tests.Unit/Services/EfAnalysis/EfAnalysisAdvancedTests.cs b/tests/ProjGraph.Tests.Unit/Services/EfAnalysis/EfAnalysisAdvancedTests.cs index a126cbd..67194b7 100644 --- a/tests/ProjGraph.Tests.Unit/Services/EfAnalysis/EfAnalysisAdvancedTests.cs +++ b/tests/ProjGraph.Tests.Unit/Services/EfAnalysis/EfAnalysisAdvancedTests.cs @@ -327,7 +327,8 @@ public void MermaidErdRenderer_ShouldRenderShortenedDefaultValues() Name = "AiProviderName", Type = "string", DefaultValue = "AzureOpenAi", - IsRequired = true + IsRequired = true, + IsExplicitlyRequired = true } ] } @@ -485,4 +486,35 @@ public class GroupUser { model.Entities.Count(e => e.Name == "Group").Should().Be(1, "Group should appear only once"); model.Entities.Count(e => e.Name == "User").Should().Be(1, "User should appear only once"); } + + [Fact] + public async Task AnalyzeContextAsync_ShouldHandleSelfReferencingEntity() + { + // Arrange + using var temp = new TestDirectory(); + const string content = """ + using Microsoft.EntityFrameworkCore; + using System; + namespace Test; + public class AppDbContext : DbContext + { + public DbSet Permissions { get; set; } + } + public class PermissionEntry { + public Guid Id { get; set; } + public Guid? ParentPermissionId { get; set; } + public virtual PermissionEntry? ParentPermission { get; set; } + } + """; + var filePath = temp.CreateFile("SelfRef.cs", content); + + // Act + var model = await _service.AnalyzeContextAsync(filePath, "AppDbContext"); + + // Assert + var rel = model.Relationships.Should().ContainSingle().Which; + rel.SourceEntity.Should().Be("PermissionEntry"); + rel.TargetEntity.Should().Be("PermissionEntry"); + rel.Type.Should().Be(EfRelationshipType.OneToMany); + } } \ No newline at end of file diff --git a/tests/ProjGraph.Tests.Unit/Services/EfAnalysis/EfAnalysisServiceSnapshotTests.cs b/tests/ProjGraph.Tests.Unit/Services/EfAnalysis/EfAnalysisServiceSnapshotTests.cs index 5c0e4a7..a1b3c81 100644 --- a/tests/ProjGraph.Tests.Unit/Services/EfAnalysis/EfAnalysisServiceSnapshotTests.cs +++ b/tests/ProjGraph.Tests.Unit/Services/EfAnalysis/EfAnalysisServiceSnapshotTests.cs @@ -136,4 +136,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) rel.TargetEntity.Should().Be("Post"); rel.Type.Should().Be(EfRelationshipType.OneToMany); } -} + + [Fact] + public async Task AnalyzeSnapshotAsync_WithCompositeKey_ShouldNotIncludeCommaAsProperty() + { + // Arrange + using var temp = new TestDirectory(); + const string content = """ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Infrastructure; + using Microsoft.EntityFrameworkCore.Metadata; + + [DbContext(typeof(TestDbContext))] + partial class TestDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { + modelBuilder.Entity("Test.OrderItem", b => + { + b.Property("OrderId"); + b.Property("ProductId"); + b.HasKey("OrderId", "ProductId"); + b.ToTable("OrderItems"); + }); + } + } + """; + var filePath = temp.CreateFile("Snapshot.cs", content); + + // Act + var model = await _service.AnalyzeSnapshotAsync(filePath, "TestDbContextModelSnapshot"); + + // Assert + var entity = model.Entities.Should().ContainSingle(e => e.Name == "OrderItem").Which; + entity.Properties.Should().HaveCount(2); + entity.Properties.Should().Contain(p => p.Name == "OrderId"); + entity.Properties.Should().Contain(p => p.Name == "ProductId"); + entity.Properties.Should().NotContain(p => p.Name.Contains(',')); + } +} \ No newline at end of file diff --git a/tests/ProjGraph.Tests.Unit/Services/EfAnalysis/EnumDefaultValueTests.cs b/tests/ProjGraph.Tests.Unit/Services/EfAnalysis/EnumDefaultValueTests.cs new file mode 100644 index 0000000..dbca224 --- /dev/null +++ b/tests/ProjGraph.Tests.Unit/Services/EfAnalysis/EnumDefaultValueTests.cs @@ -0,0 +1,128 @@ +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.EntityFrameworkCore; +using ProjGraph.Core.Models; +using ProjGraph.Lib.Services.EfAnalysis; + +namespace ProjGraph.Tests.Unit.Services.EfAnalysis; + +public class EnumDefaultValueTests +{ + [Fact] + public void AnalyzeContextAsync_ShouldUseEnumValueForDefaultValue() + { + // Arrange + const string content = """ + using Microsoft.EntityFrameworkCore; + using System; + + namespace TestNamespace + { + public enum UserStatus + { + Inactive = 0, + Active = 1, + Pending = 2 + } + + public class User + { + public Guid Id { get; set; } + public UserStatus Status { get; set; } + } + + public class AppDbContext : DbContext + { + public DbSet Users { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .Property(u => u.Status) + .HasDefaultValue(UserStatus.Active); + } + } + } + """; + + // We need to create a temporary file or use a compilation directly. + // EfAnalysisService.AnalyzeContextAsync takes a project path or a context name. + // For unit tests, we usually use compilation and ModelSnapshotParser or similar. + + var syntaxTree = CSharpSyntaxTree.ParseText(content); + var compilation = CSharpCompilation.Create("TestAssembly") + .AddReferences(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)) + .AddReferences( + MetadataReference.CreateFromFile(typeof(DbContext).Assembly.Location)) + .AddSyntaxTrees(syntaxTree); + + var contextType = compilation.GetTypeByMetadataName("TestNamespace.AppDbContext"); + contextType.Should().NotBeNull(); + var entities = new Dictionary(); + var model = new EfModel { ContextName = "AppDbContext" }; + + // Act + FluentApiConfigurationParser.ApplyFluentApiConstraints(contextType, entities, model, compilation); + + // Assert + var user = model.Entities.Should().Contain(e => e.Name == "User").Which; + var status = user.Properties.Should().Contain(p => p.Name == "Status").Which; + + // This is what failing currently: it returns "Active" but we want "1" + status.DefaultValue.Should().Be("1"); + } + + [Fact] + public void AnalyzeContextAsync_ShouldStillShortenNamesWhenNotResolvable() + { + // Arrange + const string content = """ + using Microsoft.EntityFrameworkCore; + using System; + + namespace TestNamespace + { + public class User + { + public Guid Id { get; set; } + public string Role { get; set; } + } + + public class AppDbContext : DbContext + { + public DbSet Users { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .Property(u => u.Role) + .HasDefaultValue(UnknownNamespace.Roles.Guest); + } + } + } + """; + + var syntaxTree = CSharpSyntaxTree.ParseText(content); + var compilation = CSharpCompilation.Create("TestAssembly") + .AddReferences(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)) + .AddReferences( + MetadataReference.CreateFromFile(typeof(DbContext).Assembly.Location)) + .AddSyntaxTrees(syntaxTree); + + var contextType = compilation.GetTypeByMetadataName("TestNamespace.AppDbContext"); + contextType.Should().NotBeNull(); + var entities = new Dictionary(); + var model = new EfModel { ContextName = "AppDbContext" }; + + // Act + FluentApiConfigurationParser.ApplyFluentApiConstraints(contextType, entities, model, compilation); + + // Assert + var user = model.Entities.Should().Contain(e => e.Name == "User").Which; + var role = user.Properties.Should().Contain(p => p.Name == "Role").Which; + + // Should still shorten to "Guest" even if not resolvable + role.DefaultValue.Should().Be("Guest"); + } +} \ No newline at end of file