diff --git a/composer.json b/composer.json index 4bd691d..3f54ed7 100644 --- a/composer.json +++ b/composer.json @@ -27,14 +27,14 @@ "require": { "php": "^8.5", "tiny-blocks/collection": "^2.6", - "tiny-blocks/http": "^7.0" + "tiny-blocks/http": "^7.2" }, "require-dev": { - "ergebnis/composer-normalize": "^2.52", - "infection/infection": "^0.34", + "ergebnis/composer-normalize": "^2.54", + "infection/infection": "^0.35", "nyholm/psr7": "^1.8", - "phpstan/phpstan": "^2.2", - "phpunit/phpunit": "^13.2", + "phpstan/phpstan": "^2.3", + "phpunit/phpunit": "^13.4", "slevomat/coding-standard": "^8.31", "squizlabs/php_codesniffer": "^4.0" }, @@ -75,7 +75,7 @@ "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", "tests": [ "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", - "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage" + "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html" ] } } diff --git a/tests/Unit/Clause/FiltersTest.php b/tests/Unit/Clause/FiltersTest.php index c15aa5a..259b6ca 100644 --- a/tests/Unit/Clause/FiltersTest.php +++ b/tests/Unit/Clause/FiltersTest.php @@ -38,60 +38,6 @@ 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 */ @@ -241,6 +187,21 @@ public function testFromWhenNoComparisonsThenYieldsAnEmptyPredicate(): void self::assertSame([], $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 testFromTreeWhenEmptyGroupThenYieldsAnEmptyPredicate(): void { /** @Given an empty group */ @@ -520,6 +481,34 @@ public function supports(Operator $operator): bool self::assertSame(['filter_0' => '%acme%'], $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()); + } + + 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 function testFromTreeWhenNestedGroupThenRendersEachGroupParenthesizedWithThreadedOffsets(): void { /** @Given an AND group nesting an OR group */ @@ -552,4 +541,15 @@ public function testFromTreeWhenNestedGroupThenRendersEachGroupParenthesizedWith ); self::assertSame(['filter_0' => 'PAID', 'filter_1' => '100', 'filter_2' => '200'], $filters->parameters()); } + + 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%'] + ]; + } } diff --git a/tests/Unit/Cursor/KeysetTest.php b/tests/Unit/Cursor/KeysetTest.php index d898bce..b939b39 100644 --- a/tests/Unit/Cursor/KeysetTest.php +++ b/tests/Unit/Cursor/KeysetTest.php @@ -125,33 +125,33 @@ public function testCursorWhenIncomingCursorGivenThenKeysValuesBySortField(): vo self::assertSame(['created_at' => '2023-01-15T10:30:00Z', 'id' => 5], $cursor); } - public function testCriteriaWhenDecodedCountMismatchesThenThrowsCursorIsInvalid(): void + public function testCriteriaWhenTokenCannotBeDecodedThenThrowsCursorIsInvalid(): void { - /** @Given an opaque token carrying a single key value */ - $token = Token::fromKeys(keys: [5])->toString(); - - /** @And a request whose sort carries two fields */ + /** @Given a request carrying a token that never came out of the codec */ $request = Query::from( - parameters: ['sort' => 'created_at,id', 'page' => ['cursor' => $token, 'size' => '2']] + parameters: ['sort' => 'created_at,id', 'page' => ['cursor' => 'not-a-cursor', 'size' => '2']] ); /** @Then an exception indicating the cursor is invalid is raised */ $this->expectException(CursorIsInvalid::class); + $this->expectExceptionMessage('Cursor token is invalid and could not be decoded.'); /** @When the criteria is parsed from the request */ Criteria::fromQuery(schema: $this->schema, request: $request); } - public function testCriteriaWhenTokenCannotBeDecodedThenThrowsCursorIsInvalid(): void + public function testCriteriaWhenDecodedCountMismatchesThenThrowsCursorIsInvalid(): void { - /** @Given a request carrying a token that never came out of the codec */ + /** @Given an opaque token carrying a single key value */ + $token = Token::fromKeys(keys: [5])->toString(); + + /** @And a request whose sort carries two fields */ $request = Query::from( - parameters: ['sort' => 'created_at,id', 'page' => ['cursor' => 'not-a-cursor', 'size' => '2']] + parameters: ['sort' => 'created_at,id', 'page' => ['cursor' => $token, 'size' => '2']] ); /** @Then an exception indicating the cursor is invalid is raised */ $this->expectException(CursorIsInvalid::class); - $this->expectExceptionMessage('Cursor token is invalid and could not be decoded.'); /** @When the criteria is parsed from the request */ Criteria::fromQuery(schema: $this->schema, request: $request); diff --git a/tests/Unit/FilterTest.php b/tests/Unit/FilterTest.php index 8f934a2..bbeec81 100644 --- a/tests/Unit/FilterTest.php +++ b/tests/Unit/FilterTest.php @@ -60,36 +60,6 @@ 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 */ @@ -262,6 +232,21 @@ public function testFromQueryWhenPersonalValueBreaksKindThenMessageOmitsTheValue Criteria::fromQuery(schema: $schema, request: $query); } + 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 testFromQueryWhenDateTimeValueMatchesKindThenComparisonIsReturned(): void { /** @Given a schema allowing a date-time field under the greater-than operator */ @@ -376,6 +361,21 @@ public function testFromQueryWhenMalformedPersonalValueThenMessageOmitsTheExpres Criteria::fromQuery(schema: $this->schema, request: $query); } + 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 testFromQueryWhenOperatorNotAllowedThenThrowsFilterOperatorNotAllowed(): void { /** @Given a schema allowing the status field under equality only */