Repository navigation
(feat) Branchable temporal records with explainable history - #367
Conversation
Add named branches to #[Temporal] models: a branch can be forked from a point-in-time snapshot of the main timeline and queried independently via Model::at($datetime, $branch) / $model->history($branch), without touching main history. Restoring from any branch still only ever appends a new version through the normal save()/snapshot path; history is never rewritten. Also fix diff() to run both snapshots through the model's casts and unexposable (hidden) attribute filtering instead of comparing raw stored values, so hidden fields no longer leak into diffs and cast values compare in their proper type. Branch support is fully opt-in via #[Temporal(branches: true)]; existing temporal models are unaffected. MigrateTemporalCommand and the per-driver DDL generators gained the matching branch column/index for MySQL, PostgreSQL and SQLite, following the same pattern already used for the actor column.
|
Thanks for the PR. The design is sound and opt-in, and it leaves existing temporal models untouched. The 17 tests in Thanks for the PR. The design is sound and opt-in, and it leaves existing temporal models untouched. The 17 tests in Must fix1.
|
- diff() compares cast values strictly instead of via (string), so array/json, bool and null changes are no longer dropped - Branchable models throw a "run migrate:temporal" RuntimeException when the history table lacks the branch column, instead of silently reading main-timeline rows - Add saveToBranch(), diffAgainstBranch() and restoreVersion(); both restore paths refuse deleted versions - migrate:temporal creates the (record_id, branch) index when it adds the column to an existing table - Validate branch names (1-50 chars, [A-Za-z0-9_-]) on every entry point; forking an existing branch name now throws - Use history_id as a tiebreak for same-microsecond versions - Port TemporalBranchTest to the Sqlite/Mysql/Pgsql driver layout - Drop phpstan baseline suppressions, don't cache missing columns, scope the PostgreSQL column check to the current schema
|
@techmahedy, updated again. |
|
@abdulmajidcse # Review, round 2: Branchable temporal records ( Thanks for the quick follow-up. Most of the first round is properly fixed, and the new Reading the new code and probing it against SQLite turned up two regressions and a few smaller problems. Each item below was reproduced by running it, except the MySQL collation item, which is reasoned from the DDL and needs a MySQL run to confirm. Status of round one
Must fix1. Writes on an unmigrated branchable table now save the record and then throwFile:
Reproduced: add The live row is saved, no history row is written, and the caller gets an exception. A caller that retries creates duplicates. Deploying the attribute before running The previous behavior was safe: skip the Change: keep the guard on reads and on branch operations ( 2.
|
- Record main writes on branchable models before migrate:temporal; only named-branch operations require the branch column - Compare uncast scalars by value in diff() so 5 and '5' are equal - Keep the model dirty after saveToBranch() - Give diffAgainstBranch() its own result shape and return null when the live record is deleted - Cache column checks per connection, including misses, and clear them in migrate:temporal and resetCache() - Accept lowercase branch names only - Index (record_id, branch, valid_from); report columns and indexes separately in migrate:temporal - Document the restoreVersion()/restoreTo() return convention
Resolve conflicts with the upstream temporal fixes: - diff() compares cast, visible values with upstream's snapshotValuesDiffer() instead of sameCastValue() - snapshotAt() and TemporalBuilder::find() let the latest row decide (a "deleted" row means no snapshot), keeping the branch filter and history_id tiebreak - rewindTo() returns null and restoreTo() returns false at a deleted point on every model, replacing the branchable-only exception; restoreVersion() still refuses an explicit deleted entry
|
@techmahedy, check now, please. |
thanks for update, perfect now, make sure your all the functions follow this doc comments convention |
| } | ||
| } | ||
|
|
||
| private function createBranchIndexSql(string $driver, string $table): string |
There was a problem hiding this comment.
https://doppar.com/versions/4.x/contributions#phpdoc
follow this convention please
| } | ||
|
|
||
| /** Validate branch names consistently with the persisted column. */ | ||
| public static function validateBranchName(string $branch): void |
There was a problem hiding this comment.
| } | ||
|
|
||
| /** Ensure branch-enabled models have completed migrate:temporal. */ | ||
| public static function assertBranchColumn(PDO $pdo, string $historyTable, string $driver): void |
There was a problem hiding this comment.
| } | ||
|
|
||
| /** Append an edited proposed state to an existing branch without saving the live record. */ | ||
| public static function snapshotBranchVersion(Model $model, string $branch): void |
There was a problem hiding this comment.
Pull Request Checklist
Problem - Solution of this issue
Doppar's
#[Temporal]models already snapshot every create, update, delete and restore into an append-only history table, and support point-in-time reads throughModel::at($datetime). There was no way to keep a proposed change to a record, review it against the live state, and then apply or discard it without touching the live history.What this adds
A model opts in with
#[Temporal(branches: true)]. A named branch is forked from the main timeline, takes further proposed versions, and can be compared with the live record and restored from:Model::at(),history(),diff(),rewindTo()andrestoreTo()take an optional branch, defaulting tomain.history(null)returns every branch.branch($name, $fromDatetime)forks a branch from the main snapshot at that time. Forking an existing name throws. The branch timeline starts when the fork is created.saveToBranch($name)appends the in-memory state to an existing branch without writing the live row. The model stays dirty, so a latersave()still makes it live.diffAgainstBranch($name)returnsat,main,branch {name, snapshot},changes,addedandremoved. It returnsnullwhen the live record is deleted.restoreVersion($historyId)restores one history entry. It returnsfalsefor an unknown id and throws for adeletedentry.[a-z0-9_-], validated on every entry point.mainis reserved. Names are lowercase-only so that MySQL's case-insensitive collation and PostgreSQL/SQLite cannot disagree about which branch a name refers to.mainon a model withoutbranches: truethrows aRuntimeException. On a branchable model whose history table has not been migrated yet, writes tomainstill succeed and are recorded. Every branch operation throws and asks you to runmigrate:temporal.diff()now runs both snapshots through the model's casts and hides unexposable attributes, then compares them with upstream'ssnapshotValuesDiffer(), so5and'5'are equal andnulland''differ. The result also includes abranchkey.Behaviour of existing temporal models
Models without
branches: truebehave as before, apart from two additive changes:diff()results gain abranchkey (alwaysmain), and values are cast and hidden attributes filtered out before comparing.restoreVersion()is new. It refusesdeletedentries on every temporal model.rewindTo()andrestoreTo()follow upstream's rule from b3fa670 on every model: at a point where the record was deleted,rewindTo()returnsnullandrestoreTo()returnsfalse. This branch is merged with the current4.x.Schema changes
An opt-in
branchcolumn (VARCHAR(50)/TEXT,NOT NULL DEFAULT 'main') and a(record_id, branch, valid_from)index, generated per driver inTemporalManagerthe same way as the opt-inactorcolumn.migrate:temporaladds the column and the index to existing history tables, reports them separately, and clears the column cache. Rows written before the migration take the defaultmain.Column-existence checks are cached per PDO connection (a
WeakMap), including negative results, and the cache is cleared byTemporalManager::resetCache()and bymigrate:temporal.Tests
tests/Model/TemporalBranch/runs the same behaviour suite on SQLite, MySQL and PostgreSQL (TemporalBranchSqliteTest,TemporalBranchMysqlTest,TemporalBranchPgsqlTest). It covers forking, branch writes, branch diffs, restore guards, name validation including uppercase names, writes before migration, uncast type equivalence, and same-microsecond ordering.tests/Console/MigrateTemporalCommandTest.phpcovers fresh tables, adding the column and index to existing tables, idempotent reruns and dry-run output.4.x: 4593 tests, 0 failures. PHPStan clean.Docs
temporal-orm.mddocumentsbranches: true, the new methods, branch naming rules and the restore guards: doppar/docs#116Checklist