From e4f169d67882d65c26399d4e586578def3989125 Mon Sep 17 00:00:00 2001 From: Aitor Lopez Beltran Date: Sat, 24 Oct 2026 08:48:31 +0000 Subject: [PATCH] [CASE-4447] Handle a violated composite foreign key in db_belongs_to on PostgreSQL --- CHANGELOG.md | 1 + .../lib/adapters/postgresql_adapter.rb | 6 ++-- .../adapters/foreign_key_error_column_spec.rb | 9 ++++++ spec/validations/db_belongs_to_spec.rb | 29 +++++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a417909..7c7d38b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased - xxxx-xx-xx +- `db_belongs_to` handles a violated composite foreign key on PostgreSQL. Its error names every column of the key, `Key (tenant_id, company_id)`; each is now a candidate, so the association's `must exist` error is added instead of the exception being raised - Fix `validates_db_uniqueness_of` raising `NoMethodError` on PostgreSQL when the table has row-level security. PostgreSQL omits the `Key (...)=(...)` detail from the unique violation there, so the error is now matched by its index name only ## [2.1.1] - 2026-03-31 diff --git a/lib/database_validations/lib/adapters/postgresql_adapter.rb b/lib/database_validations/lib/adapters/postgresql_adapter.rb index 0ff4fdc..03911d9 100644 --- a/lib/database_validations/lib/adapters/postgresql_adapter.rb +++ b/lib/database_validations/lib/adapters/postgresql_adapter.rb @@ -15,9 +15,11 @@ def unique_error_columns(error) error.message[/Key \((.+)\)=/, 1]&.split(', ') end + # A composite key reports every column, `Key (tenant_id, company_id)=(1, 2)`; each is a candidate, and the + # rescuer picks the one with a matching `db_belongs_to`. def foreign_key_error_column(error) - column = error.message[/Key \(([^)]+)\)/, 1] - column ? [column] : [] + columns = error.message[/Key \(([^)]+)\)/, 1] + columns ? columns.split(', ') : [] end end end diff --git a/spec/adapters/foreign_key_error_column_spec.rb b/spec/adapters/foreign_key_error_column_spec.rb index 6e11c5c..82527eb 100644 --- a/spec/adapters/foreign_key_error_column_spec.rb +++ b/spec/adapters/foreign_key_error_column_spec.rb @@ -13,6 +13,15 @@ def build_error(message:, sql: nil) ) expect(described_class.foreign_key_error_column(error)).to eq(['company_id']) end + + it 'extracts every column of a composite key' do + error = build_error( + message: 'PG::ForeignKeyViolation: ERROR: insert or update on table "db_belongs_users" violates ' \ + 'foreign key constraint "fk_tenant_company" DETAIL: Key (tenant_id, company_id)=(2, 1) is not ' \ + 'present in table "companies".' + ) + expect(described_class.foreign_key_error_column(error)).to eq(%w[tenant_id company_id]) + end end describe DatabaseValidations::Adapters::MysqlAdapter do diff --git a/spec/validations/db_belongs_to_spec.rb b/spec/validations/db_belongs_to_spec.rb index 9efaeef..2cbdd53 100644 --- a/spec/validations/db_belongs_to_spec.rb +++ b/spec/validations/db_belongs_to_spec.rb @@ -6,6 +6,7 @@ class BelongsUser < ActiveRecord::Base; end class DbBelongsUser < ActiveRecord::Base; end class Department < ActiveRecord::Base; end class MultiFkUser < ActiveRecord::Base; end + class TenantUser < ActiveRecord::Base; end # rubocop:enable RSpec/LeakyConstantDeclaration # rubocop:enable Lint/ConstantDefinitionInBlock @@ -196,6 +197,34 @@ def define_tables end include_examples 'works as belongs_to' + + describe 'a composite foreign key that leads with a tenant column' do + let(:tenant_user_klass) do + define_class(TenantUser, :tenant_users) do + db_belongs_to :company + end + end + + before do + ActiveRecord::Schema.define(version: 3) do + add_column :companies, :tenant_id, :integer, null: false, default: 1 + add_index :companies, %i[id tenant_id], unique: true + create_table :tenant_users do |t| + t.integer :tenant_id, null: false, default: 1 + t.belongs_to :company, foreign_key: true + end + add_foreign_key :tenant_users, :companies, column: %i[tenant_id company_id], primary_key: %i[tenant_id id] + end + end + + it "adds the association's error for a parent of another tenant" do + company = company_klass.create!(tenant_id: 1) + record = tenant_user_klass.new(company_id: company.id, tenant_id: 2) + + expect(record.save).to be false + expect(record.errors[:company]).to be_present + end + end end describe 'sqlite3' do