From f21df4f567b1606baffd5b28f6adffea33bf3837 Mon Sep 17 00:00:00 2001 From: Funky Waddle Date: Wed, 10 Dec 2025 09:17:55 -0600 Subject: [PATCH] =?UTF-8?q?Relations=20Phase=201=20(belongsToMany,=20neste?= =?UTF-8?q?d=20eager,=20per=E2=80=91relation=20constraints)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 82 ++++++ examples/mysql_relations_pivot.php | 94 +++++++ src/Model/AbstractDao.php | 265 +++++++++++++++++- src/NoSql/Mongo/AbstractMongoDao.php | 82 +++++- tests/BelongsToManyMysqlTest.php | 114 ++++++++ tests/BelongsToManySqliteTest.php | 96 +++++++ .../RelationsNestedConstraintsSqliteTest.php | 112 ++++++++ 7 files changed, 843 insertions(+), 2 deletions(-) create mode 100644 examples/mysql_relations_pivot.php create mode 100644 tests/BelongsToManyMysqlTest.php create mode 100644 tests/BelongsToManySqliteTest.php create mode 100644 tests/RelationsNestedConstraintsSqliteTest.php diff --git a/README.md b/README.md index 150cf53..256bea0 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,88 @@ 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. +### 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`. + +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 user’s posts and each post’s 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. + +### Per‑relation 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 (per‑relation `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) Define schema metadata on your DAO by overriding `schema()`. The schema enables: diff --git a/examples/mysql_relations_pivot.php b/examples/mysql_relations_pivot.php new file mode 100644 index 0000000..9e451fc --- /dev/null +++ b/examples/mysql_relations_pivot.php @@ -0,0 +1,94 @@ + '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"; diff --git a/src/Model/AbstractDao.php b/src/Model/AbstractDao.php index 7f9db4d..283423f 100644 --- a/src/Model/AbstractDao.php +++ b/src/Model/AbstractDao.php @@ -16,6 +16,20 @@ abstract class AbstractDao implements DaoInterface private array $relationFields = []; /** @var array */ private array $with = []; + /** + * Nested eager-loading tree built from with() calls. + * Example: with(['posts.comments','user']) => + * [ 'posts' => ['comments' => []], 'user' => [] ] + * @var array> + */ + 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 + */ + private array $withConstraints = []; /** Soft delete include flags */ private bool $includeTrashed = false; private bool $onlyTrashed = false; @@ -458,6 +472,11 @@ abstract class AbstractDao implements DaoInterface /** @var class-string $daoClass */ $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; if ($relFields) { $relatedDao->fields(...$relFields); } @@ -492,6 +511,28 @@ abstract class AbstractDao implements DaoInterface $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') { $foreignKey = (string)($config['foreignKey'] ?? ''); // on parent $otherKey = (string)($config['otherKey'] ?? 'id'); // on related @@ -515,16 +556,204 @@ abstract class AbstractDao implements DaoInterface $fk = $arr[$foreignKey] ?? 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 $this->with = []; + $this->withTree = []; + $this->withConstraints = []; // 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 $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 $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 $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 { - $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; } @@ -575,6 +804,40 @@ abstract class AbstractDao implements DaoInterface $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 ===== protected function getSchema(): array diff --git a/src/NoSql/Mongo/AbstractMongoDao.php b/src/NoSql/Mongo/AbstractMongoDao.php index dac0357..72e565c 100644 --- a/src/NoSql/Mongo/AbstractMongoDao.php +++ b/src/NoSql/Mongo/AbstractMongoDao.php @@ -23,6 +23,17 @@ abstract class AbstractMongoDao /** @var array */ private array $with = []; + /** + * Nested eager-loading tree for Mongo relations, built from with() paths. + * @var array> + */ + 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 + */ + private array $withConstraints = []; /** @var array> */ private array $relationFields = []; @@ -353,7 +364,19 @@ abstract class AbstractMongoDao /** Eager load relations on next find* call. */ 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; } @@ -387,6 +410,9 @@ abstract class AbstractMongoDao /** @var class-string<\Pairity\NoSql\Mongo\AbstractMongoDao> $daoClass */ $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; if ($relFields) { $related->fields(...$relFields); } @@ -418,6 +444,18 @@ abstract class AbstractMongoDao $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') { $foreignKey = (string)($cfg['foreignKey'] ?? ''); // on parent $otherKey = (string)($cfg['otherKey'] ?? '_id'); // on related @@ -441,10 +479,23 @@ abstract class AbstractMongoDao $fk = isset($arr[$foreignKey]) ? (string)$arr[$foreignKey] : 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 $this->with = []; + $this->withTree = []; + $this->withConstraints = []; // keep relationFields for potential subsequent relation loads within same high-level call } @@ -453,4 +504,33 @@ abstract class AbstractMongoDao { 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; + } } diff --git a/tests/BelongsToManyMysqlTest.php b/tests/BelongsToManyMysqlTest.php new file mode 100644 index 0000000..0fe5d4c --- /dev/null +++ b/tests/BelongsToManyMysqlTest.php @@ -0,0 +1,114 @@ +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 . '`'); + } +} diff --git a/tests/BelongsToManySqliteTest.php b/tests/BelongsToManySqliteTest.php new file mode 100644 index 0000000..f92eb99 --- /dev/null +++ b/tests/BelongsToManySqliteTest.php @@ -0,0 +1,96 @@ + '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'] ?? []); + } +} diff --git a/tests/RelationsNestedConstraintsSqliteTest.php b/tests/RelationsNestedConstraintsSqliteTest.php new file mode 100644 index 0000000..f7a3f46 --- /dev/null +++ b/tests/RelationsNestedConstraintsSqliteTest.php @@ -0,0 +1,112 @@ + '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); + } +}