diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 7bc61f19..4e375c8d 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -7,7 +7,7 @@ on: branches: - master env: - VERSION: 10.0.11 + VERSION: 10.0.12 jobs: build-and-deploy: runs-on: ubuntu-latest diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ApiClients.Audit/AGENTS.md b/Api.ApiClients.Audit/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.ApiClients.Audit/AGENTS.md +++ b/Api.ApiClients.Audit/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.ApiClients.Audit/Api.ApiClients.Audit.Service.Models/Criterias/ExampleQueryCriteria.cs b/Api.ApiClients.Audit/Api.ApiClients.Audit.Service.Models/Criterias/ExampleQueryCriteria.cs index 79df8273..74d1d40b 100644 --- a/Api.ApiClients.Audit/Api.ApiClients.Audit.Service.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.ApiClients.Audit/Api.ApiClients.Audit.Service.Models/Criterias/ExampleQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(Example.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ApiClients.Entity/AGENTS.md b/Api.ApiClients.Entity/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.ApiClients.Entity/AGENTS.md +++ b/Api.ApiClients.Entity/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.ApiClients.Entity/Api.ApiClients.Entity.Service.Models/Criterias/ExampleQueryCriteria.cs b/Api.ApiClients.Entity/Api.ApiClients.Entity.Service.Models/Criterias/ExampleQueryCriteria.cs index ab142fc1..a5a703f7 100644 --- a/Api.ApiClients.Entity/Api.ApiClients.Entity.Service.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.ApiClients.Entity/Api.ApiClients.Entity.Service.Models/Criterias/ExampleQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(Example.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ApiClients.RootLogIn/AGENTS.md b/Api.ApiClients.RootLogIn/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.ApiClients.RootLogIn/AGENTS.md +++ b/Api.ApiClients.RootLogIn/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ApiClients/AGENTS.md b/Api.ApiClients/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.ApiClients/AGENTS.md +++ b/Api.ApiClients/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Auth.External.Custom/AGENTS.md b/Api.Auth.External.Custom/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Auth.External.Custom/AGENTS.md +++ b/Api.Auth.External.Custom/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-entity/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-entity.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Auth.External.Microsoft/AGENTS.md b/Api.Auth.External.Microsoft/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Auth.External.Microsoft/AGENTS.md +++ b/Api.Auth.External.Microsoft/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Auth.RootLogin/AGENTS.md b/Api.Auth.RootLogin/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Auth.RootLogin/AGENTS.md +++ b/Api.Auth.RootLogin/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md b/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Authorization/.github/prompts/nano-add-entity.prompt.md b/Api.Authorization/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Authorization/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Authorization/AGENTS.md b/Api.Authorization/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Authorization/AGENTS.md +++ b/Api.Authorization/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ContentNegotiation/AGENTS.md b/Api.ContentNegotiation/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.ContentNegotiation/AGENTS.md +++ b/Api.ContentNegotiation/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md b/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Cookies/.github/prompts/nano-add-entity.prompt.md b/Api.Cookies/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Cookies/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Cookies/AGENTS.md b/Api.Cookies/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Cookies/AGENTS.md +++ b/Api.Cookies/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.CustomConfigSection/AGENTS.md b/Api.CustomConfigSection/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.CustomConfigSection/AGENTS.md +++ b/Api.CustomConfigSection/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.CustomMiddleware/AGENTS.md b/Api.CustomMiddleware/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.CustomMiddleware/AGENTS.md +++ b/Api.CustomMiddleware/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.CustomService/.github/prompts/nano-add-entity.prompt.md b/Api.CustomService/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.CustomService/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.CustomService/AGENTS.md b/Api.CustomService/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.CustomService/AGENTS.md +++ b/Api.CustomService/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Audit/AGENTS.md b/Api.Data.Audit/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.Audit/AGENTS.md +++ b/Api.Data.Audit/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.Audit/Api.Data.Audit.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.Audit/Api.Data.Audit.Models/Criterias/ExampleQueryCriteria.cs index d48bdb58..990569d4 100644 --- a/Api.Data.Audit/Api.Data.Audit.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.Audit/Api.Data.Audit.Models/Criterias/ExampleQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(Example.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.EntityEvents/AGENTS.md b/Api.Data.EntityEvents/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.EntityEvents/AGENTS.md +++ b/Api.Data.EntityEvents/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Identity.Auth.ApiKey/AGENTS.md b/Api.Data.Identity.Auth.ApiKey/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.Identity.Auth.ApiKey/AGENTS.md +++ b/Api.Data.Identity.Auth.ApiKey/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.Identity.Auth.ApiKey/Api.Data.Identity.Auth.ApiKey.Models/Criterias/UserQueryCriteria.cs b/Api.Data.Identity.Auth.ApiKey/Api.Data.Identity.Auth.ApiKey.Models/Criterias/UserQueryCriteria.cs index df43c6bd..8d2c283b 100644 --- a/Api.Data.Identity.Auth.ApiKey/Api.Data.Identity.Auth.ApiKey.Models/Criterias/UserQueryCriteria.cs +++ b/Api.Data.Identity.Auth.ApiKey/Api.Data.Identity.Auth.ApiKey.Models/Criterias/UserQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(User.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Identity.Auth.External.Custom/AGENTS.md b/Api.Data.Identity.Auth.External.Custom/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.Identity.Auth.External.Custom/AGENTS.md +++ b/Api.Data.Identity.Auth.External.Custom/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.Identity.Auth.External.Custom/Api.Data.Identity.Auth.External.Custom.Models/Criterias/UserQueryCriteria.cs b/Api.Data.Identity.Auth.External.Custom/Api.Data.Identity.Auth.External.Custom.Models/Criterias/UserQueryCriteria.cs index 141f45ca..504859dc 100644 --- a/Api.Data.Identity.Auth.External.Custom/Api.Data.Identity.Auth.External.Custom.Models/Criterias/UserQueryCriteria.cs +++ b/Api.Data.Identity.Auth.External.Custom/Api.Data.Identity.Auth.External.Custom.Models/Criterias/UserQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(User.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Identity.Auth.Jwt/AGENTS.md b/Api.Data.Identity.Auth.Jwt/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.Identity.Auth.Jwt/AGENTS.md +++ b/Api.Data.Identity.Auth.Jwt/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.Identity.Auth.Jwt/Api.Data.Identity.Auth.Jwt.Models/Criterias/UserQueryCriteria.cs b/Api.Data.Identity.Auth.Jwt/Api.Data.Identity.Auth.Jwt.Models/Criterias/UserQueryCriteria.cs index 3f098bda..414a8728 100644 --- a/Api.Data.Identity.Auth.Jwt/Api.Data.Identity.Auth.Jwt.Models/Criterias/UserQueryCriteria.cs +++ b/Api.Data.Identity.Auth.Jwt/Api.Data.Identity.Auth.Jwt.Models/Criterias/UserQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(User.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Identity/AGENTS.md b/Api.Data.Identity/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.Identity/AGENTS.md +++ b/Api.Data.Identity/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.Identity/Api.Data.Identity.Models/Criterias/UserQueryCriteria.cs b/Api.Data.Identity/Api.Data.Identity.Models/Criterias/UserQueryCriteria.cs index 38bd2c2c..f491810d 100644 --- a/Api.Data.Identity/Api.Data.Identity.Models/Criterias/UserQueryCriteria.cs +++ b/Api.Data.Identity/Api.Data.Identity.Models/Criterias/UserQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(User.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.InMemory/AGENTS.md b/Api.Data.InMemory/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.InMemory/AGENTS.md +++ b/Api.Data.InMemory/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.InMemory/Api.Data.InMemory.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.InMemory/Api.Data.InMemory.Models/Criterias/ExampleQueryCriteria.cs index afea6960..b677a3b8 100644 --- a/Api.Data.InMemory/Api.Data.InMemory.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.InMemory/Api.Data.InMemory.Models/Criterias/ExampleQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith("Name", this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.LazyLoading/AGENTS.md b/Api.Data.LazyLoading/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.LazyLoading/AGENTS.md +++ b/Api.Data.LazyLoading/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.MySql.Collation/AGENTS.md b/Api.Data.MySql.Collation/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.MySql.Collation/AGENTS.md +++ b/Api.Data.MySql.Collation/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.MySql.Collation/Api.Data.MySql.Collation.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.MySql.Collation/Api.Data.MySql.Collation.Models/Criterias/ExampleQueryCriteria.cs index 51548e14..fa33deb5 100644 --- a/Api.Data.MySql.Collation/Api.Data.MySql.Collation.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.MySql.Collation/Api.Data.MySql.Collation.Models/Criterias/ExampleQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(Example.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.MySql.Mappings/AGENTS.md b/Api.Data.MySql.Mappings/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.MySql.Mappings/AGENTS.md +++ b/Api.Data.MySql.Mappings/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings.Models/Criterias/ExampleNormalizedQueryCriteria.cs b/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings.Models/Criterias/ExampleNormalizedQueryCriteria.cs index 2351fae2..680ef789 100644 --- a/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings.Models/Criterias/ExampleNormalizedQueryCriteria.cs +++ b/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings.Models/Criterias/ExampleNormalizedQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.FullName)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(ExampleNormalized.FullNameNormalized), this.FullName.ToUpper()); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.MySql.Spatial/AGENTS.md b/Api.Data.MySql.Spatial/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.MySql.Spatial/AGENTS.md +++ b/Api.Data.MySql.Spatial/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.MySql.Spatial/Api.Data.MySql.Spatial.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.MySql.Spatial/Api.Data.MySql.Spatial.Models/Criterias/ExampleQueryCriteria.cs index 52e5dc67..bb104577 100644 --- a/Api.Data.MySql.Spatial/Api.Data.MySql.Spatial.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.MySql.Spatial/Api.Data.MySql.Spatial.Models/Criterias/ExampleQueryCriteria.cs @@ -24,7 +24,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (this.Point != null) { @@ -38,9 +38,6 @@ public override IList GetExpressions() .StartsWith(nameof(Example.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.MySql.StoredProcedures/AGENTS.md b/Api.Data.MySql.StoredProcedures/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.MySql.StoredProcedures/AGENTS.md +++ b/Api.Data.MySql.StoredProcedures/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.MySql.Views/AGENTS.md b/Api.Data.MySql.Views/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.MySql.Views/AGENTS.md +++ b/Api.Data.MySql.Views/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.MySql.Views/Api.Data.MySql.Views.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.MySql.Views/Api.Data.MySql.Views.Models/Criterias/ExampleQueryCriteria.cs index b49bb4b8..bd34396b 100644 --- a/Api.Data.MySql.Views/Api.Data.MySql.Views.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.MySql.Views/Api.Data.MySql.Views.Models/Criterias/ExampleQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(Example.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.MySql/AGENTS.md b/Api.Data.MySql/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.MySql/AGENTS.md +++ b/Api.Data.MySql/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.MySql/Api.Data.MySql.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.MySql/Api.Data.MySql.Models/Criterias/ExampleQueryCriteria.cs index 6da5e5ba..7123df38 100644 --- a/Api.Data.MySql/Api.Data.MySql.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.MySql/Api.Data.MySql.Models/Criterias/ExampleQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(Example.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-entity.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.PostgreSQL.Spatial/AGENTS.md b/Api.Data.PostgreSQL.Spatial/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.PostgreSQL.Spatial/AGENTS.md +++ b/Api.Data.PostgreSQL.Spatial/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.PostgreSQL.Spatial/Api.Data.PostgreSQL.Spatial.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.PostgreSQL.Spatial/Api.Data.PostgreSQL.Spatial.Models/Criterias/ExampleQueryCriteria.cs index 0ad07cb6..9414249b 100644 --- a/Api.Data.PostgreSQL.Spatial/Api.Data.PostgreSQL.Spatial.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.PostgreSQL.Spatial/Api.Data.PostgreSQL.Spatial.Models/Criterias/ExampleQueryCriteria.cs @@ -24,7 +24,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (this.Point != null) { @@ -38,9 +38,6 @@ public override IList GetExpressions() .StartsWith(nameof(Example.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.PostgreSQL/AGENTS.md b/Api.Data.PostgreSQL/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.PostgreSQL/AGENTS.md +++ b/Api.Data.PostgreSQL/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.PostgreSQL/Api.Data.PostgreSQL.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.PostgreSQL/Api.Data.PostgreSQL.Models/Criterias/ExampleQueryCriteria.cs index 905a7d9e..ee05575d 100644 --- a/Api.Data.PostgreSQL/Api.Data.PostgreSQL.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.PostgreSQL/Api.Data.PostgreSQL.Models/Criterias/ExampleQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(Example.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Repository.AutoSave/AGENTS.md b/Api.Data.Repository.AutoSave/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.Repository.AutoSave/AGENTS.md +++ b/Api.Data.Repository.AutoSave/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Repository.Includes/AGENTS.md b/Api.Data.Repository.Includes/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.Repository.Includes/AGENTS.md +++ b/Api.Data.Repository.Includes/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.SoftDelete/AGENTS.md b/Api.Data.SoftDelete/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.SoftDelete/AGENTS.md +++ b/Api.Data.SoftDelete/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.SoftDelete/Api.Data.SoftDelete.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.SoftDelete/Api.Data.SoftDelete.Models/Criterias/ExampleQueryCriteria.cs index e4b3d5a3..3fc17221 100644 --- a/Api.Data.SoftDelete/Api.Data.SoftDelete.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.SoftDelete/Api.Data.SoftDelete.Models/Criterias/ExampleQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(Example.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.SqLite/AGENTS.md b/Api.Data.SqLite/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.SqLite/AGENTS.md +++ b/Api.Data.SqLite/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.SqLite/Api.Data.SqLite.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.SqLite/Api.Data.SqLite.Models/Criterias/ExampleQueryCriteria.cs index 0393a6de..38b322a2 100644 --- a/Api.Data.SqLite/Api.Data.SqLite.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.SqLite/Api.Data.SqLite.Models/Criterias/ExampleQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(Example.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-entity.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.SqlServer.Spatial/AGENTS.md b/Api.Data.SqlServer.Spatial/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.SqlServer.Spatial/AGENTS.md +++ b/Api.Data.SqlServer.Spatial/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.SqlServer.Spatial/Api.Data.SqlServer.Spatial.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.SqlServer.Spatial/Api.Data.SqlServer.Spatial.Models/Criterias/ExampleQueryCriteria.cs index 6fe94854..555d880c 100644 --- a/Api.Data.SqlServer.Spatial/Api.Data.SqlServer.Spatial.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.SqlServer.Spatial/Api.Data.SqlServer.Spatial.Models/Criterias/ExampleQueryCriteria.cs @@ -24,7 +24,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (this.Point != null) { @@ -38,9 +38,6 @@ public override IList GetExpressions() .StartsWith(nameof(Example.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.SqlServer/AGENTS.md b/Api.Data.SqlServer/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.SqlServer/AGENTS.md +++ b/Api.Data.SqlServer/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Data.SqlServer/Api.Data.SqlServer.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.SqlServer/Api.Data.SqlServer.Models/Criterias/ExampleQueryCriteria.cs index 5f58eaa5..22ded176 100644 --- a/Api.Data.SqlServer/Api.Data.SqlServer.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.SqlServer/Api.Data.SqlServer.Models/Criterias/ExampleQueryCriteria.cs @@ -18,7 +18,7 @@ public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -26,9 +26,6 @@ public override IList GetExpressions() .StartsWith(nameof(Example.Name), this.Name); } - expressions - .Add(expression); - return expressions; } } \ No newline at end of file diff --git a/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Data.Triggers/AGENTS.md b/Api.Data.Triggers/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Data.Triggers/AGENTS.md +++ b/Api.Data.Triggers/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Documentation.Csp/AGENTS.md b/Api.Documentation.Csp/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Documentation.Csp/AGENTS.md +++ b/Api.Documentation.Csp/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md b/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Documentation/.github/prompts/nano-add-entity.prompt.md b/Api.Documentation/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Documentation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Documentation/AGENTS.md b/Api.Documentation/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Documentation/AGENTS.md +++ b/Api.Documentation/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ErrorHandling/AGENTS.md b/Api.ErrorHandling/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.ErrorHandling/AGENTS.md +++ b/Api.ErrorHandling/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Eventing.RabbitMq/AGENTS.md b/Api.Eventing.RabbitMq/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Eventing.RabbitMq/AGENTS.md +++ b/Api.Eventing.RabbitMq/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.HealthChecks/AGENTS.md b/Api.HealthChecks/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.HealthChecks/AGENTS.md +++ b/Api.HealthChecks/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Hosting.Http/AGENTS.md b/Api.Hosting.Http/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Hosting.Http/AGENTS.md +++ b/Api.Hosting.Http/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Hosting.Https/AGENTS.md b/Api.Hosting.Https/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Hosting.Https/AGENTS.md +++ b/Api.Hosting.Https/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Hosting.MultipartLimits/AGENTS.md b/Api.Hosting.MultipartLimits/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Hosting.MultipartLimits/AGENTS.md +++ b/Api.Hosting.MultipartLimits/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Localization/.claude/skills/nano-add-entity/SKILL.md b/Api.Localization/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Localization/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Localization/.github/prompts/nano-add-entity.prompt.md b/Api.Localization/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Localization/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Localization/AGENTS.md b/Api.Localization/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Localization/AGENTS.md +++ b/Api.Localization/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Logging.Log4Net/AGENTS.md b/Api.Logging.Log4Net/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Logging.Log4Net/AGENTS.md +++ b/Api.Logging.Log4Net/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Logging.Microsoft/AGENTS.md b/Api.Logging.Microsoft/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Logging.Microsoft/AGENTS.md +++ b/Api.Logging.Microsoft/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Logging.NLog/AGENTS.md b/Api.Logging.NLog/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Logging.NLog/AGENTS.md +++ b/Api.Logging.NLog/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Logging.Serilog/AGENTS.md b/Api.Logging.Serilog/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Logging.Serilog/AGENTS.md +++ b/Api.Logging.Serilog/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md b/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Metrics/.github/prompts/nano-add-entity.prompt.md b/Api.Metrics/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Metrics/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Metrics/AGENTS.md b/Api.Metrics/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Metrics/AGENTS.md +++ b/Api.Metrics/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.MultipartJson/AGENTS.md b/Api.MultipartJson/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.MultipartJson/AGENTS.md +++ b/Api.MultipartJson/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md +++ b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.Cors/AGENTS.md b/Api.PolicyHeaders.Cors/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.PolicyHeaders.Cors/AGENTS.md +++ b/Api.PolicyHeaders.Cors/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md +++ b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.FrameOptions/AGENTS.md b/Api.PolicyHeaders.FrameOptions/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.PolicyHeaders.FrameOptions/AGENTS.md +++ b/Api.PolicyHeaders.FrameOptions/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.Hsts/AGENTS.md b/Api.PolicyHeaders.Hsts/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.PolicyHeaders.Hsts/AGENTS.md +++ b/Api.PolicyHeaders.Hsts/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.Robots/AGENTS.md b/Api.PolicyHeaders.Robots/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.PolicyHeaders.Robots/AGENTS.md +++ b/Api.PolicyHeaders.Robots/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.PolicyHeaders.XssProtection/AGENTS.md b/Api.PolicyHeaders.XssProtection/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.PolicyHeaders.XssProtection/AGENTS.md +++ b/Api.PolicyHeaders.XssProtection/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.RequestTracing/AGENTS.md b/Api.RequestTracing/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.RequestTracing/AGENTS.md +++ b/Api.RequestTracing/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ResponseCache/AGENTS.md b/Api.ResponseCache/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.ResponseCache/AGENTS.md +++ b/Api.ResponseCache/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.ResponseCompression/AGENTS.md b/Api.ResponseCompression/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.ResponseCompression/AGENTS.md +++ b/Api.ResponseCompression/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Session/.claude/skills/nano-add-entity/SKILL.md b/Api.Session/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Session/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Session/.github/prompts/nano-add-entity.prompt.md b/Api.Session/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Session/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Session/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Session/AGENTS.md b/Api.Session/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Session/AGENTS.md +++ b/Api.Session/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.StartupTasks/AGENTS.md b/Api.StartupTasks/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.StartupTasks/AGENTS.md +++ b/Api.StartupTasks/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.StaticFiles/AGENTS.md b/Api.StaticFiles/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.StaticFiles/AGENTS.md +++ b/Api.StaticFiles/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Storage.Azure/AGENTS.md b/Api.Storage.Azure/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Storage.Azure/AGENTS.md +++ b/Api.Storage.Azure/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Storage.Local/AGENTS.md b/Api.Storage.Local/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Storage.Local/AGENTS.md +++ b/Api.Storage.Local/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md b/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.TimeZone/AGENTS.md b/Api.TimeZone/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.TimeZone/AGENTS.md +++ b/Api.TimeZone/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md b/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Versioning/.github/prompts/nano-add-entity.prompt.md b/Api.Versioning/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.Versioning/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.Versioning/AGENTS.md b/Api.Versioning/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.Versioning/AGENTS.md +++ b/Api.Versioning/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md b/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api.VirusScan/AGENTS.md b/Api.VirusScan/AGENTS.md index 65129246..34adbeba 100644 --- a/Api.VirusScan/AGENTS.md +++ b/Api.VirusScan/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Api._Blank/.claude/skills/nano-add-entity/SKILL.md b/Api._Blank/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Api._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api._Blank/.github/prompts/nano-add-entity.prompt.md b/Api._Blank/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Api._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Api._Blank/AGENTS.md b/Api._Blank/AGENTS.md index 65129246..34adbeba 100644 --- a/Api._Blank/AGENTS.md +++ b/Api._Blank/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.CustomConfigSection/AGENTS.md b/Console.CustomConfigSection/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.CustomConfigSection/AGENTS.md +++ b/Console.CustomConfigSection/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md b/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.CustomService/.github/prompts/nano-add-entity.prompt.md b/Console.CustomService/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.CustomService/.github/prompts/nano-add-entity.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.CustomService/AGENTS.md b/Console.CustomService/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.CustomService/AGENTS.md +++ b/Console.CustomService/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Data.InMemory/AGENTS.md b/Console.Data.InMemory/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.Data.InMemory/AGENTS.md +++ b/Console.Data.InMemory/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Data.MySql/AGENTS.md b/Console.Data.MySql/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.Data.MySql/AGENTS.md +++ b/Console.Data.MySql/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Data.PostgreSQL/AGENTS.md b/Console.Data.PostgreSQL/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.Data.PostgreSQL/AGENTS.md +++ b/Console.Data.PostgreSQL/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Data.SqLite/AGENTS.md b/Console.Data.SqLite/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.Data.SqLite/AGENTS.md +++ b/Console.Data.SqLite/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Data.SqlServer/AGENTS.md b/Console.Data.SqlServer/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.Data.SqlServer/AGENTS.md +++ b/Console.Data.SqlServer/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Eventing.RabbitMq/AGENTS.md b/Console.Eventing.RabbitMq/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.Eventing.RabbitMq/AGENTS.md +++ b/Console.Eventing.RabbitMq/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.ExceptionHandling/AGENTS.md b/Console.ExceptionHandling/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.ExceptionHandling/AGENTS.md +++ b/Console.ExceptionHandling/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.Localization/.claude/skills/nano-add-entity/SKILL.md b/Console.Localization/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.Localization/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Localization/.github/prompts/nano-add-entity.prompt.md b/Console.Localization/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.Localization/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Localization/AGENTS.md b/Console.Localization/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.Localization/AGENTS.md +++ b/Console.Localization/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Logging.Log4Net/AGENTS.md b/Console.Logging.Log4Net/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.Logging.Log4Net/AGENTS.md +++ b/Console.Logging.Log4Net/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Logging.Microsoft/AGENTS.md b/Console.Logging.Microsoft/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.Logging.Microsoft/AGENTS.md +++ b/Console.Logging.Microsoft/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Logging.NLog/AGENTS.md b/Console.Logging.NLog/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.Logging.NLog/AGENTS.md +++ b/Console.Logging.NLog/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Logging.Serilog/AGENTS.md b/Console.Logging.Serilog/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.Logging.Serilog/AGENTS.md +++ b/Console.Logging.Serilog/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.StartupTasks/AGENTS.md b/Console.StartupTasks/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.StartupTasks/AGENTS.md +++ b/Console.StartupTasks/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Storage.Azure/AGENTS.md b/Console.Storage.Azure/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.Storage.Azure/AGENTS.md +++ b/Console.Storage.Azure/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Storage.Local/AGENTS.md b/Console.Storage.Local/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.Storage.Local/AGENTS.md +++ b/Console.Storage.Local/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console.Workers/.claude/skills/nano-add-entity/SKILL.md b/Console.Workers/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console.Workers/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Workers/.github/prompts/nano-add-entity.prompt.md b/Console.Workers/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console.Workers/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console.Workers/AGENTS.md b/Console.Workers/AGENTS.md index 65129246..34adbeba 100644 --- a/Console.Workers/AGENTS.md +++ b/Console.Workers/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Console._Blank/.claude/skills/nano-add-entity/SKILL.md b/Console._Blank/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Console._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console._Blank/.github/prompts/nano-add-entity.prompt.md b/Console._Blank/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Console._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Console._Blank/AGENTS.md b/Console._Blank/AGENTS.md index 65129246..34adbeba 100644 --- a/Console._Blank/AGENTS.md +++ b/Console._Blank/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria diff --git a/Web._Blank/.claude/skills/nano-add-entity/SKILL.md b/Web._Blank/.claude/skills/nano-add-entity/SKILL.md index 2d1237bc..f42e5b6a 100644 --- a/Web._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Web._Blank/.github/prompts/nano-add-entity.prompt.md b/Web._Blank/.github/prompts/nano-add-entity.prompt.md index 589971d8..2e87fa5b 100644 --- a/Web._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-entity.prompt.md @@ -286,7 +286,7 @@ public class QueryCriteria : BaseQueryCriteria { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { @@ -294,14 +294,14 @@ public class QueryCriteria : BaseQueryCriteria .StartsWith(nameof(.NameNormalized), this.Name.ToUpper()); } - expressions - .Add(expression); - return expressions; } } ``` +- `base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so add the conditions to + that first expression and return the list as it is. Never `Add` that same expression again, it would be + combined with itself. Only `Add` a new `CriteriaExpression` for a separate group that is AND-ed in. - Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. diff --git a/Web._Blank/AGENTS.md b/Web._Blank/AGENTS.md index 65129246..34adbeba 100644 --- a/Web._Blank/AGENTS.md +++ b/Web._Blank/AGENTS.md @@ -258,7 +258,7 @@ public class MyUserController(ILogger logger, MyApi myApi, MyO | Attribute | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------ | | `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | -| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Query]` | Querystring parameter: a scalar, a list of scalars, or an object (sent as `Name.Property=value`, such as `Paging.Number=2`); optional `Name` override. | | `[Body]` | The JSON request body (one complex object). | | `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | | `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | @@ -297,6 +297,8 @@ public class SetMyFileRequest : BaseRequest 3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` (typed response — use `NamedStream` or `Stream` for file downloads). +**Paging a custom request**: give it one `[Query] public virtual Pagination? Paging { get; set; }` and have the client method set `Paging = paging`. It is sent as `Paging.Number`, `Paging.Count` and `Paging.Skip`, and the action binds it with `[FromQuery] Pagination? paging`, so `Skip` is kept. Never copy `Number` and `Count` into two scalar properties, that drops `Skip`. + **Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly whenever the route doesn't naturally match the response type, or the request has no typed response at all. @@ -2072,19 +2074,22 @@ public class MyEntityQueryCriteria : BaseQueryCriteria public override IList GetExpressions() { var expressions = base.GetExpressions(); - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + var expression = expressions.First(); if (!string.IsNullOrEmpty(this.Name)) { expression.StartsWith(nameof(MyEntity.Name), this.Name); } - expressions.Add(expression); return expressions; } } ``` +`base.GetExpressions()` already returns a list holding one `CriteriaExpression`, so the conditions go on that first +expression and the list is returned as it is. Never `Add` that same expression again: the list is AND-ed, so it would +be combined with itself. Only `Add` a new `CriteriaExpression` for a separate group (see [Combining criteria](#combining-criteria--sequential-not-grouped)). + Built on the [DynamicExpression](https://github.com/vivet/DynamicExpression) library — criteria properties are compiled into LINQ expressions against the entity, not hand-written `Where` clauses. @@ -2212,6 +2217,14 @@ filter by a property on a **collection navigation**, wrap the nested property in expression.GreaterThan("Orders[Total]", 100m); // x.Orders.Any(o => o.Total > 100) ``` +A negated operation (`NotEqual`, `NotIn`, `NotContains`) on a collection path means **no element matches**, it compiles to +`!x.Orders.Any(...)` with the positive operation inside. Use it to leave out rows that have a related row, such as the +users who are not in an organization: + +```csharp +expression.NotEqual("OrganizationScopes[OrganizationId]", organizationId); // !x.OrganizationScopes.Any(s => s.OrganizationId == organizationId) +``` + ##### Ordering and pagination `Ordering` (`IQuery.Order`) — `By` (default `"Id"`, supports the same dotted-path navigation as criteria