On Postgres the following should work in a constant number of queries (2).
# Keep a reference to foo and bar options with outer references so they can be reused.
foo_options = Foo.options.through.objects.filter(
foo_id=OuterRef("pk")
).values("option_id")
bar_options = Bar.options.through.objects.filter(
bar_id=OuterRef(OuterRef("pk"))
).values("option_id")
bars = Bar.objects.annotate(
# Accumulate all the foo ids
matching_options_foo_pks=ArraySubquery(
Foo.objects.filter(
# All the foo with no set difference between foo and bar options.
~Exists(
foo_options.difference(bar_options)
),
# ... and not difference between bar and foo options (both directions)
~Exists(
bar_options.difference(foo_options)
),
).values("pk")
)
)
# Retrieve all matching foos across all bars in a single query a bit
# like prefetch related does.
matching_foos = Foo.objects.in_bulk(
set(itertools.chain.from_iterable(
bar.matching_options_foo_pks for bar in bars
))
)
for bar in bars:
foo_matches = [
matching_foos[foo_pk]
for foo_pk in bar.matching_options_foo_pks
]
This should generate SQL of the form
SELECT
bar.*,
ARRAY(
SELECT foo.id
FROM foo
WHERE (
NOT EXISTS (
SELECT option_id FROM foo_options WHERE foo_options.foo_id = foo.id
EXCEPT
SELECT option_id FROM bar_options WHERE bar_options.bar_id = bar.id
)
AND NOT EXISTS (
SELECT option_id FROM bar_options WHERE bar_options.bar_id = bar.id
EXCEPT
SELECT option_id FROM foo_options WHERE foo_options.foo_id = foo.id
)
)
) matching_options_foo_pks
FROM bar