Mountains

Code is Sisyphean

Modifying a has_many to include nils

  • Refactoring Rails
  • I had this interesting use case come up.

    We have a multi-tenant app that has an Organization  model. An Organization has many BarcodeItem.

    Originally, the query was direct: We only wanted an instance of Organization  to return the barcode_items  that belonged to it.  Then we decided to pivot on this particular feature and have an organization include both its own barcode_items  but also any BarcodeItem  that was explicitly set to nil . The idea here was that some BarcodeItem  would be “global”, and the least complicated way to do that would be to de-associate them from any Organization.

    So I started with a spec to test this out:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    describe "barcode_items" do
      it "returns both this organizations barcodes as well as global ones" do
        # Test that it can see globals
        create(:barcode_item, organization_id: nil) # global
        expect(organization.barcode_items.count).to eq(1)
        # Test that it can also see its own items
        create(:barcode_item, :for_organization, organization: organization)
        expect(organization.barcode_items.count).to eq(2)
      end
    end
    

    My first thought was to use an association model, something like:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    class Organization < ApplicationRecord
      # ...
      has_many :barcode_items do
        def all
          # this doesn't work but it's the idea I started with
          where(organization_id: [self.id, nil])
        end
      end
      # ...
    end
    

    But even after just writing that it just smelled wrong. Surely there’s an easier way to do this. Consulting the docs, I was reminded of using association scopes. So I tried this instead:

    1
    2
    3
    4
    5
    
    class Organization < ApplicationRecord
      # ...
      has_many :barcode_items, -> (organization) { organization_id: [nil, organzation.id] }
      # ...
    end
    

    Still fails. I needed to see what it’s actually doing behind the scenes, so I dumped the SQL before the test:

    1
    2
    3
    4
    5
    6
    7
    
    it "returns both this organizations barcodes as well as global ones" do
      # ...
      create(:barcode_item, organization_id: nil) # global
      puts organization.barcode_items.to_sql
      expect(organization.barcode_items.count).to eq(1)
      # ...
    end
    

    RSpec displayed the query as:

    1
    2
    3
    4
    5
    
    SELECT "barcode_items".* FROM "barcode_items" 
      WHERE "barcode_items"."organization_id" = 4 
      AND ("barcode_items"."organization_id" = 4 
        OR "barcode_items"."organization_id" IS NULL)
    -- returns both this organizations barcodes as well as global ones (FAILED - 1)
    

    A-ha!

    The additional scoping added to the association is a boolean AND, but we need a boolean OR. After several failed attempts at mimicking that functionality, I found a StackOverflow answer that suggested unscoping the query first.

    1
    2
    3
    4
    5
    6
    7
    
    class Organization < ApplicationRecord
      # ...
      has_many :barcode_items, ->(organization) { 
        unscope(where: :organization_id).where(organization_id: [nil,organization.id])
      }
      # ...
    end
    

    Success!