join‑based eager loading (SQL)

This commit is contained in:
Funky Waddle 2025-12-10 21:09:04 -06:00
parent 95ba97808f
commit cb1251ae14
9 changed files with 2731 additions and 15 deletions

112
README.md
View file

@ -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.
### Joinbased eager loading (optin, SQL)
For singlelevel relations on SQL DAOs, you can optin to a joinbased 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() // optin for the next call
->with(['posts']) // singlelevel only in MVP
->findAllBy([]);
```
Behavior and limitations (MVP):
- Singlelevel 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 twoquery 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()`.
- Perrelation constraints that rely on ordering/limits arent applied in join mode in this MVP; prefer the default batched strategy for those cases.
Tip: If join mode cant be used (e.g., nested paths or missing relation field projections), Pairity silently falls back to the portable batched eager loader.
Perrelation hint (optional):
You can hint join strategy per relation path. This is useful when you want to selectively join specific relations in a singlelevel eager load. The join will be used only when safe (singlelevel 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 singlelevel relations in this MVP. Nested paths (e.g., `posts.comments`) will use the portable strategy.
### belongsToMany (SQL) and pivot helpers
Pairity supports manytomany relations for SQL DAOs via a pivot table. Declare `belongsToMany` in your DAOs `relations()` and use the builtin 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 percolumn 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 toplevel keys in `toArray(true|false)`. Relations (nested DTOs) apply their own accessors during their own `toArray()`.
### Custom casters
In addition to builtin 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 columns `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 builtins).
- On INSERT/UPDATE, Pairity applies `toStorage()` per column (or builtins) and maintains timestamp/softdelete 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

File diff suppressed because it is too large Load diff

View 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";
}

View file

@ -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 perrelation eager loading strategies for firstlevel 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);
$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]);
$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);
$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);
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');

View file

@ -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);
}
}

View 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;
}

View 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']));
}
}

View 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);
}
}

View 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);
}
}