DATA-2: optional list params + normalize frontend's '==' Gridify operator

- GridifyParams wrapper: nullable page/pageSize/filter/orderBy so list endpoints
  work with no query params (Gridify's non-nullable int Page made [AsParameters]
  treat them as required -> 400). Defaults page=1,pageSize=20.
- Normalize incoming filter '==' -> '=' : the frontend gridify.ts emits '==' for
  equals (its convention) but Gridify's equals is '='. Safe (values are escaped;
  != >= <= =* contain no '=='). Fixes status==Active (frontend default that 500'd).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 01:00:35 +02:00
parent c937b6d5a8
commit 7627468b82
7 changed files with 39 additions and 12 deletions

View File

@@ -6,22 +6,49 @@ namespace GerbilManagerWebAPI.Common
/// <summary>Standard paged list envelope returned by every list endpoint.</summary> /// <summary>Standard paged list envelope returned by every list endpoint.</summary>
public record PagedResult<T>(IReadOnlyList<T> Items, int TotalCount, int Page, int PageSize); public record PagedResult<T>(IReadOnlyList<T> Items, int TotalCount, int Page, int PageSize);
/// <summary>
/// Query-string parameters for list endpoints. All optional (nullable) so the
/// frontend can call a list with no params; Gridify's own GridifyQuery has
/// non-nullable int Page/PageSize which [AsParameters] would make REQUIRED.
/// Bound via [AsParameters]: ?filter=…&orderBy=…&page=1&pageSize=20.
/// </summary>
public class GridifyParams
{
public string? Filter { get; set; }
public string? OrderBy { get; set; }
public int? Page { get; set; }
public int? PageSize { get; set; }
public GridifyQuery ToQuery() => new()
{
Filter = NormalizeFilter(Filter),
OrderBy = OrderBy,
Page = Page is > 0 ? Page.Value : 1,
PageSize = PageSize is > 0 ? PageSize.Value : 20,
};
// The frontend's gridify.ts emits "==" for equals (its documented convention),
// but Gridify's equals operator is a single "=". Translate "==" -> "=". This is
// safe because the frontend backslash-escapes any "=" inside values, and the
// other operators it uses ( != >= <= =* ) contain no literal "==".
private static string? NormalizeFilter(string? filter) =>
string.IsNullOrEmpty(filter) ? filter : filter.Replace("==", "=");
}
public static class QueryableExtensions public static class QueryableExtensions
{ {
/// <summary> /// <summary>
/// Apply a Gridify query (filter/order/page) to an EF query and project each /// Apply a Gridify query (filter/order/page) to an EF query and project each
/// row to a DTO, returning the standard paged envelope. Filter/orderBy names /// row to a DTO, returning the standard paged envelope. Filter/orderBy names
/// are the ENTITY property names (case-insensitive), e.g. "status==Active", /// are the ENTITY property names (case-insensitive), e.g. "status==Active",
/// "orderBy=dateOfBirth", "litterId==...". /// "orderBy=dateOfBirth", "litterId==".
/// </summary> /// </summary>
public static async Task<PagedResult<TDto>> ToPagedResultAsync<TEntity, TDto>( public static async Task<PagedResult<TDto>> ToPagedResultAsync<TEntity, TDto>(
this IQueryable<TEntity> source, this IQueryable<TEntity> source,
GridifyQuery query, GridifyParams parameters,
Func<TEntity, TDto> map) Func<TEntity, TDto> map)
{ {
query.Page = query.Page <= 0 ? 1 : query.Page; var query = parameters.ToQuery();
query.PageSize = query.PageSize <= 0 ? 20 : query.PageSize;
Paging<TEntity> paging = await source.GridifyAsync(query); Paging<TEntity> paging = await source.GridifyAsync(query);
var items = paging.Data.Select(map).ToList(); var items = paging.Data.Select(map).ToList();
return new PagedResult<TDto>(items, paging.Count, query.Page, query.PageSize); return new PagedResult<TDto>(items, paging.Count, query.Page, query.PageSize);

View File

@@ -13,7 +13,7 @@ namespace GerbilManagerWebAPI.Endpoints
{ {
var group = app.MapGroup("/color-varieties").WithTags("ColorVarieties"); var group = app.MapGroup("/color-varieties").WithTags("ColorVarieties");
group.MapGet("/", async ([AsParameters] GridifyQuery query, ApplicationContext db) => group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.ColorVarieties.AsNoTracking().OrderBy(v => v.SortOrder) TypedResults.Ok(await db.ColorVarieties.AsNoTracking().OrderBy(v => v.SortOrder)
.ToPagedResultAsync(query, ToDto))); .ToPagedResultAsync(query, ToDto)));

View File

@@ -13,7 +13,7 @@ namespace GerbilManagerWebAPI.Endpoints
{ {
var group = app.MapGroup("/contacts").WithTags("Contacts"); var group = app.MapGroup("/contacts").WithTags("Contacts");
group.MapGet("/", async ([AsParameters] GridifyQuery query, ApplicationContext db) => group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.Contacts.AsNoTracking().ToPagedResultAsync(query, ToDto))); TypedResults.Ok(await db.Contacts.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<ContactDto>, NotFound>> (Guid id, ApplicationContext db) => group.MapGet("/{id:guid}", async Task<Results<Ok<ContactDto>, NotFound>> (Guid id, ApplicationContext db) =>

View File

@@ -13,7 +13,7 @@ namespace GerbilManagerWebAPI.Endpoints
{ {
var group = app.MapGroup("/enclosures").WithTags("Enclosures"); var group = app.MapGroup("/enclosures").WithTags("Enclosures");
group.MapGet("/", async ([AsParameters] GridifyQuery query, ApplicationContext db) => group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.Enclosures.AsNoTracking().ToPagedResultAsync(query, ToDto))); TypedResults.Ok(await db.Enclosures.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<EnclosureDto>, NotFound>> (Guid id, ApplicationContext db) => group.MapGet("/{id:guid}", async Task<Results<Ok<EnclosureDto>, NotFound>> (Guid id, ApplicationContext db) =>

View File

@@ -15,7 +15,7 @@ namespace GerbilManagerWebAPI.Endpoints
var group = app.MapGroup("/gerbils").WithTags("Gerbils"); var group = app.MapGroup("/gerbils").WithTags("Gerbils");
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name) // GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
group.MapGet("/", async ([AsParameters] GridifyQuery query, ApplicationContext db) => group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.Gerbils.AsNoTracking() TypedResults.Ok(await db.Gerbils.AsNoTracking()
.ToPagedResultAsync(query, ToDto))); .ToPagedResultAsync(query, ToDto)));

View File

@@ -14,7 +14,7 @@ namespace GerbilManagerWebAPI.Endpoints
var group = app.MapGroup("/litters").WithTags("Litters"); var group = app.MapGroup("/litters").WithTags("Litters");
// GET /litters (Gridify: date range + orderBy=date supported on the DateOnly column) // GET /litters (Gridify: date range + orderBy=date supported on the DateOnly column)
group.MapGet("/", async ([AsParameters] GridifyQuery query, ApplicationContext db) => group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.Litters.AsNoTracking().ToPagedResultAsync(query, ToDto))); TypedResults.Ok(await db.Litters.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<LitterDto>, NotFound>> (Guid id, ApplicationContext db) => group.MapGet("/{id:guid}", async Task<Results<Ok<LitterDto>, NotFound>> (Guid id, ApplicationContext db) =>

View File

@@ -14,7 +14,7 @@ namespace GerbilManagerWebAPI.Endpoints
var group = app.MapGroup("/health-records").WithTags("HealthRecords"); var group = app.MapGroup("/health-records").WithTags("HealthRecords");
// GET /health-records?filter=gerbilId==… // GET /health-records?filter=gerbilId==…
group.MapGet("/", async ([AsParameters] GridifyQuery query, ApplicationContext db) => group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.HealthRecords.AsNoTracking().ToPagedResultAsync(query, ToDto))); TypedResults.Ok(await db.HealthRecords.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<HealthRecordDto>, NotFound>> (Guid id, ApplicationContext db) => group.MapGet("/{id:guid}", async Task<Results<Ok<HealthRecordDto>, NotFound>> (Guid id, ApplicationContext db) =>
@@ -66,7 +66,7 @@ namespace GerbilManagerWebAPI.Endpoints
{ {
var group = app.MapGroup("/weight-records").WithTags("WeightRecords"); var group = app.MapGroup("/weight-records").WithTags("WeightRecords");
group.MapGet("/", async ([AsParameters] GridifyQuery query, ApplicationContext db) => group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.WeightRecords.AsNoTracking().ToPagedResultAsync(query, ToDto))); TypedResults.Ok(await db.WeightRecords.AsNoTracking().ToPagedResultAsync(query, ToDto)));
group.MapGet("/{id:guid}", async Task<Results<Ok<WeightRecordDto>, NotFound>> (Guid id, ApplicationContext db) => group.MapGet("/{id:guid}", async Task<Results<Ok<WeightRecordDto>, NotFound>> (Guid id, ApplicationContext db) =>