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