|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 = []; /** Soft delete include flags */ private bool $includeTrashed = false; private bool $onlyTrashed = false; 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; } /** @param array $criteria */ public function findOneBy(array $criteria): ?AbstractDto { [$where, $bindings] = $this->buildWhere($this->applyDefaultScopes($criteria)); $where = $this->appendScopedWhere($where); $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(); 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 { [$where, $bindings] = $this->buildWhere($this->applyDefaultScopes($criteria)); $where = $this->appendScopedWhere($where); $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(); return $dtos; } /** @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]); } } 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('/(?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 = []; // do not reset relationFields here; they may be reused by subsequent loads in the same call } // ===== belongsToMany helpers (pivot operations) ===== /** * Attach related ids to a parent for a belongsToMany relation. * Returns number of rows inserted into the pivot table. * @param string $relationName * @param int|string $parentId * @param array $relatedIds */ public function attach(string $relationName, int|string $parentId, array $relatedIds): int { if (!$relatedIds) return 0; $cfg = $this->relations()[$relationName] ?? null; if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') { throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation"); } $pivot = (string)($cfg['pivot'] ?? ($cfg['pivotTable'] ?? '')); $fk = (string)($cfg['foreignPivotKey'] ?? ''); $rk = (string)($cfg['relatedPivotKey'] ?? ''); if ($pivot === '' || $fk === '' || $rk === '') { throw new \InvalidArgumentException("belongsToMany relation '{$relationName}' requires pivot, foreignPivotKey, relatedPivotKey"); } $cols = [$fk, $rk]; $valuesSql = []; $params = []; foreach (array_values(array_unique($relatedIds, SORT_REGULAR)) as $i => $rid) { $p1 = "p_fk_{$i}"; $p2 = "p_rk_{$i}"; $valuesSql[] = '(:' . $p1 . ', :' . $p2 . ')'; $params[$p1] = $parentId; $params[$p2] = $rid; } $sql = 'INSERT INTO ' . $pivot . ' (' . implode(', ', $cols) . ') VALUES ' . implode(', ', $valuesSql); return $this->connection->execute($sql, $params); } /** * Detach related ids from a parent for a belongsToMany relation. If $relatedIds is empty, detaches all. * Returns affected rows. * @param array $relatedIds */ public function detach(string $relationName, int|string $parentId, array $relatedIds = []): int { $cfg = $this->relations()[$relationName] ?? null; if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') { throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation"); } $pivot = (string)($cfg['pivot'] ?? ($cfg['pivotTable'] ?? '')); $fk = (string)($cfg['foreignPivotKey'] ?? ''); $rk = (string)($cfg['relatedPivotKey'] ?? ''); if ($pivot === '' || $fk === '' || $rk === '') { throw new \InvalidArgumentException("belongsToMany relation '{$relationName}' requires pivot, foreignPivotKey, relatedPivotKey"); } $where = $fk . ' = :pid'; $params = ['pid' => $parentId]; if ($relatedIds) { $ph = []; foreach (array_values(array_unique($relatedIds, SORT_REGULAR)) as $i => $rid) { $k = "r_$i"; $ph[] = ":$k"; $params[$k] = $rid; } $where .= ' AND ' . $rk . ' IN (' . implode(', ', $ph) . ')'; } $sql = 'DELETE FROM ' . $pivot . ' WHERE ' . $where; return $this->connection->execute($sql, $params); } /** * Sync the related ids set for a parent: attach missing, detach extra. * Returns ['attached' => int, 'detached' => int]. * @param array $relatedIds * @return array{attached:int,detached:int} */ public function sync(string $relationName, int|string $parentId, array $relatedIds): array { $cfg = $this->relations()[$relationName] ?? null; if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') { throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation"); } $pivot = (string)($cfg['pivot'] ?? ($cfg['pivotTable'] ?? '')); $fk = (string)($cfg['foreignPivotKey'] ?? ''); $rk = (string)($cfg['relatedPivotKey'] ?? ''); if ($pivot === '' || $fk === '' || $rk === '') { throw new \InvalidArgumentException("belongsToMany relation '{$relationName}' requires pivot, foreignPivotKey, relatedPivotKey"); } // Read current related ids $rows = $this->connection->query('SELECT ' . $rk . ' AS rk FROM ' . $pivot . ' WHERE ' . $fk . ' = :pid', ['pid' => $parentId]); $current = []; foreach ($rows as $r) { $v = $r['rk'] ?? null; if ($v !== null) { $current[(string)$v] = true; } } $target = []; foreach (array_values(array_unique($relatedIds, SORT_REGULAR)) as $v) { $target[(string)$v] = true; } $toAttach = array_diff_key($target, $current); $toDetach = array_diff_key($current, $target); $attached = $toAttach ? $this->attach($relationName, $parentId, array_keys($toAttach)) : 0; $detached = $toDetach ? $this->detach($relationName, $parentId, array_keys($toDetach)) : 0; return ['attached' => (int)$attached, 'detached' => (int)$detached]; } public function with(array $relations): static { // 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; } 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; 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; 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; } } 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]); } }