Search

Includes and aggregates

Load related rows with their own search and a limit per parent, read @more, and add count, exists, min, max, avg and sum values of relations.

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.

app/Rest/Resources/CategoryResource.php
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

POST /api/products/search
{
  "search": {
    "includes": [
      {"relation": "category"}
    ]
  }
}
200 OK
{
  "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:

KeyUse
relationA relation that the resource declares, or a dotted path such as products.category
aliasThe key of the include in the row, see Aliases
filters, sorts, selects, scopes, instructions, aggregates, gatesAs in a search, with the fields, scopes and instructions of the related resource
limitThe most related rows for each parent row, one of the limits() of the related resource
includesNested includes
typesThe search of each target of a MorphTo relation
  • page, text and trashed give a 422 in an include: The search.includes.0.text field is prohibited.
  • A missing relation gives The search.includes.0.relation field is required., and an undeclared one The search.includes.0.relation field is not a valid or allowed relation for this resource. A path that ends with pivot, such as tags.pivot, gives a 422 too.
  • create in the gates of an include gives a 422, and so does gates in an include whose resource turns its gates off.
  • An include without alias renders under the name of its relation in snake case: featuredProducts renders as featured_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 and map(), and is not returned.
  • Each resource that an include reaches needs viewAny, checked before validation, and its perimeterQuery() applies to the related rows.
  • When includes lists 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:

POST /api/categories/search
{
  "search": {
    "includes": [
      {
        "relation": "products",
        "selects": [{"field": "id"}, {"field": "name"}],
        "sorts": [{"field": "price", "direction": "desc"}],
        "limit": 50
      }
    ]
  }
}
  • The limit is the limit of the include, or else the $includeLimit of the related resource, 10 by default.
  • The rows kept are the first ones in the order of the include: its sorts, or else the defaultOrderBy() of its resource, then the key.
  • This applies to every include other than BelongsTo and MorphTo: HasOne, HasMany, the many-to-many relations, the through relations and the *OfMany relations, at every level, in a search, in the search of an action and in the response block of a mutation.
  • limit must be one of the limits() of the related resource: The selected search.includes.0.limit is invalid. $includeLimit must be one of them too, otherwise the first read of the limits throws InvalidResourceException.
  • 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:

422 Unprocessable Content
{
  "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.

POST /api/categories/search
{
  "search": {
    "includes": [
      {"relation": "products", "selects": [{"field": "id"}, {"field": "name"}]}
    ]
  }
}
200 OK
{
  "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 @more to 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 and map().
  • rest.search.more_key in config/rest.php renames the key. A resource that declares a field of that name throws InvalidResourceException, and an include or aggregate alias of that name gives a 422.

Nested and dotted includes

POST /api/authors/search
{
  "search": {
    "includes": [
      {"relation": "posts", "includes": [{"relation": "comments", "limit": 25}]},
      {"relation": "tags.posts"}
    ]
  }
}
  • Includes nest up to rest.limits.depths.includes levels, 3 by default, and each relation of a dotted include counts: author.posts takes two levels.
  • The relation that a dotted include goes through, such as posts in posts.comments, is limited like an include: each parent row keeps the first $includeLimit rows of the relation, in the defaultOrderBy() of its resource, and its perimeterQuery() applies. List posts as an include of its own, with its limit and sorts, 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:

POST /api/categories/search
{
  "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() and virtual() 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 relation refuses alias: The search.includes.0.alias is not allowed on a nested relation, use a nested include instead.
  • Inside a MorphTo include, the type key (@type) is reserved. Inside a many-to-many include, the pivot key (pivot, or the name given to as() 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.

POST /api/labels/search
{
  "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, types holds the search of each type. Without types, every target loads and needs viewAny.
  • 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

POST /api/categories/search
{
  "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}]}
    ]
  }
}
200 OK
{
  "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": {}
}
KeyUse
relationA relation of the resource, or a path of relations such as tickets.tags
typecount, exists, min, max, avg or sum (Lomkit\Rest\Fields\AggregateFunction), required
fieldRequired for min, max, avg and sum; refused for count and exists
aliasThe key of the value in the row
filtersThe filters of the related rows, with the fields of their resource

Functions and fields

  • min and max take a field of the related resource that is sortable by its own column: sortable by default or declared sortable(), and neither virtual() nor writeOnly(). A selectable(false) field counts only when it is declared sortable(). sortUsing() does not count, because the aggregate reads the column.
  • min and max refuse a Boolean field. They accept an ID field with UUID keys on every database.
  • avg and sum also need a Number or Integer field, or an app type that extends Number.
  • Another field gives The selected search.aggregates.0.field is invalid.
  • An aggregate counts every related row: the limit of 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. A null alias gives The 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

POST /api/teams/search
{
  "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 needs viewAny.
  • A path holds at most rest.limits.depths.includes relations: The search.aggregates.0.relation field goes deeper than the limit of 3 include levels.
  • A MorphTo relation gives The search.aggregates.0.relation field cannot be a MorphTo relation., and a path through one The 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

LimitDefaultCounts
rest.limits.sizes.includes20The entries of each includes list
rest.limits.sizes.aggregates20The entries of each aggregates list
rest.limits.depths.includes3The levels of includes, the relations of a dotted include, of a filter path and of an aggregate path
rest.limits.sizes.rows10000The 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

StatusExample
403The user may not viewAny a resource that an include or an aggregate reaches
422The search.includes.0.sorts field is prohibited: this relation loads at most one record per parent.
422The search.includes.0.alias conflicts with a field of this resource.
422The search.aggregates.0.alias is already used by an include.
422The search.aggregates.0.relation field cannot be a MorphTo relation.