join‑based eager loading (SQL)
This commit is contained in:
parent
95ba97808f
commit
cb1251ae14
112
README.md
112
README.md
|
|
@ -260,6 +260,49 @@ Notes:
|
|||
- Loaded relations are attached onto the DTO under the relation name (e.g., `$user->posts`).
|
||||
- `hasOne` is supported like `hasMany` but attaches a single DTO instead of a list.
|
||||
|
||||
### Join‑based eager loading (opt‑in, SQL)
|
||||
|
||||
For single‑level relations on SQL DAOs, you can opt‑in to a join‑based eager loading strategy that fetches parent and related rows in one query using `LEFT JOIN`s.
|
||||
|
||||
Usage:
|
||||
|
||||
```php
|
||||
// Require explicit projection for related fields when joining
|
||||
$users = (new UserDao($conn))
|
||||
->fields('id', 'name', 'posts.title')
|
||||
->useJoinEager() // opt‑in for the next call
|
||||
->with(['posts']) // single‑level only in MVP
|
||||
->findAllBy([]);
|
||||
```
|
||||
|
||||
Behavior and limitations (MVP):
|
||||
- Single‑level only: `with(['posts'])` is supported; nested paths like `posts.comments` fall back to the default batched strategy.
|
||||
- You must specify the fields to load from related tables via `fields('relation.column')` so the ORM can alias columns safely.
|
||||
- Supported relation types: `hasOne`, `hasMany`, `belongsTo`.
|
||||
- `belongsToMany` continues to use the portable two‑query pivot strategy.
|
||||
- Soft deletes on related tables are respected by adding `... AND related.deleted_at IS NULL` to the join condition when configured in the related DAO `schema()`.
|
||||
- Per‑relation constraints that rely on ordering/limits aren’t applied in join mode in this MVP; prefer the default batched strategy for those cases.
|
||||
|
||||
Tip: If join mode can’t be used (e.g., nested paths or missing relation field projections), Pairity silently falls back to the portable batched eager loader.
|
||||
|
||||
Per‑relation hint (optional):
|
||||
|
||||
You can hint join strategy per relation path. This is useful when you want to selectively join specific relations in a single‑level eager load. The join will be used only when safe (single‑level paths and explicit relation projections are present), otherwise Pairity falls back to the portable strategy.
|
||||
|
||||
```php
|
||||
$users = (new UserDao($conn))
|
||||
->fields('id','name','posts.title')
|
||||
->with([
|
||||
// Hint join for posts; you can also pass a callable under 'constraint' alongside 'strategy'
|
||||
'posts' => ['strategy' => 'join']
|
||||
])
|
||||
->findAllBy([]);
|
||||
```
|
||||
|
||||
Notes:
|
||||
- If you also call `useJoinEager()` or `eagerStrategy('join')`, that global setting takes precedence.
|
||||
- Join eager is still limited to single‑level relations in this MVP. Nested paths (e.g., `posts.comments`) will use the portable strategy.
|
||||
|
||||
### belongsToMany (SQL) and pivot helpers
|
||||
|
||||
Pairity supports many‑to‑many relations for SQL DAOs via a pivot table. Declare `belongsToMany` in your DAO’s `relations()` and use the built‑in pivot helpers `attach`, `detach`, and `sync`.
|
||||
|
|
@ -578,6 +621,75 @@ $deep = array_map(fn($u) => $u->toArray(), $users); // deep (default)
|
|||
$shallow = array_map(fn($u) => $u->toArray(false), $users); // shallow
|
||||
```
|
||||
|
||||
## Attribute accessors/mutators & custom casters (Milestone C)
|
||||
|
||||
Pairity supports lightweight DTO accessors/mutators and pluggable per‑column casters declared in your DAO `schema()`.
|
||||
|
||||
### DTO attribute accessors/mutators
|
||||
|
||||
- Accessor: define `protected function get{Name}Attribute($value): mixed` to transform a field when reading via property access or `toArray()`.
|
||||
- Mutator: define `protected function set{Name}Attribute($value): mixed` to normalize a field when the DTO is hydrated from an array (constructor/fromArray).
|
||||
|
||||
Example:
|
||||
|
||||
```php
|
||||
class UserDto extends \Pairity\Model\AbstractDto {
|
||||
// Uppercase name when reading
|
||||
protected function getNameAttribute($value): mixed {
|
||||
return is_string($value) ? strtoupper($value) : $value;
|
||||
}
|
||||
// Trim name on hydration
|
||||
protected function setNameAttribute($value): mixed {
|
||||
return is_string($value) ? trim($value) : $value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Accessors are applied for top‑level keys in `toArray(true|false)`. Relations (nested DTOs) apply their own accessors during their own `toArray()`.
|
||||
|
||||
### Custom casters
|
||||
|
||||
In addition to built‑in casts (`int`, `float`, `bool`, `string`, `datetime`, `json`), you can declare a custom caster class per column. A caster implements:
|
||||
|
||||
```php
|
||||
use Pairity\Model\Casting\CasterInterface;
|
||||
|
||||
final class MoneyCaster implements CasterInterface {
|
||||
public function fromStorage(mixed $value): mixed {
|
||||
// DB integer cents -> PHP array/object
|
||||
return ['cents' => (int)$value];
|
||||
}
|
||||
public function toStorage(mixed $value): mixed {
|
||||
// PHP array/object -> DB integer cents
|
||||
return is_array($value) && isset($value['cents']) ? (int)$value['cents'] : (int)$value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Declare it in the DAO `schema()` under the column’s `cast` using its class name:
|
||||
|
||||
```php
|
||||
protected function schema(): array
|
||||
{
|
||||
return [
|
||||
'primaryKey' => 'id',
|
||||
'columns' => [
|
||||
'id' => ['cast' => 'int'],
|
||||
'name' => ['cast' => 'string'],
|
||||
'price_cents' => ['cast' => MoneyCaster::class], // custom caster
|
||||
'meta' => ['cast' => 'json'],
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- On SELECT, Pairity hydrates the DTO and applies `fromStorage()` per column (or built‑ins).
|
||||
- On INSERT/UPDATE, Pairity applies `toStorage()` per column (or built‑ins) and maintains timestamp/soft‑delete behavior.
|
||||
- Custom caster class strings are resolved once and cached per DAO instance.
|
||||
|
||||
See the test `tests/CastersAndAccessorsSqliteTest.php` for a complete, runnable example.
|
||||
|
||||
## Pagination
|
||||
|
||||
Both SQL and Mongo DAOs provide pagination helpers that return DTOs alongside metadata. They honor the usual query modifiers:
|
||||
|
|
|
|||
1818
composer.lock
generated
Normal file
1818
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
106
examples/mysql_join_eager_demo.php
Normal file
106
examples/mysql_join_eager_demo.php
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use Pairity\Database\ConnectionManager;
|
||||
use Pairity\Model\AbstractDao;
|
||||
use Pairity\Model\AbstractDto;
|
||||
|
||||
// Configure MySQL connection (adjust credentials as needed)
|
||||
$conn = ConnectionManager::make([
|
||||
'driver' => 'mysql',
|
||||
'host' => getenv('MYSQL_HOST') ?: '127.0.0.1',
|
||||
'port' => (int)(getenv('MYSQL_PORT') ?: 3306),
|
||||
'database' => getenv('MYSQL_DB') ?: 'app',
|
||||
'username' => getenv('MYSQL_USER') ?: 'root',
|
||||
'password' => getenv('MYSQL_PASS') ?: 'secret',
|
||||
'charset' => 'utf8mb4',
|
||||
]);
|
||||
|
||||
// Ensure demo tables (idempotent)
|
||||
$conn->execute('CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(190) NOT NULL
|
||||
)');
|
||||
$conn->execute('CREATE TABLE IF NOT EXISTS posts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
title VARCHAR(190) NOT NULL,
|
||||
deleted_at DATETIME NULL
|
||||
)');
|
||||
|
||||
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'], 'deleted_at'=>['cast'=>'datetime'] ],
|
||||
'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'],
|
||||
]; }
|
||||
}
|
||||
|
||||
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'],'name'=>['cast'=>'string']]]; }
|
||||
}
|
||||
|
||||
$userDao = new UserDao($conn);
|
||||
$postDao = new PostDao($conn);
|
||||
|
||||
// Clean minimal (demo only)
|
||||
foreach ($userDao->findAllBy() as $u) { $userDao->deleteById((int)$u->toArray(false)['id']); }
|
||||
foreach ($postDao->findAllBy() as $p) { $postDao->deleteById((int)$p->toArray(false)['id']); }
|
||||
|
||||
// Seed
|
||||
$u1 = $userDao->insert(['name' => 'Alice']);
|
||||
$u2 = $userDao->insert(['name' => 'Bob']);
|
||||
$uid1 = (int)$u1->toArray(false)['id'];
|
||||
$uid2 = (int)$u2->toArray(false)['id'];
|
||||
$postDao->insert(['user_id' => $uid1, 'title' => 'P1']);
|
||||
$postDao->insert(['user_id' => $uid1, 'title' => 'P2']);
|
||||
$postDao->insert(['user_id' => $uid2, 'title' => 'Hidden', 'deleted_at' => gmdate('Y-m-d H:i:s')]);
|
||||
|
||||
// Baseline portable eager (batched IN)
|
||||
$batched = $userDao->fields('id','name','posts.title')->with(['posts'])->findAllBy([]);
|
||||
echo "Batched eager: \n";
|
||||
foreach ($batched as $u) {
|
||||
$arr = $u->toArray(false);
|
||||
$titles = array_map(fn($p) => $p->toArray(false)['title'] ?? '', $arr['posts'] ?? []);
|
||||
echo "- {$arr['name']} posts: " . implode(', ', $titles) . "\n";
|
||||
}
|
||||
|
||||
// Join-based eager (global opt-in)
|
||||
$joined = $userDao->fields('id','name','posts.title')->useJoinEager()->with(['posts'])->findAllBy([]);
|
||||
echo "\nJoin eager (global): \n";
|
||||
foreach ($joined as $u) {
|
||||
$arr = $u->toArray(false);
|
||||
$titles = array_map(fn($p) => $p->toArray(false)['title'] ?? '', $arr['posts'] ?? []);
|
||||
echo "- {$arr['name']} posts: " . implode(', ', $titles) . "\n";
|
||||
}
|
||||
|
||||
// Per-relation join hint (equivalent in this single-rel case)
|
||||
$hinted = $userDao->fields('id','name','posts.title')
|
||||
->with(['posts' => ['strategy' => 'join']])
|
||||
->findAllBy([]); // will fallback to batched if conditions not met
|
||||
echo "\nJoin eager (per-relation hint): \n";
|
||||
foreach ($hinted as $u) {
|
||||
$arr = $u->toArray(false);
|
||||
$titles = array_map(fn($p) => $p->toArray(false)['title'] ?? '', $arr['posts'] ?? []);
|
||||
echo "- {$arr['name']} posts: " . implode(', ', $titles) . "\n";
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ namespace Pairity\Model;
|
|||
use Pairity\Contracts\ConnectionInterface;
|
||||
use Pairity\Contracts\DaoInterface;
|
||||
use Pairity\Orm\UnitOfWork;
|
||||
use Pairity\Model\Casting\CasterInterface;
|
||||
|
||||
abstract class AbstractDao implements DaoInterface
|
||||
{
|
||||
|
|
@ -30,6 +31,12 @@ abstract class AbstractDao implements DaoInterface
|
|||
* @var array<string, callable>
|
||||
*/
|
||||
private array $withConstraints = [];
|
||||
/**
|
||||
* Optional per‑relation eager loading strategies for first‑level relations.
|
||||
* Keys are relation names; values like 'join'.
|
||||
* @var array<string,string>
|
||||
*/
|
||||
private array $withStrategies = [];
|
||||
/** Soft delete include flags */
|
||||
private bool $includeTrashed = false;
|
||||
private bool $onlyTrashed = false;
|
||||
|
|
@ -37,6 +44,12 @@ abstract class AbstractDao implements DaoInterface
|
|||
private array $runtimeScopes = [];
|
||||
/** @var array<string, callable> */
|
||||
private array $namedScopes = [];
|
||||
/**
|
||||
* Optional eager loading strategy for next find* call.
|
||||
* null (default) uses the portable subquery/batched IN strategy.
|
||||
* 'join' opts in to join-based eager loading for supported SQL relations (single level).
|
||||
*/
|
||||
private ?string $eagerStrategy = null;
|
||||
|
||||
public function __construct(ConnectionInterface $connection)
|
||||
{
|
||||
|
|
@ -94,6 +107,25 @@ abstract class AbstractDao implements DaoInterface
|
|||
return $this->connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in to join-based eager loading for the next find* call (SQL only, single-level relations).
|
||||
*/
|
||||
public function useJoinEager(): static
|
||||
{
|
||||
$this->eagerStrategy = 'join';
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set eager strategy explicitly: 'join' or 'subquery'. Resets after next find* call.
|
||||
*/
|
||||
public function eagerStrategy(string $strategy): static
|
||||
{
|
||||
$strategy = strtolower($strategy);
|
||||
$this->eagerStrategy = in_array($strategy, ['join', 'subquery'], true) ? $strategy : null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $criteria */
|
||||
public function findOneBy(array $criteria): ?AbstractDto
|
||||
{
|
||||
|
|
@ -101,14 +133,24 @@ abstract class AbstractDao implements DaoInterface
|
|||
$this->applyRuntimeScopesToCriteria($criteria);
|
||||
[$where, $bindings] = $this->buildWhere($criteria);
|
||||
$where = $this->appendScopedWhere($where);
|
||||
$dto = null;
|
||||
if ($this->with && $this->shouldUseJoinEager()) {
|
||||
[$sql, $bindings2, $meta] = $this->buildJoinSelect($where, $bindings, 1, 0);
|
||||
$rows = $this->connection->query($sql, $bindings2);
|
||||
$list = $this->hydrateFromJoinRows($rows, $meta);
|
||||
$dto = $list[0] ?? null;
|
||||
} else {
|
||||
$sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($where ? ' WHERE ' . $where : '') . ' LIMIT 1';
|
||||
$rows = $this->connection->query($sql, $bindings);
|
||||
$dto = isset($rows[0]) ? $this->hydrate($this->castRowFromStorage($rows[0])) : null;
|
||||
if ($dto && $this->with) {
|
||||
$this->attachRelations([$dto]);
|
||||
}
|
||||
}
|
||||
$this->resetFieldSelections();
|
||||
$this->resetRuntimeScopes();
|
||||
$this->eagerStrategy = null; // reset
|
||||
$this->withStrategies = [];
|
||||
return $dto;
|
||||
}
|
||||
|
||||
|
|
@ -134,14 +176,22 @@ abstract class AbstractDao implements DaoInterface
|
|||
$this->applyRuntimeScopesToCriteria($criteria);
|
||||
[$where, $bindings] = $this->buildWhere($criteria);
|
||||
$where = $this->appendScopedWhere($where);
|
||||
if ($this->with && $this->shouldUseJoinEager()) {
|
||||
[$sql2, $bindings2, $meta] = $this->buildJoinSelect($where, $bindings, null, null);
|
||||
$rows = $this->connection->query($sql2, $bindings2);
|
||||
$dtos = $this->hydrateFromJoinRows($rows, $meta);
|
||||
} else {
|
||||
$sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($where ? ' WHERE ' . $where : '');
|
||||
$rows = $this->connection->query($sql, $bindings);
|
||||
$dtos = array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows);
|
||||
if ($dtos && $this->with) {
|
||||
$this->attachRelations($dtos);
|
||||
}
|
||||
}
|
||||
$this->resetFieldSelections();
|
||||
$this->resetRuntimeScopes();
|
||||
$this->eagerStrategy = null; // reset
|
||||
$this->withStrategies = [];
|
||||
return $dtos;
|
||||
}
|
||||
|
||||
|
|
@ -176,6 +226,8 @@ abstract class AbstractDao implements DaoInterface
|
|||
}
|
||||
$this->resetFieldSelections();
|
||||
$this->resetRuntimeScopes();
|
||||
$this->eagerStrategy = null; // reset
|
||||
$this->withStrategies = [];
|
||||
|
||||
$lastPage = (int)max(1, (int)ceil($total / $perPage));
|
||||
return [
|
||||
|
|
@ -209,6 +261,8 @@ abstract class AbstractDao implements DaoInterface
|
|||
if ($dtos && $this->with) { $this->attachRelations($dtos); }
|
||||
$this->resetFieldSelections();
|
||||
$this->resetRuntimeScopes();
|
||||
$this->eagerStrategy = null; // reset
|
||||
$this->withStrategies = [];
|
||||
|
||||
return [
|
||||
'data' => $dtos,
|
||||
|
|
@ -761,9 +815,189 @@ abstract class AbstractDao implements DaoInterface
|
|||
$this->with = [];
|
||||
$this->withTree = [];
|
||||
$this->withConstraints = [];
|
||||
$this->withStrategies = [];
|
||||
// do not reset relationFields here; they may be reused by subsequent loads in the same call
|
||||
}
|
||||
|
||||
// ===== Join-based eager loading (opt-in, single-level) =====
|
||||
|
||||
/** Determine if join-based eager should be used for current with() selection. */
|
||||
private function shouldUseJoinEager(): bool
|
||||
{
|
||||
// Determine if join strategy is desired globally or per relation
|
||||
$globalJoin = ($this->eagerStrategy === 'join');
|
||||
$perRelJoin = false;
|
||||
if (!$globalJoin && $this->with) {
|
||||
$allMarked = true;
|
||||
foreach ($this->with as $rel) {
|
||||
if (($this->withStrategies[$rel] ?? null) !== 'join') { $allMarked = false; break; }
|
||||
}
|
||||
$perRelJoin = $allMarked;
|
||||
}
|
||||
if (!$globalJoin && !$perRelJoin) return false;
|
||||
// Only single-level paths supported in join MVP (no nested trees)
|
||||
foreach ($this->withTree as $rel => $sub) {
|
||||
if (!empty($sub)) return false; // nested present => fallback
|
||||
}
|
||||
// Require relationFields for each relation to know what to select safely
|
||||
foreach ($this->with as $rel) {
|
||||
if (!isset($this->relationFields[$rel]) || empty($this->relationFields[$rel])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SELECT with LEFT JOINs for the requested relations.
|
||||
* Returns [sql, bindings, meta] where meta describes relation aliases and selected columns.
|
||||
* @param ?int $limit
|
||||
* @param ?int $offset
|
||||
* @return array{0:string,1:array<string,mixed>,2:array<string,mixed>}
|
||||
*/
|
||||
private function buildJoinSelect(string $baseWhere, array $bindings, ?int $limit, ?int $offset): array
|
||||
{
|
||||
$t0 = 't0';
|
||||
$pk = $this->getPrimaryKey();
|
||||
// Base select: ensure PK is included
|
||||
$baseCols = $this->selectedFields ?: ['*'];
|
||||
if ($baseCols === ['*'] || !in_array($pk, $baseCols, true)) {
|
||||
// Select * to keep behavior; PK is present implicitly
|
||||
$baseSelect = "$t0.*";
|
||||
} else {
|
||||
$quoted = array_map(fn($c) => "$t0.$c", $baseCols);
|
||||
$baseSelect = implode(', ', $quoted);
|
||||
}
|
||||
|
||||
$selects = [ $baseSelect ];
|
||||
$joins = [];
|
||||
$meta = [ 'rels' => [] ];
|
||||
|
||||
$relations = $this->relations();
|
||||
$aliasIndex = 1;
|
||||
foreach ($this->with as $name) {
|
||||
if (!isset($relations[$name])) continue;
|
||||
$cfg = $relations[$name];
|
||||
$type = (string)($cfg['type'] ?? '');
|
||||
$daoClass = $cfg['dao'] ?? null;
|
||||
if (!is_string($daoClass) || $type === '') continue;
|
||||
/** @var class-string<AbstractDao> $daoClass */
|
||||
$relDao = new $daoClass($this->getConnection());
|
||||
$ta = 't' . $aliasIndex++;
|
||||
$on = '';
|
||||
if ($type === 'hasMany' || $type === 'hasOne') {
|
||||
$foreignKey = (string)($cfg['foreignKey'] ?? '');
|
||||
$localKey = (string)($cfg['localKey'] ?? 'id');
|
||||
if ($foreignKey === '') continue;
|
||||
$on = "$ta.$foreignKey = $t0.$localKey";
|
||||
} elseif ($type === 'belongsTo') {
|
||||
$foreignKey = (string)($cfg['foreignKey'] ?? '');
|
||||
$otherKey = (string)($cfg['otherKey'] ?? 'id');
|
||||
if ($foreignKey === '') continue;
|
||||
$on = "$ta.$otherKey = $t0.$foreignKey";
|
||||
} else {
|
||||
// belongsToMany not supported in join MVP
|
||||
continue;
|
||||
}
|
||||
// Soft-delete scope for related in JOIN (append in ON)
|
||||
if ($relDao->hasSoftDeletes()) {
|
||||
$del = $relDao->softDeleteConfig()['deletedAt'] ?? 'deleted_at';
|
||||
$on .= " AND $ta.$del IS NULL";
|
||||
}
|
||||
$joins[] = 'LEFT JOIN ' . $relDao->getTable() . ' ' . $ta . ' ON ' . $on;
|
||||
// Select related fields with alias prefix
|
||||
$relCols = $this->relationFields[$name] ?? [];
|
||||
$pref = $name . '__';
|
||||
foreach ($relCols as $col) {
|
||||
$selects[] = "$ta.$col AS `{$pref}{$col}`";
|
||||
}
|
||||
$meta['rels'][$name] = [ 'alias' => $ta, 'type' => $type, 'dao' => $relDao, 'cols' => $relCols ];
|
||||
}
|
||||
|
||||
$sql = 'SELECT ' . implode(', ', $selects) . ' FROM ' . $this->getTable() . ' ' . $t0;
|
||||
if ($joins) {
|
||||
$sql .= ' ' . implode(' ', $joins);
|
||||
}
|
||||
if ($baseWhere !== '') {
|
||||
$sql .= ' WHERE ' . $baseWhere;
|
||||
}
|
||||
if ($limit !== null) {
|
||||
$sql .= ' LIMIT ' . (int)$limit;
|
||||
}
|
||||
if ($offset !== null) {
|
||||
$sql .= ' OFFSET ' . (int)$offset;
|
||||
}
|
||||
return [$sql, $bindings, $meta];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate DTOs from joined rows with aliased related columns.
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @param array<string,mixed> $meta
|
||||
* @return array<int,AbstractDto>
|
||||
*/
|
||||
private function hydrateFromJoinRows(array $rows, array $meta): array
|
||||
{
|
||||
if (!$rows) return [];
|
||||
$pk = $this->getPrimaryKey();
|
||||
$out = [];
|
||||
$byId = [];
|
||||
foreach ($rows as $row) {
|
||||
// Split base and related segments (related segments are prefixed as rel__col)
|
||||
$base = [];
|
||||
$relSegments = [];
|
||||
foreach ($row as $k => $v) {
|
||||
if (is_string($k) && str_contains($k, '__')) {
|
||||
[$rel, $col] = explode('__', $k, 2);
|
||||
$relSegments[$rel][$col] = $v;
|
||||
} else {
|
||||
$base[$k] = $v;
|
||||
}
|
||||
}
|
||||
$idVal = $base[$pk] ?? null;
|
||||
if ($idVal === null) {
|
||||
// cannot hydrate without PK; skip row
|
||||
continue;
|
||||
}
|
||||
$idKey = (string)$idVal;
|
||||
if (!isset($byId[$idKey])) {
|
||||
$dto = $this->hydrate($this->castRowFromStorage($base));
|
||||
$byId[$idKey] = $dto;
|
||||
$out[] = $dto;
|
||||
}
|
||||
$parent = $byId[$idKey];
|
||||
// Attach each relation if there are any non-null values
|
||||
foreach (($meta['rels'] ?? []) as $name => $info) {
|
||||
$seg = $relSegments[$name] ?? [];
|
||||
// Detect empty (all null)
|
||||
$allNull = true;
|
||||
foreach ($seg as $vv) { if ($vv !== null) { $allNull = false; break; } }
|
||||
if ($allNull) {
|
||||
// Ensure default: hasMany => [], hasOne/belongsTo => null (only set if not already set)
|
||||
if (!isset($parent->toArray(false)[$name])) {
|
||||
if (($info['type'] ?? '') === 'hasMany') { $parent->setRelation($name, []); }
|
||||
else { $parent->setRelation($name, null); }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
/** @var AbstractDao $relDao */
|
||||
$relDao = $info['dao'];
|
||||
// Cast and hydrate child DTO
|
||||
$child = $relDao->hydrate($relDao->castRowFromStorage($seg));
|
||||
if (($info['type'] ?? '') === 'hasMany') {
|
||||
$current = $parent->toArray(false)[$name] ?? [];
|
||||
if (!is_array($current)) { $current = []; }
|
||||
// Append; no dedup to keep simple
|
||||
$current[] = $child;
|
||||
$parent->setRelation($name, $current);
|
||||
} else {
|
||||
$parent->setRelation($name, $child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ===== belongsToMany helpers (pivot operations) =====
|
||||
|
||||
/**
|
||||
|
|
@ -861,15 +1095,23 @@ abstract class AbstractDao implements DaoInterface
|
|||
public function with(array $relations): static
|
||||
{
|
||||
// Accept ['rel', 'rel.child'] or ['rel' => callable, 'rel.child' => callable]
|
||||
// Also accepts config arrays like ['rel' => ['strategy' => 'join']] and
|
||||
// ['rel' => ['strategy' => 'join', 'constraint' => callable]]
|
||||
$names = [];
|
||||
$tree = [];
|
||||
foreach ($relations as $key => $value) {
|
||||
if (is_int($key)) { // plain name
|
||||
$path = (string)$value;
|
||||
$this->insertRelationPath($tree, $path);
|
||||
} else { // constraint
|
||||
} else { // constraint or config
|
||||
$path = (string)$key;
|
||||
if (is_callable($value)) {
|
||||
if (is_array($value)) {
|
||||
$strategy = isset($value['strategy']) ? strtolower((string)$value['strategy']) : null;
|
||||
if ($strategy) { $this->withStrategies[$path] = $strategy; }
|
||||
if (isset($value['constraint']) && is_callable($value['constraint'])) {
|
||||
$this->withConstraints[$path] = $value['constraint'];
|
||||
}
|
||||
} elseif (is_callable($value)) {
|
||||
$this->withConstraints[$path] = $value;
|
||||
}
|
||||
$this->insertRelationPath($tree, $path);
|
||||
|
|
@ -1047,6 +1289,11 @@ abstract class AbstractDao implements DaoInterface
|
|||
private function castFromStorage(string $type, mixed $value): mixed
|
||||
{
|
||||
if ($value === null) return null;
|
||||
// Support custom caster classes via class-string in schema 'cast'
|
||||
$caster = $this->resolveCaster($type);
|
||||
if ($caster) {
|
||||
return $caster->fromStorage($value);
|
||||
}
|
||||
switch ($type) {
|
||||
case 'int': return (int)$value;
|
||||
case 'float': return (float)$value;
|
||||
|
|
@ -1120,6 +1367,11 @@ abstract class AbstractDao implements DaoInterface
|
|||
private function castForStorage(string $type, mixed $value): mixed
|
||||
{
|
||||
if ($value === null) return null;
|
||||
// Support custom caster classes via class-string in schema 'cast'
|
||||
$caster = $this->resolveCaster($type);
|
||||
if ($caster) {
|
||||
return $caster->toStorage($value);
|
||||
}
|
||||
switch ($type) {
|
||||
case 'int': return (int)$value;
|
||||
case 'float': return (float)$value;
|
||||
|
|
@ -1139,6 +1391,31 @@ abstract class AbstractDao implements DaoInterface
|
|||
}
|
||||
}
|
||||
|
||||
/** Cache for resolved caster instances. @var array<string, CasterInterface> */
|
||||
private array $casterCache = [];
|
||||
|
||||
/** Resolve a caster from a type/class string. */
|
||||
private function resolveCaster(string $type): ?CasterInterface
|
||||
{
|
||||
// Not a class-string? return null to use built-ins
|
||||
if (!class_exists($type)) {
|
||||
return null;
|
||||
}
|
||||
if (isset($this->casterCache[$type])) {
|
||||
return $this->casterCache[$type];
|
||||
}
|
||||
try {
|
||||
$obj = new $type();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
if ($obj instanceof CasterInterface) {
|
||||
$this->casterCache[$type] = $obj;
|
||||
return $obj;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function nowString(): string
|
||||
{
|
||||
return gmdate('Y-m-d H:i:s');
|
||||
|
|
|
|||
|
|
@ -12,7 +12,15 @@ abstract class AbstractDto implements DtoInterface
|
|||
/** @param array<string,mixed> $attributes */
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
$this->attributes = $attributes;
|
||||
// Apply mutators if defined
|
||||
foreach ($attributes as $key => $value) {
|
||||
$method = $this->mutatorMethod($key);
|
||||
if (method_exists($this, $method)) {
|
||||
// set{Name}Attribute($value): mixed
|
||||
$value = $this->{$method}($value);
|
||||
}
|
||||
$this->attributes[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
|
|
@ -23,7 +31,13 @@ abstract class AbstractDto implements DtoInterface
|
|||
|
||||
public function __get(string $name): mixed
|
||||
{
|
||||
return $this->attributes[$name] ?? null;
|
||||
$value = $this->attributes[$name] ?? null;
|
||||
$method = $this->accessorMethod($name);
|
||||
if (method_exists($this, $method)) {
|
||||
// get{Name}Attribute($value): mixed
|
||||
return $this->{$method}($value);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function __isset(string $name): bool
|
||||
|
|
@ -44,11 +58,26 @@ abstract class AbstractDto implements DtoInterface
|
|||
public function toArray(bool $deep = true): array
|
||||
{
|
||||
if (!$deep) {
|
||||
return $this->attributes;
|
||||
// Apply accessors at top level for scalar attributes
|
||||
$out = [];
|
||||
foreach ($this->attributes as $key => $value) {
|
||||
$method = $this->accessorMethod($key);
|
||||
if (method_exists($this, $method)) {
|
||||
$out[$key] = $this->{$method}($value);
|
||||
} else {
|
||||
$out[$key] = $value;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($this->attributes as $key => $value) {
|
||||
// Apply accessor before deep conversion for scalars/arrays
|
||||
$method = $this->accessorMethod($key);
|
||||
if (method_exists($this, $method)) {
|
||||
$value = $this->{$method}($value);
|
||||
}
|
||||
if ($value instanceof DtoInterface) {
|
||||
$result[$key] = $value->toArray(true);
|
||||
} elseif (is_array($value)) {
|
||||
|
|
@ -66,4 +95,21 @@ abstract class AbstractDto implements DtoInterface
|
|||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function accessorMethod(string $key): string
|
||||
{
|
||||
return 'get' . $this->studly($key) . 'Attribute';
|
||||
}
|
||||
|
||||
private function mutatorMethod(string $key): string
|
||||
{
|
||||
return 'set' . $this->studly($key) . 'Attribute';
|
||||
}
|
||||
|
||||
private function studly(string $value): string
|
||||
{
|
||||
$value = str_replace(['-', '_'], ' ', $value);
|
||||
$value = ucwords($value);
|
||||
return str_replace(' ', '', $value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
src/Model/Casting/CasterInterface.php
Normal file
12
src/Model/Casting/CasterInterface.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace Pairity\Model\Casting;
|
||||
|
||||
interface CasterInterface
|
||||
{
|
||||
/** Convert a raw storage value (from DB/driver) to a PHP value for the DTO. */
|
||||
public function fromStorage(mixed $value): mixed;
|
||||
|
||||
/** Convert a PHP value to a storage value suitable for persistence. */
|
||||
public function toStorage(mixed $value): mixed;
|
||||
}
|
||||
93
tests/CastersAndAccessorsSqliteTest.php
Normal file
93
tests/CastersAndAccessorsSqliteTest.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pairity\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Pairity\Database\ConnectionManager;
|
||||
use Pairity\Model\AbstractDao;
|
||||
use Pairity\Model\AbstractDto;
|
||||
use Pairity\Model\Casting\CasterInterface;
|
||||
|
||||
final class CastersAndAccessorsSqliteTest extends TestCase
|
||||
{
|
||||
private function conn()
|
||||
{
|
||||
return ConnectionManager::make(['driver' => 'sqlite', 'path' => ':memory:']);
|
||||
}
|
||||
|
||||
public function testCustomCasterAndDtoAccessorsMutators(): void
|
||||
{
|
||||
$conn = $this->conn();
|
||||
// simple schema
|
||||
$conn->execute('CREATE TABLE widgets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
price_cents INTEGER,
|
||||
meta TEXT
|
||||
)');
|
||||
|
||||
// Custom caster for money cents <-> Money object (array for simplicity)
|
||||
$moneyCasterClass = new class implements CasterInterface {
|
||||
public function fromStorage(mixed $value): mixed { return ['cents' => (int)$value]; }
|
||||
public function toStorage(mixed $value): mixed {
|
||||
if (is_array($value) && isset($value['cents'])) { return (int)$value['cents']; }
|
||||
return (int)$value;
|
||||
}
|
||||
};
|
||||
$moneyCasterFqcn = get_class($moneyCasterClass);
|
||||
|
||||
// DTO with accessor/mutator for name (capitalize on get, trim on set)
|
||||
$Dto = new class([]) extends AbstractDto {
|
||||
protected function getNameAttribute($value): mixed { return is_string($value) ? strtoupper($value) : $value; }
|
||||
protected function setNameAttribute($value): mixed { return is_string($value) ? trim($value) : $value; }
|
||||
};
|
||||
$dtoClass = get_class($Dto);
|
||||
|
||||
$Dao = new class($conn, $dtoClass, $moneyCasterFqcn) extends AbstractDao {
|
||||
private string $dto; private string $caster;
|
||||
public function __construct($c, string $dto, string $caster) { parent::__construct($c); $this->dto = $dto; $this->caster = $caster; }
|
||||
public function getTable(): string { return 'widgets'; }
|
||||
protected function dtoClass(): string { return $this->dto; }
|
||||
protected function schema(): array
|
||||
{
|
||||
return [
|
||||
'primaryKey' => 'id',
|
||||
'columns' => [
|
||||
'id' => ['cast' => 'int'],
|
||||
'name' => ['cast' => 'string'],
|
||||
'price_cents' => ['cast' => $this->caster], // custom caster
|
||||
'meta' => ['cast' => 'json'],
|
||||
],
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
$dao = new $Dao($conn, $dtoClass, $moneyCasterFqcn);
|
||||
|
||||
// Insert with mutator (name will be trimmed) and caster (price array -> storage int)
|
||||
$created = $dao->insert([
|
||||
'name' => ' gizmo ',
|
||||
'price_cents' => ['cents' => 1234],
|
||||
'meta' => ['color' => 'red']
|
||||
]);
|
||||
$arr = $created->toArray(false);
|
||||
$this->assertSame('GIZMO', $arr['name']); // accessor uppercases
|
||||
$this->assertIsArray($arr['price_cents']);
|
||||
$this->assertSame(1234, $arr['price_cents']['cents']); // fromStorage via caster
|
||||
$this->assertSame('red', $arr['meta']['color']);
|
||||
|
||||
$id = $arr['id'];
|
||||
|
||||
// Update with caster value
|
||||
$updated = $dao->update($id, ['price_cents' => ['cents' => 1999]]);
|
||||
$this->assertSame(1999, $updated->toArray(false)['price_cents']['cents']);
|
||||
|
||||
// Verify raw storage is int (select directly)
|
||||
$raw = $conn->query('SELECT price_cents, meta, name FROM widgets WHERE id = :id', ['id' => $id])[0] ?? [];
|
||||
$this->assertSame(1999, (int)$raw['price_cents']);
|
||||
$this->assertIsString($raw['meta']);
|
||||
$this->assertSame('gizmo', strtolower((string)$raw['name']));
|
||||
}
|
||||
}
|
||||
125
tests/JoinEagerMysqlTest.php
Normal file
125
tests/JoinEagerMysqlTest.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pairity\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Pairity\Database\ConnectionManager;
|
||||
use Pairity\Schema\SchemaManager;
|
||||
use Pairity\Schema\Blueprint;
|
||||
use Pairity\Model\AbstractDao;
|
||||
use Pairity\Model\AbstractDto;
|
||||
|
||||
final class JoinEagerMysqlTest extends TestCase
|
||||
{
|
||||
private function mysqlConfig(): array
|
||||
{
|
||||
$host = getenv('MYSQL_HOST') ?: null;
|
||||
if (!$host) {
|
||||
$this->markTestSkipped('MYSQL_HOST not set; skipping MySQL join eager test');
|
||||
}
|
||||
return [
|
||||
'driver' => 'mysql',
|
||||
'host' => $host,
|
||||
'port' => (int)(getenv('MYSQL_PORT') ?: 3306),
|
||||
'database' => getenv('MYSQL_DB') ?: 'pairity',
|
||||
'username' => getenv('MYSQL_USER') ?: 'root',
|
||||
'password' => getenv('MYSQL_PASS') ?: 'root',
|
||||
'charset' => 'utf8mb4',
|
||||
];
|
||||
}
|
||||
|
||||
public function testJoinEagerHasManyAndBelongsTo(): void
|
||||
{
|
||||
$cfg = $this->mysqlConfig();
|
||||
$conn = ConnectionManager::make($cfg);
|
||||
$schema = SchemaManager::forConnection($conn);
|
||||
|
||||
// Unique table names per run
|
||||
$suf = substr(sha1((string)microtime(true)), 0, 6);
|
||||
$usersT = 'je_users_' . $suf;
|
||||
$postsT = 'je_posts_' . $suf;
|
||||
|
||||
// Create tables
|
||||
$schema->create($usersT, function (Blueprint $t) { $t->increments('id'); $t->string('name', 190); });
|
||||
$schema->create($postsT, function (Blueprint $t) { $t->increments('id'); $t->integer('user_id'); $t->string('title', 190); $t->datetime('deleted_at')->nullable(); });
|
||||
|
||||
// DTOs
|
||||
$UserDto = new class([]) extends AbstractDto {};
|
||||
$PostDto = new class([]) extends AbstractDto {};
|
||||
$uClass = get_class($UserDto); $pClass = get_class($PostDto);
|
||||
|
||||
// DAOs
|
||||
$PostDao = new class($conn, $postsT, $pClass) extends AbstractDao {
|
||||
private string $table; private string $dto;
|
||||
public function __construct($c, string $table, string $dto) { parent::__construct($c); $this->table=$table; $this->dto=$dto; }
|
||||
public function getTable(): string { return $this->table; }
|
||||
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'], 'deleted_at'=>['cast'=>'datetime'] ],
|
||||
'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'],
|
||||
]; }
|
||||
};
|
||||
|
||||
$UserDao = new class($conn, $usersT, $uClass, get_class($PostDao)) extends AbstractDao {
|
||||
private string $table; private string $dto; private string $postDaoClass;
|
||||
public function __construct($c, string $table, string $dto, string $postDaoClass) { parent::__construct($c); $this->table=$table; $this->dto=$dto; $this->postDaoClass=$postDaoClass; }
|
||||
public function getTable(): string { return $this->table; }
|
||||
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'],'name'=>['cast'=>'string']]]; }
|
||||
};
|
||||
|
||||
$postDao = new $PostDao($conn, $postsT, $pClass);
|
||||
$userDao = new $UserDao($conn, $usersT, $uClass, get_class($postDao));
|
||||
|
||||
// Seed
|
||||
$u1 = $userDao->insert(['name' => 'Alice']);
|
||||
$u2 = $userDao->insert(['name' => 'Bob']);
|
||||
$uid1 = (int)$u1->toArray(false)['id'];
|
||||
$uid2 = (int)$u2->toArray(false)['id'];
|
||||
$postDao->insert(['user_id' => $uid1, 'title' => 'P1']);
|
||||
$postDao->insert(['user_id' => $uid1, 'title' => 'P2']);
|
||||
// soft-deleted child for Bob
|
||||
$postDao->insert(['user_id' => $uid2, 'title' => 'Hidden', 'deleted_at' => gmdate('Y-m-d H:i:s')]);
|
||||
|
||||
// Baseline batched eager
|
||||
$baseline = $userDao->fields('id','name','posts.title')->with(['posts'])->findAllBy([]);
|
||||
$this->assertCount(2, $baseline);
|
||||
$postsAlice = $baseline[0]->toArray(false)['posts'] ?? [];
|
||||
$this->assertIsArray($postsAlice);
|
||||
$this->assertCount(2, $postsAlice);
|
||||
|
||||
// Join-based eager (global)
|
||||
$joined = $userDao->fields('id','name','posts.title')->useJoinEager()->with(['posts'])->findAllBy([]);
|
||||
$this->assertCount(2, $joined);
|
||||
foreach ($joined as $u) {
|
||||
$posts = $u->toArray(false)['posts'] ?? [];
|
||||
foreach ($posts as $p) {
|
||||
$this->assertNotSame('Hidden', $p->toArray(false)['title'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
// belongsTo join: Posts -> User
|
||||
$UserDao2 = get_class($userDao);
|
||||
$PostDao2 = new class($conn, $postsT, $pClass, $UserDao2, $usersT, $uClass) extends AbstractDao {
|
||||
private string $pTable; private string $dto; private string $userDaoClass; private string $uTable; private string $uDto;
|
||||
public function __construct($c,string $pTable,string $dto,string $userDaoClass,string $uTable,string $uDto){ parent::__construct($c); $this->pTable=$pTable; $this->dto=$dto; $this->userDaoClass=$userDaoClass; $this->uTable=$uTable; $this->uDto=$uDto; }
|
||||
public function getTable(): string { return $this->pTable; }
|
||||
protected function dtoClass(): string { return $this->dto; }
|
||||
protected function relations(): array { return [ 'user' => [ 'type'=>'belongsTo', 'dao'=>get_class(new class($this->getConnection(), $this->uTable, $this->uDto) extends AbstractDao { private string $t; private string $d; public function __construct($c,string $t,string $d){ parent::__construct($c); $this->t=$t; $this->d=$d; } public function getTable(): string { return $this->t; } protected function dtoClass(): string { return $this->d; } protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; } }), 'foreignKey'=>'user_id', 'otherKey'=>'id' ] ]; }
|
||||
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; }
|
||||
};
|
||||
$postDaoJ = new $PostDao2($conn, $postsT, $pClass, $UserDao2, $usersT, $uClass);
|
||||
$rows = $postDaoJ->fields('id','title','user.name')->useJoinEager()->with(['user'])->findAllBy([]);
|
||||
$this->assertNotEmpty($rows);
|
||||
$arr = $rows[0]->toArray(false);
|
||||
$this->assertArrayHasKey('user', $arr);
|
||||
|
||||
// Cleanup
|
||||
$schema->drop($usersT);
|
||||
$schema->drop($postsT);
|
||||
}
|
||||
}
|
||||
127
tests/JoinEagerSqliteTest.php
Normal file
127
tests/JoinEagerSqliteTest.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?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 JoinEagerSqliteTest extends TestCase
|
||||
{
|
||||
private function conn()
|
||||
{
|
||||
return ConnectionManager::make(['driver' => 'sqlite', 'path' => ':memory:']);
|
||||
}
|
||||
|
||||
public function testHasManyJoinEagerWithProjectionAndSoftDeleteScope(): void
|
||||
{
|
||||
$conn = $this->conn();
|
||||
// schema
|
||||
$conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)');
|
||||
$conn->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, deleted_at TEXT NULL)');
|
||||
|
||||
// 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'], 'deleted_at'=>['cast'=>'datetime'] ],
|
||||
'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'],
|
||||
]; }
|
||||
};
|
||||
|
||||
$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'],'name'=>['cast'=>'string']]]; }
|
||||
};
|
||||
|
||||
$postDao = new $PostDao($conn, $pClass);
|
||||
$userDao = new $UserDao($conn, $uClass, get_class($postDao));
|
||||
|
||||
// seed
|
||||
$u1 = $userDao->insert(['name' => 'Alice']);
|
||||
$u2 = $userDao->insert(['name' => 'Bob']);
|
||||
$uid1 = (int)$u1->toArray(false)['id'];
|
||||
$uid2 = (int)$u2->toArray(false)['id'];
|
||||
$postDao->insert(['user_id' => $uid1, 'title' => 'P1']);
|
||||
$postDao->insert(['user_id' => $uid1, 'title' => 'P2']);
|
||||
$postDao->insert(['user_id' => $uid2, 'title' => 'Hidden', 'deleted_at' => gmdate('Y-m-d H:i:s')]); // soft-deleted
|
||||
|
||||
// Batched (subquery) for baseline
|
||||
$baseline = $userDao->fields('id','name','posts.title')->with(['posts'])->findAllBy([]);
|
||||
$this->assertCount(2, $baseline);
|
||||
$alice = $baseline[0]->toArray(false);
|
||||
$this->assertIsArray($alice['posts'] ?? null);
|
||||
$this->assertCount(2, $alice['posts']);
|
||||
|
||||
// Join-based eager (opt-in). Requires relation field projection.
|
||||
$joined = $userDao->fields('id','name','posts.title')->useJoinEager()->with(['posts'])->findAllBy([]);
|
||||
$this->assertCount(2, $joined);
|
||||
$aliceJ = $joined[0]->toArray(false);
|
||||
$this->assertIsArray($aliceJ['posts'] ?? null);
|
||||
$this->assertCount(2, $aliceJ['posts']);
|
||||
|
||||
// Ensure soft-deleted child was filtered out via ON condition
|
||||
foreach ($joined as $u) {
|
||||
$posts = $u->toArray(false)['posts'] ?? [];
|
||||
foreach ($posts as $p) {
|
||||
$this->assertNotSame('Hidden', $p->toArray(false)['title'] ?? null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testBelongsToJoinEagerSingleLevel(): void
|
||||
{
|
||||
$conn = $this->conn();
|
||||
$conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)');
|
||||
$conn->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT)');
|
||||
|
||||
$UserDto = new class([]) extends AbstractDto {};
|
||||
$PostDto = new class([]) extends AbstractDto {};
|
||||
$uClass = get_class($UserDto); $pClass = get_class($PostDto);
|
||||
|
||||
$UserDao = new class($conn, $uClass) extends AbstractDao {
|
||||
private string $dto; public function __construct($c,string $dto){ parent::__construct($c); $this->dto=$dto; }
|
||||
public function getTable(): string { return 'users'; }
|
||||
protected function dtoClass(): string { return $this->dto; }
|
||||
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; }
|
||||
};
|
||||
$PostDao = new class($conn, $pClass, get_class($UserDao)) extends AbstractDao {
|
||||
private string $dto; private string $userDaoClass; public function __construct($c,string $dto,string $u){ parent::__construct($c); $this->dto=$dto; $this->userDaoClass=$u; }
|
||||
public function getTable(): string { return 'posts'; }
|
||||
protected function dtoClass(): string { return $this->dto; }
|
||||
protected function relations(): array { return [
|
||||
'user' => [ 'type' => 'belongsTo', 'dao' => $this->userDaoClass, 'foreignKey' => 'user_id', 'otherKey' => 'id' ],
|
||||
]; }
|
||||
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; }
|
||||
};
|
||||
|
||||
$userDao = new $UserDao($conn, $uClass);
|
||||
$postDao = new $PostDao($conn, $pClass, get_class($userDao));
|
||||
|
||||
$u = $userDao->insert(['name' => 'Alice']);
|
||||
$uid = (int)$u->toArray(false)['id'];
|
||||
$p = $postDao->insert(['user_id' => $uid, 'title' => 'Hello']);
|
||||
|
||||
$rows = $postDao->fields('id','title','user.name')->useJoinEager()->with(['user'])->findAllBy([]);
|
||||
$this->assertNotEmpty($rows);
|
||||
$arr = $rows[0]->toArray(false);
|
||||
$this->assertSame('Hello', $arr['title']);
|
||||
$this->assertSame('Alice', $arr['user']->toArray(false)['name'] ?? null);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue