Pagination and Scopes

This commit is contained in:
Funky Waddle 2025-12-10 14:42:37 -06:00
parent f21df4f567
commit 95ba97808f
7 changed files with 669 additions and 6 deletions

View file

@ -578,6 +578,79 @@ $deep = array_map(fn($u) => $u->toArray(), $users); // deep (default)
$shallow = array_map(fn($u) => $u->toArray(false), $users); // shallow
```
## Pagination
Both SQL and Mongo DAOs provide pagination helpers that return DTOs alongside metadata. They honor the usual query modifiers:
- SQL: `fields()`, `with([...])` (eager load)
- Mongo: `fields()` (projection), `sort()`, `with([...])`
Methods and return shapes:
```php
// SQL
/** @return array{data: array<int, DTO>, total: int, perPage: int, currentPage: int, lastPage: int} */
$page = $userDao->paginate(page: 2, perPage: 10, criteria: ['status' => 'active']);
/** @return array{data: array<int, DTO>, perPage: int, currentPage: int, nextPage: int|null} */
$simple = $userDao->simplePaginate(page: 1, perPage: 10, criteria: []);
// Mongo
$page = $userMongoDao->paginate(2, 10, /* filter */ []);
$simple = $userMongoDao->simplePaginate(1, 10, /* filter */ []);
```
Example (SQL + SQLite):
```php
$page1 = (new UserDao($conn))->paginate(1, 10); // total + lastPage included
$sp = (new UserDao($conn))->simplePaginate(1, 10); // no total; nextPage detection
// With projection and eager loading
$with = (new UserDao($conn))
->fields('id','email','posts.title')
->with(['posts'])
->paginate(1, 5);
```
Example (Mongo):
```php
$with = (new UserMongoDao($mongo))
->fields('email','posts.title')
->sort(['email' => 1])
->with(['posts'])
->paginate(1, 10, []);
```
See examples: `examples/sqlite_pagination.php` and `examples/nosql/mongo_pagination.php`.
## Query Scopes (MVP)
Define small, reusable filters using scopes. Scopes are reset after each `find*`/`paginate*` call.
- Adhoc scope: `scope(callable $fn)` where `$fn` mutates the criteria/filter array for the next query.
- Named scopes: `registerScope('name', fn (&$criteria, ...$args) => ...)` and then call `$dao->name(...$args)` before `find*`/`paginate*`.
SQL example:
```php
$userDao->registerScope('active', function (&$criteria) { $criteria['status'] = 'active'; });
$active = $userDao->active()->paginate(1, 50);
// Combine with adhoc scope
$inactive = $userDao->scope(function (&$criteria) { $criteria['status'] = 'inactive'; })
->findAllBy();
```
Mongo example (filter scopes):
```php
$userMongoDao->registerScope('active', function (&$filter) { $filter['status'] = 'active'; });
$page = $userMongoDao->active()->paginate(1, 25, []);
```
## Unit of Work (opt-in)
Pairity offers an optional Unit of Work (UoW) that you can enable per block to batch and order mutations atomically, while keeping the familiar DAO/DTO API.

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../vendor/autoload.php';
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Model\AbstractDto;
// Connect via URI or discrete params
$conn = MongoConnectionManager::make([
// 'uri' => 'mongodb://user:pass@127.0.0.1:27017/?authSource=admin',
'host' => '127.0.0.1',
'port' => 27017,
]);
class UserDoc extends AbstractDto {}
class PostDoc extends AbstractDto {}
class PostMongoDao extends AbstractMongoDao
{
protected function collection(): string { return 'pairity_demo.pg_posts'; }
protected function dtoClass(): string { return PostDoc::class; }
}
class UserMongoDao extends AbstractMongoDao
{
protected function collection(): string { return 'pairity_demo.pg_users'; }
protected function dtoClass(): string { return UserDoc::class; }
protected function relations(): array
{
return [
'posts' => [
'type' => 'hasMany',
'dao' => PostMongoDao::class,
'foreignKey' => 'user_id',
'localKey' => '_id',
],
];
}
}
$userDao = new UserMongoDao($conn);
$postDao = new PostMongoDao($conn);
// Clean collections for demo
foreach ($userDao->findAllBy([]) as $u) { $id = (string)($u->toArray(false)['_id'] ?? ''); if ($id) { $userDao->deleteById($id); } }
foreach ($postDao->findAllBy([]) as $p) { $id = (string)($p->toArray(false)['_id'] ?? ''); if ($id) { $postDao->deleteById($id); } }
// Seed 22 users; every 3rd has a post
for ($i=1; $i<=22; $i++) {
$status = $i % 2 === 0 ? 'active' : 'inactive';
$u = $userDao->insert(['email' => "mp{$i}@example.com", 'status' => $status]);
$uid = (string)($u->toArray(false)['_id'] ?? '');
if ($i % 3 === 0) { $postDao->insert(['user_id' => $uid, 'title' => 'Post '.$i]); }
}
// Paginate
$page1 = $userDao->paginate(1, 10, []);
echo "Page1 total={$page1['total']} lastPage={$page1['lastPage']} count=".count($page1['data'])."\n";
// Simple paginate
$sp = $userDao->simplePaginate(3, 10, []);
echo 'Simple nextPage on page 3: ' . json_encode($sp['nextPage']) . "\n";
// Projection + sort + eager relation
$with = $userDao->fields('email','posts.title')->sort(['email' => 1])->with(['posts'])->paginate(1, 5, []);
echo 'With posts: ' . json_encode(array_map(fn($d) => $d->toArray(), $with['data'])) . "\n";
// Named scope example
$userDao->registerScope('active', function (&$filter) { $filter['status'] = 'active'; });
$active = $userDao->active()->paginate(1, 100, []);
echo "Active total: {$active['total']}\n";

View file

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
// SQLite connection (file db.sqlite in project root)
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
]);
// Demo tables
$conn->execute('CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT,
status TEXT
)');
$conn->execute('CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
title TEXT
)');
class UserDto extends AbstractDto {}
class PostDto extends AbstractDto {}
class PostDao extends AbstractDao {
public function getTable(): string { return 'posts'; }
protected function dtoClass(): string { return PostDto::class; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; }
}
class UserDao extends AbstractDao {
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return UserDto::class; }
protected function relations(): array {
return [
'posts' => [
'type' => 'hasMany',
'dao' => PostDao::class,
'foreignKey' => 'user_id',
'localKey' => 'id',
],
];
}
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'status'=>['cast'=>'string']]]; }
}
$userDao = new UserDao($conn);
$postDao = new PostDao($conn);
// Seed a few users if table is empty
$hasAny = $userDao->findAllBy();
if (!$hasAny) {
for ($i=1; $i<=25; $i++) {
$status = $i % 2 === 0 ? 'active' : 'inactive';
$u = $userDao->insert(['email' => "p{$i}@example.com", 'status' => $status]);
$uid = (int)($u->toArray(false)['id'] ?? 0);
if ($i % 5 === 0) { $postDao->insert(['user_id' => $uid, 'title' => 'Hello '.$i]); }
}
}
// Paginate (page 1, perPage 10)
$page1 = $userDao->paginate(1, 10);
echo "Page 1: total={$page1['total']} lastPage={$page1['lastPage']} count=".count($page1['data'])."\n";
// Simple paginate (detect next page)
$sp = $userDao->simplePaginate(1, 10);
echo 'Simple nextPage: ' . json_encode($sp['nextPage']) . "\n";
// Projection + eager load
$with = $userDao->fields('id','email','posts.title')->with(['posts'])->paginate(1, 5);
echo 'With posts: ' . json_encode(array_map(fn($d) => $d->toArray(), $with['data'])) . "\n";
// Named scope
$userDao->registerScope('active', function (&$criteria) { $criteria['status'] = 'active'; });
$active = $userDao->active()->paginate(1, 50);
echo "Active total: {$active['total']}\n";

View file

@ -33,6 +33,10 @@ abstract class AbstractDao implements DaoInterface
/** Soft delete include flags */
private bool $includeTrashed = false;
private bool $onlyTrashed = false;
/** @var array<int, callable> */
private array $runtimeScopes = [];
/** @var array<string, callable> */
private array $namedScopes = [];
public function __construct(ConnectionInterface $connection)
{
@ -93,7 +97,9 @@ abstract class AbstractDao implements DaoInterface
/** @param array<string,mixed> $criteria */
public function findOneBy(array $criteria): ?AbstractDto
{
[$where, $bindings] = $this->buildWhere($this->applyDefaultScopes($criteria));
$criteria = $this->applyDefaultScopes($criteria);
$this->applyRuntimeScopesToCriteria($criteria);
[$where, $bindings] = $this->buildWhere($criteria);
$where = $this->appendScopedWhere($where);
$sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($where ? ' WHERE ' . $where : '') . ' LIMIT 1';
$rows = $this->connection->query($sql, $bindings);
@ -102,6 +108,7 @@ abstract class AbstractDao implements DaoInterface
$this->attachRelations([$dto]);
}
$this->resetFieldSelections();
$this->resetRuntimeScopes();
return $dto;
}
@ -123,7 +130,9 @@ abstract class AbstractDao implements DaoInterface
*/
public function findAllBy(array $criteria = []): array
{
[$where, $bindings] = $this->buildWhere($this->applyDefaultScopes($criteria));
$criteria = $this->applyDefaultScopes($criteria);
$this->applyRuntimeScopesToCriteria($criteria);
[$where, $bindings] = $this->buildWhere($criteria);
$where = $this->appendScopedWhere($where);
$sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($where ? ' WHERE ' . $where : '');
$rows = $this->connection->query($sql, $bindings);
@ -132,9 +141,83 @@ abstract class AbstractDao implements DaoInterface
$this->attachRelations($dtos);
}
$this->resetFieldSelections();
$this->resetRuntimeScopes();
return $dtos;
}
/**
* Paginate results for the given criteria.
* @return array{data:array<int,AbstractDto>,total:int,perPage:int,currentPage:int,lastPage:int}
*/
public function paginate(int $page, int $perPage = 15, array $criteria = []): array
{
$page = max(1, $page);
$perPage = max(1, $perPage);
$criteria = $this->applyDefaultScopes($criteria);
$this->applyRuntimeScopesToCriteria($criteria);
[$where, $bindings] = $this->buildWhere($criteria);
$whereFinal = $this->appendScopedWhere($where);
// Total
$countSql = 'SELECT COUNT(*) AS cnt FROM ' . $this->getTable() . ($whereFinal ? ' WHERE ' . $whereFinal : '');
$countRows = $this->connection->query($countSql, $bindings);
$total = (int)($countRows[0]['cnt'] ?? 0);
// Page data
$offset = ($page - 1) * $perPage;
$dataSql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable()
. ($whereFinal ? ' WHERE ' . $whereFinal : '')
. ' LIMIT ' . $perPage . ' OFFSET ' . $offset;
$rows = $this->connection->query($dataSql, $bindings);
$dtos = array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows);
if ($dtos && $this->with) {
$this->attachRelations($dtos);
}
$this->resetFieldSelections();
$this->resetRuntimeScopes();
$lastPage = (int)max(1, (int)ceil($total / $perPage));
return [
'data' => $dtos,
'total' => $total,
'perPage' => $perPage,
'currentPage' => $page,
'lastPage' => $lastPage,
];
}
/** Simple pagination without total count. Returns nextPage if there might be more. */
public function simplePaginate(int $page, int $perPage = 15, array $criteria = []): array
{
$page = max(1, $page);
$perPage = max(1, $perPage);
$criteria = $this->applyDefaultScopes($criteria);
$this->applyRuntimeScopesToCriteria($criteria);
[$where, $bindings] = $this->buildWhere($criteria);
$whereFinal = $this->appendScopedWhere($where);
$offset = ($page - 1) * $perPage;
$sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable()
. ($whereFinal ? ' WHERE ' . $whereFinal : '')
. ' LIMIT ' . ($perPage + 1) . ' OFFSET ' . $offset; // fetch one extra to detect more
$rows = $this->connection->query($sql, $bindings);
$hasMore = count($rows) > $perPage;
if ($hasMore) { array_pop($rows); }
$dtos = array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows);
if ($dtos && $this->with) { $this->attachRelations($dtos); }
$this->resetFieldSelections();
$this->resetRuntimeScopes();
return [
'data' => $dtos,
'perPage' => $perPage,
'currentPage' => $page,
'nextPage' => $hasMore ? $page + 1 : null,
];
}
/** @param array<string,mixed> $data */
public function insert(array $data): AbstractDto
{
@ -420,6 +503,16 @@ abstract class AbstractDao implements DaoInterface
}
}
// Named scope call support: if a scope is registered with this method name, queue it and return $this
if (isset($this->namedScopes[$name]) && is_callable($this->namedScopes[$name])) {
$callable = $this->namedScopes[$name];
// Bind arguments
$this->runtimeScopes[] = function (&$criteria) use ($callable, $arguments) {
$callable($criteria, ...$arguments);
};
return $this;
}
throw new \BadMethodCallException(static::class . "::{$name} does not exist");
}
@ -430,6 +523,36 @@ abstract class AbstractDao implements DaoInterface
return strtolower($snake);
}
// ===== Scopes (MVP) =====
/** Register a named scope callable: function(array &$criteria, ...$args): void */
public function registerScope(string $name, callable $fn): static
{
$this->namedScopes[$name] = $fn;
return $this;
}
/** Add an ad-hoc scope for the next query: callable(array &$criteria): void */
public function scope(callable $fn): static
{
$this->runtimeScopes[] = $fn;
return $this;
}
/** @param array<string,mixed> $criteria */
private function applyRuntimeScopesToCriteria(array &$criteria): void
{
if (!$this->runtimeScopes) return;
foreach ($this->runtimeScopes as $fn) {
try { $fn($criteria); } catch (\Throwable) {}
}
}
private function resetRuntimeScopes(): void
{
$this->runtimeScopes = [];
}
/**
* Specify fields to select on the base entity and optionally on relations via dot-notation.
* Example: fields('id', 'name', 'posts.title')

View file

@ -37,6 +37,12 @@ abstract class AbstractMongoDao
/** @var array<string, array<int,string>> */
private array $relationFields = [];
/** Scopes (MVP) */
/** @var array<int, callable> */
private array $runtimeScopes = [];
/** @var array<string, callable> */
private array $namedScopes = [];
public function __construct(MongoConnectionInterface $connection)
{
$this->connection = $connection;
@ -114,10 +120,13 @@ abstract class AbstractMongoDao
/** @param array<string,mixed>|Filter $filter */
public function findOneBy(array|Filter $filter): ?AbstractDto
{
$filterArr = $this->normalizeFilterInput($filter);
$this->applyRuntimeScopesToFilter($filterArr);
$opts = $this->buildOptions();
$opts['limit'] = 1;
$docs = $this->connection->find($this->databaseName(), $this->collection(), $this->normalizeFilterInput($filter), $opts);
$docs = $this->connection->find($this->databaseName(), $this->collection(), $filterArr, $opts);
$this->resetModifiers();
$this->resetRuntimeScopes();
$row = $docs[0] ?? null;
return $row ? $this->hydrate($row) : null;
}
@ -129,15 +138,18 @@ abstract class AbstractMongoDao
*/
public function findAllBy(array|Filter $filter = [], array $options = []): array
{
$filterArr = $this->normalizeFilterInput($filter);
$this->applyRuntimeScopesToFilter($filterArr);
$opts = $this->buildOptions();
// external override/merge
foreach ($options as $k => $v) { $opts[$k] = $v; }
$docs = $this->connection->find($this->databaseName(), $this->collection(), $this->normalizeFilterInput($filter), $opts);
$docs = $this->connection->find($this->databaseName(), $this->collection(), $filterArr, $opts);
$dtos = array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []);
if ($dtos && $this->with) {
$this->attachRelations($dtos);
}
$this->resetModifiers();
$this->resetRuntimeScopes();
return $dtos;
}
@ -226,7 +238,11 @@ abstract class AbstractMongoDao
return 0;
}
// For MVP provide deleteOne semantic; bulk deletes could be added later
return $this->connection->deleteOne($this->databaseName(), $this->collection(), $this->normalizeFilterInput($filter));
$flt = $this->normalizeFilterInput($filter);
$this->applyRuntimeScopesToFilter($flt);
$res = $this->connection->deleteOne($this->databaseName(), $this->collection(), $flt);
$this->resetRuntimeScopes();
return $res;
}
/** Upsert by id convenience. */
@ -252,8 +268,10 @@ abstract class AbstractMongoDao
if (!$values) return [];
// Normalize values (unique)
$values = array_values(array_unique($values));
$filter = [ $field => ['$in' => $values] ];
$this->applyRuntimeScopesToFilter($filter);
$opts = $this->buildOptions();
$docs = $this->connection->find($this->databaseName(), $this->collection(), [ $field => ['$in' => $values] ], $opts);
$docs = $this->connection->find($this->databaseName(), $this->collection(), $filter, $opts);
return array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []);
}
@ -284,6 +302,14 @@ abstract class AbstractMongoDao
return $this->deleteBy([$col => $arguments[0] ?? null]);
}
}
// Named scope invocation
if (isset($this->namedScopes[$name]) && is_callable($this->namedScopes[$name])) {
$callable = $this->namedScopes[$name];
$this->runtimeScopes[] = function (&$filter) use ($callable, $arguments) {
$callable($filter, ...$arguments);
};
return $this;
}
throw new \BadMethodCallException(static::class . "::{$name} does not exist");
}
@ -338,6 +364,74 @@ abstract class AbstractMongoDao
return $opts;
}
/**
* Paginate results.
* @return array{data:array<int,AbstractDto>,total:int,perPage:int,currentPage:int,lastPage:int}
*/
public function paginate(int $page, int $perPage = 15, array|Filter $filter = []): array
{
$page = max(1, $page);
$perPage = max(1, $perPage);
$flt = $this->normalizeFilterInput($filter);
$this->applyRuntimeScopesToFilter($flt);
// Total via aggregation count
$pipeline = [];
if (!empty($flt)) { $pipeline[] = ['$match' => $flt]; }
$pipeline[] = ['$count' => 'cnt'];
$agg = $this->connection->aggregate($this->databaseName(), $this->collection(), $pipeline, []);
$arr = is_iterable($agg) ? iterator_to_array($agg, false) : (array)$agg;
$total = (int)($arr[0]['cnt'] ?? 0);
// Page data
$opts = $this->buildOptions();
$opts['limit'] = $perPage;
$opts['skip'] = ($page - 1) * $perPage;
$docs = $this->connection->find($this->databaseName(), $this->collection(), $flt, $opts);
$dtos = array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []);
if ($dtos && $this->with) { $this->attachRelations($dtos); }
$this->resetModifiers();
$this->resetRuntimeScopes();
$lastPage = (int)max(1, (int)ceil($total / $perPage));
return [
'data' => $dtos,
'total' => $total,
'perPage' => $perPage,
'currentPage' => $page,
'lastPage' => $lastPage,
];
}
/** Simple pagination without total; returns nextPage if more likely exists. */
public function simplePaginate(int $page, int $perPage = 15, array|Filter $filter = []): array
{
$page = max(1, $page);
$perPage = max(1, $perPage);
$flt = $this->normalizeFilterInput($filter);
$this->applyRuntimeScopesToFilter($flt);
$opts = $this->buildOptions();
$opts['limit'] = $perPage + 1; // fetch one extra
$opts['skip'] = ($page - 1) * $perPage;
$docs = $this->connection->find($this->databaseName(), $this->collection(), $flt, $opts);
$docsArr = is_iterable($docs) ? iterator_to_array($docs, false) : (array)$docs;
$hasMore = count($docsArr) > $perPage;
if ($hasMore) { array_pop($docsArr); }
$dtos = array_map(fn($d) => $this->hydrate($d), $docsArr);
if ($dtos && $this->with) { $this->attachRelations($dtos); }
$this->resetModifiers();
$this->resetRuntimeScopes();
return [
'data' => $dtos,
'perPage' => $perPage,
'currentPage' => $page,
'nextPage' => $hasMore ? $page + 1 : null,
];
}
private function resetModifiers(): void
{
$this->projection = null;
@ -533,4 +627,33 @@ abstract class AbstractMongoDao
{
return $this->withConstraints[$path] ?? null;
}
// ===== Scopes (MVP) =====
/** Register a named scope callable: function(array &$filter, ...$args): void */
public function registerScope(string $name, callable $fn): static
{
$this->namedScopes[$name] = $fn;
return $this;
}
/** Add an ad-hoc scope callable(array &$filter): void for next query. */
public function scope(callable $fn): static
{
$this->runtimeScopes[] = $fn;
return $this;
}
/** @param array<string,mixed> $filter */
private function applyRuntimeScopesToFilter(array &$filter): void
{
if (!$this->runtimeScopes) return;
foreach ($this->runtimeScopes as $fn) {
try { $fn($filter); } catch (\Throwable) {}
}
}
private function resetRuntimeScopes(): void
{
$this->runtimeScopes = [];
}
}

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Model\AbstractDto;
final class MongoPaginationTest extends TestCase
{
private function hasMongoExt(): bool { return \extension_loaded('mongodb'); }
public function testPaginateAndSimplePaginateWithScopes(): void
{
if (!$this->hasMongoExt()) { $this->markTestSkipped('ext-mongodb not loaded'); }
// Connect (skip if server unavailable)
try {
$conn = MongoConnectionManager::make([
'host' => \getenv('MONGO_HOST') ?: '127.0.0.1',
'port' => (int)(\getenv('MONGO_PORT') ?: 27017),
]);
} catch (\Throwable $e) {
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
}
// Inline DTO and DAOs
$userDto = new class([]) extends AbstractDto {};
$userDtoClass = \get_class($userDto);
$postDto = new class([]) extends AbstractDto {};
$postDtoClass = \get_class($postDto);
$PostDao = new class($conn, $postDtoClass) extends AbstractMongoDao {
private string $dto; public function __construct($c, string $dto){ parent::__construct($c); $this->dto = $dto; }
protected function collection(): string { return 'pairity_test.pg_posts'; }
protected function dtoClass(): string { return $this->dto; }
};
$UserDao = new class($conn, $userDtoClass, get_class($PostDao)) extends AbstractMongoDao {
private string $dto; private string $postDaoClass; public function __construct($c,string $dto,string $p){ parent::__construct($c); $this->dto=$dto; $this->postDaoClass=$p; }
protected function collection(): string { return 'pairity_test.pg_users'; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array { return [
'posts' => [ 'type' => 'hasMany', 'dao' => $this->postDaoClass, 'foreignKey' => 'user_id', 'localKey' => '_id' ],
]; }
};
$postDao = new $PostDao($conn, $postDtoClass);
$userDao = new $UserDao($conn, $userDtoClass, get_class($postDao));
// Clean
foreach ($userDao->findAllBy([]) as $u) { $id = (string)($u->toArray(false)['_id'] ?? ''); if ($id) { $userDao->deleteById($id); } }
foreach ($postDao->findAllBy([]) as $p) { $id = (string)($p->toArray(false)['_id'] ?? ''); if ($id) { $postDao->deleteById($id); } }
// Seed 26 users; attach posts to some
for ($i=1; $i<=26; $i++) {
$status = $i % 2 === 0 ? 'active' : 'inactive';
$u = $userDao->insert(['email' => "m{$i}@ex.com", 'status' => $status]);
$uid = (string)($u->toArray(false)['_id'] ?? '');
if ($i % 4 === 0) { $postDao->insert(['user_id' => $uid, 'title' => 'T'.$i]); }
}
// Paginate
$page = $userDao->paginate(2, 10, []);
$this->assertSame(26, $page['total']);
$this->assertCount(10, $page['data']);
$this->assertSame(3, $page['lastPage']);
// Simple paginate last page nextPage null
$sp = $userDao->simplePaginate(3, 10, []);
$this->assertNull($sp['nextPage']);
// fields + sort + with on paginate
$with = $userDao->fields('email','posts.title')->sort(['email' => 1])->with(['posts'])->paginate(1, 5);
$this->assertNotEmpty($with['data']);
$first = $with['data'][0]->toArray(false);
$this->assertArrayHasKey('email', $first);
$this->assertArrayHasKey('posts', $first);
// Scopes
$userDao->registerScope('active', function (&$filter) { $filter['status'] = 'active'; });
$active = $userDao->active()->paginate(1, 100, []);
// Half of 26 rounded down
$this->assertSame(13, $active['total']);
}
}

View file

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDao;
use Pairity\Model\AbstractDto;
final class PaginationSqliteTest extends TestCase
{
private function conn()
{
return ConnectionManager::make(['driver' => 'sqlite', 'path' => ':memory:']);
}
public function testPaginateAndSimplePaginateWithScopesAndRelations(): void
{
$conn = $this->conn();
// schema
$conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT, status TEXT)');
$conn->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT)');
// DTOs
$UserDto = new class([]) extends AbstractDto {};
$PostDto = new class([]) extends AbstractDto {};
$uClass = get_class($UserDto); $pClass = get_class($PostDto);
// DAOs
$PostDao = new class($conn, $pClass) extends AbstractDao {
private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
public function getTable(): string { return 'posts'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; }
};
$UserDao = new class($conn, $uClass, get_class($PostDao)) extends AbstractDao {
private string $dto; private string $postDaoClass; public function __construct($c,string $dto,string $p){ parent::__construct($c); $this->dto=$dto; $this->postDaoClass=$p; }
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array {
return [
'posts' => [
'type' => 'hasMany',
'dao' => $this->postDaoClass,
'foreignKey' => 'user_id',
'localKey' => 'id',
],
];
}
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'status'=>['cast'=>'string']]]; }
};
$postDao = new $PostDao($conn, $pClass);
$userDao = new $UserDao($conn, $uClass, get_class($postDao));
// seed 35 users (20 active, 15 inactive)
for ($i=1; $i<=35; $i++) {
$status = $i <= 20 ? 'active' : 'inactive';
$u = $userDao->insert(['email' => "u{$i}@example.com", 'status' => $status]);
$uid = (int)($u->toArray(false)['id'] ?? 0);
if ($i % 5 === 0) {
$postDao->insert(['user_id' => $uid, 'title' => 'P'.$i]);
}
}
// paginate page 2 of size 10
$page = $userDao->paginate(2, 10, []);
$this->assertSame(35, $page['total']);
$this->assertSame(10, count($page['data']));
$this->assertSame(4, $page['lastPage']);
$this->assertSame(2, $page['currentPage']);
// simplePaginate last page should have nextPage null
$simple = $userDao->simplePaginate(4, 10, []);
$this->assertNull($simple['nextPage']);
$this->assertSame(10, $simple['perPage']);
// fields() projection + with() eager on paginated results
$with = $userDao->fields('id', 'email', 'posts.title')->with(['posts'])->paginate(1, 10);
$this->assertNotEmpty($with['data']);
$first = $with['data'][0]->toArray(false);
$this->assertArrayHasKey('id', $first);
$this->assertArrayHasKey('email', $first);
$this->assertArrayHasKey('posts', $first);
// scopes: named scope to filter active users only
$userDao->registerScope('active', function (&$criteria) { $criteria['status'] = 'active'; });
$activePage = $userDao->active()->paginate(1, 50);
$this->assertSame(20, $activePage['total']);
// ad-hoc scope combining additional condition (no-op example)
$combined = $userDao->scope(function (&$criteria) { if (!isset($criteria['status'])) { $criteria['status'] = 'inactive'; } })
->paginate(1, 100);
$this->assertSame(15, $combined['total']);
}
}