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
32 changes: 32 additions & 0 deletions src/Phaseolies/Database/Entity/Attributes/Computed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace Phaseolies\Database\Entity\Attributes;

/**
* Marks a model method as a computed (virtual) property.
*
* Computed properties are derived values — they are never persisted to the
* database, never included in dirty-checking, and are automatically appended
* to toArray() / toJson() output alongside real attributes.
*
* Usage:
*
* #[Computed]
* public function fullName(): string
* {
* return "{$this->first_name} {$this->last_name}";
* }
*
* Access:
*
* $user->fullName // calls the method transparently
* $user->full_name // calls the method transparently
* $user->toArray() // includes 'fullName' automatically
* json_encode($user) // includes 'fullName' automatically
*
* Hiding a computed property from serialization:
*
* $user->makeHidden(['fullName'])->toArray();
*/
#[\Attribute(\Attribute::TARGET_METHOD)]
final class Computed {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

namespace Phaseolies\Database\Entity\Computed;

use Phaseolies\Database\Entity\Attributes\Computed;

trait InteractsWithComputedProperties
{
/**
* Per-class cache of method names decorated with #[Computed].
*
* @var array<string, list<string>>
*/
private static array $computedAttributeCache = [];

/**
* Ensure computed method names are scanned and cached for this class.
*
* @return void
*/
private function ensureComputedCached(): void
{
$class = static::class;

if (!array_key_exists($class, self::$computedAttributeCache)) {
self::$computedAttributeCache[$class] = self::scanComputedAttributes($class);
}
}

/**
* Scan the class for public methods decorated with #[Computed]
*
* @param string $class
* @return list<array{method: string, key: string}>
*/
private static function scanComputedAttributes(string $class): array
{
$found = [];
$reflection = new \ReflectionClass($class);

foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
if (!empty($method->getAttributes(Computed::class))) {
$methodName = $method->getName();
$found[] = [
'method' => $methodName,
'key' => str()->snake($methodName),
];
}
}

return $found;
}

/**
* Determine whether the given name maps to a #[Computed] method
*
* @param string $name
* @return bool
*/
public function isComputed(string $name): bool
{
$this->ensureComputedCached();

foreach (self::$computedAttributeCache[static::class] as $entry) {
if ($entry['key'] === $name || $entry['method'] === $name) {
return true;
}
}

return false;
}

/**
* Call the computed method that corresponds to the given name
*
* @param string $name
* @return mixed
*/
public function resolveComputed(string $name): mixed
{
$this->ensureComputedCached();

foreach (self::$computedAttributeCache[static::class] as $entry) {
if ($entry['key'] === $name || $entry['method'] === $name) {
return $this->{$entry['method']}();
}
}

return null;
}

/**
* Resolve all computed properties to a snake_case key => value map.
*
* @return array<string, mixed>
*/
public function getComputedAttributes(): array
{
$this->ensureComputedCached();

$result = [];

foreach (self::$computedAttributeCache[static::class] as $entry) {
if (!in_array($entry['key'], $this->unexposable, true) &&
!in_array($entry['method'], $this->unexposable, true)) {
$result[$entry['key']] = $this->{$entry['method']}();
}
}

return $result;
}

/**
* Reset the #[Computed] scan cache for one or all model classes.
*
* @param string|null $class
* @return void
*/
public static function resetComputedCache(?string $class = null): void
{
if ($class !== null) {
unset(self::$computedAttributeCache[$class]);
} else {
self::$computedAttributeCache = [];
}
}
}
12 changes: 11 additions & 1 deletion src/Phaseolies/Database/Entity/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Phaseolies\Database\Entity\Casts\InteractsWithCasting;
use Phaseolies\Database\Temporal\InteractsWithTemporal;
use Phaseolies\Database\Entity\Watches\InteractsWithWatches;
use Phaseolies\Database\Entity\Computed\InteractsWithComputedProperties;
use Phaseolies\Database\Database;
use Phaseolies\Database\Contracts\Support\Jsonable;
use PDO;
Expand All @@ -22,6 +23,7 @@ abstract class Model implements ArrayAccess, JsonSerializable, Stringable, Jsona
use InteractsWithTemporal;
use InteractsWithCasting;
use InteractsWithWatches;
use InteractsWithComputedProperties;

/**
* The name of the database table associated with the model.
Expand Down Expand Up @@ -562,6 +564,10 @@ public function makeVisible(): array
}
}

foreach ($this->getComputedAttributes() as $key => $value) {
$visibleAttributes[$key] = $value;
}

return $visibleAttributes;
}

Expand Down Expand Up @@ -873,6 +879,10 @@ public function __get($name)
return $this->relations[$name];
}

if ($this->isComputed($name)) {
return $this->resolveComputed($name);
}

if (method_exists($this, $name)) {
$relation = $this->$name();

Expand Down Expand Up @@ -942,7 +952,7 @@ public function __get($name)
}

return $this->attributes[$name];
} catch (\Throwable $th) {
} catch (\Throwable) {
return;
}
}
Expand Down
Loading
Loading