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
|
$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)
|
## 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.
|
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 */
|
/** Soft delete include flags */
|
||||||
private bool $includeTrashed = false;
|
private bool $includeTrashed = false;
|
||||||
private bool $onlyTrashed = false;
|
private bool $onlyTrashed = false;
|
||||||
|
/** @var array<int, callable> */
|
||||||
|
private array $runtimeScopes = [];
|
||||||
|
/** @var array<string, callable> */
|
||||||
|
private array $namedScopes = [];
|
||||||
|
|
||||||
public function __construct(ConnectionInterface $connection)
|
public function __construct(ConnectionInterface $connection)
|
||||||
{
|
{
|
||||||
|
|
@ -93,7 +97,9 @@ abstract class AbstractDao implements DaoInterface
|
||||||
/** @param array<string,mixed> $criteria */
|
/** @param array<string,mixed> $criteria */
|
||||||
public function findOneBy(array $criteria): ?AbstractDto
|
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);
|
$where = $this->appendScopedWhere($where);
|
||||||
$sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($where ? ' WHERE ' . $where : '') . ' LIMIT 1';
|
$sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($where ? ' WHERE ' . $where : '') . ' LIMIT 1';
|
||||||
$rows = $this->connection->query($sql, $bindings);
|
$rows = $this->connection->query($sql, $bindings);
|
||||||
|
|
@ -102,6 +108,7 @@ abstract class AbstractDao implements DaoInterface
|
||||||
$this->attachRelations([$dto]);
|
$this->attachRelations([$dto]);
|
||||||
}
|
}
|
||||||
$this->resetFieldSelections();
|
$this->resetFieldSelections();
|
||||||
|
$this->resetRuntimeScopes();
|
||||||
return $dto;
|
return $dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,7 +130,9 @@ abstract class AbstractDao implements DaoInterface
|
||||||
*/
|
*/
|
||||||
public function findAllBy(array $criteria = []): array
|
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);
|
$where = $this->appendScopedWhere($where);
|
||||||
$sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($where ? ' WHERE ' . $where : '');
|
$sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($where ? ' WHERE ' . $where : '');
|
||||||
$rows = $this->connection->query($sql, $bindings);
|
$rows = $this->connection->query($sql, $bindings);
|
||||||
|
|
@ -132,9 +141,83 @@ abstract class AbstractDao implements DaoInterface
|
||||||
$this->attachRelations($dtos);
|
$this->attachRelations($dtos);
|
||||||
}
|
}
|
||||||
$this->resetFieldSelections();
|
$this->resetFieldSelections();
|
||||||
|
$this->resetRuntimeScopes();
|
||||||
return $dtos;
|
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 */
|
/** @param array<string,mixed> $data */
|
||||||
public function insert(array $data): AbstractDto
|
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");
|
throw new \BadMethodCallException(static::class . "::{$name} does not exist");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -430,6 +523,36 @@ abstract class AbstractDao implements DaoInterface
|
||||||
return strtolower($snake);
|
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.
|
* Specify fields to select on the base entity and optionally on relations via dot-notation.
|
||||||
* Example: fields('id', 'name', 'posts.title')
|
* Example: fields('id', 'name', 'posts.title')
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@ abstract class AbstractMongoDao
|
||||||
/** @var array<string, array<int,string>> */
|
/** @var array<string, array<int,string>> */
|
||||||
private array $relationFields = [];
|
private array $relationFields = [];
|
||||||
|
|
||||||
|
/** Scopes (MVP) */
|
||||||
|
/** @var array<int, callable> */
|
||||||
|
private array $runtimeScopes = [];
|
||||||
|
/** @var array<string, callable> */
|
||||||
|
private array $namedScopes = [];
|
||||||
|
|
||||||
public function __construct(MongoConnectionInterface $connection)
|
public function __construct(MongoConnectionInterface $connection)
|
||||||
{
|
{
|
||||||
$this->connection = $connection;
|
$this->connection = $connection;
|
||||||
|
|
@ -114,10 +120,13 @@ abstract class AbstractMongoDao
|
||||||
/** @param array<string,mixed>|Filter $filter */
|
/** @param array<string,mixed>|Filter $filter */
|
||||||
public function findOneBy(array|Filter $filter): ?AbstractDto
|
public function findOneBy(array|Filter $filter): ?AbstractDto
|
||||||
{
|
{
|
||||||
|
$filterArr = $this->normalizeFilterInput($filter);
|
||||||
|
$this->applyRuntimeScopesToFilter($filterArr);
|
||||||
$opts = $this->buildOptions();
|
$opts = $this->buildOptions();
|
||||||
$opts['limit'] = 1;
|
$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->resetModifiers();
|
||||||
|
$this->resetRuntimeScopes();
|
||||||
$row = $docs[0] ?? null;
|
$row = $docs[0] ?? null;
|
||||||
return $row ? $this->hydrate($row) : null;
|
return $row ? $this->hydrate($row) : null;
|
||||||
}
|
}
|
||||||
|
|
@ -129,15 +138,18 @@ abstract class AbstractMongoDao
|
||||||
*/
|
*/
|
||||||
public function findAllBy(array|Filter $filter = [], array $options = []): array
|
public function findAllBy(array|Filter $filter = [], array $options = []): array
|
||||||
{
|
{
|
||||||
|
$filterArr = $this->normalizeFilterInput($filter);
|
||||||
|
$this->applyRuntimeScopesToFilter($filterArr);
|
||||||
$opts = $this->buildOptions();
|
$opts = $this->buildOptions();
|
||||||
// external override/merge
|
// external override/merge
|
||||||
foreach ($options as $k => $v) { $opts[$k] = $v; }
|
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 : []);
|
$dtos = array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []);
|
||||||
if ($dtos && $this->with) {
|
if ($dtos && $this->with) {
|
||||||
$this->attachRelations($dtos);
|
$this->attachRelations($dtos);
|
||||||
}
|
}
|
||||||
$this->resetModifiers();
|
$this->resetModifiers();
|
||||||
|
$this->resetRuntimeScopes();
|
||||||
return $dtos;
|
return $dtos;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,7 +238,11 @@ abstract class AbstractMongoDao
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
// For MVP provide deleteOne semantic; bulk deletes could be added later
|
// 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. */
|
/** Upsert by id convenience. */
|
||||||
|
|
@ -252,8 +268,10 @@ abstract class AbstractMongoDao
|
||||||
if (!$values) return [];
|
if (!$values) return [];
|
||||||
// Normalize values (unique)
|
// Normalize values (unique)
|
||||||
$values = array_values(array_unique($values));
|
$values = array_values(array_unique($values));
|
||||||
|
$filter = [ $field => ['$in' => $values] ];
|
||||||
|
$this->applyRuntimeScopesToFilter($filter);
|
||||||
$opts = $this->buildOptions();
|
$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 : []);
|
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]);
|
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");
|
throw new \BadMethodCallException(static::class . "::{$name} does not exist");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -338,6 +364,74 @@ abstract class AbstractMongoDao
|
||||||
return $opts;
|
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
|
private function resetModifiers(): void
|
||||||
{
|
{
|
||||||
$this->projection = null;
|
$this->projection = null;
|
||||||
|
|
@ -533,4 +627,33 @@ abstract class AbstractMongoDao
|
||||||
{
|
{
|
||||||
return $this->withConstraints[$path] ?? null;
|
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