diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f0365c85..78037316 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -798,12 +798,6 @@ parameters: count: 1 path: src/Phaseolies/Database/Entity/Model.php - - - message: '#^Unsafe usage of new static\(\)\.$#' - identifier: new.static - count: 11 - path: src/Phaseolies/Database/Entity/Model.php - - message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\URL\:\:current\(\)\.$#' identifier: staticMethod.notFound diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateTemporalCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateTemporalCommand.php index 376126a6..c2325175 100644 --- a/src/Phaseolies/Console/Commands/Migrations/MigrateTemporalCommand.php +++ b/src/Phaseolies/Console/Commands/Migrations/MigrateTemporalCommand.php @@ -96,10 +96,36 @@ public function handle(): int ); if (!$dryRun && $this->tableExists($pdo, $driver, $historyTable)) { - // Table exists — check if we need to add the actor column + // Table exists — check if we need to add the actor and/or branch columns + $addedColumns = []; + $addedIndexes = []; + if ($attr?->trackActor && !$this->columnExists($pdo, $driver, $historyTable, 'actor')) { $pdo->exec($this->addActorColumnSql($driver, $historyTable)); - $row('✓', 'green', 'actor column added'); + $addedColumns[] = 'actor'; + } + + if ($attr !== null && $attr->branches && !$this->columnExists($pdo, $driver, $historyTable, 'branch')) { + $pdo->exec($this->addBranchColumnSql($driver, $historyTable)); + $addedColumns[] = 'branch'; + } + + $branchIndex = $driver === 'mysql' ? 'idx_branch' : 'idx_' . $historyTable . '_branch'; + if ($attr !== null && $attr->branches && !$this->indexExists($pdo, $driver, $historyTable, $branchIndex)) { + $pdo->exec($this->createBranchIndexSql($driver, $historyTable)); + $addedIndexes[] = 'branch'; + } + + if (!empty($addedColumns) || !empty($addedIndexes)) { + TemporalManager::resetCache(); + $status = []; + if (!empty($addedColumns)) { + $status[] = implode(', ', $addedColumns) . ' column' . (count($addedColumns) > 1 ? 's' : '') . ' added'; + } + if (!empty($addedIndexes)) { + $status[] = implode(', ', $addedIndexes) . ' index added'; + } + $row('✓', 'green', implode('; ', $status)); $created++; } else { $row('•', 'yellow', 'already exists'); @@ -109,9 +135,10 @@ public function handle(): int } $statements = TemporalManager::createHistoryTableSql( - historyTable: $historyTable, - driver: $driver, - trackActor: $attr?->trackActor ?? false, + historyTable: $historyTable, + driver: $driver, + trackActor: $attr?->trackActor ?? false, + trackBranches: $attr !== null && $attr->branches, ); if ($dryRun) { @@ -268,7 +295,7 @@ private function columnExists(PDO $pdo, string $driver, string $table, string $c if ($driver === 'pgsql') { $stmt = $pdo->prepare( "SELECT COUNT(*) FROM information_schema.columns - WHERE table_name = ? AND column_name = ?" + WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?" ); $stmt->execute([$table, $column]); return (bool) $stmt->fetchColumn(); @@ -308,4 +335,67 @@ private function addActorColumnSql(string $driver, string $table): string default => "ALTER TABLE `{$table}` ADD COLUMN `actor` VARCHAR(255) NULL", }; } + + /** + * Generate the ALTER TABLE SQL to add the branch column. + * + * @param string $driver + * @param string $table + * @return string + */ + private function addBranchColumnSql(string $driver, string $table): string + { + return match ($driver) { + 'pgsql' => "ALTER TABLE \"{$table}\" ADD COLUMN branch VARCHAR(50) NOT NULL DEFAULT 'main'", + 'sqlite' => "ALTER TABLE \"{$table}\" ADD COLUMN branch TEXT NOT NULL DEFAULT 'main'", + default => "ALTER TABLE `{$table}` ADD COLUMN `branch` VARCHAR(50) NOT NULL DEFAULT 'main'", + }; + } + + /** + * Check whether the given index exists on the given table. + * + * @param PDO $pdo + * @param string $driver + * @param string $table + * @param string $index + * @return bool + */ + private function indexExists(PDO $pdo, string $driver, string $table, string $index): bool + { + try { + if ($driver === 'sqlite') { + foreach ($pdo->query("PRAGMA index_list(\"{$table}\")")->fetchAll(PDO::FETCH_ASSOC) as $row) { + if ($row['name'] === $index) return true; + } + return false; + } + if ($driver === 'pgsql') { + $stmt = $pdo->prepare('SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() AND tablename = ? AND indexname = ?'); + $stmt->execute([$table, $index]); + return $stmt->fetchColumn() !== false; + } + $stmt = $pdo->prepare("SHOW INDEX FROM `{$table}` WHERE Key_name = ?"); + $stmt->execute([$index]); + return $stmt->fetch() !== false; + } catch (\Throwable) { + return false; + } + } + + /** + * Generate the CREATE INDEX SQL for the branch lookup index. + * + * @param string $driver + * @param string $table + * @return string + */ + private function createBranchIndexSql(string $driver, string $table): string + { + $name = $driver === 'mysql' ? 'idx_branch' : 'idx_' . $table . '_branch'; + return match ($driver) { + 'pgsql', 'sqlite' => "CREATE INDEX \"{$name}\" ON \"{$table}\" (record_id, branch, valid_from)", + default => "CREATE INDEX `{$name}` ON `{$table}` (record_id, branch, valid_from)", + }; + } } diff --git a/src/Phaseolies/Database/Entity/Model.php b/src/Phaseolies/Database/Entity/Model.php index eb71d56b..e1f62151 100644 --- a/src/Phaseolies/Database/Entity/Model.php +++ b/src/Phaseolies/Database/Entity/Model.php @@ -15,6 +15,8 @@ use Phaseolies\Database\Entity\Attributes\Hook; /** + * @phpstan-consistent-constructor + * @property string $__branch Temporal history metadata. * @method static \Phaseolies\Database\Entity\Builder withoutHook() */ abstract class Model implements Jsonable, \ArrayAccess, \JsonSerializable, \Stringable diff --git a/src/Phaseolies/Database/Temporal/Attributes/Temporal.php b/src/Phaseolies/Database/Temporal/Attributes/Temporal.php index 79eabc84..d412a3ce 100644 --- a/src/Phaseolies/Database/Temporal/Attributes/Temporal.php +++ b/src/Phaseolies/Database/Temporal/Attributes/Temporal.php @@ -10,9 +10,11 @@ final class Temporal /** * @param string $suffix * @param bool $trackActor + * @param bool $branches */ public function __construct( public readonly string $suffix = '_history', public readonly bool $trackActor = false, + public readonly bool $branches = false, ) {} } diff --git a/src/Phaseolies/Database/Temporal/InteractsWithTemporal.php b/src/Phaseolies/Database/Temporal/InteractsWithTemporal.php index 0b7189de..cedf03bf 100644 --- a/src/Phaseolies/Database/Temporal/InteractsWithTemporal.php +++ b/src/Phaseolies/Database/Temporal/InteractsWithTemporal.php @@ -34,14 +34,15 @@ protected function registerTemporalHooks(): void } /** - * Return a TemporalBuilder scoped to the given point in time. + * Return a TemporalBuilder scoped to the given point in time (and branch). * * @param string $datetime + * @param string $branch * @return TemporalBuilder * * @throws \RuntimeException */ - public static function at(string $datetime): TemporalBuilder + public static function at(string $datetime, string $branch = 'main'): TemporalBuilder { if (!TemporalManager::isTemporal(static::class)) { throw new \RuntimeException( @@ -49,6 +50,8 @@ public static function at(string $datetime): TemporalBuilder ); } + TemporalManager::assertBranchSupported(static::class, $branch); + $model = new static(); return new TemporalBuilder( @@ -57,29 +60,146 @@ public static function at(string $datetime): TemporalBuilder modelClass: static::class, rowPerPage: $model->pageSize, datetime: $datetime, + branch: $branch, ); } /** - * Return the complete audit history for this model instance + * Fork a new branch from this record's main timeline as it existed at + * the given point in time. Its timeline starts when the fork is created, + * so earlier queries on that branch return no snapshot. + * + * @param string $name + * @param string $fromDatetime + * @return bool * + * @throws \RuntimeException + */ + public function branch(string $name, string $fromDatetime): bool + { + $this->assertTemporalAndLoaded(); + + if (!TemporalManager::isBranchable(static::class)) { + throw new \RuntimeException( + static::class . ' does not support branches. Add #[Temporal(branches: true)] to enable them.' + ); + } + + TemporalManager::validateBranchName($name); + if ($name === 'main') { + throw new \InvalidArgumentException('Branch name must be non-empty and cannot be "main".'); + } + + $snapshot = $this->snapshotAt($fromDatetime, 'main'); + + if ($snapshot === null) { + return false; + } + + TemporalManager::snapshotBranch($this, $snapshot, $name); + + return true; + } + + /** + * Append this in-memory proposed state as a new version on an existing + * branch. The live record is not written and the model stays dirty. + * + * @param string $branch + * @return bool + * + * @throws \InvalidArgumentException + * @throws \RuntimeException + */ + public function saveToBranch(string $branch): bool + { + $this->assertTemporalAndLoaded(); + TemporalManager::assertBranchSupported(static::class, $branch); + if ($branch === 'main') { + throw new \InvalidArgumentException('saveToBranch() requires a named branch.'); + } + TemporalManager::snapshotBranchVersion($this, $branch); + return true; + } + + /** + * Compare the current main state with the latest state on a named branch. + * + * @param string $branch + * @return array|null + * + * @throws \InvalidArgumentException + * @throws \RuntimeException + */ + public function diffAgainstBranch(string $branch): ?array + { + $this->assertTemporalAndLoaded(); + TemporalManager::assertBranchSupported(static::class, $branch); + if ($branch === 'main') { + throw new \InvalidArgumentException('A named branch is required.'); + } + $now = \Carbon\Carbon::now()->format('Y-m-d H:i:s.u'); + $main = $this->snapshotAt($now, 'main'); + $proposed = $this->snapshotAt($now, $branch); + if ($main === null || $proposed === null) { + return null; + } + $probe = new static(); + $diff = $this->formatDiff($main, $proposed, $branch, $now, $now, $probe); + $visibleMain = $this->visibleSnapshot($main, $probe); + $visibleBranch = $this->visibleSnapshot($proposed, $probe); + + return [ + 'at' => $now, + 'main' => $visibleMain, + 'branch' => ['name' => $branch, 'snapshot' => $visibleBranch], + 'changes' => $diff['changes'], + 'added' => $diff['added'], + 'removed' => $diff['removed'], + ]; + } + + /** + * Return the complete audit history for this model instance on the given branch. + * + * @param string|null $branch Pass null to include every branch. * @return Collection * @throws \RuntimeException */ - public function history(): Collection + public function history(?string $branch = 'main'): Collection { $this->assertTemporalAndLoaded(); $historyTable = $this->historyTable(); $pdo = $this->getConnection(); + $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); - $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()]); + $hasActor = TemporalManager::hasActorColumn($pdo, $historyTable, $driver); + $hasBranch = TemporalManager::isBranchable(static::class); + if ($branch !== null) { + TemporalManager::assertBranchSupported(static::class, $branch); + } + if ($hasBranch) { + TemporalManager::assertBranchColumn($pdo, $historyTable, $driver); + } + + $columns = 'history_id, action, valid_from, snapshot, changed_cols' + . ($hasActor ? ', actor' : '') + . ($hasBranch ? ', branch' : ''); + + $sql = "SELECT {$columns} + FROM {$historyTable} + WHERE record_id = ?" + . ($hasBranch && $branch !== null ? ' AND branch = ?' : '') + . ' ORDER BY valid_from ASC, history_id ASC'; + + $params = [$this->getKey()]; + if ($hasBranch && $branch !== null) { + $params[] = $branch; + } + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $models = []; @@ -96,7 +216,8 @@ public function history(): Collection $entry->__changed_cols = $row['changed_cols'] ? json_decode($row['changed_cols'], true) : null; - $entry->__actor = $row['actor'] ?? null; + $entry->__actor = $row['actor'] ?? null; + $entry->__branch = $row['branch'] ?? 'main'; $models[] = $entry; } @@ -105,37 +226,62 @@ public function history(): Collection } /** - * Compare the model's state at two points in time and return a structured diff. + * Compare the model's state at two points in time on the given branch and + * return a structured diff. Values are run through the model's casts and + * attributes marked unexposable (hidden) are excluded from the result. * * @param string $from * @param string $to + * @param string $branch * @return array|null + * + * @throws \RuntimeException */ - public function diff(string $from, string $to): ?array + public function diff(string $from, string $to, string $branch = 'main'): ?array { $this->assertTemporalAndLoaded(); + TemporalManager::assertBranchSupported(static::class, $branch); - $snapshotFrom = $this->snapshotAt($from); - $snapshotTo = $this->snapshotAt($to); + $snapshotFrom = $this->snapshotAt($from, $branch); + $snapshotTo = $this->snapshotAt($to, $branch); if ($snapshotFrom === null || $snapshotTo === null) { return null; } + return $this->formatDiff($snapshotFrom, $snapshotTo, $branch, $from, $to, new static()); + } + + /** + * Build the structured diff between two raw snapshots. + * + * @param array $snapshotFrom + * @param array $snapshotTo + * @param string $branch + * @param string $from + * @param string $to + * @param Model $probe + * @return array + */ + private function formatDiff(array $snapshotFrom, array $snapshotTo, string $branch, string $from, string $to, Model $probe): array + { + $visibleFrom = $this->visibleSnapshot($snapshotFrom, $probe); + $visibleTo = $this->visibleSnapshot($snapshotTo, $probe); + $changes = []; $added = []; $removed = []; - foreach ($snapshotTo as $col => $newVal) { - if (!array_key_exists($col, $snapshotFrom)) { + foreach ($visibleTo as $col => $newVal) { + if (!array_key_exists($col, $visibleFrom)) { $added[$col] = $newVal; - } elseif (self::snapshotValuesDiffer($snapshotFrom[$col], $newVal)) { - $changes[$col] = ['from' => $snapshotFrom[$col], 'to' => $newVal]; + } elseif (self::snapshotValuesDiffer($visibleFrom[$col], $newVal)) { + $changes[$col] = ['from' => $visibleFrom[$col], 'to' => $newVal]; } } - foreach ($snapshotFrom as $col => $oldVal) { - if (!array_key_exists($col, $snapshotTo)) { + foreach ($visibleFrom as $col => $oldVal) { + if (!array_key_exists($col, $visibleTo)) { $removed[$col] = $oldVal; } } @@ -143,24 +289,52 @@ public function diff(string $from, string $to): ?array return [ 'from' => $from, 'to' => $to, + 'branch' => $branch, 'changes' => $changes, 'added' => $added, 'removed' => $removed, ]; } + /** + * Apply this model's casts and unexposable (hidden) attribute filtering + * to a raw stored snapshot, for display/diffing purposes only. + * + * @param array $snapshot + * @param Model $probe + * @return array + */ + private function visibleSnapshot(array $snapshot, Model $probe): array + { + $visible = []; + + foreach ($snapshot as $key => $value) { + if (in_array($key, $probe->unexposable, true)) { + continue; + } + + $visible[$key] = $probe->castForGet($key, $value); + } + + return $visible; + } + /** * Return a new (unsaved) model instance populated with the state of this - * record at the given point in time. + * record at the given point in time on the given branch. * * @param string $datetime + * @param string $branch * @return static|null + * + * @throws \RuntimeException */ - public function rewindTo(string $datetime): ?static + public function rewindTo(string $datetime, string $branch = 'main'): ?static { $this->assertTemporalAndLoaded(); + TemporalManager::assertBranchSupported(static::class, $branch); - $snapshot = $this->snapshotAt($datetime); + $snapshot = $this->snapshotAt($datetime, $branch); if ($snapshot === null) { return null; @@ -176,15 +350,26 @@ public function rewindTo(string $datetime): ?static } /** - * Restore this model to its state at the given datetime and immediately - * persist the change. + * Restore this model to its state at the given datetime on the given + * branch and immediately persist the change. This always creates a new + * version through the normal save()/snapshot hooks; it never rewrites + * existing history, and restoring from a branch other than "main" + * requires the model to be configured for branches. + * + * Returns false when there is nothing to restore: no snapshot exists at + * that point, or the record had been deleted by then. The same rule makes + * rewindTo() return null for that point. * * @param string $datetime + * @param string $branch * @return bool + * + * @throws \RuntimeException */ - public function restoreTo(string $datetime): bool + public function restoreTo(string $datetime, string $branch = 'main'): bool { - $rewound = $this->rewindTo($datetime); + $this->assertTemporalAndLoaded(); + $rewound = $this->rewindTo($datetime, $branch); if ($rewound === null) { return false; @@ -203,6 +388,50 @@ public function restoreTo(string $datetime): bool return true; } + /** + * Restore this record to a specific history entry and persist it as a new + * version through the normal save()/snapshot hooks. + * + * Returns false when the id is unknown or belongs to another record, and + * throws when the entry is a "deleted" snapshot, since restoring an + * explicitly chosen deletion is always a mistake. + * + * @param int $historyId + * @return bool + * + * @throws \RuntimeException + */ + public function restoreVersion(int $historyId): bool + { + $this->assertTemporalAndLoaded(); + $historyTable = $this->historyTable(); + $pdo = $this->getConnection(); + $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $hasBranch = TemporalManager::isBranchable(static::class); + if ($hasBranch) { + TemporalManager::assertBranchColumn($pdo, $historyTable, $driver); + } + + $stmt = $pdo->prepare( + "SELECT snapshot, action FROM {$historyTable} WHERE history_id = ? AND record_id = ?" + ); + $stmt->execute([$historyId, $this->getKey()]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return false; + } + if ($row['action'] === 'deleted') { + throw new \RuntimeException('A deleted temporal version cannot be restored.'); + } + $snapshot = json_decode($row['snapshot'], true) ?? []; + $restored = new static($snapshot); + $restored->setOriginalAttributes($this->getAttributes()); + if (!$restored->save()) { + return false; + } + return true; + } + /** * Whether this model class is marked #[Temporal]. * @@ -224,30 +453,41 @@ public function historyTable(): string } /** - * Fetch the raw snapshot array for this record at the given datetime. + * Fetch the raw snapshot array for this record at the given datetime on + * the given branch. * * @param string $datetime + * @param string $branch * @return array|null */ - private function snapshotAt(string $datetime): ?array + private function snapshotAt(string $datetime, string $branch = 'main'): ?array { $historyTable = $this->historyTable(); $pdo = $this->getConnection(); + $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); - // 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'; + $hasBranch = TemporalManager::isBranchable(static::class); + if ($hasBranch) { + TemporalManager::assertBranchColumn($pdo, $historyTable, $driver); } - $stmt = $pdo->prepare( - "SELECT snapshot, action - FROM {$historyTable} - WHERE record_id = ? - AND valid_from <= ? - ORDER BY valid_from DESC - LIMIT 1" - ); - $stmt->execute([$this->getKey(), $datetime]); + $datetime = $this->normalizeTemporalDateTime($datetime); + + $sql = "SELECT snapshot, action + FROM {$historyTable} + WHERE record_id = ? + AND valid_from <= ?" + . ($hasBranch ? ' AND branch = ?' : '') + . ' ORDER BY valid_from DESC, history_id DESC + LIMIT 1'; + + $params = [$this->getKey(), $datetime]; + if ($hasBranch) { + $params[] = $branch; + } + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); $row = $stmt->fetch(PDO::FETCH_ASSOC); @@ -302,6 +542,27 @@ private static function snapshotScalar(mixed $value): string return is_bool($value) ? ($value ? '1' : '0') : (string) $value; } + /** + * Expand a partial datetime to the end of the given day, minute or second. + * + * @param string $datetime + * @return string + */ + private function normalizeTemporalDateTime(string $datetime): string + { + $datetime = trim($datetime); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $datetime)) { + return $datetime . ' 23:59:59.999999'; + } + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $datetime)) { + return $datetime . ':59.999999'; + } + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $datetime)) { + return $datetime . '.999999'; + } + return $datetime; + } + /** * Assert that the model is temporal and has a primary key loaded. * diff --git a/src/Phaseolies/Database/Temporal/TemporalBuilder.php b/src/Phaseolies/Database/Temporal/TemporalBuilder.php index 0e503543..6e9ef960 100644 --- a/src/Phaseolies/Database/Temporal/TemporalBuilder.php +++ b/src/Phaseolies/Database/Temporal/TemporalBuilder.php @@ -16,18 +16,33 @@ class TemporalBuilder extends Builder */ protected string $datetime; + /** + * The branch these history queries are scoped to. + * + * @var string + */ + protected string $branch; + /** * @param PDO $pdo * @param string $table * @param string $modelClass - * @param int $rowPerPage + * @param int $rowPerPage * @param string $datetime + * @param string $branch */ - public function __construct(PDO $pdo, string $table, string $modelClass, int $rowPerPage, string $datetime) - { + public function __construct( + PDO $pdo, + string $table, + string $modelClass, + int $rowPerPage, + string $datetime, + string $branch = 'main' + ) { parent::__construct($pdo, $table, $modelClass, $rowPerPage); $this->datetime = $this->normalizeDateTime($datetime); + $this->branch = $branch; } /** @@ -81,19 +96,25 @@ public function first(): ?Model public function find($id): ?Model { $historyTable = TemporalManager::historyTable($this->table, $this->modelClass); - $key = (new $this->modelClass())->getKeyName(); + $hasBranch = $this->hasBranchColumn($historyTable); $sql = " SELECT snapshot, action FROM {$historyTable} WHERE record_id = ? - AND valid_from <= ? - ORDER BY valid_from DESC + AND valid_from <= ?" + . ($hasBranch ? ' AND branch = ?' : '') . " + ORDER BY valid_from DESC, history_id DESC LIMIT 1 "; + $params = [$id, $this->datetime]; + if ($hasBranch) { + $params[] = $this->branch; + } + $stmt = $this->pdo->prepare($sql); - $stmt->execute([$id, $this->datetime]); + $stmt->execute($params); $row = $stmt->fetch(PDO::FETCH_ASSOC); // The latest row decides: if the record was deleted by then, it did not exist. @@ -115,6 +136,11 @@ public function find($id): ?Model private function fetchTemporalRows(): array { $historyTable = TemporalManager::historyTable($this->table, $this->modelClass); + $hasBranch = $this->hasBranchColumn($historyTable); + + $innerBranch = $hasBranch ? ' AND h2.branch = ?' : ''; + $tieBranch = $hasBranch ? ' AND h3.branch = ?' : ''; + $outerBranch = $hasBranch ? ' AND h1.branch = ?' : ''; $sql = " SELECT h1.snapshot, h1.record_id, h1.action @@ -123,17 +149,48 @@ private function fetchTemporalRows(): array SELECT MAX(h2.valid_from) FROM {$historyTable} h2 WHERE h2.record_id = h1.record_id - AND h2.valid_from <= ? + AND h2.valid_from <= ?{$innerBranch} + ) + AND h1.history_id = ( + SELECT MAX(h3.history_id) + FROM {$historyTable} h3 + WHERE h3.record_id = h1.record_id + AND h3.valid_from = h1.valid_from{$tieBranch} ) - AND h1.action != 'deleted' + AND h1.action != 'deleted'{$outerBranch} "; + $params = [$this->datetime]; + if ($hasBranch) { + $params[] = $this->branch; + $params[] = $this->branch; + $params[] = $this->branch; + } + $stmt = $this->pdo->prepare($sql); - $stmt->execute([$this->datetime]); + $stmt->execute($params); return $stmt->fetchAll(PDO::FETCH_ASSOC); } + /** + * Whether the history table has a usable branch column for this model. + * + * @param string $historyTable + * @return bool + */ + private function hasBranchColumn(string $historyTable): bool + { + if (!TemporalManager::isBranchable($this->modelClass)) { + return false; + } + + $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + + TemporalManager::assertBranchColumn($this->pdo, $historyTable, $driver); + return true; + } + /** * Hydrate an array of raw history rows into model instances. * @@ -420,4 +477,14 @@ public function getDatetime(): string { return $this->datetime; } + + /** + * Expose the resolved branch for external use. + * + * @return string + */ + public function getBranch(): string + { + return $this->branch; + } } diff --git a/src/Phaseolies/Database/Temporal/TemporalManager.php b/src/Phaseolies/Database/Temporal/TemporalManager.php index 8a1304b3..577b3fa0 100644 --- a/src/Phaseolies/Database/Temporal/TemporalManager.php +++ b/src/Phaseolies/Database/Temporal/TemporalManager.php @@ -18,9 +18,9 @@ class TemporalManager /** * Per-process cache of history table column existence checks * - * @var array + * @var \WeakMap>|null */ - private static array $columnCache = []; + private static ?\WeakMap $columnCache = null; /** * Determine whether the given model class carries #[Temporal]. @@ -65,14 +65,79 @@ public static function historyTable(string $table, string $class): string return $table . $suffix; } + /** + * Determine whether the given model class supports named branches. + * + * @param string $class + * @return bool + */ + public static function isBranchable(string $class): bool + { + $attribute = self::getAttribute($class); + return $attribute !== null && $attribute->branches; + } + + /** + * Guard a branch argument against classes that were not configured for branching. + * + * @param string $class + * @param string $branch + * @return void + * + * @throws \RuntimeException + */ + public static function assertBranchSupported(string $class, string $branch): void + { + self::validateBranchName($branch); + + if ($branch !== 'main' && !self::isBranchable($class)) { + throw new \RuntimeException( + $class . ' does not support branches. Add #[Temporal(branches: true)] to enable them.' + ); + } + } + + /** + * Validate branch names consistently with the persisted column. + * + * @param string $branch + * @return void + * + * @throws \InvalidArgumentException + */ + public static function validateBranchName(string $branch): void + { + if (strlen($branch) > 50 || !preg_match('/^[a-z0-9_-]+$/', $branch)) { + throw new \InvalidArgumentException('Branch names must be 1-50 lowercase characters using letters, numbers, _ or -.'); + } + } + + /** + * Ensure branch-enabled models have completed migrate:temporal. + * + * @param PDO $pdo + * @param string $historyTable + * @param string $driver + * @return void + * + * @throws \RuntimeException + */ + public static function assertBranchColumn(PDO $pdo, string $historyTable, string $driver): void + { + if (!self::hasBranchColumn($pdo, $historyTable, $driver)) { + throw new \RuntimeException("Temporal branch column is missing from {$historyTable}; run migrate:temporal."); + } + } + /** * Snapshot the model's current state into the history table. * * @param Model $model * @param string $action + * @param string $branch * @return void */ - public static function snapshot(Model $model, string $action): void + public static function snapshot(Model $model, string $action, string $branch = 'main'): void { $pdo = $model->getConnection(); $historyTable = self::historyTable($model->getTable(), get_class($model)); @@ -111,53 +176,158 @@ public static function snapshot(Model $model, string $action): void $data['actor'] = self::resolveActor(); } + if ($attr !== null && $attr->branches) { + if (self::hasBranchColumn($pdo, $historyTable, $driver)) { + $data['branch'] = $branch; + } elseif ($branch !== 'main') { + self::assertBranchColumn($pdo, $historyTable, $driver); + } + } + self::insertHistory($pdo, $historyTable, $data); } /** - * Check whether the `actor` column exists in the given history table + * Snapshot an already-resolved attribute array as a new branch entry. + * + * Used to fork a branch from a point-in-time snapshot of the main + * timeline without disturbing the record's own history. + * + * @param Model $model + * @param array $snapshot + * @param string $branch + * @return void + */ + public static function snapshotBranch(Model $model, array $snapshot, string $branch): void + { + $pdo = $model->getConnection(); + $historyTable = self::historyTable($model->getTable(), get_class($model)); + $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + self::assertBranchColumn($pdo, $historyTable, $driver); + + $stmt = $pdo->prepare("SELECT 1 FROM {$historyTable} WHERE record_id = ? AND branch = ? LIMIT 1"); + $stmt->execute([$model->getKey(), $branch]); + if ($stmt->fetchColumn()) { + throw new \RuntimeException("Branch '{$branch}' already exists for this record."); + } + + $data = [ + 'record_id' => $model->getKey(), + 'action' => 'branched', + 'valid_from' => self::nowMicro($driver), + 'snapshot' => json_encode($snapshot, JSON_UNESCAPED_UNICODE), + 'changed_cols' => null, + 'branch' => $branch, + ]; + + $attr = self::getAttribute(get_class($model)); + if ($attr?->trackActor && self::hasActorColumn($pdo, $historyTable, $driver)) { + $data['actor'] = self::resolveActor(); + } + + self::insertHistory($pdo, $historyTable, $data); + } + + /** + * Append an edited proposed state to an existing branch without saving + * the live record. + * + * @param Model $model + * @param string $branch + * @return void + * + * @throws \RuntimeException + */ + public static function snapshotBranchVersion(Model $model, string $branch): void + { + $pdo = $model->getConnection(); + $table = self::historyTable($model->getTable(), get_class($model)); + $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + self::assertBranchColumn($pdo, $table, $driver); + $exists = $pdo->prepare("SELECT 1 FROM {$table} WHERE record_id = ? AND branch = ? LIMIT 1"); + $exists->execute([$model->getKey(), $branch]); + if (!$exists->fetchColumn()) { + throw new \RuntimeException("Branch '{$branch}' does not exist for this record."); + } + self::snapshot($model, 'updated', $branch); + } + + /** + * Check whether the `actor` column exists in the given history table. + * + * @param PDO $pdo + * @param string $historyTable + * @param string $driver + * @return bool + */ + public static function hasActorColumn(PDO $pdo, string $historyTable, string $driver): bool + { + return self::hasColumn($pdo, $historyTable, 'actor', $driver); + } + + /** + * Check whether the `branch` 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 + public static function hasBranchColumn(PDO $pdo, string $historyTable, string $driver): bool { - $cacheKey = $historyTable . '.actor'; + return self::hasColumn($pdo, $historyTable, 'branch', $driver); + } - if (!array_key_exists($cacheKey, self::$columnCache)) { + /** + * Check whether the given column exists in the given history table. + * + * @param PDO $pdo + * @param string $historyTable + * @param string $column + * @param string $driver + * @return bool + */ + private static function hasColumn(PDO $pdo, string $historyTable, string $column, string $driver): bool + { + self::$columnCache ??= new \WeakMap(); + $connectionCache = self::$columnCache[$pdo] ?? []; + $cacheKey = $historyTable . '.' . $column; + + if (!array_key_exists($cacheKey, $connectionCache)) { try { - self::$columnCache[$cacheKey] = match ($driver) { - 'pgsql' => (function () use ($pdo, $historyTable): bool { + $exists = match ($driver) { + 'pgsql' => (function () use ($pdo, $historyTable, $column): bool { $stmt = $pdo->prepare( "SELECT COUNT(*) FROM information_schema.columns - WHERE table_name = ? AND column_name = 'actor'" + WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?" ); - $stmt->execute([$historyTable]); + $stmt->execute([$historyTable, $column]); return (bool) $stmt->fetchColumn(); })(), - 'sqlite' => (function () use ($pdo, $historyTable): bool { + 'sqlite' => (function () use ($pdo, $historyTable, $column): bool { $stmt = $pdo->query("PRAGMA table_info(\"{$historyTable}\")"); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) { - if ($col['name'] === 'actor') { + if ($col['name'] === $column) { return true; } } return false; })(), - default => (function () use ($pdo, $historyTable): bool { - $stmt = $pdo->prepare("SHOW COLUMNS FROM `{$historyTable}` LIKE 'actor'"); - $stmt->execute(); + default => (function () use ($pdo, $historyTable, $column): bool { + $stmt = $pdo->prepare("SHOW COLUMNS FROM `{$historyTable}` LIKE ?"); + $stmt->execute([$column]); return $stmt->fetch() !== false; })(), }; + $connectionCache[$cacheKey] = $exists; + self::$columnCache[$pdo] = $connectionCache; } catch (\Throwable) { - self::$columnCache[$cacheKey] = false; + unset($connectionCache[$cacheKey]); + self::$columnCache[$pdo] = $connectionCache; } } - return self::$columnCache[$cacheKey]; + return $connectionCache[$cacheKey] ?? false; } /** @@ -215,21 +385,38 @@ private static function resolveActor(): mixed * @param string $historyTable * @param string $driver * @param bool $trackActor + * @param bool $trackBranches * @return string[] */ - public static function createHistoryTableSql(string $historyTable, string $driver, bool $trackActor = false): array - { - $actorCol = $trackActor ? self::actorColumnSql($driver) : ''; + public static function createHistoryTableSql( + string $historyTable, + string $driver, + bool $trackActor = false, + bool $trackBranches = false + ): array { + $actorCol = $trackActor ? self::actorColumnSql($driver) : ''; + $branchCol = $trackBranches ? self::branchColumnSql($driver) : ''; return match ($driver) { - 'pgsql' => self::pgsqlDdl($historyTable, $actorCol), - 'sqlite' => self::sqliteDdl($historyTable, $actorCol), - default => self::mysqlDdl($historyTable, $actorCol), + 'pgsql' => self::pgsqlDdl($historyTable, $actorCol, $branchCol, $trackBranches), + 'sqlite' => self::sqliteDdl($historyTable, $actorCol, $branchCol, $trackBranches), + default => self::mysqlDdl($historyTable, $actorCol, $branchCol, $trackBranches), }; } - private static function mysqlDdl(string $table, string $actorCol): array + /** + * Generate the MySQL history table DDL. + * + * @param string $table + * @param string $actorCol + * @param string $branchCol + * @param bool $trackBranches + * @return array + */ + private static function mysqlDdl(string $table, string $actorCol, string $branchCol, bool $trackBranches): array { + $branchIndex = $trackBranches ? ",\n INDEX `idx_branch` (`record_id`, `branch`, `valid_from`)" : ''; + return [ "CREATE TABLE IF NOT EXISTS `{$table}` ( `history_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, @@ -239,14 +426,24 @@ private static function mysqlDdl(string $table, string $actorCol): array `snapshot` JSON NOT NULL, `changed_cols` JSON NULL, {$actorCol} + {$branchCol} PRIMARY KEY (`history_id`), INDEX `idx_record_id` (`record_id`), - INDEX `idx_valid_from` (`valid_from`) + INDEX `idx_valid_from` (`valid_from`){$branchIndex} ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" ]; } - private static function pgsqlDdl(string $table, string $actorCol): array + /** + * Generate the PostgreSQL history table DDL. + * + * @param string $table + * @param string $actorCol + * @param string $branchCol + * @param bool $trackBranches + * @return array + */ + private static function pgsqlDdl(string $table, string $actorCol, string $branchCol, bool $trackBranches): array { $stmts = [ "CREATE TABLE IF NOT EXISTS \"{$table}\" ( @@ -256,16 +453,29 @@ private static function pgsqlDdl(string $table, string $actorCol): array valid_from TIMESTAMPTZ NOT NULL, snapshot JSONB NOT NULL, changed_cols JSONB NULL - {$actorCol} + {$actorCol}{$branchCol} )", "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)", ]; + if ($trackBranches) { + $stmts[] = "CREATE INDEX IF NOT EXISTS \"idx_{$table}_branch\" ON \"{$table}\" (record_id, branch, valid_from)"; + } + return $stmts; } - private static function sqliteDdl(string $table, string $actorCol): array + /** + * Generate the SQLite history table DDL. + * + * @param string $table + * @param string $actorCol + * @param string $branchCol + * @param bool $trackBranches + * @return array + */ + private static function sqliteDdl(string $table, string $actorCol, string $branchCol, bool $trackBranches): array { $stmts = [ "CREATE TABLE IF NOT EXISTS \"{$table}\" ( @@ -275,12 +485,16 @@ private static function sqliteDdl(string $table, string $actorCol): array valid_from TEXT NOT NULL, snapshot TEXT NOT NULL, changed_cols TEXT NULL - {$actorCol} + {$actorCol}{$branchCol} )", "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)", ]; + if ($trackBranches) { + $stmts[] = "CREATE INDEX IF NOT EXISTS \"idx_{$table}_branch\" ON \"{$table}\" (record_id, branch, valid_from)"; + } + return $stmts; } @@ -294,7 +508,22 @@ private static function actorColumnSql(string $driver): string } /** - * Reset the internal class attribute cache. Useful in tests. + * Generate the branch column definition for the given driver. + * + * @param string $driver + * @return string + */ + private static function branchColumnSql(string $driver): string + { + return match ($driver) { + 'pgsql' => ",\n branch VARCHAR(50) NOT NULL DEFAULT 'main'", + 'sqlite' => ",\n branch TEXT NOT NULL DEFAULT 'main'", + default => "`branch` VARCHAR(50) NOT NULL DEFAULT 'main',", + }; + } + + /** + * Reset the internal class attribute and column-existence caches. Useful in tests. * * @param string|null $class When null, clears all entries. * @return void @@ -305,6 +534,7 @@ public static function resetCache(?string $class = null): void unset(self::$cache[$class]); } else { self::$cache = []; + self::$columnCache = null; } } } diff --git a/tests/Console/MigrateTemporalCommandTest.php b/tests/Console/MigrateTemporalCommandTest.php index 9102e179..03e78c09 100644 --- a/tests/Console/MigrateTemporalCommandTest.php +++ b/tests/Console/MigrateTemporalCommandTest.php @@ -12,13 +12,25 @@ function config($key = null, $default = null) namespace Tests\Unit\Console { require_once __DIR__ . '/../Support/Model/MockTemporalRecord.php'; +require_once __DIR__ . '/../Support/Model/MockMigratableRecord.php'; +require_once __DIR__ . '/Support/CommandTestEnvironment.php'; +use PDO; use PHPUnit\Framework\TestCase; use Phaseolies\Console\Commands\Migrations\MigrateTemporalCommand; +use Phaseolies\Database\Database; +use Phaseolies\Database\Temporal\TemporalManager; use Tests\Support\Model\MockTemporalRecord; +use Tests\Support\Model\MockMigratableRecord; +use Tests\Unit\Console\Support\InteractsWithFakeCommandIO; class MigrateTemporalCommandTest extends TestCase { + private ?PDO $pdo = null; + + /** @var string[] */ + private array $tempDirs = []; + protected function setUp(): void { parent::setUp(); @@ -26,6 +38,32 @@ protected function setUp(): void if (class_exists(\Tests\Unit\Console\Support\CommandTestEnvironment::class)) { \Tests\Unit\Console\Support\CommandTestEnvironment::$config['database.default'] = 'default_connection'; } + + TemporalManager::resetCache(); + + $this->pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $this->setStaticProperty(Database::class, 'connections', ['default' => $this->pdo]); + } + + /** + * Reset connections, temporal caches and temporary directories. + * + * @return void + */ + protected function tearDown(): void + { + $this->setStaticProperty(Database::class, 'connections', []); + TemporalManager::resetCache(); + $this->pdo = null; + + foreach ($this->tempDirs as $dir) { + $this->removeDirectory($dir); + } + $this->tempDirs = []; + + parent::tearDown(); } public function testResolveConnectionForModelPrefersModelConnectionWhenNoOverride(): void @@ -62,5 +100,183 @@ public function testResolveConnectionForModelFallsBackToDefaultConfig(): void $this->assertSame('default_connection', $connection); } + + public function testHandleCreatesHistoryTableWithActorAndBranchColumnsForFreshTable(): void + { + $modelsPath = $this->writeDiscoverableModel(); + + $command = $this->makeCommand(['path' => $modelsPath]); + $exitCode = $command->handle(); + + $historyTable = (new MockMigratableRecord())->historyTable(); + + $this->assertSame(0, $exitCode); + $this->assertEmpty($command->capturedErrors); + $this->assertTrue($this->tableExists($historyTable)); + $this->assertTrue(TemporalManager::hasActorColumn($this->pdo, $historyTable, 'sqlite')); + $this->assertTrue(TemporalManager::hasBranchColumn($this->pdo, $historyTable, 'sqlite')); + $indexes = $this->pdo->query("PRAGMA index_list(\"{$historyTable}\")")->fetchAll(PDO::FETCH_ASSOC); + $this->assertContains('idx_' . $historyTable . '_branch', array_column($indexes, 'name')); + $this->assertStringContainsString('Created: 1', implode(' ', $command->capturedSuccesses)); + } + + public function testHandleAddsMissingActorAndBranchColumnsToAnExistingTable(): void + { + $modelsPath = $this->writeDiscoverableModel(); + $historyTable = (new MockMigratableRecord())->historyTable(); + + // Simulate a table that predates both the actor and branch columns. + foreach (TemporalManager::createHistoryTableSql($historyTable, 'sqlite', false, false) as $sql) { + $this->pdo->exec($sql); + } + TemporalManager::resetCache(); + + $this->assertFalse(TemporalManager::hasActorColumn($this->pdo, $historyTable, 'sqlite')); + $this->assertFalse(TemporalManager::hasBranchColumn($this->pdo, $historyTable, 'sqlite')); + + $command = $this->makeCommand(['path' => $modelsPath]); + $exitCode = $command->handle(); + + // The migration path changes the schema after the pre-check; existence + // checks must observe the new columns immediately. + TemporalManager::resetCache(); + + $this->assertSame(0, $exitCode); + $this->assertEmpty($command->capturedErrors); + $this->assertTrue(TemporalManager::hasActorColumn($this->pdo, $historyTable, 'sqlite')); + $this->assertTrue(TemporalManager::hasBranchColumn($this->pdo, $historyTable, 'sqlite')); + $indexes = $this->pdo->query("PRAGMA index_list(\"{$historyTable}\")")->fetchAll(PDO::FETCH_ASSOC); + $this->assertContains('idx_' . $historyTable . '_branch', array_column($indexes, 'name')); + + $status = implode(' ', array_map(fn ($line) => $line[0], $command->capturedLines)); + $this->assertStringContainsString('actor, branch columns added; branch index added', $status); + } + + public function testHandleIsIdempotentOnceColumnsExist(): void + { + $modelsPath = $this->writeDiscoverableModel(); + $historyTable = (new MockMigratableRecord())->historyTable(); + + foreach (TemporalManager::createHistoryTableSql($historyTable, 'sqlite', true, true) as $sql) { + $this->pdo->exec($sql); + } + TemporalManager::resetCache(); + + $command = $this->makeCommand(['path' => $modelsPath]); + $exitCode = $command->handle(); + + $this->assertSame(0, $exitCode); + $status = implode(' ', array_map(fn ($line) => $line[0], $command->capturedLines)); + $this->assertStringContainsString('already exists', $status); + $this->assertStringNotContainsString('column', $status); + } + + public function testHandleDryRunPrintsBranchColumnSqlWithoutCreatingTheTable(): void + { + $modelsPath = $this->writeDiscoverableModel(); + $historyTable = (new MockMigratableRecord())->historyTable(); + + $command = $this->makeCommand(['path' => $modelsPath, 'show' => true]); + $exitCode = $command->handle(); + + $this->assertSame(0, $exitCode); + $this->assertFalse($this->tableExists($historyTable)); + + $output = implode(' ', array_map(fn ($line) => $line[0], $command->capturedLines)); + $this->assertStringContainsString("branch TEXT NOT NULL DEFAULT 'main'", $output); + $this->assertStringContainsString('actor TEXT NULL', $output); + } + + /** + * Write a copy of MockMigratableRecord into a temp directory so + * MigrateTemporalCommand's filesystem-based discovery can find it. The + * class itself is already loaded via the require_once at the top of + * this file; discovery only reads this file's text to resolve the FQCN. + * + * @return string + */ + private function writeDiscoverableModel(): string + { + $dir = sys_get_temp_dir() . '/doppar-migrate-temporal-' . bin2hex(random_bytes(5)); + mkdir($dir, 0755, true); + $this->tempDirs[] = $dir; + + copy( + __DIR__ . '/../Support/Model/MockMigratableRecord.php', + $dir . '/MockMigratableRecord.php' + ); + + return $dir; + } + + /** + * Recursively delete a temporary directory. + * + * @param string $dir + * @return void + */ + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $dir . '/' . $item; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + + rmdir($dir); + } + + /** + * Build a migrate:temporal command that captures its output. + * + * @param array $options + * @return MigrateTemporalCommand + */ + private function makeCommand(array $options): MigrateTemporalCommand + { + $command = new class extends MigrateTemporalCommand + { + use InteractsWithFakeCommandIO; + }; + $command->fakeOptions = $options; + + return $command; + } + + /** + * Check whether the given table exists in the SQLite test database. + * + * @param string $table + * @return bool + */ + private function tableExists(string $table): bool + { + $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?"); + $stmt->execute([$table]); + + return (bool) $stmt->fetchColumn(); + } + + /** + * Overwrite a static property through reflection. + * + * @param string $className + * @param string $propertyName + * @param mixed $value + * @return void + */ + private function setStaticProperty(string $className, string $propertyName, mixed $value): void + { + $reflection = new \ReflectionClass($className); + $property = $reflection->getProperty($propertyName); + $property->setValue(null, $value); + } } } diff --git a/tests/Model/TemporalBranch/TemporalBranchBehavior.php b/tests/Model/TemporalBranch/TemporalBranchBehavior.php new file mode 100644 index 00000000..b51b600e --- /dev/null +++ b/tests/Model/TemporalBranch/TemporalBranchBehavior.php @@ -0,0 +1,569 @@ + [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'title', 'type' => 'string'], + ['name' => 'price', 'type' => 'string'], + ['name' => 'internal_note', 'type' => 'text', 'nullable' => true], + ['name' => 'metadata', 'type' => 'text', 'nullable' => true], + ['name' => 'enabled', 'type' => 'boolean', 'nullable' => true], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ['name' => 'updated_at', 'type' => 'datetime', 'nullable' => true], + ], + 'temporal_records' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'title', 'type' => 'string'], + ['name' => 'status', 'type' => 'string'], + ['name' => 'body', 'type' => 'text'], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ['name' => 'updated_at', 'type' => 'datetime', 'nullable' => true], + ], + ]; + } + + /** + * The branch tests create their own records. + * + * @return array + */ + protected function seedData(): array + { + return []; + } + + /** + * History tables are rebuilt for every test: some tests replace them with + * an unmigrated (branch-less) shape, and the DDL under test is per-driver. + * + * @return void + */ + protected function resetTables(): void + { + parent::resetTables(); + + $this->recreateHistoryTable((new MockBranchableRecord())->historyTable(), false, true); + $this->recreateHistoryTable((new MockTemporalRecord())->historyTable(), true, false); + } + + /** + * Connect the database and register each mock model's temporal hooks once. + * + * @return void + */ + protected function setupDatabaseConnections(): void + { + parent::setupDatabaseConnections(); + + // Hooks register once per class on first construction, so boot the + // classes first, then clear the registry and register them exactly once. + $branchable = new MockBranchableRecord(); + $temporal = new MockTemporalRecord(); + HookHandler::$hooks = []; + TemporalManager::resetCache(); + \Closure::bind(fn() => $this->registerTemporalHooks(), $branchable, MockBranchableRecord::class)(); + \Closure::bind(fn() => $this->registerTemporalHooks(), $temporal, MockTemporalRecord::class)(); + + Carbon::setTestNow(); + } + + #[After] + /** + * Clear the frozen clock, registered hooks and temporal caches. + * + * @return void + */ + protected function resetTemporalState(): void + { + Carbon::setTestNow(); + HookHandler::$hooks = []; + TemporalManager::resetCache(); + } + + public function testBranchForksFromMainWithoutDisturbingMainHistory(): void + { + $this->travelTo('2026-02-01 10:00:00.000000'); + $created = MockBranchableRecord::create([ + 'title' => 'Widget', + 'price' => '100', + 'internal_note' => 'cost basis is high', + ]); + $record = MockBranchableRecord::find($created->id); + + $this->travelTo('2026-02-01 11:00:00.000000'); + $record->price = '120'; + $record->save(); + + $this->travelTo('2026-02-01 12:00:00.000000'); + $this->assertTrue($record->branch('proposed-pricing', '2026-02-01 10:30:00')); + + $mainHistory = $record->history(); + $this->assertCount(2, $mainHistory); + $this->assertSame(['created', 'updated'], $mainHistory->map->__action->all()); + + $branchHistory = $record->history('proposed-pricing'); + $this->assertCount(1, $branchHistory); + $this->assertSame('branched', $branchHistory[0]->__action); + $this->assertSame(100, $branchHistory[0]->price); + + $onMain = MockBranchableRecord::at('2026-02-01 12:30:00')->find($record->id); + $onBranch = MockBranchableRecord::at('2026-02-01 12:30:00', 'proposed-pricing')->find($record->id); + + $this->assertSame(120, $onMain->price); + $this->assertSame(100, $onBranch->price); + } + + public function testBranchReturnsFalseWhenSourceSnapshotIsMissing(): void + { + $this->travelTo('2026-02-01 10:00:00.000000'); + $created = MockBranchableRecord::create([ + 'title' => 'Widget', + 'price' => '100', + 'internal_note' => 'n/a', + ]); + $record = MockBranchableRecord::find($created->id); + + $this->assertFalse($record->branch('too-early', '2026-01-01 00:00:00')); + } + + public function testBranchRejectsEmptyAndReservedNames(): void + { + $created = MockBranchableRecord::create([ + 'title' => 'Widget', + 'price' => '100', + 'internal_note' => 'n/a', + ]); + $record = MockBranchableRecord::find($created->id); + + $this->expectException(\InvalidArgumentException::class); + $record->branch('main', '2026-02-01 10:00:00'); + } + + public function testBranchNameValidationAndDuplicateForkGuard(): void + { + $this->travelTo('2026-02-01 10:00:00.000000'); + $created = MockBranchableRecord::create(['title' => 'Widget', 'price' => '100', 'internal_note' => null]); + $record = MockBranchableRecord::find($created->id); + + foreach ([str_repeat('a', 51), '', 'has space', "quote'd", 'MAIN', 'Draft'] as $invalid) { + try { + $record->branch($invalid, '2026-02-01 10:00:00'); + $this->fail("Expected branch name [{$invalid}] to be rejected."); + } catch (\InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + + // Read paths validate the name too, before it reaches the database. + $this->expectInvalidBranchName(fn() => MockBranchableRecord::at('2026-02-01 10:00:00', str_repeat('a', 51))); + $this->expectInvalidBranchName(fn() => $record->history(str_repeat('a', 51))); + $this->expectInvalidBranchName(fn() => $record->diff('2026-02-01', '2026-02-02', 'bad name')); + + $this->assertTrue($record->branch(str_repeat('a', 50), '2026-02-01 10:00:00')); + $this->assertTrue($record->branch('proposal', '2026-02-01 10:00:00')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('already exists'); + $record->branch('proposal', '2026-02-01 10:00:00'); + } + + public function testBranchThrowsForModelsNotConfiguredForBranching(): void + { + $created = MockTemporalRecord::create([ + 'title' => 'Contract A', + 'status' => 'draft', + 'body' => 'Original body', + ]); + $record = MockTemporalRecord::find($created->id); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not support branches'); + $record->branch('attempt', '2026-01-01 00:00:00'); + } + + public function testAtWithNonMainBranchThrowsForModelsNotConfiguredForBranching(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not support branches'); + MockTemporalRecord::at('2026-01-01 00:00:00', 'some-branch'); + } + + public function testBranchOperationsRequireTheMigratedBranchColumn(): void + { + $this->recreateHistoryTable((new MockBranchableRecord())->historyTable(), false, false); + $this->pdo->exec("INSERT INTO branchable_records (title, price) VALUES ('Widget', '100')"); + $record = MockBranchableRecord::find(1); + TemporalManager::resetCache(); + + $this->assertMigrationGuard(fn() => MockBranchableRecord::at('2026-02-01 10:00:00')->find(1)); + $this->assertMigrationGuard(fn() => MockBranchableRecord::at('2026-02-01 10:00:00', 'proposal')->get()); + $this->assertMigrationGuard(fn() => $record->branch('proposal', '2026-02-01 10:00:00')); + $this->assertMigrationGuard(fn() => $record->history()); + $this->assertMigrationGuard(fn() => $record->diff('2026-02-01', '2026-02-02')); + } + + public function testMainWritesRemainSafeBeforeBranchColumnMigration(): void + { + $this->recreateHistoryTable((new MockBranchableRecord())->historyTable(), false, false); + TemporalManager::resetCache(); + + $record = MockBranchableRecord::create([ + 'title' => 'Widget', 'price' => '100', 'internal_note' => null, + ]); + $record = MockBranchableRecord::find($record->id); + $record->title = 'Renamed'; + $this->assertTrue($record->save()); + $this->assertTrue($record->delete()); + + $count = $this->pdo->query('SELECT COUNT(*) FROM branchable_records_history')->fetchColumn(); + $this->assertSame(3, (int) $count); + } + + public function testDiffRespectsCastsAndHidesUnexposableAttributes(): void + { + $this->travelTo('2026-02-01 10:00:00.000000'); + $created = MockBranchableRecord::create([ + 'title' => 'Widget', + 'price' => '100', + 'internal_note' => 'cost basis is high', + ]); + $record = MockBranchableRecord::find($created->id); + + $this->travelTo('2026-02-01 11:00:00.000000'); + $record->price = '150'; + $record->internal_note = 'revised note'; + $record->save(); + + $diff = $record->diff('2026-02-01 10:30:00', '2026-02-01 11:30:00'); + + $this->assertIsInt($diff['changes']['price']['from']); + $this->assertIsInt($diff['changes']['price']['to']); + $this->assertSame(100, $diff['changes']['price']['from']); + $this->assertSame(150, $diff['changes']['price']['to']); + $this->assertArrayNotHasKey('internal_note', $diff['changes']); + $this->assertArrayNotHasKey('internal_note', $diff['added']); + $this->assertArrayNotHasKey('internal_note', $diff['removed']); + } + + public function testDiffDetectsArrayAndBooleanCastChanges(): void + { + $this->travelTo('2026-02-01 10:00:00.000000'); + $created = MockBranchableRecord::create([ + 'title' => 'Widget', 'price' => '100', 'internal_note' => null, + 'metadata' => ['color' => 'blue'], 'enabled' => false, + ]); + $record = MockBranchableRecord::find($created->id); + + $this->travelTo('2026-02-01 11:00:00.000000'); + $record->metadata = ['color' => 'red']; + $record->enabled = true; + $record->save(); + + $diff = $record->diff('2026-02-01 10:30:00', '2026-02-01 11:30:00'); + + $this->assertSame(['color' => 'blue'], $diff['changes']['metadata']['from']); + $this->assertSame(['color' => 'red'], $diff['changes']['metadata']['to']); + $this->assertFalse($diff['changes']['enabled']['from']); + $this->assertTrue($diff['changes']['enabled']['to']); + $this->assertArrayNotHasKey('title', $diff['changes']); + } + + public function testDiffIgnoresEquivalentUncastScalarTypes(): void + { + $this->pdo->exec("INSERT INTO branchable_records (title, price) VALUES ('5', '100')"); + $record = MockBranchableRecord::find(1); + $table = $record->historyTable(); + $first = json_encode(['id' => 1, 'title' => 5, 'price' => '100']); + $second = json_encode(['id' => 1, 'title' => '5', 'price' => '100']); + $stmt = $this->pdo->prepare( + "INSERT INTO {$table} (record_id, action, valid_from, snapshot, branch) VALUES (?, 'updated', ?, ?, 'main')" + ); + $stmt->execute([1, '2026-02-01 10:00:00.000000', $first]); + $stmt->execute([1, '2026-02-01 11:00:00.000000', $second]); + + $diff = $record->diff('2026-02-01 10:30:00', '2026-02-01 11:30:00'); + + $this->assertArrayNotHasKey('title', $diff['changes']); + } + + public function testBranchCanBeUpdatedDiffedAndRestoredByHistoryId(): void + { + $this->travelTo('2026-02-01 10:00:00.000000'); + $created = MockBranchableRecord::create(['title' => 'Widget', 'price' => '100', 'internal_note' => null]); + $record = MockBranchableRecord::find($created->id); + + $this->travelTo('2026-02-01 11:00:00.000000'); + $record->branch('proposal', '2026-02-01 10:30:00'); + + $proposal = MockBranchableRecord::at('2026-02-01 11:30:00', 'proposal')->find($record->id); + $proposal->price = '140'; + $this->assertTrue($proposal->saveToBranch('proposal')); + $this->assertArrayHasKey('price', $proposal->getDirtyAttributes()); + + // Writing to the branch never touches the live row or main history. + $this->assertSame(100, MockBranchableRecord::find($record->id)->price); + $this->assertCount(1, $record->history()); + + $diff = $record->diffAgainstBranch('proposal'); + $this->assertSame('proposal', $diff['branch']['name']); + $this->assertSame(100, $diff['main']['price']); + $this->assertSame(140, $diff['branch']['snapshot']['price']); + $this->assertSame(100, $diff['changes']['price']['from']); + $this->assertSame(140, $diff['changes']['price']['to']); + + $branchHistory = $record->history('proposal'); + $this->assertSame(['branched', 'updated'], $branchHistory->map->__action->all()); + + $this->travelTo('2026-02-01 12:00:00.000000'); + $this->assertTrue($record->restoreVersion($branchHistory[1]->__history_id)); + $this->assertSame(140, MockBranchableRecord::find($record->id)->price); + $this->assertSame(['created', 'updated'], $record->history()->map->__action->all()); + } + + public function testSaveToBranchRequiresAnExistingBranch(): void + { + $created = MockBranchableRecord::create(['title' => 'Widget', 'price' => '100', 'internal_note' => null]); + $record = MockBranchableRecord::find($created->id); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not exist'); + $record->saveToBranch('never-forked'); + } + + public function testRestoreFromBranchCreatesANewMainVersionAndNeverRewritesHistory(): void + { + $this->travelTo('2026-02-01 10:00:00.000000'); + $created = MockBranchableRecord::create([ + 'title' => 'Widget', + 'price' => '100', + 'internal_note' => 'n/a', + ]); + $record = MockBranchableRecord::find($created->id); + + $this->travelTo('2026-02-01 11:00:00.000000'); + $record->price = '120'; + $record->save(); + + $this->travelTo('2026-02-01 12:00:00.000000'); + $record->branch('rollback-candidate', '2026-02-01 10:30:00'); + + $this->travelTo('2026-02-01 13:00:00.000000'); + $this->assertTrue($record->restoreTo('2026-02-01 12:30:00', 'rollback-candidate')); + + $fresh = MockBranchableRecord::find($record->id); + $this->assertSame(100, $fresh->price); + + // Restoring persisted through the normal save()/snapshot path, so the + // main timeline only ever grew — nothing was rewritten. + $this->assertCount(3, $fresh->history()); + $this->assertSame(['created', 'updated', 'updated'], $fresh->history()->map->__action->all()); + } + + public function testRestoreVersionRejectsDeletedHistoryEntries(): void + { + $created = MockBranchableRecord::create(['title' => 'Widget', 'price' => '100', 'internal_note' => null]); + $record = MockBranchableRecord::find($created->id); + $record->delete(); + $deleted = $record->history()[1]; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('deleted temporal version'); + $record->restoreVersion($deleted->__history_id); + } + + public function testRewindAndRestoreAgreeOnAPointWhereTheRecordWasDeleted(): void + { + $this->travelTo('2026-02-01 10:00:00.000000'); + $created = MockBranchableRecord::create(['title' => 'Widget', 'price' => '100', 'internal_note' => null]); + $record = MockBranchableRecord::find($created->id); + + $this->travelTo('2026-02-01 11:00:00.000000'); + $record->delete(); + + $this->assertNull($record->rewindTo('2026-02-01 11:30:00')); + $this->assertFalse($record->restoreTo('2026-02-01 11:30:00')); + $this->assertNotNull($record->rewindTo('2026-02-01 10:30:00')); + } + + public function testRestoreToNonMainBranchThrowsForModelsNotConfiguredForBranching(): void + { + $created = MockTemporalRecord::create([ + 'title' => 'Contract A', + 'status' => 'draft', + 'body' => 'Original body', + ]); + $record = MockTemporalRecord::find($created->id); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not support branches'); + $record->restoreTo('2026-01-01 00:00:00', 'some-branch'); + } + + public function testRewindAndRestoreTreatDeletedPointsTheSameOnNonBranchableModels(): void + { + $this->travelTo('2026-02-01 10:00:00.000000'); + $created = MockTemporalRecord::create([ + 'title' => 'Contract A', 'status' => 'draft', 'body' => 'Original body', + ]); + $record = MockTemporalRecord::find($created->id); + $this->travelTo('2026-02-01 11:00:00.000000'); + $record->delete(); + + $this->assertNull($record->rewindTo('2026-02-01 11:30:00')); + $this->assertFalse($record->restoreTo('2026-02-01 11:30:00')); + } + + public function testDiffAgainstBranchReturnsNullAfterMainDeletion(): void + { + $this->travelTo('2026-02-01 10:00:00.000000'); + $created = MockBranchableRecord::create([ + 'title' => 'Widget', 'price' => '100', 'internal_note' => null, + ]); + $record = MockBranchableRecord::find($created->id); + $record->branch('proposal', '2026-02-01 10:00:00'); + $this->travelTo('2026-02-01 11:00:00.000000'); + $record->delete(); + + $this->assertNull($record->diffAgainstBranch('proposal')); + } + + public function testSameMicrosecondVersionsResolveToTheLatestHistoryId(): void + { + $this->travelTo('2026-02-01 10:00:00.000000'); + $created = MockBranchableRecord::create(['title' => 'Widget', 'price' => '100', 'internal_note' => null]); + $record = MockBranchableRecord::find($created->id); + + // Same frozen clock: both updates share one valid_from value. + $record->price = '110'; + $record->save(); + $record->price = '120'; + $record->save(); + + $this->assertSame(120, MockBranchableRecord::at('2026-02-01 10:00:00')->find($record->id)->price); + $this->assertSame(120, MockBranchableRecord::at('2026-02-01 10:00:00')->get()->first()->price); + $this->assertSame(120, $record->rewindTo('2026-02-01 10:00:00')->price); + } + + public function testHasBranchColumnSeesAColumnAddedAfterANegativeCheck(): void + { + $table = 'migration_check_history'; + $driver = static::driverName(); + + $this->recreateHistoryTable($table, false, false); + + $this->assertFalse(TemporalManager::hasBranchColumn($this->pdo, $table, $driver)); + + $this->pdo->exec(sprintf( + "ALTER TABLE %s ADD COLUMN branch VARCHAR(50) NOT NULL DEFAULT 'main'", + $this->quoteIdentifier($table) + )); + TemporalManager::resetCache(); + + $this->assertTrue(TemporalManager::hasBranchColumn($this->pdo, $table, $driver)); + + $this->dropTablesIfExist([$table]); + } + + public function testTemporalManagerDdlIncludesBranchColumnAndIndexWhenEnabled(): void + { + $mysql = TemporalManager::createHistoryTableSql('widgets_history', 'mysql', false, true); + $pgsql = TemporalManager::createHistoryTableSql('widgets_history', 'pgsql', false, true); + $sqlite = TemporalManager::createHistoryTableSql('widgets_history', 'sqlite', false, true); + + $this->assertStringContainsString("`branch` VARCHAR(50) NOT NULL DEFAULT 'main'", $mysql[0]); + $this->assertStringContainsString('INDEX `idx_branch` (`record_id`, `branch`, `valid_from`)', $mysql[0]); + + $this->assertCount(4, $pgsql); + $this->assertStringContainsString("branch VARCHAR(50) NOT NULL DEFAULT 'main'", $pgsql[0]); + $this->assertStringContainsString('idx_widgets_history_branch', $pgsql[3]); + $this->assertStringContainsString('(record_id, branch, valid_from)', $pgsql[3]); + + $this->assertCount(4, $sqlite); + $this->assertStringContainsString("branch TEXT NOT NULL DEFAULT 'main'", $sqlite[0]); + $this->assertStringContainsString('idx_widgets_history_branch', $sqlite[3]); + $this->assertStringContainsString('(record_id, branch, valid_from)', $sqlite[3]); + } + + /** + * Drop and recreate a history table with the given optional columns. + * + * @param string $table + * @param bool $trackActor + * @param bool $trackBranches + * @return void + */ + private function recreateHistoryTable(string $table, bool $trackActor, bool $trackBranches): void + { + $this->dropTablesIfExist([$table]); + + foreach (TemporalManager::createHistoryTableSql($table, static::driverName(), $trackActor, $trackBranches) as $sql) { + $this->pdo->exec($sql); + } + } + + /** + * Freeze the clock at the given UTC datetime. + * + * @param string $datetime + * @return void + */ + private function travelTo(string $datetime): void + { + Carbon::setTestNow(Carbon::parse($datetime, 'UTC')); + } + + /** + * Assert that the operation asks for migrate:temporal to be run. + * + * @param callable $operation + * @return void + */ + private function assertMigrationGuard(callable $operation): void + { + try { + $operation(); + $this->fail('Expected missing branch column to require migrate:temporal.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('run migrate:temporal', $exception->getMessage()); + } + } + + /** + * Assert that the operation rejects the branch name. + * + * @param callable $operation + * @return void + */ + private function expectInvalidBranchName(callable $operation): void + { + try { + $operation(); + $this->fail('Expected an invalid branch name to be rejected.'); + } catch (\InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } +} diff --git a/tests/Model/TemporalBranch/TemporalBranchMysqlTest.php b/tests/Model/TemporalBranch/TemporalBranchMysqlTest.php new file mode 100644 index 00000000..aee34149 --- /dev/null +++ b/tests/Model/TemporalBranch/TemporalBranchMysqlTest.php @@ -0,0 +1,22 @@ +