Includes and aggregates
Includes load the related rows of each row, each include with its own search. Aggregates add one value per row, computed over a relation. Both run in SQL, also next to a text search on a search engine.
use Lomkit\Rest\Fields\ID;
use Lomkit\Rest\Fields\Text;
use Lomkit\Rest\Relations\BelongsToMany;
use Lomkit\Rest\Relations\HasMany;
class CategoryResource extends Resource
{
public static string $model = Category::class;
public function fields(): array
{
return [ID::make('id'), Text::make('name')];
}
public function relations(): array
{
return [
HasMany::make('products', ProductResource::class),
BelongsToMany::make('featuredProducts', ProductResource::class),
];
}
}
Includes
{
"search": {
"includes": [
{"relation": "category"}
]
}
}
{
"current_page": 1,
"data": [
{"id": 2, "category_id": 1, "name": "Floor lamp", "price": 129, "category": {"id": 1, "name": "Lighting"}},
{"id": 1, "category_id": 1, "name": "Desk lamp", "price": 39, "category": {"id": 1, "name": "Lighting"}}
],
"from": 1,
"last_page": 1,
"per_page": 50,
"to": 2,
"total": 2,
"meta": {}
}
An include takes the keys of a search, read on the related resource:
| Key | Use |
|---|---|
relation | A relation that the resource declares, or a dotted path such as products.category |
alias | The key of the include in the row, see Aliases |
filters, sorts, selects, scopes, instructions, aggregates, gates | As in a search, with the fields, scopes and instructions of the related resource |
limit | The most related rows for each parent row, one of the limits() of the related resource |
includes | Nested includes |
types | The search of each target of a MorphTo relation |
page,textandtrashedgive a 422 in an include:The search.includes.0.text field is prohibited.- A missing
relationgivesThe search.includes.0.relation field is required., and an undeclared oneThe search.includes.0.relation field is not a valid or allowed relation for this resource.A path that ends withpivot, such astags.pivot, gives a 422 too. createin thegatesof an include gives a 422, and so doesgatesin an include whose resource turns its gates off.- An include without alias renders under the name of its relation in snake case:
featuredProductsrenders asfeatured_products. - A row returns only the relations that the search includes. A relation that the model loads by itself (
$with, a hook) stays loaded for policies, hooks andmap(), and is not returned. - Each resource that an include reaches needs
viewAny, checked before validation, and itsperimeterQuery()applies to the related rows. - When
includeslists one relation twice without an alias, the response renders the last entry.
Limits per parent
An include returns at most a set number of rows for each parent row:
{
"search": {
"includes": [
{
"relation": "products",
"selects": [{"field": "id"}, {"field": "name"}],
"sorts": [{"field": "price", "direction": "desc"}],
"limit": 50
}
]
}
}
- The limit is the
limitof the include, or else the$includeLimitof the related resource, 10 by default. - The rows kept are the first ones in the order of the include: its
sorts, or else thedefaultOrderBy()of its resource, then the key. - This applies to every include other than
BelongsToandMorphTo:HasOne,HasMany, the many-to-many relations, the through relations and the*OfManyrelations, at every level, in a search, in the search of an action and in theresponseblock of a mutation. limitmust be one of thelimits()of the related resource:The selected search.includes.0.limit is invalid.$includeLimitmust be one of them too, otherwise the first read of the limits throwsInvalidResourceException.- An include that needs more rows sends a higher
limit, or the resource sets a higher$includeLimit.
class ProductResource extends Resource
{
public int $includeLimit = 25;
}
A BelongsTo or MorphTo include loads the one related row of each parent row, without limit and without order. limit and sorts there give a 422:
{
"message": "The search.includes.0.limit field is prohibited: this relation loads at most one record per parent.",
"errors": {
"search.includes.0.limit": ["The search.includes.0.limit field is prohibited: this relation loads at most one record per parent."]
}
}
More rows
A row whose level includes a to-many relation (HasMany, MorphMany, BelongsToMany, MorphToMany, MorphedByMany or HasManyThrough) ends with @more: the keys of those includes that have more rows than they returned, [] when none.
{
"search": {
"includes": [
{"relation": "products", "selects": [{"field": "id"}, {"field": "name"}]}
]
}
}
{
"current_page": 1,
"data": [
{"id": 2, "name": "Furniture", "products": [{"id": 14, "name": "Desk"}, {"id": 13, "name": "Chair"}], "@more": []},
{"id": 1, "name": "Lighting", "products": [{"id": 12, "name": "Wall lamp"}, ...], "@more": ["products"]}
],
"from": 1,
"last_page": 1,
"per_page": 50,
"to": 2,
"total": 2,
"meta": {}
}
- Read
@moreto tell a client that a list is not complete, and fetch the rest with a search on the related resource. - Each such include reads one more row for each parent and drops it before
searched(), the gates andmap(). rest.search.more_keyinconfig/rest.phprenames the key. A resource that declares a field of that name throwsInvalidResourceException, and an include or aggregate alias of that name gives a 422.
Nested and dotted includes
{
"search": {
"includes": [
{"relation": "posts", "includes": [{"relation": "comments", "limit": 25}]},
{"relation": "tags.posts"}
]
}
}
- Includes nest up to
rest.limits.depths.includeslevels, 3 by default, and each relation of a dotted include counts:author.poststakes two levels. - The relation that a dotted include goes through, such as
postsinposts.comments, is limited like an include: each parent row keeps the first$includeLimitrows of the relation, in thedefaultOrderBy()of its resource, and itsperimeterQuery()applies. Listpostsas an include of its own, with itslimitandsorts, to choose its rows. - An include that a dotted include goes through keeps its own filters, sorts, scopes, limit and nested includes, whatever their order in
includes. - An owner that its perimeter hides renders
null, and the rows below a hidden row are not rendered.
Aliases
alias renames the key of an include, so that one relation can show several times under different searches:
{
"search": {
"includes": [
{"relation": "products", "alias": "cheap_products", "filters": [{"field": "price", "operator": "<", "value": 50}]},
{"relation": "products", "alias": "premium_products", "filters": [{"field": "price", "operator": ">=", "value": 500}]}
]
}
}
- An alias looks like an identifier,
^[A-Za-z_][A-Za-z0-9_]*$, and is used as written. - It must differ from the fields of the resource,
writeOnly()andvirtual()fields included, its relations, the keys of the other includes and aggregates of the same search, the gates key and@more:The search.includes.0.alias conflicts with a field of this resource.,The search.includes.0.alias is already used by an aggregate.,The search.includes.1.alias is already used by another include. - An include without alias whose key is the name of a field, a reserved key or the key of an aggregate gets
The search.includes.0.alias field is required, because the key posts is already used. - A dotted
relationrefusesalias:The search.includes.0.alias is not allowed on a nested relation, use a nested include instead. - Inside a
MorphToinclude, the type key (@type) is reserved. Inside a many-to-many include, the pivot key (pivot, or the name given toas()in snake case) is reserved.
Many-to-many rows
The rows of a BelongsToMany, MorphToMany or MorphedByMany include hold pivot, with the selectable pivot fields of the relation, {} when there are none:
{"id": 1, "name": "Mathematics", "pivot": {"weight": 3}}
A writeOnly() or selectable(false) pivot field is not returned. See Relations.
MorphTo includes
A MorphTo include returns each target with its type first, under @type (rest.morph.type_key): the morph-map alias of its model, or its class name.
{
"search": {
"includes": [
{
"relation": "labelable",
"types": {
"book": {"selects": [{"field": "title"}], "aggregates": [{"relation": "labels", "type": "count"}]},
"shelf": {}
}
}
]
}
}
{"id": 1, "text": "Classic", "labelable": {"@type": "book", "title": "Dune", "labels_count": 2}}
- With several targets,
typesholds the search of each type. Withouttypes, every target loads and needsviewAny. - A row whose stored type is not a target, or not a listed type, returns
null, without a query. - Filters, aggregates and nested includes apply on the query of the target.
Aggregates
{
"search": {
"aggregates": [
{"relation": "products", "type": "count"},
{"relation": "products", "type": "max", "field": "price"},
{"relation": "products", "type": "min", "field": "price", "alias": "cheapest", "filters": [{"field": "price", "operator": ">", "value": 0}]}
]
}
}
{
"current_page": 1,
"data": [
{"id": 1, "name": "Lighting", "products_count": 2, "products_max_price": 129, "cheapest": 39}
],
"from": 1,
"last_page": 1,
"per_page": 50,
"to": 1,
"total": 1,
"meta": {}
}
| Key | Use |
|---|---|
relation | A relation of the resource, or a path of relations such as tickets.tags |
type | count, exists, min, max, avg or sum (Lomkit\Rest\Fields\AggregateFunction), required |
field | Required for min, max, avg and sum; refused for count and exists |
alias | The key of the value in the row |
filters | The filters of the related rows, with the fields of their resource |
Functions and fields
minandmaxtake a field of the related resource that is sortable by its own column: sortable by default or declaredsortable(), and neithervirtual()norwriteOnly(). Aselectable(false)field counts only when it is declaredsortable().sortUsing()does not count, because the aggregate reads the column.minandmaxrefuse aBooleanfield. They accept anIDfield with UUID keys on every database.avgandsumalso need aNumberorIntegerfield, or an app type that extendsNumber.- Another field gives
The selected search.aggregates.0.field is invalid. - An aggregate counts every related row: the
limitof an include does not apply to it.
Keys
- The key of a value is its
alias, or<relation>_<type>_<field>, with the field name in lower case:products_max_price,products_count. - An alias looks like an identifier,
^[A-Za-z_][A-Za-z0-9_]*$, of at most 255 characters:The search.aggregates.0.alias field format is invalid.Anullalias givesThe search.aggregates.0.alias field must be a string.: leave the key out to keep the default name. - Each key of a row names one value. The key of an aggregate must differ from the fields and relations of the resource, the keys of the includes and of the other aggregates of the same search, and the gates key. A clash gives a 422 under
search.aggregates.0.alias:... conflicts with a field of this resource.,... conflicts with a relation of this resource.,... is already used by an include.,... is already used by another aggregate.or... is reserved. - Two aggregates with the same default key need an alias:
The search.aggregates.1.alias field is required, because the key products_count is already used. - A row returns its aggregates after its fields. Sort on the key of an aggregate over a relation to many with
sorts, see Sorts and selects.
Paths
{
"search": {
"aggregates": [{"relation": "tickets.tags", "type": "count"}]
}
}
- The aggregate reads the rows at the end of the path and counts each of them once, however many rows of the levels before lead to it. Its key joins the relations:
tickets_tags_count. - The
perimeterQuery()of the resource of each level and the global scopes of its model apply, and each resource needsviewAny. - A path holds at most
rest.limits.depths.includesrelations:The search.aggregates.0.relation field goes deeper than the limit of 3 include levels. - A
MorphTorelation givesThe search.aggregates.0.relation field cannot be a MorphTo relation., and a path through oneThe search.aggregates.0.relation field cannot go through a MorphTo relation.Aggregate a relation of the target inside the include instead, as in the MorphTo example above.
Limits
| Limit | Default | Counts |
|---|---|---|
rest.limits.sizes.includes | 20 | The entries of each includes list |
rest.limits.sizes.aggregates | 20 | The entries of each aggregates list |
rest.limits.depths.includes | 3 | The levels of includes, the relations of a dotted include, of a filter path and of an aggregate path |
rest.limits.sizes.rows | 10000 | The rows that one page can render, its included rows counted |
The rows are counted before any query, from the limits that the request asks for: each to-many include counts its limit for each parent row, nested includes multiplying, and a to-one include one row for each parent. The 422 goes on the first key that takes the page over: the limit of an include, or its relation when the include takes the default limit. See Response rows.
Errors
| Status | Example |
|---|---|
403 | The user may not viewAny a resource that an include or an aggregate reaches |
422 | The search.includes.0.sorts field is prohibited: this relation loads at most one record per parent. |
422 | The search.includes.0.alias conflicts with a field of this resource. |
422 | The search.aggregates.0.alias is already used by an include. |
422 | The search.aggregates.0.relation field cannot be a MorphTo relation. |