diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e20c92..32b3612 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -286,6 +286,7 @@ jobs: $tables = [ 'maa_persistence_test_global_ordering', 'maa_persistence_test_scoped_ordering', + 'maa_persistence_test_pagination_items', ]; $triggers = [ 'maa_persistence_test_fail_global_target_update', @@ -293,7 +294,7 @@ jobs: ]; $tableStatement = $pdo->prepare( - 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME IN (?, ?) ORDER BY TABLE_NAME' + 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME IN (?, ?, ?) ORDER BY TABLE_NAME' ); $tableStatement->execute([$schema, ...$tables]); $remainingTables = $tableStatement->fetchAll(PDO::FETCH_COLUMN); @@ -444,6 +445,7 @@ jobs: $tables = [ 'maa_persistence_test_global_ordering', 'maa_persistence_test_scoped_ordering', + 'maa_persistence_test_pagination_items', ]; $triggers = [ 'maa_persistence_test_fail_global_target_update', @@ -451,7 +453,7 @@ jobs: ]; $tableStatement = $pdo->prepare( - 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME IN (?, ?) ORDER BY TABLE_NAME' + 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME IN (?, ?, ?) ORDER BY TABLE_NAME' ); $tableStatement->execute([$schema, ...$tables]); $remainingTables = $tableStatement->fetchAll(PDO::FETCH_COLUMN); diff --git a/tests/Fixtures/MySql/create_pagination_items_table.sql b/tests/Fixtures/MySql/create_pagination_items_table.sql new file mode 100644 index 0000000..efa1604 --- /dev/null +++ b/tests/Fixtures/MySql/create_pagination_items_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE `maa_persistence_test_pagination_items` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `tenant_id` INT UNSIGNED NOT NULL, + `category` VARCHAR(32) NOT NULL, + `name` VARCHAR(128) NOT NULL, + `score` INT NOT NULL, + `is_active` TINYINT(1) NOT NULL, + `nullable_code` VARCHAR(32) NULL, + `created_at` DATETIME(6) NOT NULL, + `deleted_at` DATETIME(6) NULL, + PRIMARY KEY (`id`), + KEY `idx_pagination_base` (`tenant_id`, `deleted_at`, `id`), + KEY `idx_pagination_filter` (`tenant_id`, `category`, `is_active`, `nullable_code`, `score`, `id`), + KEY `idx_pagination_sort` (`created_at`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/tests/Integration/Pdo/Pagination/PdoPaginatorFailureContractTest.php b/tests/Integration/Pdo/Pagination/PdoPaginatorFailureContractTest.php new file mode 100644 index 0000000..12d2a1e --- /dev/null +++ b/tests/Integration/Pdo/Pagination/PdoPaginatorFailureContractTest.php @@ -0,0 +1,94 @@ +insertMultiTenantDataset(); + $table = PaginationSchemaManager::TABLE; + + $descriptors = [ + new PdoPaginationQueryDescriptor("SELECT id AS total_count FROM `$table` WHERE tenant_id = 999", [], 'SELECT 1 AS filtered_count', [], "SELECT id FROM `$table`", []), + new PdoPaginationQueryDescriptor("SELECT id AS total_count FROM `$table` WHERE tenant_id = 1", [], 'SELECT 1 AS filtered_count', [], "SELECT id FROM `$table`", []), + new PdoPaginationQueryDescriptor("SELECT id, name FROM `$table` WHERE tenant_id = 1 LIMIT 1", [], 'SELECT 1 AS filtered_count', [], "SELECT id FROM `$table`", []), + ]; + + foreach ($descriptors as $descriptor) { + try { + (new PdoPaginator())->paginate($this->pdo(), $descriptor, new PageRequest(), $this->config(), static fn (array $row): array => $row); + self::fail('Expected count shape failure.'); + } catch (PaginationExecutionException $exception) { + self::assertInstanceOf(PaginationExecutionException::class, $exception); + } + } + } + + public function testPlaceholderViolationsAndInvalidTableColumnPropagatePdoException(): void + { + $this->expectException(PDOException::class); + + (new PdoPaginator())->paginate( + $this->pdo(), + new PdoPaginationQueryDescriptor('SELECT COUNT(*) AS total_count FROM missing_pagination_table', [], 'SELECT 1 AS filtered_count', [], 'SELECT id FROM missing_pagination_table', []), + new PageRequest(), + $this->config(), + static fn (array $row): array => $row, + ); + } + + public function testMapperThrowableIdentityAndInvalidMapperResults(): void + { + $this->insertMultiTenantDataset(); + $throwable = new RuntimeException('mapper'); + + try { + (new PdoPaginator())->paginate($this->pdo(), $this->descriptor(), new PageRequest(), $this->config(), static fn (array $row) => throw $throwable); + self::fail('Expected mapper throwable.'); + } catch (RuntimeException $caught) { + self::assertSame($throwable, $caught); + } + + $this->expectException(PaginationExecutionException::class); + $this->invokePaginatorWithMapper(static fn (array $row): int => 1); + } + + public function testResourceMapperFailureClosesResource(): void + { + $this->insertMultiTenantDataset(); + $resource = fopen('php://memory', 'r'); + self::assertIsResource($resource); + + try { + $this->expectException(PaginationExecutionException::class); + $this->invokePaginatorWithMapper(static fn (array $row) => $resource); + } finally { + fclose($resource); + } + } + + private function invokePaginatorWithMapper(callable $mapper): void + { + $method = new ReflectionMethod(PdoPaginator::class, 'paginate'); + $method->invokeArgs(new PdoPaginator(), [ + $this->pdo(), + $this->descriptor(), + new PageRequest(), + $this->config(), + $mapper, + ]); + } +} diff --git a/tests/Integration/Pdo/Pagination/PdoPaginatorQuerySemanticsTest.php b/tests/Integration/Pdo/Pagination/PdoPaginatorQuerySemanticsTest.php new file mode 100644 index 0000000..7d0c89f --- /dev/null +++ b/tests/Integration/Pdo/Pagination/PdoPaginatorQuerySemanticsTest.php @@ -0,0 +1,182 @@ +insertMultiTenantDataset(); + $paginator = new PdoPaginator(); + + $firstPage = $paginator->paginate( + $this->pdo(), + $this->descriptor(), + new PageRequest(1, 2, 'score', ' asc '), + $this->config(), + static fn (array $row): array => $row, + ); + $secondPage = $paginator->paginate( + $this->pdo(), + $this->descriptor(), + new PageRequest(2, 2, 'score', 'ASC'), + $this->config(), + static fn (array $row): array => $row, + ); + + self::assertSame(4, $firstPage->total); + self::assertSame(3, $firstPage->filtered); + self::assertSame(2, $firstPage->totalPages); + self::assertSame([$ids[0], $ids[1]], self::idsFromRows($firstPage->data)); + self::assertSame([$ids[2]], self::idsFromRows($secondPage->data)); + } + + public function testNoFilterIdenticalCountsNullBindingInvalidSortFallbackAndObjectMapper(): void + { + $ids = $this->insertMultiTenantDataset(); + $table = PaginationSchemaManager::TABLE; + $descriptor = new PdoPaginationQueryDescriptor( + "SELECT COUNT(*) AS total_count FROM `$table` WHERE tenant_id = :tenant AND deleted_at IS NULL AND nullable_code <=> :code", + ['tenant' => 1, 'code' => null], + "SELECT COUNT(*) AS filtered_count FROM `$table` WHERE tenant_id = :tenant_filtered AND deleted_at IS NULL AND nullable_code <=> :code_filtered", + ['tenant_filtered' => 1, 'code_filtered' => null], + "SELECT id, name FROM `$table` WHERE tenant_id = :tenant_data AND deleted_at IS NULL AND nullable_code <=> :code_data", + ['tenant_data' => 1, 'code_data' => null], + ); + $objects = []; + + $result = (new PdoPaginator())->paginate( + $this->pdo(), + $descriptor, + new PageRequest(1, 10, 'unknown', 'bad'), + $this->config(), + static function (array $row) use (&$objects): stdClass { + $object = (object) $row; + $objects[] = $object; + + return $object; + }, + ); + + self::assertSame(2, $result->total); + self::assertSame($result->total, $result->filtered); + self::assertSame('created_at', $result->sortBy); + self::assertSame('DESC', $result->sortDirection->value); + self::assertSame([$ids[3], $ids[0]], self::idsFromObjects($result->data)); + self::assertSame($objects[0], $result->data[0]); + } + + public function testOverflowZeroFilteredEmptyPositiveFilteredAndFilteredGreaterThanTotal(): void + { + $this->insertMultiTenantDataset(); + $table = PaginationSchemaManager::TABLE; + $paginator = new PdoPaginator(); + + $overflow = $paginator->paginate( + $this->pdo(), + $this->descriptor(), + new PageRequest(99, 2), + $this->config(), + static fn (array $row): array => $row, + ); + self::assertSame(1, $overflow->page); + self::assertCount(2, $overflow->data); + + $zero = $paginator->paginate( + $this->pdo(), + new PdoPaginationQueryDescriptor( + "SELECT COUNT(*) AS total_count FROM `$table` WHERE tenant_id = 1", + [], + "SELECT COUNT(*) AS filtered_count FROM `$table` WHERE tenant_id = 999", + [], + 'THIS INVALID DATA SQL MUST NOT BE PREPARED', + [], + ), + new PageRequest(7, 2), + $this->config(), + static fn (array $row): array => $row, + ); + self::assertSame(1, $zero->page); + self::assertSame([], $zero->data); + self::assertSame(0, $zero->filtered); + + $emptyPositive = $paginator->paginate( + $this->pdo(), + new PdoPaginationQueryDescriptor( + "SELECT COUNT(*) AS total_count FROM `$table` WHERE tenant_id = 1", + [], + 'SELECT 1 AS filtered_count', + [], + "SELECT id FROM `$table` WHERE tenant_id = 999", + [], + ), + new PageRequest(1, 2), + $this->config(), + static fn (array $row): array => $row, + ); + self::assertSame(1, $emptyPositive->filtered); + self::assertSame([], $emptyPositive->data); + self::assertSame(1, $emptyPositive->totalPages); + + $filteredGreaterThanTotal = $paginator->paginate( + $this->pdo(), + new PdoPaginationQueryDescriptor( + 'SELECT 1 AS total_count', + [], + 'SELECT 3 AS filtered_count', + [], + "SELECT id FROM `$table` WHERE tenant_id = 1 AND deleted_at IS NULL AND is_active = 1", + [], + ), + new PageRequest(1, 3), + $this->config(), + static fn (array $row): array => $row, + ); + self::assertSame(1, $filteredGreaterThanTotal->total); + self::assertSame(3, $filteredGreaterThanTotal->filtered); + } + + /** + * @param list> $rows + * + * @return list + */ + private static function idsFromRows(array $rows): array + { + $ids = []; + foreach ($rows as $row) { + self::assertArrayHasKey('id', $row); + self::assertTrue(is_int($row['id']) || is_string($row['id'])); + $ids[] = (int) $row['id']; + } + + return $ids; + } + + /** + * @param list $rows + * + * @return list + */ + private static function idsFromObjects(array $rows): array + { + $ids = []; + foreach ($rows as $row) { + self::assertTrue(property_exists($row, 'id')); + $id = $row->id; + self::assertTrue(is_int($id) || is_string($id)); + $ids[] = (int) $id; + } + + return $ids; + } +} diff --git a/tests/Integration/Pdo/Pagination/PdoPaginatorTransactionTest.php b/tests/Integration/Pdo/Pagination/PdoPaginatorTransactionTest.php new file mode 100644 index 0000000..7777cd9 --- /dev/null +++ b/tests/Integration/Pdo/Pagination/PdoPaginatorTransactionTest.php @@ -0,0 +1,59 @@ +insertMultiTenantDataset(); + $attributes = [ + PDO::ATTR_ERRMODE, + PDO::ATTR_DEFAULT_FETCH_MODE, + PDO::ATTR_EMULATE_PREPARES, + PDO::ATTR_STRINGIFY_FETCHES, + ]; + $before = []; + foreach ($attributes as $attribute) { + $before[$attribute] = $this->pdo()->getAttribute($attribute); + } + + self::assertFalse($this->pdo()->inTransaction()); + (new PdoPaginator())->paginate( + $this->pdo(), + $this->descriptor(), + new PageRequest(), + $this->config(), + static fn (array $row): array => $row, + ); + self::assertFalse($this->pdo()->inTransaction()); + + foreach ($before as $attribute => $value) { + self::assertSame($value, $this->pdo()->getAttribute($attribute)); + if (is_bool($value) || $value === 0 || $value === 1) { + self::assertSame((bool) $value, (bool) $this->pdo()->getAttribute($attribute)); + } + } + + $this->pdo()->beginTransaction(); + try { + (new PdoPaginator())->paginate( + $this->pdo(), + $this->descriptor(), + new PageRequest(), + $this->config(), + static fn (array $row): array => $row, + ); + self::assertTrue($this->pdo()->inTransaction()); + } finally { + $this->pdo()->rollBack(); + } + } +} diff --git a/tests/Regression/Exception/PaginationExceptionContractTest.php b/tests/Regression/Exception/PaginationExceptionContractTest.php new file mode 100644 index 0000000..db7681f --- /dev/null +++ b/tests/Regression/Exception/PaginationExceptionContractTest.php @@ -0,0 +1,70 @@ + $className */ + #[DataProvider('exceptionClasses')] + public function testExceptionContract(string $className): void + { + $class = new ReflectionClass($className); + $exception = new $className('pagination'); + + self::assertTrue($class->isFinal()); + self::assertInstanceOf(SystemMaatifyException::class, $exception); + self::assertInstanceOf(PersistenceException::class, $exception); + self::assertSame(ErrorCodeEnum::MAATIFY_ERROR, $exception->getErrorCode()); + } + + /** @return iterable}> */ + public static function exceptionClasses(): iterable + { + yield 'configuration' => [InvalidPaginationConfigurationException::class]; + yield 'query' => [InvalidPaginationQueryException::class]; + yield 'execution' => [PaginationExecutionException::class]; + } + + public function testNoPaginationSpecificMarkerExists(): void + { + self::assertFalse(interface_exists('Maatify\\Persistence\\Exception\\PaginationException')); + } + + public function testRepresentativeConfigurationTrigger(): void + { + $this->expectException(InvalidPaginationConfigurationException::class); + + $reflection = new ReflectionClass(SortWhitelist::class); + $reflection->newInstanceArgs([[]]); + } + + public function testRepresentativeQueryTrigger(): void + { + $this->expectException(InvalidPaginationQueryException::class); + + new PdoPaginationQueryDescriptor('', [], 'SELECT COUNT(*) AS filtered_count', [], 'SELECT id FROM t', []); + } + + public function testRepresentativeExecutionTrigger(): void + { + $this->expectException(PaginationExecutionException::class); + + new PageResult([], 0, 20, 0, 0, 0, false, false, 'id', SortDirectionEnum::ASC); + } +} diff --git a/tests/Regression/Pdo/Ordering/OrderingPublicApiRegressionTest.php b/tests/Regression/Pdo/Ordering/OrderingPublicApiRegressionTest.php new file mode 100644 index 0000000..4365f68 --- /dev/null +++ b/tests/Regression/Pdo/Ordering/OrderingPublicApiRegressionTest.php @@ -0,0 +1,154 @@ +getNamespaceName()); + self::assertTrue($class->isFinal()); + self::assertTrue($class->isReadOnly()); + self::assertConstructorParameters($class, [ + ['table', 'string', false, false, null], + ['scopeColumn', 'string', true, true, null], + ['idColumn', 'string', false, true, 'id'], + ['orderColumn', 'string', false, true, 'display_order'], + ['deletedAtColumn', 'string', true, true, 'deleted_at'], + ]); + + foreach (['table', 'scopeColumn', 'idColumn', 'orderColumn', 'deletedAtColumn'] as $property) { + $reflectionProperty = $class->getProperty($property); + self::assertTrue($reflectionProperty->isPublic()); + self::assertTrue($reflectionProperty->isPromoted()); + } + + self::assertPublicMethod($class, 'quotedTable', [], 'string'); + self::assertPublicMethod($class, 'quotedIdColumn', [], 'string'); + self::assertPublicMethod($class, 'quotedOrderColumn', [], 'string'); + self::assertPublicMethod($class, 'quotedScopeColumn', [], 'string'); + self::assertPublicMethod($class, 'quotedDeletedAtColumn', [], 'string'); + } + + public function testScopedOrderingManagerApiMatchesRepositoryReality(): void + { + $class = new ReflectionClass(ScopedOrderingManager::class); + + self::assertSame('Maatify\\Persistence\\Pdo\\Ordering', $class->getNamespaceName()); + self::assertTrue($class->isFinal()); + self::assertTrue($class->isReadOnly()); + self::assertNull($class->getConstructor()); + + self::assertPublicMethod($class, 'getNextPosition', [ + ['pdo', PDO::class, false, false, null], + ['config', ScopedOrderingConfig::class, false, false, null], + ['scopeValue', 'int|string', true, true, null], + ], 'int'); + self::assertPublicMethod($class, 'moveWithinScope', [ + ['pdo', PDO::class, false, false, null], + ['config', ScopedOrderingConfig::class, false, false, null], + ['scopeValue', 'int|string', true, false, null], + ['id', 'int', false, false, null], + ['newOrder', 'int', false, false, null], + ], 'bool'); + self::assertPublicMethod($class, 'rowExistsInScope', [ + ['pdo', PDO::class, false, false, null], + ['config', ScopedOrderingConfig::class, false, false, null], + ['scopeValue', 'int|string', true, false, null], + ['id', 'int', false, false, null], + ], 'bool'); + } + + /** + * @param ReflectionClass $class + * @param list $expected + */ + private static function assertConstructorParameters(ReflectionClass $class, array $expected): void + { + $constructor = $class->getConstructor(); + self::assertNotNull($constructor); + self::assertParameters($constructor->getParameters(), $expected); + } + + /** + * @param ReflectionClass $class + * @param list $expectedParameters + */ + private static function assertPublicMethod(ReflectionClass $class, string $methodName, array $expectedParameters, string $returnType): void + { + $method = $class->getMethod($methodName); + self::assertTrue($method->isPublic()); + self::assertParameters($method->getParameters(), $expectedParameters); + self::assertSame($returnType, self::typeName($method->getReturnType())); + } + + /** + * @param list $parameters + * @param list $expected + */ + private static function assertParameters(array $parameters, array $expected): void + { + self::assertCount(count($expected), $parameters); + + foreach ($expected as $index => [$name, $type, $allowsNull, $hasDefault, $default]) { + $parameter = $parameters[$index]; + self::assertSame($name, $parameter->getName()); + self::assertSame($type, self::typeName($parameter->getType())); + self::assertSame($allowsNull, $parameter->allowsNull()); + self::assertSame($hasDefault, $parameter->isDefaultValueAvailable()); + if ($hasDefault) { + self::assertSame($default, $parameter->getDefaultValue()); + } + } + } + + private static function typeName(?ReflectionType $type): string + { + self::assertNotNull($type); + + if ($type instanceof ReflectionNamedType) { + return $type->getName(); + } + + if ($type instanceof ReflectionUnionType) { + $parts = []; + foreach ($type->getTypes() as $innerType) { + $name = self::typeName($innerType); + if ($name !== 'null') { + $parts[] = $name; + } + } + sort($parts); + + return implode('|', $parts); + } + + if ($type instanceof ReflectionIntersectionType) { + $parts = []; + foreach ($type->getTypes() as $innerType) { + $parts[] = self::typeName($innerType); + } + sort($parts); + + return implode('&', $parts); + } + + self::fail('Unsupported reflection type.'); + } + +} diff --git a/tests/Regression/Pdo/Pagination/PaginationPublicApiRegressionTest.php b/tests/Regression/Pdo/Pagination/PaginationPublicApiRegressionTest.php new file mode 100644 index 0000000..3ec48b6 --- /dev/null +++ b/tests/Regression/Pdo/Pagination/PaginationPublicApiRegressionTest.php @@ -0,0 +1,174 @@ +getNamespaceName()); + self::assertTrue($class->isFinal()); + self::assertTrue($class->isReadOnly()); + } + + self::assertSame(['ASC' => 'ASC', 'DESC' => 'DESC'], array_column(SortDirectionEnum::cases(), 'value', 'name')); + self::assertSame(['cases', 'from', 'tryFrom'], array_map(static fn (\ReflectionMethod $method): string => $method->getName(), (new ReflectionClass(SortDirectionEnum::class))->getMethods())); + } + + public function testConstructorContractsAndPromotedProperties(): void + { + self::assertConstructor(PageRequest::class, [ + ['page', 'int|string', true, true, null, true], + ['perPage', 'int|string', true, true, null, true], + ['sortBy', 'string', true, true, null, true], + ['sortDirection', 'string', true, true, null, true], + ]); + self::assertConstructor(PaginationConfig::class, [ + ['sortWhitelist', SortWhitelist::class, false, false, null, true], + ['defaultSortBy', 'string', false, false, null, true], + ['defaultSortDirection', SortDirectionEnum::class, false, false, null, true], + ['tieBreakerSortBy', 'string', false, false, null, true], + ['tieBreakerDirection', SortDirectionEnum::class, false, false, null, true], + ['defaultPerPage', 'int', false, true, 20, true], + ['minPerPage', 'int', false, true, 1, true], + ['maxPerPage', 'int', false, true, 200, true], + ]); + self::assertConstructor(PdoPaginationQueryDescriptor::class, [ + ['totalSql', 'string', false, false, null, true], + ['totalParams', 'array', false, false, null, true], + ['filteredCountSql', 'string', false, false, null, true], + ['filteredCountParams', 'array', false, false, null, true], + ['dataSql', 'string', false, false, null, true], + ['dataParams', 'array', false, false, null, true], + ]); + self::assertConstructor(PageResult::class, [ + ['data', 'array', false, false, null, true], + ['page', 'int', false, false, null, true], + ['perPage', 'int', false, false, null, true], + ['total', 'int', false, false, null, true], + ['filtered', 'int', false, false, null, true], + ['totalPages', 'int', false, false, null, true], + ['hasNext', 'bool', false, false, null, true], + ['hasPrevious', 'bool', false, false, null, true], + ['sortBy', 'string', false, false, null, true], + ['sortDirection', SortDirectionEnum::class, false, false, null, true], + ]); + self::assertConstructor(SortWhitelist::class, [['sorts', 'array', false, false, null, false]]); + } + + public function testPublicMethodsAndResultEnvelope(): void + { + self::assertMethod(PdoPaginator::class, 'paginate', ['pdo', 'query', 'request', 'config', 'mapper'], PageResult::class); + self::assertMethod(SortWhitelist::class, 'contains', ['key'], 'bool'); + self::assertMethod(SortWhitelist::class, 'quotedIdentifierFor', ['key'], 'string'); + self::assertMethod(PageResult::class, 'toArray', [], 'array'); + self::assertMethod(PageResult::class, 'jsonSerialize', [], 'array'); + self::assertContains(JsonSerializable::class, class_implements(PageResult::class)); + + $result = new PageResult([], 1, 20, 0, 0, 0, false, false, 'id', SortDirectionEnum::ASC); + self::assertSame(['data', 'pagination'], array_keys($result->toArray())); + self::assertSame(['page', 'per_page', 'total', 'filtered', 'total_pages', 'has_next', 'has_previous', 'sort_by', 'sort_direction'], array_keys($result->toArray()['pagination'])); + self::assertArrayNotHasKey('offset', $result->toArray()['pagination']); + } + + /** + * @param class-string $className + * @param list $expected + */ + private static function assertConstructor(string $className, array $expected): void + { + $class = new ReflectionClass($className); + $constructor = $class->getConstructor(); + self::assertNotNull($constructor); + self::assertParameters($constructor->getParameters(), $expected); + } + + /** + * @param class-string $className + * @param list $parameterNames + */ + private static function assertMethod(string $className, string $methodName, array $parameterNames, string $returnType): void + { + $method = (new ReflectionClass($className))->getMethod($methodName); + self::assertTrue($method->isPublic()); + self::assertSame($parameterNames, array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $method->getParameters())); + self::assertSame($returnType, self::typeName($method->getReturnType())); + } + + /** + * @param list $parameters + * @param list $expected + */ + private static function assertParameters(array $parameters, array $expected): void + { + self::assertCount(count($expected), $parameters); + foreach ($expected as $index => [$name, $type, $nullable, $hasDefault, $default, $promoted]) { + $parameter = $parameters[$index]; + self::assertSame($name, $parameter->getName()); + self::assertSame($type, self::typeName($parameter->getType())); + self::assertSame($nullable, $parameter->allowsNull()); + self::assertSame($hasDefault, $parameter->isDefaultValueAvailable()); + self::assertSame($promoted, $parameter->isPromoted()); + if ($hasDefault) { + self::assertSame($default, $parameter->getDefaultValue()); + } + } + } + + private static function typeName(?ReflectionType $type): string + { + self::assertNotNull($type); + + if ($type instanceof ReflectionNamedType) { + return $type->getName(); + } + + if ($type instanceof ReflectionUnionType) { + $parts = []; + foreach ($type->getTypes() as $innerType) { + $name = self::typeName($innerType); + if ($name !== 'null') { + $parts[] = $name; + } + } + sort($parts); + + return implode('|', $parts); + } + + if ($type instanceof ReflectionIntersectionType) { + $parts = []; + foreach ($type->getTypes() as $innerType) { + $parts[] = self::typeName($innerType); + } + sort($parts); + + return implode('&', $parts); + } + + self::fail('Unsupported reflection type.'); + } + +} diff --git a/tests/Regression/Pdo/Pagination/PaginationSqlContractRegressionTest.php b/tests/Regression/Pdo/Pagination/PaginationSqlContractRegressionTest.php new file mode 100644 index 0000000..2666b1a --- /dev/null +++ b/tests/Regression/Pdo/Pagination/PaginationSqlContractRegressionTest.php @@ -0,0 +1,127 @@ + 1]]); + $pdo = new ScriptedPdo([$total, $filtered, $data]); + $query = new PdoPaginationQueryDescriptor( + 'TOTAL SQL', + ['total_tenant' => 1], + 'FILTERED SQL', + ['filtered_category' => 'book'], + 'DATA SQL', + ['data_active' => true], + ); + + $result = (new PdoPaginator())->paginate( + $pdo, + $query, + new PageRequest(1, 2, ' created_at ', 'desc; DROP'), + $this->config(), + static fn (array $row): array => $row, + ); + + self::assertSame('created_at', $result->sortBy); + self::assertSame('DESC', $result->sortDirection->value); + self::assertSame([ + 'TOTAL SQL', + 'FILTERED SQL', + "DATA SQL\nORDER BY `created_at` DESC, `id` ASC\nLIMIT :__pagination_limit\nOFFSET :__pagination_offset", + ], $pdo->preparedSql); + self::assertSame([':total_tenant'], array_column($total->bindCalls, 'parameter')); + self::assertSame([':filtered_category'], array_column($filtered->bindCalls, 'parameter')); + self::assertSame([':data_active', ':__pagination_limit', ':__pagination_offset'], array_column($data->bindCalls, 'parameter')); + self::assertSame([PDO::PARAM_BOOL, PDO::PARAM_INT, PDO::PARAM_INT], array_column($data->bindCalls, 'type')); + self::assertStringNotContainsString('DROP', $pdo->preparedSql[2]); + } + + public function testDuplicateIdentifierSqlZeroFilteredSkipAndOverflowSingleExecution(): void + { + $zeroPdo = new ScriptedPdo([ScriptedPdoStatement::count(10), ScriptedPdoStatement::count(0)]); + $zero = (new PdoPaginator())->paginate( + $zeroPdo, + $this->descriptor(), + new PageRequest(9, 2), + $this->config(duplicate: true), + static fn (array $row): array => $row, + ); + self::assertSame([], $zero->data); + self::assertSame(['TOTAL SQL', 'FILTERED SQL'], $zeroPdo->preparedSql); + + $data = ScriptedPdoStatement::data([]); + $overflowPdo = new ScriptedPdo([ScriptedPdoStatement::count(10), ScriptedPdoStatement::count(3), $data]); + $overflow = (new PdoPaginator())->paginate( + $overflowPdo, + $this->descriptor(), + new PageRequest(99, 2), + $this->config(duplicate: true), + static fn (array $row): array => $row, + ); + + self::assertSame(1, $overflow->page); + self::assertSame(1, $data->executeCalls); + self::assertSame("DATA SQL\nORDER BY `id` DESC\nLIMIT :__pagination_limit\nOFFSET :__pagination_offset", $overflowPdo->preparedSql[2]); + self::assertSame(['data' => [], 'pagination' => ['page' => 1, 'per_page' => 2, 'total' => 10, 'filtered' => 3, 'total_pages' => 2, 'has_next' => true, 'has_previous' => false, 'sort_by' => 'created_at', 'sort_direction' => 'DESC']], $overflow->toArray()); + self::assertArrayNotHasKey('offset', $overflow->toArray()['pagination']); + } + + #[DataProvider('noParserExamples')] + public function testConstructorAcceptsApprovedNoParserExamples(string $dataSql): void + { + $descriptor = new PdoPaginationQueryDescriptor('SELECT COUNT(*) AS total_count', [], 'SELECT COUNT(*) AS filtered_count', [], $dataSql, []); + + self::assertSame($dataSql, $descriptor->dataSql); + } + + /** @return iterable */ + public static function noParserExamples(): iterable + { + yield 'missing placeholder correspondence' => ['SELECT id FROM items WHERE tenant_id = :missing']; + yield 'unused parameter possible' => ['SELECT id FROM items']; + yield 'repeated named placeholder' => ['SELECT id FROM items WHERE a = :x OR b = :x']; + yield 'positional placeholder' => ['SELECT id FROM items WHERE a = ?']; + yield 'mixed placeholders' => ['SELECT id FROM items WHERE a = ? AND b = :b']; + yield 'caller order by' => ['SELECT id FROM items ORDER BY id']; + yield 'caller limit' => ['SELECT id FROM items LIMIT 10']; + yield 'malformed without semicolon or reserved prefix' => ['THIS IS NOT VALID SQL']; + } + + private function descriptor(): PdoPaginationQueryDescriptor + { + return new PdoPaginationQueryDescriptor('TOTAL SQL', [], 'FILTERED SQL', [], 'DATA SQL', []); + } + + private function config(bool $duplicate = false): PaginationConfig + { + return new PaginationConfig( + new SortWhitelist(['created_at' => $duplicate ? 'id' : 'created_at', 'id' => 'id']), + 'created_at', + SortDirectionEnum::DESC, + 'id', + SortDirectionEnum::ASC, + 2, + 1, + 50, + ); + } +} diff --git a/tests/Support/MySql/PaginationFixture.php b/tests/Support/MySql/PaginationFixture.php new file mode 100644 index 0000000..dfc71a6 --- /dev/null +++ b/tests/Support/MySql/PaginationFixture.php @@ -0,0 +1,43 @@ +pdo->prepare( + 'INSERT INTO `' . PaginationSchemaManager::TABLE . '` ' + . '(`tenant_id`, `category`, `name`, `score`, `is_active`, `nullable_code`, `created_at`, `deleted_at`) ' + . 'VALUES (:tenant_id, :category, :name, :score, :is_active, :nullable_code, :created_at, :deleted_at)' + ); + + $statement->bindValue(':tenant_id', $tenantId, PDO::PARAM_INT); + $statement->bindValue(':category', $category, PDO::PARAM_STR); + $statement->bindValue(':name', $name, PDO::PARAM_STR); + $statement->bindValue(':score', $score, PDO::PARAM_INT); + $statement->bindValue(':is_active', $isActive, PDO::PARAM_BOOL); + $statement->bindValue(':nullable_code', $nullableCode, $nullableCode === null ? PDO::PARAM_NULL : PDO::PARAM_STR); + $statement->bindValue(':created_at', $createdAt, PDO::PARAM_STR); + $statement->bindValue(':deleted_at', $deletedAt, $deletedAt === null ? PDO::PARAM_NULL : PDO::PARAM_STR); + $statement->execute(); + + return (int) $this->pdo->lastInsertId(); + } +} diff --git a/tests/Support/MySql/PaginationIntegrationTestCase.php b/tests/Support/MySql/PaginationIntegrationTestCase.php new file mode 100644 index 0000000..3f31148 --- /dev/null +++ b/tests/Support/MySql/PaginationIntegrationTestCase.php @@ -0,0 +1,114 @@ +create(); + self::$schemaManager = new PaginationSchemaManager(self::$pdo); + self::$schemaManager->initialize(); + } + + protected function setUp(): void + { + $this->rollBackOpenTransaction(); + self::$schemaManager->reset(); + $this->fixture = new PaginationFixture(self::$pdo); + } + + protected function tearDown(): void + { + $this->rollBackOpenTransaction(); + self::$schemaManager->reset(); + } + + public static function tearDownAfterClass(): void + { + if (isset(self::$pdo) && self::$pdo->inTransaction()) { + self::$pdo->rollBack(); + } + + if (isset(self::$schemaManager)) { + self::$schemaManager->dropTable(); + } + } + + protected function pdo(): PDO + { + return self::$pdo; + } + + protected function config(): PaginationConfig + { + return new PaginationConfig( + new SortWhitelist([ + 'id' => 'id', + 'score' => 'score', + 'created_at' => 'created_at', + 'same_id' => 'id', + ]), + 'created_at', + SortDirectionEnum::DESC, + 'id', + SortDirectionEnum::ASC, + 2, + 1, + 50, + ); + } + + /** + * @param array $total + * @param array $filtered + * @param array $data + */ + protected function descriptor(array $total = [], array $filtered = [], array $data = []): PdoPaginationQueryDescriptor + { + $table = PaginationSchemaManager::TABLE; + + return new PdoPaginationQueryDescriptor( + "SELECT COUNT(*) AS total_count FROM `$table` WHERE tenant_id = :tenant_total AND deleted_at IS NULL", + $total ?: ['tenant_total' => 1], + "SELECT COUNT(*) AS filtered_count FROM `$table` WHERE tenant_id = :tenant_filtered AND deleted_at IS NULL AND is_active = :active_filtered", + $filtered ?: ['tenant_filtered' => 1, 'active_filtered' => true], + "SELECT id, tenant_id, category, name, score, is_active, nullable_code, created_at, deleted_at FROM `$table` WHERE tenant_id = :tenant_data AND deleted_at IS NULL AND is_active = :active_data", + $data ?: ['tenant_data' => 1, 'active_data' => true], + ); + } + + /** @return list */ + protected function insertMultiTenantDataset(): array + { + return [ + $this->fixture->insertItem(1, 'book', 'Alpha', 10, true, null, '2026-01-01 00:00:00.000000'), + $this->fixture->insertItem(1, 'book', 'Beta', 10, true, 'B', '2026-01-02 00:00:00.000000'), + $this->fixture->insertItem(1, 'tool', 'Gamma', 20, true, 'C', '2026-01-03 00:00:00.000000'), + $this->fixture->insertItem(1, 'book', 'Inactive', 30, false, null, '2026-01-04 00:00:00.000000'), + $this->fixture->insertItem(1, 'book', 'Deleted', 40, true, null, '2026-01-05 00:00:00.000000', '2026-01-06 00:00:00.000000'), + $this->fixture->insertItem(2, 'book', 'Other Tenant', 50, true, null, '2026-01-07 00:00:00.000000'), + ]; + } + + private function rollBackOpenTransaction(): void + { + if (isset(self::$pdo) && self::$pdo->inTransaction()) { + self::$pdo->rollBack(); + } + } +} diff --git a/tests/Support/MySql/PaginationSchemaManager.php b/tests/Support/MySql/PaginationSchemaManager.php new file mode 100644 index 0000000..5cdf928 --- /dev/null +++ b/tests/Support/MySql/PaginationSchemaManager.php @@ -0,0 +1,46 @@ +dropTable(); + $this->executeFixture('create_pagination_items_table.sql'); + } + + public function reset(): void + { + $this->pdo->exec('TRUNCATE TABLE `' . self::TABLE . '`'); + } + + public function dropTable(): void + { + $this->pdo->exec('DROP TABLE IF EXISTS `' . self::TABLE . '`'); + } + + /** @param non-empty-string $filename */ + private function executeFixture(string $filename): void + { + $path = dirname(__DIR__, 2) . '/Fixtures/MySql/' . $filename; + $sql = file_get_contents($path); + + if (!is_string($sql) || $sql === '') { + throw new AssertionFailedError(sprintf('Unable to read MySQL fixture %s.', $filename)); + } + + $this->pdo->exec($sql); + } +} diff --git a/tests/Support/Pdo/Pagination/ScriptedPdo.php b/tests/Support/Pdo/Pagination/ScriptedPdo.php new file mode 100644 index 0000000..c3b16f8 --- /dev/null +++ b/tests/Support/Pdo/Pagination/ScriptedPdo.php @@ -0,0 +1,79 @@ + */ + public array $preparedSql = []; + + public int $beginTransactionCalls = 0; + + public int $commitCalls = 0; + + public int $rollBackCalls = 0; + + /** @var list */ + public array $setAttributeCalls = []; + + /** @param list $prepareQueue */ + public function __construct(private array $prepareQueue) + { + } + + /** @param array $options */ + public function prepare(string $query, array $options = []): PDOStatement|false + { + unset($options); + + $this->preparedSql[] = $query; + if ($this->prepareQueue === []) { + throw new LogicException('Scripted PDO prepare queue exhausted.'); + } + + $result = array_shift($this->prepareQueue); + if ($result instanceof Throwable) { + throw $result; + } + + return $result; + } + + public function beginTransaction(): bool + { + $this->beginTransactionCalls++; + + return true; + } + + public function commit(): bool + { + $this->commitCalls++; + + return true; + } + + public function rollBack(): bool + { + $this->rollBackCalls++; + + return true; + } + + public function setAttribute(int $attribute, mixed $value): bool + { + $this->setAttributeCalls[] = [ + 'attribute' => $attribute, + 'value' => $value, + ]; + + return true; + } +} diff --git a/tests/Support/Pdo/Pagination/ScriptedPdoStatement.php b/tests/Support/Pdo/Pagination/ScriptedPdoStatement.php new file mode 100644 index 0000000..26e26da --- /dev/null +++ b/tests/Support/Pdo/Pagination/ScriptedPdoStatement.php @@ -0,0 +1,101 @@ + */ + public array $bindCalls = []; + + public int $executeCalls = 0; + + public int $fetchCalls = 0; + + /** + * @param list $fetchQueue + * @param array $bindResults + */ + public function __construct( + private int $columns = 1, + private array $fetchQueue = [], + private bool|Throwable $executeResult = true, + private ?string $statementErrorCode = '00000', + private array $bindResults = [], + ) { + } + + public static function count(mixed $value): self + { + return new self(1, [['total_count' => $value], false]); + } + + /** @param list> $rows */ + public static function data(array $rows): self + { + return new self($rows === [] ? 1 : count($rows[0]), [...$rows, false]); + } + + public function bindValue(string|int $param, mixed $value, int $type = PDO::PARAM_STR): bool + { + $parameter = (string) $param; + $this->bindCalls[] = [ + 'parameter' => $parameter, + 'value' => $value, + 'type' => $type, + ]; + + $result = $this->bindResults[$parameter] ?? true; + if ($result instanceof Throwable) { + throw $result; + } + + return $result; + } + + /** @param array|null $params */ + public function execute(?array $params = null): bool + { + unset($params); + + $this->executeCalls++; + if ($this->executeResult instanceof Throwable) { + throw $this->executeResult; + } + + return $this->executeResult; + } + + public function fetch(int $mode = PDO::FETCH_DEFAULT, int $cursorOrientation = PDO::FETCH_ORI_NEXT, int $cursorOffset = 0): mixed + { + unset($mode, $cursorOrientation, $cursorOffset); + + $this->fetchCalls++; + if ($this->fetchQueue === []) { + throw new LogicException('Scripted PDO statement fetch queue exhausted.'); + } + + $result = array_shift($this->fetchQueue); + if ($result instanceof Throwable) { + throw $result; + } + + return $result; + } + + public function columnCount(): int + { + return $this->columns; + } + + public function errorCode(): ?string + { + return $this->statementErrorCode; + } +} diff --git a/tests/Unit/Pdo/Pagination/PageRequestTest.php b/tests/Unit/Pdo/Pagination/PageRequestTest.php new file mode 100644 index 0000000..63bbf55 --- /dev/null +++ b/tests/Unit/Pdo/Pagination/PageRequestTest.php @@ -0,0 +1,37 @@ +page); + self::assertNull($default->perPage); + self::assertNull($default->sortBy); + self::assertNull($default->sortDirection); + + $request = new PageRequest('abc', ' 7x ', ' created_at ', ' desc '); + + self::assertSame('abc', $request->page); + self::assertSame(' 7x ', $request->perPage); + self::assertSame(' created_at ', $request->sortBy); + self::assertSame(' desc ', $request->sortDirection); + self::assertSame( + ['__construct'], + array_map( + static fn (ReflectionMethod $method): string => $method->getName(), + (new ReflectionClass(PageRequest::class))->getMethods(ReflectionMethod::IS_PUBLIC), + ), + ); + } +} diff --git a/tests/Unit/Pdo/Pagination/PageResultTest.php b/tests/Unit/Pdo/Pagination/PageResultTest.php new file mode 100644 index 0000000..4e484b5 --- /dev/null +++ b/tests/Unit/Pdo/Pagination/PageResultTest.php @@ -0,0 +1,112 @@ + */ + public function toArray(): array + { + $this->toArrayCalled = true; + + return ['called' => true]; + } + + /** @return array */ + public function jsonSerialize(): array + { + $this->jsonSerializeCalled = true; + + return ['called' => true]; + } + }; + + $result = new PageResult([['id' => 1], $object], 1, 20, 30, 22, 2, true, false, 'created_at', SortDirectionEnum::DESC); + $array = $result->toArray(); + + self::assertSame($array, $result->jsonSerialize()); + self::assertArrayNotHasKey('offset', $array['pagination']); + self::assertSame($object, $array['data'][1]); + self::assertSame(2, $array['pagination']['total_pages']); + self::assertFalse($object->toArrayCalled); + self::assertFalse($object->jsonSerializeCalled); + } + + /** @param list|object> $data */ + #[DataProvider('validResults')] + public function testValidResultMatrix(array $data, int $page, int $perPage, int $total, int $filtered, int $totalPages, bool $hasNext, bool $hasPrevious): void + { + $result = new PageResult($data, $page, $perPage, $total, $filtered, $totalPages, $hasNext, $hasPrevious, 'id', SortDirectionEnum::ASC); + + self::assertSame($page, $result->page); + self::assertSame($data, $result->data); + } + + /** @return iterable|object>, int, int, int, int, int, bool, bool}> */ + public static function validResults(): iterable + { + yield 'arrays' => [[['id' => 1]], 1, 20, 1, 1, 1, false, false]; + yield 'objects' => [[new \stdClass()], 1, 20, 1, 1, 1, false, false]; + yield 'empty positive filtered' => [[], 2, 20, 50, 50, 3, true, true]; + yield 'zero result' => [[], 1, 20, 0, 0, 0, false, false]; + } + + /** @param list $arguments */ + #[DataProvider('invalidResults')] + public function testInvariantRejection(array $arguments): void + { + $this->expectException(PaginationExecutionException::class); + + $this->newPageResult($arguments); + } + + /** @return iterable}> */ + public static function invalidResults(): iterable + { + yield 'associative outer data' => [[['x' => []], 1, 20, 0, 0, 0, false, false, 'id', SortDirectionEnum::ASC]]; + yield 'scalar item' => [[[1], 1, 20, 1, 1, 1, false, false, 'id', SortDirectionEnum::ASC]]; + yield 'page below one' => [[[], 0, 20, 0, 0, 0, false, false, 'id', SortDirectionEnum::ASC]]; + yield 'per page below one' => [[[], 1, 0, 0, 0, 0, false, false, 'id', SortDirectionEnum::ASC]]; + yield 'negative total' => [[[], 1, 20, -1, 0, 0, false, false, 'id', SortDirectionEnum::ASC]]; + yield 'negative filtered' => [[[], 1, 20, 0, -1, 0, false, false, 'id', SortDirectionEnum::ASC]]; + yield 'negative pages' => [[[], 1, 20, 0, 0, -1, false, false, 'id', SortDirectionEnum::ASC]]; + yield 'data exceeds per page' => [[[[], []], 1, 1, 2, 2, 2, true, false, 'id', SortDirectionEnum::ASC]]; + yield 'bad sort key' => [[[], 1, 20, 0, 0, 0, false, false, 'bad-key', SortDirectionEnum::ASC]]; + yield 'bad total pages' => [[[], 1, 20, 20, 20, 2, false, false, 'id', SortDirectionEnum::ASC]]; + yield 'zero filtered non-empty data' => [[[['id' => 1]], 1, 20, 0, 0, 0, false, false, 'id', SortDirectionEnum::ASC]]; + yield 'zero filtered page not one' => [[[], 2, 20, 0, 0, 0, false, false, 'id', SortDirectionEnum::ASC]]; + yield 'zero filtered has next' => [[[], 1, 20, 0, 0, 0, true, false, 'id', SortDirectionEnum::ASC]]; + yield 'bad has next' => [[[], 1, 20, 50, 50, 3, false, false, 'id', SortDirectionEnum::ASC]]; + yield 'bad has previous' => [[[], 1, 20, 50, 50, 3, true, true, 'id', SortDirectionEnum::ASC]]; + } + + /** + * @param list $arguments + * + * @return PageResult|object> + */ + private function newPageResult(array $arguments): PageResult + { + $reflection = new ReflectionClass(PageResult::class); + + /** @var PageResult|object> $result */ + $result = $reflection->newInstanceArgs($arguments); + + return $result; + } +} diff --git a/tests/Unit/Pdo/Pagination/PaginationConfigTest.php b/tests/Unit/Pdo/Pagination/PaginationConfigTest.php new file mode 100644 index 0000000..8865c58 --- /dev/null +++ b/tests/Unit/Pdo/Pagination/PaginationConfigTest.php @@ -0,0 +1,68 @@ + 'id', + 'created_at' => 'created_at', + 'alias' => 'id', + ]); + + $default = new PaginationConfig($whitelist, 'created_at', SortDirectionEnum::DESC, 'id', SortDirectionEnum::ASC); + + self::assertSame(20, $default->defaultPerPage); + self::assertSame(1, $default->minPerPage); + self::assertSame(200, $default->maxPerPage); + + $custom = new PaginationConfig($whitelist, 'alias', SortDirectionEnum::ASC, 'id', SortDirectionEnum::DESC, 10, 2, 50); + + self::assertSame(10, $custom->defaultPerPage); + self::assertSame(2, $custom->minPerPage); + self::assertSame(50, $custom->maxPerPage); + } + + #[DataProvider('invalidConfig')] + public function testInvalidConfig(string $default, string $tie, int $defaultPerPage, int $minPerPage, int $maxPerPage): void + { + $this->expectException(InvalidPaginationConfigurationException::class); + + new PaginationConfig( + new SortWhitelist(['id' => 'id', 'created_at' => 'created_at']), + $default, + SortDirectionEnum::ASC, + $tie, + SortDirectionEnum::ASC, + $defaultPerPage, + $minPerPage, + $maxPerPage, + ); + } + + /** @return iterable */ + public static function invalidConfig(): iterable + { + yield 'zero min' => ['id', 'id', 20, 0, 200]; + yield 'negative min' => ['id', 'id', 20, -1, 200]; + yield 'max below min' => ['id', 'id', 20, 10, 5]; + yield 'default below min' => ['id', 'id', 1, 2, 10]; + yield 'default above max' => ['id', 'id', 11, 2, 10]; + yield 'malformed default' => ['bad-key', 'id', 5, 1, 10]; + yield 'malformed tie breaker' => ['id', 'bad-key', 5, 1, 10]; + yield 'missing default' => ['missing', 'id', 5, 1, 10]; + yield 'missing tie breaker' => ['id', 'missing', 5, 1, 10]; + yield 'empty default' => ['', 'id', 5, 1, 10]; + } +} diff --git a/tests/Unit/Pdo/Pagination/PdoPaginationQueryDescriptorTest.php b/tests/Unit/Pdo/Pagination/PdoPaginationQueryDescriptorTest.php new file mode 100644 index 0000000..9af2e82 --- /dev/null +++ b/tests/Unit/Pdo/Pagination/PdoPaginationQueryDescriptorTest.php @@ -0,0 +1,143 @@ + 1], + 'SELECT COUNT(*) AS filtered_count', + ['b' => '1.23'], + 'SELECT id FROM t', + ['c' => true, 'd' => null], + ); + + self::assertSame(' SELECT COUNT(*) AS total_count ', $descriptor->totalSql); + self::assertSame(['a' => 1], $descriptor->totalParams); + self::assertSame(['b' => '1.23'], $descriptor->filteredCountParams); + self::assertSame(['c' => true, 'd' => null], $descriptor->dataParams); + } + + #[DataProvider('badSql')] + public function testSqlRejection(string $sql): void + { + $this->expectException(InvalidPaginationQueryException::class); + + new PdoPaginationQueryDescriptor($sql, [], 'SELECT COUNT(*) AS filtered_count', [], 'SELECT id FROM t', []); + } + + /** @return iterable */ + public static function badSql(): iterable + { + yield 'empty' => ['']; + yield 'whitespace' => [' ']; + yield 'leading semicolon' => [';SELECT 1']; + yield 'middle semicolon' => ['SELECT 1; SELECT 2']; + yield 'trailing semicolon' => ['SELECT 1;']; + yield 'reserved limit' => ['SELECT :__pagination_limit']; + yield 'reserved offset' => ['SELECT :__pagination_offset']; + yield 'reserved custom' => ['SELECT :__pagination_custom']; + } + + /** @param array $params */ + #[DataProvider('badParams')] + public function testTotalParamRejection(array $params): void + { + $this->expectException(InvalidPaginationQueryException::class); + + $this->newDescriptor('SELECT COUNT(*) AS total_count', $params, 'SELECT COUNT(*) AS filtered_count', [], 'SELECT id FROM t', []); + } + + /** @param array $params */ + #[DataProvider('badParams')] + public function testFilteredParamRejection(array $params): void + { + $this->expectException(InvalidPaginationQueryException::class); + + $this->newDescriptor('SELECT COUNT(*) AS total_count', [], 'SELECT COUNT(*) AS filtered_count', $params, 'SELECT id FROM t', []); + } + + /** @param array $params */ + #[DataProvider('badParams')] + public function testDataParamRejection(array $params): void + { + $this->expectException(InvalidPaginationQueryException::class); + + $this->newDescriptor('SELECT COUNT(*) AS total_count', [], 'SELECT COUNT(*) AS filtered_count', [], 'SELECT id FROM t', $params); + } + + /** @return iterable}> */ + public static function badParams(): iterable + { + yield 'integer key' => [[1 => 'x']]; + yield 'empty key' => [['' => 'x']]; + yield 'leading colon' => [[':a' => 'x']]; + yield 'starts digit' => [['1a' => 'x']]; + yield 'whitespace' => [['a b' => 'x']]; + yield 'hyphen' => [['a-b' => 'x']]; + yield 'reserved' => [['__pagination_limit' => 1]]; + yield 'float' => [['a' => 1.2]]; + yield 'array' => [['a' => []]]; + yield 'object' => [['a' => new \stdClass()]]; + } + + public function testResourceParamRejectionClosesResource(): void + { + $resource = fopen('php://memory', 'r'); + self::assertIsResource($resource); + + try { + $this->expectException(InvalidPaginationQueryException::class); + $this->newDescriptor('SELECT COUNT(*) AS total_count', ['a' => $resource], 'SELECT COUNT(*) AS filtered_count', [], 'SELECT id FROM t', []); + } finally { + fclose($resource); + } + } + + #[DataProvider('noParser')] + public function testNoParserContractAllowsNonPreflightSql(string $sql): void + { + $descriptor = new PdoPaginationQueryDescriptor('SELECT COUNT(*) AS total_count', [], 'SELECT COUNT(*) AS filtered_count', [], $sql, []); + + self::assertSame($sql, $descriptor->dataSql); + } + + /** @return iterable */ + public static function noParser(): iterable + { + yield 'missing placeholder' => ['SELECT :missing']; + yield 'unused parameter possible' => ['SELECT id FROM t']; + yield 'repeated named' => ['SELECT :x + :x']; + yield 'positional' => ['SELECT ?']; + yield 'mixed' => ['SELECT ? AND :x']; + yield 'order by' => ['SELECT id FROM t ORDER BY id']; + yield 'limit' => ['SELECT id FROM t LIMIT 1']; + yield 'malformed' => ['MALFORMED SQL']; + } + + /** + * @param array $totalParams + * @param array $filteredParams + * @param array $dataParams + */ + private function newDescriptor(string $totalSql, array $totalParams, string $filteredSql, array $filteredParams, string $dataSql, array $dataParams): PdoPaginationQueryDescriptor + { + $reflection = new ReflectionClass(PdoPaginationQueryDescriptor::class); + + /** @var PdoPaginationQueryDescriptor $descriptor */ + $descriptor = $reflection->newInstanceArgs([$totalSql, $totalParams, $filteredSql, $filteredParams, $dataSql, $dataParams]); + + return $descriptor; + } +} diff --git a/tests/Unit/Pdo/Pagination/PdoPaginatorExecutionTest.php b/tests/Unit/Pdo/Pagination/PdoPaginatorExecutionTest.php new file mode 100644 index 0000000..7a11dbc --- /dev/null +++ b/tests/Unit/Pdo/Pagination/PdoPaginatorExecutionTest.php @@ -0,0 +1,37 @@ +'id','created_at'=>$dup?'id':'created_at']),'created_at',SortDirectionEnum::DESC,'id',SortDirectionEnum::ASC); } + public function testOrderSeparateMapsTypedBindingsAndFinalSql(): void { $total=ScriptedPdoStatement::count(10); $filtered=ScriptedPdoStatement::count(5); $data=ScriptedPdoStatement::data([['id'=>1]]); $pdo=new ScriptedPdo([$total,$filtered,$data]); $q=new PdoPaginationQueryDescriptor('TOTAL',['total_tenant'=>1],'FILTERED',['filtered_category'=>'books'],'DATA',['data_active'=>true,'none'=>null]); $r=(new PdoPaginator())->paginate($pdo,$q,new PageRequest(2,2,'created_at','desc'),$this->config(),fn(array $row): array=>$row); self::assertSame(['TOTAL','FILTERED',"DATA +ORDER BY `created_at` DESC, `id` ASC +LIMIT :__pagination_limit +OFFSET :__pagination_offset"],$pdo->preparedSql); self::assertSame(':total_tenant',$total->bindCalls[0]['parameter']); self::assertSame(PDO::PARAM_INT,$total->bindCalls[0]['type']); self::assertSame(':filtered_category',$filtered->bindCalls[0]['parameter']); self::assertSame(PDO::PARAM_STR,$filtered->bindCalls[0]['type']); self::assertSame([':data_active',':none',':__pagination_limit',':__pagination_offset'],array_column($data->bindCalls,'parameter')); self::assertSame([PDO::PARAM_BOOL,PDO::PARAM_NULL,PDO::PARAM_INT,PDO::PARAM_INT],array_column($data->bindCalls,'type')); self::assertSame(2,$r->page); } + public function testDuplicateTieBreakerZeroOverflowAndMapper(): void { $data=ScriptedPdoStatement::data([]); $pdo=new ScriptedPdo([ScriptedPdoStatement::count(10),ScriptedPdoStatement::count(5),$data]); $r=(new PdoPaginator())->paginate($pdo,new PdoPaginationQueryDescriptor('T',[],'F',[],'D',[]),new PageRequest(99,2),$this->config(true),fn(array $row): object=>(object)$row); self::assertSame("D +ORDER BY `id` DESC +LIMIT :__pagination_limit +OFFSET :__pagination_offset",$pdo->preparedSql[2]); self::assertSame(1,$r->page); self::assertSame(1,$data->executeCalls); } + public function testZeroFilteredSkipsDataAndMapperAndTransactions(): void { $pdo=new ScriptedPdo([ScriptedPdoStatement::count(3),ScriptedPdoStatement::count(0)]); $called=false; $r=(new PdoPaginator())->paginate($pdo,new PdoPaginationQueryDescriptor('T',[],'F',[],'D',[]),new PageRequest(9,20),$this->config(),function(array $row) use (&$called): array { $called=true; return $row; }); self::assertSame(1,$r->page); self::assertSame(0,$r->totalPages); self::assertFalse($called); self::assertCount(2,$pdo->preparedSql); self::assertSame(0,$pdo->beginTransactionCalls+$pdo->commitCalls+$pdo->rollBackCalls); self::assertSame([],$pdo->setAttributeCalls); } +} diff --git a/tests/Unit/Pdo/Pagination/PdoPaginatorFailureTest.php b/tests/Unit/Pdo/Pagination/PdoPaginatorFailureTest.php new file mode 100644 index 0000000..685633a --- /dev/null +++ b/tests/Unit/Pdo/Pagination/PdoPaginatorFailureTest.php @@ -0,0 +1,184 @@ +assertExecutionFailure(new ScriptedPdo([false]), 1); } + public function testFilteredPrepareFalse(): void { $this->assertExecutionFailure(new ScriptedPdo([ScriptedPdoStatement::count(1), false]), 2); } + public function testDataPrepareFalse(): void { $this->assertExecutionFailure(new ScriptedPdo([ScriptedPdoStatement::count(1), ScriptedPdoStatement::count(1), false]), 3); } + public function testTotalBindFalse(): void { $this->assertExecutionFailure(new ScriptedPdo([$this->countStatement(bindResults: [':p' => false])]), 1); } + public function testFilteredBindFalse(): void { $this->assertExecutionFailure(new ScriptedPdo([ScriptedPdoStatement::count(1), $this->countStatement(bindResults: [':p' => false])]), 2); } + public function testDataBindFalse(): void { $this->assertExecutionFailure(new ScriptedPdo([ScriptedPdoStatement::count(1), ScriptedPdoStatement::count(1), $this->dataStatement(bindResults: [':p' => false])]), 3); } + public function testInternalLimitBindFalse(): void { $this->assertExecutionFailure(new ScriptedPdo([ScriptedPdoStatement::count(1), ScriptedPdoStatement::count(1), $this->dataStatement(bindResults: [':__pagination_limit' => false])]), 3); } + public function testInternalOffsetBindFalse(): void { $this->assertExecutionFailure(new ScriptedPdo([ScriptedPdoStatement::count(1), ScriptedPdoStatement::count(1), $this->dataStatement(bindResults: [':__pagination_offset' => false])]), 3); } + public function testTotalExecuteFalse(): void { $this->assertExecutionFailure(new ScriptedPdo([$this->countStatement(executeResult: false)]), 1); } + public function testFilteredExecuteFalse(): void { $this->assertExecutionFailure(new ScriptedPdo([ScriptedPdoStatement::count(1), $this->countStatement(executeResult: false)]), 2); } + public function testDataExecuteFalse(): void { $this->assertExecutionFailure(new ScriptedPdo([ScriptedPdoStatement::count(1), ScriptedPdoStatement::count(1), $this->dataStatement(executeResult: false)]), 3); } + + public function testNormalEofProducesResult(): void + { + $data = ScriptedPdoStatement::data([['id' => 1]]); + $result = (new PdoPaginator())->paginate(new ScriptedPdo([ScriptedPdoStatement::count(1), ScriptedPdoStatement::count(1), $data]), $this->queryWithoutParams(), new PageRequest(), $this->config(), static fn (array $row): array => $row); + self::assertSame([['id' => 1]], $result->data); + self::assertSame(2, $data->fetchCalls); + } + + public function testFetchFalseWithNonSuccessSqlstate(): void { $this->assertExecutionFailure(new ScriptedPdo([$this->countStatement(fetchQueue: [false], errorCode: 'HY000')]), 1); } + public function testInvalidScalarFetch(): void { $this->assertExecutionFailure(new ScriptedPdo([new ScriptedPdoStatement(1, [123], true, '00000')]), 1); } + public function testNumericKeyRow(): void { $this->assertExecutionFailure(new ScriptedPdo([new ScriptedPdoStatement(1, [[1], false])]), 1); } + public function testEmptyDataRow(): void { $this->assertExecutionFailure(new ScriptedPdo([ScriptedPdoStatement::count(1), ScriptedPdoStatement::count(1), new ScriptedPdoStatement(1, [[], false])]), 3); } + public function testCountZeroRows(): void { $this->assertExecutionFailure(new ScriptedPdo([new ScriptedPdoStatement(1, [false])]), 1); } + public function testCountMultipleRows(): void { $this->assertExecutionFailure(new ScriptedPdo([new ScriptedPdoStatement(1, [['total_count' => 1], ['total_count' => 2], false])]), 1); } + public function testCountMultipleColumns(): void { $this->assertExecutionFailure(new ScriptedPdo([new ScriptedPdoStatement(2, [['a' => 1, 'b' => 2], false])]), 1); } + + public function testInvalidCountRepresentations(): void + { + foreach ([false, null, '', '-1', '+1', '1.2', '1e2', PHP_INT_MAX . '0', []] as $value) { + $this->assertExecutionFailure(new ScriptedPdo([new ScriptedPdoStatement(1, [['total_count' => $value], false])]), 1); + } + } + + public function testDataFetchFailureAfterMappedRowDoesNotReturnPartialResult(): void + { + $data = new ScriptedPdoStatement(1, [['id' => 1], false], true, 'HY000'); + $this->assertExecutionFailure(new ScriptedPdo([ScriptedPdoStatement::count(1), ScriptedPdoStatement::count(1), $data]), 3); + self::assertSame(2, $data->fetchCalls); + } + + public function testPdoExceptionIdentityFromPrepareBindExecuteAndFetch(): void + { + foreach ([$this->pdoPrepareThrowable(), $this->pdoBindThrowable(), $this->pdoExecuteThrowable(), $this->pdoFetchThrowable()] as [$pdo, $exception]) { + try { + (new PdoPaginator())->paginate($pdo, $this->query(), new PageRequest(), $this->config(), static fn (array $row): array => $row); + self::fail('Expected PDOException.'); + } catch (PDOException $caught) { + self::assertSame($exception, $caught); + } + } + } + + public function testMapperThrowableIdentityAndInvalidMapperResults(): void + { + $exception = new RuntimeException('mapper'); + try { + (new PdoPaginator())->paginate($this->successfulPdo(), $this->queryWithoutParams(), new PageRequest(), $this->config(), static fn (array $row) => throw $exception); + self::fail('Expected mapper exception.'); + } catch (RuntimeException $caught) { + self::assertSame($exception, $caught); + } + + $this->expectException(PaginationExecutionException::class); + $this->invokePaginatorWithMapper(static fn (array $row): int => 1); + } + + public function testResourceMapperResultIsRejectedAndClosed(): void + { + $resource = fopen('php://memory', 'r'); + self::assertIsResource($resource); + try { + $this->expectException(PaginationExecutionException::class); + $this->invokePaginatorWithMapper(static fn (array $row) => $resource); + } finally { + fclose($resource); + } + } + + private function assertExecutionFailure(ScriptedPdo $pdo, int $prepareCount): void + { + try { + (new PdoPaginator())->paginate($pdo, $this->query(), new PageRequest(), $this->config(), static fn (array $row): array => $row); + self::fail('Expected PaginationExecutionException.'); + } catch (PaginationExecutionException $exception) { + self::assertInstanceOf(PaginationExecutionException::class, $exception); + self::assertCount($prepareCount, $pdo->preparedSql); + } + } + + private function invokePaginatorWithMapper(callable $mapper): void + { + (new ReflectionMethod(PdoPaginator::class, 'paginate'))->invokeArgs(new PdoPaginator(), [$this->successfulPdo(), $this->queryWithoutParams(), new PageRequest(), $this->config(), $mapper]); + } + + private function query(): PdoPaginationQueryDescriptor + { + return new PdoPaginationQueryDescriptor('T', ['p' => 1], 'F', ['p' => 1], 'D', ['p' => 1]); + } + private function queryWithoutParams(): PdoPaginationQueryDescriptor + { + return new PdoPaginationQueryDescriptor('T', [], 'F', [], 'D', []); + } + private function config(): PaginationConfig + { + return new PaginationConfig(new SortWhitelist(['id' => 'id']), 'id', SortDirectionEnum::ASC, 'id', SortDirectionEnum::ASC); + } + private function successfulPdo(): ScriptedPdo + { + return new ScriptedPdo([ + ScriptedPdoStatement::count(1), + ScriptedPdoStatement::count(1), + ScriptedPdoStatement::data([['id' => 1]]), + ]); + } + /** + * @param list $fetchQueue + * @param array $bindResults + */ + private function countStatement( + array $fetchQueue = [['total_count' => 1], false], + bool|PDOException $executeResult = true, + ?string $errorCode = '00000', + array $bindResults = [], + ): ScriptedPdoStatement { + return new ScriptedPdoStatement(1, $fetchQueue, $executeResult, $errorCode, $bindResults); + } + /** @param array $bindResults */ + private function dataStatement(bool|PDOException $executeResult = true, array $bindResults = []): ScriptedPdoStatement + { + return new ScriptedPdoStatement(1, [['id' => 1], false], $executeResult, '00000', $bindResults); + } + /** @return array{ScriptedPdo, PDOException} */ + private function pdoPrepareThrowable(): array + { + $exception = new PDOException('prepare'); + + return [new ScriptedPdo([$exception]), $exception]; + } + /** @return array{ScriptedPdo, PDOException} */ + private function pdoBindThrowable(): array + { + $exception = new PDOException('bind'); + + return [new ScriptedPdo([$this->countStatement(bindResults: [':p' => $exception])]), $exception]; + } + /** @return array{ScriptedPdo, PDOException} */ + private function pdoExecuteThrowable(): array + { + $exception = new PDOException('execute'); + + return [new ScriptedPdo([$this->countStatement(executeResult: $exception)]), $exception]; + } + /** @return array{ScriptedPdo, PDOException} */ + private function pdoFetchThrowable(): array + { + $exception = new PDOException('fetch'); + + return [new ScriptedPdo([new ScriptedPdoStatement(1, [$exception])]), $exception]; + } +} diff --git a/tests/Unit/Pdo/Pagination/PdoPaginatorNormalizationTest.php b/tests/Unit/Pdo/Pagination/PdoPaginatorNormalizationTest.php new file mode 100644 index 0000000..2315ffe --- /dev/null +++ b/tests/Unit/Pdo/Pagination/PdoPaginatorNormalizationTest.php @@ -0,0 +1,171 @@ +paginate( + $this->pdo(100), + $this->descriptor(), + new PageRequest($input, 20), + $this->config(defaultPerPage: 20, minPerPage: 1, maxPerPage: 50), + static fn (array $row): array => $row, + ); + + self::assertSame($expected, $result->page); + } + + /** @return iterable */ + public static function pageInputs(): iterable + { + yield [null, 1]; + yield ['', 1]; + yield [' ', 1]; + yield ['abc', 1]; + yield ['1.2', 1]; + yield ['1e2', 1]; + yield ['+', 1]; + yield [0, 1]; + yield [-1, 1]; + yield ['-1', 1]; + yield [1, 1]; + yield ['+1', 1]; + yield ['0002', 2]; + yield [3, 3]; + yield [str_repeat('9', 30), 1]; + yield ['-' . str_repeat('9', 30), 1]; + } + + #[DataProvider('maxPageInputs')] + public function testPhpIntMaxPageIsAcceptedWhenTotalPagesPermit(int|string $input): void + { + $result = (new PdoPaginator())->paginate( + $this->pdo(PHP_INT_MAX), + $this->descriptor(), + new PageRequest($input, 1), + $this->config(defaultPerPage: 1, minPerPage: 1, maxPerPage: 50), + static fn (array $row): array => $row, + ); + + self::assertSame(PHP_INT_MAX, $result->page); + self::assertSame(1, $result->perPage); + self::assertSame(PHP_INT_MAX, $result->totalPages); + } + + /** @return iterable */ + public static function maxPageInputs(): iterable + { + yield [PHP_INT_MAX]; + yield [(string) PHP_INT_MAX]; + } + + #[DataProvider('perPageInputs')] + public function testPerPageNormalization(int|string|null $input, int $expected): void + { + $result = (new PdoPaginator())->paginate( + $this->pdo(100), + $this->descriptor(), + new PageRequest(1, $input), + $this->config(defaultPerPage: 20, minPerPage: 2, maxPerPage: 50), + static fn (array $row): array => $row, + ); + + self::assertSame($expected, $result->perPage); + } + + /** @return iterable */ + public static function perPageInputs(): iterable + { + yield [null, 20]; + yield ['', 20]; + yield ['bad', 20]; + yield [str_repeat('9', 30), 20]; + yield ['-' . str_repeat('9', 30), 20]; + yield [-1, 2]; + yield [0, 2]; + yield [1, 2]; + yield [2, 2]; + yield [25, 25]; + yield [50, 50]; + yield [51, 50]; + yield [PHP_INT_MAX, 50]; + yield [(string) PHP_INT_MAX, 50]; + yield ['0005', 5]; + } + + #[DataProvider('sortInputs')] + public function testSortNormalization(?string $sort, ?string $direction, string $expectedSort, string $expectedDirection): void + { + $result = (new PdoPaginator())->paginate( + $this->pdo(100), + $this->descriptor(), + new PageRequest(1, 20, $sort, $direction), + $this->config(defaultPerPage: 20, minPerPage: 1, maxPerPage: 50), + static fn (array $row): array => $row, + ); + + self::assertSame($expectedSort, $result->sortBy); + self::assertSame($expectedDirection, $result->sortDirection->value); + } + + /** @return iterable */ + public static function sortInputs(): iterable + { + yield [null, null, 'created_at', 'DESC']; + yield ['', '', 'created_at', 'DESC']; + yield [' bad ', 'bad', 'created_at', 'DESC']; + yield ['ID', 'asc', 'created_at', 'ASC']; + yield [' id ', 'dEsC', 'id', 'DESC']; + } + + private function descriptor(): PdoPaginationQueryDescriptor + { + return new PdoPaginationQueryDescriptor( + 'SELECT COUNT(*) AS total_count', + [], + 'SELECT COUNT(*) AS filtered_count', + [], + 'SELECT id FROM t', + [], + ); + } + + private function config(int $defaultPerPage, int $minPerPage, int $maxPerPage): PaginationConfig + { + return new PaginationConfig( + new SortWhitelist(['id' => 'id', 'created_at' => 'created_at']), + 'created_at', + SortDirectionEnum::DESC, + 'id', + SortDirectionEnum::ASC, + $defaultPerPage, + $minPerPage, + $maxPerPage, + ); + } + + private function pdo(int $filtered): ScriptedPdo + { + return new ScriptedPdo([ + ScriptedPdoStatement::count($filtered), + ScriptedPdoStatement::count($filtered), + ScriptedPdoStatement::data([['id' => 1]]), + ]); + } +} diff --git a/tests/Unit/Pdo/Pagination/SortWhitelistTest.php b/tests/Unit/Pdo/Pagination/SortWhitelistTest.php new file mode 100644 index 0000000..16a07a5 --- /dev/null +++ b/tests/Unit/Pdo/Pagination/SortWhitelistTest.php @@ -0,0 +1,91 @@ + 'created_at', + '_created_at' => 'p.created_at', + 'created_at2' => 'catalog.products.created_at', + 'same' => 'created_at', + ]; + $whitelist = new SortWhitelist($input); + $input['created_at'] = 'evil'; + + self::assertSame('`created_at`', $whitelist->quotedIdentifierFor('created_at')); + self::assertSame('`p`.`created_at`', $whitelist->quotedIdentifierFor('_created_at')); + self::assertSame('`catalog`.`products`.`created_at`', $whitelist->quotedIdentifierFor('created_at2')); + self::assertTrue($whitelist->contains('created_at')); + self::assertFalse($whitelist->contains('CREATED_AT')); + self::assertSame('`created_at`', $whitelist->quotedIdentifierFor('same')); + self::assertFalse((new ReflectionClass(SortWhitelist::class))->hasMethod('sorts')); + } + + /** @param array $sorts */ + #[DataProvider('invalidSorts')] + public function testInvalidSorts(array $sorts): void + { + $this->expectException(InvalidPaginationConfigurationException::class); + + $this->newWhitelist($sorts); + } + + /** @return iterable}> */ + public static function invalidSorts(): iterable + { + yield 'empty' => [[]]; + yield 'integer key' => [[1 => 'id']]; + yield 'empty key' => [['' => 'id']]; + yield 'starts digit' => [['1a' => 'id']]; + yield 'whitespace key' => [['a b' => 'id']]; + yield 'hyphen key' => [['a-b' => 'id']]; + yield 'dot key' => [['a.b' => 'id']]; + yield 'empty identifier' => [['a' => '']]; + yield 'leading dot' => [['a' => '.id']]; + yield 'trailing dot' => [['a' => 'id.']]; + yield 'middle empty' => [['a' => 'a..b']]; + yield 'too many segments' => [['a' => 'a.b.c.d']]; + yield 'function' => [['a' => 'COUNT(id)']]; + yield 'arithmetic' => [['a' => 'id + 1']]; + yield 'json operator' => [['a' => 'data->x']]; + yield 'case' => [['a' => 'CASE WHEN id THEN id END']]; + yield 'collate' => [['a' => 'name COLLATE utf8mb4_bin']]; + yield 'comma' => [['a' => 'id, name']]; + yield 'direction' => [['a' => 'id DESC']]; + yield 'comment' => [['a' => 'id -- x']]; + yield 'semicolon' => [['a' => 'id;']]; + yield 'limit' => [['a' => 'id LIMIT 1']]; + yield 'offset' => [['a' => 'id OFFSET 1']]; + yield 'non string value' => [['a' => 1]]; + yield 'backticks' => [['a' => '`id`']]; + } + + public function testUnknownLookupThrows(): void + { + $this->expectException(InvalidPaginationConfigurationException::class); + + (new SortWhitelist(['id' => 'id']))->quotedIdentifierFor('missing'); + } + + /** @param array $sorts */ + private function newWhitelist(array $sorts): SortWhitelist + { + $reflection = new ReflectionClass(SortWhitelist::class); + + /** @var SortWhitelist $whitelist */ + $whitelist = $reflection->newInstanceArgs([$sorts]); + + return $whitelist; + } +}