Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ Filters are auto-generated for all exposed fields and associations (src/Filter/)
- Date and time filter values are bound as the field's Doctrine type
- Filters are context-aware based on field type
- Can be excluded globally via Config or per-entity/field via attributes
- A filter's `_or` is a list of branches, any of which a row matches; a branch is of a `FilterBranch_<Type>` type, the same filters less `sort` and `sortPriority`, whose own `_or` is a list of itself. `Filter\QueryBuilder` builds each filter's condition as a string (binding its parameters) and the caller ANDs them, or ORs the branches; a branch with no conditions is `1 = 1` and `_or: []` is `1 = 0`. `filterDepth` and `filterConditions` limit `_or` before any DQL is built; only conditions inside `_or` are counted. No field may be named or aliased `_or`

### Resolution and Hydration

Expand Down Expand Up @@ -150,6 +151,8 @@ Filters are auto-generated for all exposed fields and associations (src/Filter/)
- `useHydratorCache` - Cache hydrator results for as long as the entity exists
- `useQueryResultCache` - Cache query results for identical SQL and parameters until the entity manager is cleared or flushed (`Cache\QueryResultCacheListener`)
- `limit` - Default limit on the rows of a connection (default: 1000); an entity's `limit`, then an association's, replaces it, even when larger
- `filterDepth` - How deeply a filter's `_or` may nest (default 3); 0 or null is unlimited
- `filterConditions` - The most conditions inside a filter's `_or` branches (default 100); 0 or null is unlimited
- `extractByValue` - Extract by value vs reference for every entity, overriding the `extractByValue` argument of the `#[Entity]` attribute
- `entityPrefix` - Remove prefix from type names
- `sortFields` - Sort fields alphabetically
Expand Down
25 changes: 25 additions & 0 deletions docs/driver.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Creating a Driver with all config options
'useNonNullTypes' => true,
'useQueryResultCache' => true,
'excludeFilters' => [Filters::CONTAINS],
'filterConditions' => 100,
'filterDepth' => 3,
'formatJsonAs' => JsonFormat::Object,
]);

Expand Down Expand Up @@ -104,6 +106,29 @@ extract by reference. When not set the individual entity attribute value
is used and that is, by default, extract by value.


filterConditions
----------------

The most filters the branches of a filter's `_or <queries.html#or>`_ may have
in all, including those of nested ``_or``. A filter given ``null`` is not
counted, nor are a computed field's ``args``; neither are the filters outside
``_or``, so a filter without one is never limited. An ``in`` list is one
filter whatever its length. Beyond it, the query is a client error. It must
be at least 0; 0 or ``null`` is unlimited. Default is 100.


filterDepth
-----------

How deeply a filter's `_or <queries.html#or>`_ may nest: an ``_or`` of the
filter is at depth 1, and an ``_or`` of one of its branches at depth 2.
Beyond it, the query is a client error. It must be at least 0; 0 or ``null``
is unlimited. Default is 3.

GraphQL's query depth and complexity rules count the fields a query selects,
not its arguments, so they do not limit a filter.


formatJsonAs
------------

Expand Down
41 changes: 38 additions & 3 deletions docs/queries.rst
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,40 @@ To select a list of years
}


All filters are **AND** filters. For **OR** support use multiple
queries and aggregate them.
Or
--

The filters of a ``filter`` are all matched: they are **AND** filters. Its
``_or`` is a list of filters, any of which a row matches:

.. code-block:: js

filter: {
id: { gt: 1 }
_or: [
{ name: { startswith: "G" } }
{ name: { eq: "Phish" }, year: { lt: 1990 } }
]
}

This matches rows whose ``id`` is greater than 1 and which either have a name
beginning with G, or are named Phish with a year before 1990. Each branch is a
filter whose own filters are all matched, and which may have an ``_or`` of
its own. A branch has no ``sort`` or ``sortPriority``, which mean nothing in
it; its type is named ``FilterBranch_`` followed by the entity's type name, as
the filter's is named ``Filter_``.

A branch with no filters, or whose filters are all given ``null``, matches
every row, so the whole ``_or`` does. An ``_or`` of no branches, ``_or: []``,
matches no row, as ``in: []`` does; ``_or: null`` is not applied.

How deeply ``_or`` may nest, and how many filters its branches may have in
all, are limited by the `filterDepth and filterConditions
<driver.html#filterdepth>`_ config; beyond either is an error.

A field may not be named ``_or``, nor aliased as it: one which is throws
``ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata`` when its type is
built.


Pagination
Expand Down Expand Up @@ -396,7 +428,10 @@ Filters have a cost too, which these rules do not count. ``contains``,
``endswith`` begin with a wildcard, so no ordinary index serves them and each
reads every row; whether an index serves ``startswith`` depends on the
database. A ``sort`` on a column without an index sorts every matching row
before the page is taken. An ``in`` or ``notin`` list binds a parameter for
before the page is taken. An ``_or`` of filters on different columns often
stops the database using a single index; PostgreSQL's bitmap OR and MySQL's
``index_merge`` can combine indexes, but neither is certain. The
``filterDepth`` and ``filterConditions`` config limit the size of an ``_or``. An ``in`` or ``notin`` list binds a parameter for
each of its values, and databases limit them: Oracle allows 1000 values in a
list and SQL Server 2100 parameters in a query. Index the columns clients
filter and sort on, and leave out the filters a large table should not offer
Expand Down
2 changes: 2 additions & 0 deletions docs/versions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ ORM versions do not support ``doctrine/persistence`` 4.
each parameter of its method
- 14.1: a computed field with an ``expression``, its value in DQL, is filtered and sorted
by it; see `computed fields <computed-fields.html#filters-and-sorting>`_
- 14.1: a filter's ``_or`` matches any of its branches, limited by the ``filterDepth``
and ``filterConditions`` config; see `or <queries.html#or>`_
- Metadata is read as typed value objects, and cached metadata carries its format version
and the config it was built with
- Configuration mistakes, such as a misplaced attribute, an invalid name or a field
Expand Down
50 changes: 48 additions & 2 deletions src/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ final class Config
/** @var int A hard limit for fetching any collection within the schema */
protected readonly int $limit;

/**
* @var int|null How deeply a filter's _or may nest: a top-level _or is at
* depth 1. 0 or null is unlimited.
*/
protected readonly int|null $filterDepth;

/**
* @var int|null The most conditions a filter may have inside its _or
* branches. 0 or null is unlimited.
*/
protected readonly int|null $filterConditions;

/**
* @var bool|null When set to true, all entities will be extracted by value
* across all hydrators in the driver. When set to false,
Expand Down Expand Up @@ -125,6 +137,8 @@ final class Config
'batchAssociations' => ['bool'],
'batchLimit' => ['int'],
'limit' => ['int'],
'filterDepth' => ['int', 'null'],
'filterConditions' => ['int', 'null'],
'extractByValue' => ['bool', 'null'],
'entityPrefix' => ['string', 'null'],
'sortFields' => ['bool'],
Expand All @@ -144,6 +158,8 @@ public function __construct(array $config = [])
'batchAssociations' => true,
'batchLimit' => 1000,
'limit' => 1000,
'filterDepth' => 3,
'filterConditions' => 100,
'extractByValue' => null,
'entityPrefix' => null,
'sortFields' => false,
Expand Down Expand Up @@ -173,6 +189,8 @@ public function __construct(array $config = [])
$this->batchAssociations = $mergedConfig['batchAssociations'];
$this->batchLimit = $mergedConfig['batchLimit'];
$this->limit = $mergedConfig['limit'];
$this->filterDepth = $mergedConfig['filterDepth'];
$this->filterConditions = $mergedConfig['filterConditions'];
$this->extractByValue = $mergedConfig['extractByValue'];
$this->entityPrefix = $mergedConfig['entityPrefix'];
$this->sortFields = $mergedConfig['sortFields'];
Expand All @@ -187,7 +205,7 @@ public function __construct(array $config = [])
*
* @param array<array-key, mixed> $config
*
* @return array{group: string, groupSuffix: string|null, useHydratorCache: bool, useQueryResultCache: bool, batchAssociations: bool, batchLimit: int, limit: int, extractByValue: bool|null, entityPrefix: string|null, sortFields: bool, excludeFilters: Filters[], formatJsonAs: JsonFormat, useNonNullTypes: bool}
* @return array{group: string, groupSuffix: string|null, useHydratorCache: bool, useQueryResultCache: bool, batchAssociations: bool, batchLimit: int, limit: int, filterDepth: int|null, filterConditions: int|null, extractByValue: bool|null, entityPrefix: string|null, sortFields: bool, excludeFilters: Filters[], formatJsonAs: JsonFormat, useNonNullTypes: bool}
*
* @throws ConfigurationException
*/
Expand Down Expand Up @@ -222,6 +240,17 @@ private function validate(array $config): array
);
}

foreach (['filterDepth', 'filterConditions'] as $field) {
$value = $config[$field];
assert(is_int($value) || $value === null);

if ($value !== null && $value < 0) {
throw new ConfigurationException(
'Invalid configuration value for ' . $field . ': it must be at least 0, got ' . $value . '.',
);
}
}

$excludeFilters = [];
/** @psalm-suppress MixedAssignment Each filter is checked */
foreach ($config['excludeFilters'] as $filter) {
Expand All @@ -247,7 +276,7 @@ private function validate(array $config): array
);
}

/** @var array{group: string, groupSuffix: string|null, useHydratorCache: bool, useQueryResultCache: bool, batchAssociations: bool, batchLimit: int, limit: int, extractByValue: bool|null, entityPrefix: string|null, sortFields: bool, excludeFilters: Filters[], formatJsonAs: JsonFormat, useNonNullTypes: bool} $validated */
/** @var array{group: string, groupSuffix: string|null, useHydratorCache: bool, useQueryResultCache: bool, batchAssociations: bool, batchLimit: int, limit: int, filterDepth: int|null, filterConditions: int|null, extractByValue: bool|null, entityPrefix: string|null, sortFields: bool, excludeFilters: Filters[], formatJsonAs: JsonFormat, useNonNullTypes: bool} $validated */
$validated = $config;

return $validated;
Expand Down Expand Up @@ -288,6 +317,23 @@ public function getLimit(): int
return $this->limit;
}

/**
* How deeply a filter's _or may nest, or null when it is unlimited
*/
public function getFilterDepth(): int|null
{
return $this->filterDepth === 0 ? null : $this->filterDepth;
}

/**
* The most conditions a filter may have inside its _or branches, or null
* when it is unlimited
*/
public function getFilterConditions(): int|null
{
return $this->filterConditions === 0 ? null : $this->filterConditions;
}

public function getExtractByValue(): bool|null
{
return $this->extractByValue;
Expand Down
46 changes: 36 additions & 10 deletions src/ConfigBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,18 @@
*/
final class ConfigBuilder
{
private string $group = 'default';
private string|null $groupSuffix = null;
private bool $useHydratorCache = false;
private bool $useQueryResultCache = false;
private bool $batchAssociations = true;
private int $batchLimit = 1000;
private int $limit = 1000;
private bool|null $extractByValue = null;
private string|null $entityPrefix = null;
private bool $sortFields = false;
private string $group = 'default';
private string|null $groupSuffix = null;
private bool $useHydratorCache = false;
private bool $useQueryResultCache = false;
private bool $batchAssociations = true;
private int $batchLimit = 1000;
private int $limit = 1000;
private int|null $filterDepth = 3;
private int|null $filterConditions = 100;
private bool|null $extractByValue = null;
private string|null $entityPrefix = null;
private bool $sortFields = false;
/** @var Filters[] */
private array $excludeFilters = [];

Expand Down Expand Up @@ -146,6 +148,28 @@ public function withLimit(int $limit): self
return $this;
}

/**
* Set how deeply a filter's _or may nest: a top-level _or is at depth 1.
* 0 or null is unlimited.
*/
public function withFilterDepth(int|null $filterDepth): self
{
$this->filterDepth = $filterDepth;

return $this;
}

/**
* Set the most conditions a filter may have inside its _or branches. 0
* or null is unlimited.
*/
public function withFilterConditions(int|null $filterConditions): self
{
$this->filterConditions = $filterConditions;

return $this;
}

/**
* Set global extraction strategy
*
Expand Down Expand Up @@ -253,6 +277,8 @@ public function build(): Config
'batchAssociations' => $this->batchAssociations,
'batchLimit' => $this->batchLimit,
'limit' => $this->limit,
'filterDepth' => $this->filterDepth,
'filterConditions' => $this->filterConditions,
'extractByValue' => $this->extractByValue,
'entityPrefix' => $this->entityPrefix,
'sortFields' => $this->sortFields,
Expand Down
Loading
Loading