. // }}} namespace App\Core; use App\Entity\Actor; use App\Util\Exception\BugFoundException; use App\Util\Exception\NotFoundException; use App\Util\Formatting; use BadMethodCallException; use Component\Notification\Entity\Attention; use DateTime; use DateTimeInterface; use Exception; use Functional as F; /** * Base class to all entities, with some utilities * * @method int getId() // Not strictly true */ abstract class Entity { /** * @return string Returns the name of this entity's DB table */ public static function schemaName(): string { return static::schemaDef()['name']; } public function __call(string $name, array $arguments): mixed { if (Formatting::startsWith($name, 'has')) { $prop = Formatting::camelCaseToSnakeCase(Formatting::removePrefix($name, 'has')); // https://wiki.php.net/rfc/closure_apply#proposal $private_property_accessor = fn ($prop) => isset($this->{$prop}); $private_property_accessor = $private_property_accessor->bindTo($this, static::class); return $private_property_accessor($prop); } throw new BadMethodCallException('Non existent non-static method ' . static::class . "->{$name} called with arguments: " . var_export($arguments, true)); } abstract public static function schemaDef(): array; /** * Create an instance of the called class or fill in the * properties of $obj with the associative array $args. Doesn't * persist the result */ public static function create(array $args, bool $_delegated_call = false): static { $date = new DateTime(); $class = static::class; $obj = new $class(); foreach (['created', 'modified'] as $prop) { if (property_exists($class, $prop) && !isset($args[$prop])) { $args[$prop] = $date; } } if (!$_delegated_call) { return static::createOrUpdate($obj, $args, _delegated_call: true); } else { return $obj; } } /** * @param ?static $obj */ public static function createOrUpdate(?self $obj, array $args, bool $_delegated_call = false): static { $date = new DateTime(); $class = static::class; if (!$_delegated_call) { if (property_exists($class, 'modified') && !isset($args['modified'])) { $args['modified'] = $date; } } if (\is_null($obj)) { $obj = static::create($args, _delegated_call: true); } foreach ($args as $prop => $val) { if (property_exists($obj, $prop)) { $set = 'set' . ucfirst(Formatting::snakeCaseToCamelCase($prop)); $obj->{$set}($val); } else { throw new BugFoundException("Property {$class}::{$prop} doesn't exist"); } } return $obj; } /** * Create a new instance, but check for duplicates * * @throws \App\Util\Exception\ServerException * * @return array [$obj, $is_update] */ public static function checkExistingAndCreateOrUpdate(array $args, array $find_by_keys = []): array { $find_by = $find_by_keys === [] ? $args : array_intersect_key($args, array_flip($find_by_keys)); try { $obj = DB::findOneBy(static::class, $find_by, return_null: false); } catch (NotFoundException) { $obj = null; // @codeCoverageIgnoreStart } catch (Exception $e) { Log::unexpected_exception($e); // @codeCoverageIgnoreEnd } $is_update = !\is_null($obj); return [self::createOrUpdate($obj, $args), $is_update]; } /** * Get an Entity from its primary key * * Support multiple formats: * - mixed $values - convert to array and check next * - array[int => mixed] $values - get keys for entity and set them in order and proceed to next case * - array[string => mixed] $values - Perform a regular find * * Examples: * Entity::getByPK(42); * Entity::getByPK([42, 'foo']); * Entity::getByPK(['key1' => 42, 'key2' => 'foo']) * * @throws \App\Util\Exception\DuplicateFoundException */ public static function getByPK(mixed $values): ?self { $values = \is_array($values) ? $values : [$values]; $class = static::class; $keys = DB::getPKForClass($class); $find_by = []; foreach ($values as $k => $v) { if (\is_string($k)) { $find_by[$k] = $v; } else { $find_by[$keys[$k]] = $v; } } try { return DB::findOneBy($class, $find_by); } catch (NotFoundException $e) { return null; } } /** * Tests that this object is equal to another one based on a custom object comparison * * @param self $other the value to test * * @return bool true if equals, false otherwise */ public function equals(self $other): bool { foreach (array_keys($this::schemaDef()['fields']) as $attribute) { $getter = 'get' . Formatting::snakeCaseToPascalCase($attribute); $current = $this->{$getter}(); $target = $other->{$getter}(); if ($current instanceof DateTimeInterface) { if ($current->getTimestamp() !== $target->getTimestamp()) { return false; } } else { if ($current !== $target) { return false; } } } return true; } /** * Ids of the Actors that should be informed about this object. * BEWARE: If you call this, your object must have a serial integer id! * * @return array int[] of Actor's id */ public function getAttentionTargetIds(): array { $attention = DB::findBy(Attention::class, [ 'object_type' => static::schemaName(), 'object_id' => $this->getId(), ]); return F\map($attention, fn ($cc) => $cc->getTargetId()); } /** * To whom should this be brought attention to? * * @return array Actor[] */ public function getAttentionTargets(): array { $target_ids = $this->getAttentionTargetIds(); return DB::findBy(Actor::class, ['id' => $target_ids]); } }