diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateTemporalCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateTemporalCommand.php new file mode 100644 index 0000000..95badb1 --- /dev/null +++ b/src/Phaseolies/Console/Commands/Migrations/MigrateTemporalCommand.php @@ -0,0 +1,272 @@ +executeWithTiming(function () { + $connection = $this->option('connection') ?: config('database.default'); + $dryRun = (bool) $this->option('show'); + $modelsPath = $this->option('path') ?: base_path('app/Models'); + + try { + $pdo = Database::getPdoInstance($connection ?: null); + $driver = strtolower($pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + } catch (\Throwable $e) { + $this->displayError('Failed to connect to database: ' . $e->getMessage()); + return Command::FAILURE; + } + + $this->line("🕐 Temporal Migration — connection: {$connection}"); + $this->newLine(); + + $classes = $this->discoverTemporalModels($modelsPath); + + if (empty($classes)) { + $this->displayInfo('No #[Temporal] models found in ' . $modelsPath); + return Command::SUCCESS; + } + + $created = 0; + $skipped = 0; + + foreach ($classes as $class) { + $attr = TemporalManager::getAttribute($class); + $model = new $class(); + $baseTable = $model->getTable(); + $historyTable = TemporalManager::historyTable($baseTable, $class); + + $this->line(" Model : {$class}"); + $this->line(" Base table : {$baseTable}"); + $this->line(" History table : {$historyTable}"); + + if (!$dryRun && $this->tableExists($pdo, $driver, $historyTable)) { + // Table exists — check if we need to add the actor column + if ($attr?->trackActor && !$this->columnExists($pdo, $driver, $historyTable, 'actor')) { + $sql = $this->addActorColumnSql($driver, $historyTable); + $pdo->exec($sql); + $this->line(' → Added missing `actor` column.'); + $created++; + } else { + $this->line(' → Already exists, skipped.'); + $skipped++; + } + $this->newLine(); + continue; + } + + $statements = TemporalManager::createHistoryTableSql( + historyTable: $historyTable, + driver: $driver, + trackActor: $attr?->trackActor ?? false, + ); + + if ($dryRun) { + foreach ($statements as $sql) { + $this->line(' SQL: ' . preg_replace('/\s+/', ' ', trim($sql))); + } + } else { + foreach ($statements as $sql) { + $pdo->exec($sql); + } + $this->line(' → Created successfully.'); + $created++; + } + + $this->newLine(); + } + + if (!$dryRun) { + $this->displaySuccess( + "Done. Created: {$created}" . ($skipped ? ", Skipped (already exist): {$skipped}" : '') + ); + } + + return Command::SUCCESS; + }); + } + + /** + * Scan the Models directory for classes that extend Model and carry #[Temporal]. + * + * @param string $path + * @return string[] + */ + private function discoverTemporalModels(string $path): array + { + if (!is_dir($path)) { + return []; + } + + $found = []; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); + + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $class = $this->classFromFile($file->getPathname()); + + if ($class === null || !class_exists($class)) { + continue; + } + + if (!is_subclass_of($class, Model::class)) { + continue; + } + + $ref = new \ReflectionClass($class); + + if (!empty($ref->getAttributes(Temporal::class))) { + $found[] = $class; + } + } + + return $found; + } + + /** + * Extract the fully-qualified class name from a PHP file by reading its + * namespace and class declarations. + * + * @param string $filePath + * @return string|null + */ + private function classFromFile(string $filePath): ?string + { + $content = @file_get_contents($filePath); + + if ($content === false) { + return null; + } + + if (!preg_match('/^namespace\s+(.+?);/m', $content, $nsMatch)) { + return null; + } + + if (!preg_match('/^(?:abstract\s+)?class\s+(\w+)/m', $content, $classMatch)) { + return null; + } + + return $nsMatch[1] . '\\' . $classMatch[1]; + } + + /** + * Check whether a table already exists in the database. + * + * @param PDO $pdo + * @param string $driver + * @param string $table + * @return bool + */ + private function tableExists(PDO $pdo, string $driver, string $table): bool + { + try { + if ($driver === 'pgsql') { + $stmt = $pdo->prepare("SELECT to_regclass(?) IS NOT NULL"); + $stmt->execute([$table]); + return (bool) $stmt->fetchColumn(); + } + + if ($driver === 'sqlite') { + $stmt = $pdo->prepare("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?"); + $stmt->execute([$table]); + return (bool) $stmt->fetchColumn(); + } + + // MySQL + $stmt = $pdo->prepare("SHOW TABLES LIKE ?"); + $stmt->execute([$table]); + return $stmt->fetchColumn() !== false; + } catch (\Throwable) { + return false; + } + } + + /** + * Check whether a specific column exists in a table. + * + * @param PDO $pdo + * @param string $driver + * @param string $table + * @param string $column + * @return bool + */ + private function columnExists(PDO $pdo, string $driver, string $table, string $column): bool + { + try { + if ($driver === 'pgsql') { + $stmt = $pdo->prepare( + "SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = ? AND column_name = ?" + ); + $stmt->execute([$table, $column]); + return (bool) $stmt->fetchColumn(); + } + + if ($driver === 'sqlite') { + $stmt = $pdo->query("PRAGMA table_info(\"{$table}\")"); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) { + if ($col['name'] === $column) { + return true; + } + } + return false; + } + + // MySQL + $stmt = $pdo->prepare("SHOW COLUMNS FROM `{$table}` LIKE ?"); + $stmt->execute([$column]); + return $stmt->fetch() !== false; + } catch (\Throwable) { + return false; + } + } + + /** + * Generate the ALTER TABLE SQL to add the actor column. + * + * @param string $driver + * @param string $table + * @return string + */ + private function addActorColumnSql(string $driver, string $table): string + { + return match ($driver) { + 'pgsql' => "ALTER TABLE \"{$table}\" ADD COLUMN actor VARCHAR(255) NULL", + 'sqlite' => "ALTER TABLE \"{$table}\" ADD COLUMN actor TEXT NULL", + default => "ALTER TABLE `{$table}` ADD COLUMN `actor` VARCHAR(255) NULL", + }; + } +} diff --git a/src/Phaseolies/Database/Entity/Model.php b/src/Phaseolies/Database/Entity/Model.php index 46a0040..57e7edf 100644 --- a/src/Phaseolies/Database/Entity/Model.php +++ b/src/Phaseolies/Database/Entity/Model.php @@ -6,6 +6,7 @@ use Phaseolies\Support\Collection; use Phaseolies\Database\Entity\Query\InteractsWithModelQueryProcessing; use Phaseolies\Database\Entity\Hooks\HookHandler; +use Phaseolies\Database\Temporal\InteractsWithTemporal; use Phaseolies\Database\Database; use Phaseolies\Database\Contracts\Support\Jsonable; use PDO; @@ -16,6 +17,7 @@ abstract class Model implements ArrayAccess, JsonSerializable, Stringable, Jsonable { use InteractsWithModelQueryProcessing; + use InteractsWithTemporal; /** * The name of the database table associated with the model. @@ -186,6 +188,7 @@ protected function registerHooks(): void } $this->registerAttributeHooks(); + $this->registerTemporalHooks(); } /** diff --git a/src/Phaseolies/Database/Temporal/Attributes/Temporal.php b/src/Phaseolies/Database/Temporal/Attributes/Temporal.php new file mode 100644 index 0000000..79eabc8 --- /dev/null +++ b/src/Phaseolies/Database/Temporal/Attributes/Temporal.php @@ -0,0 +1,18 @@ + static fn(Model $model) => TemporalManager::snapshot($model, 'created'), + 'after_updated' => static fn(Model $model) => TemporalManager::snapshot($model, 'updated'), + 'after_deleted' => static fn(Model $model) => TemporalManager::snapshot($model, 'deleted'), + ]); + } + + /** + * Return a TemporalBuilder scoped to the given point in time. + * + * @param string $datetime + * @return TemporalBuilder + * + * @throws \RuntimeException + */ + public static function at(string $datetime): TemporalBuilder + { + if (!TemporalManager::isTemporal(static::class)) { + throw new \RuntimeException( + static::class . ' is not marked #[Temporal]. Add the attribute to enable time-travel queries.' + ); + } + + $model = new static(); + + return new TemporalBuilder( + pdo: $model->getConnection(), + table: $model->getTable(), + modelClass: static::class, + rowPerPage: $model->pageSize, + datetime: $datetime, + ); + } + + /** + * Return the complete audit history for this model instance + * + * @return Collection + * @throws \RuntimeException + */ + public function history(): Collection + { + $this->assertTemporalAndLoaded(); + + $historyTable = $this->historyTable(); + $pdo = $this->getConnection(); + + $stmt = $pdo->prepare( + "SELECT history_id, action, valid_from, snapshot, changed_cols, actor + FROM {$historyTable} + WHERE record_id = ? + ORDER BY valid_from ASC" + ); + $stmt->execute([$this->getKey()]); + + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + $models = []; + + foreach ($rows as $row) { + $data = json_decode($row['snapshot'], true) ?? []; + $entry = new static($data); + $entry->setOriginalAttributes($data); + + // Attach metadata as virtual attributes (not in $creatable → safe) + $entry->__history_id = (int) $row['history_id']; + $entry->__action = $row['action']; + $entry->__valid_from = $row['valid_from']; + $entry->__changed_cols = $row['changed_cols'] + ? json_decode($row['changed_cols'], true) + : null; + $entry->__actor = $row['actor'] ?? null; + + $models[] = $entry; + } + + return new Collection(static::class, $models); + } + + /** + * Compare the model's state at two points in time and return a structured diff. + * + * @param string $from + * @param string $to + * @return array|null + */ + public function diff(string $from, string $to): ?array + { + $this->assertTemporalAndLoaded(); + + $snapshotFrom = $this->snapshotAt($from); + $snapshotTo = $this->snapshotAt($to); + + if ($snapshotFrom === null || $snapshotTo === null) { + return null; + } + + $changes = []; + $added = []; + $removed = []; + + foreach ($snapshotTo as $col => $newVal) { + if (!array_key_exists($col, $snapshotFrom)) { + $added[$col] = $newVal; + } elseif ((string) $snapshotFrom[$col] !== (string) $newVal) { + $changes[$col] = ['from' => $snapshotFrom[$col], 'to' => $newVal]; + } + } + + foreach ($snapshotFrom as $col => $oldVal) { + if (!array_key_exists($col, $snapshotTo)) { + $removed[$col] = $oldVal; + } + } + + return [ + 'from' => $from, + 'to' => $to, + 'changes' => $changes, + 'added' => $added, + 'removed' => $removed, + ]; + } + + /** + * Return a new (unsaved) model instance populated with the state of this + * record at the given point in time. + * + * @param string $datetime + * @return static|null + */ + public function rewindTo(string $datetime): ?static + { + $this->assertTemporalAndLoaded(); + + $snapshot = $this->snapshotAt($datetime); + + if ($snapshot === null) { + return null; + } + + $rewound = new static($snapshot); + + // mark everything as dirty → full re-save + $rewound->setOriginalAttributes([]); + + return $rewound; + } + + /** + * Restore this model to its state at the given datetime and immediately + * persist the change. + * + * @param string $datetime + * @return bool + */ + public function restoreTo(string $datetime): bool + { + $rewound = $this->rewindTo($datetime); + + if ($rewound === null) { + return false; + } + + return $rewound->save(); + } + + /** + * Whether this model class is marked #[Temporal]. + * + * @return bool + */ + public function isTemporal(): bool + { + return TemporalManager::isTemporal(static::class); + } + + /** + * Return the history table name for this model. + * + * @return string + */ + public function historyTable(): string + { + return TemporalManager::historyTable($this->getTable(), static::class); + } + + /** + * Fetch the raw snapshot array for this record at the given datetime. + * + * @param string $datetime + * @return array|null + */ + private function snapshotAt(string $datetime): ?array + { + $historyTable = $this->historyTable(); + $pdo = $this->getConnection(); + + // Normalize to end-of-day when only a date is given + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', trim($datetime))) { + $datetime .= ' 23:59:59.999999'; + } + + $stmt = $pdo->prepare( + "SELECT snapshot + FROM {$historyTable} + WHERE record_id = ? + AND valid_from <= ? + AND action != 'deleted' + ORDER BY valid_from DESC + LIMIT 1" + ); + $stmt->execute([$this->getKey(), $datetime]); + + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? (json_decode($row['snapshot'], true) ?? null) : null; + } + + /** + * Assert that the model is temporal and has a primary key loaded. + * + * @throws \RuntimeException + */ + private function assertTemporalAndLoaded(): void + { + if (!TemporalManager::isTemporal(static::class)) { + throw new \RuntimeException( + static::class . ' is not marked #[Temporal].' + ); + } + + if ($this->getKey() === null) { + throw new \RuntimeException( + 'Cannot call temporal methods on an unsaved ' . static::class . ' instance.' + ); + } + } +} diff --git a/src/Phaseolies/Database/Temporal/TemporalBuilder.php b/src/Phaseolies/Database/Temporal/TemporalBuilder.php new file mode 100644 index 0000000..420160a --- /dev/null +++ b/src/Phaseolies/Database/Temporal/TemporalBuilder.php @@ -0,0 +1,348 @@ +datetime = $this->normalizeDateTime($datetime); + } + + /** + * Return a Collection of model instances as they existed at $this->datetime. + * + * Algorithm: + * For each distinct record_id in the history table, take the row with the + * largest valid_from that is still ≤ $datetime. Exclude records whose last + * action was 'deleted'. Hydrate each snapshot into a model instance. + * Apply any fluent where / orderBy / limit conditions in PHP. + * + * @return Collection + */ + public function get(): Collection + { + $rows = $this->fetchTemporalRows(); + $models = $this->hydrateSnapshots($rows); + $models = $this->applyPhpConditions($models); + $models = $this->applyPhpOrdering($models); + $models = $this->applyPhpLimit($models); + + return new Collection($this->modelClass, $models); + } + + /** + * Return the first model instance as it existed at $this->datetime + * + * @return Model|null + */ + public function first(): ?Model + { + $rows = $this->fetchTemporalRows(); + + if (empty($rows)) { + return null; + } + + $models = $this->hydrateSnapshots($rows); + $models = $this->applyPhpConditions($models); + $models = $this->applyPhpOrdering($models); + + return $models[0] ?? null; + } + + /** + * Find a specific record by primary key as it existed at $this->datetime. + * + * @param int|string $id + * @return Model|null + */ + public function find($id): ?Model + { + $historyTable = TemporalManager::historyTable($this->table, $this->modelClass); + $key = (new $this->modelClass())->getKeyName(); + + $sql = " + SELECT snapshot + FROM {$historyTable} + WHERE record_id = ? + AND valid_from <= ? + AND action != 'deleted' + ORDER BY valid_from DESC + LIMIT 1 + "; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute([$id, $this->datetime]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + return null; + } + + return $this->hydrateOne($row['snapshot']); + } + + /** + * Fetch the latest-snapshot-per-record rows from the history table + * + * Uses a correlated subquery for broad driver compatibility (MySQL 5.7+, + * PostgreSQL 9+, SQLite 3.7+). + * + * @return array + */ + private function fetchTemporalRows(): array + { + $historyTable = TemporalManager::historyTable($this->table, $this->modelClass); + + $sql = " + SELECT h1.snapshot, h1.record_id, h1.action + FROM {$historyTable} h1 + WHERE h1.valid_from = ( + SELECT MAX(h2.valid_from) + FROM {$historyTable} h2 + WHERE h2.record_id = h1.record_id + AND h2.valid_from <= ? + ) + AND h1.action != 'deleted' + "; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute([$this->datetime]); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Hydrate an array of raw history rows into model instances. + * + * @param array $rows + * @return Model[] + */ + private function hydrateSnapshots(array $rows): array + { + return array_map( + fn(array $row) => $this->hydrateOne($row['snapshot']), + $rows + ); + } + + /** + * Decode a JSON snapshot string and construct a model instance from it + * + * @param string $snapshotJson + * @return Model + */ + private function hydrateOne(string $snapshotJson): Model + { + $data = json_decode($snapshotJson, true) ?? []; + $model = new $this->modelClass($data); + $model->setOriginalAttributes($data); + + return $model; + } + + /** + * Apply the builder's $conditions array as a PHP-level filter on an array + * of already-hydrated model instances. + * + * @param Model[] $models + * @return Model[] + */ + private function applyPhpConditions(array $models): array + { + if (empty($this->conditions)) { + return $models; + } + + return array_values(array_filter($models, function (Model $model) { + $pass = true; + + foreach ($this->conditions as $condition) { + // Skip typed conditions (NESTED, EXISTS, RAW_WHERE, etc.) + if (isset($condition['type'])) { + continue; + } + + [$boolean, $field, $operator, $value] = array_pad($condition, 4, null); + $extra = $condition[4] ?? null; + + // Strip table prefix if present (e.g. "contracts.status" → "status") + $column = str_contains($field, '.') ? explode('.', $field, 2)[1] : $field; + $actual = $model->{$column} ?? null; + + $result = $this->evaluateCondition($actual, $operator, $value, $extra); + + $pass = $boolean === 'OR' ? ($pass || $result) : ($pass && $result); + } + + return $pass; + })); + } + + /** + * Evaluate a single condition against an actual value. + * + * @param mixed $actual + * @param string $operator + * @param mixed $value + * @param mixed $extra + * @return bool + */ + private function evaluateCondition(mixed $actual, string $operator, mixed $value, mixed $extra): bool + { + return match (strtoupper($operator)) { + '=' => $actual == $value, + '!=' => $actual != $value, + '<>' => $actual != $value, + '>' => $actual > $value, + '>=' => $actual >= $value, + '<' => $actual < $value, + '<=' => $actual <= $value, + 'IS NULL' => $actual === null, + 'IS NOT NULL' => $actual !== null, + 'IN' => in_array($actual, (array) $value, strict: false), + 'NOT IN' => !in_array($actual, (array) $value, strict: false), + 'BETWEEN' => $actual >= $value && $actual <= $extra, + 'NOT BETWEEN' => $actual < $value || $actual > $extra, + 'LIKE', 'ILIKE' => $this->phpLike($actual, $value), + 'NOT LIKE' => !$this->phpLike($actual, $value), + default => true, + }; + } + + /** + * Emulate SQL LIKE pattern matching in PHP. + * Converts SQL wildcards (% and _) to regex equivalents. + * + * @param mixed $actual + * @param string $pattern + * @return bool + */ + private function phpLike(mixed $actual, string $pattern): bool + { + $regex = preg_quote($pattern, '/'); + $regex = str_replace(['%', '_'], ['.*', '.'], $regex); + + return (bool) preg_match('/^' . $regex . '$/iu', (string) $actual); + } + + /** + * Apply $this->orderBy clauses to an in-memory model array. + * + * @param Model[] $models + * @return Model[] + */ + private function applyPhpOrdering(array $models): array + { + if (empty($this->orderBy)) { + return $models; + } + + usort($models, function (Model $a, Model $b) { + foreach ($this->orderBy as $order) { + // Skip raw ORDER BY entries + if (isset($order['type'])) { + continue; + } + + [$field, $direction] = $order; + $col = str_contains($field, '.') ? explode('.', $field, 2)[1] : $field; + + $va = $a->{$col} ?? null; + $vb = $b->{$col} ?? null; + + $cmp = $va <=> $vb; + + if ($cmp !== 0) { + return strtoupper($direction) === 'DESC' ? -$cmp : $cmp; + } + } + + return 0; + }); + + return $models; + } + + /** + * Apply $this->limit and $this->offset to an in-memory model array. + * + * @param Model[] $models + * @return Model[] + */ + private function applyPhpLimit(array $models): array + { + $offset = $this->offset ?? 0; + $limit = $this->limit; + + if ($offset > 0) { + $models = array_slice($models, $offset); + } + + if ($limit !== null) { + $models = array_slice($models, 0, $limit); + } + + return $models; + } + + /** + * Normalize various human-friendly datetime strings into a sortable + * + * @param string $datetime + * @return string + */ + private function normalizeDateTime(string $datetime): string + { + $datetime = trim($datetime); + + // Date-only: append end-of-day + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $datetime)) { + return $datetime . ' 23:59:59.999999'; + } + + // Date + HH:MM only + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $datetime)) { + return $datetime . ':59.999999'; + } + + // Date + HH:MM:SS only + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $datetime)) { + return $datetime . '.999999'; + } + + return $datetime; + } + + /** + * Expose the resolved datetime for external use (e.g. in TemporalEntry). + * + * @return string + */ + public function getDatetime(): string + { + return $this->datetime; + } +} diff --git a/src/Phaseolies/Database/Temporal/TemporalManager.php b/src/Phaseolies/Database/Temporal/TemporalManager.php new file mode 100644 index 0000000..8a1304b --- /dev/null +++ b/src/Phaseolies/Database/Temporal/TemporalManager.php @@ -0,0 +1,310 @@ + + */ + private static array $cache = []; + + /** + * Per-process cache of history table column existence checks + * + * @var array + */ + private static array $columnCache = []; + + /** + * Determine whether the given model class carries #[Temporal]. + * + * @param string $class + * @return bool + */ + public static function isTemporal(string $class): bool + { + return self::getAttribute($class) !== null; + } + + /** + * Return the resolved #[Temporal] instance for a class, or null. + * + * @param string $class + * @return Temporal|null + */ + public static function getAttribute(string $class): ?Temporal + { + if (!array_key_exists($class, self::$cache)) { + $ref = new \ReflectionClass($class); + $attrs = $ref->getAttributes(Temporal::class); + self::$cache[$class] = $attrs ? $attrs[0]->newInstance() : null; + } + + return self::$cache[$class]; + } + + /** + * Return the history table name for the given base table and model class. + * + * @param string $table + * @param string $class + * @return string + */ + public static function historyTable(string $table, string $class): string + { + $attr = self::getAttribute($class); + $suffix = $attr?->suffix ?? '_history'; + + return $table . $suffix; + } + + /** + * Snapshot the model's current state into the history table. + * + * @param Model $model + * @param string $action + * @return void + */ + public static function snapshot(Model $model, string $action): void + { + $pdo = $model->getConnection(); + $historyTable = self::historyTable($model->getTable(), get_class($model)); + + $recordId = $model->getKey(); + if ($recordId === null && $action === 'created') { + $recordId = (int) $pdo->lastInsertId() ?: null; + } + + $snapshot = $model->getAttributes(); + if (!isset($snapshot[$model->getPrimaryKey()]) && $recordId !== null) { + $snapshot[$model->getPrimaryKey()] = $recordId; + } + + // For updates, record which columns changed + $changedCols = null; + if ($action === 'updated') { + $dirty = $model->getDirtyAttributes(); + unset($dirty['updated_at']); + $changedCols = !empty($dirty) ? json_encode(array_keys($dirty)) : null; + } + + $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $validFrom = self::nowMicro($driver); + + $data = [ + 'record_id' => $recordId, + 'action' => $action, + 'valid_from' => $validFrom, + 'snapshot' => json_encode($snapshot, JSON_UNESCAPED_UNICODE), + 'changed_cols' => $changedCols, + ]; + + $attr = self::getAttribute(get_class($model)); + if ($attr?->trackActor && self::hasActorColumn($pdo, $historyTable, $driver)) { + $data['actor'] = self::resolveActor(); + } + + self::insertHistory($pdo, $historyTable, $data); + } + + /** + * Check whether the `actor` column exists in the given history table + * + * @param PDO $pdo + * @param string $historyTable + * @param string $driver + * @return bool + */ + private static function hasActorColumn(PDO $pdo, string $historyTable, string $driver): bool + { + $cacheKey = $historyTable . '.actor'; + + if (!array_key_exists($cacheKey, self::$columnCache)) { + try { + self::$columnCache[$cacheKey] = match ($driver) { + 'pgsql' => (function () use ($pdo, $historyTable): bool { + $stmt = $pdo->prepare( + "SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = ? AND column_name = 'actor'" + ); + $stmt->execute([$historyTable]); + return (bool) $stmt->fetchColumn(); + })(), + 'sqlite' => (function () use ($pdo, $historyTable): bool { + $stmt = $pdo->query("PRAGMA table_info(\"{$historyTable}\")"); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) { + if ($col['name'] === 'actor') { + return true; + } + } + return false; + })(), + default => (function () use ($pdo, $historyTable): bool { + $stmt = $pdo->prepare("SHOW COLUMNS FROM `{$historyTable}` LIKE 'actor'"); + $stmt->execute(); + return $stmt->fetch() !== false; + })(), + }; + } catch (\Throwable) { + self::$columnCache[$cacheKey] = false; + } + } + + return self::$columnCache[$cacheKey]; + } + + /** + * Insert a single row into the history table. + * + * @param PDO $pdo + * @param string $table + * @param array $data + * @return void + */ + private static function insertHistory(PDO $pdo, string $table, array $data): void + { + $columns = implode(', ', array_keys($data)); + $placeholders = implode(', ', array_fill(0, count($data), '?')); + + $stmt = $pdo->prepare("INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})"); + $stmt->execute(array_values($data)); + } + + /** + * Generate a high-resolution timestamp string for the current driver. + * + * @param string $driver + * @return string + */ + private static function nowMicro(string $driver): string + { + $now = \Carbon\Carbon::now(); + + return match ($driver) { + 'pgsql' => $now->format('Y-m-d H:i:s.uP'), + 'sqlite' => $now->format('Y-m-d H:i:s.u'), + default => $now->format('Y-m-d H:i:s.u'), + }; + } + + /** + * Try to resolve the currently authenticated user's identifier + * + * @return string|int|null + */ + private static function resolveActor(): mixed + { + try { + $user = auth()->user(); + return $user?->getKey(); + } catch (\Throwable) { + return null; + } + } + + /** + * Generate the CREATE TABLE SQL for the history table. + * + * @param string $historyTable + * @param string $driver + * @param bool $trackActor + * @return string[] + */ + public static function createHistoryTableSql(string $historyTable, string $driver, bool $trackActor = false): array + { + $actorCol = $trackActor ? self::actorColumnSql($driver) : ''; + + return match ($driver) { + 'pgsql' => self::pgsqlDdl($historyTable, $actorCol), + 'sqlite' => self::sqliteDdl($historyTable, $actorCol), + default => self::mysqlDdl($historyTable, $actorCol), + }; + } + + private static function mysqlDdl(string $table, string $actorCol): array + { + return [ + "CREATE TABLE IF NOT EXISTS `{$table}` ( + `history_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `record_id` BIGINT UNSIGNED NOT NULL, + `action` VARCHAR(10) NOT NULL, + `valid_from` DATETIME(6) NOT NULL, + `snapshot` JSON NOT NULL, + `changed_cols` JSON NULL, + {$actorCol} + PRIMARY KEY (`history_id`), + INDEX `idx_record_id` (`record_id`), + INDEX `idx_valid_from` (`valid_from`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ]; + } + + private static function pgsqlDdl(string $table, string $actorCol): array + { + $stmts = [ + "CREATE TABLE IF NOT EXISTS \"{$table}\" ( + history_id BIGSERIAL PRIMARY KEY, + record_id BIGINT NOT NULL, + action VARCHAR(10) NOT NULL, + valid_from TIMESTAMPTZ NOT NULL, + snapshot JSONB NOT NULL, + changed_cols JSONB NULL + {$actorCol} + )", + "CREATE INDEX IF NOT EXISTS \"idx_{$table}_record_id\" ON \"{$table}\" (record_id)", + "CREATE INDEX IF NOT EXISTS \"idx_{$table}_valid_from\" ON \"{$table}\" (valid_from)", + ]; + + return $stmts; + } + + private static function sqliteDdl(string $table, string $actorCol): array + { + $stmts = [ + "CREATE TABLE IF NOT EXISTS \"{$table}\" ( + history_id INTEGER PRIMARY KEY AUTOINCREMENT, + record_id INTEGER NOT NULL, + action TEXT NOT NULL, + valid_from TEXT NOT NULL, + snapshot TEXT NOT NULL, + changed_cols TEXT NULL + {$actorCol} + )", + "CREATE INDEX IF NOT EXISTS \"idx_{$table}_record_id\" ON \"{$table}\" (record_id)", + "CREATE INDEX IF NOT EXISTS \"idx_{$table}_valid_from\" ON \"{$table}\" (valid_from)", + ]; + + return $stmts; + } + + private static function actorColumnSql(string $driver): string + { + return match ($driver) { + 'pgsql' => ",\n actor VARCHAR(255) NULL", + 'sqlite' => ",\n actor TEXT NULL", + default => "`actor` VARCHAR(255) NULL,", + }; + } + + /** + * Reset the internal class attribute cache. Useful in tests. + * + * @param string|null $class When null, clears all entries. + * @return void + */ + public static function resetCache(?string $class = null): void + { + if ($class !== null) { + unset(self::$cache[$class]); + } else { + self::$cache = []; + } + } +}