Skip to content

Fix Attachment#deletable? false positives#3855

Merged
tvdeyen merged 4 commits intomainfrom
fix-attachment-deletable-ingredient-refs
Apr 28, 2026
Merged

Fix Attachment#deletable? false positives#3855
tvdeyen merged 4 commits intomainfrom
fix-attachment-deletable-ingredient-refs

Conversation

@tvdeyen
Copy link
Copy Markdown
Member

@tvdeyen tvdeyen commented Apr 24, 2026

What is this pull request for?

Fixes a bug where Alchemy::Attachment#deletable? (and the corresponding .deletable scope) would incorrectly treat attachments as deletable when they were referenced inside ingredient values other than polymorphic related_object — for example a download URL pasted into a Richtext body, or a Link ingredient pointing at the attachment's download path.

On top of the fix, both the attachments and pictures admin archives used to call deletable? per row, each issuing up to two extra queries. The controllers now preload the deletable ids for the current page in a single query scoped via a subquery, so filter, sort, and pagination are applied on the database side.

Notable changes

  • Alchemy::Attachment.deletable now also excludes rows whose download URL pattern (%/attachment/<id>/download%) appears in any ingredient's value column, using Arel::Nodes::Concat so it works across SQLite, PostgreSQL, and MySQL.
  • Alchemy::Attachment#deletable? gains a secondary check (referenced_in_ingredient_value?) using LIKE on the concrete id, acting as a cheap short-circuit after the polymorphic check.
  • RelatableResource::RELATED_INGREDIENTS_SUBQUERY extracted as a reusable SQL constant.
  • Alchemy::Admin::AttachmentsController#index and Alchemy::Admin::PicturesController (via before_actions) preload @deletable_attachment_ids / @deletable_picture_ids as a Set; the _picture and _files_list partials check membership instead of calling deletable? per row.

Checklist

  • I have followed Pull Request guidelines
  • I have added a detailed description into each commit message
  • I have added tests to cover this change

@tvdeyen tvdeyen requested a review from a team as a code owner April 24, 2026 20:19
@tvdeyen tvdeyen added backport-to-8.2-stable Needs to be backported to 8.2-stable backport-to-8.1-stable Needs to be backported to 8.1-stable backport-to-8.0-stable Needs to be backported to 8.0-stable labels Apr 24, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 24, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.07%. Comparing base (6826968) to head (f32d773).
⚠️ Report is 11 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3855      +/-   ##
==========================================
+ Coverage   98.06%   98.07%   +0.01%     
==========================================
  Files         322      323       +1     
  Lines        8530     8582      +52     
==========================================
+ Hits         8365     8417      +52     
  Misses        165      165              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@tvdeyen tvdeyen added the bug label Apr 24, 2026
tvdeyen added 2 commits April 27, 2026 10:16
Extracts the shared polymorphic-reference subquery from
RelatableResource into a constant so including classes
can reuse it if they oeverride the .deletable scope, because
you can't call super in scopes.
The deletable scope only considered polymorphic related_object
references, which missed attachments linked from /attachment/:id/download
URLs written into ingredient values by the file tab of the link dialog.
On sites where attachments are linked from Richtext, Link, or Html
ingredients, every attachment was reported as deletable, risking
accidental data loss.

The scope now adds a correlated NOT EXISTS against ingredient value
columns. The per-row LIKE pattern is built with Arel::Nodes::Concat so
it compiles to || on SQLite and PostgreSQL and to CONCAT() on MySQL
without adapter-specific SQL. The /download suffix in the pattern
delimits the id, so matching attachment 1 does not incorrectly pull in
URLs pointing at attachment 10.
@tvdeyen tvdeyen force-pushed the fix-attachment-deletable-ingredient-refs branch from 88c3c0c to 3f0bc74 Compare April 27, 2026 08:17
@tvdeyen tvdeyen changed the title Fix Attachment#deletable? false positives and preload deletable ids in admin lists Fix Attachment#deletable? false positives Apr 27, 2026
tvdeyen added 2 commits April 27, 2026 17:10
Rendering the attachment index used to call Attachment#deletable? per
row to decide which delete buttons to enable. Each call issued up to
two queries (the polymorphic related_ingredients check and the LIKE
scan for ingredient-value references), so a page of N attachments cost
up to 2N extra queries in addition to the main fetch.

The controller now runs the deletable scope once for the ids on the
current page and stores the result in a Set. Because the scope relation
is chained as the subquery source of an IN clause, Rails applies the
same Ransack filter, sort, and pagination on the database side without
materializing the ids Ruby-side, keeping the check tight to the rows
the view actually renders.
Rendering the picture archive used to call Picture#deletable? per row
to decide which delete buttons to show. Each call issued up to two
queries (the polymorphic related_ingredients check and the LIKE scan
for ingredient-value references), so a page of N pictures cost up to
2N extra queries in addition to the main fetch.

The controller now runs the deletable scope once for the ids on the
current page and stores the result in a Set. Because the scope relation
is chained as the subquery source of an IN clause, Rails applies the
same Ransack filter, sort, and pagination on the database side without
materializing the ids Ruby-side, keeping the check tight to the rows
the view actually renders. The same preload is wired to the update
action since it re-renders the picture partial via turbo stream.
@tvdeyen tvdeyen force-pushed the fix-attachment-deletable-ingredient-refs branch from 3f0bc74 to f32d773 Compare April 27, 2026 15:11
@tvdeyen tvdeyen merged commit 4bb7881 into main Apr 28, 2026
28 checks passed
@tvdeyen tvdeyen deleted the fix-attachment-deletable-ingredient-refs branch April 28, 2026 08:42
@alchemycms-bot
Copy link
Copy Markdown

💔 Some backports could not be created

Status Branch Result
8.0-stable Backport failed because of merge conflicts
8.1-stable Backport failed because of merge conflicts
8.2-stable

Manual backport

To create the backport manually run:

backport --pr 3855

Questions ?

Please refer to the Backport tool documentation and see the Github Action logs for details

@alchemycms-ci-bot
Copy link
Copy Markdown
Collaborator

💚 All backports created successfully

Status Branch Result
8.1-stable

Questions ?

Please refer to the Backport tool documentation

@alchemycms-ci-bot
Copy link
Copy Markdown
Collaborator

💚 All backports created successfully

Status Branch Result
8.0-stable

Questions ?

Please refer to the Backport tool documentation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-to-8.0-stable Needs to be backported to 8.0-stable backport-to-8.1-stable Needs to be backported to 8.1-stable backport-to-8.2-stable Needs to be backported to 8.2-stable bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants