Model.php

PHP

Path: src/Database/Model.php

<?php

namespace mini\Database;

use mini\Authorizer\Ability;
use mini\Exceptions\AccessDeniedException;
use mini\Mini;
use mini\Validator\Purpose;
use mini\Validator\ValidationError;
use mini\Validator\ValidatorStore;

use function mini\can;
use function mini\model;

/**
 * Abstract base class for database entities (Active Record pattern)
 *
 * Provides save(), delete(), find(), and query() methods for entities.
 * Table name is detected from #[Table] attribute via model().
 * Primary key is detected from #[PrimaryKey] attribute via model() (defaults to 'id').
 *
 * Automatically handles:
 * - Dehydration (entity → array) via Dehydrator (includes #[CreatedAt]/#[UpdatedAt] timestamps)
 * - Validation via WriteValidator (if validation attributes are present)
 * - Identity tracking (correctly detects insert vs update even if PK changes)
 * - Authorization via can() system (override provideCanList/Create/Read/Update/Delete)
 *
 * Safe vs Unsafe methods:
 * - saveUnsafe(), deleteUnsafe() are **final** — guaranteed persistence layer
 * - save(), delete() use two-layer auth: can() + updatable()/deletable() row scoping
 * - query(), find() are **overridable** — default pass-through to unsafe methods
 * - updatable(), deletable() — row-level write scoping (default to query())
 * - queryUnsafe(), findUnsafe() are **final** — raw database access
 *
 * Override query() to filter rows by user permissions:
 * ```php
 * public static function query(): Query {
 *     auth()->requireLogin();
 *     return static::queryUnsafe()->eq('org_id', auth()->getClaim('org_id'));
 * }
 * ```
 *
 * Override provideCanUpdate/Delete etc. for action authorization:
 * ```php
 * public function provideCanUpdate(): ?bool {
 *     return $this->user_id === auth()->getUserId();
 * }
 * ```
 *
 * ```php
 * #[Table('users')]
 * class User extends Model
 * {
 *     #[PrimaryKey]
 *     public ?int $id = null;
 *
 *     public string $email = '';
 *     public string $name = '';
 * }
 * ```
 */
abstract class Model
{
    /**
     * Tracks the original primary key value from when entity was loaded.
     * Used to correctly detect insert vs update even if PK is changed.
     */
    private mixed $_modelOriginalId = null;

    /**
     * Whether this entity has been deleted from the database.
     * Prevents accidental re-insertion of deleted entities.
     */
    private bool $_modelDeleted = false;

    /**
     * Snapshot of property values from when the entity was loaded or last saved.
     * Used for dirty tracking — null means entity was never loaded (i.e., it's new).
     */
    private ?array $_modelOriginalData = null;

    /**
     * Get the database connection
     *
     * Override to use vdb() or other DatabaseInterface implementations.
     * The provide* prefix signals this is a framework declaration point.
     */
    protected static function provideDatabase(): DatabaseInterface
    {
        return Mini::$mini->get(DatabaseInterface::class);
    }

    /**
     * Get the original primary key from when entity was loaded.
     *
     * Returns null for new (unsaved) entities.
     * Used internally for insert vs update detection and authorization checks.
     */
    protected function getOriginalPrimaryKey(): mixed
    {
        return $this->_modelOriginalId;
    }

    // =========================================================================
    // Authorization declaration points
    //
    // Override these to control who can perform actions on this entity.
    // Return true to allow, false to deny, null for no opinion (→ default allow).
    // The provide* prefix signals these are framework declaration points.
    // =========================================================================

    /** Can the current user list entities of this class? */
    public static function provideCanList(): ?bool { return null; }

    /** Can the current user create new entities of this class? */
    public static function provideCanCreate(): ?bool { return null; }

    /** Can the current user read this entity? */
    public function provideCanRead(): ?bool { return null; }

    /** Can the current user update this entity? */
    public function provideCanUpdate(): ?bool { return null; }

    /** Can the current user delete this entity? */
    public function provideCanDelete(): ?bool { return null; }

    // =========================================================================
    // Unsafe methods - raw database access, no authorization filtering
    // =========================================================================

    /**
     * Create a query builder without authorization filtering
     *
     * Use this for system operations (CLI, migrations, background jobs)
     * or when you need to bypass user-based row filtering.
     */
    public static final function queryUnsafe(): Query
    {
        $db = static::provideDatabase();
        $table = $db->quoteIdentifier(model(static::class)->tableName);

        return $db->query("SELECT * FROM {$table}")
            ->withEntityClass(static::class)
            ->withLoadCallback(fn(object $entity) => $entity->markLoaded());
    }

    /**
     * Find an entity by primary key without authorization filtering
     */
    public static final function findUnsafe(mixed $id): ?static
    {
        return static::queryUnsafe()
            ->eq(model(static::class)->primaryKey, $id)
            ->limit(1)
            ->one();
    }

    /**
     * Save entity without authorization check — the guaranteed persistence layer
     *
     * Handles timestamps, dehydration, validation, and the actual INSERT/UPDATE.
     * Cannot be overridden — override save() instead for custom logic.
     *
     * @param string[]|null $only Only save these properties (null = all). Filters the
     *     columns written to the database. Validation still runs against full entity state.
     * @param Query|null $scope Query scope for the UPDATE target. Defaults to queryUnsafe().
     *     Used by save() to pass updatable() scope, making the row-level check atomic with the write.
     * @return int Number of affected rows
     * @throws \mini\ValidationException If validation fails
     */
    public final function saveUnsafe(?array $only = null, ?Query $scope = null): int
    {
        if ($this->_modelDeleted) {
            throw new \LogicException(
                "Cannot save a deleted entity. Create a new instance instead."
            );
        }

        $info = model(static::class);
        $pk = $info->primaryKey;
        $entityClass = static::class;
        $db = static::provideDatabase();
        $data = Dehydrator::dehydrate($this);
        $isUpdate = $this->getOriginalPrimaryKey() !== null;

        // Detect primary key mutation — always a programming error
        if ($this->_modelOriginalId !== null && $this->_modelOriginalId !== $this->{$pk}) {
            $from = var_export($this->_modelOriginalId, true);
            $to = var_export($this->{$pk}, true);
            throw new \LogicException(
                "Primary key '{$pk}' was changed from {$from} to {$to} on {$entityClass}. "
                . "Delete and re-insert instead of mutating the primary key."
            );
        }

        if ($isUpdate) {

            // Fetch current DB state for validation
            $currentData = [];
            $current = static::findUnsafe($this->getOriginalPrimaryKey());
            if ($current !== null) {
                $currentData = Dehydrator::dehydrate($current);
            }
            WriteValidator::validateUpdate($entityClass, $currentData, $data);

            unset($data[$pk]);

            // Partial save: only write specified columns
            if ($only !== null) {
                $data = array_intersect_key($data, array_flip($only));
            }

            $updateQuery = ($scope ?? static::queryUnsafe())
                ->eq($pk, $this->getOriginalPrimaryKey())
                ->limit(1);
            $affected = $db->update($updateQuery, $data);

            if (isset($this->{$pk})) {
                $this->_modelOriginalId = $this->{$pk};
            }
        } else {
            WriteValidator::validateInsert($entityClass, $data);

            if ($data[$pk] === null) {
                // Auto-increment: let DB generate the PK
                unset($data[$pk]);
                $newId = $db->insert($info->tableName, $data);
                $this->{$pk} = $newId;
            } else {
                // Developer-supplied PK (e.g. UUID): include in INSERT
                $db->insert($info->tableName, $data);
            }

            $this->_modelOriginalId = $this->{$pk};
            $affected = 1;
        }

        // Re-snapshot so entity is clean after save
        $this->_modelOriginalData = $this->_snapshotProperties();

        return $affected;
    }

    /**
     * Delete entity without authorization check — the guaranteed persistence layer
     *
     * Cannot be overridden — override delete() instead for custom logic.
     *
     * @param Query|null $scope Query scope for the DELETE target. Defaults to queryUnsafe().
     *     Used by delete() to pass deletable() scope, making the row-level check atomic with the write.
     * @return int Number of affected rows
     * @throws \RuntimeException If entity has no identity
     */
    public final function deleteUnsafe(?Query $scope = null): int
    {
        if ($this->_modelDeleted) {
            throw new \LogicException("Cannot delete an already deleted entity.");
        }

        $pk = model(static::class)->primaryKey;
        $id = $this->getOriginalPrimaryKey() ?? $this->{$pk} ?? null;

        if ($id === null) {
            throw new \RuntimeException("Cannot delete entity without primary key");
        }

        $deleteQuery = ($scope ?? static::queryUnsafe())->eq($pk, $id)->limit(1);
        $affected = static::provideDatabase()->delete($deleteQuery);

        if ($affected > 0) {
            $this->_modelOriginalId = null;
            $this->_modelDeleted = true;
        }

        return $affected;
    }

    // =========================================================================
    // Safe methods - with authorization
    // Override query() for row-level scoping, providecan*() for action authorization
    // =========================================================================

    /**
     * Create a query builder with authorization filtering
     *
     * Override this method to filter rows based on the current user:
     * ```php
     * public static function query(): Query {
     *     return static::queryUnsafe()->eq('user_id', auth()->userId());
     * }
     * ```
     *
     * By default, no filtering is applied (same as queryUnsafe).
     */
    public static function query(): Query
    {
        return static::queryUnsafe();
    }

    /**
     * Find an entity by primary key with authorization filtering
     *
     * Returns null if entity doesn't exist OR if current user lacks access.
     */
    public static function find(mixed $id): ?static
    {
        return static::query()
            ->eq(model(static::class)->primaryKey, $id)
            ->limit(1)
            ->one();
    }

    /**
     * Row-level scope for updates — which rows can be updated?
     *
     * Defaults to query() (same as read scoping). Override to diverge:
     * ```php
     * public static function updatable(): Query {
     *     return static::queryUnsafe()->eq('owner_id', auth()->getUserId());
     * }
     * ```
     */
    public static function updatable(): Query
    {
        return static::query();
    }

    /**
     * Row-level scope for deletes — which rows can be deleted?
     *
     * Defaults to query() (same as read scoping). Override to diverge:
     * ```php
     * public static function deletable(): Query {
     *     return static::queryUnsafe()->eq('owner_id', auth()->getUserId());
     * }
     * ```
     */
    public static function deletable(): Query
    {
        return static::query();
    }

    /**
     * Save entity with authorization
     *
     * Two layers:
     * 1. can() — action authorization (provideCanUpdate/provideCanCreate)
     * 2. updatable() — row-level write scoping (entity must be reachable)
     *
     * @param string[]|null $only Only save these properties (null = all)
     * @return int Number of affected rows
     * @throws AccessDeniedException If not authorized
     * @throws \mini\ValidationException If validation fails
     */
    public function save(?array $only = null): int
    {
        $originalId = $this->getOriginalPrimaryKey();

        if ($originalId !== null) {
            if (!can(Ability::Update, $this)) {
                throw new AccessDeniedException("Not authorized to update this entity");
            }
            // Row-level scoping is atomic with the UPDATE — if the row isn't
            // reachable via updatable(), the UPDATE affects 0 rows.
            $affected = $this->saveUnsafe($only, static::updatable());
            if ($affected === 0) {
                throw new AccessDeniedException("Not authorized to update this entity");
            }
            return $affected;
        }

        if (!can(Ability::Create, static::class)) {
            throw new AccessDeniedException("Not authorized to create this entity");
        }

        return $this->saveUnsafe($only);
    }

    /**
     * Delete entity with authorization
     *
     * Two layers:
     * 1. can() — action authorization (provideCanDelete)
     * 2. deletable() — row-level write scoping (entity must be reachable)
     *
     * @return int Number of affected rows
     * @throws AccessDeniedException If not authorized
     * @throws \RuntimeException If entity has no identity
     */
    public function delete(): int
    {
        if (!can(Ability::Delete, $this)) {
            throw new AccessDeniedException("Not authorized to delete this entity");
        }

        // Row-level scoping is atomic with the DELETE — if the row isn't
        // reachable via deletable(), the DELETE affects 0 rows.
        $affected = $this->deleteUnsafe(static::deletable());
        if ($affected === 0) {
            throw new AccessDeniedException("Not authorized to delete this entity");
        }
        return $affected;
    }

    // =========================================================================
    // Validation & dirty tracking
    // =========================================================================

    /**
     * Check which properties have changed since the entity was loaded (or last saved).
     *
     * Returns null if the entity is clean or was never loaded from the database
     * (new entities are not considered dirty — they have no original state).
     *
     * Returns an associative array of original values for changed properties:
     *   ['name' => 'Old Name', 'email' => 'old@example.com']
     *
     * The caller can get the new values from the entity itself.
     *
     * @return array<string, mixed>|null Original values of changed properties, or null if clean
     */
    public function isDirty(): ?array
    {
        if ($this->_modelOriginalData === null) {
            return null;
        }

        $dirty = [];
        $refClass = new \ReflectionClass($this);

        foreach ($refClass->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) {
            if ($prop->isStatic()) {
                continue;
            }

            $name = $prop->getName();

            if (!array_key_exists($name, $this->_modelOriginalData)) {
                continue;
            }

            $current = $prop->isInitialized($this) ? $prop->getValue($this) : null;
            $original = $this->_modelOriginalData[$name];

            if ($current !== $original) {
                $dirty[$name] = $original;
            }
        }

        return $dirty ?: null;
    }

    /**
     * Check if entity is valid without saving
     *
     * Auto-detects Create vs Update purpose based on entity state.
     * Runs purpose-scoped validation first, then core validation.
     *
     * @return ValidationError|null Null if valid, ValidationError if invalid
     */
    public function isInvalid(): ?ValidationError
    {
        $data = Dehydrator::dehydrate($this);
        $store = Mini::$mini->get(ValidatorStore::class);
        $purpose = $this->getOriginalPrimaryKey() !== null
            ? Purpose::Update
            : Purpose::Create;

        // Purpose-scoped validation
        $error = $store->get(static::class, $purpose)->isInvalid($data);
        if ($error !== null) {
            return $error;
        }

        // Core validation
        return $store->get(static::class)->isInvalid($data);
    }

    // =========================================================================
    // Internal
    // =========================================================================

    /**
     * Reset identity on clone — cloned entity is treated as new (unsaved)
     */
    public function __clone(): void
    {
        $this->_modelOriginalId = null;
        $this->_modelOriginalData = null;
        $this->_modelDeleted = false;
    }

    /**
     * Mark this entity as loaded from the database
     *
     * Called by PartialQuery when hydrating entities.
     * Sets the original identity for correct insert/update detection
     * and snapshots property values for dirty tracking.
     *
     * @internal
     */
    private function markLoaded(): void
    {
        $pk = model(static::class)->primaryKey;
        if (isset($this->{$pk})) {
            $this->_modelOriginalId = $this->{$pk};
        }
        $this->_modelOriginalData = $this->_snapshotProperties();
    }

    /**
     * Snapshot all public property values for dirty tracking
     */
    private function _snapshotProperties(): array
    {
        $data = [];
        $refClass = new \ReflectionClass($this);

        foreach ($refClass->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) {
            if ($prop->isStatic()) {
                continue;
            }
            if ($prop->isInitialized($this)) {
                $data[$prop->getName()] = $prop->getValue($this);
            }
        }

        return $data;
    }
}