|null */ private ?array $selectedFields = null; /** @var array> */ 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 = []; /** * Optional per‑relation eager loading strategies for first‑level relations. * Keys are relation names; values like 'join'. * @var array */ private array $withStrategies = []; /** Soft delete include flags */ private bool $includeTrashed = false; private bool $onlyTrashed = false; /** @var array */ private array $runtimeScopes = []; /** @var array */ private array $namedScopes = []; /** * Optional eager loading strategy for next find* call. * null (default) uses the portable subquery/batched IN strategy. * 'join' opts in to join-based eager loading for supported SQL relations (single level). */ private ?string $eagerStrategy = null; public function __construct(ConnectionInterface $connection) { $this->connection = $connection; } abstract public function getTable(): string; /** * The DTO class this DAO hydrates. * @return class-string */ abstract protected function dtoClass(): string; /** * Relation metadata to enable eager/lazy loading. * @return array> */ protected function relations(): array { return []; } /** * Optional schema metadata for this DAO (MVP). * Example structure: * return [ * 'primaryKey' => 'id', * 'columns' => [ * 'id' => ['cast' => 'int'], * 'email' => ['cast' => 'string'], * 'data' => ['cast' => 'json'], * ], * 'timestamps' => ['createdAt' => 'created_at', 'updatedAt' => 'updated_at'], * 'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'], * ]; * * @return array */ protected function schema(): array { return []; } public function getPrimaryKey(): string { $schema = $this->getSchema(); if (isset($schema['primaryKey']) && is_string($schema['primaryKey']) && $schema['primaryKey'] !== '') { return $schema['primaryKey']; } return $this->primaryKey; } public function getConnection(): ConnectionInterface { return $this->connection; } /** * Opt-in to join-based eager loading for the next find* call (SQL only, single-level relations). */ public function useJoinEager(): static { $this->eagerStrategy = 'join'; return $this; } /** * Set eager strategy explicitly: 'join' or 'subquery'. Resets after next find* call. */ public function eagerStrategy(string $strategy): static { $strategy = strtolower($strategy); $this->eagerStrategy = in_array($strategy, ['join', 'subquery'], true) ? $strategy : null; return $this; } /** @param array $criteria */ public function findOneBy(array $criteria): ?AbstractDto { $criteria = $this->applyDefaultScopes($criteria); $this->applyRuntimeScopesToCriteria($criteria); [$where, $bindings] = $this->buildWhere($criteria); $where = $this->appendScopedWhere($where); $dto = null; if ($this->with && $this->shouldUseJoinEager()) { [$sql, $bindings2, $meta] = $this->buildJoinSelect($where, $bindings, 1, 0); $rows = $this->connection->query($sql, $bindings2); $list = $this->hydrateFromJoinRows($rows, $meta); $dto = $list[0] ?? null; } else { $sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($where ? ' WHERE ' . $where : '') . ' LIMIT 1'; $rows = $this->connection->query($sql, $bindings); $dto = isset($rows[0]) ? $this->hydrate($this->castRowFromStorage($rows[0])) : null; if ($dto && $this->with) { $this->attachRelations([$dto]); } } $this->resetFieldSelections(); $this->resetRuntimeScopes(); $this->eagerStrategy = null; // reset $this->withStrategies = []; return $dto; } public function findById(int|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([$this->getPrimaryKey() => $id]); } /** * @param array $criteria * @return array */ public function findAllBy(array $criteria = []): array { $criteria = $this->applyDefaultScopes($criteria); $this->applyRuntimeScopesToCriteria($criteria); [$where, $bindings] = $this->buildWhere($criteria); $where = $this->appendScopedWhere($where); if ($this->with && $this->shouldUseJoinEager()) { [$sql2, $bindings2, $meta] = $this->buildJoinSelect($where, $bindings, null, null); $rows = $this->connection->query($sql2, $bindings2); $dtos = $this->hydrateFromJoinRows($rows, $meta); } else { $sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($where ? ' WHERE ' . $where : ''); $rows = $this->connection->query($sql, $bindings); $dtos = array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows); if ($dtos && $this->with) { $this->attachRelations($dtos); } } $this->resetFieldSelections(); $this->resetRuntimeScopes(); $this->eagerStrategy = null; // reset $this->withStrategies = []; return $dtos; } /** * Paginate results for the given criteria. * @return array{data:array,total:int,perPage:int,currentPage:int,lastPage:int} */ public function paginate(int $page, int $perPage = 15, array $criteria = []): array { $page = max(1, $page); $perPage = max(1, $perPage); $criteria = $this->applyDefaultScopes($criteria); $this->applyRuntimeScopesToCriteria($criteria); [$where, $bindings] = $this->buildWhere($criteria); $whereFinal = $this->appendScopedWhere($where); // Total $countSql = 'SELECT COUNT(*) AS cnt FROM ' . $this->getTable() . ($whereFinal ? ' WHERE ' . $whereFinal : ''); $countRows = $this->connection->query($countSql, $bindings); $total = (int)($countRows[0]['cnt'] ?? 0); // Page data $offset = ($page - 1) * $perPage; $dataSql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($whereFinal ? ' WHERE ' . $whereFinal : '') . ' LIMIT ' . $perPage . ' OFFSET ' . $offset; $rows = $this->connection->query($dataSql, $bindings); $dtos = array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows); if ($dtos && $this->with) { $this->attachRelations($dtos); } $this->resetFieldSelections(); $this->resetRuntimeScopes(); $this->eagerStrategy = null; // reset $this->withStrategies = []; $lastPage = (int)max(1, (int)ceil($total / $perPage)); return [ 'data' => $dtos, 'total' => $total, 'perPage' => $perPage, 'currentPage' => $page, 'lastPage' => $lastPage, ]; } /** Simple pagination without total count. Returns nextPage if there might be more. */ public function simplePaginate(int $page, int $perPage = 15, array $criteria = []): array { $page = max(1, $page); $perPage = max(1, $perPage); $criteria = $this->applyDefaultScopes($criteria); $this->applyRuntimeScopesToCriteria($criteria); [$where, $bindings] = $this->buildWhere($criteria); $whereFinal = $this->appendScopedWhere($where); $offset = ($page - 1) * $perPage; $sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($whereFinal ? ' WHERE ' . $whereFinal : '') . ' LIMIT ' . ($perPage + 1) . ' OFFSET ' . $offset; // fetch one extra to detect more $rows = $this->connection->query($sql, $bindings); $hasMore = count($rows) > $perPage; if ($hasMore) { array_pop($rows); } $dtos = array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows); if ($dtos && $this->with) { $this->attachRelations($dtos); } $this->resetFieldSelections(); $this->resetRuntimeScopes(); $this->eagerStrategy = null; // reset $this->withStrategies = []; return [ 'data' => $dtos, 'perPage' => $perPage, 'currentPage' => $page, 'nextPage' => $hasMore ? $page + 1 : null, ]; } /** @param array $data */ public function insert(array $data): AbstractDto { if (empty($data)) { throw new \InvalidArgumentException('insert() requires non-empty data'); } $data = $this->prepareForInsert($data); $cols = array_keys($data); $placeholders = array_map(fn($c) => ':' . $c, $cols); $sql = 'INSERT INTO ' . $this->getTable() . ' (' . implode(', ', $cols) . ') VALUES (' . implode(', ', $placeholders) . ')'; $this->connection->execute($sql, $data); $id = $this->connection->lastInsertId(); $pk = $this->getPrimaryKey(); if ($id !== null) { return $this->findById($id) ?? $this->hydrate(array_merge($data, [$pk => $id])); } // Fallback when lastInsertId is unavailable: return hydrated DTO from provided data return $this->hydrate($this->castRowFromStorage($data)); } /** @param array $data */ public function update(int|string $id, array $data): AbstractDto { $uow = UnitOfWork::current(); if ($uow && !UnitOfWork::isSuspended()) { // Defer execution; return a synthesized DTO $existing = $this->findById($id); if (!$existing && empty($data)) { throw new \InvalidArgumentException('No data provided to update and record not found'); } $toStore = $this->prepareForUpdate($data); $self = $this; $conn = $this->connection; $uow->enqueueWithMeta($conn, [ 'type' => 'update', 'mode' => 'byId', 'dao' => $this, 'id' => (string)$id, ], function () use ($self, $id, $toStore) { UnitOfWork::suspendDuring(function () use ($self, $id, $toStore) { // execute real update now $sets = []; $params = []; foreach ($toStore as $col => $val) { $sets[] = "$col = :set_$col"; $params["set_$col"] = $val; } $params['pk'] = $id; $sql = 'UPDATE ' . $self->getTable() . ' SET ' . implode(', ', $sets) . ' WHERE ' . $self->getPrimaryKey() . ' = :pk'; $self->getConnection()->execute($sql, $params); }); }); $base = $existing ? $existing->toArray(false) : []; $pk = $this->getPrimaryKey(); $result = array_merge($base, $data, [$pk => $id]); return $this->hydrate($this->castRowFromStorage($result)); } if (empty($data)) { $existing = $this->findById($id); if ($existing) return $existing; throw new \InvalidArgumentException('No data provided to update and record not found'); } $data = $this->prepareForUpdate($data); $sets = []; $params = []; foreach ($data as $col => $val) { $sets[] = "$col = :set_$col"; $params["set_$col"] = $val; } $params['pk'] = $id; $sql = 'UPDATE ' . $this->getTable() . ' SET ' . implode(', ', $sets) . ' WHERE ' . $this->getPrimaryKey() . ' = :pk'; $this->connection->execute($sql, $params); $updated = $this->findById($id); if ($updated === null) { $pk = $this->getPrimaryKey(); return $this->hydrate($this->castRowFromStorage(array_merge($data, [$pk => $id]))); } return $updated; } public function deleteById(int|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->deleteById($theId); }); }); // deferred; immediate affected count unknown return 0; } if ($this->hasSoftDeletes()) { $columns = $this->softDeleteConfig(); $deletedAt = $columns['deletedAt'] ?? 'deleted_at'; $now = $this->nowString(); $sql = 'UPDATE ' . $this->getTable() . " SET {$deletedAt} = :ts WHERE " . $this->getPrimaryKey() . ' = :pk'; return $this->connection->execute($sql, ['ts' => $now, 'pk' => $id]); } $sql = 'DELETE FROM ' . $this->getTable() . ' WHERE ' . $this->getPrimaryKey() . ' = :pk'; return $this->connection->execute($sql, ['pk' => $id]); } /** @param array $criteria */ public function deleteBy(array $criteria): int { $uow = UnitOfWork::current(); if ($uow && !UnitOfWork::isSuspended()) { $self = $this; $conn = $this->connection; $crit = $criteria; $uow->enqueueWithMeta($conn, [ 'type' => 'delete', 'mode' => 'byCriteria', 'dao' => $this, 'criteria' => $criteria, ], function () use ($self, $crit) { UnitOfWork::suspendDuring(function () use ($self, $crit) { $self->deleteBy($crit); }); }); return 0; } if ($this->hasSoftDeletes()) { [$where, $bindings] = $this->buildWhere($criteria); if ($where === '') { return 0; } $columns = $this->softDeleteConfig(); $deletedAt = $columns['deletedAt'] ?? 'deleted_at'; $now = $this->nowString(); $sql = 'UPDATE ' . $this->getTable() . " SET {$deletedAt} = :ts WHERE " . $where; $bindings = array_merge(['ts' => $now], $bindings); return $this->connection->execute($sql, $bindings); } [$where, $bindings] = $this->buildWhere($criteria); if ($where === '') { return 0; } $sql = 'DELETE FROM ' . $this->getTable() . ' WHERE ' . $where; return $this->connection->execute($sql, $bindings); } /** * Update rows matching the given criteria with the provided data. * * @param array $criteria * @param array $data */ public function updateBy(array $criteria, array $data): int { $uow = UnitOfWork::current(); if ($uow && !UnitOfWork::isSuspended()) { if (empty($data)) { return 0; } $self = $this; $conn = $this->connection; $crit = $criteria; $payload = $this->prepareForUpdate($data); $uow->enqueueWithMeta($conn, [ 'type' => 'update', 'mode' => 'byCriteria', 'dao' => $this, 'criteria' => $criteria, ], function () use ($self, $crit, $payload) { UnitOfWork::suspendDuring(function () use ($self, $crit, $payload) { $self->updateBy($crit, $payload); }); }); // unknown affected rows until commit return 0; } if (empty($data)) { return 0; } [$where, $whereBindings] = $this->buildWhere($criteria); if ($where === '') { return 0; } // Ensure timestamps and storage casts are applied consistently with update() $data = $this->prepareForUpdate($data); $sets = []; $setParams = []; foreach ($data as $col => $val) { $sets[] = "$col = :set_$col"; $setParams["set_$col"] = $val; } $sql = 'UPDATE ' . $this->getTable() . ' SET ' . implode(', ', $sets) . ' WHERE ' . $where; return $this->connection->execute($sql, array_merge($setParams, $whereBindings)); } /** Expose relation metadata for UoW ordering/cascades. */ public function relationMap(): array { return $this->relations(); } /** * @param array $criteria * @return array{0:string,1:array} */ protected function buildWhere(array $criteria): array { if (!$criteria) { return ['', []]; } $parts = []; $bindings = []; foreach ($criteria as $col => $val) { $param = 'w_' . preg_replace('/[^a-zA-Z0-9_]/', '_', (string)$col); if ($val === null) { $parts[] = "$col IS NULL"; } else { $parts[] = "$col = :$param"; $bindings[$param] = $val; } } return [implode(' AND ', $parts), $bindings]; } /** * Fetch all rows where a column is within the given set of values. * * @param string $column * @param array $values * @return array> */ /** * Fetch related rows where a column is within a set of values. * Returns DTOs. * * @param string $column * @param array $values * @param array|null $selectFields If provided, use these fields instead of the DAO's current selection * @return array */ public function findAllWhereIn(string $column, array $values, ?array $selectFields = null): array { if (empty($values)) { return []; } $values = array_values(array_unique($values, SORT_REGULAR)); $placeholders = []; $bindings = []; foreach ($values as $i => $val) { $ph = "in_{$i}"; $placeholders[] = ":{$ph}"; $bindings[$ph] = $val; } $selectList = $selectFields && $selectFields !== ['*'] ? implode(', ', $selectFields) : $this->selectList(); $where = $column . ' IN (' . implode(', ', $placeholders) . ')'; $where = $this->appendScopedWhere($where); $sql = 'SELECT ' . $selectList . ' FROM ' . $this->getTable() . ' WHERE ' . $where; $rows = $this->connection->query($sql, $bindings); return array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows); } /** * Magic dynamic find/update/delete helpers: * - findOneBy{Column}($value) * - findAllBy{Column}($value) * - updateBy{Column}($value, array $data) * - deleteBy{Column}($value) */ 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]; $colPart = $m[2]; $column = $this->normalizeColumn($colPart); switch ($op) { case 'findOneBy': $value = $arguments[0] ?? null; return $this->findOneBy([$column => $value]); case 'findAllBy': $value = $arguments[0] ?? null; return $this->findAllBy([$column => $value]); case 'updateBy': $value = $arguments[0] ?? null; $data = $arguments[1] ?? []; if (!is_array($data)) { throw new \InvalidArgumentException('updateBy* expects second argument as array $data'); } return $this->updateBy([$column => $value], $data); case 'deleteBy': $value = $arguments[0] ?? null; return $this->deleteBy([$column => $value]); } } // Named scope call support: if a scope is registered with this method name, queue it and return $this if (isset($this->namedScopes[$name]) && is_callable($this->namedScopes[$name])) { $callable = $this->namedScopes[$name]; // Bind arguments $this->runtimeScopes[] = function (&$criteria) use ($callable, $arguments) { $callable($criteria, ...$arguments); }; return $this; } throw new \BadMethodCallException(static::class . "::{$name} does not exist"); } protected function normalizeColumn(string $studly): string { // Convert StudlyCase/CamelCase to snake_case and lowercase $snake = preg_replace('/(?namedScopes[$name] = $fn; return $this; } /** Add an ad-hoc scope for the next query: callable(array &$criteria): void */ public function scope(callable $fn): static { $this->runtimeScopes[] = $fn; return $this; } /** @param array $criteria */ private function applyRuntimeScopesToCriteria(array &$criteria): void { if (!$this->runtimeScopes) return; foreach ($this->runtimeScopes as $fn) { try { $fn($criteria); } catch (\Throwable) {} } } private function resetRuntimeScopes(): void { $this->runtimeScopes = []; } /** * Specify fields to select on the base entity and optionally on relations via dot-notation. * Example: fields('id', 'name', 'posts.title') */ public function fields(string ...$fields): static { $base = []; foreach ($fields as $f) { if (str_contains($f, '.')) { [$rel, $col] = explode('.', $f, 2); if ($rel !== '') { $this->relationFields[$rel][] = $col; } } else { if ($f !== '') { $base[] = $f; } } } if ($base) { $this->selectedFields = $base; } else { $this->selectedFields = $this->selectedFields ?? null; } return $this; } /** @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; // silently ignore unknown } $config = $relations[$name]; $type = (string)($config['type'] ?? ''); $daoClass = $config['dao'] ?? null; $dtoClass = $config['dto'] ?? null; // kept for docs compatibility if (!is_string($daoClass)) { continue; } /** @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); } if ($type === 'hasMany' || $type === 'hasOne') { $foreignKey = (string)($config['foreignKey'] ?? ''); $localKey = (string)($config['localKey'] ?? 'id'); if ($foreignKey === '') continue; $keys = []; foreach ($parents as $p) { $arr = $p->toArray(); if (isset($arr[$localKey])) { $keys[] = $arr[$localKey]; } } if (!$keys) continue; $children = $relatedDao->findAllWhereIn($foreignKey, $keys); // group children by foreignKey value $grouped = []; foreach ($children as $child) { $fk = $child->toArray()[$foreignKey] ?? null; if ($fk === null) continue; $grouped[$fk][] = $child; } foreach ($parents as $p) { $arr = $p->toArray(); $key = $arr[$localKey] ?? null; $list = ($key !== null && isset($grouped[$key])) ? $grouped[$key] : []; if ($type === 'hasOne') { $first = $list[0] ?? null; $p->setRelation($name, $first); } else { $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 if ($foreignKey === '') continue; $ownerIds = []; foreach ($parents as $p) { $arr = $p->toArray(); if (isset($arr[$foreignKey])) { $ownerIds[] = $arr[$foreignKey]; } } if (!$ownerIds) continue; $owners = $relatedDao->findAllWhereIn($otherKey, $ownerIds); $byId = []; foreach ($owners as $o) { $id = $o->toArray()[$otherKey] ?? null; if ($id !== null) { $byId[$id] = $o; } } foreach ($parents as $p) { $arr = $p->toArray(); $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 = []; $this->withStrategies = []; // do not reset relationFields here; they may be reused by subsequent loads in the same call } // ===== Join-based eager loading (opt-in, single-level) ===== /** Determine if join-based eager should be used for current with() selection. */ private function shouldUseJoinEager(): bool { // Determine if join strategy is desired globally or per relation $globalJoin = ($this->eagerStrategy === 'join'); $perRelJoin = false; if (!$globalJoin && $this->with) { $allMarked = true; foreach ($this->with as $rel) { if (($this->withStrategies[$rel] ?? null) !== 'join') { $allMarked = false; break; } } $perRelJoin = $allMarked; } if (!$globalJoin && !$perRelJoin) return false; // Only single-level paths supported in join MVP (no nested trees) foreach ($this->withTree as $rel => $sub) { if (!empty($sub)) return false; // nested present => fallback } // Require relationFields for each relation to know what to select safely foreach ($this->with as $rel) { if (!isset($this->relationFields[$rel]) || empty($this->relationFields[$rel])) { return false; } } return true; } /** * Build a SELECT with LEFT JOINs for the requested relations. * Returns [sql, bindings, meta] where meta describes relation aliases and selected columns. * @param ?int $limit * @param ?int $offset * @return array{0:string,1:array,2:array} */ private function buildJoinSelect(string $baseWhere, array $bindings, ?int $limit, ?int $offset): array { $t0 = 't0'; $pk = $this->getPrimaryKey(); // Base select: ensure PK is included $baseCols = $this->selectedFields ?: ['*']; if ($baseCols === ['*'] || !in_array($pk, $baseCols, true)) { // Select * to keep behavior; PK is present implicitly $baseSelect = "$t0.*"; } else { $quoted = array_map(fn($c) => "$t0.$c", $baseCols); $baseSelect = implode(', ', $quoted); } $selects = [ $baseSelect ]; $joins = []; $meta = [ 'rels' => [] ]; $relations = $this->relations(); $aliasIndex = 1; 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 $daoClass */ $relDao = new $daoClass($this->getConnection()); $ta = 't' . $aliasIndex++; $on = ''; if ($type === 'hasMany' || $type === 'hasOne') { $foreignKey = (string)($cfg['foreignKey'] ?? ''); $localKey = (string)($cfg['localKey'] ?? 'id'); if ($foreignKey === '') continue; $on = "$ta.$foreignKey = $t0.$localKey"; } elseif ($type === 'belongsTo') { $foreignKey = (string)($cfg['foreignKey'] ?? ''); $otherKey = (string)($cfg['otherKey'] ?? 'id'); if ($foreignKey === '') continue; $on = "$ta.$otherKey = $t0.$foreignKey"; } else { // belongsToMany not supported in join MVP continue; } // Soft-delete scope for related in JOIN (append in ON) if ($relDao->hasSoftDeletes()) { $del = $relDao->softDeleteConfig()['deletedAt'] ?? 'deleted_at'; $on .= " AND $ta.$del IS NULL"; } $joins[] = 'LEFT JOIN ' . $relDao->getTable() . ' ' . $ta . ' ON ' . $on; // Select related fields with alias prefix $relCols = $this->relationFields[$name] ?? []; $pref = $name . '__'; foreach ($relCols as $col) { $selects[] = "$ta.$col AS `{$pref}{$col}`"; } $meta['rels'][$name] = [ 'alias' => $ta, 'type' => $type, 'dao' => $relDao, 'cols' => $relCols ]; } $sql = 'SELECT ' . implode(', ', $selects) . ' FROM ' . $this->getTable() . ' ' . $t0; if ($joins) { $sql .= ' ' . implode(' ', $joins); } if ($baseWhere !== '') { $sql .= ' WHERE ' . $baseWhere; } if ($limit !== null) { $sql .= ' LIMIT ' . (int)$limit; } if ($offset !== null) { $sql .= ' OFFSET ' . (int)$offset; } return [$sql, $bindings, $meta]; } /** * Hydrate DTOs from joined rows with aliased related columns. * @param array> $rows * @param array $meta * @return array */ private function hydrateFromJoinRows(array $rows, array $meta): array { if (!$rows) return []; $pk = $this->getPrimaryKey(); $out = []; $byId = []; foreach ($rows as $row) { // Split base and related segments (related segments are prefixed as rel__col) $base = []; $relSegments = []; foreach ($row as $k => $v) { if (is_string($k) && str_contains($k, '__')) { [$rel, $col] = explode('__', $k, 2); $relSegments[$rel][$col] = $v; } else { $base[$k] = $v; } } $idVal = $base[$pk] ?? null; if ($idVal === null) { // cannot hydrate without PK; skip row continue; } $idKey = (string)$idVal; if (!isset($byId[$idKey])) { $dto = $this->hydrate($this->castRowFromStorage($base)); $byId[$idKey] = $dto; $out[] = $dto; } $parent = $byId[$idKey]; // Attach each relation if there are any non-null values foreach (($meta['rels'] ?? []) as $name => $info) { $seg = $relSegments[$name] ?? []; // Detect empty (all null) $allNull = true; foreach ($seg as $vv) { if ($vv !== null) { $allNull = false; break; } } if ($allNull) { // Ensure default: hasMany => [], hasOne/belongsTo => null (only set if not already set) if (!isset($parent->toArray(false)[$name])) { if (($info['type'] ?? '') === 'hasMany') { $parent->setRelation($name, []); } else { $parent->setRelation($name, null); } } continue; } /** @var AbstractDao $relDao */ $relDao = $info['dao']; // Cast and hydrate child DTO $child = $relDao->hydrate($relDao->castRowFromStorage($seg)); if (($info['type'] ?? '') === 'hasMany') { $current = $parent->toArray(false)[$name] ?? []; if (!is_array($current)) { $current = []; } // Append; no dedup to keep simple $current[] = $child; $parent->setRelation($name, $current); } else { $parent->setRelation($name, $child); } } } return $out; } // ===== 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 { // Accept ['rel', 'rel.child'] or ['rel' => callable, 'rel.child' => callable] // Also accepts config arrays like ['rel' => ['strategy' => 'join']] and // ['rel' => ['strategy' => 'join', 'constraint' => callable]] $names = []; $tree = []; foreach ($relations as $key => $value) { if (is_int($key)) { // plain name $path = (string)$value; $this->insertRelationPath($tree, $path); } else { // constraint or config $path = (string)$key; if (is_array($value)) { $strategy = isset($value['strategy']) ? strtolower((string)$value['strategy']) : null; if ($strategy) { $this->withStrategies[$path] = $strategy; } if (isset($value['constraint']) && is_callable($value['constraint'])) { $this->withConstraints[$path] = $value['constraint']; } } elseif (is_callable($value)) { $this->withConstraints[$path] = $value; } $this->insertRelationPath($tree, $path); } } $this->withTree = $tree; $this->with = array_keys($tree); // first-level only return $this; } public function load(AbstractDto $dto, string $relation): void { $this->with([$relation]); $this->attachRelations([$dto]); } /** @param array $dtos */ public function loadMany(array $dtos, string $relation): void { if (!$dtos) return; $this->with([$relation]); $this->attachRelations($dtos); } protected function hydrate(array $row): AbstractDto { $class = $this->dtoClass(); /** @var AbstractDto $dto */ $dto = $class::fromArray($row); $uow = UnitOfWork::current(); if ($uow && !UnitOfWork::isSuspended()) { $pk = $this->getPrimaryKey(); $idVal = $row[$pk] ?? null; if ($idVal !== null) { $uow->attach(static::class, (string)$idVal, $dto); } } return $dto; } private function selectList(): string { if ($this->selectedFields && $this->selectedFields !== ['*']) { return implode(', ', $this->selectedFields); } // By default, select all columns when fields() is not used. return '*'; } private function resetFieldSelections(): void { $this->selectedFields = null; $this->relationFields = []; $this->includeTrashed = 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 ===== protected function getSchema(): array { return $this->schema(); } protected function hasSoftDeletes(): bool { $sd = $this->getSchema()['softDeletes'] ?? null; return is_array($sd) && !empty($sd['enabled']); } /** @return array{deletedAt?:string} */ protected function softDeleteConfig(): array { $sd = $this->getSchema()['softDeletes'] ?? []; return is_array($sd) ? $sd : []; } /** @return array{createdAt?:string,updatedAt?:string} */ protected function timestampsConfig(): array { $ts = $this->getSchema()['timestamps'] ?? []; return is_array($ts) ? $ts : []; } /** Returns array cast map col=>type */ protected function castsMap(): array { $cols = $this->getSchema()['columns'] ?? []; if (!is_array($cols)) return []; $map = []; foreach ($cols as $name => $meta) { if (is_array($meta) && isset($meta['cast']) && is_string($meta['cast'])) { $map[$name] = $meta['cast']; } } return $map; } // Note: default SELECT projection now always '*' unless fields() is used. /** * Apply default scopes (e.g., soft deletes) to criteria. * For now, we don't alter criteria array; soft delete is appended as SQL fragment. * This method allows future transformations. * @param array $criteria * @return array */ protected function applyDefaultScopes(array $criteria): array { return $criteria; } /** Append soft-delete scope to a WHERE clause string (without bindings). */ private function appendScopedWhere(string $where): string { if (!$this->hasSoftDeletes()) return $where; $deletedAt = $this->softDeleteConfig()['deletedAt'] ?? 'deleted_at'; $frag = ''; if ($this->onlyTrashed) { $frag = "{$deletedAt} IS NOT NULL"; } elseif (!$this->includeTrashed) { $frag = "{$deletedAt} IS NULL"; } if ($frag === '') return $where; if ($where === '' ) return $frag; return $where . ' AND ' . $frag; } /** Cast a database row to PHP types according to schema casts. */ private function castRowFromStorage(array $row): array { $casts = $this->castsMap(); if (!$casts) return $row; foreach ($casts as $col => $type) { if (!array_key_exists($col, $row)) continue; $row[$col] = $this->castFromStorage($type, $row[$col]); } return $row; } private function castFromStorage(string $type, mixed $value): mixed { if ($value === null) return null; // Support custom caster classes via class-string in schema 'cast' $caster = $this->resolveCaster($type); if ($caster) { return $caster->fromStorage($value); } switch ($type) { case 'int': return (int)$value; case 'float': return (float)$value; case 'bool': return (bool)$value; case 'string': return (string)$value; case 'json': if (is_string($value)) { $decoded = json_decode($value, true); return (json_last_error() === JSON_ERROR_NONE) ? $decoded : $value; } return $value; case 'datetime': try { return new \DateTimeImmutable(is_string($value) ? $value : (string)$value); } catch (\Throwable) { return $value; } default: return $value; } } /** Prepare data for INSERT: filter known columns, auto timestamps, storage casting. */ private function prepareForInsert(array $data): array { $data = $this->filterToKnownColumns($data); // timestamps $ts = $this->timestampsConfig(); $now = $this->nowString(); if (!empty($ts['createdAt']) && !array_key_exists($ts['createdAt'], $data)) { $data[$ts['createdAt']] = $now; } if (!empty($ts['updatedAt']) && !array_key_exists($ts['updatedAt'], $data)) { $data[$ts['updatedAt']] = $now; } return $this->castForStorageAll($data); } /** Prepare data for UPDATE: filter known columns, auto updatedAt, storage casting. */ private function prepareForUpdate(array $data): array { $data = $this->filterToKnownColumns($data); $ts = $this->timestampsConfig(); if (!empty($ts['updatedAt'])) { $data[$ts['updatedAt']] = $this->nowString(); } return $this->castForStorageAll($data); } /** Keep only keys defined in schema columns (if any). */ private function filterToKnownColumns(array $data): array { $cols = $this->getSchema()['columns'] ?? null; if (!is_array($cols) || !$cols) return $data; $allowed = array_fill_keys(array_keys($cols), true); return array_intersect_key($data, $allowed); } private function castForStorageAll(array $data): array { $casts = $this->castsMap(); if (!$casts) return $data; foreach ($data as $k => $v) { if (isset($casts[$k])) { $data[$k] = $this->castForStorage($casts[$k], $v); } } return $data; } private function castForStorage(string $type, mixed $value): mixed { if ($value === null) return null; // Support custom caster classes via class-string in schema 'cast' $caster = $this->resolveCaster($type); if ($caster) { return $caster->toStorage($value); } switch ($type) { case 'int': return (int)$value; case 'float': return (float)$value; case 'bool': return (int)((bool)$value); // store as 0/1 for portability case 'string': return (string)$value; case 'json': if (is_string($value)) return $value; return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); case 'datetime': if ($value instanceof \DateTimeInterface) { $utc = (new \DateTimeImmutable('@' . $value->getTimestamp()))->setTimezone(new \DateTimeZone('UTC')); return $utc->format('Y-m-d H:i:s'); } return (string)$value; default: return $value; } } /** Cache for resolved caster instances. @var array */ private array $casterCache = []; /** Resolve a caster from a type/class string. */ private function resolveCaster(string $type): ?CasterInterface { // Not a class-string? return null to use built-ins if (!class_exists($type)) { return null; } if (isset($this->casterCache[$type])) { return $this->casterCache[$type]; } try { $obj = new $type(); } catch (\Throwable) { return null; } if ($obj instanceof CasterInterface) { $this->casterCache[$type] = $obj; return $obj; } return null; } private function nowString(): string { return gmdate('Y-m-d H:i:s'); } // ===== Soft delete toggles ===== public function withTrashed(): static { $this->includeTrashed = true; $this->onlyTrashed = false; return $this; } public function onlyTrashed(): static { $this->includeTrashed = true; $this->onlyTrashed = true; return $this; } // ===== Soft delete helpers & utilities ===== /** Restore a soft-deleted row by primary key. No-op when soft deletes are disabled. */ public function restoreById(int|string $id): int { if (!$this->hasSoftDeletes()) { return 0; } $deletedAt = $this->softDeleteConfig()['deletedAt'] ?? 'deleted_at'; $sql = 'UPDATE ' . $this->getTable() . " SET {$deletedAt} = NULL WHERE " . $this->getPrimaryKey() . ' = :pk'; return $this->connection->execute($sql, ['pk' => $id]); } /** Restore rows matching criteria. No-op when soft deletes are disabled. */ public function restoreBy(array $criteria): int { if (!$this->hasSoftDeletes()) { return 0; } [$where, $bindings] = $this->buildWhere($criteria); if ($where === '') { return 0; } $deletedAt = $this->softDeleteConfig()['deletedAt'] ?? 'deleted_at'; $sql = 'UPDATE ' . $this->getTable() . " SET {$deletedAt} = NULL WHERE " . $where; return $this->connection->execute($sql, $bindings); } /** Permanently delete a row by id even when soft deletes are enabled. */ public function forceDeleteById(int|string $id): int { $sql = 'DELETE FROM ' . $this->getTable() . ' WHERE ' . $this->getPrimaryKey() . ' = :pk'; return $this->connection->execute($sql, ['pk' => $id]); } /** Permanently delete rows matching criteria even when soft deletes are enabled. */ public function forceDeleteBy(array $criteria): int { [$where, $bindings] = $this->buildWhere($criteria); if ($where === '') { return 0; } $sql = 'DELETE FROM ' . $this->getTable() . ' WHERE ' . $where; return $this->connection->execute($sql, $bindings); } /** Touch a row by updating only the configured updatedAt column, if timestamps are enabled. */ public function touch(int|string $id): int { $ts = $this->timestampsConfig(); if (empty($ts['updatedAt'])) { return 0; } $col = $ts['updatedAt']; $sql = 'UPDATE ' . $this->getTable() . " SET {$col} = :ts WHERE " . $this->getPrimaryKey() . ' = :pk'; return $this->connection->execute($sql, ['ts' => $this->nowString(), 'pk' => $id]); } }