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
9 changes: 7 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# BowPHP Framework Roadmap

> Living document based on source code analysis (5.x branch) and the project manifesto.
> Last updated: May 2026
> Last updated: June 2026

---

Expand Down Expand Up @@ -68,6 +68,11 @@ Highlights from the latest iterations — already merged into `5.x`. Full detail

### Barry ORM

* **Eager loading** via `Builder::eager(string|array $relations)` — batches related models in a single `WHERE IN` query (solves N+1), supports `hasOne` / `hasMany` / `belongsTo` / `belongsToMany`; the eager list is reset after each `get()` so it can't leak into the next query on the shared builder.
* Relations now **lazy-load once per model instance** and keep the result in memory (`Model::setRelation` + private `$relations`), replacing the previous file-based cache in `BelongsTo` / `HasOne` (fixes stale and cross-parent cache bugs).
* Fixed `belongsTo` resolution inside loops — each parent now resolves its own related model instead of always returning the first.
* Fixed `HasMany::addConstraints()` to filter on the foreign-key column (was incorrectly using the parent primary key).
* Deep casting now applied when `toArray()` / `toJson()` / `__toString()` are called.
* `SoftDelete` trait (`delete` → `deleted_at`, `restore`, `forceDelete`, `withTrashed` / `onlyTrashed` / `withoutTrashed`, events `model.restoring/restored/forceDeleting/forceDeleted`).
* Fixed `array` cast: no longer returns `stdClass`.
* Removed dead `$soft_delete` property (replaced by the trait).
Expand Down Expand Up @@ -112,7 +117,7 @@ Highlights from the latest iterations — already merged into `5.x`. Full detail
| Separate unit tests from integration tests | ⏳ Planned | High | DB/FTP/S3 tests require external services |
| Add PHPUnit `@group` annotations | ⏳ Planned | High | `@group unit`, `@group integration`, `@group database` |
| Configure GitHub Actions with Docker services | ⏳ Planned | High | MySQL, PostgreSQL, Redis for CI |
| Increase unit test coverage | 🔄 Ongoing | Medium | 1,600+ tests, 0 logical failures. Recent additions: SoftDelete, AttributeRouteRegistrar, new validation rules, Pagination |
| Increase unit test coverage | 🔄 Ongoing | Medium | 1,600+ tests, 0 logical failures. Recent additions: eager loading (`EagerLoadingQueryTest`), SoftDelete, AttributeRouteRegistrar, new validation rules, Pagination |
| Integrate PHPStan level 5+ | ⏳ Planned | Medium | Current constraint: `phpstan/phpstan: ^0.12.87` — upgrade to ^1.x before targeting higher levels |

### Code Fixes
Expand Down
52 changes: 52 additions & 0 deletions src/Database/Barry/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ class Builder extends QueryBuilder
*/
protected ?string $model = null;

/**
* The relationships to eager load.
*
* @var array
*/
protected array $eager_loads = [];

/**
* Register relationships to eager load on the query result.
*
* @param string|array $relations
* @return Builder
*/
public function eager(string|array $relations): Builder
{
$this->eager_loads = array_merge($this->eager_loads, (array)$relations);

return $this;
}

/**
* Get information
*
Expand All @@ -28,6 +48,11 @@ public function get(array $columns = []): Model|Collection|null
{
$data = parent::get($columns);

// Read and reset the eager loads now: query() memoizes a shared Builder
// instance, so the list must not leak into the next query on this model.
$eager_loads = $this->eager_loads;
$this->eager_loads = [];

if (is_null($data)) {
return null;
}
Expand All @@ -41,9 +66,36 @@ public function get(array $columns = []): Model|Collection|null
$data[$key] = new $this->model((array)$value);
}

if (count($eager_loads) > 0) {
$this->eagerLoadRelations($data, $eager_loads);
}

return new Collection($data);
}

/**
* Eager load the given relationships onto a set of parent models.
*
* @param Model[] $models
* @param array $relations
* @return void
*/
protected function eagerLoadRelations(array $models, array $relations): void
{
if (count($models) === 0) {
return;
}

foreach ($relations as $name) {
// Build the relation without the single parent constraint so it can
// be batched across every parent with one whereIn query.
$relation = Relation::noConstraints(fn () => $models[0]->$name());

$relation->addEagerConstraints($models);
$relation->match($models, $relation->getEager(), $name);
}
}

/**
* Check if rows exists
*
Expand Down
35 changes: 34 additions & 1 deletion src/Database/Barry/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,13 @@ abstract class Model implements ArrayAccess, JsonSerializable
*/
private array $original = [];

/**
* The loaded relationships, resolved lazily once per model instance.
*
* @var array
*/
private array $relations = [];

/**
* Model constructor.
*
Expand Down Expand Up @@ -880,6 +887,20 @@ public function getAttribute(string $key): mixed
return $this->attributes[$key] ?? null;
}

/**
* Set a loaded relationship on the model's in-memory store.
*
* Used by eager loading to pre-populate a relation so a later access
* resolves from memory instead of issuing a query.
*
* @param string $name
* @param mixed $value
*/
public function setRelation(string $name, mixed $value): void
{
$this->relations[$name] = $value;
}

/**
* Returns the data
*
Expand Down Expand Up @@ -919,8 +940,20 @@ public function __get(string $name): mixed
$attribute_exists = isset($this->attributes[$name]);

if (!$attribute_exists && method_exists($this, $name)) {
// Lazy-load once: a relation is resolved a single time per model
// instance and its result kept in memory. Repeated access returns
// the same loaded object instead of re-querying or re-hydrating.
if (array_key_exists($name, $this->relations)) {
return $this->relations[$name];
}

$result = $this->$name();
return $result instanceof Relation ? $result->getResults() : $result;

if ($result instanceof Relation) {
return $this->relations[$name] = $result->getResults();
}

return $result;
}

if (!$attribute_exists) {
Expand Down
101 changes: 101 additions & 0 deletions src/Database/Barry/Relation.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Bow\Database\Barry;

use Closure;
use Bow\Database\Collection;
use Bow\Database\QueryBuilder;

abstract class Relation
Expand Down Expand Up @@ -139,4 +141,103 @@ public function create(array $attributes): Model
* @return mixed
*/
abstract public function getResults(): mixed;

/**
* The parent attribute whose value is matched against the related models.
*
* @return string
*/
abstract protected function eagerParentKey(): string;

/**
* The related column queried when eager loading the relation.
*
* @return string
*/
abstract protected function eagerRelatedKey(): string;

/**
* Whether the relation resolves to many related models.
*
* @return bool
*/
abstract protected function eagerIsMany(): bool;

/**
* Run the given callback with relation constraints disabled.
*
* Lets the eager loader build a relation without the single parent WHERE
* clause so it can be replaced by a batched whereIn over every parent.
*
* @param Closure $callback
* @return mixed
*/
public static function noConstraints(Closure $callback): mixed
{
$previous = static::$has_constraints;
static::$has_constraints = false;

try {
return $callback();
} finally {
static::$has_constraints = $previous;
}
}

/**
* Constrain the relation query to every parent key in a single whereIn.
*
* @param Model[] $parents
* @return void
*/
public function addEagerConstraints(array $parents): void
{
$keys = array_values(array_unique(array_filter(
array_map(fn (Model $parent) => $parent->getAttribute($this->eagerParentKey()), $parents),
fn ($value) => !is_null($value)
)));

// Fall back to an impossible match when no parent exposes a key so the
// query stays well-formed and returns nothing.
$this->query->whereIn($this->eagerRelatedKey(), count($keys) > 0 ? $keys : [0]);
}

/**
* Execute the eager query and return the related models.
*
* @return Collection
*/
public function getEager(): Collection
{
$results = $this->query->get();

return $results instanceof Collection ? $results : new Collection([]);
}

/**
* Match the eager loaded related models back onto their parents.
*
* @param Model[] $parents
* @param Collection $results
* @param string $name
* @return void
*/
public function match(array $parents, Collection $results, string $name): void
{
$dictionary = [];

foreach ($results as $related) {
$dictionary[$related->getAttribute($this->eagerRelatedKey())][] = $related;
}

foreach ($parents as $parent) {
$key = $parent->getAttribute($this->eagerParentKey());
$matched = $dictionary[$key] ?? [];

$parent->setRelation(
$name,
$this->eagerIsMany() ? new Collection($matched) : ($matched[0] ?? null)
);
}
}
}
50 changes: 27 additions & 23 deletions src/Database/Barry/Relations/BelongsTo.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

namespace Bow\Database\Barry\Relations;

use Bow\Cache\Cache;
use Bow\Database\Barry\Model;
use Bow\Database\Barry\Relation;
use Bow\Database\Exception\QueryBuilderException;
Expand Down Expand Up @@ -38,28 +37,9 @@ public function __construct(
*/
public function getResults(): mixed
{
// Include the parent's foreign key value in the cache key so each parent
// resolves to its own related model. Without it the key is identical for
// every parent and a loop would always return the first cached result.
$foreign_key_value = $this->parent->getAttribute($this->foreign_key);
$key = $this->query->getTable() . ":" . $this->local_key . ":belongsto:"
. $this->related->getTable() . ":" . $this->foreign_key . ":" . $foreign_key_value;

$cache = Cache::store('file')->get($key);

if (!is_null($cache)) {
$related = new $this->related();
$related->setAttributes($cache);
return $related;
}

$result = $this->query->first();

if (!is_null($result)) {
Cache::store('file')->set($key, $result->toArray(), 500);
}

return $result;
// The result is lazy-loaded once per parent model instance and kept in
// memory by the model itself, so a plain query is enough here.
return $this->query->first();
}

/**
Expand All @@ -80,4 +60,28 @@ public function addConstraints(): void
$foreign_key_value = $this->parent->getAttribute($this->foreign_key);
$this->query->where($this->local_key, '=', $foreign_key_value);
}

/**
* @inheritDoc
*/
protected function eagerParentKey(): string
{
return $this->foreign_key;
}

/**
* @inheritDoc
*/
protected function eagerRelatedKey(): string
{
return $this->local_key;
}

/**
* @inheritDoc
*/
protected function eagerIsMany(): bool
{
return false;
}
}
24 changes: 24 additions & 0 deletions src/Database/Barry/Relations/BelongsToMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,28 @@ public function addConstraints(): void
$foreign_key_value = $this->parent->getAttribute($this->foreign_key);
$this->query->where($this->local_key, '=', $foreign_key_value);
}

/**
* @inheritDoc
*/
protected function eagerParentKey(): string
{
return $this->foreign_key;
}

/**
* @inheritDoc
*/
protected function eagerRelatedKey(): string
{
return $this->local_key;
}

/**
* @inheritDoc
*/
protected function eagerIsMany(): bool
{
return true;
}
}
Loading
Loading