rails icon indicating copy to clipboard operation
rails copied to clipboard

Broken table alias referencing in Rails 7.0.7

Open kaoru opened this issue 9 months ago • 11 comments

Steps to reproduce

We're using a combination of "where missing" and "or + scope" to find goals without a state, either because they have no state record or because they have a state record with a NULL state column on it.

The behaviour has changed in Rails 7.0.7 to throw an ActiveRecord::StatementInvalid error, because on 7.0.6 the left joined table was not given an alias but on 7.0.7 it is given an alias and that alias is then not consistently used in the where clauses produced.

Note this looks a lot like https://github.com/rails/rails/issues/48334 but that bug is fixed on 7.0.6 and 7.0.7.

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  # gem 'rails', '~> 7.0', '= 7.0.6'
  gem 'rails', '~> 7.0', '= 7.0.7'
  # gem 'rails', github: 'rails/rails'
  gem 'sqlite3'
end

require "active_record"
require "minitest/autorun"
require "logger"

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
#ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :goals, force: true do |t|
  end

  create_table :goal_states, force: true do |t|
    t.integer :goal_id
    t.string :state
  end
end

class Goal < ActiveRecord::Base
  has_one :state, class_name: 'GoalState', dependent: :destroy

  scope :no_state, -> { where.missing(:state).or(left_joins(:state).merge(GoalState.not_set)) }
end

class GoalState < ActiveRecord::Base
  belongs_to :goal

  scope :set, -> { where.not(state: nil) }
  scope :not_set, -> { where(state: nil) }
end

class BugTest < Minitest::Test
  def setup
    @g1 = Goal.create

    @g2 = Goal.create
    @g2.create_state(state: nil)

    @g3 = Goal.create
    @g3.create_state(state: 'cool')
  end

  # This works on Rails 7.0.6 and gives an ActiveRecord::StatementInvalid on
  # Rails 7.0.7 because the table alias behaviour has changed.
  def test_no_state
    puts
    puts Goal.no_state.to_sql
    puts

    assert_equal 2, Goal.no_state.count
    assert_equal Goal.no_state, [@g1, @g2]
  end
end

Expected behavior

On Rails 7.0.6 the SQL produced is:

SELECT "goals".* FROM "goals" LEFT OUTER JOIN "goal_states" ON "goal_states"."goal_id" = "goals"."id" WHERE ("goal_states"."id" IS NULL OR "goal_states"."state" IS NULL)

Actual behavior

On Rails 7.0.7 (and on the main branch of rails/rails) the SQL produced is:

SELECT "goals".* FROM "goals" LEFT OUTER JOIN "goal_states" "state" ON "state"."goal_id" = "goals"."id" WHERE ("state"."id" IS NULL OR "goal_states"."state" IS NULL)

Which throws an error:

ActiveRecord::StatementInvalid: SQLite3::SQLException: no such column: goal_states.state

Because the missing gives the left join an alias of "state" but the check produced by the "OR" uses the table name "goal_states".

I've fixed this in our codebase by changing the code to scope :no_status, -> { left_joins(:status).merge(Goals::Status.not_set) } because technically with a left join the missing check is redundant anyway! But I thought I'd report the bug in case it's affecting other people with harder-to-fix query cases.

System configuration

Rails version: 7.0.7

Ruby version: 3.2.2

kaoru avatar Aug 15 '23 16:08 kaoru