activerecord_where_assoc icon indicating copy to clipboard operation
activerecord_where_assoc copied to clipboard

🐛 Composite Primary Keys (Rails 7.1 and above) not supported

Open alexeiemam opened this issue 1 year ago • 6 comments

I've included a test case that returns an error indicating the composite primary keys are not respected

BugTest#test_associations:
ActiveRecord::StatementInvalid: ...: no such column: kids.["version_id", "organisation_id", "father_id"]

due to this generated query :

❌
SELECT COUNT(*) FROM "fathers" 
  WHERE (
    EXISTS (
      SELECT 1 FROM "kids" 
        WHERE "kids"."[""version_id"", ""organisation_id"", ""father_id""]" = "fathers"."[""version_id"", ""organisation_id"", ""local_id""]" 

        AND "kids"."version_id" = 1 
        AND "kids"."organisation_id" = 9234 
        AND "kids"."medals" = 11
    )
  )

Expectation is it would generate something akin to

✅ 
SELECT COUNT(*) FROM "fathers" 
  WHERE (
    EXISTS (
      SELECT 1 FROM "kids" 
        WHERE  
          "kids"."version_id" = "fathers"."version_id"
          AND "kids"."organisation_id" = "fathers"."organisation_id"
          AND "kids"."father_id" = "fathers"."local_id"
          
          AND "kids"."version_id" = 1 
          AND "kids"."organisation_id" = 9234 
          AND "kids"."medals" = 11
    )
  )

I've left some other (passing) tests in (but commented out), in case you want to get an idea of the kinds of compose-primary-key-based queries activerecord generates

# frozen_string_literal: true

require "bundler/inline"

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

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

  gem "rails", '~> 7.1.3'
  # If you want to test against edge Rails replace the previous line with this:
  # gem "rails", github: "rails/rails", branch: "main"

  gem "sqlite3", '~> 1.4'
  gem 'activerecord_where_assoc', '~> 1.1.5'
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 :versions, force: true do |t|
    t.string :name
    t.string :descriptor
  end

  create_table :organisations, primary_key: [:version_id, :local_id], force: true do |t|
    t.bigint :version_id
    t.bigint :local_id
    t.string :name
  end

  create_table :fathers, primary_key: [:version_id, :organisation_id, :local_id], force: true do |t|
    t.bigint :version_id
    t.bigint :organisation_id
    t.bigint :local_id
    t.string :name
  end

  create_table :mothers, primary_key: [:version_id, :organisation_id, :local_id], force: true do |t|
    t.bigint :version_id
    t.bigint :organisation_id
    t.bigint :local_id 
    t.string :name
  end

  create_table :kids, primary_key: [:version_id, :organisation_id, :local_id], force: true do |t|
    t.bigint :version_id
    t.bigint :organisation_id
    t.bigint :local_id
    t.string :name
    t.integer :medals
    t.bigint :father_id 
    t.bigint :mother_id 
  end
end

class Version < ActiveRecord::Base
  has_many :organisations 
end 

class Organisation < ActiveRecord::Base 
  self.primary_key = [:version_id, :local_id]

  belongs_to :version 
end 

class Mother < ActiveRecord::Base
  self.primary_key = [:version_id, :organisation_id, :local_id]

  belongs_to :version 

  belongs_to :organisation,  
  query_constraints: [:version_id, :organisation_id]

  has_many :kids, 
    query_constraints: [:version_id, :organisation_id, :mother_id]

  has_many :entanglements, through: :kids, source: :father
end

class Father < ActiveRecord::Base
  self.primary_key = [:version_id, :organisation_id, :local_id]

  belongs_to :version 

  belongs_to :organisation,  
  query_constraints: [:version_id, :organisation_id]

  has_many :kids, 
    query_constraints: [:version_id, :organisation_id, :father_id]

  has_many :entanglements, through: :kids, source: :mother
end

class Kid < ActiveRecord::Base
  self.primary_key = [:version_id, :organisation_id, :local_id]

  belongs_to :version 

  belongs_to :organisation,  
  query_constraints: [:version_id, :organisation_id]

  belongs_to :mother, 
    query_constraints: [:version_id, :organisation_id, :mother_id]

  belongs_to :father, 
    query_constraints: [:version_id, :organisation_id, :father_id]
end

class BugTest < Minitest::Test
  def test_associations
    version = Version.create!(name: 'K-Swiss', descriptor: 'Rcudgel Xmin for the foreseeable')
    organisation = Organisation.create!(name: 'Effaclar', local_id: 9234, version: version)

    base_version_organisation_details = {
      version: version, 
      organisation: organisation
    }
    mother = 
      Mother.create!(base_version_organisation_details.merge(
        local_id: 98,
        name: 'Mariana'
      )) 

    father = 
      Father.create!(base_version_organisation_details.merge(
        local_id: 117,
        name: 'Carlito'
      )) 

    father_2 = Father.create!(base_version_organisation_details.merge(
      local_id: 1982,
      name: 'Abdel-aziz'
    ))

    ### Can pass mother/father objects and let Rails figure it out
    kid_1 = Kid.create!(base_version_organisation_details.merge(
      local_id: 1,
      name: 'Jim',
      mother: mother,
      father: father,
      medals: 5
    ))

    ### Can pass specific mother/father Local IDs
    kid_2 = Kid.create!(base_version_organisation_details.merge(
      local_id: 2,
      name: 'Jesse',
      mother_id: mother.local_id,
      father_id: father.local_id,
      medals: 9
    ))

    kid_3 = Kid.create!(base_version_organisation_details.merge(
      local_id: 29,
      name: 'Sinbad',
      mother: mother,
      father: father_2,
      medals: 11
    ))
    
    # puts "assert_equal 1, Father.count" 
    # assert_equal 2, Father.count 
    # puts "assert_equal 1, Mother.count"
    # assert_equal 1, Mother.count
    # puts "assert_equal 2, Kid.count"  
    # assert_equal 3, Kid.count  
    # puts "assert_equal 2, father.kids.count"
    # assert_equal 2, father.kids.count
    # puts "assert_equal 2, mother.kids.count"
    # assert_equal 3, mother.kids.count
    # puts "assert_equal father.kids.to_a, mother.kids.to_a " 
    # assert_equal kid_1.father, father
    # puts "assert_equal 25, mother.kids.sum(:medals)"
    # assert_equal 25, mother.kids.sum(:medals)
    # puts "assert_equal kid_1.father.entanglements.count, kid_2.father.entanglements.count"
    # assert_equal kid_1.father.entanglements.count, kid_2.father.entanglements.count
    # puts "assert_equal kid_2.mother.entanglements.count, kid_1.mother.entanglements.count"
    # assert_equal kid_2.mother.entanglements.count, kid_1.mother.entanglements.count
    # puts "assert_equal 3, kid_3.mother.entanglements.count"
    # assert_equal 3, kid_3.mother.entanglements.count
    # puts "assert_equal 1, kid_3.father.entanglements.count"
    # assert_equal 1, kid_3.father.entanglements.count
    # puts "assert_equal 2, kid_2.father.entanglements.count"
    # assert_equal 2, kid_2.father.entanglements.count

    assert_equal 1, Father.where_assoc_exists(:kids, {
      version_id: version.id, 
      organisation_id: organisation.local_id, 
      medals: kid_3.medals
    }).count
  end
end

I'll have a dig through the code.

alexeiemam avatar Jul 23 '24 20:07 alexeiemam

Looks like there would need to be some changes to

at least

alexeiemam avatar Jul 23 '24 21:07 alexeiemam

Thanks for the thorough reporting!

The main case is likely happening in CoreLogic.wrapper_and_join_constraints. The join_keys's values are arrays instead of symbols, so there would need to be iteration for the constraints = table[key].eq(foreign_table[foreign_key]).

There are other places referring to primary_key and foreign_key in CoreLogic which also likely need to be handled. I'd suggest making a failing test for each case first, then altering the code to make sure the test case does hit the location as the code is quite complex due to all of different cases.

Good luck and let me know if I can be of help

MaxLap avatar Jul 23 '24 21:07 MaxLap

Hum, seems I didn't see your 2nd message while I was looking around. I guess we are agreeing :)

I don't think that ActiveRecordCompat.join_keys needs to be changed. I would just add some checks to handle when the keys are arrays.

MaxLap avatar Jul 23 '24 21:07 MaxLap

I'll make a fork to get support going for the basic case in the test above, then I'll consider making composite-primary-key variants of each of your existing test cases to make sure the more weird and wonderful usages are still covered.

alexeiemam avatar Jul 23 '24 22:07 alexeiemam

@MaxLap I have a candidate fix for you to review at #18 The gem's test setup is a bit more bespoke than I can add to confidently without some guidance, I could use some pointers / an exemplar for a composite-primary-key-like model.

alexeiemam avatar Jul 24 '24 15:07 alexeiemam

Sorry, I forgot about the test system... It's been a while.

Lots of meta programming because I wanted most model & association to have their own conditions that couldn't overlap (each require an identifier to be a multiple of a different prime). So accessing a S1 via an association means that the it must be a multiple of 7 (just an example prime) and of 31 (another random prime) from the S1 model and the association itself. So when creating the model, the code must set the correct value.

So when creating objects, create_default! and create_assoc! are used which populate the column with a number that matches the conditions for the specified associations.

Definitely overly complex and I wouldn't do it again if I was redoing this all.

I'll take care of the task and the testing, I don't want to impose this burden on you and I think it's something worth supporting. I'm travelling tomorrow for two weeks, so if I program while travelling, I'll look at this, otherwise it will be when I come back. If I forget, ping me in september.

MaxLap avatar Jul 25 '24 03:07 MaxLap

Released in v1.2.0

MaxLap avatar Aug 31 '24 14:08 MaxLap