Pairity/src/NoSql/Mongo/AbstractMongoDao.php

724 lines
29 KiB
PHP
Raw Normal View History

2025-12-10 14:02:24 +00:00
<?php
namespace Pairity\NoSql\Mongo;
use Pairity\Model\AbstractDto;
2025-12-10 14:36:11 +00:00
use Pairity\Orm\UnitOfWork;
use Pairity\Events\Events;
2025-12-10 14:02:24 +00:00
/**
* Base DAO for MongoDB collections returning DTOs.
*
* Usage: extend and implement collection() + dtoClass().
*/
abstract class AbstractMongoDao
{
protected MongoConnectionInterface $connection;
/** @var array<int,string>|null */
private ?array $projection = null; // list of field names to include
/** @var array<string,int> */
private array $sortSpec = [];
private ?int $limitVal = null;
private ?int $skipVal = null;
/** @var array<int,string> */
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 = [];
2025-12-10 14:02:24 +00:00
/** @var array<string, array<int,string>> */
private array $relationFields = [];
2025-12-10 20:42:37 +00:00
/** Scopes (MVP) */
/** @var array<int, callable> */
private array $runtimeScopes = [];
/** @var array<string, callable> */
private array $namedScopes = [];
2025-12-10 14:02:24 +00:00
public function __construct(MongoConnectionInterface $connection)
{
$this->connection = $connection;
}
/** Collection name (e.g., "users"). */
abstract protected function collection(): string;
/** @return class-string<AbstractDto> */
abstract protected function dtoClass(): string;
/** Access to underlying connection. */
public function getConnection(): MongoConnectionInterface
{
return $this->connection;
}
/** Relation metadata (MVP). Override in concrete DAO. */
protected function relations(): array
{
return [];
}
// ========= Query modifiers =========
/**
* Specify projection fields to include on base entity and optionally on relations via dot-notation.
* Example: fields('email','name','posts.title')
*/
public function fields(string ...$fields): static
{
$base = [];
foreach ($fields as $f) {
$f = (string)$f;
if ($f === '') continue;
if (str_contains($f, '.')) {
[$rel, $col] = explode('.', $f, 2);
if ($rel !== '' && $col !== '') {
$this->relationFields[$rel][] = $col;
}
} else {
$base[] = $f;
}
}
$this->projection = $base ?: null;
return $this;
}
/** Sorting spec, e.g., sort(['created_at' => -1]) */
public function sort(array $spec): static
{
// sanitize values to 1 or -1
$out = [];
foreach ($spec as $k => $v) {
$out[(string)$k] = ((int)$v) < 0 ? -1 : 1;
}
$this->sortSpec = $out;
return $this;
}
public function limit(int $n): static
{
$this->limitVal = max(0, $n);
return $this;
}
public function skip(int $n): static
{
$this->skipVal = max(0, $n);
return $this;
}
// ========= CRUD =========
/** @param array<string,mixed>|Filter $filter */
public function findOneBy(array|Filter $filter): ?AbstractDto
{
2025-12-10 20:42:37 +00:00
$filterArr = $this->normalizeFilterInput($filter);
// Events: dao.beforeFind (Mongo) — allow filter mutation
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$filterArr]; Events::dispatcher()->dispatch('dao.beforeFind', $ev); } catch (\Throwable) {}
2025-12-10 20:42:37 +00:00
$this->applyRuntimeScopesToFilter($filterArr);
2025-12-10 14:02:24 +00:00
$opts = $this->buildOptions();
$opts['limit'] = 1;
2025-12-10 20:42:37 +00:00
$docs = $this->connection->find($this->databaseName(), $this->collection(), $filterArr, $opts);
2025-12-10 14:02:24 +00:00
$this->resetModifiers();
2025-12-10 20:42:37 +00:00
$this->resetRuntimeScopes();
2025-12-10 14:02:24 +00:00
$row = $docs[0] ?? null;
$dto = $row ? $this->hydrate($row) : null;
try { $payload = ['dao' => $this, 'collection' => $this->collection(), 'dto' => $dto]; Events::dispatcher()->dispatch('dao.afterFind', $payload); } catch (\Throwable) {}
return $dto;
2025-12-10 14:02:24 +00:00
}
/**
* @param array<string,mixed>|Filter $filter
* @param array<string,mixed> $options Additional options (merged after internal modifiers)
* @return array<int,AbstractDto>
*/
public function findAllBy(array|Filter $filter = [], array $options = []): array
{
2025-12-10 20:42:37 +00:00
$filterArr = $this->normalizeFilterInput($filter);
// Events: dao.beforeFind (Mongo)
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$filterArr]; Events::dispatcher()->dispatch('dao.beforeFind', $ev); } catch (\Throwable) {}
2025-12-10 20:42:37 +00:00
$this->applyRuntimeScopesToFilter($filterArr);
2025-12-10 14:02:24 +00:00
$opts = $this->buildOptions();
// external override/merge
foreach ($options as $k => $v) { $opts[$k] = $v; }
2025-12-10 20:42:37 +00:00
$docs = $this->connection->find($this->databaseName(), $this->collection(), $filterArr, $opts);
2025-12-10 14:02:24 +00:00
$dtos = array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []);
if ($dtos && $this->with) {
$this->attachRelations($dtos);
}
$this->resetModifiers();
2025-12-10 20:42:37 +00:00
$this->resetRuntimeScopes();
try { $payload = ['dao' => $this, 'collection' => $this->collection(), 'dtos' => $dtos]; Events::dispatcher()->dispatch('dao.afterFind', $payload); } catch (\Throwable) {}
2025-12-10 14:02:24 +00:00
return $dtos;
}
public function findById(string $id): ?AbstractDto
{
2025-12-10 14:36:11 +00:00
$uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) {
$managed = $uow->get(static::class, (string)$id);
if ($managed instanceof AbstractDto) {
return $managed;
}
}
2025-12-10 14:02:24 +00:00
return $this->findOneBy(['_id' => $id]);
}
/** @param array<string,mixed> $data */
public function insert(array $data): AbstractDto
{
2025-12-10 14:36:11 +00:00
// Inserts remain immediate to obtain a real id, even under UoW
// Events: dao.beforeInsert (Mongo) — allow mutation of $data
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'data' => &$data]; Events::dispatcher()->dispatch('dao.beforeInsert', $ev); } catch (\Throwable) {}
2025-12-10 14:36:11 +00:00
$id = UnitOfWork::suspendDuring(function () use ($data) {
return $this->connection->insertOne($this->databaseName(), $this->collection(), $data);
});
$dto = $this->findById($id) ?? $this->hydrate(array_merge($data, ['_id' => $id]));
try { $payload = ['dao' => $this, 'collection' => $this->collection(), 'dto' => $dto]; Events::dispatcher()->dispatch('dao.afterInsert', $payload); } catch (\Throwable) {}
return $dto;
2025-12-10 14:02:24 +00:00
}
/** @param array<string,mixed> $data */
public function update(string $id, array $data): AbstractDto
{
2025-12-10 14:36:11 +00:00
$uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) {
$self = $this; $conn = $this->connection; $theId = $id; $payload = $data;
// Events: dao.beforeUpdate (Mongo)
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id, 'data' => &$payload]; Events::dispatcher()->dispatch('dao.beforeUpdate', $ev); } catch (\Throwable) {}
$uow->enqueueWithMeta($conn, [
'type' => 'update',
'mode' => 'byId',
'dao' => $this,
'id' => (string)$id,
], function () use ($self, $theId, $payload) {
2025-12-10 14:36:11 +00:00
UnitOfWork::suspendDuring(function () use ($self, $theId, $payload) {
$self->doImmediateUpdateWithLock($theId, $payload);
2025-12-10 14:36:11 +00:00
});
});
$base = $this->findById($id)?->toArray(false) ?? [];
$result = array_merge($base, $data, ['_id' => $id]);
$dto = $this->hydrate($result);
try { $p = ['dao' => $this, 'collection' => $this->collection(), 'dto' => $dto]; Events::dispatcher()->dispatch('dao.afterUpdate', $p); } catch (\Throwable) {}
return $dto;
2025-12-10 14:36:11 +00:00
}
// Events: dao.beforeUpdate (Mongo)
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id, 'data' => &$data]; Events::dispatcher()->dispatch('dao.beforeUpdate', $ev); } catch (\Throwable) {}
$this->doImmediateUpdateWithLock($id, $data);
$dto = $this->findById($id) ?? $this->hydrate(array_merge($data, ['_id' => $id]));
try { $p = ['dao' => $this, 'collection' => $this->collection(), 'dto' => $dto]; Events::dispatcher()->dispatch('dao.afterUpdate', $p); } catch (\Throwable) {}
return $dto;
2025-12-10 14:02:24 +00:00
}
public function deleteById(string $id): int
{
2025-12-10 14:36:11 +00:00
$uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) {
$self = $this; $conn = $this->connection; $theId = $id;
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id]; Events::dispatcher()->dispatch('dao.beforeDelete', $ev); } catch (\Throwable) {}
$uow->enqueueWithMeta($conn, [
'type' => 'delete',
'mode' => 'byId',
'dao' => $this,
'id' => (string)$id,
], function () use ($self, $theId) {
2025-12-10 14:36:11 +00:00
UnitOfWork::suspendDuring(function () use ($self, $theId) {
$self->getConnection()->deleteOne($self->databaseName(), $self->collection(), ['_id' => $theId]);
});
});
return 0;
}
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id]; Events::dispatcher()->dispatch('dao.beforeDelete', $ev); } catch (\Throwable) {}
$affected = $this->connection->deleteOne($this->databaseName(), $this->collection(), ['_id' => $id]);
try { $p = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id, 'affected' => $affected]; Events::dispatcher()->dispatch('dao.afterDelete', $p); } catch (\Throwable) {}
return $affected;
2025-12-10 14:02:24 +00:00
}
/** @param array<string,mixed>|Filter $filter */
public function deleteBy(array|Filter $filter): int
{
2025-12-10 14:36:11 +00:00
$uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) {
$self = $this; $conn = $this->connection; $flt = $this->normalizeFilterInput($filter);
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$flt]; Events::dispatcher()->dispatch('dao.beforeDelete', $ev); } catch (\Throwable) {}
$uow->enqueueWithMeta($conn, [
'type' => 'delete',
'mode' => 'byCriteria',
'dao' => $this,
'criteria' => $flt,
], function () use ($self, $flt) {
2025-12-10 14:36:11 +00:00
UnitOfWork::suspendDuring(function () use ($self, $flt) {
$self->getConnection()->deleteOne($self->databaseName(), $self->collection(), $flt);
});
});
return 0;
}
2025-12-10 14:02:24 +00:00
// For MVP provide deleteOne semantic; bulk deletes could be added later
2025-12-10 20:42:37 +00:00
$flt = $this->normalizeFilterInput($filter);
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$flt]; Events::dispatcher()->dispatch('dao.beforeDelete', $ev); } catch (\Throwable) {}
2025-12-10 20:42:37 +00:00
$this->applyRuntimeScopesToFilter($flt);
$res = $this->connection->deleteOne($this->databaseName(), $this->collection(), $flt);
try { $p = ['dao' => $this, 'collection' => $this->collection(), 'filter' => $flt, 'affected' => $res]; Events::dispatcher()->dispatch('dao.afterDelete', $p); } catch (\Throwable) {}
2025-12-10 20:42:37 +00:00
$this->resetRuntimeScopes();
return $res;
2025-12-10 14:02:24 +00:00
}
/** Upsert by id convenience. */
public function upsertById(string $id, array $data): string
{
return $this->connection->upsertOne($this->databaseName(), $this->collection(), ['_id' => $id], ['$set' => $data]);
}
/** @param array<string,mixed>|Filter $filter @param array<string,mixed> $update */
public function upsertBy(array|Filter $filter, array $update): string
{
return $this->connection->upsertOne($this->databaseName(), $this->collection(), $this->normalizeFilterInput($filter), $update);
}
/**
* Fetch related docs where a field is within the given set of values.
* @param string $field
* @param array<int,string> $values
* @return array<int,AbstractDto>
*/
public function findAllWhereIn(string $field, array $values): array
{
if (!$values) return [];
// Normalize values (unique)
$values = array_values(array_unique($values));
2025-12-10 20:42:37 +00:00
$filter = [ $field => ['$in' => $values] ];
$this->applyRuntimeScopesToFilter($filter);
2025-12-10 14:02:24 +00:00
$opts = $this->buildOptions();
2025-12-10 20:42:37 +00:00
$docs = $this->connection->find($this->databaseName(), $this->collection(), $filter, $opts);
2025-12-10 14:02:24 +00:00
return array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []);
}
// ========= Dynamic helpers =========
public function __call(string $name, array $arguments): mixed
{
if (preg_match('/^(findOneBy|findAllBy|updateBy|deleteBy)([A-Z][A-Za-z0-9_]*)$/', $name, $m)) {
$op = $m[1];
$col = $this->normalizeColumn($m[2]);
switch ($op) {
case 'findOneBy':
return $this->findOneBy([$col => $arguments[0] ?? null]);
case 'findAllBy':
return $this->findAllBy([$col => $arguments[0] ?? null]);
case 'updateBy':
$value = $arguments[0] ?? null;
$data = $arguments[1] ?? [];
if (!is_array($data)) {
throw new \InvalidArgumentException('updateBy* expects second argument as array $data');
}
$one = $this->findOneBy([$col => $value]);
if (!$one) { return 0; }
$id = (string)($one->toArray(false)['_id'] ?? '');
$this->update($id, $data);
return 1;
case 'deleteBy':
return $this->deleteBy([$col => $arguments[0] ?? null]);
}
}
2025-12-10 20:42:37 +00:00
// Named scope invocation
if (isset($this->namedScopes[$name]) && is_callable($this->namedScopes[$name])) {
$callable = $this->namedScopes[$name];
$this->runtimeScopes[] = function (&$filter) use ($callable, $arguments) {
$callable($filter, ...$arguments);
};
return $this;
}
2025-12-10 14:02:24 +00:00
throw new \BadMethodCallException(static::class . "::{$name} does not exist");
}
// ========= Internals =========
protected function normalizeColumn(string $studly): string
{
$snake = preg_replace('/(?<!^)[A-Z]/', '_$0', $studly) ?? $studly;
return strtolower($snake);
}
protected function hydrate(array $doc): AbstractDto
{
// Ensure _id is a string for DTO friendliness
if (isset($doc['_id']) && !is_string($doc['_id'])) {
$doc['_id'] = (string)$doc['_id'];
}
$class = $this->dtoClass();
/** @var AbstractDto $dto */
$dto = $class::fromArray($doc);
2025-12-10 14:36:11 +00:00
$uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) {
$idVal = $doc['_id'] ?? null;
if ($idVal !== null) {
$uow->attach(static::class, (string)$idVal, $dto);
// Bind this DAO to allow snapshot diffing to emit updates
$uow->bindDao(static::class, (string)$idVal, $this);
2025-12-10 14:36:11 +00:00
}
}
2025-12-10 14:02:24 +00:00
return $dto;
}
/** @param array<string,mixed>|Filter $filter */
private function normalizeFilterInput(array|Filter $filter): array
{
if ($filter instanceof Filter) {
return $filter->toArray();
}
return $filter;
}
/** Build MongoDB driver options from current modifiers. */
private function buildOptions(): array
{
$opts = [];
if ($this->projection) {
$proj = [];
foreach ($this->projection as $f) { $proj[$f] = 1; }
$opts['projection'] = $proj;
}
if ($this->sortSpec) { $opts['sort'] = $this->sortSpec; }
if ($this->limitVal !== null) { $opts['limit'] = $this->limitVal; }
if ($this->skipVal !== null) { $opts['skip'] = $this->skipVal; }
return $opts;
}
2025-12-10 20:42:37 +00:00
/**
* Paginate results.
* @return array{data:array<int,AbstractDto>,total:int,perPage:int,currentPage:int,lastPage:int}
*/
public function paginate(int $page, int $perPage = 15, array|Filter $filter = []): array
{
$page = max(1, $page);
$perPage = max(1, $perPage);
$flt = $this->normalizeFilterInput($filter);
$this->applyRuntimeScopesToFilter($flt);
// Total via aggregation count
$pipeline = [];
if (!empty($flt)) { $pipeline[] = ['$match' => $flt]; }
$pipeline[] = ['$count' => 'cnt'];
$agg = $this->connection->aggregate($this->databaseName(), $this->collection(), $pipeline, []);
$arr = is_iterable($agg) ? iterator_to_array($agg, false) : (array)$agg;
$total = (int)($arr[0]['cnt'] ?? 0);
// Page data
$opts = $this->buildOptions();
$opts['limit'] = $perPage;
$opts['skip'] = ($page - 1) * $perPage;
$docs = $this->connection->find($this->databaseName(), $this->collection(), $flt, $opts);
$dtos = array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []);
if ($dtos && $this->with) { $this->attachRelations($dtos); }
$this->resetModifiers();
$this->resetRuntimeScopes();
$lastPage = (int)max(1, (int)ceil($total / $perPage));
return [
'data' => $dtos,
'total' => $total,
'perPage' => $perPage,
'currentPage' => $page,
'lastPage' => $lastPage,
];
}
/** Simple pagination without total; returns nextPage if more likely exists. */
public function simplePaginate(int $page, int $perPage = 15, array|Filter $filter = []): array
{
$page = max(1, $page);
$perPage = max(1, $perPage);
$flt = $this->normalizeFilterInput($filter);
$this->applyRuntimeScopesToFilter($flt);
$opts = $this->buildOptions();
$opts['limit'] = $perPage + 1; // fetch one extra
$opts['skip'] = ($page - 1) * $perPage;
$docs = $this->connection->find($this->databaseName(), $this->collection(), $flt, $opts);
$docsArr = is_iterable($docs) ? iterator_to_array($docs, false) : (array)$docs;
$hasMore = count($docsArr) > $perPage;
if ($hasMore) { array_pop($docsArr); }
$dtos = array_map(fn($d) => $this->hydrate($d), $docsArr);
if ($dtos && $this->with) { $this->attachRelations($dtos); }
$this->resetModifiers();
$this->resetRuntimeScopes();
return [
'data' => $dtos,
'perPage' => $perPage,
'currentPage' => $page,
'nextPage' => $hasMore ? $page + 1 : null,
];
}
2025-12-10 14:02:24 +00:00
private function resetModifiers(): void
{
$this->projection = null;
$this->sortSpec = [];
$this->limitVal = null;
$this->skipVal = null;
$this->with = [];
$this->relationFields = [];
}
/** Resolve database name from collection string if provided as db.collection; else default to 'app'. */
private function databaseName(): string
{
// Allow subclasses to define "db.collection" in collection() if they want to target a specific DB quickly
$col = $this->collection();
if (str_contains($col, '.')) {
return explode('.', $col, 2)[0];
}
return 'app';
}
// ===== Relations (MVP) =====
/** Eager load relations on next find* call. */
public function with(array $relations): static
{
// 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);
2025-12-10 14:02:24 +00:00
return $this;
}
/** Lazy load a single relation for one DTO. */
public function load(AbstractDto $dto, string $relation): void
{
$this->with([$relation]);
$this->attachRelations([$dto]);
// do not call resetModifiers here to avoid wiping user sort/limit; with() is cleared in attachRelations
}
/** @param array<int,AbstractDto> $dtos */
public function loadMany(array $dtos, string $relation): void
{
if (!$dtos) return;
$this->with([$relation]);
$this->attachRelations($dtos);
}
/** @param array<int,AbstractDto> $parents */
protected function attachRelations(array $parents): void
{
if (!$parents) return;
$relations = $this->relations();
foreach ($this->with as $name) {
if (!isset($relations[$name])) continue;
$cfg = $relations[$name];
$type = (string)($cfg['type'] ?? '');
$daoClass = $cfg['dao'] ?? null;
if (!is_string($daoClass) || $type === '') continue;
/** @var class-string<\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); }
2025-12-10 14:02:24 +00:00
$relFields = $this->relationFields[$name] ?? null;
if ($relFields) { $related->fields(...$relFields); }
if ($type === 'hasMany' || $type === 'hasOne') {
$foreignKey = (string)($cfg['foreignKey'] ?? ''); // on child
$localKey = (string)($cfg['localKey'] ?? '_id'); // on parent
if ($foreignKey === '') continue;
$keys = [];
foreach ($parents as $p) {
$arr = $p->toArray(false);
if (isset($arr[$localKey])) { $keys[] = (string)$arr[$localKey]; }
}
if (!$keys) continue;
$children = $related->findAllWhereIn($foreignKey, $keys);
$grouped = [];
foreach ($children as $child) {
$fk = $child->toArray(false)[$foreignKey] ?? null;
if ($fk !== null) { $grouped[(string)$fk][] = $child; }
}
foreach ($parents as $p) {
$arr = $p->toArray(false);
$key = isset($arr[$localKey]) ? (string)$arr[$localKey] : null;
$list = ($key !== null && isset($grouped[$key])) ? $grouped[$key] : [];
if ($type === 'hasOne') {
$p->setRelation($name, $list[0] ?? null);
} else {
$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); }
}
2025-12-10 14:02:24 +00:00
} elseif ($type === 'belongsTo') {
$foreignKey = (string)($cfg['foreignKey'] ?? ''); // on parent
$otherKey = (string)($cfg['otherKey'] ?? '_id'); // on related
if ($foreignKey === '') continue;
$ownerIds = [];
foreach ($parents as $p) {
$arr = $p->toArray(false);
if (isset($arr[$foreignKey])) { $ownerIds[] = (string)$arr[$foreignKey]; }
}
if (!$ownerIds) continue;
$owners = $related->findAllWhereIn($otherKey, $ownerIds);
$byId = [];
foreach ($owners as $o) {
$id = $o->toArray(false)[$otherKey] ?? null;
if ($id !== null) { $byId[(string)$id] = $o; }
}
foreach ($parents as $p) {
$arr = $p->toArray(false);
$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); }
}
2025-12-10 14:02:24 +00:00
}
}
// reset eager-load request
$this->with = [];
$this->withTree = [];
$this->withConstraints = [];
2025-12-10 14:02:24 +00:00
// keep relationFields for potential subsequent relation loads within same high-level call
}
/** Expose relation metadata for UoW ordering/cascades. */
public function relationMap(): array
{
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;
}
2025-12-10 20:42:37 +00:00
// ===== Scopes (MVP) =====
/** Register a named scope callable: function(array &$filter, ...$args): void */
public function registerScope(string $name, callable $fn): static
{
$this->namedScopes[$name] = $fn;
return $this;
}
/** Add an ad-hoc scope callable(array &$filter): void for next query. */
public function scope(callable $fn): static
{
$this->runtimeScopes[] = $fn;
return $this;
}
/** @param array<string,mixed> $filter */
private function applyRuntimeScopesToFilter(array &$filter): void
{
if (!$this->runtimeScopes) return;
foreach ($this->runtimeScopes as $fn) {
try { $fn($filter); } catch (\Throwable) {}
}
}
private function resetRuntimeScopes(): void
{
$this->runtimeScopes = [];
}
// ===== Optimistic locking (MVP) for Mongo =====
/**
* Override to enable locking. Example return:
* ['type' => 'version', 'column' => '_v']
* Currently only 'version' (numeric increment) is supported for Mongo.
* @return array{type:string,column:string}|array{}
*/
protected function locking(): array { return []; }
private function hasOptimisticLocking(): bool
{
$cfg = $this->locking();
return is_array($cfg) && isset($cfg['type'], $cfg['column']) && $cfg['type'] === 'version' && is_string($cfg['column']) && $cfg['column'] !== '';
}
private function doImmediateUpdateWithLock(string $id, array $payload): void
{
if (!$this->hasOptimisticLocking()) {
$this->connection->updateOne($this->databaseName(), $this->collection(), ['_id' => $id], ['$set' => $payload]);
return;
}
$cfg = $this->locking();
$col = (string)$cfg['column'];
// Fetch current version
$docs = $this->connection->find($this->databaseName(), $this->collection(), ['_id' => $id], ['limit' => 1, 'projection' => [$col => 1]]);
$cur = $docs[0][$col] ?? null;
$filter = ['_id' => $id];
if ($cur !== null) { $filter[$col] = $cur; }
$update = ['$set' => $payload, '$inc' => [$col => 1]];
$modified = $this->connection->updateOne($this->databaseName(), $this->collection(), $filter, $update);
if ($cur !== null && $modified === 0) {
throw new \Pairity\Orm\OptimisticLockException('Optimistic lock failed for ' . static::class . ' id=' . $id);
}
}
2025-12-10 14:02:24 +00:00
}