From 2eff2208239469c09c66b4e530e6cb0aad854069 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 31 Jul 2026 12:25:44 -0300 Subject: [PATCH 1/3] refactor: Render the built-in filter clause from a template table. Each operator carried its own match arm, so the cyclomatic complexity of the function grew with every operator and had already reached the ceiling the linter enforces. The arms only ever differed by the SQL template, so a lookup keyed by the operator token expresses the same mapping and keeps the complexity flat. --- src/Internal/FilterClause.php | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/Internal/FilterClause.php b/src/Internal/FilterClause.php index 38fe908..51f0e98 100644 --- a/src/Internal/FilterClause.php +++ b/src/Internal/FilterClause.php @@ -14,6 +14,17 @@ */ final readonly class FilterClause { + private const array TEMPLATES = [ + Operator::IN->value => '%s IN (%s)', + Operator::EQUAL->value => '%s = %s', + Operator::NOT_IN->value => '%s NOT IN (%s)', + Operator::LESS_THAN->value => '%s < %s', + Operator::NOT_EQUAL->value => '%s <> %s', + Operator::GREATER_THAN->value => '%s > %s', + Operator::LESS_THAN_OR_EQUAL->value => '%s <= %s', + Operator::GREATER_THAN_OR_EQUAL->value => '%s >= %s' + ]; + /** * Renders the built-in fragment from the column, the placeholder offset, and the comparison. * @@ -24,6 +35,8 @@ */ public static function from(FilterColumn $column, int $offset, Comparison $comparison): Fragment { + $operator = $comparison->operator(); + $values = array_map($column->normalize(...), $comparison->values()); $names = array_map( static fn(int $index): string => sprintf('filter_%d', ($offset + $index)), @@ -35,24 +48,8 @@ public static function from(FilterColumn $column, int $offset, Comparison $compa $names ); - $sql = match ($comparison->operator()) { - Operator::IN => - sprintf('%s IN (%s)', $column->column(), implode(', ', $placeholders)), - Operator::NOT_IN => - sprintf('%s NOT IN (%s)', $column->column(), implode(', ', $placeholders)), - Operator::EQUAL => - sprintf('%s = %s', $column->column(), $placeholders[0]), - Operator::NOT_EQUAL => - sprintf('%s <> %s', $column->column(), $placeholders[0]), - Operator::LESS_THAN => - sprintf('%s < %s', $column->column(), $placeholders[0]), - Operator::GREATER_THAN => - sprintf('%s > %s', $column->column(), $placeholders[0]), - Operator::LESS_THAN_OR_EQUAL => - sprintf('%s <= %s', $column->column(), $placeholders[0]), - Operator::GREATER_THAN_OR_EQUAL => - sprintf('%s >= %s', $column->column(), $placeholders[0]) - }; + $argument = $operator->isMultiValued() ? implode(', ', $placeholders) : $placeholders[0]; + $sql = sprintf(self::TEMPLATES[$operator->value], $column->column(), $argument); return Fragment::of(sql: $sql, parameters: $parameters); } From 45796c0950e3d5441758ee64c2833dd42c7a0f14 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 31 Jul 2026 12:26:04 -0300 Subject: [PATCH 2/3] feat: Add the =sw= prefix operator. The operator renders as an anchored LIKE so a B-tree index on the column stays usable, and every wildcard inside the value is escaped before binding, so a search for a literal percent sign cannot expand into a scan. There is no unanchored counterpart by design, since it cannot use the index and degrades the listing into a table scan as the data grows. The escape character is an exclamation mark rather than the customary backslash. A backslash needs one spelling under the default MySQL mode and another under NO_BACKSLASH_ESCAPES, where the usual form fails with Incorrect arguments to ESCAPE, so no single literal is valid in both. Case and accent insensitivity come from the column collation instead, so the rendered predicate carries no vendor-specific COLLATE. --- README.md | 110 ++++++++++++++++++------------ src/Internal/AnchoredPrefix.php | 25 +++++++ src/Internal/FilterClause.php | 7 +- src/Operator.php | 1 + tests/Unit/Clause/FiltersTest.php | 58 ++++++++++++++++ tests/Unit/FilterTest.php | 30 ++++++++ tests/Unit/OperatorTest.php | 2 + 7 files changed, 187 insertions(+), 46 deletions(-) create mode 100644 src/Internal/AnchoredPrefix.php diff --git a/README.md b/README.md index 1b73203..0c777dd 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,14 @@ ## Overview -A typed, framework-independent toolkit for querying an HTTP collection endpoint. The endpoint declares -the query contract once on a `Schema`, the library parses an incoming request query string against it, and the consumer -reads an already-validated result: a conjunction of comparisons for filtering, an effective sort, and a pagination view. -The result renders as a JSON:API response carrying an RFC 8288 `Link` header and a body `links` object. +A typed, framework-independent toolkit for querying an HTTP collection endpoint. The endpoint declares the query +contract once on a `Schema`, the library parses an incoming request query string against it, and the consumer reads an +already-validated result: a conjunction of comparisons for filtering, an effective sort, and a pagination view. The +result renders as a JSON:API response carrying an RFC 8288 `Link` header and a body `links` object. The library never touches a data store. It turns the query string into value objects the consumer applies to its own -store, and it renders the response the consumer returns. Every computation is O(1) value-object math over the inputs the -consumer supplies. +store, and it renders the response the consumer returns. Every computation is O (1) value-object math over the inputs +the consumer supplies. The pagination approach is a fixed decision of the endpoint, not a runtime property of a request. The public API splits into two contexts, `Cursor` and `Offset`, each complete and approach-only, with the building blocks common to both at @@ -50,8 +50,8 @@ parameters, validate them against the schema, and expose the same `comparisons() `Schema` is the contract of the query an endpoint accepts. `filterable` declares a field with its permitted operators and, optionally, the permitted values and the `ValueKind` every value must match. `sortable` declares the fields the client may sort by. `defaultSort` declares the sort applied when the client sends none. `maxPerPage` and -`defaultPerPage` -bound the page size. The query parameter names follow JSON:API and are fixed: `filter`, `sort`, and the `page` family. +`defaultPerPage` bound the page size. The query parameter names follow JSON:API and are fixed: `filter`, `sort`, and the +`page` family. ```php comparisons() as $comparison) { ``` The supported operators map to their RSQL tokens through the `Operator` enum (`==`, `!=`, `=lt=`, `=gt=`, `=le=`, -`=ge=`, `=in=`, `=out=`). The logical connectives map through the `LogicalOperator` enum, where `;` (AND) binds tighter -than `,` (OR), and parentheses group. By default, the filter must be a single comparison or an AND group of comparisons, -and any other shape raises `FilterShapeNotSupported`. A schema that calls `allowDisjunction` accepts OR groups and -nested -groups, validates every leaf the same way, and the consumer then reads the full tree from `Criteria::filter()` to render -it. A malformed expression raises `FilterExpressionIsInvalid`, and anything outside the contract raises a dedicated -exception, each implementing `HttpQueryException`. +`=ge=`, `=in=`, `=out=`, `=sw=`). The logical connectives map through the `LogicalOperator` enum, where `;` (AND) +binds tighter than `,` (OR), and parentheses group. By default, the filter must be a single comparison or an AND group +of comparisons, and any other shape raises `FilterShapeNotSupported`. A schema that calls `allowDisjunction` +accepts OR groups and nested groups, validates every leaf the same way, and the consumer then reads the full tree from +`Criteria::filter()` to render it. A malformed expression raises `FilterExpressionIsInvalid`, and anything outside the +contract raises a dedicated exception, each implementing `HttpQueryException`. | Exception | Raised when | |-----------------------------|-----------------------------------------------------------------------------------| @@ -131,6 +130,29 @@ exception, each implementing `HttpQueryException`. | `FilterOperatorNotAllowed` | A comparison uses an operator not allowed for its field. | | `FilterValueNotAllowed` | A compared value falls outside the permitted set or the expected kind. | +### Prefix matching with `=sw=` + +`=sw=` compares the value as a **literal prefix** of the column. It renders as an anchored `LIKE` so a B-tree index on +the column is still usable: + +```sql +cli.name LIKE :filter_0 ESCAPE '!' +``` + +Only the trailing `%` is added by the library. Every `%`, `_`, and `!` inside the value is escaped before binding, so a +search for `100%` matches a name starting with the literal `100%` instead of expanding into a wildcard. There is no +unanchored counterpart (contains, ends with) by design: an unanchored comparison cannot use the index and degrades the +listing into a table scan as the data grows. + +The escape character is `!` rather than the customary backslash on purpose. A backslash has to be written `'\\'` in a +SQL literal under the default MySQL mode and `'\'` under `NO_BACKSLASH_ESCAPES`, so no single spelling is valid in both. +Under `NO_BACKSLASH_ESCAPES` the backslash form fails with `Incorrect arguments to ESCAPE`. A `!` needs no quoting in +either mode, so the rendered predicate is independent of the server configuration. + +**Case and accent sensitivity come from the column collation, not from this library.** The rendered SQL carries no +`COLLATE`, so a column declared `utf8mb4_0900_ai_ci` (accent-insensitive, case-insensitive) makes `jose` match `José`, +while a binary collation matches exactly. Declare the collation the endpoint promises. + `ValueKind` is the kind a value is validated against. For the multivalued operators (`=in=`, `=out=`) every value is checked. @@ -271,14 +293,14 @@ An invalid cursor token raises `CursorIsInvalid` when it is decoded. ### Building the store query -The library decides what to fetch and hands the consumer typed SQL fragments to apply against its own -store. It builds no statement: the `SELECT`, the `FROM`, the joins, the `LIMIT`, and the dialect stay -with the consumer (or its query builder). Every fragment is a `Clause\SqlClause` exposing `sql()`, -`parameters()`, and `isEmpty()`, so the consumer programs the `WHERE` assembly against one abstraction. +The library decides what to fetch and hands the consumer typed SQL fragments to apply against its own store. It builds +no statement: the `SELECT`, the `FROM`, the joins, the `LIMIT`, and the dialect stay with the consumer (or its query +builder). Every fragment is a `Clause\SqlClause` exposing `sql()`, `parameters()`, and `isEmpty()`, so the consumer +programs the `WHERE` assembly against one abstraction. -`Clause\FilterColumns` maps each queryable field to its column. It is an immutable fluent builder, with -`plain`, `boolean`, and `wrapped` shortcuts, and `with` for a custom `FilterColumn`. The same mapping -feeds the filter, the seek, and the sort. +`Clause\FilterColumns` maps each queryable field to its column. It is an immutable fluent builder, with `plain`, +`boolean`, and `wrapped` shortcuts, and `with` for a custom `FilterColumn`. The same mapping feeds the filter, the seek, +and the sort. ```php sql(), $limit- # Bind $predicate->parameters() and run $sql against your store. ``` -`Clause\Every::of` combines several `SqlClause` predicates, dropping the empty ones, joining the rest -with `AND`, and merging their parameters. It is itself a `SqlClause`, so a consumer-owned predicate -(for example a tenant scope) drops into the same combination by implementing `SqlClause`. +`Clause\Every::of` combines several `SqlClause` predicates, dropping the empty ones, joining the rest with `AND`, and +merging their parameters. It is itself a `SqlClause`, so a consumer-owned predicate (for example a tenant scope) drops +into the same combination by implementing `SqlClause`. -`Filters::from` renders a flat list of comparisons joined with `AND`, which fits the default -conjunction-only contract. When the schema calls `allowDisjunction`, the filter may be an OR group or a -nested group, so the consumer renders the full tree with `Filters::fromTree(filter: $criteria->filter(), -columns: $columns)` instead. It walks the tree, joins each group by its own connective, wraps every group -in parentheses, and threads the placeholder offsets across the whole tree. +`Filters::from` renders a flat list of comparisons joined with `AND`, which fits the default conjunction-only contract. +When the schema calls `allowDisjunction`, the filter may be an OR group or a nested group, so the consumer renders the +full tree with `Filters::fromTree(filter: $criteria->filter(), columns: $columns)` instead. It walks the tree, joins +each group by its own connective, wraps every group in parentheses, and threads the placeholder offsets across the whole +tree. -`Limit` is the page size value object. `plus` raises it by an amount and `plusOne` raises it by one, -the extra row a keyset page fetches to detect a next page. `toInteger` reads the value back. +`Limit` is the page size value object. `plus` raises it by an amount and `plusOne` raises it by one, the extra row a +keyset page fetches to detect a next page. `toInteger` reads the value back. -`Filters::from` renders each comparison with the built-in operator mapping (`==`, `!=`, `=in=`, -`=out=`, `=lt=`, `=gt=`, `=le=`, `=ge=`). To render a shape the built-in mapping does not cover, pass a -`Clause\OperatorRenderer`. The first renderer that `supports` a comparison's operator renders it, -otherwise the built-in mapping does. +`Filters::from` renders each comparison with the built-in operator mapping (`==`, `!=`, `=in=`, `=out=`, `=lt=`, `=gt=`, +`=le=`, `=ge=`, `=sw=`). To render a shape the built-in mapping does not cover, pass a `Clause\OperatorRenderer`. The +first renderer that `supports` a comparison's operator renders it, otherwise the built-in mapping does. ```php page(total: 480, items: $items)->toResponse(baseUri: '/v1 The body carries the data, the meta, and the links, with the filter and the sort preserved in every URI. The two approaches return different shapes, shown below. -An **offset** page exposes the full window (`total`, `total_pages`, `current_page`, `has_previous`) and a link for -every relation: +An **offset** page exposes the full window (`total`, `total_pages`, `current_page`, `has_previous`) and a link for every +relation: ```json { @@ -479,8 +499,8 @@ Link: ` the consumer applies. An OR -group, or a group nested inside another group, is rejected as an unsupported shape. +single comparison or an AND group of comparisons flattens into the `list` the consumer applies. An OR group, +or a group nested inside another group, is rejected as an unsupported shape. > Zdenek Jirutka, *RSQL / FIQL parser*. diff --git a/src/Internal/AnchoredPrefix.php b/src/Internal/AnchoredPrefix.php new file mode 100644 index 0000000..f8c9e9e --- /dev/null +++ b/src/Internal/AnchoredPrefix.php @@ -0,0 +1,25 @@ +value => '%s NOT IN (%s)', Operator::LESS_THAN->value => '%s < %s', Operator::NOT_EQUAL->value => '%s <> %s', + Operator::STARTS_WITH->value => '%s LIKE %s ESCAPE \'!\'', Operator::GREATER_THAN->value => '%s > %s', Operator::LESS_THAN_OR_EQUAL->value => '%s <= %s', Operator::GREATER_THAN_OR_EQUAL->value => '%s >= %s' @@ -37,7 +38,11 @@ public static function from(FilterColumn $column, int $offset, Comparison $compa { $operator = $comparison->operator(); - $values = array_map($column->normalize(...), $comparison->values()); + $compared = $comparison->hasOperator(operator: Operator::STARTS_WITH) + ? array_map(AnchoredPrefix::from(...), $comparison->values()) + : $comparison->values(); + + $values = array_map($column->normalize(...), $compared); $names = array_map( static fn(int $index): string => sprintf('filter_%d', ($offset + $index)), array_keys($values) diff --git a/src/Operator.php b/src/Operator.php index 650bfb5..5dbc878 100644 --- a/src/Operator.php +++ b/src/Operator.php @@ -16,6 +16,7 @@ enum Operator: string case NOT_IN = '=out='; case LESS_THAN = '=lt='; case NOT_EQUAL = '!='; + case STARTS_WITH = '=sw='; case GREATER_THAN = '=gt='; case LESS_THAN_OR_EQUAL = '=le='; case GREATER_THAN_OR_EQUAL = '=ge='; diff --git a/tests/Unit/Clause/FiltersTest.php b/tests/Unit/Clause/FiltersTest.php index 121c056..c15aa5a 100644 --- a/tests/Unit/Clause/FiltersTest.php +++ b/tests/Unit/Clause/FiltersTest.php @@ -4,7 +4,10 @@ namespace Test\TinyBlocks\HttpQuery\Unit\Clause; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionClass; +use ReflectionMethod; use TinyBlocks\HttpQuery\Clause\FilterColumn; use TinyBlocks\HttpQuery\Clause\FilterColumns; use TinyBlocks\HttpQuery\Clause\Filters; @@ -13,6 +16,7 @@ use TinyBlocks\HttpQuery\Comparison; use TinyBlocks\HttpQuery\Exceptions\FilterColumnNotMapped; use TinyBlocks\HttpQuery\Group; +use TinyBlocks\HttpQuery\Internal\AnchoredPrefix; use TinyBlocks\HttpQuery\LogicalOperator; use TinyBlocks\HttpQuery\Operator; @@ -34,6 +38,60 @@ public function testFromWhenColumnIsBooleanThenBindsTrueAsOne(): void self::assertSame(['filter_0' => 1], $filters->parameters()); } + public function testFromWhenOperatorStartsWithThenRendersAnchoredLikeAndAppendsTheWildcard(): void + { + /** @Given a prefix comparison over a name field */ + $comparison = Comparison::of(field: 'name', values: ['jose'], operator: Operator::STARTS_WITH); + + /** @When the predicate is assembled */ + $filters = Filters::from( + columns: FilterColumns::create()->plain(field: 'name', column: 'cli.name'), + comparisons: [$comparison] + ); + + /** @Then the predicate anchors the prefix and only the trailing wildcard is added */ + self::assertSame("cli.name LIKE :filter_0 ESCAPE '!'", $filters->sql()); + self::assertSame(['filter_0' => 'jose%'], $filters->parameters()); + } + + #[DataProvider('wildcardCases')] + public function testFromWhenPrefixCarriesAWildcardThenNeutralizesIt(string $value, string $bound): void + { + /** @Given a prefix whose raw value carries a character LIKE would read as a wildcard or an escape */ + + /** @When the predicate is assembled */ + $filters = Filters::from( + columns: FilterColumns::create()->plain(field: 'name', column: 'cli.name'), + comparisons: [Comparison::of(field: 'name', values: [$value], operator: Operator::STARTS_WITH)] + ); + + /** @Then the character binds escaped, so it matches literally instead of expanding */ + self::assertSame(['filter_0' => $bound], $filters->parameters()); + } + + public function testConstructorWhenInvokedThroughReflectionThenInstantiatesTheStaticOnlyPrefix(): void + { + /** @Given an uninitialized instance of the static-only prefix anchor */ + $anchor = new ReflectionClass(AnchoredPrefix::class)->newInstanceWithoutConstructor(); + + /** @When invoking its otherwise-uncallable private constructor */ + new ReflectionMethod(AnchoredPrefix::class, '__construct')->invoke($anchor); + + /** @Then the static-only prefix anchor is instantiated */ + self::assertInstanceOf(AnchoredPrefix::class, $anchor); + } + + public static function wildcardCases(): array + { + return [ + 'percent' => ['value' => '100%', 'bound' => '100!%%'], + 'escape char' => ['value' => 'a!b', 'bound' => 'a!!b%'], + 'underscore' => ['value' => 'a_b', 'bound' => 'a!_b%'], + 'backslash' => ['value' => 'a\\b', 'bound' => 'a\\b%'], + 'no wildcard' => ['value' => 'jose', 'bound' => 'jose%'] + ]; + } + public function testFromWhenFieldHasNoColumnMappingThenThrows(): void { /** @Given a comparison over a field absent from the column mapping */ diff --git a/tests/Unit/FilterTest.php b/tests/Unit/FilterTest.php index 3438ba6..815075e 100644 --- a/tests/Unit/FilterTest.php +++ b/tests/Unit/FilterTest.php @@ -60,6 +60,36 @@ public function testFromQueryWhenAndGroupThenComparisonsCarryEveryLeaf(): void ], $comparisons); } + public function testFromQueryWhenPrefixOperatorThenComparisonCarriesTheLiteralPrefix(): void + { + /** @Given a query carrying a prefix comparison */ + $query = Query::from(parameters: ['filter' => 'name=sw=jose']); + + /** @When reading the validated comparisons */ + $comparisons = Criteria::fromQuery(schema: $this->schema, request: $query)->comparisons(); + + /** @Then the only comparison is a prefix comparison carrying the raw value */ + self::assertEquals( + [Comparison::of(field: 'name', values: ['jose'], operator: Operator::STARTS_WITH)], + $comparisons + ); + } + + public function testFromQueryWhenPrefixOperatorFollowsEqualThenBothTokensResolve(): void + { + /** @Given a query whose expression mixes the equality and the prefix tokens */ + $query = Query::from(parameters: ['filter' => 'status==ACTIVE;name=sw=jo']); + + /** @When reading the validated comparisons */ + $comparisons = Criteria::fromQuery(schema: $this->schema, request: $query)->comparisons(); + + /** @Then neither token shadows the other during scanning */ + self::assertEquals([ + Comparison::of(field: 'status', values: ['ACTIVE'], operator: Operator::EQUAL), + Comparison::of(field: 'name', values: ['jo'], operator: Operator::STARTS_WITH) + ], $comparisons); + } + public function testFromQueryWhenInListThenComparisonCarriesEveryValue(): void { /** @Given a query carrying an IN list comparison */ diff --git a/tests/Unit/OperatorTest.php b/tests/Unit/OperatorTest.php index 0664efc..495b683 100644 --- a/tests/Unit/OperatorTest.php +++ b/tests/Unit/OperatorTest.php @@ -56,6 +56,7 @@ public static function operatorCases(): array 'NOT_IN' => ['operator' => Operator::NOT_IN, 'token' => '=out='], 'LESS_THAN' => ['operator' => Operator::LESS_THAN, 'token' => '=lt='], 'NOT_EQUAL' => ['operator' => Operator::NOT_EQUAL, 'token' => '!='], + 'STARTS_WITH' => ['operator' => Operator::STARTS_WITH, 'token' => '=sw='], 'GREATER_THAN' => ['operator' => Operator::GREATER_THAN, 'token' => '=gt='], 'LESS_THAN_OR_EQUAL' => ['operator' => Operator::LESS_THAN_OR_EQUAL, 'token' => '=le='], 'GREATER_THAN_OR_EQUAL' => ['operator' => Operator::GREATER_THAN_OR_EQUAL, 'token' => '=ge='] @@ -70,6 +71,7 @@ public static function multiValuedCases(): array 'NOT_IN' => ['operator' => Operator::NOT_IN, 'multiValued' => true], 'LESS_THAN' => ['operator' => Operator::LESS_THAN, 'multiValued' => false], 'NOT_EQUAL' => ['operator' => Operator::NOT_EQUAL, 'multiValued' => false], + 'STARTS_WITH' => ['operator' => Operator::STARTS_WITH, 'multiValued' => false], 'GREATER_THAN' => ['operator' => Operator::GREATER_THAN, 'multiValued' => false], 'LESS_THAN_OR_EQUAL' => ['operator' => Operator::LESS_THAN_OR_EQUAL, 'multiValued' => false], 'GREATER_THAN_OR_EQUAL' => ['operator' => Operator::GREATER_THAN_OR_EQUAL, 'multiValued' => false] From 964ce46b4afd3783c99fa3ab0bfebcb9013daf1f Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 31 Jul 2026 12:27:46 -0300 Subject: [PATCH 3/3] build: Raise the tiny-blocks/http constraint to ^6.4. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index b2c4a54..81ff927 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "require": { "php": "^8.5", "tiny-blocks/collection": "^2.5", - "tiny-blocks/http": "^6.3" + "tiny-blocks/http": "^6.4" }, "require-dev": { "ergebnis/composer-normalize": "^2.52",