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
12 changes: 6 additions & 6 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -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"
]
}
}
108 changes: 54 additions & 54 deletions tests/Unit/Clause/FiltersTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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%']
];
}
}
20 changes: 10 additions & 10 deletions tests/Unit/Cursor/KeysetTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
60 changes: 30 additions & 30 deletions tests/Unit/FilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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 */
Expand Down