From 95ba97808f9fed3ed07066ff7f3993213d4dcfa2 Mon Sep 17 00:00:00 2001 From: Funky Waddle Date: Wed, 10 Dec 2025 14:42:37 -0600 Subject: [PATCH] Pagination and Scopes --- README.md | 73 +++++++++++++++ examples/nosql/mongo_pagination.php | 74 +++++++++++++++ examples/sqlite_pagination.php | 83 +++++++++++++++++ src/Model/AbstractDao.php | 127 +++++++++++++++++++++++++- src/NoSql/Mongo/AbstractMongoDao.php | 131 ++++++++++++++++++++++++++- tests/MongoPaginationTest.php | 88 ++++++++++++++++++ tests/PaginationSqliteTest.php | 99 ++++++++++++++++++++ 7 files changed, 669 insertions(+), 6 deletions(-) create mode 100644 examples/nosql/mongo_pagination.php create mode 100644 examples/sqlite_pagination.php create mode 100644 tests/MongoPaginationTest.php create mode 100644 tests/PaginationSqliteTest.php diff --git a/README.md b/README.md index 256bea0..bb39a91 100644 --- a/README.md +++ b/README.md @@ -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, total: int, perPage: int, currentPage: int, lastPage: int} */ +$page = $userDao->paginate(page: 2, perPage: 10, criteria: ['status' => 'active']); + +/** @return array{data: array, 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. + +- Ad‑hoc 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 ad‑hoc 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. diff --git a/examples/nosql/mongo_pagination.php b/examples/nosql/mongo_pagination.php new file mode 100644 index 0000000..840a069 --- /dev/null +++ b/examples/nosql/mongo_pagination.php @@ -0,0 +1,74 @@ + '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"; diff --git a/examples/sqlite_pagination.php b/examples/sqlite_pagination.php new file mode 100644 index 0000000..697d48e --- /dev/null +++ b/examples/sqlite_pagination.php @@ -0,0 +1,83 @@ + '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"; diff --git a/src/Model/AbstractDao.php b/src/Model/AbstractDao.php index 283423f..6ede775 100644 --- a/src/Model/AbstractDao.php +++ b/src/Model/AbstractDao.php @@ -33,6 +33,10 @@ abstract class AbstractDao implements DaoInterface /** Soft delete include flags */ private bool $includeTrashed = false; private bool $onlyTrashed = false; + /** @var array */ + private array $runtimeScopes = []; + /** @var array */ + private array $namedScopes = []; public function __construct(ConnectionInterface $connection) { @@ -93,7 +97,9 @@ abstract class AbstractDao implements DaoInterface /** @param array $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,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 $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 $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') diff --git a/src/NoSql/Mongo/AbstractMongoDao.php b/src/NoSql/Mongo/AbstractMongoDao.php index 72e565c..ad0dd67 100644 --- a/src/NoSql/Mongo/AbstractMongoDao.php +++ b/src/NoSql/Mongo/AbstractMongoDao.php @@ -37,6 +37,12 @@ abstract class AbstractMongoDao /** @var array> */ private array $relationFields = []; + /** Scopes (MVP) */ + /** @var array */ + private array $runtimeScopes = []; + /** @var array */ + private array $namedScopes = []; + public function __construct(MongoConnectionInterface $connection) { $this->connection = $connection; @@ -114,10 +120,13 @@ abstract class AbstractMongoDao /** @param array|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,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 $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 = []; + } } diff --git a/tests/MongoPaginationTest.php b/tests/MongoPaginationTest.php new file mode 100644 index 0000000..d54e55e --- /dev/null +++ b/tests/MongoPaginationTest.php @@ -0,0 +1,88 @@ +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']); + } +} diff --git a/tests/PaginationSqliteTest.php b/tests/PaginationSqliteTest.php new file mode 100644 index 0000000..69b3512 --- /dev/null +++ b/tests/PaginationSqliteTest.php @@ -0,0 +1,99 @@ + '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']); + } +}