Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions src/PocketORM/Concerns/DeepFetch.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
use Closure;
use Pocketframe\PocketORM\Entity\Entity;
use Pocketframe\PocketORM\Essentials\DataSet;
use Pocketframe\PocketORM\QueryEngine\QueryEngine;
use Pocketframe\PocketORM\Relationships\Bridge;
use Pocketframe\PocketORM\Relationships\HasMultiple;
use Pocketframe\PocketORM\Relationships\HasOne;
use Pocketframe\PocketORM\Relationships\OwnedBy;
use Pocketframe\PocketORM\Relationships\BelongsTo;

trait DeepFetch
{
Expand Down Expand Up @@ -136,26 +137,36 @@ private function loadRelation(DataSet $records, string $relation, array $columns
return;
}

$firstConfig = reset($items)->getRelationshipConfig($base);
$firstConfig = reset($items)->getRelationshipConfig($base);
$relationship = new $firstConfig[0](...$this->prepareRelationshipArgs(reset($items), $firstConfig));

// Apply user callback if exists
// ── START HERE: build your engine on the related table
$engine = $relationship->getQueryEngine()->select($columns);

// ── apply any user‐provided filter
if (isset($this->includeCallbacks[$base])) {
($this->includeCallbacks[$base])($relationship->getQueryBuilder());
($this->includeCallbacks[$base])($engine);
}

$relatedMap = $relationship->deepFetch($items, $columns);
// ── do the filtered deep-fetch
$relatedMap = $relationship->deepFetchUsingEngine($items, $engine);

// decide which key to use for lookup
$lookupKey = ($relationship instanceof Bridge || $relationship instanceof HasMultiple)
? 'id'
: $relationship->getForeignKey();

foreach ($items as $parent) {
$key = $parent->{$lookupKey} ?? null;
$data = $relatedMap[$key] ?? [];
$parent->setDeepFetch($base, $this->formatLoadedData($relationship, $data));
if ($relationship instanceof HasOne || $relationship instanceof BelongsTo) {
$parent->setDeepFetch($base, $data ?: null);
} else {
$parent->setDeepFetch($base, new DataSet($data));
}
}

// handle nested ("comments.user") includes if any
if ($segments) {
$nextRelation = implode('.', $segments);
$allChild = [];
Expand All @@ -176,6 +187,7 @@ private function loadRelation(DataSet $records, string $relation, array $columns
}
}


/**
* Prepare the constructor args for each relationship type.
*/
Expand All @@ -194,7 +206,7 @@ private function formatLoadedData($relationship, $data)
{
return match (true) {
$relationship instanceof HasOne,
$relationship instanceof OwnedBy => $data,
$relationship instanceof BelongsTo => $data,
$relationship instanceof HasMultiple,
$relationship instanceof Bridge => $data instanceof DataSet ? $data : new DataSet($data),
default => null,
Expand Down
68 changes: 54 additions & 14 deletions src/PocketORM/Entity/Entity.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
use Pocketframe\PocketORM\QueryEngine\QueryEngine;
use Pocketframe\PocketORM\Relationships\HasOne;
use Pocketframe\PocketORM\Relationships\HasMultiple;
use Pocketframe\PocketORM\Relationships\OwnedBy;
use Pocketframe\PocketORM\Relationships\BelongsTo;
use Pocketframe\PocketORM\Relationships\Bridge;
use Pocketframe\PocketORM\Relationships\RelationshipNotDefinedException;
use Pocketframe\PocketORM\Schema\Schema;
Expand All @@ -21,7 +21,7 @@
* Base Active Record–style Entity class.
*
* Features:
* - 'relationship' array for defining relationships (HasOne, HasMultiple, OwnedBy, Bridge)
* - 'relationship' array for defining relationships (HasOne, HasMultiple, BelongsTo, Bridge)
* - fillable/guarded attributes
* - timestamp handling via HasTimeStamps
* - eager loading cache
Expand Down Expand Up @@ -67,7 +67,7 @@ abstract class Entity
* protected array $relationship = [
* 'profile' => [Entity::HAS_ONE, Profile::class, 'user_id'],
* 'posts' => [Entity::HAS_MULTIPLE, Post::class, 'post_id'],
* 'role' => [Entity::OWNED_BY, Role::class, 'role_id'],
* 'role' => [Entity::BELONGS_TO, Role::class, 'role_id'],
* 'groups' => [Entity::BRIDGE, Group::class, 'user_groups', 'user_id', 'group_id']
* ];
*/
Expand Down Expand Up @@ -129,7 +129,7 @@ abstract class Entity
// Relationship type constants for convenience
const HAS_ONE = HasOne::class;
const HAS_MULTIPLE = HasMultiple::class;
const OWNED_BY = OwnedBy::class;
const BELONGS_TO = BelongsTo::class;
const BRIDGE = Bridge::class;

/**
Expand Down Expand Up @@ -172,7 +172,7 @@ public function __get(string $name)

// If it’s a BELONGS-TO or HAS-ONE, immediately resolve to a single model:
if (
$handler instanceof \Pocketframe\PocketORM\Relationships\OwnedBy
$handler instanceof \Pocketframe\PocketORM\Relationships\BelongsTo
|| $handler instanceof \Pocketframe\PocketORM\Relationships\HasOne
) {
$resolved = $handler->resolve();
Expand Down Expand Up @@ -328,7 +328,7 @@ public function getIntegerColumns(): array
break;
case self::HAS_ONE:
case self::HAS_MULTIPLE:
case self::OWNED_BY:
case self::BELONGS_TO:
// Third element is the foreign key
$integerColumns[] = $config[2];
break;
Expand Down Expand Up @@ -591,15 +591,22 @@ protected function loadRelationship(string $relation)
throw new \Exception("Undefined relationship: {$relation}");
}

[$relClass, $relatedEntity, $key1, $key2] = $this->relationship[$relation] + [null, null];
$foreignKey = $key2 ?? $this->guessForeignKey();

// Instantiate handler (no caching)
if ($relClass === Bridge::class) {
[$pivotTable, $parentKey, $relatedKey] = [$key1, $key2, null];
$handler = new Bridge($this, $relatedEntity, $pivotTable, $parentKey, $relatedKey);
$config = $this->relationship[$relation];

if ($config[0] === Bridge::class) {
// Bridge: [Bridge, RelatedClass, pivotTable, parentKey, relatedKey]
[, $relatedEntity, $pivotTable, $parentKey, $relatedKey] = $config;
$handler = new Bridge(
$this,
$relatedEntity,
$pivotTable,
$parentKey,
$relatedKey
);
} else {
$handler = new $relClass($this, $relatedEntity, $foreignKey);
// HasOne/HasMany/OwnedBy: [Type, RelatedClass, foreignKey]
[, $relatedEntity, $foreignKey] = $config;
$handler = new $config[0]($this, $relatedEntity, $foreignKey);
}

return $handler;
Expand Down Expand Up @@ -659,6 +666,39 @@ public function __call(string $name, array $arguments)
);
}

public function relation(string $name)
{
// 1) Grab the raw config
$config = $this->getRelationshipConfig($name);

// 2) If it’s a Bridge relationship, it must have 5 slots
if ($config[0] === self::BRIDGE) {
// [0] = Bridge class
// [1] = related entity class
// [2] = pivot table name
// [3] = parent key column in pivot
// [4] = related key column in pivot
[, $relatedEntity, $pivotTable, $parentKey, $relatedKey] = $config;

return new Bridge(
$this, // entity type
$relatedEntity, // e.g. Tag::class
$pivotTable, // e.g. 'category_tags'
$parentKey, // e.g. 'category_id'
$relatedKey // e.g. 'tag_id'
);
}

// 3) Otherwise it’s a HasOne / HasMultiple / OwnedBy
[, $relatedEntity, $foreignKey] = $config;

return new $config[0](
$this,
$relatedEntity,
$foreignKey
);
}

/**
* Default guess for foreign key: "classname_id".
*
Expand Down
12 changes: 12 additions & 0 deletions src/PocketORM/Essentials/DataSet.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@ public function reduce(callable $callback, $initial)
return array_reduce($this->records, $callback, $initial);
}

/**
* Join a column of this dataset into a string.
*
* @param string $glue What to insert between values
* @param string $column The name of the column to join
* @return string
*/
public function join(string $glue, string $column): string
{
return implode($glue, $this->pluck($column));
}

/**
* Group the dataset by a key.
*
Expand Down
54 changes: 34 additions & 20 deletions src/PocketORM/QueryEngine/QueryEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class QueryEngine
private ?string $entityClass;
protected float $timeStart = 0.0;
protected bool $distinct = false;
private bool $trashFilterAdded = false;
private bool $trashFilterApplied = false;

/**
* Constructor
Expand Down Expand Up @@ -2651,14 +2653,17 @@ public function getDisabledGlobalScopes(): array
*/
protected function applyTrashableConditions(): void
{
// If we've already applied it once, skip entirely
if ($this->trashFilterApplied) {
return;
}

// 0) No entity? nothing to do
if (! $this->entityClass) {
return;
}

// 1) Only proceed if the model uses the Trashable trait
// class_uses only returns traits used directly on this class,
// not on parents—if you need inherited traits, use class_uses_recursive().
if (! in_array(
\Pocketframe\PocketORM\Concerns\Trashable::class,
class_uses($this->entityClass),
Expand All @@ -2667,7 +2672,7 @@ class_uses($this->entityClass),
return;
}

// 2) Make sure the getters actually exist before calling them
// 2) Make sure the getters actually exist
if (
! method_exists($this->entityClass, 'getTrashColumn')
|| ! method_exists($this->entityClass, 'getRestoreValue')
Expand All @@ -2681,6 +2686,8 @@ class_uses($this->entityClass),

// 4) If someone already added a where on this column, do nothing
if ($this->hasAnyTrashCondition($col)) {
// But still mark it so we don't try again later
$this->trashFilterApplied = true;
return;
}

Expand All @@ -2704,6 +2711,9 @@ class_uses($this->entityClass),
$this->whereNull($col);
}
}

// ─ mark that we've already applied it ─
$this->trashFilterApplied = true;
}

protected function hasAnyTrashCondition(string $column): bool
Expand Down Expand Up @@ -2886,40 +2896,50 @@ protected function compileOrders(): string
*/
protected function compileWheres(): string
{
if (empty($this->wheres)) return '';
if (empty($this->wheres)) {
return '';
}

// ── DEDUPE: remove duplicate where entries ───────────────
$unique = [];
foreach ($this->wheres as $where) {
// serialize the array structure as a unique key
$key = serialize($where);
if (!isset($unique[$key])) {
$unique[$key] = $where;
}
}
// overwrite with only unique where-clauses
$this->wheres = array_values($unique);

// ── NOW BUILD the SQL from deduped $this->wheres ─────────
$clauses = [];
foreach ($this->wheres as $index => $where) {
$clause = $index === 0 ? '' : $where['boolean'] . ' ';
$column = $this->quoteColumn($where['column'] ?? '');

switch ($where['type']) {
// Basic comparison
case 'basic':
$clause .= "{$column} {$where['operator']} ?";
break;

// IN/NOT IN
case 'in':
$placeholders = implode(', ', array_fill(0, count($where['values']), '?'));
$not = $where['not'] ? 'NOT ' : '';
$clause .= "{$column} {$not}IN ({$placeholders})";
break;

// NULL/NOT NULL
case 'null':
$not = $where['not'] ? 'NOT ' : '';
$clause .= "{$column} IS {$not}NULL";
break;

// Column comparison
case 'column':
$first = $this->quoteColumn($where['first']);
$second = $this->quoteColumn($where['second']);
$clause .= "{$first} {$where['operator']} {$second}";
break;

// JSON operations
case 'json_contains':
$clause .= "JSON_CONTAINS({$column}, ?)";
break;
Expand All @@ -2937,19 +2957,17 @@ protected function compileWheres(): string
$clause .= "JSON_SEARCH({$column}, 'one', ?) IS NULL";
break;

// BETWEEN
case 'between':
$not = $where['not'] ? 'NOT ' : '';
$clause .= "{$column} {$not}BETWEEN ? AND ?";
break;
case 'between_columns':
$not = $where['not'] ? 'NOT ' : '';
$start = $this->quoteColumn($where['start']);
$end = $this->quoteColumn($where['end']);
$end = $this->quoteColumn($where['end']);
$clause .= "{$column} {$not}BETWEEN {$start} AND {$end}";
break;

// Date/time functions
case 'date':
case 'month':
case 'day':
Expand All @@ -2959,27 +2977,22 @@ protected function compileWheres(): string
$clause .= "{$function}({$column}) {$where['operator']} ?";
break;

// Nested queries
case 'nested':
$nestedSql = $where['query']->compileWheres();
$nestedClause = substr($nestedSql, 7); // Remove ' WHERE '
$not = $where['not'] ?? false ? 'NOT ' : '';
$nestedSql = $where['query']->compileWheres();
$nestedClause = substr($nestedSql, 7); // drop leading ' WHERE '
$not = !empty($where['not']) ? 'NOT ' : '';
$clause .= "{$not}({$nestedClause})";
break;

case 'exists':
// Build the full sub-query SQL (including its SELECT and WHEREs)
$subSql = $where['query']->toSql();
// Wrap in EXISTS(...)
$clause .= "EXISTS({$subSql})";
break;

// Full-text search
case 'fulltext':
$clause .= "MATCH({$column}) AGAINST(? IN {$where['mode']} MODE)";
break;

// Raw expressions
case 'raw':
$clause .= $where['sql'];
break;
Expand All @@ -2990,6 +3003,7 @@ protected function compileWheres(): string

$clauses[] = $clause;
}

return ' WHERE ' . implode(' ', $clauses);
}

Expand Down
Loading