|null */ private ?array $projection = null; // list of field names to include /** @var array */ private array $sortSpec = []; private ?int $limitVal = null; private ?int $skipVal = null; /** @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 = []; public function __construct(MongoConnectionInterface $connection) { $this->connection = $connection; } /** Collection name (e.g., "users"). */ abstract protected function collection(): string; /** @return class-string */ 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|Filter $filter */ public function findOneBy(array|Filter $filter): ?AbstractDto { $opts = $this->buildOptions(); $opts['limit'] = 1; $docs = $this->connection->find($this->databaseName(), $this->collection(), $this->normalizeFilterInput($filter), $opts); $this->resetModifiers(); $row = $docs[0] ?? null; return $row ? $this->hydrate($row) : null; } /** * @param array|Filter $filter * @param array $options Additional options (merged after internal modifiers) * @return array */ public function findAllBy(array|Filter $filter = [], array $options = []): array { $opts = $this->buildOptions(); // external override/merge foreach ($options as $k => $v) { $opts[$k] = $v; } $docs = $this->connection->find($this->databaseName(), $this->collection(), $this->normalizeFilterInput($filter), $opts); $dtos = array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []); if ($dtos && $this->with) { $this->attachRelations($dtos); } $this->resetModifiers(); return $dtos; } public function findById(string $id): ?AbstractDto { $uow = UnitOfWork::current(); if ($uow && !UnitOfWork::isSuspended()) { $managed = $uow->get(static::class, (string)$id); if ($managed instanceof AbstractDto) { return $managed; } } return $this->findOneBy(['_id' => $id]); } /** @param array $data */ public function insert(array $data): AbstractDto { // Inserts remain immediate to obtain a real id, even under UoW $id = UnitOfWork::suspendDuring(function () use ($data) { return $this->connection->insertOne($this->databaseName(), $this->collection(), $data); }); return $this->findById($id) ?? $this->hydrate(array_merge($data, ['_id' => $id])); } /** @param array $data */ public function update(string $id, array $data): AbstractDto { $uow = UnitOfWork::current(); if ($uow && !UnitOfWork::isSuspended()) { $self = $this; $conn = $this->connection; $theId = $id; $payload = $data; $uow->enqueueWithMeta($conn, [ 'type' => 'update', 'mode' => 'byId', 'dao' => $this, 'id' => (string)$id, ], function () use ($self, $theId, $payload) { UnitOfWork::suspendDuring(function () use ($self, $theId, $payload) { $self->getConnection()->updateOne($self->databaseName(), $self->collection(), ['_id' => $theId], ['$set' => $payload]); }); }); $base = $this->findById($id)?->toArray(false) ?? []; $result = array_merge($base, $data, ['_id' => $id]); return $this->hydrate($result); } $this->connection->updateOne($this->databaseName(), $this->collection(), ['_id' => $id], ['$set' => $data]); return $this->findById($id) ?? $this->hydrate(array_merge($data, ['_id' => $id])); } public function deleteById(string $id): int { $uow = UnitOfWork::current(); if ($uow && !UnitOfWork::isSuspended()) { $self = $this; $conn = $this->connection; $theId = $id; $uow->enqueueWithMeta($conn, [ 'type' => 'delete', 'mode' => 'byId', 'dao' => $this, 'id' => (string)$id, ], function () use ($self, $theId) { UnitOfWork::suspendDuring(function () use ($self, $theId) { $self->getConnection()->deleteOne($self->databaseName(), $self->collection(), ['_id' => $theId]); }); }); return 0; } return $this->connection->deleteOne($this->databaseName(), $this->collection(), ['_id' => $id]); } /** @param array|Filter $filter */ public function deleteBy(array|Filter $filter): int { $uow = UnitOfWork::current(); if ($uow && !UnitOfWork::isSuspended()) { $self = $this; $conn = $this->connection; $flt = $this->normalizeFilterInput($filter); $uow->enqueueWithMeta($conn, [ 'type' => 'delete', 'mode' => 'byCriteria', 'dao' => $this, 'criteria' => $flt, ], function () use ($self, $flt) { UnitOfWork::suspendDuring(function () use ($self, $flt) { $self->getConnection()->deleteOne($self->databaseName(), $self->collection(), $flt); }); }); return 0; } // For MVP provide deleteOne semantic; bulk deletes could be added later return $this->connection->deleteOne($this->databaseName(), $this->collection(), $this->normalizeFilterInput($filter)); } /** 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|Filter $filter @param array $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 $values * @return array */ public function findAllWhereIn(string $field, array $values): array { if (!$values) return []; // Normalize values (unique) $values = array_values(array_unique($values)); $opts = $this->buildOptions(); $docs = $this->connection->find($this->databaseName(), $this->collection(), [ $field => ['$in' => $values] ], $opts); 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]); } } throw new \BadMethodCallException(static::class . "::{$name} does not exist"); } // ========= Internals ========= protected function normalizeColumn(string $studly): string { $snake = preg_replace('/(?dtoClass(); /** @var AbstractDto $dto */ $dto = $class::fromArray($doc); $uow = UnitOfWork::current(); if ($uow && !UnitOfWork::isSuspended()) { $idVal = $doc['_id'] ?? null; if ($idVal !== null) { $uow->attach(static::class, (string)$idVal, $dto); } } return $dto; } /** @param array|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; } 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); 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 $dtos */ public function loadMany(array $dtos, string $relation): void { if (!$dtos) return; $this->with([$relation]); $this->attachRelations($dtos); } /** @param array $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); } $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); } } } 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); } } } } // reset eager-load request $this->with = []; $this->withTree = []; $this->withConstraints = []; // 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; } }