Relations Phase 1 (belongsToMany, nested eager, per‑relation constraints)
This commit is contained in:
parent
d8cae37a4d
commit
f21df4f567
82
README.md
82
README.md
|
|
@ -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 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)
|
## 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:
|
||||||
|
|
|
||||||
94
examples/mysql_relations_pivot.php
Normal file
94
examples/mysql_relations_pivot.php
Normal 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";
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
114
tests/BelongsToManyMysqlTest.php
Normal file
114
tests/BelongsToManyMysqlTest.php
Normal 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 . '`');
|
||||||
|
}
|
||||||
|
}
|
||||||
96
tests/BelongsToManySqliteTest.php
Normal file
96
tests/BelongsToManySqliteTest.php
Normal 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'] ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
tests/RelationsNestedConstraintsSqliteTest.php
Normal file
112
tests/RelationsNestedConstraintsSqliteTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue