SmartSelector is a Roslyn Source Generator for strongly typed DTO projections. It generates reusable Expression<Func<TSource, TDto>> selectors, conversion helpers, LINQ extension methods, and optional DTO properties, reducing mapping boilerplate while keeping projections suitable for Entity Framework Core.
[AutoSelect<TSource>]generates a selector expression, a cachedFromconverter, andSelect{Dto}/To{Dto}extensions.[AutoProperties]and[AutoProperties<TSource>]generate supported DTO properties.ExcludeandFlatteningcan be configured onAutoProperties,AutoDetails, or directly onAutoSelect.- Convention-based flattening maps names such as
CustomerAddressCitytoCustomer.Address.City. [MapFrom]maps aliases and explicit nested paths such as"Warehouse.Location".- Nested objects, collections, and arrays are projected recursively; object arrays use
Select(...).ToArray(). [AutoDetails]generates or completes the exact nested DTO type declared by a property.- Nullable-aware generation propagates null, produces empty non-nullable collections when appropriate, and reports unsafe contracts.
- Nested destination DTOs are supported when the complete declaration chain is non-generic and
partial. - Compile-time diagnostics cover invalid usage, incompatible mappings, ambiguous flattening, nullability, and invalid paths.
- The generator package includes analyzer variants for supported Roslyn versions.
SmartSelector focuses on declarative 1:1 mappings. It intentionally does not provide custom resolvers, formatters, callbacks, or global naming policies. Use a manually written LINQ expression for calculations or domain-specific transformations.
| Component | Target |
|---|---|
RoyalCode.SmartSelector |
.NET 8, .NET 9, .NET 10 |
RoyalCode.SmartSelector.Generators |
.NET Standard 2.0 analyzer |
Reference both packages at the same version:
<ItemGroup>
<PackageReference Include="RoyalCode.SmartSelector" Version="0.5.0" />
<PackageReference Include="RoyalCode.SmartSelector.Generators"
Version="0.5.0"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>Then import:
using RoyalCode.SmartSelector;No runtime registration or dependency injection configuration is required.
Given an entity:
public sealed class User
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
}Declare a partial DTO:
using RoyalCode.SmartSelector;
namespace MyApp.Users;
[AutoSelect<User>,
AutoProperties(Exclude = [nameof(User.PasswordHash)])]
public partial class UserDetails { }Use the generated API:
// IQueryable: keeps the projection in the expression tree.
var users = await db.Users
.SelectUserDetails()
.ToListAsync(ct);
// Already materialized objects: uses a cached compiled delegate.
UserDetails details = UserDetails.From(user);
UserDetails details2 = user.ToUserDetails();
// Reuse or compose the generated expression directly.
Expression<Func<User, UserDetails>> selector =
UserDetails.SelectUserExpression;For User → UserDetails, the generated public contract is:
UserDetails.SelectUserExpression;
UserDetails.From(User user);
query.SelectUserDetails(); // IQueryable<User>
items.SelectUserDetails(); // IEnumerable<User>
user.ToUserDetails(); // User| Requirement | Declaration |
|---|---|
| Selector for manually declared properties | [AutoSelect<TEntity>] |
| Selector and supported automatic properties | [AutoSelect<TEntity>, AutoProperties] |
| Properties only, without selector or extensions | [AutoProperties<TEntity>] |
| Exclude automatic properties | Exclude = [nameof(TEntity.Property)] |
| Generate flattened properties for a root navigation | Flattening = [nameof(TEntity.Navigation)] |
| Rename or explicitly select a source path | [MapFrom(...)] |
| Generate or complete a nested details type | [AutoDetails] |
AutoSelect<TSource> alone does not generate DTO properties. Declare them manually or add AutoProperties. Providing Exclude or Flattening directly to AutoSelect also enables automatic property generation.
Do not combine AutoSelect<Product> with AutoProperties<Product>. When AutoSelect<TSource> is present, use the non-generic [AutoProperties] form because the source type is already known.
[AutoSelect<Product>, AutoProperties]
public partial class ProductDetails { }
[AutoProperties<Product>]
public partial class ProductSnapshot { } // properties onlyAutomatic property generation supports:
- numeric primitives,
bool,char,string,decimal, andDateTime; - enums and structs, including application value objects;
- supported nullable value/reference types;
- arrays of simple types, enums, or structs;
- generic collections implementing
IEnumerable<T>whenTis a supported simple type, enum, or struct.
Complex classes are not added as ordinary automatic properties. Declare a nested DTO, use AutoDetails, or flatten the navigation. Properties already declared by the user are not generated again.
Configure the options on AutoProperties:
[AutoSelect<Order>,
AutoProperties(
Exclude = [nameof(Order.InternalCode)],
Flattening = [nameof(Order.Customer)])]
public partial class OrderDetails { }Or directly on AutoSelect:
[AutoSelect<Order>(
Exclude = [nameof(Order.InternalCode)],
Flattening = [nameof(Order.Customer)])]
public partial class OrderDetails { }Both options are case-sensitive. If Customer has supported Name and Email properties, configured flattening generates CustomerName and CustomerEmail and omits the complex Customer property from automatic generation.
A manually declared DTO property can represent a deep path by concatenating source property names:
[AutoSelect<Order>]
public partial class OrderDetails
{
public string CustomerAddressCountryRegionName { get; set; } = string.Empty;
}The generated assignment is equivalent to:
CustomerAddressCountryRegionName =
source.Customer.Address.Country.Region.Name;If a destination name matches multiple source paths, SmartSelector reports RCSS010. Rename it or use MapFrom.
Use nameof for direct members and a dot-separated string for nested paths:
[AutoSelect<Supplier>]
public partial class SupplierDetails
{
[MapFrom(nameof(Supplier.Name))]
public string DisplayName { get; set; } = string.Empty;
[MapFrom("Warehouse.Location")]
public string? Location { get; set; }
}Every segment must be a readable public property. An explicit nested path takes precedence over similarly named direct properties. Invalid nested paths report RCSS017 on the destination property.
Declare the desired shape and SmartSelector projects it recursively:
[AutoSelect<Post>]
public partial class PostDetails
{
public string Title { get; set; } = string.Empty;
public AuthorDetails Author { get; set; } = new();
public IReadOnlyList<CommentDetails> Comments { get; set; } = [];
}
public class AuthorDetails
{
public string Name { get; set; } = string.Empty;
}
public class CommentDetails
{
public string Content { get; set; } = string.Empty;
public string AuthorName { get; set; } = string.Empty;
}Object collections are emitted with Select(...).ToList() when the destination requires a list. A destination such as CommentDetails[] is emitted with Select(...).ToArray().
AutoDetails generates or completes the exact type declared by the property:
[AutoSelect<Customer>, AutoProperties]
public partial class CustomerDetails
{
[AutoDetails(Exclude = [nameof(Address.InternalCode)])]
public AddressDto Address { get; set; } = new();
}If AddressDto does not exist, it is generated from the matching source property type. If it exists, it must be partial; existing properties are preserved and only missing supported properties are generated. AutoDetails also accepts Flattening.
Only one property may request generation of a given details type, and an existing type must be accessible enough for the property.
With nullable reference types enabled, SmartSelector applies directional null handling:
| Source | Destination | Behavior |
|---|---|---|
| nullable scalar/navigation | nullable destination | propagates null, adding a conditional when required |
| nullable collection | nullable collection | propagates null |
| nullable collection | non-nullable collection | produces an empty collection and reports RCSS016 (Info) |
| nullable array | non-nullable array | uses Array.Empty<T>() and reports RCSS016 (Info) |
| nullable scalar/navigation | non-nullable destination | preserves previous behavior and reports RCSS015 (Warning) |
Treat RCSS015 as a DTO contract issue. Make the destination nullable, change the source model, or exclude the property. SmartSelector does not invent scalar or object defaults to conceal a mismatch.
Nullable-oblivious code (#nullable disable) retains its previous behavior without annotation-based guards or diagnostics.
Nested DTOs are supported when every declaration is non-generic and partial:
public partial class Contracts
{
[AutoSelect<User>]
public partial class UserDetails
{
public int Id { get; set; }
}
}Destination DTOs must be declared in a namespace. Generic destination DTOs and DTOs inside generic containing types report RCSS008.
The TSource argument may be namespace-qualified, use global::, refer to a nested type, or be a constructed generic type such as Envelope<string>.
The IQueryable<TSource> extension applies the generated expression directly:
var page = await db.Orders
.Where(order => order.Active)
.OrderBy(order => order.Id)
.SelectOrderDetails()
.Take(50)
.ToListAsync(ct);
var query = db.Orders.Select(OrderDetails.SelectOrderExpression);Apply entity filters and ordering before projection when they depend on members absent from the DTO. Translation depends on the EF Core provider and version, so integration-test important projections with the production provider.
Use From, To{Dto}, and the IEnumerable<TSource> extension for already materialized objects; do not invoke From inside an IQueryable expression.
| ID | Severity | Meaning |
|---|---|---|
RCSS000 |
Error | invalid AutoSelect usage, including a non-partial declaration chain |
RCSS001 |
Error | no corresponding source property was found |
RCSS002 |
Error | source and destination property types are incompatible |
RCSS003 |
Error | generic AutoProperties<TSource> used with AutoSelect |
RCSS004 |
Error | generic and non-generic AutoProperties used together |
RCSS005 |
Error | invalid AutoProperties<TSource> type argument |
RCSS006 |
Error | AutoProperties<TSource> destination is not partial |
RCSS007 |
Error | non-generic AutoProperties has no AutoSelect<TSource> |
RCSS008 |
Error | generic destination DTO or generic containing type |
RCSS010 |
Warning | ambiguous convention-based flattened path |
RCSS011 |
Error | destination DTO is in the global namespace |
RCSS012 |
Error | existing AutoDetails target type is not partial |
RCSS013 |
Error | multiple properties request the same AutoDetails type |
RCSS014 |
Error | AutoDetails target type has insufficient accessibility |
RCSS015 |
Warning | nullable source flows into a non-nullable destination |
RCSS016 |
Info | nullable collection is projected as empty when null |
RCSS017 |
Error | nested MapFrom path is invalid or unreadable |
There is no RCSS009 rule in version 0.5.0.
The generator package contains analyzer variants selected by the compiler API version:
| Validated SDK | SDK Roslyn | Loaded variant | Minimum requirement |
|---|---|---|---|
| 8.0.422 | 4.8 | roslyn4.8 |
Roslyn 4.8 / .NET SDK 8.0.4xx |
| 9.0.100 | 4.12 | roslyn4.8 |
Roslyn 4.8 / .NET SDK 8.0.4xx |
| 10.0.301 | 5.6 | roslyn5.6 |
Roslyn 5.6 / .NET SDK 10.0.3xx |
RoyalCode.Extensions.SourceGenerator is shipped beside each analyzer variant. This matrix was validated by building and executing a consumer application for each SDK family.
- No custom resolvers, formatters, conditional mapping callbacks, or global naming policies.
- No generic destination DTOs or generic destination containing types.
- No destination DTOs in the global namespace.
MapFromsupports readable public property paths, not methods, fields, or indexers.- Provider-specific LINQ translation must be validated by the consuming application.
- Detailed usage guide
- Selector reference
- Rules for AI tools and coding agents
- Demo project
- Benchmarks
- Release notes
SmartSelector is licensed under the GNU Affero General Public License v3.0.