From 5e27dc285209c9a2d6b7882600efba5a660ffb85 Mon Sep 17 00:00:00 2001 From: Hamid Mayeli Date: Thu, 3 Sep 2026 09:25:42 +0100 Subject: [PATCH 1/3] Fix AutoDto issues with Suffix. --- src/Gener8/PropertyDataBuilder.cs | 5 ++- src/Gener8/SyntaxTransformer.cs | 29 ++++++++++----- tests/Gener8.Tests/SuffixedDtoNameTests.cs | 43 ++++++++++++++++++++++ 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/src/Gener8/PropertyDataBuilder.cs b/src/Gener8/PropertyDataBuilder.cs index 763a8e4..47fcac9 100644 --- a/src/Gener8/PropertyDataBuilder.cs +++ b/src/Gener8/PropertyDataBuilder.cs @@ -13,7 +13,8 @@ internal sealed class PropertyDataBuilder( INamedTypeSymbol modelSymbol, RepositoryKind repositoryKind, IReadOnlyCollection? qualifyingNamespaces = null, - IReadOnlyCollection? ignoredTypeMappings = null) + IReadOnlyCollection? ignoredTypeMappings = null, + string dtoSuffix = "Dto") { private readonly List _autoTargetSymbols = []; private readonly List _alreadyNullablePropertyNames = []; @@ -173,7 +174,7 @@ private void TryAddInferredMapping( if (typeMappings.ContainsKey(key)) return; if (ignoredTypeMappings?.Contains(key) == true) return; - typeMappings[key] = namedType.Name + "Dto"; + typeMappings[key] = namedType.Name + dtoSuffix; _autoTargetSymbols.Add((INamedTypeSymbol)originalDef); } diff --git a/src/Gener8/SyntaxTransformer.cs b/src/Gener8/SyntaxTransformer.cs index 73b04e1..e6bc566 100644 --- a/src/Gener8/SyntaxTransformer.cs +++ b/src/Gener8/SyntaxTransformer.cs @@ -54,8 +54,9 @@ public static bool IsPartialClassWithAttributes(SyntaxNode node) var modelFullName = modelSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var qualifyingNamespaces = GetQualifyingNamespaces(attr, modelSymbol); var ignoredTypeMappings = GetIgnoredTypeMappings(classSymbol); + var dtoSuffix = GetDtoSuffix(classSymbol.Name, modelSymbol.Name); - var builder = new PropertyDataBuilder(classSymbol, attr, modelSymbol, repositoryKind, qualifyingNamespaces, ignoredTypeMappings); + var builder = new PropertyDataBuilder(classSymbol, attr, modelSymbol, repositoryKind, qualifyingNamespaces, ignoredTypeMappings, dtoSuffix); var properties = builder.GetProperties(); if (builder.AlreadyNullablePropertyNames.Count > 0) @@ -82,7 +83,7 @@ public static bool IsPartialClassWithAttributes(SyntaxNode node) return new ClassTargetResult(null, errors); } - var autoTargets = BuildAutoTargets(builder.AutoTargetSymbols, ns, accessibility, qualifyingNamespaces, repositoryKind, ignoredTypeMappings); + var autoTargets = BuildAutoTargets(builder.AutoTargetSymbols, ns, accessibility, qualifyingNamespaces, repositoryKind, ignoredTypeMappings, dtoSuffix); var target = new TargetClass( classSymbol.Name, @@ -129,11 +130,12 @@ private static IReadOnlyCollection BuildAutoTargets( string accessibility, IReadOnlyCollection qualifyingNamespaces, RepositoryKind repositoryKind, - IReadOnlyCollection ignoredTypeMappings) + IReadOnlyCollection ignoredTypeMappings, + string dtoSuffix) { var result = new List(); var visited = new HashSet(); - CollectAutoTargets(symbols, targetNs, accessibility, qualifyingNamespaces, repositoryKind, ignoredTypeMappings, visited, result); + CollectAutoTargets(symbols, targetNs, accessibility, qualifyingNamespaces, repositoryKind, ignoredTypeMappings, dtoSuffix, visited, result); return result; } @@ -144,6 +146,7 @@ private static void CollectAutoTargets( IReadOnlyCollection qualifyingNamespaces, RepositoryKind repositoryKind, IReadOnlyCollection ignoredTypeMappings, + string dtoSuffix, HashSet visited, List result) { @@ -152,16 +155,16 @@ private static void CollectAutoTargets( var key = symbol.ToDisplayString(); if (!visited.Add(key)) continue; - var dtoName = symbol.Name + "Dto"; + var dtoName = symbol.Name + dtoSuffix; var modelFullName = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); // No classSymbol/attribute for synthesised DTOs — all options default to empty/false. - // Propagate repositoryKind and ignoredTypeMappings so ignore rules apply transitively. - var builder = new PropertyDataBuilder(null, null, symbol, repositoryKind, qualifyingNamespaces, ignoredTypeMappings); + // Propagate repositoryKind, ignoredTypeMappings, and dtoSuffix so child DTOs inherit the same suffix. + var builder = new PropertyDataBuilder(null, null, symbol, repositoryKind, qualifyingNamespaces, ignoredTypeMappings, dtoSuffix); var props = builder.GetProperties(); // Depth-first: add nested auto-targets before this one so dependencies come first. - CollectAutoTargets(builder.AutoTargetSymbols, targetNs, accessibility, qualifyingNamespaces, repositoryKind, ignoredTypeMappings, visited, result); + CollectAutoTargets(builder.AutoTargetSymbols, targetNs, accessibility, qualifyingNamespaces, repositoryKind, ignoredTypeMappings, dtoSuffix, visited, result); result.Add(new TargetClass( dtoName, @@ -171,7 +174,7 @@ private static void CollectAutoTargets( new ModelClass(modelFullName, symbol.Name, GetPrimaryConstructorParams(symbol)), repositoryKind, [], - "ToDto")); + ComputeToDtoMethodName(dtoName, symbol.Name))); } } @@ -194,6 +197,14 @@ private static string ComputeToDtoMethodName(string dtoClassName, string modelNa return "ToDto"; } + // Returns the raw suffix to append to auto-generated child DTO names. + // "OrderView"/"Order" → "View"; "OrderDto"/"Order" → "Dto"; no-match → "Dto". + private static string GetDtoSuffix(string dtoClassName, string modelName) + { + var method = ComputeToDtoMethodName(dtoClassName, modelName); + return method.Substring(2); // strip "To": "ToView" → "View", "ToDto" → "Dto" + } + // Returns ordered property names (in constructor parameter order) when the model type has a // non-implicit constructor whose parameters all resolve to public properties. Supports both // records (PascalCase params) and regular classes (camelCase params capitalized to match props). diff --git a/tests/Gener8.Tests/SuffixedDtoNameTests.cs b/tests/Gener8.Tests/SuffixedDtoNameTests.cs index 82607fa..1b17b7a 100644 --- a/tests/Gener8.Tests/SuffixedDtoNameTests.cs +++ b/tests/Gener8.Tests/SuffixedDtoNameTests.cs @@ -93,6 +93,49 @@ public partial class OrderSummary { } Assert.Contains("ToModel(this OrderSummary", source); } + [Fact] + public void AutoDtoPropertyFollowTheSameSuffix() + { + var results = GeneratorDriver.Run(""" + using Gener8; + namespace MyApp.Models + { + public class Customer { public string Name { get; set; } = ""; } + public class Order { public Customer Customer { get; set; } = new(); } + } + namespace MyApp.Views + { + [FromModel(typeof(MyApp.Models.Order))] + public partial class OrderView { } + } + """); + + Assert.Contains(results.Keys, k => k.EndsWith("CustomerView.g.cs")); + Assert.Contains(results.Keys, k => k.EndsWith("CustomerViewExtensions.g.cs")); + + var source = results.First(r => r.Key.EndsWith("CustomerViewExtensions.g.cs")).Value; + Assert.Contains("ToView(this global::MyApp.Models.Customer", source); + Assert.Contains("ToModel(this CustomerView", source); + } + + [Fact] + public void AutoDtoPropertyCallsCorrectSuffixedMethod() + { + // When the nested mapped DTO also follows the suffix convention, the chained call + // inside ToView() should call .ToView() not .ToDto(). + var results = GeneratorDriver.Run(""" + using Gener8; + public class Address { public string Street { get; set; } = ""; } + public partial class AddressView { } + public class Order { public Address ShippingAddress { get; set; } = new(); } + [FromModel(typeof(Order))] + public partial class OrderView { } + """); + + var source = results["OrderViewExtensions.g.cs"]; + Assert.Contains("ShippingAddress = model.ShippingAddress.ToView(),", source); + } + [Fact] public void TypeMappedNestedPropertyCallsCorrectSuffixedMethod() { From a4f6fddeef1c9a28b42a357d8831b68c1f51043e Mon Sep 17 00:00:00 2001 From: Hamid Mayeli Date: Thu, 3 Sep 2026 22:16:28 +0100 Subject: [PATCH 2/3] Add only include --- docs/features.md | 72 +++++ src/Gener8.Abstractions/FromModelAttribute.cs | 1 + src/Gener8/Diagnostics.cs | 16 ++ src/Gener8/PropertyDataBuilder.cs | 114 +++++++- src/Gener8/SyntaxTransformer.cs | 31 ++- tests/Gener8.Tests/OnlyIncludeTests.cs | 252 ++++++++++++++++++ 6 files changed, 471 insertions(+), 15 deletions(-) create mode 100644 tests/Gener8.Tests/OnlyIncludeTests.cs diff --git a/docs/features.md b/docs/features.md index e395d4e..a938309 100644 --- a/docs/features.md +++ b/docs/features.md @@ -553,6 +553,78 @@ internal partial class OrderDto { } --- +## 10. Include only specific properties (`OnlyInclude`) + +A whitelist alternative to `Ignore`. When `OnlyInclude` is set, **only** the listed properties are extracted from the model — all others are dropped. This is useful when a model has many properties but the DTO needs only a small subset. + +```csharp +public class Product +{ + public string Name { get; set; } = ""; + public decimal Price { get; set; } + public string Sku { get; set; } = ""; + public string InternalCode { get; set; } = ""; +} + +[FromModel(typeof(Product), OnlyInclude = [nameof(Product.Name), nameof(Product.Price)])] +public partial class ProductSummaryDto { } + +// Generated ProductSummaryDto.g.cs: +// public string Name { get; set; } +// public decimal Price { get; set; } +// (Sku and InternalCode are not present) +``` + +### Nested property paths + +`OnlyInclude` supports dotted paths to restrict which properties are included in **auto-generated companion DTOs**. When a model property's type is itself in a qualifying namespace (via `DtoNamespaces` or the model's own namespace), the sub-paths after the dot become the `OnlyInclude` for that nested companion DTO, recursively. + +```csharp +// Models in namespace MyApp.Models (auto-qualifying): +public class Address { public string Street { get; set; } = ""; public string PostCode { get; set; } = ""; } +public class Customer { public string FullName { get; set; } = ""; public Address Address { get; set; } = new(); } +public class Order { public int Id { get; set; } public Customer Customer { get; set; } = new(); } + +// DTOs: +[FromModel(typeof(Order), OnlyInclude = ["Id", "Customer.FullName", "Customer.Address.PostCode"])] +public partial class OrderDto { } + +// Generated OrderDto.g.cs: +// public int Id { get; set; } +// public CustomerDto Customer { get; set; } +// +// Auto-generated CustomerDto.g.cs (OnlyInclude = ["FullName", "Address.PostCode"]): +// public string FullName { get; set; } +// public AddressDto Address { get; set; } +// +// Auto-generated AddressDto.g.cs (OnlyInclude = ["PostCode"]): +// public string PostCode { get; set; } +``` + +- A plain name (`"Customer"`) includes the whole property with no restriction on the companion DTO's properties. +- A dotted path restricts the companion DTO. When both a plain name and dotted paths exist for the same property, the plain name wins (all properties included). +- Deep paths (`"A.B.C"`) are applied recursively: `A` → sub-paths `["B.C"]` → `B` → sub-paths `["C"]`. +- Sub-path filtering only has an effect for properties whose types trigger auto-DTO generation. For simple types (`string`, `int`, etc.) or types outside qualifying namespaces, the dotted path is treated as a plain property inclusion. + +### Constraints + +- **`OnlyInclude` and `Ignore` cannot be used together** — they serve opposite purposes (whitelist vs. blacklist). Use one or the other. +- **All listed names must exist on the model** — the generator validates each top-level name in `OnlyInclude` against the model's public properties. Dotted paths' first segment is validated; deeper segments are not validated by the generator. +- **GEN004** is raised when `OnlyInclude` and `Ignore` are both set on the same DTO. +- **GEN005** is raised for each top-level name in `OnlyInclude` that does not match any public property on the model. + +### Interaction with other features + +| Feature | Behaviour | +|---|---| +| `Flatten` | A property in both `OnlyInclude` and `Flatten` is flattened as normal (all sub-properties inlined). | +| `ForceNullable` | Can be combined freely; only the included properties are affected. | +| `RenameProperty` | Works normally on included properties. | +| `IncludeInherited` | Inherited properties are eligible for `OnlyInclude` just like declared ones. | +| `DtoNamespaces` | Dotted paths in `OnlyInclude` propagate `OnlyInclude` into the auto-generated companion DTOs. | + +--- + ## Planned features The following features are on the roadmap but not yet implemented: diff --git a/src/Gener8.Abstractions/FromModelAttribute.cs b/src/Gener8.Abstractions/FromModelAttribute.cs index 8812614..14b90da 100644 --- a/src/Gener8.Abstractions/FromModelAttribute.cs +++ b/src/Gener8.Abstractions/FromModelAttribute.cs @@ -11,4 +11,5 @@ public sealed class FromModelAttribute(System.Type modelType) : System.Attribute public RepositoryType Repository { get; set; } public string[] DtoNamespaces { get; set; } = []; public string[] ForceNullable { get; set; } = []; + public string[] OnlyInclude { get; set; } = []; } diff --git a/src/Gener8/Diagnostics.cs b/src/Gener8/Diagnostics.cs index a2012a9..3ff5c22 100644 --- a/src/Gener8/Diagnostics.cs +++ b/src/Gener8/Diagnostics.cs @@ -28,6 +28,22 @@ internal static class Diagnostics defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + public static readonly DiagnosticDescriptor OnlyIncludeIgnoreConflict = new( + id: "GEN004", + title: "OnlyInclude and Ignore cannot be used together", + messageFormat: "'{0}' uses both OnlyInclude and Ignore. Use OnlyInclude to whitelist properties, or Ignore to blacklist them, not both.", + category: "Gener8", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidOnlyIncludePath = new( + id: "GEN005", + title: "Invalid OnlyInclude property path", + messageFormat: "OnlyInclude: '{0}' does not exist as a property on model '{1}'", + category: "Gener8", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + public static readonly DiagnosticDescriptor UnexpectedError = new( id: "GEN999", title: "Unexpected generator error", diff --git a/src/Gener8/PropertyDataBuilder.cs b/src/Gener8/PropertyDataBuilder.cs index 47fcac9..66b7c44 100644 --- a/src/Gener8/PropertyDataBuilder.cs +++ b/src/Gener8/PropertyDataBuilder.cs @@ -14,15 +14,20 @@ internal sealed class PropertyDataBuilder( RepositoryKind repositoryKind, IReadOnlyCollection? qualifyingNamespaces = null, IReadOnlyCollection? ignoredTypeMappings = null, - string dtoSuffix = "Dto") + string dtoSuffix = "Dto", + IReadOnlyCollection? onlyIncludePaths = null) { - private readonly List _autoTargetSymbols = []; + private readonly List<(INamedTypeSymbol Symbol, IReadOnlyCollection? OnlyIncludePaths)> _autoTargetSymbols = []; private readonly List _alreadyNullablePropertyNames = []; private readonly List _iSetWithInitializerPropertyNames = []; + private readonly List _invalidOnlyIncludePaths = []; + private bool _hasOnlyIncludeIgnoreConflict; - public IReadOnlyCollection AutoTargetSymbols => _autoTargetSymbols; + public IReadOnlyCollection<(INamedTypeSymbol Symbol, IReadOnlyCollection? OnlyIncludePaths)> AutoTargetSymbols => _autoTargetSymbols; public IReadOnlyList AlreadyNullablePropertyNames => _alreadyNullablePropertyNames; public IReadOnlyList ISetWithInitializerPropertyNames => _iSetWithInitializerPropertyNames; + public IReadOnlyList InvalidOnlyIncludePaths => _invalidOnlyIncludePaths; + public bool HasOnlyIncludeIgnoreConflict => _hasOnlyIncludeIgnoreConflict; public IReadOnlyCollection GetProperties() { @@ -30,8 +35,13 @@ public IReadOnlyCollection GetProperties() var flattenNames = GetFlattenProperties(); var flattenPrefix = GetFlattenPrefix(); var includeInherited = GetIncludeInherited(); + var onlyInclude = ParseOnlyInclude(); + + if (onlyInclude is not null && ignoredNames.Count > 0) + _hasOnlyIncludeIgnoreConflict = true; + var typeMappings = GetTypeMappings(); - PopulateInferredMappings(typeMappings, includeInherited); + PopulateInferredMappings(typeMappings, includeInherited, onlyInclude); var renameMap = GetRenameMap(); var forceNullableNames = GetForceNullableProperties(); var existingDtoProps = GetExistingDtoPropertyNames(); @@ -43,6 +53,7 @@ public IReadOnlyCollection GetProperties() foreach (var property in GetModelProperties(modelSymbol, includeInherited, ctorBackedNameSet)) { if (ignoredNames.Contains(property.Name)) continue; + if (onlyInclude is not null && !onlyInclude.ContainsKey(property.Name)) continue; if (flattenNames.Contains(property.Name)) { @@ -108,13 +119,24 @@ public IReadOnlyCollection GetProperties() } } + if (onlyInclude is not null) + { + var allModelPropNames = GetAllModelPropertyNames(includeInherited); + foreach (var key in onlyInclude.Keys) + if (!allModelPropNames.Contains(key)) + _invalidOnlyIncludePaths.Add(key); + } + return properties; } // Scans model properties for complex types in qualifying namespaces and adds inferred // TypeMappings (e.g. Customer -> CustomerDto). Uses symbol identity to avoid overriding // explicit [TypeMapping] attributes, and the non-nullable key format for consistency. - private void PopulateInferredMappings(Dictionary typeMappings, bool includeInherited) + private void PopulateInferredMappings( + Dictionary typeMappings, + bool includeInherited, + Dictionary?>? onlyInclude) { if (qualifyingNamespaces is null || qualifyingNamespaces.Count == 0) return; @@ -132,18 +154,23 @@ private void PopulateInferredMappings(Dictionary typeMappings, b } foreach (var property in GetModelProperties(modelSymbol, includeInherited)) - TryAddInferredMapping(property.Type, typeMappings, explicitSourceSymbols); + { + if (onlyInclude is not null && !onlyInclude.ContainsKey(property.Name)) continue; + var subPaths = onlyInclude is not null && onlyInclude.TryGetValue(property.Name, out var sp) ? sp : null; + TryAddInferredMapping(property.Type, typeMappings, explicitSourceSymbols, (IReadOnlyCollection?)subPaths); + } } private void TryAddInferredMapping( ITypeSymbol type, Dictionary typeMappings, - HashSet explicitSourceSymbols) + HashSet explicitSourceSymbols, + IReadOnlyCollection? subPaths) { // Recurse into array element types (e.g. Product[] -> ProductDto). if (type is IArrayTypeSymbol arrayType) { - TryAddInferredMapping(arrayType.ElementType, typeMappings, explicitSourceSymbols); + TryAddInferredMapping(arrayType.ElementType, typeMappings, explicitSourceSymbols, subPaths); return; } @@ -151,7 +178,7 @@ private void TryAddInferredMapping( if (type is INamedTypeSymbol { IsGenericType: true, Arity: 1 } collType && IsSupportedMappedCollection(collType)) { - TryAddInferredMapping(collType.TypeArguments[0], typeMappings, explicitSourceSymbols); + TryAddInferredMapping(collType.TypeArguments[0], typeMappings, explicitSourceSymbols, subPaths); return; } @@ -175,7 +202,8 @@ private void TryAddInferredMapping( if (ignoredTypeMappings?.Contains(key) == true) return; typeMappings[key] = namedType.Name + dtoSuffix; - _autoTargetSymbols.Add((INamedTypeSymbol)originalDef); + var sym = (INamedTypeSymbol)originalDef; + _autoTargetSymbols.Add((sym, subPaths)); } private HashSet GetExistingDtoPropertyNames() @@ -227,6 +255,72 @@ private HashSet GetIgnoredProperties() return ignoredNames; } + // Parses OnlyInclude paths from the attribute or the onlyIncludePaths parameter. + // Returns null when no OnlyInclude is set (include all properties). + // For paths with dots ("Customer.FullName"), the first segment is the key and the + // remaining path is a sub-path stored as the value. A plain name maps to a null value + // (include the whole property without restricting its sub-properties). + private Dictionary?>? ParseOnlyInclude() + { + IReadOnlyCollection? paths = null; + + if (onlyIncludePaths is not null) + { + paths = onlyIncludePaths; + } + else if (attribute is not null) + { + var list = new List(); + foreach (var namedArg in attribute.NamedArguments) + { + if (namedArg.Key != "OnlyInclude") continue; + foreach (var item in namedArg.Value.Values) + if (item.Value is string name) + list.Add(name); + } + if (list.Count > 0) paths = list; + } + + if (paths is null) return null; + + var result = new Dictionary?>(); + foreach (var path in paths) + { + var dotIndex = path.IndexOf('.'); + if (dotIndex < 0) + { + // Plain name overrides any previously accumulated sub-paths for the same key. + result[path] = null; + } + else + { + var head = path.Substring(0, dotIndex); + var tail = path.Substring(dotIndex + 1); + if (result.TryGetValue(head, out var existing) && existing is null) + continue; // plain name already set — ignore dotted paths for this key + if (!result.ContainsKey(head)) + result[head] = []; + result[head]!.Add(tail); + } + } + return result.Count > 0 ? result : null; + } + + private HashSet GetAllModelPropertyNames(bool includeInherited) + { + var names = new HashSet(); + var current = modelSymbol; + while (current is not null && current.SpecialType != SpecialType.System_Object) + { + foreach (var member in current.GetMembers()) + if (member is IPropertySymbol { DeclaredAccessibility: Accessibility.Public, IsStatic: false }) + names.Add(member.Name); + if (!includeInherited) break; + current = current.BaseType; + } + return names; + } + private static PropertyData BuildPropertyData( IPropertySymbol property, Dictionary typeMappings, diff --git a/src/Gener8/SyntaxTransformer.cs b/src/Gener8/SyntaxTransformer.cs index e6bc566..0dafdc7 100644 --- a/src/Gener8/SyntaxTransformer.cs +++ b/src/Gener8/SyntaxTransformer.cs @@ -59,6 +59,26 @@ public static bool IsPartialClassWithAttributes(SyntaxNode node) var builder = new PropertyDataBuilder(classSymbol, attr, modelSymbol, repositoryKind, qualifyingNamespaces, ignoredTypeMappings, dtoSuffix); var properties = builder.GetProperties(); + if (builder.HasOnlyIncludeIgnoreConflict) + { + return new ClassTargetResult(null, [Diagnostic.Create( + Diagnostics.OnlyIncludeIgnoreConflict, + context.Node.GetLocation(), + classSymbol.Name)]); + } + + if (builder.InvalidOnlyIncludePaths.Count > 0) + { + var errors = new List(builder.InvalidOnlyIncludePaths.Count); + foreach (var path in builder.InvalidOnlyIncludePaths) + errors.Add(Diagnostic.Create( + Diagnostics.InvalidOnlyIncludePath, + context.Node.GetLocation(), + path, + modelSymbol.Name)); + return new ClassTargetResult(null, errors); + } + if (builder.AlreadyNullablePropertyNames.Count > 0) { var errors = new List(builder.AlreadyNullablePropertyNames.Count); @@ -125,7 +145,7 @@ private static IReadOnlyCollection GetQualifyingNamespaces( // Recursively synthesises TargetClass records for all transitive auto-DTO types. // Returns a flat list (depth-first) safe to iterate and de-duplicate in the pipeline. private static IReadOnlyCollection BuildAutoTargets( - IReadOnlyCollection symbols, + IReadOnlyCollection<(INamedTypeSymbol Symbol, IReadOnlyCollection? OnlyIncludePaths)> symbols, string? targetNs, string accessibility, IReadOnlyCollection qualifyingNamespaces, @@ -140,7 +160,7 @@ private static IReadOnlyCollection BuildAutoTargets( } private static void CollectAutoTargets( - IReadOnlyCollection symbols, + IReadOnlyCollection<(INamedTypeSymbol Symbol, IReadOnlyCollection? OnlyIncludePaths)> symbols, string? targetNs, string accessibility, IReadOnlyCollection qualifyingNamespaces, @@ -150,7 +170,7 @@ private static void CollectAutoTargets( HashSet visited, List result) { - foreach (var symbol in symbols) + foreach (var (symbol, symbolOnlyIncludePaths) in symbols) { var key = symbol.ToDisplayString(); if (!visited.Add(key)) continue; @@ -159,8 +179,9 @@ private static void CollectAutoTargets( var modelFullName = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); // No classSymbol/attribute for synthesised DTOs — all options default to empty/false. - // Propagate repositoryKind, ignoredTypeMappings, and dtoSuffix so child DTOs inherit the same suffix. - var builder = new PropertyDataBuilder(null, null, symbol, repositoryKind, qualifyingNamespaces, ignoredTypeMappings, dtoSuffix); + // Propagate repositoryKind, ignoredTypeMappings, dtoSuffix, and OnlyIncludePaths so + // child DTOs inherit the same suffix and property filter from their parent. + var builder = new PropertyDataBuilder(null, null, symbol, repositoryKind, qualifyingNamespaces, ignoredTypeMappings, dtoSuffix, symbolOnlyIncludePaths); var props = builder.GetProperties(); // Depth-first: add nested auto-targets before this one so dependencies come first. diff --git a/tests/Gener8.Tests/OnlyIncludeTests.cs b/tests/Gener8.Tests/OnlyIncludeTests.cs new file mode 100644 index 0000000..a4a269c --- /dev/null +++ b/tests/Gener8.Tests/OnlyIncludeTests.cs @@ -0,0 +1,252 @@ +using Microsoft.CodeAnalysis; + +namespace Gener8.Tests; + +public class OnlyIncludeTests +{ + [Fact] + public void OnlyInclude_SimpleNames_OnlyThosePropertiesGenerated() + { + var results = GeneratorDriver.Run(""" + using Gener8; + public class Product { public string Name { get; set; } = ""; public decimal Price { get; set; } public string Sku { get; set; } = ""; } + [FromModel(typeof(Product), OnlyInclude = [nameof(Product.Name), nameof(Product.Price)])] + public partial class ProductDto { } + """); + + var source = results["ProductDto.g.cs"]; + Assert.Contains("public string Name", source); + Assert.Contains("public decimal Price", source); + Assert.DoesNotContain("Sku", source); + } + + [Fact] + public void OnlyInclude_Extensions_OnlyMappedPropertiesAppear() + { + var results = GeneratorDriver.Run(""" + using Gener8; + public class Product { public string Name { get; set; } = ""; public decimal Price { get; set; } public string Sku { get; set; } = ""; } + [FromModel(typeof(Product), OnlyInclude = [nameof(Product.Name)])] + public partial class ProductDto { } + """); + + var ext = results["ProductDtoExtensions.g.cs"]; + Assert.Contains("Name = dto.Name,", ext); + Assert.Contains("Name = model.Name,", ext); + Assert.DoesNotContain("Price", ext); + Assert.DoesNotContain("Sku", ext); + } + + [Fact] + public void OnlyInclude_SingleProperty_Works() + { + var results = GeneratorDriver.Run(""" + using Gener8; + public class Customer { public string Name { get; set; } = ""; public string Email { get; set; } = ""; } + [FromModel(typeof(Customer), OnlyInclude = [nameof(Customer.Email)])] + public partial class CustomerDto { } + """); + + var source = results["CustomerDto.g.cs"]; + Assert.Contains("public string Email", source); + Assert.DoesNotContain("Name", source); + } + + [Fact] + public void OnlyInclude_DottedPath_AutoDtoGetsOnlySubProperty() + { + var results = GeneratorDriver.Run(""" + using Gener8; + namespace MyApp.Models + { + public class Customer { public string FullName { get; set; } = ""; public string Email { get; set; } = ""; } + public class Order { public int Id { get; set; } public Customer Customer { get; set; } = new(); } + } + namespace MyApp.Dtos + { + [FromModel(typeof(MyApp.Models.Order), OnlyInclude = ["Id", "Customer.FullName"])] + public partial class OrderDto { } + } + """); + + // OrderDto should have Id and Customer + var orderDto = results["MyApp.Dtos.OrderDto.g.cs"]; + Assert.Contains("public int Id", orderDto); + Assert.Contains("CustomerDto", orderDto); + + // Auto-generated CustomerDto should only have FullName, not Email + var customerDto = results.First(r => r.Key.EndsWith("CustomerDto.g.cs")).Value; + Assert.Contains("public string FullName", customerDto); + Assert.DoesNotContain("Email", customerDto); + } + + [Fact] + public void OnlyInclude_DottedPath_DeepNested_TwoLevels() + { + var results = GeneratorDriver.Run(""" + using Gener8; + namespace MyApp.Models + { + public class Address { public string Street { get; set; } = ""; public string PostCode { get; set; } = ""; } + public class Customer { public string Name { get; set; } = ""; public Address Address { get; set; } = new(); } + public class Order { public int Id { get; set; } public Customer Customer { get; set; } = new(); } + } + namespace MyApp.Dtos + { + [FromModel(typeof(MyApp.Models.Order), OnlyInclude = ["Id", "Customer.Address.PostCode"])] + public partial class OrderDto { } + } + """); + + // OrderDto has Id and Customer + var orderDto = results["MyApp.Dtos.OrderDto.g.cs"]; + Assert.Contains("public int Id", orderDto); + Assert.Contains("CustomerDto", orderDto); + + // CustomerDto should only have Address + var customerDto = results.First(r => r.Key.EndsWith("CustomerDto.g.cs")).Value; + Assert.Contains("AddressDto", customerDto); + Assert.DoesNotContain("Name", customerDto); + + // AddressDto should only have PostCode + var addressDto = results.First(r => r.Key.EndsWith("AddressDto.g.cs")).Value; + Assert.Contains("public string PostCode", addressDto); + Assert.DoesNotContain("Street", addressDto); + } + + [Fact] + public void OnlyInclude_PlainNameTakesPrecedenceOverDottedPath() + { + // "Customer" (plain) + "Customer.FullName" (dotted) → include all Customer properties + var results = GeneratorDriver.Run(""" + using Gener8; + namespace MyApp.Models + { + public class Customer { public string FullName { get; set; } = ""; public string Email { get; set; } = ""; } + public class Order { public int Id { get; set; } public Customer Customer { get; set; } = new(); } + } + namespace MyApp.Dtos + { + [FromModel(typeof(MyApp.Models.Order), OnlyInclude = ["Customer", "Customer.FullName"])] + public partial class OrderDto { } + } + """); + + // CustomerDto should have BOTH FullName and Email because plain "Customer" takes precedence + var customerDto = results.First(r => r.Key.EndsWith("CustomerDto.g.cs")).Value; + Assert.Contains("public string FullName", customerDto); + Assert.Contains("public string Email", customerDto); + } + + [Fact] + public void OnlyInclude_WithSuffixNaming_CorrectMethodName() + { + var results = GeneratorDriver.RunWithNullable(""" + using Gener8; + public class Product { public string Name { get; set; } = ""; public decimal Price { get; set; } } + [FromModel(typeof(Product), OnlyInclude = [nameof(Product.Name)])] + public partial class ProductView { } + """); + + var ext = results["ProductViewExtensions.g.cs"]; + Assert.Contains("ToView(this global::Product? model)", ext); + Assert.Contains("ToModel(this ProductView? dto)", ext); + } + + [Fact] + public void OnlyInclude_GEN004_WhenUsedWithIgnore() + { + var diagnostics = GeneratorDriver.RunForDiagnostics(""" + using Gener8; + public class Product { public string Name { get; set; } = ""; public decimal Price { get; set; } } + [FromModel(typeof(Product), OnlyInclude = [nameof(Product.Name)], Ignore = [nameof(Product.Price)])] + public partial class ProductDto { } + """); + + var gen004 = Assert.Single(diagnostics, d => d.Id == "GEN004"); + Assert.Equal(DiagnosticSeverity.Error, gen004.Severity); + Assert.Contains("ProductDto", gen004.GetMessage()); + } + + [Fact] + public void OnlyInclude_GEN004_NoSourceEmitted() + { + var sources = GeneratorDriver.RunUnchecked(""" + using Gener8; + public class Product { public string Name { get; set; } = ""; public decimal Price { get; set; } } + [FromModel(typeof(Product), OnlyInclude = [nameof(Product.Name)], Ignore = [nameof(Product.Price)])] + public partial class ProductDto { } + """); + + Assert.DoesNotContain(sources.Keys, k => k.Contains("ProductDto")); + } + + [Fact] + public void OnlyInclude_GEN005_InvalidPropertyName() + { + var diagnostics = GeneratorDriver.RunForDiagnostics(""" + using Gener8; + public class Product { public string Name { get; set; } = ""; } + [FromModel(typeof(Product), OnlyInclude = ["NonExistent"])] + public partial class ProductDto { } + """); + + var gen005 = Assert.Single(diagnostics, d => d.Id == "GEN005"); + Assert.Equal(DiagnosticSeverity.Error, gen005.Severity); + Assert.Contains("NonExistent", gen005.GetMessage()); + Assert.Contains("Product", gen005.GetMessage()); + } + + [Fact] + public void OnlyInclude_GEN005_ReportedForEachInvalidPath() + { + var diagnostics = GeneratorDriver.RunForDiagnostics(""" + using Gener8; + public class Product { public string Name { get; set; } = ""; } + [FromModel(typeof(Product), OnlyInclude = ["BadA", "BadB"])] + public partial class ProductDto { } + """); + + Assert.Equal(2, diagnostics.Count(d => d.Id == "GEN005")); + } + + [Fact] + public void OnlyInclude_Empty_AllPropertiesIncluded() + { + // OnlyInclude = [] is equivalent to not setting OnlyInclude + var results = GeneratorDriver.Run(""" + using Gener8; + public class Product { public string Name { get; set; } = ""; public decimal Price { get; set; } } + [FromModel(typeof(Product), OnlyInclude = [])] + public partial class ProductDto { } + """); + + var source = results["ProductDto.g.cs"]; + Assert.Contains("public string Name", source); + Assert.Contains("public decimal Price", source); + } + + [Fact] + public void OnlyInclude_MultipleSubPathsForSameNestedType() + { + // "Customer.FullName" and "Customer.Email" both constrain CustomerDto + var results = GeneratorDriver.Run(""" + using Gener8; + namespace MyApp.Models + { + public class Customer { public string FullName { get; set; } = ""; public string Email { get; set; } = ""; public string Phone { get; set; } = ""; } + public class Order { public int Id { get; set; } public Customer Customer { get; set; } = new(); } + } + namespace MyApp.Dtos + { + [FromModel(typeof(MyApp.Models.Order), OnlyInclude = ["Customer.FullName", "Customer.Email"])] + public partial class OrderDto { } + } + """); + + var customerDto = results.First(r => r.Key.EndsWith("CustomerDto.g.cs")).Value; + Assert.Contains("public string FullName", customerDto); + Assert.Contains("public string Email", customerDto); + Assert.DoesNotContain("Phone", customerDto); + } +} From aef65af47af368dce1a8918044ed851cc3b089ff Mon Sep 17 00:00:00 2001 From: Hamid Mayeli Date: Thu, 3 Sep 2026 22:18:09 +0100 Subject: [PATCH 3/3] Nothing --- src/Gener8/DefaultSource.DynamoDb.cs | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 src/Gener8/DefaultSource.DynamoDb.cs diff --git a/src/Gener8/DefaultSource.DynamoDb.cs b/src/Gener8/DefaultSource.DynamoDb.cs deleted file mode 100644 index a463599..0000000 --- a/src/Gener8/DefaultSource.DynamoDb.cs +++ /dev/null @@ -1,3 +0,0 @@ -// DynamoDB converters and repository base classes previously injected from this file have been -// moved to Gener8.Extensions.DynamoDB (separate NuGet package). -namespace Gener8;