Photo

Hi, I'm Aaron.

Modifying a has_many to include nils

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!