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/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", 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 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::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' + ]; + /** * Renders the built-in fragment from the column, the placeholder offset, and the comparison. * @@ -24,7 +36,13 @@ */ public static function from(FilterColumn $column, int $offset, Comparison $comparison): Fragment { - $values = array_map($column->normalize(...), $comparison->values()); + $operator = $comparison->operator(); + + $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) @@ -35,24 +53,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); } 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]