Skip to content

fix(tags): deleting a tag orphans every use/application of it - #235

Open
DocArmoryTech wants to merge 4 commits into
cerebrate-project:mainfrom
DocArmoryTech:fix/tags-cascade-del
Open

fix(tags): deleting a tag orphans every use/application of it#235
DocArmoryTech wants to merge 4 commits into
cerebrate-project:mainfrom
DocArmoryTech:fix/tags-cascade-del

Conversation

@DocArmoryTech

Copy link
Copy Markdown
Contributor

Deleting a tag leaves every tags_tagged row that referenced it behind,
pointing at an id that no longer exists.

Why

TagsTable::initialize() declares no association to Tagged:

public function initialize(array $config): void
{
    $this->setTable('tags_tags');
    $this->setDisplayField('name');
    $this->addBehavior('Timestamp');
}

No hasMany, no dependent => true, and tags_tagged.tag_id carries no
foreign key. Tags::delete therefore removes the tags_tags row and nothing
touches the taggings.

The orphans are invisible rather than just stale. Every query that resolves a tagging joins tags_tags, so the rows disappear from counts while still occupying the unique index on (tag_id, fk_id, fk_model).
tags_tags.counter, maintained by the CounterCache that TagBehavior::attachCounters() attaches, is equally blind to them.

Changes

  • TagsTable: add hasMany('Tagged', [... 'dependent' => true, 'cascadeCallbacks' => true]). cascadeCallbacks keeps the delete going through the ORM rather than a bulk query.
  • New migration pruning rows orphaned before the association existed, and rebuilding tags_tags.counter afterwards since a raw DELETE bypasses the CounterCache. Any instance that has ever deleted a tag has these.

Notes

The migration is a no-op where there is nothing to prune, and is not reversible i.e. the rows referenced tags that no longer exist, so there is nothing to restore them to.

An alternative to the association is a foreign key on tags_tagged.tag_id with ON DELETE CASCADE. It is cheaper but bypasses the behaviour layer, so I went with the association.

Verifying

SELECT COUNT(*) FROM tags_tagged tg
WHERE NOT EXISTS (SELECT 1 FROM tags_tags t WHERE t.id = tg.tag_id);

Non-zero before the migration on any instance that has deleted a tag; zero after. Creating a tag, applying it, then deleting it should leave no residue.

iglocska and others added 4 commits June 11, 2026 09:45
TagsTable declared no association to Tagged, and tags_tagged.tag_id carries
no foreign key, so Tags::delete removed the row from tags_tags and left
every tags_tagged row pointing at an id that no longer existed.

The orphans are invisible rather than merely stale. Every query that
resolves a tagging joins tags_tags, so the rows vanish from counts while
still occupying the unique index on (tag_id, fk_id, fk_model).
tags_tags.counter, maintained by the CounterCache that
TagBehavior::attachCounters() attaches, is equally blind to them.

Observed on a production instance: removing two superseded tags left 429
orphaned taggings, 27% of the table. Total taggings read 1,590 against a
reportable count of 1,161, and nothing surfaced the difference -- it was
found by comparing a raw COUNT(*) against a joined report.

Adds the missing hasMany with dependent => true, so deletion cascades, plus
a migration pruning rows orphaned before the association existed. Any
instance that has ever deleted a tag has them.

cascadeCallbacks is set so the delete goes through the ORM rather than a
bulk query, keeping behaviours on Tagged in play.

The migration is a no-op where there is nothing to prune, and rebuilds
tags_tags.counter afterwards since a raw DELETE bypasses the CounterCache.
It is not reversible: the deleted rows referenced tags that no longer
exist, so there is nothing to restore them to.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants