Repository navigation
Releases
Releases · Behnam-RK/django-guitars
v2.22.0
Immutable
release. Only release title and notes can be modified.
Added
- A guard on every cascade child archives a child created or re-pointed under an archived parent (#91, ADR 0044). An archive locks the parent row
FOR NO KEY UPDATE, which the foreign-key check of an insert (FOR KEY SHARE) does not wait for, and the cascade only reaches rows that exist when its statement starts. So a child committed while another session archived its parent stayed live under it, in either order, atREAD COMMITTEDandREPEATABLE READ, and the same for a child moved onto an archived parent and a leaf added to a tree being archived. This was true of the rule the arms replaced too; it was measured with two sessions for the first time. Each cascade child table now carriessoft_delete_guard_on_<n>_<table>, aBEFORE INSERT OR UPDATErow trigger that reads the parentFOR SHAREwhen the key arrives or moves, waits for an uncommitted archive, and copies the parent's_deleted_at; an archive in turn waits for an uncommitted insert and takes its child. A key declared on an MTI descendant archives the ancestor's row from anAFTERtrigger. The stamp is the parent's own, so restoring the parent brings the late child back. GUITARS_CASCADE_GUARD(defaultTrue):Falseretires every recorded guard.tests/test_concurrency_arms.py: the interleavings, stepped by hand with two connections, and the limits that remain.
Changed
- A child created under an archived parent is archived with it, where it was live. Code that relied on a live child under an archived parent needs the parent restored first.
- Existing projects:
makemigrationswrites one enforcement migration per app that hosts a cascade child, creating the guards. A guard reads the parent row on every insert and on every update that moves the key, and holdsFOR SHAREon it until commit, so an insert waits for a concurrent update of that parent and a bulk load pays a lookup per row, and two transactions that each insert a child and then update the same parent (a counter, a denormalised total) now deadlock (40P01) where they serialised. If that pattern is common in your code, setGUITARS_CASCADE_GUARD = False. It runs as the invoker:FOR SHAREneedsUPDATEon the parent table, and a role without it gets a plain read, so no insert fails but the race stays open for that role.session_replication_role = replicaskips it. - Not closed, and pinned by tests: an archive in a
REPEATABLE READtransaction whose snapshot predates a child's commit still misses that child (twoSERIALIZABLEsessions are refused with40001), and a child restored by hand under an archived parent stays live. RetireEnforcementtakes the guard, with a column or a whole table: its body names the key and_deleted_at.- Tests only, for corners the suite did not reach: a self key on a model with no
_updated_at(Twig), an MTI child's redirect rule and parent trigger across a drop and a rename in the scan, and--adoptover a recorded self-cascade trigger.
v2.21.0
Immutable
release. Only release title and notes can be modified.
Fixed
- A model deleted and later recreated on the same table, a renamed model's old table retaken by a new model, and a model moved to another app and back are no longer read as covered (#66, ADR 0043). The scan read every app's files in registry order and never reacted to a
DeleteModel, so a recreated or retaking table got no soft-delete rule, no_updated_attrigger and, tenanted, no policy (its rows deleted outright by a rawDELETE), and a moved-back model's owned sweep kept naming the intermediate table, failing everyUPDATEof its owner, all withmakeguitarmigrations --checkgreen. It now replays the migrations in the ordermigrateruns them, each file's table events (create, rename, drop) before its headers: a drop voids what lived on the table, a rename moves every family with it and forwards a later header that still names the old name, and a name a new model takes is a new table. A file whose tables were since renamed away or dropped no longer vouches for its[DIGEST], so an identical operation set is written again.live_tables,_rekey,_chain_endsandkeep_existinggo, and so dograph.renamed_tables,renames_by_migration,moves_between_apps_by_migrationandretired_enforcement. Existing projects:makemigrations(ormakeguitarmigrations) writes the missing coverage for a history of any of the three shapes: a plainCREATEfor the table, a re-emitted sweep and owner trigger for a model moved back. Two migrations the graph leaves unordered are replayed inmigrate's order;--checknames the missing edge (ADR 0036).
v2.20.1
Immutable
release. Only release title and notes can be modified.
Changed
- Tests only: strict-xfail repros for the shapes #66 still has open on 2.20.0, from its re-scope. Pinned: a plain cascade child deleted and later recreated on the same
db_table, and a model retaking a renamed model's old table, each left with no soft-delete rule or_updated_attrigger and--checkgreen, so a rawDELETEremoves their rows; and a model moved to another app and back, whose owned sweep still names the intermediate table, so everyUPDATEof its owner's table fails. The recreated MTI, tenanted and primary-key-changed variants are on the issue, not pinned. Four fixture apps (tests/issue66_*) carry the histories. No library change.
v2.20.0
Immutable
release. Only release title and notes can be modified.
Changed
- A cycle of
CASCADEkeys through several tables is enforced (#85, ADR 0041). It was refused while the cascade was anON UPDATErule, which PostgreSQL rewrites into itself; since 2.19.0 it is an arm of the owner's trigger, and an arm stops at_deleted_at IS NULL. Every project that declared one gets the arms: one archive stamps the whole loop it reaches, and a restore matches that stamp, so it travels up the loop too. A joined key into its own MTI root is enforced as well.soft_delete()and the.delete()fast path no longer decline a cycle edge. A cycle holding an owned key is still refused, with a note that no longer cites rule recursion.CascadeKind.CYCLEis gone andclassify_cascadetakes nocycle_edges. Existing projects:makemigrations(ormakeguitarmigrations) writes a migration for each app hosting a cycle key, which replaces the owner'ssoft_delete_cascade_on_*trigger with one carrying the arms. - A self-referential
CASCADEkey is an arm of the owner's trigger (#85, ADR 0042). It took a trigger of its own since 2.8.0, which stampedNOW()and never revived. It now carries the parent's_deleted_atand a restore revives the subtree archived with it; a child archived earlier keeps its own stamp. The primary-key-rewrite refusal is the union of the two (wider than the old trigger's for a statement that re-keys an archived row and also touches a live sibling with children), and a$$in a name no longer refuses the key.makemigrationswrites, for each app hosting a self key, a migration that replaces the owner's trigger and retires the self one, whose reverse rebuilds it (the owner's trigger, re-emitted, reverses to aDROPas every re-emission does). Existing projects:DROP TRIGGERtakes ACCESS EXCLUSIVE on those tables until the migration commits, so setlock_timeout. Dropping a self key's column now fails an archive or a restore of the table until re-generated, not everyUPDATE.
v2.19.1
Immutable
release. Only release title and notes can be modified.
Changed
- The MTI parent
_updated_attrigger skips an ancestor this transaction already stamped (#84, ADR 0040). A fullsave()of a multi-table-inheritance child updates the ancestor first, so the follow-upUPDATErewrote the row with the same value: two writes became one. A child-onlyupdate_fieldssave still stamps the ancestor. The change is a body replaced in place,CREATE OR REPLACE FUNCTION set_parent_updated_at(): no child table is migrated or locked, and theWHEN (pg_trigger_depth() = 0)stays.
Existing projects: run makemigrations (or makeguitarmigrations). It writes one migration in TRIGGER_FUNCTION_APP that replaces the function body; unapplying it puts the old body back.
v2.19.0
Immutable
release. Only release title and notes can be modified.
Changed
_updated_atis aBEFORE UPDATE … FOR EACH ROWtrigger that assignsNEW, not a statement trigger that issues a secondUPDATE(#80, ADR 0038). The old form re-UPDATEd every row through dynamic SQL, so each row was written twice, the plan was never cached, and the follow-up was itself rewritten by the table'sON UPDATErules: on a consumer's staging database one single-rowUPDATEof a table with six cascade rules became 35 statements over 13 tables, 466 of 576 ms spent inupdated_at_trigger. It now assignsNEW._updated_at := NOW()on the row being written, so oneUPDATEwrites each row once andRETURNING _updated_atreads the stamped value. Always assigned, as before: a caller still cannot set the column. Existing projects: the nextmakeguitarmigrationsemits one function migration (stamp_updated_at(), inTRIGGER_FUNCTION_APP) and one replacement migration per app that swaps eachupdated_at_triggerwithCREATE OR REPLACE TRIGGER(SHARE ROW EXCLUSIVE, notACCESS EXCLUSIVE, on the app's tables until it commits: writes block, reads do not, so setlock_timeouton a large app), reversing to the statement trigger;makemigrations --checkis red until it does. The frozenset_updated_at()stays in a migrated database, called by nothing, and a table not yet regenerated keeps the old trigger and works. The MTI parent trigger (set_parent_updated_at) is unchanged.- The self-referential cascade no longer leaves
_updated_atstale below its first level. The new trigger carries noWHEN (pg_trigger_depth() = 0), so the cascade children of every level a rawDELETEreaches are stamped, as the ORM collector already did; the two paths now agree.delete()therefore takes its fast path for a self-referential key, which it used to stand aside for on this account. - The cascade and the owned rule are no longer
ON UPDATErules (#80, ADR 0039). A rule is expanded and planned on everyUPDATEof its table, whether or not_deleted_atmoves: one rename of aBandwas twelve statements over eight tables, and each cascade level multiplies it. Each cascade key is now an archive arm of its owner's existing statement trigger, beside its revive arm, behind a firstEXISTSthat a plainUPDATEstops at, so the same rename is one statement. Flat and joined forms; it reads the owner's before image for the key and the after image for the stamp, keeping_deleted_at IS NULLand the parent's own timestamp. The owned rule is retired and its sweep, which stamped a superset, carries the work alone. Existing projects: the nextmakeguitarmigrationsemits, in each app hosting an owner, the owner's newsoft_delete_cascade_on_*trigger (cascade arms and revive arms in one), a retirement of the revive-onlysoft_delete_revive_on_*trigger it supersedes, and a retirement of every cascade rule and owned rule it recorded, each reversing to what it dropped;makemigrations --checkis red until it does. A rule is dropped only where the trigger carries its arm, so one whose arm is refused keeps cascading. The migration takesACCESS EXCLUSIVEon each owner table (the revive-only trigger'sDROP) andSHARE ROW EXCLUSIVEon the others until it commits: setlock_timeouton a large app. - A statement archiving a row while rewriting its primary key is refused (
feature_not_supported) where a live child holds a key of a live row the statement read (so a pk reused inside it is caught too), as the self-referential cascade has been since 2.8.0: the rule readold.per row, the arm pairs a row across the statement on its primary key. Rewrite the key and archive in separate statements; a key rewrite with nothing live below it, or with no archive, is untouched. - Without
FORCE ROW LEVEL SECURITYthe cascade no longer reaches a child the scope cannot see. A rule's action ran as the table owner, who is exempt from row-level security unless forced, so it archived cross-tenant children; the arm runs as the invoker and leaves them live, as underFORCE(the default) both always did. OnlyGUITARS_RLS_FORCE = False, the staged-retrofit state, differs. - What a plpgsql arm gives up that a rule recorded. A rule depended on every table and column it named, so a
RemoveFieldorDeleteModelof a child failed atmigrate. An arm records no dependency: dropping or renaming a child's table or key column leaves the owner's trigger naming what is gone and fails an archive of that owner untilmakeguitarmigrationsre-emits it (--checkis red).DISABLE TRIGGERandsession_replication_role = replicanow switch a cascade off, where a rule survived both. - A table or column named with
$$gets another dollar-quote tag in the owner trigger and the owned sweep ($guitars$), where an arm spliced into$$would close it early. No key is left without its cascade, and no recorded rule is retired for want of one; every migration written so far keeps$$. sweepownedfollows a relation the database holds an owned rule or a sweep for, since a 2.19.0 database holds no owned rule. A scan no longer flags an app for the digest guard when it reads a trigger-family retirement, which would have waived it for ever after the one-time retirement of every owned rule (ADR 0021 point 9).
v2.18.1
Immutable
release. Only release title and notes can be modified.
Changed
- Documentation only: ADRs 0036 and 0037 are accepted, and
CLAUDE.mddescribes the 2.18.0 behaviour it had gone stale on (the sparing closure reads aGenericRelationchild; migration edges also run forward, and a model moved between apps is read as a rename).
v2.18.0
Immutable
release. Only release title and notes can be modified.
Fixed
- A fresh
migratecould run a rename or delete of a table before an older enforcement migration naming it (#61, ADR 0036). The SQL of a cascade rule or an MTI redirect rule names a table; nothing ordered it before a later migration of another app that renames that table away or drops it, so a fresh database failed withrelation "…" does not existwhere an incrementally migrated one never did. Themakemigrationsoverride now writes the dependency onto the rename or delete it has just built (not the leaf--updaterewrites), andmakemigrations --checknames any pair already on disk with the migration to edit and the tuple to paste. Column-level changes are not read. Upgrade step: a history already holding such a pair turns--checkred until the named dependency is added to the rename or delete migration. - A model moved between apps (
SeparateDatabaseAndStaterenaming the table on its database side and deleting the model on its state side) is read as the rename it is (#66): its coverage follows the table, instead of the new name reading as uncovered and colliding on the plainCREATE. What the destination's app filed under the new name is kept whichever app is scanned first, and what another app filed under the old name is carried to the end of its chain once the scan is done, through later renames or moves, before any retirement is settled; a rename chain that began in another app is joined to this one's, so the old rule is dropped rather than left live (ADR 0037). Known limits: a model moved back keeps a stale rule name, and the migration doing the move's database half has to be ordered before the enforcement naming its table by hand. hard_delete()aborted atCOMMITfor aGenericRelationchild (#76). Sparing now reads what points at a generic child, as it does a cascade child's, and spares the target instead.- A tenant lookup spelled as the column (
tenanted_manager(label='label_id')) read as an ancestor's field: the policy joined the table to itself and no autofill trigger was written. It is the model's own field now. Upgrade step: such a project regenerates its tenant policy (makemigrations). - The retirement reverse of a revive trigger refuses, rather than rebuilds, a body naming
$$, as the forward path has always skipped it. - Instance
hard_delete()asksguitars.E005of every model it can seed from before Phase 1, owned targets included; it used to meet one mid-walk, after signals had fired.
Changed
- The owned fixpoint reads each cascade closure once (#60).
_OwnedScankeeps each root's closure and narrows it for a shrinking candidate set and for a spared target asked again next round; a singleWITH RECURSIVEcannot express the shrinking fixpoint, PostgreSQL refusing the recursion underNOT EXISTS. The delta-only rounds shipped in 2.14.3.
v2.17.0
Immutable
release. Only release title and notes can be modified.
Added
guitars.E005refuses an MTI child that declares a primary key of its own beside its parent link (#64, ADR 0034). Every join the kit writes from such a child up to an ancestor holding a kit column or tenant dimension read its key as that link: aDELETEof the child archived an unrelated ancestor row, the parent_updated_attrigger and owned arms touched or matched one, and a tenant policy's owner join matched another tenant's row.manage.py checkandmigratenow stop on it, with a hint: drop the explicit key, or make the model its own with a foreign key to its parent. The generator re-asks the question for a project that skips checks, and writes no MTI redirect rule, parent trigger, owned arm, owner-join policy or cascade rule into such a model, naming E005 in a note except for the owned arm, which is dropped quietly;hard_delete(), which runs no check, raisesImproperlyConfiguredfor it, for its whole tree in the queryset form and for any such child the instance walk reaches, where it removed an unrelated ancestor row. A tenanted model with no kit column is covered too. A child holding its own column or tenant dimension is not: it joins nothing.
Fixed
- A
CASCADEkey throughto_fieldmatched on the owner's primary key (#59, ADR 0035). With an integer column the rule archived the wrong rows (a seat holdingnumber2 matched the ticket whose pk was 2); with a char columnCREATE RULEcomparedvarcharwithbigintandmigratefailed. The cascade rule, its revive arm and the self-cascade trigger now match children on theto_fieldcolumn, pairing a row across a statement on the pk. A key into the primary key renders as it did: nothing re-emits for it. A column declared on a descendant, below the table holding_deleted_at, is a column the rule cannot read, and is refused with a note instead. soft_delete()and the.delete()fast path take ato_fieldkey now: it was a refusal, and a decline, while the rule matched the wrong rows.
Changed
- Upgrade step. A project with a
to_fieldcascade key regenerates one enforcement migration (makemigrations);--checkis red until it does. A project with the E005 shape cannot start until it restructures the model.
v2.16.0
Immutable
release. Only release title and notes can be modified.
Changed
- One revive trigger per owner table (#70, ADR 0033). Since 2.11.0 every cascade key carried its own statement-level revive trigger on the owner table, each running a full
UPDATE … FROMjoin over the transition tables on everyUPDATEof that table: one consumer table had 16. The generator now emits one trigger and function per owner,soft_delete_revive_on_<owner>, with every key's revive as an arm, joined forms included, behind one test of whether the statement revived a row at all. On 16 keysscripts/bench_revive.pymeasures +0.245 ms anUPDATEper key against +0.047 ms per owner. Upgrade step:makemigrations(ormakeguitarmigrations) emits one migration in each app hosting an owner that retires every per-key revive whose key still cascades,IF EXISTSover every spelling its tables held, and creates the per-owner triggers;--checkfails until it is generated. Its reverse rebuilds the per-key triggers. - A child table dropped without its retirement (#63) now breaks only an
UPDATEthat revives a row of the owner, where it broke everyUPDATE. Quieter, so keepmakemigrations --checkin CI: deleting the child's model moves the owner trigger's digest, and--checkin a run including the trigger's host app fails until the trigger is regenerated without that arm. A run scoped away from that app names the trigger. - An MTI parent and its child both keyed from one model no longer clash on a revive name: their revives are two arms of one function. The cascade rule clash on that shape is unchanged.
Added
scripts/bench_revive.py, timing a single-rowUPDATEwith N keys' revives per key and per owner. Not run in CI.