Pagination and Scopes
This commit is contained in:
parent
f21df4f567
commit
95ba97808f
73
README.md
73
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<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.
|
||||
|
||||
- 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.
|
||||
|
|
|
|||
74
examples/nosql/mongo_pagination.php
Normal file
74
examples/nosql/mongo_pagination.php
Normal 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";
|
||||
83
examples/sqlite_pagination.php
Normal file
83
examples/sqlite_pagination.php
Normal 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";
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
88
tests/MongoPaginationTest.php
Normal file
88
tests/MongoPaginationTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
99
tests/PaginationSqliteTest.php
Normal file
99
tests/PaginationSqliteTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue