Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/Gener8.Abstractions/FromModelAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; } = [];
}
3 changes: 0 additions & 3 deletions src/Gener8/DefaultSource.DynamoDb.cs

This file was deleted.

16 changes: 16 additions & 0 deletions src/Gener8/Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
117 changes: 106 additions & 11 deletions src/Gener8/PropertyDataBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,35 @@ internal sealed class PropertyDataBuilder(
INamedTypeSymbol modelSymbol,
RepositoryKind repositoryKind,
IReadOnlyCollection<string>? qualifyingNamespaces = null,
IReadOnlyCollection<string>? ignoredTypeMappings = null)
IReadOnlyCollection<string>? ignoredTypeMappings = null,
string dtoSuffix = "Dto",
IReadOnlyCollection<string>? onlyIncludePaths = null)
{
private readonly List<INamedTypeSymbol> _autoTargetSymbols = [];
private readonly List<(INamedTypeSymbol Symbol, IReadOnlyCollection<string>? OnlyIncludePaths)> _autoTargetSymbols = [];
private readonly List<string> _alreadyNullablePropertyNames = [];
private readonly List<string> _iSetWithInitializerPropertyNames = [];
private readonly List<string> _invalidOnlyIncludePaths = [];
private bool _hasOnlyIncludeIgnoreConflict;

public IReadOnlyCollection<INamedTypeSymbol> AutoTargetSymbols => _autoTargetSymbols;
public IReadOnlyCollection<(INamedTypeSymbol Symbol, IReadOnlyCollection<string>? OnlyIncludePaths)> AutoTargetSymbols => _autoTargetSymbols;
public IReadOnlyList<string> AlreadyNullablePropertyNames => _alreadyNullablePropertyNames;
public IReadOnlyList<string> ISetWithInitializerPropertyNames => _iSetWithInitializerPropertyNames;
public IReadOnlyList<string> InvalidOnlyIncludePaths => _invalidOnlyIncludePaths;
public bool HasOnlyIncludeIgnoreConflict => _hasOnlyIncludeIgnoreConflict;

public IReadOnlyCollection<PropertyData> GetProperties()
{
var ignoredNames = GetIgnoredProperties();
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();
Expand All @@ -42,6 +53,7 @@ public IReadOnlyCollection<PropertyData> 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))
{
Expand Down Expand Up @@ -107,13 +119,24 @@ public IReadOnlyCollection<PropertyData> 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<string, string> typeMappings, bool includeInherited)
private void PopulateInferredMappings(
Dictionary<string, string> typeMappings,
bool includeInherited,
Dictionary<string, List<string>?>? onlyInclude)
{
if (qualifyingNamespaces is null || qualifyingNamespaces.Count == 0) return;

Expand All @@ -131,26 +154,31 @@ private void PopulateInferredMappings(Dictionary<string, string> 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<string>?)subPaths);
}
}

private void TryAddInferredMapping(
ITypeSymbol type,
Dictionary<string, string> typeMappings,
HashSet<ISymbol> explicitSourceSymbols)
HashSet<ISymbol> explicitSourceSymbols,
IReadOnlyCollection<string>? 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;
}

// Recurse into supported collection element types (e.g. List<Customer> -> CustomerDto).
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;
}

Expand All @@ -173,8 +201,9 @@ private void TryAddInferredMapping(
if (typeMappings.ContainsKey(key)) return;
if (ignoredTypeMappings?.Contains(key) == true) return;

typeMappings[key] = namedType.Name + "Dto";
_autoTargetSymbols.Add((INamedTypeSymbol)originalDef);
typeMappings[key] = namedType.Name + dtoSuffix;
var sym = (INamedTypeSymbol)originalDef;
_autoTargetSymbols.Add((sym, subPaths));
}

private HashSet<string> GetExistingDtoPropertyNames()
Expand Down Expand Up @@ -226,6 +255,72 @@ private HashSet<string> 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<string, List<string>?>? ParseOnlyInclude()
{
IReadOnlyCollection<string>? paths = null;

if (onlyIncludePaths is not null)
{
paths = onlyIncludePaths;
}
else if (attribute is not null)
{
var list = new List<string>();
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<string, List<string>?>();
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<string> GetAllModelPropertyNames(bool includeInherited)
{
var names = new HashSet<string>();
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<string, string> typeMappings,
Expand Down
Loading