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
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -286,14 +286,15 @@ 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',
'maa_persistence_test_fail_scoped_target_update',
];

$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);
Expand Down Expand Up @@ -444,14 +445,15 @@ 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',
'maa_persistence_test_fail_scoped_target_update',
];

$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);
Expand Down
15 changes: 15 additions & 0 deletions tests/Fixtures/MySql/create_pagination_items_table.sql
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

namespace Maatify\Persistence\Tests\Integration\Pdo\Pagination;

use Maatify\Persistence\Exception\PaginationExecutionException;
use Maatify\Persistence\Pdo\Pagination\PageRequest;
use Maatify\Persistence\Pdo\Pagination\PdoPaginationQueryDescriptor;
use Maatify\Persistence\Pdo\Pagination\PdoPaginator;
use Maatify\Persistence\Tests\Support\MySql\PaginationIntegrationTestCase;
use Maatify\Persistence\Tests\Support\MySql\PaginationSchemaManager;
use PDOException;
use ReflectionMethod;
use RuntimeException;

final class PdoPaginatorFailureContractTest extends PaginationIntegrationTestCase
{
public function testCountShapeFailures(): void
{
$this->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,
]);
}
}
182 changes: 182 additions & 0 deletions tests/Integration/Pdo/Pagination/PdoPaginatorQuerySemanticsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<?php

declare(strict_types=1);

namespace Maatify\Persistence\Tests\Integration\Pdo\Pagination;

use Maatify\Persistence\Pdo\Pagination\PageRequest;
use Maatify\Persistence\Pdo\Pagination\PdoPaginationQueryDescriptor;
use Maatify\Persistence\Pdo\Pagination\PdoPaginator;
use Maatify\Persistence\Tests\Support\MySql\PaginationIntegrationTestCase;
use Maatify\Persistence\Tests\Support\MySql\PaginationSchemaManager;
use stdClass;

final class PdoPaginatorQuerySemanticsTest extends PaginationIntegrationTestCase
{
public function testBaseTotalFilteredSortingTieBreakerAndConsecutivePages(): void
{
$ids = $this->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<array<string, mixed>> $rows
*
* @return list<int>
*/
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<object> $rows
*
* @return list<int>
*/
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;
}
}
59 changes: 59 additions & 0 deletions tests/Integration/Pdo/Pagination/PdoPaginatorTransactionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace Maatify\Persistence\Tests\Integration\Pdo\Pagination;

use Maatify\Persistence\Pdo\Pagination\PageRequest;
use Maatify\Persistence\Pdo\Pagination\PdoPaginator;
use Maatify\Persistence\Tests\Support\MySql\PaginationIntegrationTestCase;
use PDO;

final class PdoPaginatorTransactionTest extends PaginationIntegrationTestCase
{
public function testAttributesUnchangedOutsideTransactionAndCallerTransactionPreserved(): void
{
$this->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();
}
}
}
Loading
Loading