From 07034cd8ace6633167bb92ca8295029b6d6143a4 Mon Sep 17 00:00:00 2001 From: collerek Date: Fri, 24 Apr 2026 11:02:50 +0200 Subject: [PATCH] add support for filtering by fk columns --- docs/queries/filter-and-sort.md | 17 ++++ ormar/queryset/field_accessor.py | 10 +- .../test_fields_access.py | 94 ++++++++++++++++++- 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/docs/queries/filter-and-sort.md b/docs/queries/filter-and-sort.md index 0a48c5ea4..88f9a9ada 100644 --- a/docs/queries/filter-and-sort.md +++ b/docs/queries/filter-and-sort.md @@ -161,6 +161,23 @@ Product.objects.filter( ).get() ``` +!!!note + A foreign-key accessor can also be compared directly to a primary-key value + or a model instance, which is equivalent to the kwargs form and stays a + single-table filter (no JOIN is added): + + ```python + # both produce: WHERE books.author = 5 + Book.objects.filter(author=5) + Book.objects.filter(Book.author == 5) + + # with an instance - the PK is extracted for you + Book.objects.filter(Book.author == tolkien) + ``` + + This only applies to own FK columns. Many-to-many and reverse relations + still require targeting a concrete column, e.g. `Category.products.name`. + !!!note All methods that do not return the rows explicitly returns a QuerySet instance so you can chain them together diff --git a/ormar/queryset/field_accessor.py b/ormar/queryset/field_accessor.py index 531484b3e..445c4b27a 100644 --- a/ormar/queryset/field_accessor.py +++ b/ormar/queryset/field_accessor.py @@ -63,10 +63,12 @@ def __getattr__(self, item: str) -> Any: return object.__getattribute__(self, item) # pragma: no cover def _check_field(self) -> None: - if not self._field: - raise AttributeError( - "Cannot filter by Model, you need to provide model name" - ) + if self._field: + return + field = self._source_model.ormar_config.model_fields.get(self._access_chain) + if field is not None and not field.virtual and not field.is_multi: + return + raise AttributeError("Cannot filter by Model, you need to provide model name") def _select_operator(self, op: str, other: Any) -> FilterGroup: self._check_field() diff --git a/tests/test_model_definition/test_fields_access.py b/tests/test_model_definition/test_fields_access.py index 4b63f31ee..7321061dd 100644 --- a/tests/test_model_definition/test_fields_access.py +++ b/tests/test_model_definition/test_fields_access.py @@ -32,6 +32,21 @@ class Product(ormar.Model): category = ormar.ForeignKey(Category) +class Supplier(ormar.Model): + ormar_config = base_ormar_config.copy(tablename="suppliers") + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) + + +class Item(ormar.Model): + ormar_config = base_ormar_config.copy(tablename="items") + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) + supplier = ormar.ForeignKey(Supplier, name="supplier_id") + + create_test_database = init_tests(base_ormar_config) @@ -62,8 +77,40 @@ def test_fields_access(): assert curr_field._access_chain == "categories__products__rating" assert curr_field._source_model == PriceList + # FK accessor accepts the same operators as a regular field + sample_category = Category(id=7, name="x") + assert (Product.category == 3)._kwargs_dict == {"category__exact": 3} + assert (Product.category == sample_category)._kwargs_dict == { + "category__exact": sample_category + } + assert (Product.category >= 3)._kwargs_dict == {"category__gte": 3} + assert (Product.category <= 3)._kwargs_dict == {"category__lte": 3} + assert (Product.category > 3)._kwargs_dict == {"category__gt": 3} + assert (Product.category < 3)._kwargs_dict == {"category__lt": 3} + assert (Product.category << [1, 2])._kwargs_dict == {"category__in": [1, 2]} + assert Product.category.in_([1, 2])._kwargs_dict == {"category__in": [1, 2]} + assert (Product.category >> None)._kwargs_dict == {"category__isnull": True} + assert Product.category.isnull(False)._kwargs_dict == {"category__isnull": False} + + # FK accessor with an explicit db alias (name="supplier_id") still works + # because the check keys on the ormar field registry, not on table.columns + sample_supplier = Supplier(id=9, name="acme") + assert (Item.supplier == 2)._kwargs_dict == {"supplier__exact": 2} + assert (Item.supplier == sample_supplier)._kwargs_dict == { + "supplier__exact": sample_supplier + } + assert (Item.supplier << [sample_supplier, 5])._kwargs_dict == { + "supplier__in": [sample_supplier, 5] + } + assert (Item.supplier >= 2)._kwargs_dict == {"supplier__gte": 2} + + # m2m accessor has no own column - comparison still raises with pytest.raises(AttributeError): - assert Product.category >= 3 + assert Category.price_lists >= 3 + + # reverse FK accessor (virtual relation) - comparison still raises + with pytest.raises(AttributeError): + assert Category.products >= 3 @pytest.mark.parametrize( @@ -204,3 +251,48 @@ async def test_filtering_by_field_access(): check = await Product.objects.get(Product.name == "My Little Pony") assert check == product2 + + +@pytest.mark.asyncio +async def test_filtering_fk_by_field_access(): + async with base_ormar_config.database: + async with base_ormar_config.database.transaction(force_rollback=True): + toys = await Category(name="Toys").save() + books = await Category(name="Books").save() + pony = await Product( + name="My Little Pony", rating=3.8, category=toys + ).save() + await Product(name="Novel", rating=4.2, category=books).save() + + # by scalar PK - should match kwargs form exactly + via_accessor = await Product.objects.filter( + Product.category == toys.pk + ).all() + via_kwargs = await Product.objects.filter(category=toys.pk).all() + assert {p.pk for p in via_accessor} == {pony.pk} + assert {p.pk for p in via_accessor} == {p.pk for p in via_kwargs} + + # by model instance + via_instance = await Product.objects.filter(Product.category == toys).all() + assert {p.pk for p in via_instance} == {pony.pk} + + # `in_` / `<<` returns matches for several PKs + all_products = await Product.objects.all() + via_in = await Product.objects.filter( + Product.category << [toys.pk, books.pk] + ).all() + assert {p.pk for p in via_in} == {p.pk for p in all_products} + + # aliased FK field (name="supplier_id") + sup = await Supplier(name="Acme").save() + other_sup = await Supplier(name="Globex").save() + gadget = await Item(name="gadget", supplier=sup).save() + await Item(name="widget", supplier=other_sup).save() + via_aliased_pk = await Item.objects.filter(Item.supplier == sup.pk).all() + via_aliased_instance = await Item.objects.filter(Item.supplier == sup).all() + via_aliased_in = await Item.objects.filter( + Item.supplier << [sup.pk, other_sup.pk] + ).all() + assert {i.pk for i in via_aliased_pk} == {gadget.pk} + assert {i.pk for i in via_aliased_instance} == {gadget.pk} + assert len(via_aliased_in) == 2