Relations Phase 1 (belongsToMany, nested eager, per‑relation constraints)

This commit is contained in:
Funky Waddle 2025-12-10 09:17:55 -06:00
parent d8cae37a4d
commit f21df4f567
7 changed files with 843 additions and 2 deletions

View file

@ -260,6 +260,88 @@ Notes:
- Loaded relations are attached onto the DTO under the relation name (e.g., `$user->posts`). - 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. - `hasOne` is supported like `hasMany` but attaches a single DTO instead of a list.
### 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`.
Relation metadata keys:
- `type` = `belongsToMany`
- `dao` = related DAO class
- `pivot` (or `pivotTable`) = pivot table name
- `foreignPivotKey` = pivot column referencing the parent table
- `relatedPivotKey` = pivot column referencing the related table
- `localKey` = parent primary key column (default `id`)
- `relatedKey` = related primary key column (default `id`)
Example (users ↔ roles):
```php
class UserDao extends AbstractDao {
protected function relations(): array {
return [
'roles' => [
'type' => 'belongsToMany',
'dao' => RoleDao::class,
'pivot' => 'user_role',
'foreignPivotKey' => 'user_id',
'relatedPivotKey' => 'role_id',
'localKey' => 'id',
'relatedKey' => 'id',
],
];
}
}
$user = $userDao->insert(['email' => 'a@b.com']);
$uid = $user->toArray(false)['id'];
$userDao->attach('roles', $uid, [$roleId1, $roleId2]); // insert into pivot
$userDao->detach('roles', $uid, [$roleId1]); // delete specific
$userDao->sync('roles', $uid, [$roleId2]); // make roles exactly this set
$with = $userDao->with(['roles'])->findById($uid); // eager load related roles
```
See `examples/mysql_relations_pivot.php` for a runnable snippet.
### Nested eager loading
You can request nested eager loading using dot notation. Example: load a users posts and each posts comments:
```php
$users = $userDao->with(['posts.comments'])->findAllBy([...]);
```
Nested eager loading works for SQL and Mongo DAOs. Pairity performs separate batched fetches per relation level to remain portable across drivers.
### Perrelation constraints
Pass a callable per relation path to customize how the related DAO queries data for that relation. The callable receives the related DAO instance so you can specify fields, ordering, and limits.
- SQL example (perrelation `fields()` projection and ordering):
```php
$users = $userDao->with([
'posts' => function (UserPostDao $dao) {
$dao->fields('id', 'title');
// $dao->orderBy('created_at DESC'); // if your DAO exposes ordering
},
'posts.comments' // nested
])->findAllBy(['status' => 'active']);
```
- Mongo example (projection, sort, limit):
```php
$docs = $userMongoDao->with([
'posts' => function (PostMongoDao $dao) {
$dao->fields('title')->sort(['title' => 1])->limit(10);
},
'posts.comments'
])->findAllBy([]);
```
Constraints are applied only to the specific relation path they are defined on.
## Model metadata & schema mapping (MVP) ## Model metadata & schema mapping (MVP)
Define schema metadata on your DAO by overriding `schema()`. The schema enables: Define schema metadata on your DAO by overriding `schema()`. The schema enables:

View file

@ -0,0 +1,94 @@
<?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' => '127.0.0.1',
'port' => 3306,
'database' => 'app',
'username' => 'root',
'password' => 'secret',
'charset' => 'utf8mb4',
]);
// Ensure demo tables (idempotent)
$conn->execute('CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(190) NOT NULL
)');
$conn->execute('CREATE TABLE IF NOT EXISTS roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL
)');
$conn->execute('CREATE TABLE IF NOT EXISTS user_role (
user_id INT NOT NULL,
role_id INT NOT NULL
)');
class UserDto extends AbstractDto {}
class RoleDto extends AbstractDto {}
class RoleDao extends AbstractDao {
public function getTable(): string { return 'roles'; }
protected function dtoClass(): string { return RoleDto::class; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; }
}
class UserDao extends AbstractDao {
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return UserDto::class; }
protected function relations(): array {
return [
'roles' => [
'type' => 'belongsToMany',
'dao' => RoleDao::class,
'pivot' => 'user_role',
'foreignPivotKey' => 'user_id',
'relatedPivotKey' => 'role_id',
'localKey' => 'id',
'relatedKey' => 'id',
],
];
}
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string']]]; }
}
$roleDao = new RoleDao($conn);
$userDao = new UserDao($conn);
// Clean minimal (demo only)
foreach ($userDao->findAllBy() as $u) { $userDao->deleteById((int)$u->toArray(false)['id']); }
foreach ($roleDao->findAllBy() as $r) { $roleDao->deleteById((int)$r->toArray(false)['id']); }
$conn->execute('DELETE FROM user_role');
// Seed
$admin = $roleDao->insert(['name' => 'admin']);
$editor = $roleDao->insert(['name' => 'editor']);
$u = $userDao->insert(['email' => 'pivot@example.com']);
$uid = (int)$u->toArray(false)['id'];
$ridAdmin = (int)$admin->toArray(false)['id'];
$ridEditor = (int)$editor->toArray(false)['id'];
// Attach roles
$userDao->attach('roles', $uid, [$ridAdmin, $ridEditor]);
$with = $userDao->with(['roles'])->findById($uid);
echo "User with roles: " . json_encode($with?->toArray(true)) . "\n";
// Detach one role
$userDao->detach('roles', $uid, [$ridAdmin]);
$with = $userDao->with(['roles'])->findById($uid);
echo "After detach: " . json_encode($with?->toArray(true)) . "\n";
// Sync to only [editor]
$userDao->sync('roles', $uid, [$ridEditor]);
$with = $userDao->with(['roles'])->findById($uid);
echo "After sync: " . json_encode($with?->toArray(true)) . "\n";

View file

@ -16,6 +16,20 @@ abstract class AbstractDao implements DaoInterface
private array $relationFields = []; private array $relationFields = [];
/** @var array<int,string> */ /** @var array<int,string> */
private array $with = []; private array $with = [];
/**
* Nested eager-loading tree built from with() calls.
* Example: with(['posts.comments','user']) =>
* [ 'posts' => ['comments' => []], 'user' => [] ]
* @var array<string, array<string,mixed>>
*/
private array $withTree = [];
/**
* Per relation (and nested path) constraints.
* Keys are relation paths like 'posts' or 'posts.comments'.
* Values are callables taking the related DAO instance.
* @var array<string, callable>
*/
private array $withConstraints = [];
/** Soft delete include flags */ /** Soft delete include flags */
private bool $includeTrashed = false; private bool $includeTrashed = false;
private bool $onlyTrashed = false; private bool $onlyTrashed = false;
@ -458,6 +472,11 @@ abstract class AbstractDao implements DaoInterface
/** @var class-string<AbstractDao> $daoClass */ /** @var class-string<AbstractDao> $daoClass */
$relatedDao = new $daoClass($this->getConnection()); $relatedDao = new $daoClass($this->getConnection());
// Apply per-relation constraint, if any
$constraint = $this->constraintForPath($name);
if (is_callable($constraint)) {
$constraint($relatedDao);
}
$relFields = $this->relationFields[$name] ?? null; $relFields = $this->relationFields[$name] ?? null;
if ($relFields) { $relatedDao->fields(...$relFields); } if ($relFields) { $relatedDao->fields(...$relFields); }
@ -492,6 +511,28 @@ abstract class AbstractDao implements DaoInterface
$p->setRelation($name, $list); $p->setRelation($name, $list);
} }
} }
// Nested eager for children of this relation
$nested = $this->withTree[$name] ?? [];
if ($nested) {
// Flatten first-level child relation names for related DAO
$childNames = array_keys($nested);
// Prepare related DAO with child-level constraints (prefix path)
$relatedDao->with($this->rebuildNestedForChild($name, $nested));
// Collect all child DTOs (hasMany arrays concatenated; hasOne singletons filtered)
$allChildren = [];
foreach ($parents as $p) {
$val = $p->toArray(false)[$name] ?? null;
if ($val instanceof AbstractDto) {
$allChildren[] = $val;
} elseif (is_array($val)) {
foreach ($val as $c) { if ($c instanceof AbstractDto) { $allChildren[] = $c; } }
}
}
if ($allChildren) {
// Call attachRelations on the related DAO to process its with list
$relatedDao->attachRelations($allChildren);
}
}
} elseif ($type === 'belongsTo') { } elseif ($type === 'belongsTo') {
$foreignKey = (string)($config['foreignKey'] ?? ''); // on parent $foreignKey = (string)($config['foreignKey'] ?? ''); // on parent
$otherKey = (string)($config['otherKey'] ?? 'id'); // on related $otherKey = (string)($config['otherKey'] ?? 'id'); // on related
@ -515,16 +556,204 @@ abstract class AbstractDao implements DaoInterface
$fk = $arr[$foreignKey] ?? null; $fk = $arr[$foreignKey] ?? null;
$p->setRelation($name, ($fk !== null && isset($byId[$fk])) ? $byId[$fk] : null); $p->setRelation($name, ($fk !== null && isset($byId[$fk])) ? $byId[$fk] : null);
} }
// Nested eager for belongsTo owner
$nested = $this->withTree[$name] ?? [];
if ($nested) {
$childNames = array_keys($nested);
$relatedDao->with($this->rebuildNestedForChild($name, $nested));
$allOwners = [];
foreach ($parents as $p) {
$val = $p->toArray(false)[$name] ?? null;
if ($val instanceof AbstractDto) { $allOwners[] = $val; }
}
if ($allOwners) {
$relatedDao->attachRelations($allOwners);
}
}
} elseif ($type === 'belongsToMany') {
// SQL-only many-to-many via pivot table
$pivot = (string)($config['pivot'] ?? ($config['pivotTable'] ?? ''));
$foreignPivotKey = (string)($config['foreignPivotKey'] ?? ''); // pivot column referencing parent
$relatedPivotKey = (string)($config['relatedPivotKey'] ?? ''); // pivot column referencing related
$localKey = (string)($config['localKey'] ?? 'id'); // on parent
$relatedKey = (string)($config['relatedKey'] ?? 'id'); // on related
if ($pivot === '' || $foreignPivotKey === '' || $relatedPivotKey === '') { continue; }
// Collect parent keys
$parentKeys = [];
foreach ($parents as $p) {
$arr = $p->toArray(false);
if (isset($arr[$localKey])) { $parentKeys[] = $arr[$localKey]; }
}
if (!$parentKeys) continue;
// Fetch pivot rows
$ph = [];$bind=[];foreach (array_values(array_unique($parentKeys, SORT_REGULAR)) as $i=>$val){$k="p_$i";$ph[]=":$k";$bind[$k]=$val;}
$pivotSql = 'SELECT ' . $foreignPivotKey . ' AS fk, ' . $relatedPivotKey . ' AS rk FROM ' . $pivot . ' WHERE ' . $foreignPivotKey . ' IN (' . implode(', ', $ph) . ')';
$rows = $this->connection->query($pivotSql, $bind);
if (!$rows) {
foreach ($parents as $p) { $p->setRelation($name, []); }
continue;
}
$byParent = [];
$relatedIds = [];
foreach ($rows as $r) {
$fkVal = $r['fk'] ?? null; $rkVal = $r['rk'] ?? null;
if ($fkVal === null || $rkVal === null) continue;
$byParent[(string)$fkVal][] = $rkVal;
$relatedIds[] = $rkVal;
}
if (!$relatedIds) {
foreach ($parents as $p) { $p->setRelation($name, []); }
continue;
}
$relatedIds = array_values(array_unique($relatedIds, SORT_REGULAR));
// Apply constraints if provided
$constraint = $this->constraintForPath($name);
if (is_callable($constraint)) { $constraint($relatedDao); }
$related = $relatedDao->findAllWhereIn($relatedKey, $relatedIds);
$relatedMap = [];
foreach ($related as $r) {
$id = $r->toArray(false)[$relatedKey] ?? null;
if ($id !== null) { $relatedMap[(string)$id] = $r; }
}
foreach ($parents as $p) {
$arr = $p->toArray(false);
$lk = $arr[$localKey] ?? null;
$ids = ($lk !== null && isset($byParent[(string)$lk])) ? $byParent[(string)$lk] : [];
$attached = [];
foreach ($ids as $rid) { if (isset($relatedMap[(string)$rid])) { $attached[] = $relatedMap[(string)$rid]; } }
$p->setRelation($name, $attached);
}
// Nested eager on related side
$nested = $this->withTree[$name] ?? [];
if ($nested && !empty($related)) {
$relatedDao->with($this->rebuildNestedForChild($name, $nested));
$relatedDao->attachRelations($related);
}
} }
} }
// reset eager-load request after use // reset eager-load request after use
$this->with = []; $this->with = [];
$this->withTree = [];
$this->withConstraints = [];
// do not reset relationFields here; they may be reused by subsequent loads in the same call // do not reset relationFields here; they may be reused by subsequent loads in the same call
} }
// ===== belongsToMany helpers (pivot operations) =====
/**
* Attach related ids to a parent for a belongsToMany relation.
* Returns number of rows inserted into the pivot table.
* @param string $relationName
* @param int|string $parentId
* @param array<int,int|string> $relatedIds
*/
public function attach(string $relationName, int|string $parentId, array $relatedIds): int
{
if (!$relatedIds) return 0;
$cfg = $this->relations()[$relationName] ?? null;
if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') {
throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation");
}
$pivot = (string)($cfg['pivot'] ?? ($cfg['pivotTable'] ?? ''));
$fk = (string)($cfg['foreignPivotKey'] ?? '');
$rk = (string)($cfg['relatedPivotKey'] ?? '');
if ($pivot === '' || $fk === '' || $rk === '') {
throw new \InvalidArgumentException("belongsToMany relation '{$relationName}' requires pivot, foreignPivotKey, relatedPivotKey");
}
$cols = [$fk, $rk];
$valuesSql = [];
$params = [];
foreach (array_values(array_unique($relatedIds, SORT_REGULAR)) as $i => $rid) {
$p1 = "p_fk_{$i}"; $p2 = "p_rk_{$i}";
$valuesSql[] = '(:' . $p1 . ', :' . $p2 . ')';
$params[$p1] = $parentId;
$params[$p2] = $rid;
}
$sql = 'INSERT INTO ' . $pivot . ' (' . implode(', ', $cols) . ') VALUES ' . implode(', ', $valuesSql);
return $this->connection->execute($sql, $params);
}
/**
* Detach related ids from a parent for a belongsToMany relation. If $relatedIds is empty, detaches all.
* Returns affected rows.
* @param array<int,int|string> $relatedIds
*/
public function detach(string $relationName, int|string $parentId, array $relatedIds = []): int
{
$cfg = $this->relations()[$relationName] ?? null;
if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') {
throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation");
}
$pivot = (string)($cfg['pivot'] ?? ($cfg['pivotTable'] ?? ''));
$fk = (string)($cfg['foreignPivotKey'] ?? '');
$rk = (string)($cfg['relatedPivotKey'] ?? '');
if ($pivot === '' || $fk === '' || $rk === '') {
throw new \InvalidArgumentException("belongsToMany relation '{$relationName}' requires pivot, foreignPivotKey, relatedPivotKey");
}
$where = $fk . ' = :pid';
$params = ['pid' => $parentId];
if ($relatedIds) {
$ph = [];
foreach (array_values(array_unique($relatedIds, SORT_REGULAR)) as $i => $rid) { $k = "r_$i"; $ph[] = ":$k"; $params[$k] = $rid; }
$where .= ' AND ' . $rk . ' IN (' . implode(', ', $ph) . ')';
}
$sql = 'DELETE FROM ' . $pivot . ' WHERE ' . $where;
return $this->connection->execute($sql, $params);
}
/**
* Sync the related ids set for a parent: attach missing, detach extra.
* Returns ['attached' => int, 'detached' => int].
* @param array<int,int|string> $relatedIds
* @return array{attached:int,detached:int}
*/
public function sync(string $relationName, int|string $parentId, array $relatedIds): array
{
$cfg = $this->relations()[$relationName] ?? null;
if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') {
throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation");
}
$pivot = (string)($cfg['pivot'] ?? ($cfg['pivotTable'] ?? ''));
$fk = (string)($cfg['foreignPivotKey'] ?? '');
$rk = (string)($cfg['relatedPivotKey'] ?? '');
if ($pivot === '' || $fk === '' || $rk === '') {
throw new \InvalidArgumentException("belongsToMany relation '{$relationName}' requires pivot, foreignPivotKey, relatedPivotKey");
}
// Read current related ids
$rows = $this->connection->query('SELECT ' . $rk . ' AS rk FROM ' . $pivot . ' WHERE ' . $fk . ' = :pid', ['pid' => $parentId]);
$current = [];
foreach ($rows as $r) { $v = $r['rk'] ?? null; if ($v !== null) { $current[(string)$v] = true; } }
$target = [];
foreach (array_values(array_unique($relatedIds, SORT_REGULAR)) as $v) { $target[(string)$v] = true; }
$toAttach = array_diff_key($target, $current);
$toDetach = array_diff_key($current, $target);
$attached = $toAttach ? $this->attach($relationName, $parentId, array_keys($toAttach)) : 0;
$detached = $toDetach ? $this->detach($relationName, $parentId, array_keys($toDetach)) : 0;
return ['attached' => (int)$attached, 'detached' => (int)$detached];
}
public function with(array $relations): static public function with(array $relations): static
{ {
$this->with = $relations; // Accept ['rel', 'rel.child'] or ['rel' => callable, 'rel.child' => callable]
$names = [];
$tree = [];
foreach ($relations as $key => $value) {
if (is_int($key)) { // plain name
$path = (string)$value;
$this->insertRelationPath($tree, $path);
} else { // constraint
$path = (string)$key;
if (is_callable($value)) {
$this->withConstraints[$path] = $value;
}
$this->insertRelationPath($tree, $path);
}
}
$this->withTree = $tree;
$this->with = array_keys($tree); // first-level only
return $this; return $this;
} }
@ -575,6 +804,40 @@ abstract class AbstractDao implements DaoInterface
$this->onlyTrashed = false; $this->onlyTrashed = false;
} }
// ===== with()/nested helpers =====
private function insertRelationPath(array &$tree, string $path): void
{
$parts = array_values(array_filter(explode('.', $path), fn($p) => $p !== ''));
if (!$parts) return;
$level =& $tree;
foreach ($parts as $i => $p) {
if (!isset($level[$p])) { $level[$p] = []; }
$level =& $level[$p];
}
}
/** Build child-level with() array (flattened) for a nested subtree, preserving constraints under full paths. */
private function rebuildNestedForChild(string $prefix, array $subtree): array
{
$out = [];
foreach ($subtree as $name => $child) {
$full = $prefix . '.' . $name;
// include with constraint if exists
if (isset($this->withConstraints[$full]) && is_callable($this->withConstraints[$full])) {
$out[$name] = $this->withConstraints[$full];
} else {
$out[] = $name;
}
}
return $out;
}
private function constraintForPath(string $path): mixed
{
return $this->withConstraints[$path] ?? null;
}
// ===== Schema helpers & behaviors ===== // ===== Schema helpers & behaviors =====
protected function getSchema(): array protected function getSchema(): array

View file

@ -23,6 +23,17 @@ abstract class AbstractMongoDao
/** @var array<int,string> */ /** @var array<int,string> */
private array $with = []; private array $with = [];
/**
* Nested eager-loading tree for Mongo relations, built from with() paths.
* @var array<string, array<string,mixed>>
*/
private array $withTree = [];
/**
* Per relation (and nested path) constraints. Keys are relation paths like 'posts' or 'posts.comments'.
* Values are callables(AbstractMongoDao $dao): void
* @var array<string, callable>
*/
private array $withConstraints = [];
/** @var array<string, array<int,string>> */ /** @var array<string, array<int,string>> */
private array $relationFields = []; private array $relationFields = [];
@ -353,7 +364,19 @@ abstract class AbstractMongoDao
/** Eager load relations on next find* call. */ /** Eager load relations on next find* call. */
public function with(array $relations): static public function with(array $relations): static
{ {
$this->with = $relations; // Accept ['rel', 'rel.child'] or ['rel' => callable]
$tree = [];
foreach ($relations as $key => $value) {
if (is_int($key)) {
$this->insertRelationPath($tree, (string)$value);
} else {
$path = (string)$key;
if (is_callable($value)) { $this->withConstraints[$path] = $value; }
$this->insertRelationPath($tree, $path);
}
}
$this->withTree = $tree;
$this->with = array_keys($tree);
return $this; return $this;
} }
@ -387,6 +410,9 @@ abstract class AbstractMongoDao
/** @var class-string<\Pairity\NoSql\Mongo\AbstractMongoDao> $daoClass */ /** @var class-string<\Pairity\NoSql\Mongo\AbstractMongoDao> $daoClass */
$related = new $daoClass($this->connection); $related = new $daoClass($this->connection);
// Apply per-relation constraint if provided
$constraint = $this->constraintForPath($name);
if (is_callable($constraint)) { $constraint($related); }
$relFields = $this->relationFields[$name] ?? null; $relFields = $this->relationFields[$name] ?? null;
if ($relFields) { $related->fields(...$relFields); } if ($relFields) { $related->fields(...$relFields); }
@ -418,6 +444,18 @@ abstract class AbstractMongoDao
$p->setRelation($name, $list); $p->setRelation($name, $list);
} }
} }
// Nested eager for children
$nested = $this->withTree[$name] ?? [];
if ($nested) {
$related->with($this->rebuildNestedForChild($name, $nested));
$allChildren = [];
foreach ($parents as $p) {
$val = $p->toArray(false)[$name] ?? null;
if ($val instanceof AbstractDto) { $allChildren[] = $val; }
elseif (is_array($val)) { foreach ($val as $c) { if ($c instanceof AbstractDto) { $allChildren[] = $c; } } }
}
if ($allChildren) { $related->attachRelations($allChildren); }
}
} elseif ($type === 'belongsTo') { } elseif ($type === 'belongsTo') {
$foreignKey = (string)($cfg['foreignKey'] ?? ''); // on parent $foreignKey = (string)($cfg['foreignKey'] ?? ''); // on parent
$otherKey = (string)($cfg['otherKey'] ?? '_id'); // on related $otherKey = (string)($cfg['otherKey'] ?? '_id'); // on related
@ -441,10 +479,23 @@ abstract class AbstractMongoDao
$fk = isset($arr[$foreignKey]) ? (string)$arr[$foreignKey] : null; $fk = isset($arr[$foreignKey]) ? (string)$arr[$foreignKey] : null;
$p->setRelation($name, ($fk !== null && isset($byId[$fk])) ? $byId[$fk] : null); $p->setRelation($name, ($fk !== null && isset($byId[$fk])) ? $byId[$fk] : null);
} }
// Nested eager for owner
$nested = $this->withTree[$name] ?? [];
if ($nested) {
$related->with($this->rebuildNestedForChild($name, $nested));
$allOwners = [];
foreach ($parents as $p) {
$val = $p->toArray(false)[$name] ?? null;
if ($val instanceof AbstractDto) { $allOwners[] = $val; }
}
if ($allOwners) { $related->attachRelations($allOwners); }
}
} }
} }
// reset eager-load request // reset eager-load request
$this->with = []; $this->with = [];
$this->withTree = [];
$this->withConstraints = [];
// keep relationFields for potential subsequent relation loads within same high-level call // keep relationFields for potential subsequent relation loads within same high-level call
} }
@ -453,4 +504,33 @@ abstract class AbstractMongoDao
{ {
return $this->relations(); return $this->relations();
} }
// ===== with()/nested helpers =====
private function insertRelationPath(array &$tree, string $path): void
{
$parts = array_values(array_filter(explode('.', $path), fn($p) => $p !== ''));
if (!$parts) return;
$level =& $tree;
foreach ($parts as $p) {
if (!isset($level[$p])) { $level[$p] = []; }
$level =& $level[$p];
}
}
private function rebuildNestedForChild(string $prefix, array $subtree): array
{
$out = [];
foreach ($subtree as $name => $child) {
$full = $prefix . '.' . $name;
if (isset($this->withConstraints[$full]) && is_callable($this->withConstraints[$full])) {
$out[$name] = $this->withConstraints[$full];
} else { $out[] = $name; }
}
return $out;
}
private function constraintForPath(string $path): mixed
{
return $this->withConstraints[$path] ?? null;
}
} }

View file

@ -0,0 +1,114 @@
<?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 BelongsToManyMysqlTest extends TestCase
{
private function mysqlConfig(): array
{
$host = getenv('MYSQL_HOST') ?: null;
if (!$host) {
$this->markTestSkipped('MYSQL_HOST not set; skipping MySQL belongsToMany 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 testBelongsToManyEagerAndHelpers(): void
{
$cfg = $this->mysqlConfig();
$conn = ConnectionManager::make($cfg);
$schema = SchemaManager::forConnection($conn);
// unique table names per run
$suffix = substr(sha1((string)microtime(true)), 0, 6);
$usersT = 'u_btm_' . $suffix;
$rolesT = 'r_btm_' . $suffix;
$pivotT = 'ur_btm_' . $suffix;
// Create tables
$schema->create($usersT, function (Blueprint $t) { $t->increments('id'); $t->string('email', 190); });
$schema->create($rolesT, function (Blueprint $t) { $t->increments('id'); $t->string('name', 190); });
$conn->execute("CREATE TABLE `{$pivotT}` (user_id INT NOT NULL, role_id INT NOT NULL)");
// DTOs
$UserDto = new class([]) extends AbstractDto {};
$RoleDto = new class([]) extends AbstractDto {};
$userDto = get_class($UserDto); $roleDto = get_class($RoleDto);
// DAOs
$RoleDao = new class($conn, $rolesT, $roleDto) 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'],'name'=>['cast'=>'string']]]; }
};
$UserDao = new class($conn, $usersT, $userDto, get_class($RoleDao), $pivotT) extends AbstractDao {
private string $table; private string $dto; private string $roleDaoClass; private string $pivot;
public function __construct($c, string $table, string $dto, string $roleDaoClass, string $pivot) { parent::__construct($c); $this->table=$table; $this->dto=$dto; $this->roleDaoClass=$roleDaoClass; $this->pivot=$pivot; }
public function getTable(): string { return $this->table; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array {
return [
'roles' => [
'type' => 'belongsToMany',
'dao' => $this->roleDaoClass,
'pivot' => $this->pivot,
'foreignPivotKey' => 'user_id',
'relatedPivotKey' => 'role_id',
'localKey' => 'id',
'relatedKey' => 'id',
],
];
}
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string']]]; }
};
$roleDao = new $RoleDao($conn, $rolesT, $roleDto);
$userDao = new $UserDao($conn, $usersT, $userDto, get_class($roleDao), $pivotT);
// Seed
$u = $userDao->insert(['email' => 'b@example.com']);
$uid = (int)($u->toArray(false)['id'] ?? 0);
$r1 = $roleDao->insert(['name' => 'admin']);
$r2 = $roleDao->insert(['name' => 'editor']);
$rid1 = (int)($r1->toArray(false)['id'] ?? 0); $rid2 = (int)($r2->toArray(false)['id'] ?? 0);
$userDao->attach('roles', $uid, [$rid1, $rid2]);
$loaded = $userDao->with(['roles'])->findById($uid);
$this->assertNotNull($loaded);
$this->assertCount(2, $loaded->toArray(false)['roles'] ?? []);
$userDao->detach('roles', $uid, [$rid1]);
$re = $userDao->with(['roles'])->findById($uid);
$this->assertCount(1, $re->toArray(false)['roles'] ?? []);
$userDao->sync('roles', $uid, [$rid2]);
$re2 = $userDao->with(['roles'])->findById($uid);
$this->assertCount(1, $re2->toArray(false)['roles'] ?? []);
// Cleanup
$schema->drop($usersT);
$schema->drop($rolesT);
$conn->execute('DROP TABLE `' . $pivotT . '`');
}
}

View file

@ -0,0 +1,96 @@
<?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 BelongsToManySqliteTest extends TestCase
{
private function conn()
{
return ConnectionManager::make(['driver' => 'sqlite', 'path' => ':memory:']);
}
public function testBelongsToManyEagerAndPivotHelpers(): void
{
$conn = $this->conn();
// schema
$conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT)');
$conn->execute('CREATE TABLE roles (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)');
$conn->execute('CREATE TABLE user_role (user_id INTEGER, role_id INTEGER)');
// DTOs
$UserDto = new class([]) extends AbstractDto {};
$RoleDto = new class([]) extends AbstractDto {};
$userDtoClass = get_class($UserDto);
$roleDtoClass = get_class($RoleDto);
// DAOs
$RoleDao = new class($conn, $roleDtoClass) extends AbstractDao {
private string $dto;
public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
public function getTable(): string { return 'roles'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; }
};
$UserDao = new class($conn, $userDtoClass, get_class($RoleDao)) extends AbstractDao {
private string $dto; private string $roleDaoClass;
public function __construct($c, string $dto, string $roleDaoClass) { parent::__construct($c); $this->dto = $dto; $this->roleDaoClass = $roleDaoClass; }
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string']]]; }
protected function relations(): array {
return [
'roles' => [
'type' => 'belongsToMany',
'dao' => $this->roleDaoClass,
'pivot' => 'user_role',
'foreignPivotKey' => 'user_id',
'relatedPivotKey' => 'role_id',
'localKey' => 'id',
'relatedKey' => 'id',
],
];
}
};
$roleDao = new $RoleDao($conn, $roleDtoClass);
$userDao = new $UserDao($conn, $userDtoClass, get_class($roleDao));
// seed
$u = $userDao->insert(['email' => 'p@example.com']);
$uid = (int)($u->toArray(false)['id'] ?? 0);
$r1 = $roleDao->insert(['name' => 'admin']);
$r2 = $roleDao->insert(['name' => 'editor']);
$rid1 = (int)($r1->toArray(false)['id'] ?? 0);
$rid2 = (int)($r2->toArray(false)['id'] ?? 0);
// attach via helper
$userDao->attach('roles', $uid, [$rid1, $rid2]);
// eager load roles
$loaded = $userDao->with(['roles'])->findById($uid);
$this->assertNotNull($loaded);
$roles = $loaded->toArray(false)['roles'] ?? [];
$this->assertIsArray($roles);
$this->assertCount(2, $roles);
// detach one
$det = $userDao->detach('roles', $uid, [$rid1]);
$this->assertGreaterThanOrEqual(1, $det);
$reloaded = $userDao->with(['roles'])->findById($uid);
$this->assertCount(1, $reloaded->toArray(false)['roles'] ?? []);
// sync to only [rid2]
$res = $userDao->sync('roles', $uid, [$rid2]);
$this->assertIsArray($res);
$synced = $userDao->with(['roles'])->findById($uid);
$this->assertCount(1, $synced->toArray(false)['roles'] ?? []);
}
}

View file

@ -0,0 +1,112 @@
<?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 RelationsNestedConstraintsSqliteTest extends TestCase
{
private function conn()
{
return ConnectionManager::make(['driver' => 'sqlite', 'path' => ':memory:']);
}
public function testNestedEagerAndPerRelationFieldsConstraint(): 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)');
$conn->execute('CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER, body TEXT)');
// DTOs
$UserDto = new class([]) extends AbstractDto {};
$PostDto = new class([]) extends AbstractDto {};
$CommentDto = new class([]) extends AbstractDto {};
$uClass = get_class($UserDto); $pClass = get_class($PostDto); $cClass = get_class($CommentDto);
// DAOs
$CommentDao = new class($conn, $cClass) extends AbstractDao {
private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
public function getTable(): string { return 'comments'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'post_id'=>['cast'=>'int'],'body'=>['cast'=>'string']]]; }
};
$PostDao = new class($conn, $pClass, get_class($CommentDao)) extends AbstractDao {
private string $dto; private string $commentDaoClass;
public function __construct($c, string $dto, string $cd) { parent::__construct($c); $this->dto = $dto; $this->commentDaoClass = $cd; }
public function getTable(): string { return 'posts'; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array {
return [
'comments' => [
'type' => 'hasMany',
'dao' => $this->commentDaoClass,
'foreignKey' => 'post_id',
'localKey' => 'id',
],
];
}
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 $pd) { parent::__construct($c); $this->dto = $dto; $this->postDaoClass = $pd; }
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']]]; }
};
$commentDao = new $CommentDao($conn, $cClass);
$postDao = new $PostDao($conn, $pClass, get_class($commentDao));
$userDao = new $UserDao($conn, $uClass, get_class($postDao));
// seed
$u = $userDao->insert(['name' => 'Alice']);
$uid = (int)($u->toArray(false)['id'] ?? 0);
$p1 = $postDao->insert(['user_id' => $uid, 'title' => 'P1']);
$p2 = $postDao->insert(['user_id' => $uid, 'title' => 'P2']);
$pid1 = (int)($p1->toArray(false)['id'] ?? 0); $pid2 = (int)($p2->toArray(false)['id'] ?? 0);
$commentDao->insert(['post_id' => $pid1, 'body' => 'c1']);
$commentDao->insert(['post_id' => $pid1, 'body' => 'c2']);
$commentDao->insert(['post_id' => $pid2, 'body' => 'c3']);
// nested eager with per-relation fields constraint (SQL supports fields projection)
$users = $userDao
->with([
'posts' => function (AbstractDao $dao) { $dao->fields('id', 'title'); },
'posts.comments' // nested
])
->findAllBy(['id' => $uid]);
$this->assertNotEmpty($users);
$user = $users[0];
$posts = $user->toArray(false)['posts'] ?? [];
$this->assertIsArray($posts);
$this->assertCount(2, $posts);
// ensure projection respected on posts (no user_id expected)
$this->assertArrayHasKey('title', $posts[0]->toArray(false));
$this->assertArrayNotHasKey('user_id', $posts[0]->toArray(false));
// nested comments should exist
$cm = $posts[0]->toArray(false)['comments'] ?? [];
$this->assertIsArray($cm);
$this->assertNotEmpty($cm);
}
}