From cf059351a282dc19ba5937738d9b9fd561511a1a Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 28 Jul 2026 15:14:55 -0700 Subject: [PATCH 1/4] Fix Postgres has_been_used? for table names near the identifier length limit pre_count's has_been_used? guessed a table's sequence name as "#{table}_id_seq" and looked it up with a naive pg_class match. Once that guess exceeds Postgres' 63-byte NAMEDATALEN limit, Postgres silently truncates it, so the guess can collide with the table's own name (or some other relation) instead of the real, differently-truncated sequence name Postgres actually generated. has_sequence? then false-positives on that wrong relation, and the follow-up "SELECT last_value from ..." blows up with `PG::UndefinedColumn: column "last_value" does not exist` since it's querying a table, not a sequence. Use pg_get_serial_sequence(table, 'id') instead of guessing, which is what it's for. Falls back to has_rows? when the table has no "id" column at all (pg_get_serial_sequence raises rather than returning nil for that case). --- .../active_record/truncation.rb | 20 +++++++---- .../active_record/truncation_spec.rb | 36 +++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/lib/database_cleaner/active_record/truncation.rb b/lib/database_cleaner/active_record/truncation.rb index 1f3a995..f29fa60 100644 --- a/lib/database_cleaner/active_record/truncation.rb +++ b/lib/database_cleaner/active_record/truncation.rb @@ -226,20 +226,28 @@ def database_cleaner_table_cache # but then the table is cleaned. In other words, this function tells us if the given table # was ever inserted into. def has_been_used?(table) - return has_rows?(table) unless has_sequence?(table) + sequence = serial_sequence(table) + return has_rows?(table) unless sequence - cur_val = select_value("SELECT last_value from #{table}_id_seq;").to_i + cur_val = select_value("SELECT last_value from #{sequence};").to_i cur_val > 0 end - def has_sequence?(table) - select_value("SELECT true FROM pg_class WHERE relname = '#{table}_id_seq';") - end - def has_rows?(table) select_value("SELECT true FROM #{table} LIMIT 1;") end + # Table names near Postgres' 63-byte identifier limit produce a "#{table}_id_seq" guess + # that gets silently truncated by Postgres and no longer matches the real (differently + # truncated) sequence name it auto-generated, so look it up instead of guessing it. + def serial_sequence(table) + select_value("SELECT pg_get_serial_sequence(#{quote(table)}, 'id')") + rescue ::ActiveRecord::StatementInvalid + # table has no "id" column at all (pg_get_serial_sequence raises rather than + # returning nil in that case) + nil + end + def tables_with_schema rows = select_rows <<-_SQL SELECT schemaname || '.' || tablename diff --git a/spec/database_cleaner/active_record/truncation_spec.rb b/spec/database_cleaner/active_record/truncation_spec.rb index f624243..6c40946 100644 --- a/spec/database_cleaner/active_record/truncation_spec.rb +++ b/spec/database_cleaner/active_record/truncation_spec.rb @@ -98,6 +98,42 @@ expect(strategy.send(:connection)).to receive(:truncate_tables).with(['users'], {:truncate_option=>:restrict}) strategy.clean end + + if helper.db == :postgres + context "with a table name at Postgres' identifier length limit" do + # Postgres silently truncates identifiers over 63 bytes. Guessing a table's sequence + # name as "#{table}_id_seq" can collide with the (differently truncated) real name, + # or even with the table's own name once truncated, so pre_count's has_been_used? + # check must look the sequence up instead of guessing it. + # + # Scoped to :only this table: the rest of this file leaves "users"/"user_profiles" in + # whatever state earlier (mocked, never-actually-truncated) examples left them in, and + # "user_profiles" has a real FK onto "users" that RESTRICT would trip on unrelated to + # what's under test here. + subject(:strategy) { described_class.new(pre_count: true, only: [long_table_name]) } + let(:long_table_name) { "u" * 58 + "_long" } # 63 chars: Postgres' NAMEDATALEN limit + + before do + connection.execute("CREATE TABLE #{long_table_name} (id SERIAL PRIMARY KEY, name INTEGER);") + end + + after do + connection.execute("DROP TABLE IF EXISTS #{long_table_name};") + end + + it "does not raise when checking whether the table has been used" do + expect { strategy.clean }.not_to raise_error + end + + it "detects activity on the table via its actual (differently-truncated) sequence" do + connection.execute("INSERT INTO #{long_table_name} (name) VALUES (1);") + connection.execute("INSERT INTO #{long_table_name} (name) VALUES (2);") + + expect(strategy.send(:connection)).to receive(:truncate_tables).with([long_table_name], {truncate_option: :restrict}) + strategy.clean + end + end + end end describe "with truncate_option set to cascade" do From f2c7d7e4d69cbddc03bf9f6bb9d09a1d58103993 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 28 Jul 2026 15:25:32 -0700 Subject: [PATCH 2/4] Tighten serial_sequence comments --- lib/database_cleaner/active_record/truncation.rb | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/database_cleaner/active_record/truncation.rb b/lib/database_cleaner/active_record/truncation.rb index f29fa60..935a7d7 100644 --- a/lib/database_cleaner/active_record/truncation.rb +++ b/lib/database_cleaner/active_record/truncation.rb @@ -237,15 +237,12 @@ def has_rows?(table) select_value("SELECT true FROM #{table} LIMIT 1;") end - # Table names near Postgres' 63-byte identifier limit produce a "#{table}_id_seq" guess - # that gets silently truncated by Postgres and no longer matches the real (differently - # truncated) sequence name it auto-generated, so look it up instead of guessing it. + # Looks up the real sequence name instead of guessing "#{table}_id_seq", which can be + # wrong once Postgres truncates it to fit its 63-byte identifier limit. def serial_sequence(table) select_value("SELECT pg_get_serial_sequence(#{quote(table)}, 'id')") rescue ::ActiveRecord::StatementInvalid - # table has no "id" column at all (pg_get_serial_sequence raises rather than - # returning nil in that case) - nil + nil # no "id" column at all end def tables_with_schema From 4201c9378bffa31f93c71560fd641299415d176f Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 28 Jul 2026 15:49:37 -0700 Subject: [PATCH 3/4] Tighten spec comments --- .../active_record/truncation_spec.rb | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/spec/database_cleaner/active_record/truncation_spec.rb b/spec/database_cleaner/active_record/truncation_spec.rb index 6c40946..6b644a9 100644 --- a/spec/database_cleaner/active_record/truncation_spec.rb +++ b/spec/database_cleaner/active_record/truncation_spec.rb @@ -101,15 +101,9 @@ if helper.db == :postgres context "with a table name at Postgres' identifier length limit" do - # Postgres silently truncates identifiers over 63 bytes. Guessing a table's sequence - # name as "#{table}_id_seq" can collide with the (differently truncated) real name, - # or even with the table's own name once truncated, so pre_count's has_been_used? - # check must look the sequence up instead of guessing it. - # - # Scoped to :only this table: the rest of this file leaves "users"/"user_profiles" in - # whatever state earlier (mocked, never-actually-truncated) examples left them in, and - # "user_profiles" has a real FK onto "users" that RESTRICT would trip on unrelated to - # what's under test here. + # :only this table: the rest of this file leaves "users"/"user_profiles" in whatever + # state earlier (mocked) examples left them in, and "user_profiles" has a real FK + # onto "users" that RESTRICT would trip on unrelated to what's under test here. subject(:strategy) { described_class.new(pre_count: true, only: [long_table_name]) } let(:long_table_name) { "u" * 58 + "_long" } # 63 chars: Postgres' NAMEDATALEN limit From e8c0f3aecf8b327ae7a3d93c5007fa016bbb5f71 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 28 Jul 2026 16:55:18 -0700 Subject: [PATCH 4/4] Narrow serial_sequence's rescue to the expected error rescue ::ActiveRecord::StatementInvalid was catching any statement failure, not just the "no id column" case it's meant to handle, silently falling back to has_rows? for genuine SQL/connection errors too. Check the underlying PG::UndefinedColumn cause and re-raise anything else. --- lib/database_cleaner/active_record/truncation.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/database_cleaner/active_record/truncation.rb b/lib/database_cleaner/active_record/truncation.rb index 935a7d7..c591d03 100644 --- a/lib/database_cleaner/active_record/truncation.rb +++ b/lib/database_cleaner/active_record/truncation.rb @@ -241,8 +241,10 @@ def has_rows?(table) # wrong once Postgres truncates it to fit its 63-byte identifier limit. def serial_sequence(table) select_value("SELECT pg_get_serial_sequence(#{quote(table)}, 'id')") - rescue ::ActiveRecord::StatementInvalid - nil # no "id" column at all + rescue ::ActiveRecord::StatementInvalid => e + raise unless e.cause.is_a?(PG::UndefinedColumn) # no "id" column at all + + nil end def tables_with_schema