Skip to content

[CASE-4447, CASE-4454] Fix db_belongs_to on foreign key violations - #87

Merged
aitorlb merged 3 commits into
masterfrom
fk-error-own-table
Nov 9, 2026
Merged

aitorlb merged 3 commits into
masterfrom
fk-error-own-table

Conversation

@aitorlb

@aitorlb aitorlb commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

CASE-4447, CASE-4454 · Draft · Needed by toptal/career-services-backend#2339 and the row-level security enforcement stacked on toptal/career-services-backend#2407

What

db_belongs_to rescues an ActiveRecord::InvalidForeignKey raised while saving a record and, if a column of the violated key matches one of the record's db_belongs_to associations, adds that association's must exist error instead of raising. It matched by column only, so a foreign key of another table could be reported as the record's own:

  • a child saved inside the record's save (a callback, an autosaved association),
  • whose key shares a column with one of the record's associations, typically a composite key's tenant column,
  • had its failure added as the record's error: save returned false, save! raised RecordInvalid with a misleading message, and the child's integrity error was gone.

Since 2.1.2 every column of a composite key is a candidate, which made the shared tenant column match far more often. In career-services-backend every brand-owned model is about to declare db_belongs_to :brand, and every composite key there starts with brand_id, so a child's failure would be read as the parent's brand everywhere.

The fix: each adapter now reads the table the violated key is on, foreign_key_error_table:

  • PostgreSQL: insert or update on table "tenant_users" violates foreign key constraint …
  • MySQL: a foreign key constraint fails (db.tenant_users, CONSTRAINT …
  • SQLite: its message names no table, so it's read from the failed INSERT INTO or UPDATE statement.

The rescuer handles the violation only when that table is the record's (schema prefix ignored). When the table can't be read, behaviour is unchanged.

Second fix, the fallback's choice. When every candidate column's row exists, the key refuses the combination, as a key that leads with a tenant column does for a parent of another tenant. The rescuer used to give the error to the first matching association, which in such a key is the tenant's, so a record declaring both db_belongs_to :tenant and db_belongs_to :company reported "Tenant must exist" for a company of another tenant. It now gives it to the last, the parent's. Single-column keys are unaffected.

Third fix, a violation without key detail. When the table a key is on has row-level security for the current role, PostgreSQL leaves the key's values out of the message, DETAIL: Key is not present in table "companies"., because they could belong to a row the role cannot see. The rescuer found no column and re-raised InvalidForeignKey. The message still names the constraint, so the rescuer now reads that constraint's columns from the record's table, foreign_key_error_constraint on the PostgreSQL adapter. It is the foreign-key counterpart of 2.1.2's unique-violation fix, and career-services-backend enforces row-level security on every brand-owned table.

Tested

  • New PostgreSQL example: a record with db_belongs_to :company creates, in after_create, a child whose composite key (tenant_id, company_id) fails. It now raises InvalidForeignKey. Without the fix the same example fails: the error was swallowed into errors[:company].
  • New PostgreSQL example: a record declaring :tenant and :company, saved with a company of another tenant, gets errors[:company], not errors[:tenant]. It fails with the first-match fallback.
  • New PostgreSQL spec, db_belongs_to_row_level_security_spec.rb: under a role held to row-level security, a record whose parent does not exist gets errors[:rls_parent], and create! raises RecordInvalid. A first example checks the premise, that the message has no Key (. Both fail without the fix, with InvalidForeignKey.
  • Adapter examples: the table is read on PostgreSQL, MySQL and SQLite, and SQLite gives nil without a statement.
  • Locally, PostgreSQL and SQLite: the whole suite runs 592 examples with 0 failures outside MySQL. I had no MySQL server, so its 159 examples fail here both with and without this change; CI runs them. RuboCop is clean.

Changelog entry under Unreleased; releasing 2.1.3 can follow once this merges.

🤖 Generated with Claude Code

@aitorlb aitorlb self-assigned this Nov 3, 2026
@aitorlb
aitorlb marked this pull request as ready for review November 3, 2026 05:24
@aitorlb
aitorlb requested a review from AlfonsoUceda November 5, 2026 17:42
When the table a foreign key is on has row-level security for the
current role, PostgreSQL leaves the key's values out of the violation:
DETAIL: Key is not present in table "companies". The rescuer found no
column, so db_belongs_to re-raised ActiveRecord::InvalidForeignKey
instead of adding the association's error. The message still names the
constraint, so its columns are read from the record's table.
@aitorlb aitorlb changed the title [CASE-4447] Handle a violated foreign key in db_belongs_to only when it is the record's own table's [CASE-4447, CASE-4454] Handle db_belongs_to's foreign key violations correctly Nov 5, 2026
@aitorlb aitorlb changed the title [CASE-4447, CASE-4454] Handle db_belongs_to's foreign key violations correctly [CASE-4447, CASE-4454] Fix db_belongs_to on foreign key violations Nov 5, 2026
@aitorlb
aitorlb merged commit 4480d39 into master Nov 9, 2026
5 checks passed
@aitorlb aitorlb mentioned this pull request Nov 9, 2026
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