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!