Skip to content

Feature: Add geometry filters #710

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions strawberry_django/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
DateFilterLookup,
DatetimeFilterLookup,
FilterLookup,
GeometryFilterLookup,
RangeLookup,
TimeFilterLookup,
)
Expand Down Expand Up @@ -37,6 +38,8 @@
"DjangoImageType",
"DjangoModelType",
"FilterLookup",
"GeometryFilterLookup",
"GeometryFilterLookup",
"ListInput",
"ManyToManyInput",
"ManyToOneInput",
Expand Down
62 changes: 62 additions & 0 deletions strawberry_django/fields/filter_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,25 @@
import decimal
import uuid
from typing import (
TYPE_CHECKING,
Annotated,
Generic,
Optional,
TypeVar,
)

import strawberry
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Q
from strawberry import UNSET

from strawberry_django.filters import resolve_value

from .filter_order import filter_field

if TYPE_CHECKING:
from .types import Geometry

T = TypeVar("T")

_SKIP_MSG = "Filter will be skipped on `null` value"
Expand Down Expand Up @@ -123,3 +129,59 @@
str: FilterLookup,
uuid.UUID: FilterLookup,
}


GeometryFilterLookup = None

Check failure on line 134 in strawberry_django/fields/filter_types.py

View workflow job for this annotation

GitHub Actions / Typing

Type "None" is not assignable to declared type "type[GeometryFilterLookup[T@GeometryFilterLookup]]"   Type "None" is not assignable to type "type[GeometryFilterLookup[T@GeometryFilterLookup]]" (reportAssignmentType)

try:
pass
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: hrm, I think this is missing something =P

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that it was removed by auto fixes from pre-commit.com hooks. Originally there was a code to test whether geodjango is available. from django.contrib.gis.geos import GEOSGeometry And I think that it's a better idea to define a configuration variable that indicates geodjango is available or not. But I don't know where it should be located.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try:
    from django.contrib.gis.db import models as geos_fields

    GEOS_IMPORTED = True

    class GeosFieldsModel(models.Model):
        point = geos_fields.PointField(null=True, blank=True)
        line_string = geos_fields.LineStringField(null=True, blank=True)
        polygon = geos_fields.PolygonField(null=True, blank=True)
        multi_point = geos_fields.MultiPointField(null=True, blank=True)
        multi_line_string = geos_fields.MultiLineStringField(null=True, blank=True)
        multi_polygon = geos_fields.MultiPolygonField(null=True, blank=True)
        geometry = geos_fields.GeometryField(null=True, blank=True)

except ImproperlyConfigured:
    GEOS_IMPORTED = False

I found this in tests/models.py file. But I think it should be located in somewhere else.

except ImproperlyConfigured:
# If gdal is not available, skip.
pass
else:

@strawberry.input
class GeometryFilterLookup(Generic[T]):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider dynamically generating the geometry fields to reduce code duplication.

Consider reducing the repetitive field definitions by generating the geometry fields dynamically. For example, you can define a list of method names and then use a helper to build your fields. This will reduce the nesting and repetitive code without changing functionality. For instance:

try:
    from django.contrib.gis.geos import GEOSGeometry
except ImproperlyConfigured:
    # If gdal is not available, skip.
    pass
else:
    GEO_FIELD_TYPE = Optional[Annotated["Geometry", strawberry.lazy(".types")]]
    geometry_methods = [
        "bbcontains", "bboverlaps", "contained", "contains", "contains_properly",
        "coveredby", "covers", "crosses", "disjoint", "equals", "exacts",
        "intersects", "overlaps", "touches", "within", "left", "right",
        "overlaps_left", "overlaps_right", "overlaps_above", "overlaps_below",
        "strictly_above", "strictly_below",
    ]

    def _build_geometry_fields():
        fields = {name: UNSET for name in geometry_methods}
        fields.update({
            "isempty": filter_field(description=f"Test whether it's empty. {_SKIP_MSG}"),
            "isvalid": filter_field(description=f"Test whether it's valid. {_SKIP_MSG}"),
        })
        return fields

    GeometryFields = _build_geometry_fields()
    GeometryFilterLookup = strawberry.input(
        type("GeometryFilterLookup", (Generic[T],), GeometryFields)
    )

This refactoring maintains all functionality while reducing the duplication and nesting in your class definition.

bbcontains: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
bboverlaps: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
contained: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
contains: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
contains_properly: Optional[
Annotated["Geometry", strawberry.lazy(".types")]
] = UNSET
coveredby: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
covers: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
crosses: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
disjoint: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
equals: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
exacts: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
intersects: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
isempty: Optional[bool] = filter_field(
description=f"Test whether it's empty. {_SKIP_MSG}"
)
isvalid: Optional[bool] = filter_field(
description=f"Test whether it's valid. {_SKIP_MSG}"
)
overlaps: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
touches: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
within: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
left: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
right: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = UNSET
overlaps_left: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = (
UNSET
)
overlaps_right: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = (
UNSET
)
overlaps_above: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = (
UNSET
)
overlaps_below: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = (
UNSET
)
strictly_above: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = (
UNSET
)
strictly_below: Optional[Annotated["Geometry", strawberry.lazy(".types")]] = (
UNSET
)
18 changes: 16 additions & 2 deletions strawberry_django/fields/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from strawberry.file_uploads.scalars import Upload
from strawberry.scalars import JSON
from strawberry.types.enum import EnumValueDefinition
from strawberry.types.scalar import ScalarWrapper
from strawberry.utils.str_converters import capitalize_first, to_camel_case

from strawberry_django import filters
Expand Down Expand Up @@ -348,7 +349,7 @@
Geometry = strawberry.scalar(
NewType("Geometry", geos.GEOSGeometry),
serialize=lambda v: v.tuple if isinstance(v, geos.GEOSGeometry) else v, # type: ignore
parse_value=lambda v: geos.GeometryCollection,
parse_value=lambda v: geos.GEOSGeometry(v),
description=(
"An arbitrary geographical object. One of Point, "
"LineString, LinearRing, Polygon, MultiPoint, MultiLineString, MultiPolygon."
Expand Down Expand Up @@ -556,10 +557,23 @@
and (field_type is not bool or not using_old_filters)
):
if using_old_filters:
field_type = filters.FilterLookup[field_type]
field_type = filters.FilterLookup[field_type] # type: ignore

Check warning on line 560 in strawberry_django/fields/types.py

View workflow job for this annotation

GitHub Actions / Typing

Unnecessary "# type: ignore" comment (reportUnnecessaryTypeIgnoreComment)
elif type(
field_type
) is ScalarWrapper and field_type._scalar_definition.name in (

Check failure on line 563 in strawberry_django/fields/types.py

View workflow job for this annotation

GitHub Actions / Typing

Cannot access attribute "_scalar_definition" for class "type[bool]"   Attribute "_scalar_definition" is unknown (reportAttributeAccessIssue)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: might need a # type: ignore here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK.

"Point",
"LineString",
"LinearRing",
"Polygon",
"MultiPoint",
"MultilineString",
"MultiPolygon",
"Geometry",
):
field_type = filter_types.GeometryFilterLookup[field_type]
else:
field_type = filter_types.type_filter_map.get( # type: ignore
field_type, filter_types.FilterLookup

Check failure on line 576 in strawberry_django/fields/types.py

View workflow job for this annotation

GitHub Actions / Typing

Argument of type "Any | Unknown | type[bool] | ScalarWrapper" cannot be assigned to parameter "key" of type "type[ID] | type[bool] | type[date] | type[datetime] | type[time] | type[Decimal] | type[float] | type[int] | type[str] | type[UUID]" in function "get"   Type "Any | Unknown | type[bool] | ScalarWrapper" is not assignable to type "type[ID] | type[bool] | type[date] | type[datetime] | type[time] | type[Decimal] | type[float] | type[int] | type[str] | type[UUID]"     Type "ScalarWrapper" is not assignable to type "type[ID] | type[bool] | type[date] | type[datetime] | type[time] | type[Decimal] | type[float] | type[int] | type[str] | type[UUID]"       Type "ScalarWrapper" is not assignable to type "type[ID]"       Type "ScalarWrapper" is not assignable to type "type[bool]"       Type "ScalarWrapper" is not assignable to type "type[date]"       Type "ScalarWrapper" is not assignable to type "type[datetime]"       Type "ScalarWrapper" is not assignable to type "type[time]"       Type "ScalarWrapper" is not assignable to type "type[Decimal]" ... (reportArgumentType)
)[field_type]

return field_type
Expand Down
31 changes: 30 additions & 1 deletion tests/test_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pytest
import strawberry
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from graphql import GraphQLError
Expand All @@ -15,7 +16,7 @@
import strawberry_django
from strawberry_django.settings import StrawberryDjangoSettings

from . import models, utils
from . import models, types, utils


@pytest.fixture
Expand Down Expand Up @@ -79,6 +80,8 @@ class Query:
fruit: Fruit = strawberry_django.field()
berries: list[BerryFruit] = strawberry_django.field()
bananas: list[BananaFruit] = strawberry_django.field()
if settings.GEOS_IMPORTED:
geometries: list[types.GeoField] = strawberry_django.field()


@pytest.fixture
Expand Down Expand Up @@ -314,3 +317,29 @@ def fruit(self) -> Fruit:
}
""")
assert result.data == {"fruit": {"colorId": mock.ANY, "name": "Banana"}}


@pytest.mark.skipif(not settings.GEOS_IMPORTED, reason="GeoDjango is not available.")
async def test_geos(query):
from django.contrib.gis.geos import GEOSGeometry

result = await query(
"""
query GeosQuery($filter: GeoFieldFilter) {
geometries(filters: $filter) {
geometry
}
}
""",
variable_values={
"filter": {
"geometry": {
"contains": GEOSGeometry(
"POLYGON(( 10 10, 10 20, 20 20, 20 15, 10 10))"
)
}
}
},
)

assert not result.errors
7 changes: 6 additions & 1 deletion tests/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ class TomatoWithRequiredPictureType:

if settings.GEOS_IMPORTED:

@strawberry_django.type(models.GeosFieldsModel)
@strawberry_django.filters.filter(models.GeosFieldsModel, lookups=True)
class GeoFieldFilter:
geometry: auto

@strawberry_django.type(models.GeosFieldsModel, filters=GeoFieldFilter)
class GeoField:
id: auto
point: auto
Expand All @@ -47,6 +51,7 @@ class GeoField:
multi_point: auto
multi_line_string: auto
multi_polygon: auto
geometry: auto

@strawberry_django.input(models.GeosFieldsModel)
class GeoFieldInput(GeoField):
Expand Down
Loading