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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ jobs:
- name: Test Dto
run: dotnet test packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/ZibStack.NET.Dto.Tests.csproj -c Release --no-build

- name: Test Result
run: dotnet test packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ZibStack.NET.Result.Tests.csproj -c Release --no-build

- name: Test Log Analyzers
run: dotnet test packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/ZibStack.NET.Log.Analyzers.Tests.csproj -c Release --no-build

- name: Test Validation
run: dotnet test packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ZibStack.NET.Validation.Tests.csproj -c Release --no-build

- name: Benchmark Log
run: dotnet run --project packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Benchmarks/ZibStack.NET.Log.Benchmarks.csproj -c Release -- --filter "*" --exporters json

Expand Down
16 changes: 16 additions & 0 deletions ZibStack.NET.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,25 @@
<Folder Name="/packages/ZibStack.NET.Log/src/">
<Project Path="packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/ZibStack.NET.Log.Abstractions.csproj" />
<Project Path="packages/ZibStack.NET.Log/src/ZibStack.NET.Log/ZibStack.NET.Log.csproj" />
<Project Path="packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/ZibStack.NET.Log.Analyzers.csproj" />
</Folder>
<Folder Name="/packages/ZibStack.NET.Log/tests/">
<Project Path="packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Benchmarks/ZibStack.NET.Log.Benchmarks.csproj" />
<Project Path="packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Tests/ZibStack.NET.Log.Tests.csproj" />
<Project Path="packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/ZibStack.NET.Log.Analyzers.Tests.csproj" />
</Folder>
<Folder Name="/packages/ZibStack.NET.Validation/" />
<Folder Name="/packages/ZibStack.NET.Validation/src/">
<Project Path="packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ZibStack.NET.Validation.csproj" />
</Folder>
<Folder Name="/packages/ZibStack.NET.Validation/tests/">
<Project Path="packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ZibStack.NET.Validation.Tests.csproj" />
</Folder>
<Folder Name="/packages/ZibStack.NET.Result/" />
<Folder Name="/packages/ZibStack.NET.Result/src/">
<Project Path="packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ZibStack.NET.Result.csproj" />
</Folder>
<Folder Name="/packages/ZibStack.NET.Result/tests/">
<Project Path="packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ZibStack.NET.Result.Tests.csproj" />
</Folder>
</Solution>
79 changes: 79 additions & 0 deletions packages/ZibStack.NET.Dto/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ public IActionResult HandleCreate<T>(ICanCreate<T> request) where T : class
| `[PickFrom(typeof(T), ...)]` | Record (partial) | Like TS `Pick<T, K>` — whitelist of properties |
| `[OmitFrom(typeof(T), ...)]` | Record (partial) | Like TS `Omit<T, K>` — exclude listed properties |
| `[QueryDto]` | Class | Generates filter DTO with nullable properties + `ApplyFilter(IQueryable)` |
| `[QueryDto(Sortable = true)]` | Class | Adds `SortBy`, `SortDirection`, `ApplySort()`, `Apply()` to query DTO |
| `PaginatedResponse<T>` | — | Generic paginated wrapper with `Items`, `TotalCount`, `Page`, `PageSize` |
| `[PartialFrom(typeof(T))]` | Record (partial) | Generates `PatchField` properties + `ApplyTo()` for all properties of `T` |
| `[IntersectFrom(typeof(T))]` | Record (partial) | Combine multiple types into one (like TS `&`). Apply multiple times. |
| `[DtoIgnore]` | Property | Excludes from generated DTOs |
Expand Down Expand Up @@ -363,6 +365,83 @@ public IActionResult List([FromQuery] ProductQuery query)
}
```

### Sortable queries

Add `Sortable = true` to get `SortBy`, `SortDirection` properties and `ApplySort(IQueryable<T>)`:

```csharp
[QueryDto(Sortable = true, DefaultSort = "Name")]
public class Product
{
public string Name { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
}
```

```csharp
// Generated
public record ProductQuery
{
public string? Name { get; init; }
public decimal? Price { get; init; }
public int? Stock { get; init; }
public string? SortBy { get; init; }
public SortDirection? SortDirection { get; init; }

public IQueryable<Product> ApplyFilter(IQueryable<Product> query) { ... }
public IQueryable<Product> ApplySort(IQueryable<Product> query) { ... }
public IQueryable<Product> Apply(IQueryable<Product> query) { ... } // filter + sort
}

// Usage
[HttpGet]
public IActionResult List([FromQuery] ProductQuery query)
{
var results = query.Apply(_db.Products).ToList();
return Ok(results);
}
```

`SortBy` is case-insensitive and matches property names. Unknown values are ignored (no sort applied). `DefaultSort` and `DefaultSortDirection` set fallback behavior when `SortBy`/`SortDirection` are not provided.

## Paginated response (`PaginatedResponse<T>`)

Generic wrapper for paginated results:

```csharp
// Simple creation
var page = PaginatedResponse<ProductResponse>.Create(items, totalCount: 100, page: 2, pageSize: 10);

// From IQueryable (handles Skip/Take automatically)
var page = await PaginatedResponse<Product>.CreateAsync(_db.Products, page: 1, pageSize: 20);

// Map items (e.g. entity → response DTO)
var response = page.Map(p => ProductResponse.FromEntity(p));

// Properties
page.Items // IReadOnlyList<T>
page.TotalCount // int
page.Page // int
page.PageSize // int
page.TotalPages // computed
page.HasNextPage // computed
page.HasPreviousPage // computed
```

Full example with `[QueryDto]` + `[ResponseDto]`:

```csharp
[HttpGet]
public async Task<IActionResult> List([FromQuery] ProductQuery query, int page = 1, int pageSize = 20)
{
var filtered = query.Apply(_db.Products);
var paginated = await PaginatedResponse<Product>.CreateAsync(filtered, page, pageSize);
var response = paginated.Map(p => ProductResponse.FromEntity(p));
return Ok(response);
}
```

## `ApplyWithChanges()` (Update DTOs only)

Like `ApplyTo()` but returns a tuple with the list of actually changed field names. Available on Update, Combined, and UpdateDtoFor requests:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,12 @@ namespace ZibStack.NET.Dto
internal sealed class QueryDtoAttribute : System.Attribute
{
public string? Name { get; set; }
/// <summary>When true, adds SortBy and SortDirection properties + ApplySort(IQueryable) method.</summary>
public bool Sortable { get; set; }
/// <summary>Default sort property name (used when SortBy is null).</summary>
public string? DefaultSort { get; set; }
/// <summary>Default sort direction when SortDirection is null. Defaults to Asc.</summary>
public ZibStack.NET.Dto.SortDirection DefaultSortDirection { get; set; }
}
}
";
Expand Down Expand Up @@ -272,6 +278,84 @@ namespace ZibStack.NET.Dto
[System.AttributeUsage(System.AttributeTargets.Property, Inherited = false)]
internal sealed class FlattenAttribute : System.Attribute { }
}
";

private const string PaginatedResponseSource = @"// <auto-generated />
#nullable enable

namespace ZibStack.NET.Dto
{
/// <summary>
/// Generic paginated response wrapper. Use with any ResponseDto or entity type.
/// </summary>
public record PaginatedResponse<T>
{
public System.Collections.Generic.IReadOnlyList<T> Items { get; init; } = System.Array.Empty<T>();
public int TotalCount { get; init; }
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalPages => PageSize > 0 ? (int)System.Math.Ceiling((double)TotalCount / PageSize) : 0;
public bool HasNextPage => Page < TotalPages;
public bool HasPreviousPage => Page > 1;

public static PaginatedResponse<T> Create(
System.Collections.Generic.IReadOnlyList<T> items,
int totalCount,
int page,
int pageSize)
{
return new PaginatedResponse<T>
{
Items = items,
TotalCount = totalCount,
Page = page,
PageSize = pageSize,
};
}

public static async System.Threading.Tasks.Task<PaginatedResponse<T>> CreateAsync(
System.Linq.IQueryable<T> query,
int page,
int pageSize,
System.Threading.CancellationToken cancellationToken = default)
{
var totalCount = query.Count();
var items = query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
return Create(items, totalCount, page, pageSize);
}

/// <summary>Maps items to a different type while preserving pagination metadata.</summary>
public PaginatedResponse<TOut> Map<TOut>(System.Func<T, TOut> selector)
{
var mapped = new System.Collections.Generic.List<TOut>(Items.Count);
foreach (var item in Items)
mapped.Add(selector(item));

return new PaginatedResponse<TOut>
{
Items = mapped,
TotalCount = TotalCount,
Page = Page,
PageSize = PageSize,
};
}
}
}
";

private const string SortDirectionSource = @"// <auto-generated />
namespace ZibStack.NET.Dto
{
/// <summary>Sort direction for sortable query DTOs.</summary>
public enum SortDirection
{
Asc,
Desc
}
}
";

private const string DtoValidatorInterfaceSource = @"// <auto-generated />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@
var nestedName = ((INamedTypeSymbol)unwrapped2).Name;
var nestedResponseDtoAttr = unwrapped2.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == ResponseDtoAttributeFqn);
var customName = nestedResponseDtoAttr.NamedArguments

Check warning on line 182 in packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Dereference of a possibly null reference.
.FirstOrDefault(a => a.Key == "Name").Value.Value as string;
var responseName = customName ?? $"{nestedName}Response";
var nestedNs = unwrapped2.ContainingNamespace.IsGlobalNamespace
Expand Down Expand Up @@ -221,7 +221,7 @@
var attr = symbol.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == attributeFqn);

if (attr.ConstructorArguments.Length == 0) return null;

Check warning on line 224 in packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Dereference of a possibly null reference.
var targetType = attr.ConstructorArguments[0].Value as INamedTypeSymbol;
if (targetType is null) return null;

Expand Down Expand Up @@ -379,7 +379,7 @@
var attr = symbol.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == PartialFromAttributeFqn);

if (attr.ConstructorArguments.Length == 0) return null;

Check warning on line 382 in packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Dereference of a possibly null reference.
var targetType = attr.ConstructorArguments[0].Value as INamedTypeSymbol;
if (targetType is null) return null;

Expand Down Expand Up @@ -471,7 +471,7 @@
var attr = symbol.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == attributeFqn);

if (attr.ConstructorArguments.Length == 0) return null;

Check warning on line 474 in packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Dereference of a possibly null reference.
var targetType = attr.ConstructorArguments[0].Value as INamedTypeSymbol;
if (targetType is null) return null;

Expand Down Expand Up @@ -527,7 +527,11 @@
var attr = symbol.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == QueryDtoAttributeFqn);

var nameArg = attr.NamedArguments.FirstOrDefault(a => a.Key == "Name").Value.Value as string;

Check warning on line 530 in packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Dereference of a possibly null reference.
var sortable = attr.NamedArguments.FirstOrDefault(a => a.Key == "Sortable").Value.Value is true;
var defaultSort = attr.NamedArguments.FirstOrDefault(a => a.Key == "DefaultSort").Value.Value as string;
var defaultSortDirectionRaw = attr.NamedArguments.FirstOrDefault(a => a.Key == "DefaultSortDirection").Value.Value;
var defaultSortDirection = defaultSortDirectionRaw is int d ? d : 0;

var properties = new List<QueryPropertyInfo>();
foreach (var prop in GetAllProperties(symbol))
Expand Down Expand Up @@ -559,7 +563,10 @@
symbol.Name, ns,
SanitizeHintName(symbol.ToDisplayString().Replace(".", "_")),
nameArg ?? $"{symbol.Name}Query",
properties);
properties,
sortable,
defaultSort,
defaultSortDirection);
} catch { return null; } }

private static List<DtoPropertyInfo> CollectPropertiesFromType(INamedTypeSymbol type)
Expand Down Expand Up @@ -926,7 +933,8 @@

private static readonly HashSet<string> ValidationNamespaces = new()
{
"System.ComponentModel.DataAnnotations"
"System.ComponentModel.DataAnnotations",
"ZibStack.NET.Validation"
};

private static List<string> GetValidationAttributes(IPropertySymbol prop)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,8 @@ private static string GenerateQueryDtoSource(QueryDtoInfo info)
sb.AppendLine();
sb.AppendLine("using System;");
sb.AppendLine("using System.Linq;");
if (info.Sortable)
sb.AppendLine("using ZibStack.NET.Dto;");
sb.AppendLine();

if (info.Namespace is not null)
Expand All @@ -546,6 +548,16 @@ private static string GenerateQueryDtoSource(QueryDtoInfo info)
sb.AppendLine($" public {prop.NullableTypeName} {prop.PropertyName} {{ get; init; }}");
}

// Sorting properties
if (info.Sortable)
{
sb.AppendLine();
sb.AppendLine(" /// <summary>Property name to sort by. Case-insensitive.</summary>");
sb.AppendLine(" public string? SortBy { get; init; }");
sb.AppendLine(" /// <summary>Sort direction. Defaults to Asc.</summary>");
sb.AppendLine(" public SortDirection? SortDirection { get; init; }");
}

sb.AppendLine();

// ApplyFilter
Expand All @@ -562,6 +574,42 @@ private static string GenerateQueryDtoSource(QueryDtoInfo info)
sb.AppendLine(" return query;");
sb.AppendLine(" }");

// ApplySort
if (info.Sortable)
{
sb.AppendLine();
sb.AppendLine($" public IQueryable<{info.ClassName}> ApplySort(IQueryable<{info.ClassName}> query)");
sb.AppendLine(" {");

var defaultSortStr = info.DefaultSort is not null ? $"\"{info.DefaultSort}\"" : "null";
var defaultDirStr = info.DefaultSortDirection == 1 ? "SortDirection.Desc" : "SortDirection.Asc";
sb.AppendLine($" var sortBy = SortBy ?? {defaultSortStr};");
sb.AppendLine($" var direction = SortDirection ?? {defaultDirStr};");
sb.AppendLine();
sb.AppendLine(" if (sortBy is null) return query;");
sb.AppendLine();
sb.AppendLine(" return (sortBy.ToLowerInvariant(), direction) switch");
sb.AppendLine(" {");
foreach (var prop in info.Properties)
{
var lower = prop.PropertyName.ToLowerInvariant();
sb.AppendLine($" (\"{lower}\", ZibStack.NET.Dto.SortDirection.Asc) => query.OrderBy(x => x.{prop.PropertyName}),");
sb.AppendLine($" (\"{lower}\", ZibStack.NET.Dto.SortDirection.Desc) => query.OrderByDescending(x => x.{prop.PropertyName}),");
}
sb.AppendLine(" _ => query,");
sb.AppendLine(" };");
sb.AppendLine(" }");

// Convenience: ApplyFilterAndSort
sb.AppendLine();
sb.AppendLine($" public IQueryable<{info.ClassName}> Apply(IQueryable<{info.ClassName}> query)");
sb.AppendLine(" {");
sb.AppendLine(" query = ApplyFilter(query);");
sb.AppendLine(" query = ApplySort(query);");
sb.AppendLine(" return query;");
sb.AppendLine(" }");
}

sb.AppendLine("}");
return sb.ToString();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
ctx.AddSource("OmitFromAttribute.g.cs", OmitFromAttributeSource);
ctx.AddSource("QueryDtoAttribute.g.cs", QueryDtoAttributeSource);
ctx.AddSource("ResponseIgnoreAttribute.g.cs", ResponseIgnoreAttributeSource);
ctx.AddSource("PaginatedResponse.g.cs", PaginatedResponseSource);
ctx.AddSource("SortDirection.g.cs", SortDirectionSource);
ctx.AddSource("IDtoValidator.g.cs", DtoValidatorInterfaceSource);
ctx.AddSource("CreateDtoForAttribute.g.cs", CreateDtoForAttributeSource);
ctx.AddSource("UpdateDtoForAttribute.g.cs", UpdateDtoForAttributeSource);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,19 @@ internal sealed class QueryDtoInfo
public string FullyQualifiedName { get; }
public string QueryName { get; }
public List<QueryPropertyInfo> Properties { get; }
public bool Sortable { get; }
public string? DefaultSort { get; }
public int DefaultSortDirection { get; } // 0 = Asc, 1 = Desc

public QueryDtoInfo(string className, string? ns, string fullyQualifiedName, string queryName, List<QueryPropertyInfo> properties)
public QueryDtoInfo(string className, string? ns, string fullyQualifiedName, string queryName, List<QueryPropertyInfo> properties, bool sortable = false, string? defaultSort = null, int defaultSortDirection = 0)
{
ClassName = className;
Namespace = ns;
FullyQualifiedName = fullyQualifiedName;
QueryName = queryName;
Properties = properties;
Sortable = sortable;
DefaultSort = defaultSort;
DefaultSortDirection = defaultSortDirection;
}
}
Loading
Loading