diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0a7cf983..7c192005 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,41 +45,84 @@ jobs: django-version: - 4.2.* - 5.0.* + - 5.1.* python-version: - - '3.9' - - '3.10' - - '3.11' - - '3.12' - - '3.13' + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" mode: - std - geos + psycopg: + - "psycopg2" + - "psycopg" # v3 + include: + # Django 4.2 only supports python 3.8-3.12 + - django-version: 4.2.* + python-version: "3.8" + mode: std + psycopg: psycopg2 + - django-version: 4.2.* + python-version: "3.8" + mode: geos + psycopg: psycopg2 + # Python 3.8 requires psycopg<3.3 + # Ref: https://www.psycopg.org/psycopg3/docs/basic/install.html#supported-systems + - django-version: 4.2.* + python-version: "3.8" + mode: std + psycopg: "psycopg==3.2" + - django-version: 4.2.* + python-version: "3.8" + mode: geos + psycopg: "psycopg==3.2" exclude: - # Django 5.0 only supports python 3.10+ + # Django 4.2 only supports python 3.8-3.12 + - django-version: 4.2.* + python-version: "3.13" + # Django 5.0 only supports python 3.10-3.12 - django-version: 5.0.* - python-version: '3.9' + python-version: "3.9" + - django-version: 5.0.* + python-version: "3.13" + # Django 5.1 only supports python 3.10+ + - django-version: 5.1.* + python-version: "3.9" steps: - name: Checkout uses: actions/checkout@v4 + - name: Install OS Dependencies if: ${{ matrix.mode == 'geos' }} uses: daaku/gh-action-apt-install@v4 with: packages: binutils gdal-bin libproj-dev libsqlite3-mod-spatialite + - name: Install Poetry run: pipx install poetry + - name: Set up Python ${{ matrix.python-version }} id: setup-python uses: actions/setup-python@v5 with: cache: poetry python-version: ${{ matrix.python-version }} + - name: Install Deps run: poetry install + + - name: Install ${{ matrix.psycopg }} + run: | + poetry run pip install "${{ matrix.psycopg }}" + - name: Install Django ${{ matrix.django-version }} run: poetry run pip install "django==${{ matrix.django-version }}" + - name: Test with pytest run: poetry run pytest --showlocals -vvv --cov-report=xml + - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 env: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7474fc28..22b1617a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: - id: check-xml - id: check-symlinks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.3 + rev: v0.8.4 hooks: - id: ruff-format - id: ruff diff --git a/poetry.lock b/poetry.lock index 41d6406d..a5572379 100644 --- a/poetry.lock +++ b/poetry.lock @@ -188,40 +188,6 @@ files = [ [package.dependencies] Django = ">=2.2" -[[package]] -name = "django-js-asset" -version = "2.2.0" -description = "script tag with additional attributes for django.forms.Media" -optional = false -python-versions = ">=3.8" -files = [ - {file = "django_js_asset-2.2.0-py3-none-any.whl", hash = "sha256:7ef3e858e13d06f10799b56eea62b1e76706f42cf4e709be4e13356bc0ae30d8"}, - {file = "django_js_asset-2.2.0.tar.gz", hash = "sha256:0c57a82cae2317e83951d956110ce847f58ff0cdc24e314dbc18b35033917e94"}, -] - -[package.dependencies] -django = ">=3.2" - -[package.extras] -tests = ["coverage"] - -[[package]] -name = "django-mptt" -version = "0.14.0" -description = "Utilities for implementing Modified Preorder Tree Traversal with your Django Models and working with trees of Model instances." -optional = false -python-versions = ">=3.6" -files = [ - {file = "django-mptt-0.14.0.tar.gz", hash = "sha256:2c92a2b1614c53086278795ccf50580cf1f9b8564f3ff03055dd62bab5987711"}, - {file = "django_mptt-0.14.0-py3-none-any.whl", hash = "sha256:d9a87433ab0e4f35247c6f6d5a93ace6990860a4ba8796f815d185f773b9acfc"}, -] - -[package.dependencies] -django-js-asset = "*" - -[package.extras] -tests = ["coverage", "mock-django"] - [[package]] name = "django-polymorphic" version = "3.1.0" @@ -236,6 +202,20 @@ files = [ [package.dependencies] Django = ">=2.1" +[[package]] +name = "django-tree-queries" +version = "0.19.0" +description = "Tree queries with explicit opt-in, without configurability" +optional = false +python-versions = ">=3.8" +files = [ + {file = "django_tree_queries-0.19.0-py3-none-any.whl", hash = "sha256:05b9e3158e31612528f136b4704a8d807e14edc0b4a607a45377e6132517ba2c"}, + {file = "django_tree_queries-0.19.0.tar.gz", hash = "sha256:d1325e75f96e90b86c4316a3d63498101ec05703f4e629786b561e8aaab0e4a7"}, +] + +[package.extras] +tests = ["coverage"] + [[package]] name = "django-types" version = "0.20.0" @@ -911,4 +891,4 @@ enum = ["django-choices-field"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "56e83e063c18200cdc6cceb33afa4686060eea914fca7182f2ca5ff73755f4ca" +content-hash = "aec43ee431a6a68b0cad49b019a241935f51e13d28374a2ff1ad00cf9f342e96" diff --git a/pyproject.toml b/pyproject.toml index 924f4d27..7fb5dffe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,13 +37,13 @@ asgiref = ">=3.8" django-choices-field = { version = ">=2.2.2", optional = true } django-debug-toolbar = { version = ">=3.4", optional = true } strawberry-graphql = ">=0.236.0" +django-tree-queries = "^0.19.0" [tool.poetry.group.dev.dependencies] channels = { version = ">=3.0.5" } django-choices-field = "^2.2.2" django-debug-toolbar = "^4.4.6" django-guardian = "^2.4.0" -django-mptt = "^0.14.0" django-types = "^0.20.0" factory-boy = "^3.2.1" pillow = "^11.0.0" @@ -132,7 +132,7 @@ extend-ignore = [ "TRY003", "PLR6301", "PLC0415", - "TCH002", + "TC002", # ruff formatter recommends to disable those "COM812", "COM819", diff --git a/strawberry_django/fields/types.py b/strawberry_django/fields/types.py index b1cf2b41..466f820e 100644 --- a/strawberry_django/fields/types.py +++ b/strawberry_django/fields/types.py @@ -39,7 +39,7 @@ try: from django.contrib.postgres.fields import ArrayField except (ImportError, ModuleNotFoundError): # pragma: no cover - # ArrayField will not be importable if psycopg2 is not installed + # ArrayField will not be importable if psycopg or psycopg2 is not installed ArrayField = None if django.VERSION >= (5, 0): diff --git a/tests/relay/mptt/a.py b/tests/relay/mptt/a.py deleted file mode 100644 index 19560af9..00000000 --- a/tests/relay/mptt/a.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import TYPE_CHECKING, Annotated - -import strawberry -from strawberry import relay -from typing_extensions import TypeAlias - -import strawberry_django -from strawberry_django.relay import ListConnectionWithTotalCount - -from .models import MPTTAuthor - -if TYPE_CHECKING: - from .b import MPTTBookConnection - - -@strawberry_django.type(MPTTAuthor) -class MPTTAuthorType(relay.Node): - name: str - books: Annotated["MPTTBookConnection", strawberry.lazy("tests.relay.mptt.b")] = ( - strawberry_django.connection() - ) - children: "MPTTAuthorConnection" = strawberry_django.connection() - - -MPTTAuthorConnection: TypeAlias = ListConnectionWithTotalCount[MPTTAuthorType] diff --git a/tests/relay/mptt/b.py b/tests/relay/mptt/b.py deleted file mode 100644 index 5a257786..00000000 --- a/tests/relay/mptt/b.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import TYPE_CHECKING, Annotated - -import strawberry -from strawberry import relay -from typing_extensions import TypeAlias - -import strawberry_django -from strawberry_django.relay import ListConnectionWithTotalCount - -from .models import MPTTBook - -if TYPE_CHECKING: - from .a import MPTTAuthorType - - -@strawberry_django.filter(MPTTBook) -class MPTTBookFilter: - name: str - - -@strawberry_django.order(MPTTBook) -class MPTTBookOrder: - name: str - - -@strawberry_django.type(MPTTBook, filters=MPTTBookFilter, order=MPTTBookOrder) -class MPTTBookType(relay.Node): - name: str - author: Annotated["MPTTAuthorType", strawberry.lazy("tests.relay.mptt.a")] - - -MPTTBookConnection: TypeAlias = ListConnectionWithTotalCount[MPTTBookType] diff --git a/tests/relay/mptt/__init__.py b/tests/relay/treenode/__init__.py similarity index 100% rename from tests/relay/mptt/__init__.py rename to tests/relay/treenode/__init__.py diff --git a/tests/relay/treenode/a.py b/tests/relay/treenode/a.py new file mode 100644 index 00000000..3630ec43 --- /dev/null +++ b/tests/relay/treenode/a.py @@ -0,0 +1,25 @@ +from typing import TYPE_CHECKING, Annotated + +import strawberry +from strawberry import relay +from typing_extensions import TypeAlias + +import strawberry_django +from strawberry_django.relay import ListConnectionWithTotalCount + +from .models import TreeNodeAuthor + +if TYPE_CHECKING: + from .b import TreeNodeBookConnection + + +@strawberry_django.type(TreeNodeAuthor) +class TreeNodeAuthorType(relay.Node): + name: str + books: Annotated[ + "TreeNodeBookConnection", strawberry.lazy("tests.relay.treenode.b") + ] = strawberry_django.connection() + children: "TreeNodeAuthorConnection" = strawberry_django.connection() + + +TreeNodeAuthorConnection: TypeAlias = ListConnectionWithTotalCount[TreeNodeAuthorType] diff --git a/tests/relay/treenode/b.py b/tests/relay/treenode/b.py new file mode 100644 index 00000000..0872e70d --- /dev/null +++ b/tests/relay/treenode/b.py @@ -0,0 +1,34 @@ +from typing import TYPE_CHECKING, Annotated + +import strawberry +from strawberry import relay +from typing_extensions import TypeAlias + +import strawberry_django +from strawberry_django.relay import ListConnectionWithTotalCount + +from .models import TreeNodeBook + +if TYPE_CHECKING: + from .a import TreeNodeAuthorType + + +@strawberry_django.filter(TreeNodeBook) +class TreeNodeBookFilter: + name: str + + +@strawberry_django.order(TreeNodeBook) +class TreeNodeBookOrder: + name: str + + +@strawberry_django.type( + TreeNodeBook, filters=TreeNodeBookFilter, order=TreeNodeBookOrder +) +class TreeNodeBookType(relay.Node): + name: str + author: Annotated["TreeNodeAuthorType", strawberry.lazy("tests.relay.treenode.a")] + + +TreeNodeBookConnection: TypeAlias = ListConnectionWithTotalCount[TreeNodeBookType] diff --git a/tests/relay/mptt/models.py b/tests/relay/treenode/models.py similarity index 62% rename from tests/relay/mptt/models.py rename to tests/relay/treenode/models.py index a3096848..58579611 100644 --- a/tests/relay/mptt/models.py +++ b/tests/relay/treenode/models.py @@ -1,11 +1,11 @@ from django.db import models -from mptt.fields import TreeForeignKey -from mptt.models import MPTTModel +from tree_queries.fields import TreeNodeForeignKey +from tree_queries.models import TreeNode -class MPTTAuthor(MPTTModel): +class TreeNodeAuthor(TreeNode): name = models.CharField(max_length=100) - parent = TreeForeignKey( + parent = TreeNodeForeignKey( to="self", on_delete=models.CASCADE, null=True, @@ -14,10 +14,10 @@ class MPTTAuthor(MPTTModel): ) -class MPTTBook(models.Model): +class TreeNodeBook(models.Model): title = models.CharField(max_length=100) author = models.ForeignKey( - MPTTAuthor, + TreeNodeAuthor, on_delete=models.CASCADE, related_name="books", ) diff --git a/tests/relay/mptt/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql b/tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql similarity index 82% rename from tests/relay/mptt/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql rename to tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql index 8ddf9851..62ab4f9b 100644 --- a/tests/relay/mptt/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql +++ b/tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql @@ -3,13 +3,95 @@ The `ID` scalar type represents a unique identifier, often used to refetch an ob """ scalar GlobalID @specifiedBy(url: "https://relay.dev/graphql/objectidentification.htm") -type MPTTAuthorType implements Node { +"""An object with a Globally Unique ID""" +interface Node { + """The Globally Unique ID of this object""" + id: GlobalID! +} + +"""Information to aid in pagination.""" +type PageInfo { + """When paginating forwards, are there more items?""" + hasNextPage: Boolean! + + """When paginating backwards, are there more items?""" + hasPreviousPage: Boolean! + + """When paginating backwards, the cursor to continue.""" + startCursor: String + + """When paginating forwards, the cursor to continue.""" + endCursor: String +} + +type Query { + booksConn( + filters: TreeNodeBookFilter + order: TreeNodeBookOrder + + """Returns the items in the list that come before the specified cursor.""" + before: String = null + + """Returns the items in the list that come after the specified cursor.""" + after: String = null + + """Returns the first n items from the list.""" + first: Int = null + + """Returns the items in the list that come after the specified cursor.""" + last: Int = null + ): TreeNodeBookTypeConnection! + booksConn2( + filters: TreeNodeBookFilter + order: TreeNodeBookOrder + + """Returns the items in the list that come before the specified cursor.""" + before: String = null + + """Returns the items in the list that come after the specified cursor.""" + after: String = null + + """Returns the first n items from the list.""" + first: Int = null + + """Returns the items in the list that come after the specified cursor.""" + last: Int = null + ): TreeNodeBookTypeConnection! + authorsConn( + """Returns the items in the list that come before the specified cursor.""" + before: String = null + + """Returns the items in the list that come after the specified cursor.""" + after: String = null + + """Returns the first n items from the list.""" + first: Int = null + + """Returns the items in the list that come after the specified cursor.""" + last: Int = null + ): TreeNodeAuthorTypeConnection! + authorsConn2( + """Returns the items in the list that come before the specified cursor.""" + before: String = null + + """Returns the items in the list that come after the specified cursor.""" + after: String = null + + """Returns the first n items from the list.""" + first: Int = null + + """Returns the items in the list that come after the specified cursor.""" + last: Int = null + ): TreeNodeAuthorTypeConnection! +} + +type TreeNodeAuthorType implements Node { """The Globally Unique ID of this object""" id: GlobalID! name: String! books( - filters: MPTTBookFilter - order: MPTTBookOrder + filters: TreeNodeBookFilter + order: TreeNodeBookOrder """Returns the items in the list that come before the specified cursor.""" before: String = null @@ -22,7 +104,7 @@ type MPTTAuthorType implements Node { """Returns the items in the list that come after the specified cursor.""" last: Int = null - ): MPTTBookTypeConnection! + ): TreeNodeBookTypeConnection! children( """Returns the items in the list that come before the specified cursor.""" before: String = null @@ -35,148 +117,66 @@ type MPTTAuthorType implements Node { """Returns the items in the list that come after the specified cursor.""" last: Int = null - ): MPTTAuthorTypeConnection! + ): TreeNodeAuthorTypeConnection! } """A connection to a list of items.""" -type MPTTAuthorTypeConnection { +type TreeNodeAuthorTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" - edges: [MPTTAuthorTypeEdge!]! + edges: [TreeNodeAuthorTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" -type MPTTAuthorTypeEdge { +type TreeNodeAuthorTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" - node: MPTTAuthorType! + node: TreeNodeAuthorType! } -input MPTTBookFilter { +input TreeNodeBookFilter { name: String! - AND: MPTTBookFilter - OR: MPTTBookFilter - NOT: MPTTBookFilter + AND: TreeNodeBookFilter + OR: TreeNodeBookFilter + NOT: TreeNodeBookFilter DISTINCT: Boolean } -input MPTTBookOrder { +input TreeNodeBookOrder { name: String } -type MPTTBookType implements Node { +type TreeNodeBookType implements Node { """The Globally Unique ID of this object""" id: GlobalID! name: String! - author: MPTTAuthorType! + author: TreeNodeAuthorType! } """A connection to a list of items.""" -type MPTTBookTypeConnection { +type TreeNodeBookTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" - edges: [MPTTBookTypeEdge!]! + edges: [TreeNodeBookTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" -type MPTTBookTypeEdge { +type TreeNodeBookTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" - node: MPTTBookType! -} - -"""An object with a Globally Unique ID""" -interface Node { - """The Globally Unique ID of this object""" - id: GlobalID! -} - -"""Information to aid in pagination.""" -type PageInfo { - """When paginating forwards, are there more items?""" - hasNextPage: Boolean! - - """When paginating backwards, are there more items?""" - hasPreviousPage: Boolean! - - """When paginating backwards, the cursor to continue.""" - startCursor: String - - """When paginating forwards, the cursor to continue.""" - endCursor: String -} - -type Query { - booksConn( - filters: MPTTBookFilter - order: MPTTBookOrder - - """Returns the items in the list that come before the specified cursor.""" - before: String = null - - """Returns the items in the list that come after the specified cursor.""" - after: String = null - - """Returns the first n items from the list.""" - first: Int = null - - """Returns the items in the list that come after the specified cursor.""" - last: Int = null - ): MPTTBookTypeConnection! - booksConn2( - filters: MPTTBookFilter - order: MPTTBookOrder - - """Returns the items in the list that come before the specified cursor.""" - before: String = null - - """Returns the items in the list that come after the specified cursor.""" - after: String = null - - """Returns the first n items from the list.""" - first: Int = null - - """Returns the items in the list that come after the specified cursor.""" - last: Int = null - ): MPTTBookTypeConnection! - authorsConn( - """Returns the items in the list that come before the specified cursor.""" - before: String = null - - """Returns the items in the list that come after the specified cursor.""" - after: String = null - - """Returns the first n items from the list.""" - first: Int = null - - """Returns the items in the list that come after the specified cursor.""" - last: Int = null - ): MPTTAuthorTypeConnection! - authorsConn2( - """Returns the items in the list that come before the specified cursor.""" - before: String = null - - """Returns the items in the list that come after the specified cursor.""" - after: String = null - - """Returns the first n items from the list.""" - first: Int = null - - """Returns the items in the list that come after the specified cursor.""" - last: Int = null - ): MPTTAuthorTypeConnection! + node: TreeNodeBookType! } \ No newline at end of file diff --git a/tests/relay/mptt/test_lazy_annotations.py b/tests/relay/treenode/test_lazy_annotations.py similarity index 58% rename from tests/relay/mptt/test_lazy_annotations.py rename to tests/relay/treenode/test_lazy_annotations.py index ebfce237..c4b4629a 100644 --- a/tests/relay/mptt/test_lazy_annotations.py +++ b/tests/relay/treenode/test_lazy_annotations.py @@ -6,8 +6,8 @@ import strawberry_django from strawberry_django.relay import ListConnectionWithTotalCount -from .a import MPTTAuthorConnection, MPTTAuthorType -from .b import MPTTBookConnection, MPTTBookType +from .a import TreeNodeAuthorConnection, TreeNodeAuthorType +from .b import TreeNodeBookConnection, TreeNodeBookType SNAPSHOTS_DIR = pathlib.Path(__file__).parent / "snapshots" @@ -15,12 +15,12 @@ def test_lazy_type_annotations_in_schema(snapshot: Snapshot): @strawberry.type class Query: - books_conn: MPTTBookConnection = strawberry_django.connection() - books_conn2: ListConnectionWithTotalCount[MPTTBookType] = ( + books_conn: TreeNodeBookConnection = strawberry_django.connection() + books_conn2: ListConnectionWithTotalCount[TreeNodeBookType] = ( strawberry_django.connection() ) - authors_conn: MPTTAuthorConnection = strawberry_django.connection() - authors_conn2: ListConnectionWithTotalCount[MPTTAuthorType] = ( + authors_conn: TreeNodeAuthorConnection = strawberry_django.connection() + authors_conn2: ListConnectionWithTotalCount[TreeNodeAuthorType] = ( strawberry_django.connection() ) diff --git a/tests/relay/mptt/test_nested_children.py b/tests/relay/treenode/test_nested_children.py similarity index 74% rename from tests/relay/mptt/test_nested_children.py rename to tests/relay/treenode/test_nested_children.py index 7c0e62e0..d8fab0bd 100644 --- a/tests/relay/mptt/test_nested_children.py +++ b/tests/relay/treenode/test_nested_children.py @@ -5,13 +5,13 @@ import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension -from .a import MPTTAuthorConnection -from .models import MPTTAuthor +from .a import TreeNodeAuthorConnection +from .models import TreeNodeAuthor @strawberry.type class Query: - authors: MPTTAuthorConnection = strawberry_django.connection() + authors: TreeNodeAuthorConnection = strawberry_django.connection() schema = strawberry.Schema(query=Query, extensions=[DjangoOptimizerExtension]) @@ -19,9 +19,9 @@ class Query: @pytest.mark.django_db(transaction=True) def test_nested_children_total_count(): - parent = MPTTAuthor.objects.create(name="Parent") - child1 = MPTTAuthor.objects.create(name="Child1", parent=parent) - child2 = MPTTAuthor.objects.create(name="Child2", parent=parent) + parent = TreeNodeAuthor.objects.create(name="Parent") + child1 = TreeNodeAuthor.objects.create(name="Child1", parent=parent) + child2 = TreeNodeAuthor.objects.create(name="Child2", parent=parent) query = """\ query { authors(first: 1) { @@ -53,20 +53,24 @@ def test_nested_children_total_count(): "edges": [ { "node": { - "id": to_base64("MPTTAuthorType", parent.pk), + "id": to_base64("TreeNodeAuthorType", parent.pk), "name": "Parent", "children": { "totalCount": 2, "edges": [ { "node": { - "id": to_base64("MPTTAuthorType", child1.pk), + "id": to_base64( + "TreeNodeAuthorType", child1.pk + ), "name": "Child1", } }, { "node": { - "id": to_base64("MPTTAuthorType", child2.pk), + "id": to_base64( + "TreeNodeAuthorType", child2.pk + ), "name": "Child2", } }, @@ -81,7 +85,7 @@ def test_nested_children_total_count(): @pytest.mark.django_db(transaction=True) def test_nested_children_total_count_no_children(): - parent = MPTTAuthor.objects.create(name="Parent") + parent = TreeNodeAuthor.objects.create(name="Parent") query = """\ query { authors { @@ -113,7 +117,7 @@ def test_nested_children_total_count_no_children(): "edges": [ { "node": { - "id": to_base64("MPTTAuthorType", parent.pk), + "id": to_base64("TreeNodeAuthorType", parent.pk), "name": "Parent", "children": { "totalCount": 0,