Skip to content

Commit 07034cd

Browse files
committed
add support for filtering by fk columns
1 parent 70371f9 commit 07034cd

3 files changed

Lines changed: 116 additions & 5 deletions

File tree

docs/queries/filter-and-sort.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,23 @@ Product.objects.filter(
161161
).get()
162162
```
163163

164+
!!!note
165+
A foreign-key accessor can also be compared directly to a primary-key value
166+
or a model instance, which is equivalent to the kwargs form and stays a
167+
single-table filter (no JOIN is added):
168+
169+
```python
170+
# both produce: WHERE books.author = 5
171+
Book.objects.filter(author=5)
172+
Book.objects.filter(Book.author == 5)
173+
174+
# with an instance - the PK is extracted for you
175+
Book.objects.filter(Book.author == tolkien)
176+
```
177+
178+
This only applies to own FK columns. Many-to-many and reverse relations
179+
still require targeting a concrete column, e.g. `Category.products.name`.
180+
164181
!!!note
165182
All methods that do not return the rows explicitly returns a QuerySet instance so
166183
you can chain them together

ormar/queryset/field_accessor.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,12 @@ def __getattr__(self, item: str) -> Any:
6363
return object.__getattribute__(self, item) # pragma: no cover
6464

6565
def _check_field(self) -> None:
66-
if not self._field:
67-
raise AttributeError(
68-
"Cannot filter by Model, you need to provide model name"
69-
)
66+
if self._field:
67+
return
68+
field = self._source_model.ormar_config.model_fields.get(self._access_chain)
69+
if field is not None and not field.virtual and not field.is_multi:
70+
return
71+
raise AttributeError("Cannot filter by Model, you need to provide model name")
7072

7173
def _select_operator(self, op: str, other: Any) -> FilterGroup:
7274
self._check_field()

tests/test_model_definition/test_fields_access.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ class Product(ormar.Model):
3232
category = ormar.ForeignKey(Category)
3333

3434

35+
class Supplier(ormar.Model):
36+
ormar_config = base_ormar_config.copy(tablename="suppliers")
37+
38+
id: int = ormar.Integer(primary_key=True)
39+
name: str = ormar.String(max_length=100)
40+
41+
42+
class Item(ormar.Model):
43+
ormar_config = base_ormar_config.copy(tablename="items")
44+
45+
id: int = ormar.Integer(primary_key=True)
46+
name: str = ormar.String(max_length=100)
47+
supplier = ormar.ForeignKey(Supplier, name="supplier_id")
48+
49+
3550
create_test_database = init_tests(base_ormar_config)
3651

3752

@@ -62,8 +77,40 @@ def test_fields_access():
6277
assert curr_field._access_chain == "categories__products__rating"
6378
assert curr_field._source_model == PriceList
6479

80+
# FK accessor accepts the same operators as a regular field
81+
sample_category = Category(id=7, name="x")
82+
assert (Product.category == 3)._kwargs_dict == {"category__exact": 3}
83+
assert (Product.category == sample_category)._kwargs_dict == {
84+
"category__exact": sample_category
85+
}
86+
assert (Product.category >= 3)._kwargs_dict == {"category__gte": 3}
87+
assert (Product.category <= 3)._kwargs_dict == {"category__lte": 3}
88+
assert (Product.category > 3)._kwargs_dict == {"category__gt": 3}
89+
assert (Product.category < 3)._kwargs_dict == {"category__lt": 3}
90+
assert (Product.category << [1, 2])._kwargs_dict == {"category__in": [1, 2]}
91+
assert Product.category.in_([1, 2])._kwargs_dict == {"category__in": [1, 2]}
92+
assert (Product.category >> None)._kwargs_dict == {"category__isnull": True}
93+
assert Product.category.isnull(False)._kwargs_dict == {"category__isnull": False}
94+
95+
# FK accessor with an explicit db alias (name="supplier_id") still works
96+
# because the check keys on the ormar field registry, not on table.columns
97+
sample_supplier = Supplier(id=9, name="acme")
98+
assert (Item.supplier == 2)._kwargs_dict == {"supplier__exact": 2}
99+
assert (Item.supplier == sample_supplier)._kwargs_dict == {
100+
"supplier__exact": sample_supplier
101+
}
102+
assert (Item.supplier << [sample_supplier, 5])._kwargs_dict == {
103+
"supplier__in": [sample_supplier, 5]
104+
}
105+
assert (Item.supplier >= 2)._kwargs_dict == {"supplier__gte": 2}
106+
107+
# m2m accessor has no own column - comparison still raises
65108
with pytest.raises(AttributeError):
66-
assert Product.category >= 3
109+
assert Category.price_lists >= 3
110+
111+
# reverse FK accessor (virtual relation) - comparison still raises
112+
with pytest.raises(AttributeError):
113+
assert Category.products >= 3
67114

68115

69116
@pytest.mark.parametrize(
@@ -204,3 +251,48 @@ async def test_filtering_by_field_access():
204251

205252
check = await Product.objects.get(Product.name == "My Little Pony")
206253
assert check == product2
254+
255+
256+
@pytest.mark.asyncio
257+
async def test_filtering_fk_by_field_access():
258+
async with base_ormar_config.database:
259+
async with base_ormar_config.database.transaction(force_rollback=True):
260+
toys = await Category(name="Toys").save()
261+
books = await Category(name="Books").save()
262+
pony = await Product(
263+
name="My Little Pony", rating=3.8, category=toys
264+
).save()
265+
await Product(name="Novel", rating=4.2, category=books).save()
266+
267+
# by scalar PK - should match kwargs form exactly
268+
via_accessor = await Product.objects.filter(
269+
Product.category == toys.pk
270+
).all()
271+
via_kwargs = await Product.objects.filter(category=toys.pk).all()
272+
assert {p.pk for p in via_accessor} == {pony.pk}
273+
assert {p.pk for p in via_accessor} == {p.pk for p in via_kwargs}
274+
275+
# by model instance
276+
via_instance = await Product.objects.filter(Product.category == toys).all()
277+
assert {p.pk for p in via_instance} == {pony.pk}
278+
279+
# `in_` / `<<` returns matches for several PKs
280+
all_products = await Product.objects.all()
281+
via_in = await Product.objects.filter(
282+
Product.category << [toys.pk, books.pk]
283+
).all()
284+
assert {p.pk for p in via_in} == {p.pk for p in all_products}
285+
286+
# aliased FK field (name="supplier_id")
287+
sup = await Supplier(name="Acme").save()
288+
other_sup = await Supplier(name="Globex").save()
289+
gadget = await Item(name="gadget", supplier=sup).save()
290+
await Item(name="widget", supplier=other_sup).save()
291+
via_aliased_pk = await Item.objects.filter(Item.supplier == sup.pk).all()
292+
via_aliased_instance = await Item.objects.filter(Item.supplier == sup).all()
293+
via_aliased_in = await Item.objects.filter(
294+
Item.supplier << [sup.pk, other_sup.pk]
295+
).all()
296+
assert {i.pk for i in via_aliased_pk} == {gadget.pk}
297+
assert {i.pk for i in via_aliased_instance} == {gadget.pk}
298+
assert len(via_aliased_in) == 2

0 commit comments

Comments
 (0)