pax_global_header00006660000000000000000000000064151617341020014512gustar00rootroot0000000000000052 comment=506cb8a0881c5acf3a775e839a207d96aa9ab1ef strawberry-graphql-django-0.82.1/000077500000000000000000000000001516173410200167025ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/.alexrc.yaml000066400000000000000000000002531516173410200211220ustar00rootroot00000000000000allow: - black - color - colors - execute - executed - executes - execution - failure - hook - hooks - invalid" - period - primitive - special strawberry-graphql-django-0.82.1/.coveragerc000066400000000000000000000005031516173410200210210ustar00rootroot00000000000000[run] source = strawberry_django_plus omit = .venv/**,examples/** [report] precision = 2 exclude_lines = pragma: nocover pragma:nocover pragma: no cover pragma:no cover if TYPE_CHECKING: @overload @abstractmethod @abc.abstractmethod assert_never omit = */migrations/* */tests/* strawberry-graphql-django-0.82.1/.github/000077500000000000000000000000001516173410200202425ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/.github/actionlint-matcher.json000066400000000000000000000010351516173410200247210ustar00rootroot00000000000000{ "problemMatcher": [ { "owner": "actionlint", "pattern": [ { "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$", "file": 1, "line": 2, "column": 3, "message": 4, "code": 5 } ] } ] } strawberry-graphql-django-0.82.1/.github/workflows/000077500000000000000000000000001516173410200222775ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/.github/workflows/actionlint.yml000066400000000000000000000007661516173410200251770ustar00rootroot00000000000000--- name: Action Lint # yamllint disable-line rule:truthy on: pull_request: {} push: branches: - main jobs: actionlint: name: Check runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Check workflow files run: | echo "::add-matcher::.github/actionlint-matcher.json" bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) ./actionlint -color shell: bash strawberry-graphql-django-0.82.1/.github/workflows/pr.yml000066400000000000000000000014551516173410200234500ustar00rootroot00000000000000name: 🚀 AutoPub PR Check on: pull_request_target: branches: [main] permissions: contents: read jobs: check-release: name: 📦 Check for release if: github.event.pull_request.user.type != 'Bot' runs-on: ubuntu-latest outputs: has_release: ${{ steps.check.outputs.has_release }} steps: - uses: actions/checkout@v4 with: ref: "refs/pull/${{ github.event.number }}/merge" sparse-checkout: | RELEASE.md pyproject.toml sparse-checkout-cone-mode: false - name: Check for release id: check uses: autopub/autopub-action@v1.0.1 with: command: check autopub-version: "1.0.0a58" github-token: ${{ secrets.BOT_TOKEN }} fail-on-missing: "true" strawberry-graphql-django-0.82.1/.github/workflows/release.yml000066400000000000000000000034311516173410200244430ustar00rootroot00000000000000name: 🚀 AutoPub Release concurrency: release on: push: branches: [main] permissions: contents: write id-token: write jobs: check-release: name: 📦 Check for release runs-on: ubuntu-latest outputs: has_release: ${{ steps.check.outputs['has-release'] }} steps: - uses: actions/checkout@v4 with: token: ${{ secrets.BOT_TOKEN }} - name: Check for release id: check uses: autopub/autopub-action@v1.0.1 with: command: check autopub-version: "1.0.0a58" github-token: ${{ secrets.BOT_TOKEN }} - name: Debug info run: | echo "GitHub ref: ${{ github.ref }}" echo "has_release: ${{ steps.check.outputs['has-release'] }}" publish: name: 📦 Publish to PyPI needs: check-release if: ${{ github.ref == 'refs/heads/main' && needs.check-release.outputs.has_release == 'true' }} runs-on: ubuntu-latest steps: - name: Debug info run: | echo "GitHub ref: ${{ github.ref }}" echo "has_release: ${{ needs.check-release.outputs.has_release }}" - uses: actions/checkout@v4 with: token: ${{ secrets.BOT_TOKEN }} - name: Prepare release uses: autopub/autopub-action@v1.0.1 with: command: prepare autopub-version: "1.0.0a58" - name: Build package uses: autopub/autopub-action@v1.0.1 with: command: build autopub-version: "1.0.0a58" - name: Publish to PyPI uses: autopub/autopub-action@v1.0.1 with: command: publish autopub-version: "1.0.0a58" github-token: ${{ secrets.BOT_TOKEN }} git-username: botberry git-email: bot@strawberry.rocks strawberry-graphql-django-0.82.1/.github/workflows/tests.yml000066400000000000000000000055051516173410200241710ustar00rootroot00000000000000--- name: Tests # yamllint disable-line rule:truthy on: push: branches: - main - v* pull_request: branches: - main - v* release: types: - released jobs: typing: name: Typing runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true - name: Install Deps run: uv sync - name: Check for pyright errors uses: jakebailey/pyright-action@v2 with: python-path: .venv/bin/python3 tests: runs-on: ubuntu-latest strategy: fail-fast: false matrix: django-version: - 4.2.* - 5.0.* - 5.1.* - 5.2.* - 6.0.* python-version: - '3.10' - '3.11' - '3.12' - '3.13' - '3.14' mode: - std - geos gql-core: - '3.2' - '3.3' exclude: # Django 4.2 only supports python 3.8-3.12 - django-version: 4.2.* python-version: '3.13' - django-version: 4.2.* python-version: '3.14' # Django 5.0 only supports python 3.10-3.12 - django-version: 5.0.* python-version: '3.13' - django-version: 5.0.* python-version: '3.14' # Django 5.1 only supports python 3.10-3.13 - django-version: 5.1.* python-version: '3.14' # Django 6.0 only supports python 3.12+ - django-version: 6.0.* python-version: '3.10' - django-version: 6.0.* python-version: '3.11' steps: - name: Checkout uses: actions/checkout@v4 - name: Install OS Dependencies if: ${{ matrix.mode == 'geos' }} run: | sudo apt-get update sudo apt-get install -y binutils gdal-bin libproj-dev libsqlite3-mod-spatialite - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true python-version: ${{ matrix.python-version }} - name: Install Deps run: uv sync - name: Install Django ${{ matrix.django-version }} run: uv pip install "django==${{ matrix.django-version }}" - name: Install graphql-core ${{ matrix.gql-core }} run: | if [ "${{ matrix.gql-core }}" = "3.2" ]; then uv pip install "graphql-core>=3.2.0,<3.3.0" else uv pip install "graphql-core==3.3.0a12" fi - name: Test with pytest run: uv run --no-sync pytest -n auto --showlocals -vvv --cov-report=xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} strawberry-graphql-django-0.82.1/.gitignore000066400000000000000000000047021516173410200206750ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ .tmp_upload/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # intelejea .idea # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Direnv config .envrc # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # Pyenv version .python-version # MacOS files .DS_Store strawberry-graphql-django-0.82.1/.pre-commit-config.yaml000066400000000000000000000022711516173410200231650ustar00rootroot00000000000000--- repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer exclude: snapshots|CHANGELOG.md - id: check-added-large-files args: - --maxkb=1024 - id: check-docstring-first - id: check-merge-conflict - id: check-yaml exclude: mkdocs.yml - id: check-toml - id: check-json - id: check-xml - id: check-symlinks - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.7 hooks: - id: ruff-format - id: ruff args: - --fix - repo: https://github.com/patrick91/pre-commit-alex rev: aa5da9e54b92ab7284feddeaf52edf14b1690de3 hooks: - id: alex exclude: CHANGELOG.md - repo: https://github.com/pre-commit/mirrors-prettier rev: v4.0.0-alpha.8 hooks: - id: prettier files: ^docs/.*\.mdx?$ - repo: https://github.com/adamchainz/django-upgrade rev: 1.30.0 hooks: - id: django-upgrade args: - --target-version=4.2 - repo: https://github.com/ComPWA/taplo-pre-commit rev: v0.9.3 hooks: - id: taplo-format strawberry-graphql-django-0.82.1/AGENTS.md000066400000000000000000000140361516173410200202110ustar00rootroot00000000000000# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview `strawberry-graphql-django` — a Django integration for [Strawberry GraphQL](https://github.com/strawberry-graphql/strawberry). It maps Django models to GraphQL types, with automatic field resolution, query optimization, filtering, ordering, pagination, mutations, permissions, and Relay support. ## Commands ```bash # Install dependencies uv sync # Run full test suite uv run pytest # Run tests in parallel uv run pytest -n auto # Run a single test file uv run pytest tests/test_optimizer.py -x # Run a specific test uv run pytest tests/test_optimizer.py::test_function_name -x # Lint (ruff check + format check) uv run ruff check . uv run ruff format --check . # Auto-fix lint issues uv run ruff check . --fix uv run ruff format . # Type checking uv run pyright ``` ## Architecture ### Core Abstraction: Django Model → GraphQL Type The central pattern is `@strawberry_django.type(Model)`, which: 1. Introspects `Model._meta.fields` and resolves `strawberry.auto` annotations to concrete GraphQL types (`fields/types.py:resolve_model_field_type`) 2. Wraps every field as a `StrawberryDjangoField` with Django-aware resolution 3. Stores metadata in `StrawberryDjangoDefinition` on `cls.__strawberry_django_definition__` 4. Delegates to `strawberry.type()` internally for the actual GraphQL schema generation ### Field Resolution Chain `StrawberryDjangoField` (`fields/field.py`) inherits via MRO from mixins that apply in sequence: ``` StrawberryDjangoField └─ StrawberryDjangoPagination (pagination.py — injects pagination args, slices queryset) └─ StrawberryDjangoFieldOrdering (ordering.py — injects order args, applies order_by) └─ StrawberryDjangoFieldFilters (filters.py — injects filter args, applies filter()) └─ StrawberryDjangoFieldBase (fields/base.py — auto type mapping, django_getattr) └─ StrawberryField (upstream strawberry) ``` For list fields, the queryset flows: `get_queryset()` → filters → ordering → pagination → optimizer hints. ### Query Optimizer `DjangoOptimizerExtension` (`optimizer.py`) is a schema extension that intercepts query execution. It walks the GraphQL selection set and adds `only()`, `select_related()`, `prefetch_related()`, and `annotate()` to querysets based on which fields are selected. Optimization hints are accumulated in `OptimizerStore` instances attached to field definitions via `strawberry_django.field(prefetch_related=..., select_related=..., only=..., annotate=...)`. ### Mutations `mutations/fields.py` defines `DjangoCreateMutation`, `DjangoUpdateMutation`, `DjangoDeleteMutation` field classes. The actual ORM operations live in `mutations/resolvers.py` (create, update, delete helpers with `full_clean` support). Entry points are `strawberry_django.mutation()` and `strawberry_django.input_mutation()`. ### Key Modules | Module | Purpose | |---|---| | `type.py` | `@type`, `@input`, `@interface`, `@partial` decorators; `_process_type()` central logic | | `fields/field.py` | `StrawberryDjangoField`, `field()`, `connection()`, `node()`, `offset_paginated()` | | `fields/base.py` | `StrawberryDjangoFieldBase` — auto type resolution, `django_getattr` | | `fields/types.py` | Django field → GraphQL type mapping; `DjangoFileType`, input helpers | | `optimizer.py` | `DjangoOptimizerExtension`, `OptimizerStore` | | `filters.py` | `filter_type` decorator, `process_filters()`, `FilterLookup` | | `ordering.py` | `order_type` decorator, `process_order()`, `Ordering` enum | | `pagination.py` | `OffsetPaginated`, `OffsetPaginationInput`, window pagination | | `permissions.py` | `HasPerm`, `IsAuthenticated`, `DjangoPermissionExtension` | | `mutations/` | CRUD mutation fields + ORM resolvers | | `relay/` | `DjangoCursorConnection`, `DjangoListConnection`, node resolution | | `resolvers.py` | `django_resolver` decorator — sync/async bridging | | `settings.py` | `StrawberryDjangoSettings` TypedDict, configured via `settings.STRAWBERRY_DJANGO` | ### Public API `strawberry_django/__init__.py` re-exports the public surface. Users import as `import strawberry_django` or `from strawberry_django import ...`. ## Testing - **Framework:** pytest with pytest-django, pytest-asyncio (`asyncio_mode = "auto"`) - **Database:** in-memory SQLite (spatialite if GEOS available) - **Settings:** `tests/django_settings.py` (`DJANGO_SETTINGS_MODULE = "tests.django_settings"`) - **Models:** `tests/models.py` (Fruit, Color, User, Group, Tag, etc.) - **Test client:** `tests/utils.py:GraphQLTestClient` wraps Django's `Client`/`AsyncClient`; use `assert_num_queries(n)` for query count assertions - **Parametrized fixtures:** `gql_client` runs each test 4 ways: sync/async × optimizer on/off. The `schema` fixture runs with optimizer on and off. - **Snapshots:** some tests use pytest-snapshot for schema output assertions ## CI & Releases - **Matrix:** Django 4.2–6.0 × Python 3.10–3.14 × std/geos modes - **Type checking:** pyright runs as a separate CI job ## PR Checklist - [ ] Tests added for new features and bug fixes - [ ] All tests pass - [ ] Code is linted and formatted (ruff) - [ ] Type checks pass (pyright) - [ ] Documentation updated as needed - [ ] `RELEASE.md` added for releasable changes. This project uses [autopub](https://github.com/autopub/autopub). PRs with releasable changes must include a `RELEASE.md` file at the repo root (CI enforces this). On merge to `main`, autopub reads this file and auto-publishes to PyPI. The file format: ```markdown --- release type: patch --- Description of the changes, ideally with examples if adding a new feature. ``` Release type is one of `patch`, `minor`, or `major` per [semver](https://semver.org/). ## Code Style - Ruff for linting and formatting (`target-version = "py310"`, preview mode enabled) - No docstring requirements (`D1` ignored) - Pre-commit hooks: ruff, django-upgrade (target 4.2+), prettier (docs), taplo (TOML) - Pyright for type checking (`pythonVersion = "3.10"`) strawberry-graphql-django-0.82.1/CHANGELOG.md000066400000000000000000002505411516173410200205220ustar00rootroot00000000000000CHANGELOG ========= 0.82.0 - 2026-03-15 ------------------- Fix `FieldExtension` arguments being silently lost on `StrawberryDjangoField`. When a `FieldExtension` appended arguments to `field.arguments` in its `apply()` method, the arguments worked with `strawberry.field` but silently disappeared with `strawberry_django.field`. This was because the mixin chain (Pagination → Ordering → Filters → Base) created a new list on every `.arguments` access, so `.append()` mutated a temporary copy. Added a caching `arguments` property to `StrawberryDjangoField` so that the first access computes and caches the full arguments list, and subsequent accesses (including `.append()` from extensions) operate on the same cached list. This release was contributed by [@bellini666](https://github.com/bellini666) in [#892](https://github.com/strawberry-graphql/strawberry-django/pull/892) 0.81.0 - 2026-03-15 ------------------- Fix `StrFilterLookup` so it can be used without a type parameter (e.g., `name: StrFilterLookup | None`). Previously this raised `TypeError: "StrFilterLookup" is generic, but no type has been passed` at schema build time. This release was contributed by [@bellini666](https://github.com/bellini666) in [#891](https://github.com/strawberry-graphql/strawberry-django/pull/891) 0.80.0 - 2026-03-08 ------------------- Add support for graphql-core 3.3.x alongside existing 3.2.x support. The minimum supported version of strawberry-graphql has been increased to 0.310.1. When using the graphql-core 3.3.x series, the minimum supported version is 3.3.0a12. This release was contributed by [@bellini666](https://github.com/bellini666) in [#850](https://github.com/strawberry-graphql/strawberry-django/pull/850) 0.79.2 - 2026-03-08 ------------------- Fix docs example for `process_filters` custom filter method where `prefix` was missing a trailing `__`, causing Django `FieldError`. Also add a `UserWarning` in `process_filters()` when a non-empty prefix doesn't end with `__` to help users catch this mistake early. This release was contributed by [@Ckk3](https://github.com/Ckk3) in [#883](https://github.com/strawberry-graphql/strawberry-django/pull/883) 0.79.1 - 2026-03-04 ------------------- Fix FK `_id` fields (e.g. `color_id: auto`) in input types failing with `mutations.create()`. Previously, `prepare_create_update()` didn't recognize FK attnames, causing the value to be silently dropped and `full_clean()` to fail. Now attname fields are mapped and their raw PK values are passed through directly. This release was contributed by [@bellini666](https://github.com/bellini666) in [#880](https://github.com/strawberry-graphql/strawberry-django/pull/880) 0.79.0 - 2026-03-01 ------------------- Pass `Info` instead of `GraphQLResolveInfo` to callables provided in `prefetch_related` and `annotate` arguments of `strawberry_django.field`. This is technically a breaking change because the argument type passed to these callables has changed. However, `Info` acts as a proxy for `GraphQLResolveInfo` and is compatible with the utilities typically used within prefetch or annotate functions, such as `optimize`. This release was contributed by [@rcybulski1122012](https://github.com/rcybulski1122012) in [#872](https://github.com/strawberry-graphql/strawberry-django/pull/872) 0.78.0 - 2026-02-28 ------------------- Add `skip_queryset_filter` parameter to `filter_field()` for declaring virtual (non-filtering) fields on filter types. Fields marked with `skip_queryset_filter=True` appear in the GraphQL input type but are not applied as database filters. They are accessible via `self.` in custom filter methods, making them useful for passing parameters like thresholds or configuration values. ```python @strawberry_django.filter_type(models.Fruit) class FruitFilter: min_similarity: float | None = strawberry_django.filter_field( default=0.3, skip_queryset_filter=True ) @strawberry_django.filter_field def search( self, info: Info, queryset: QuerySet[models.Fruit], value: str, prefix: str ): if self.min_similarity is not None: queryset = queryset.annotate( similarity=TrigramSimilarity(f"{prefix}name", value) ).filter(similarity__gte=self.min_similarity) return queryset, Q() ``` This release was contributed by [@bellini666](https://github.com/bellini666) in [#876](https://github.com/strawberry-graphql/strawberry-django/pull/876) 0.77.0 - 2026-02-28 ------------------- Automatically inject FK fields into `.only()` on user-provided `Prefetch` querysets when the `only` optimization is enabled. This prevents N+1 queries caused by Django re-fetching the FK field needed to match prefetched rows back to parent objects. The optimizer now correctly resolves reverse relations by `related_name` and restricts FK injection to `ManyToOneRel`, `OneToOneRel`, and `GenericRelation`. This release was contributed by [@bellini666](https://github.com/bellini666) in [#874](https://github.com/strawberry-graphql/strawberry-django/pull/874) 0.76.2 - 2026-02-28 ------------------- Fix N+1 queries when using `optimize()` inside a `Prefetch` object with `.only()` optimization. The optimizer now correctly auto-adds the FK field needed by Django to match prefetched objects back to their parent. This release was contributed by [@bellini666](https://github.com/bellini666) in [#873](https://github.com/strawberry-graphql/strawberry-django/pull/873) 0.76.1 - 2026-02-28 ------------------- Fix optimizer skipping optimization entirely for aliased fields. When a GraphQL query uses aliases for the same field (e.g., `a: milestones { id }` and `b: milestones { id }`), the optimizer now merges them into a single prefetch instead of skipping optimization, preventing N+1 queries. Aliases with different arguments (e.g., `a: issues(filters: {search: "Foo"})` and `b: issues(filters: {search: "Bar"})`) are still skipped, since a single prefetch cannot satisfy both filter sets and optimizing one would produce wrong results for the other. This release was contributed by [@bellini666](https://github.com/bellini666) in [#871](https://github.com/strawberry-graphql/strawberry-django/pull/871) 0.76.0 - 2026-02-28 ------------------- Add native federation support via `strawberry_django.federation` module. New decorators that combine `strawberry_django` functionality with Apollo Federation: - `strawberry_django.federation.type` - Federation-aware Django type with auto-generated `resolve_reference` - `strawberry_django.federation.interface` - Federation-aware Django interface - `strawberry_django.federation.field` - Federation-aware Django field with directives like `@external`, `@requires`, `@provides` Example usage: ```python import strawberry import strawberry_django from strawberry.federation import Schema @strawberry_django.federation.type(models.Product, keys=["upc"]) class Product: upc: strawberry.auto name: strawberry.auto price: strawberry.auto # resolve_reference is automatically generated! schema = Schema(query=Query) ``` The auto-generated `resolve_reference` methods support composite keys and multiple keys, and integrate with the query optimizer. **Note:** This release requires `strawberry-graphql>=0.303.0`. 0.75.3 - 2026-02-26 ------------------- Add support for strawberry-graphql 0.307.x. Also, the deprecated `asserts_errors` parameter has been removed from test client `query()` methods. Use `assert_no_errors` instead. This release was contributed by [@bellini666](https://github.com/bellini666) in [#870](https://github.com/strawberry-graphql/strawberry-django/pull/870) Additional contributors: [@Copilot](https://github.com/Copilot) 0.75.2 - 2026-02-18 ------------------- Fixes compatibility with `strawberry-graphql>=0.296.0` by ensuring proper `Info` type resolution. `Info` is now imported at runtime and resolver arguments include explicit type annotations. This aligns with the updated behavior where parameter injection is strictly **type-hint based** rather than name-based. Before, resolvers relying on implicit name-based injection could fail under newer Strawberry versions. After this change, resolvers work correctly with the stricter type-based injection system introduced in newer releases. This release was contributed by [@daudln](https://github.com/daudln) in [#866](https://github.com/strawberry-graphql/strawberry-django/pull/866) Additional contributors: [@pre-commit-ci[bot]](https://github.com/pre-commit-ci[bot]) 0.75.1 - 2026-02-15 ------------------- Fix `DuplicatedTypeName` errors when using `FilterLookup[str]` by: - Exporting `StrFilterLookup` from the top-level `strawberry_django` module - Adding a deprecation warning when using `FilterLookup[str]` or `FilterLookup[uuid.UUID]` - Updating documentation to recommend using specific lookup types Users should migrate from: ```python from strawberry_django import FilterLookup @strawberry_django.filter_type(models.Fruit) class FruitFilter: name: FilterLookup[str] | None ``` To: ```python from strawberry_django import StrFilterLookup @strawberry_django.filter_type(models.Fruit) class FruitFilter: name: StrFilterLookup | None ``` This release was contributed by [@bellini666](https://github.com/bellini666) in [#851](https://github.com/strawberry-graphql/strawberry-django/pull/851) 0.75.0 - 2026-01-27 ------------------- Adds support for Django-style relationship traversal in `strawberry_django.field(field_name=...)` using `LOOKUP_SEP` (`__`). You can now flatten related objects or scalar fields without custom resolvers. Examples: ```python @strawberry_django.type(User) class UserType: role: RoleType | None = strawberry_django.field( field_name="assigned_role__role", ) role_name: str | None = strawberry_django.field( field_name="assigned_role__role__name", ) ``` The traversal returns `None` if an intermediate relationship is `None`. Documentation and tests cover the new behavior, including optimizer query counts. This release was contributed by [@bellini666](https://github.com/bellini666) in [#852](https://github.com/strawberry-graphql/strawberry-django/pull/852) 0.74.2 - 2026-01-27 ------------------- Fix offset pagination extensions so they receive pagination, order, and filter arguments consistently with connection fields. This allows extensions to inspect filters for permission/validation while keeping resolvers tolerant of missing params. 0.74.1 - 2026-01-18 ------------------- Pagination `pageInfo.limit` now returns the actual limit applied (after defaults and max caps), not the raw request value. For example, with `PAGINATION_DEFAULT_LIMIT=20`, `PAGINATION_MAX_LIMIT=50`: ```graphql { fruits(pagination: { limit: null }) { pageInfo { limit } } } ``` Before: ```json { "data": { "fruits": { "pageInfo": { "limit": null } } } } ``` After: ```json { "data": { "fruits": { "pageInfo": { "limit": 20 } } } } ``` Also fixes `limit: null` to use `PAGINATION_DEFAULT_LIMIT` instead of `PAGINATION_MAX_LIMIT`. This release was contributed by [@bellini666](https://github.com/bellini666) in [#848](https://github.com/strawberry-graphql/strawberry-django/pull/848) 0.74.0 - 2026-01-17 ------------------- Add configurable `PAGINATION_MAX_LIMIT` setting to cap pagination requests, preventing clients from requesting unlimited data via `limit: null` or excessive limits. This addresses security and performance concerns by allowing projects to enforce a maximum number of records that can be requested through pagination. **Configuration:** ```python STRAWBERRY_DJANGO = { "PAGINATION_MAX_LIMIT": 1000, # Cap all requests to 1000 records } ``` When set, any client request with `limit: null`, negative limits, or limits exceeding the configured maximum will be capped to `PAGINATION_MAX_LIMIT`. Defaults to `None` (unlimited) for backward compatibility, though setting a limit is recommended for production environments. Works with both offset-based and window-based pagination. This release was contributed by [@bellini666](https://github.com/bellini666) in [#847](https://github.com/strawberry-graphql/strawberry-django/pull/847) 0.73.1 - 2026-01-09 ------------------- This release fixes a bug, which caused nested prefetch_related hints to get incorrectly merged in certain cases. This release was contributed by [@diesieben07](https://github.com/diesieben07) in [#839](https://github.com/strawberry-graphql/strawberry-django/pull/839) 0.73.0 - 2026-01-04 ------------------- Nothing changed, testing the new release process using `autopub`. 0.72.2 - 2026-01-04 ------------------- Nothing changed, testing the new release process using `autopub`. This release was contributed by [@bellini666](https://github.com/bellini666) in [#837](https://github.com/strawberry-graphql/strawberry-django/pull/837) 0.72.0 - 2025-12-28 ------------------- ## What's Changed * feat: use the new type-friendly way to define scalars from Strawberry by [@bellini666](https://github.com/bellini666) in [#832](https://github.com/strawberry-graphql/strawberry-django/pull/832) 0.71.0 - 2025-12-26 ------------------- ## What's Changed * feat: Add string-based lookups for UUID fields by [@Akay7](https://github.com/Akay7) in [#829](https://github.com/strawberry-graphql/strawberry-django/pull/829) * feat: make messages if there's assert_no_errors more verbose by [@Akay7](https://github.com/Akay7) in [#828](https://github.com/strawberry-graphql/strawberry-django/pull/828) * refactor: replace deprecated _enum_definition with __strawberry_definition__ (https://github.com/strawberry-graphql/strawberry-django/commit/9acb4b25aa5cae25243ac48c4f1c7287db1216cc) * fix(filters): use StrawberryField for DjangoModelFilterInput to respect python_name (https://github.com/strawberry-graphql/strawberry-django/commit/a3d9f14b8be14ebe1d2eebaa2daeb4680016e6e7) 0.70.1 - 2025-12-08 ------------------- ## What's Changed * fix(input): use None as default for Maybe fields instead of UNSET by [@bellini666](https://github.com/bellini666) in [#824](https://github.com/strawberry-graphql/strawberry-django/pull/824) 0.70.0 - 2025-12-06 ------------------- ## What's Changed * feat: add support for strawberry.Maybe type in mutations and filter processing by [@deepak-singh](https://github.com/deepak-singh) in [#805](https://github.com/strawberry-graphql/strawberry-django/pull/805) 0.69.0 - 2025-12-06 ------------------- ## What's changed * feat: use prefetch_related for FK with nested annotations (https://github.com/strawberry-graphql/strawberry-django/commit/a6b3f85f80064093137602d3bd79c8a525fbe9ca) 0.68.0 - 2025-12-03 ------------------- ## What's Changed * feat: declare support for django 6.0 by [@bellini666](https://github.com/bellini666) in [#821](https://github.com/strawberry-graphql/strawberry-django/pull/821) * docs: add comprehensive guides for production usage by [@bellini666](https://github.com/bellini666) in [#810](https://github.com/strawberry-graphql/strawberry-django/pull/810) * chore(examples): modernize examples with modular apps and current best practices by [@bellini666](https://github.com/bellini666) in [#811](https://github.com/strawberry-graphql/strawberry-django/pull/811) * docs: fix critical code example errors and typos by [@bellini666](https://github.com/bellini666) in [#819](https://github.com/strawberry-graphql/strawberry-django/pull/819) 0.67.2 - 2025-11-23 ------------------- ## What's changed * fix: fix wrong total_count when using distinct on m2m/o2m relationships (ff1f016fbd95ddaa7cd76cd712a58ad460ca87df) 0.67.1 - 2025-11-22 ------------------- ## What's Changed * fix: fix n+1 regression with fragments and custom connections by [@bellini666](https://github.com/bellini666) in [#809](https://github.com/strawberry-graphql/strawberry-django/pull/809) * fix: docs by [@wimble3](https://github.com/wimble3) in [#802](https://github.com/strawberry-graphql/strawberry-django/pull/802) 0.67.0 - 2025-10-18 ------------------- ## What's Changed Note: If you have a custom connection that defines a `resolve_connection` method, ensure that you have `**kwargs` in case you are not defining all possible keyword parameters. * feat: Forward custom kwargs to relay connection resolver by [@stygmate](https://github.com/stygmate) in [#801](https://github.com/strawberry-graphql/strawberry-django/pull/801) 0.66.2 - 2025-10-15 ------------------- ## What's changed * fix: fix one extra broken future annotations with the new | syntax (https://github.com/strawberry-graphql/strawberry-django/commit/cb9df84f64755074e37ddbad5f11ef1e0eadfd23) 0.66.1 - 2025-10-14 ------------------- ## What's Changed * fix: fix broken future annotations with the new | syntax by [@bellini666](https://github.com/bellini666) in [#800](https://github.com/strawberry-graphql/strawberry-django/pull/800) 0.66.0 - 2025-10-12 ------------------- ## What's Changed * feat: support for Python 3.14 and drop 3.9, which has reached EOL by [@bellini666](https://github.com/bellini666) in [#795](https://github.com/strawberry-graphql/strawberry-django/pull/795) * fix: fix debug toolbar integration to work with v6.0 by [@bellini666](https://github.com/bellini666) in [#796](https://github.com/strawberry-graphql/strawberry-django/pull/796) * fix: Fix typo in depecation message for order decorator by [@zvyn](https://github.com/zvyn) in [#785](https://github.com/strawberry-graphql/strawberry-django/pull/785) 0.65.1 - 2025-07-26 ------------------- ## What's changed * fix(field): prevent early ImportError on Field.type to break (https://github.com/strawberry-graphql/strawberry-django/commit/a353b4f376fce9fb3b4faf88a1f92bcad857ea49) 0.65.0 - 2025-07-20 ------------------- ## What's Changed * Relay pagination optimizations by [@Kitefiko](https://github.com/Kitefiko) in [#777](https://github.com/strawberry-graphql/strawberry-django/pull/777) 0.64.0 - 2025-07-19 ------------------- ## What's changed * feat: bump minimum Strawberry version to 0.276.2 0.63.0 - 2025-07-16 ------------------- ## What's Changed * fix: ensure dataclass's kwarg-only is specified to allow mixing fields (closes [#768](https://github.com/strawberry-graphql/strawberry-django/pull/768)) by [@axieum](https://github.com/axieum) in [#769](https://github.com/strawberry-graphql/strawberry-django/pull/769) * fix: handle lazy filters and ordering in strawberry_django.connection by [@rcybulski1122012](https://github.com/rcybulski1122012) in [#773](https://github.com/strawberry-graphql/strawberry-django/pull/773) * docs: Fix minor typo "recommented". by [@roelzkie15](https://github.com/roelzkie15) in [#775](https://github.com/strawberry-graphql/strawberry-django/pull/775) * test: pytest-xdist for parallel testing by [@roelzkie15](https://github.com/roelzkie15) in [#776](https://github.com/strawberry-graphql/strawberry-django/pull/776) 0.62.0 - 2025-06-16 ------------------- ## What's Changed * delete unused filters for creating mutations by [@star2000](https://github.com/star2000) in [#761](https://github.com/strawberry-graphql/strawberry-django/pull/761) * fix: fix filters using lazy annotations by [@bellini666](https://github.com/bellini666) in [#765](https://github.com/strawberry-graphql/strawberry-django/pull/765) * Add support for AND/OR filters to be lists by [@soby](https://github.com/soby) in [#762](https://github.com/strawberry-graphql/strawberry-django/pull/762) 0.61.0 - 2025-06-08 ------------------- ## What's Changed * feat(security): disallow mutations without filters by [@star2000](https://github.com/star2000) in [#755](https://github.com/strawberry-graphql/strawberry-django/pull/755) * fix(ordering): fix lazy types in ordering by [@bellini666](https://github.com/bellini666) in [#759](https://github.com/strawberry-graphql/strawberry-django/pull/759) 0.60.0 - 2025-05-24 ------------------- ## What's Changed * fix(optimizer): Pass accurate "info" parameter to PrefetchCallable and AnnotateCallable by [@diesieben07](https://github.com/diesieben07) in [#742](https://github.com/strawberry-graphql/strawberry-django/pull/742) * feat: wrap resolvers in `django_resolver(...)` to ensure appropriate async/sync context by [@axieum](https://github.com/axieum) in [#746](https://github.com/strawberry-graphql/strawberry-django/pull/746) 0.59.1 - 2025-05-06 ------------------- ## What's Changed * fix: Fix "ordering" for connections and offset_paginated by [@diesieben07](https://github.com/diesieben07) in [#741](https://github.com/strawberry-graphql/strawberry-django/pull/741) 0.59.0 - 2025-04-30 ------------------- ## Highlights This release brings some very interesting features, thanks to [@diesieben07](https://github.com/diesieben07) 🍓 - A new ordering type is now available, created using `⁠@strawberry_django.order_type`. This type uses a list for specifying ordering criteria instead of an object, making it easier and more flexible to apply multiple orderings, ensuring they will keep their order. Check the [ordering docs](https://strawberry.rocks/docs/django/guide/ordering) for more info on how to use it - Support for "true" cursor-based pagination in connections, using the new `DjangoCursorConnection` type. Check the [relay docs](https://strawberry.rocks/docs/django/guide/relay#cursor-based-connections) for more info on how to use it Also, to maintain consistency across the codebase, we have renamed several classes and functions. The old names are still available for import and use, making this a non-breaking change, but they are marked as deprecated and will eventually be removed in the future. The renames are as follows: - `ListConnectionWithTotalCount` got renamed to `DjangoListConnection` - `strawberry_django.filter` got renamed to `strawberry_django.filter_type` ## What's Changed * feat: Add new ordering method allowing ordering by multiple fields by [@diesieben07](https://github.com/diesieben07) in [#679](https://github.com/strawberry-graphql/strawberry-django/pull/679) * feat: Add support for "true" cursor based pagination in connections by [@diesieben07](https://github.com/diesieben07) in [#730](https://github.com/strawberry-graphql/strawberry-django/pull/730) * refactor: rename ListConnectionWithTotalCount and filter for consistency by [@bellini666](https://github.com/bellini666) in [#739](https://github.com/strawberry-graphql/strawberry-django/pull/739) * fix: Fix duplicate LOOKUP_SEP being used when field hints are used with polymorphic queries by [@diesieben07](https://github.com/diesieben07) in [#736](https://github.com/strawberry-graphql/strawberry-django/pull/736) * fix: Fix a minor typing issue by [@diesieben07](https://github.com/diesieben07) in [#738](https://github.com/strawberry-graphql/strawberry-django/pull/738) * docs: Fix resolvers.md by [@Hermotimos](https://github.com/Hermotimos) in [#735](https://github.com/strawberry-graphql/strawberry-django/pull/735) 0.58.0 - 2025-04-04 ------------------- ## What's Changed * feat: Official Django 5.2 support by [@bellini666](https://github.com/bellini666) in [#728](https://github.com/strawberry-graphql/strawberry-django/pull/728) * feat: Improve handling of polymorphism in the optimizer by [@diesieben07](https://github.com/diesieben07) in [#720](https://github.com/strawberry-graphql/strawberry-django/pull/720) * fix: Compatibility with Django Debug Toolbar 5.1+ by [@cpontvieux-systra](https://github.com/cpontvieux-systra) in [#725](https://github.com/strawberry-graphql/strawberry-django/pull/725) * fix: Ensure max_results is consistently applied for connections by [@Mapiarz](https://github.com/Mapiarz) in [#727](https://github.com/strawberry-graphql/strawberry-django/pull/727) * chore: Update mutations.py to expose the full_clean parameter by [@keithhackbarth](https://github.com/keithhackbarth) in [#701](https://github.com/strawberry-graphql/strawberry-django/pull/701) 0.57.1 - 2025-03-22 ------------------- ## What's Changed * Improve fallback primary key ordering unit tests by [@SupImDos](https://github.com/SupImDos) in [#716](https://github.com/strawberry-graphql/strawberry-django/pull/716) * Fix unnecessary window pagination being used by [@diesieben07](https://github.com/diesieben07) in [#719](https://github.com/strawberry-graphql/strawberry-django/pull/719) 0.57.0 - 2025-03-02 ------------------- ## What's Changed * Order unordered querysets by primary key by [@SupImDos](https://github.com/SupImDos) in [#715](https://github.com/strawberry-graphql/strawberry-django/pull/715) 0.56.0 - 2025-02-16 ------------------- ## What's Changed * Add support for the the general `Geometry` type by [@shmoon-kr](https://github.com/shmoon-kr) in [#709](https://github.com/strawberry-graphql/strawberry-django/pull/709) 0.55.2 - 2025-02-12 ------------------- ## What's Changed * Move `django-tree-queries` dependency to dev (it was wrongly added to main dependencies) (https://github.com/strawberry-graphql/strawberry-django/commit/fec457e589646dc4790f80c67286da714871a81c) 0.55.1 - 2025-01-26 ------------------- ## What's Changed * docs: fix inverted link tags by [@pbratkowski](https://github.com/pbratkowski) in [#692](https://github.com/strawberry-graphql/strawberry-django/pull/692) * docs: fix typo by [@ticosax](https://github.com/ticosax) in [#696](https://github.com/strawberry-graphql/strawberry-django/pull/696) * fix: omit TestClient from pytest's test discovery by [@pbratkowski](https://github.com/pbratkowski) in [#694](https://github.com/strawberry-graphql/strawberry-django/pull/694) * fix(optimizer): Avoid merging prefetches when using aliases by [@bellini666](https://github.com/bellini666) in [#698](https://github.com/strawberry-graphql/strawberry-django/pull/698) 0.55.0 - 2025-01-12 ------------------- ## What's Changed * feat: Allow setting max_results for connection fields by [@bellini666](https://github.com/bellini666) in [#689](https://github.com/strawberry-graphql/strawberry-django/pull/689) 0.54.0 - 2025-01-09 ------------------- ## What's Changed * feat: Bump strawberry minumum version to 0.257.0, which contains a fix for https://github.com/strawberry-graphql/strawberry/security/advisories/GHSA-5xh2-23cc-5jc6 by [@bellini666](https://github.com/bellini666) in [#688](https://github.com/strawberry-graphql/strawberry-django/pull/688) 0.53.3 - 2025-01-07 ------------------- ## What's changed * fix(mutations): Make sure we skip refetch when the optimizer is disabled (https://github.com/strawberry-graphql/strawberry-django/commit/06f62c74a37fc20d3122e7528add8e6c6119e591) 0.53.2 - 2025-01-07 ------------------- ## What's Changed * fix: skip empty choice value when generating enums from choices by [@fabien-michel](https://github.com/fabien-michel) in [#687](https://github.com/strawberry-graphql/strawberry-django/pull/687) * test: Replace django mptt with django tree queries for tests by [@kwongtn](https://github.com/kwongtn) in [#684](https://github.com/strawberry-graphql/strawberry-django/pull/684) 0.53.1 - 2025-01-03 ------------------- ## What's Changed * fix(optimizer): Fix nested pagination optimization for m2m relations by [@bellini666](https://github.com/bellini666) in [#681](https://github.com/strawberry-graphql/strawberry-django/pull/681) * Update test scope to include django 5.1 by [@kwongtn](https://github.com/kwongtn) in [#683](https://github.com/strawberry-graphql/strawberry-django/pull/683) 0.53.0 - 2024-12-21 ------------------- ## What's Changed * Support multi-level nested create/update with model `full_clean()` by [@philipstarkey](https://github.com/philipstarkey) in [#659](https://github.com/strawberry-graphql/strawberry-django/pull/659) 0.52.1 - 2024-12-18 ------------------- ## What's Changed * fix(optimizer): Prevent issuing duplicated queries for certain uses of first() and get() by [@diesieben07](https://github.com/diesieben07) in [#675](https://github.com/strawberry-graphql/strawberry-django/pull/675) 0.52.0 - 2024-12-15 ------------------- ## What's Changed * fix(pagination)!: Use `PAGINATION_DEFAULT_LIMIT` when limit is not provided by [@bellini666](https://github.com/bellini666) in [#673](https://github.com/strawberry-graphql/strawberry-django/pull/673) * fix(mutations): Refetch instances to optimize the return value by [@bellini666](https://github.com/bellini666) in [#674](https://github.com/strawberry-graphql/strawberry-django/pull/674) 0.51.0 - 2024-12-08 ------------------- ## What's Changed * Fix Django permissions diagram syntax by [@sersorrel](https://github.com/sersorrel) in [#663](https://github.com/strawberry-graphql/strawberry-django/pull/663) * allow FullCleanOptions in full_clean arg annotation by [@g-as](https://github.com/g-as) in [#667](https://github.com/strawberry-graphql/strawberry-django/pull/667) * Forward metadata when processing django type by [@g-as](https://github.com/g-as) in [#666](https://github.com/strawberry-graphql/strawberry-django/pull/666) * Added missing unpacking of strawberry.LazyType to optimzer.py by [@NT-Timm](https://github.com/NT-Timm) in [#670](https://github.com/strawberry-graphql/strawberry-django/pull/670) * Improved language in mutations docs by [@KyeRussell](https://github.com/KyeRussell) in [#668](https://github.com/strawberry-graphql/strawberry-django/pull/668) * Batch Mutations for creating, updating, and deleting [#438](https://github.com/strawberry-graphql/strawberry-django/pull/438) by [@keithhackbarth](https://github.com/keithhackbarth) in [#653](https://github.com/strawberry-graphql/strawberry-django/pull/653) * docs: fix import typo by [@lozhkinandrei](https://github.com/lozhkinandrei) in [#661](https://github.com/strawberry-graphql/strawberry-django/pull/661) * docs: Fix incorrect import paths in faq.md by [@videvide](https://github.com/videvide) in [#669](https://github.com/strawberry-graphql/strawberry-django/pull/669) 0.50.0 - 2024-11-09 ------------------- ## What's Changed * feat: New Paginated generic to be used as a wrapped for paginated results by [@bellini666](https://github.com/bellini666) in [#642](https://github.com/strawberry-graphql/strawberry-django/pull/642) (learn how to use it [in the docs page](https://strawberry.rocks/docs/django/guide/pagination#offsetpaginated-generic)) * Update filtering caution in mutations.md by [@ldynia](https://github.com/ldynia) in [#648](https://github.com/strawberry-graphql/strawberry-django/pull/648) * update model_property path in the doc by [@alainburindi](https://github.com/alainburindi) in [#654](https://github.com/strawberry-graphql/strawberry-django/pull/654) 0.49.1 - 2024-10-19 ------------------- ## What's Changed * docs: Remove mention about having to enable subscriptions in the docs by [@bellini666](https://github.com/bellini666) in [#645](https://github.com/strawberry-graphql/strawberry-django/pull/645) * Add unit tests for partial input optional field behaviour in update mutations by [@SupImDos](https://github.com/SupImDos) in [#638](https://github.com/strawberry-graphql/strawberry-django/pull/638) * fix: Make sure that async fields always return Awaitables by [@bellini666](https://github.com/bellini666) in [#646](https://github.com/strawberry-graphql/strawberry-django/pull/646) 0.49.0 - 2024-10-17 ------------------- ## What's Changed * feat: Official support for Python 3.13 and drop support for Python 3.8 which has reached EOL by [@bellini666](https://github.com/bellini666) in [#643](https://github.com/strawberry-graphql/strawberry-django/pull/643) * Changed the recommended library for JWT Authentication in Django to strawberry-django-auth by [@pkrakesh](https://github.com/pkrakesh) in [#633](https://github.com/strawberry-graphql/strawberry-django/pull/633) 0.48.0 - 2024-09-24 ------------------- ## What's Changed * Change default Relay input m2m types from `ListInput[NodeInputPartial]` to `ListInput[NodeInput]` by [@SupImDos](https://github.com/SupImDos) in [#630](https://github.com/strawberry-graphql/strawberry-django/pull/630) * refactor: Remove guardian ObjectPermissionChecker monkey patch by [@bellini666](https://github.com/bellini666) in [#631](https://github.com/strawberry-graphql/strawberry-django/pull/631) 0.47.2 - 2024-09-04 ------------------- ## What's Changed * Fix calculation of `has_next_page` in `resolve_connection_from_cache` by [@SupImDos](https://github.com/SupImDos) in [#622](https://github.com/strawberry-graphql/strawberry-django/pull/622) * Update docs for main website by [@patrick91](https://github.com/patrick91) in [#605](https://github.com/strawberry-graphql/strawberry-django/pull/605) * docs: Update docs URLs to point to the new location by [@bellini666](https://github.com/bellini666) in [#606](https://github.com/strawberry-graphql/strawberry-django/pull/606) * docs: General doc improvements by [@bellini666](https://github.com/bellini666) in [#610](https://github.com/strawberry-graphql/strawberry-django/pull/610) 0.47.1 - 2024-07-24 ------------------- ## What's Changed * fix: Fix debug toolbar upgrade issue by [@bellini666](https://github.com/bellini666) in [#600](https://github.com/strawberry-graphql/strawberry-django/pull/600) * fix: Only set False to clear FileFields when updating an instance by [@bellini666](https://github.com/bellini666) in [#601](https://github.com/strawberry-graphql/strawberry-django/pull/601) 0.47.0 - 2024-07-18 ------------------- ## What's Changed * feat: Bump strawberry to [0.236.0](https://github.com/strawberry-graphql/strawberry/releases/tag/0.236.0) and refactor changed imports by [@bellini666](https://github.com/bellini666) in [#591](https://github.com/strawberry-graphql/strawberry-django/pull/591) 0.46.2 - 2024-07-14 ------------------- ## What's Changed * refactor(optimizer): Split optimizer code to make it cleaner and easier to understand/maintain by [@bellini666](https://github.com/bellini666) in [#575](https://github.com/strawberry-graphql/strawberry-django/pull/575) * fix(optimizer): Convert select_related into Prefetch when the type defines a custom get_queryset by [@bellini666](https://github.com/bellini666) in [#583](https://github.com/strawberry-graphql/strawberry-django/pull/583) * fix(optimizer): Avoid extra queries for prefetches with existing prefetch hints by [@bellini666](https://github.com/bellini666) in [#582](https://github.com/strawberry-graphql/strawberry-django/pull/582) * fix: Do not try to call an ordering object's `order` method if it is not a decorated method by [@bellini666](https://github.com/bellini666) in [#584](https://github.com/strawberry-graphql/strawberry-django/pull/584) * fix: Avoid pagination failures when filtering connection by last without before/after by [@bellini666](https://github.com/bellini666) in [#585](https://github.com/strawberry-graphql/strawberry-django/pull/585) 0.46.1 - 2024-06-30 ------------------- ## What's Changed * fix: Fix and test optimizer with polymorphic relay node by [@stygmate](https://github.com/stygmate) in [#570](https://github.com/strawberry-graphql/strawberry-django/pull/570) * fix: Fix nested pagination/filtering/ordering not working when "only optimization" is disabled by [@aprams](https://github.com/aprams) in [#569](https://github.com/strawberry-graphql/strawberry-django/pull/569) 0.46.0 - 2024-06-29 ------------------- ## What's Changed * feat: Add support for auto mapping of ArrayFields by [@bellini666](https://github.com/bellini666) in [#567](https://github.com/strawberry-graphql/strawberry-django/pull/567) * fix: Set files early on mutations to allow clean methods to validate them by [@bellini666](https://github.com/bellini666) in [#566](https://github.com/strawberry-graphql/strawberry-django/pull/566) * fix: Make sure the optimizer calls the type's `get_queryset` for nested lists/connections by [@bellini666](https://github.com/bellini666) in [#568](https://github.com/strawberry-graphql/strawberry-django/pull/568) 0.45.0 - 2024-06-27 ------------------- ## What's Changed * Generated fields type resolution by [@Mapiarz](https://github.com/Mapiarz) in [#565](https://github.com/strawberry-graphql/strawberry-django/pull/565) 0.44.2 - 2024-06-17 ------------------- ## What's Changed * docs: wrong typo on filter on fruitfilter by [@OdysseyJ](https://github.com/OdysseyJ) in [#555](https://github.com/strawberry-graphql/strawberry-django/pull/555) * docs: Remove officially unmaintained project by [@Eraldo](https://github.com/Eraldo) in [#557](https://github.com/strawberry-graphql/strawberry-django/pull/557) * test: Add some tests to ensure Interfaces can be properly optimized by [@bellini666](https://github.com/bellini666) in [#554](https://github.com/strawberry-graphql/strawberry-django/pull/554) * fix: Extract interface definition in optimizer to fix django-polymorphic by [@ManiacMaxo](https://github.com/ManiacMaxo) in [#556](https://github.com/strawberry-graphql/strawberry-django/pull/556) 0.44.1 - 2024-06-12 ------------------- ## What's Changed * fix: fix optimized nested connections failing to access totalCount by [@bellini666](https://github.com/bellini666) and [@Eraldo](https://github.com/Eraldo) in [#553](https://github.com/strawberry-graphql/strawberry-django/pull/553) 0.44.0 - 2024-06-10 ------------------- ## What's Changed * feat: Nested optimization for lists and connections by [@bellini666](https://github.com/bellini666) in [#540](https://github.com/strawberry-graphql/strawberry-django/pull/540) This releases finally enables the highly anticipated nested optimization for lists and connections 🚀 What does that mean? Remember that when trying to retrieve a relation list inside another type and also trying to filter/order/paginate, that would cause n+1 issues because it would force the prefetched list to be thrown away? Well, not anymore after this release! 😊 In case you find any issues with this, please let us know by registering an issue with as much information as possible on how to reproduce the issue. Note that even though this is enabled by default, nested optimizations can be disabled by passing `enabled_nested_relations_prefetch=False` when initializing the optimizer extensions. * Dropped support for Django versions earlier than 4.2 The nested optimization feature required features only available on Django 4.2+. To be able to implement it, and also considering that [django itself recommended dropping support for those versions](https://docs.djangoproject.com/en/5.0/releases/5.0/#third-party-library-support-for-older-version-of-django), from now on this lib requires Django 4.2+ 0.43.0 - 2024-06-07 ------------------- ## What's Changed * Added `export-schema` command to Docs by [@Ckk3](https://github.com/Ckk3) in [#546](https://github.com/strawberry-graphql/strawberry-django/pull/546) * fix: Fix specialized connection aliases missing filters/ordering by [@bellini666](https://github.com/bellini666) in [#547](https://github.com/strawberry-graphql/strawberry-django/pull/547) NOTE: Even though this only contains a bug fix, I decided to do a minor release because the fix is bumping the minimum required version of `strawberry-graphql` itself to 0.234.2. 0.42.0 - 2024-05-30 ------------------- ## What's Changed * refactor: Use graphql-core's collect_sub_fields instead of our own implementation by [@bellini666](https://github.com/bellini666) in [#537](https://github.com/strawberry-graphql/strawberry-django/pull/537) 0.41.1 - 2024-05-26 ------------------- ## What's changed * fix: Move Info out of the TYPE_CHECKING block to prevent a warning (https://github.com/strawberry-graphql/strawberry-django/commit/4e8c458b1a6c546af705e797d80edf48ca74d693) 0.41.0 - 2024-05-26 ------------------- ## What's Changed * docs: Fix typo by [@Eraldo](https://github.com/Eraldo) in [#531](https://github.com/strawberry-graphql/strawberry-django/pull/531) * feat: Add setting DEFAULT_PK_FIELD_NAME by [@noamsto](https://github.com/noamsto) in [#446](https://github.com/strawberry-graphql/strawberry-django/pull/446) * fix: Fix AttributeError when using optimizer and prefetch_related by [@jacobwegner](https://github.com/jacobwegner) in [#533](https://github.com/strawberry-graphql/strawberry-django/pull/533) 0.40.0 - 2024-05-11 ------------------- ## What's Changed * feat: Avoid calling Type.get_queryset method more than once by [@bellini666](https://github.com/bellini666) in (https://github.com/strawberry-graphql/strawberry-django/commit/690551374053760903d70c6d267e73a64c6ad282) * test(listconnectionwithtotalcount): check the number of SQL queries when only fetching totalCount by [@euriostigue](https://github.com/euriostigue) in [#525](https://github.com/strawberry-graphql/strawberry-django/pull/525) * fix(optimizer): handle existing select_related in querysets by [@taobojlen](https://github.com/taobojlen) in [#515](https://github.com/strawberry-graphql/strawberry-django/pull/515) 0.39.2 - 2024-04-25 ------------------- ## What's Changed * fix: Delete mutation should not throw error if no objects in filterset by [@keithhackbarth](https://github.com/keithhackbarth) in [#522](https://github.com/strawberry-graphql/strawberry-django/pull/522) 0.39.1 - 2024-04-21 ------------------- ## What's changed * fix: fix annotations inheritance override for python 3.8/3.9 0.39.0 - 2024-04-21 ------------------- ## What's changed * feat: support for strawberry 0.227.1+ 0.38.0 - 2024-04-20 ------------------- ## What's Changed * feat: Ability to use custom field_cls for connections and nodes ([#517](https://github.com/strawberry-graphql/strawberry-django/pull/517)) * Fix typos in filtering documentation by [@cdroege](https://github.com/cdroege) in [#520](https://github.com/strawberry-graphql/strawberry-django/pull/520) 0.37.1 - 2024-04-14 ------------------- ## What's Changed * Fixing Docs Typo by [@drewbeno1](https://github.com/drewbeno1) in [#513](https://github.com/strawberry-graphql/strawberry-django/pull/513) * fix: fix debug toolbar when used with apollo_sandbox ide ([#514](https://github.com/strawberry-graphql/strawberry-django/pull/514)) * fix: fix debug toolbar running on ASGI and Python 3.12 0.37.0 - 2024-04-01 ------------------- ## What's Changed * feat: filter_field optional value resolution by [@Kitefiko](https://github.com/Kitefiko) in [#510](https://github.com/strawberry-graphql/strawberry-django/pull/510) 0.36.0 - 2024-03-30 ------------------- ## What's Changed * feat: properly resolve `_id` fields to `ID` (https://github.com/strawberry-graphql/strawberry-django/issues/506) 0.35.1 - 2024-03-19 ------------------- ## What's Changed * fix: async with new filter API (assert queryset is wrong) by [@devkral](https://github.com/devkral) in [#504](https://github.com/strawberry-graphql/strawberry-django/pull/504) 0.35.0 - 2024-03-18 ------------------- ## 🚀 Highlights (contains **BREAKING CHANGES**) This release contains a major refactor of how filters and ordering works with this library (https://github.com/strawberry-graphql/strawberry-django/pull/478). Thank you very much for this excellent work [@Kitefiko](https://github.com/Kitefiko) 😊 Some distinctions between the new API and the old API: ### Filtering * The previously deprecated `NOT` filters with a leading `n` were removing, `NOT` is the only negation option from now on * New `DISTINCT: Boolean` option to call `.distinct()` in the resulting QuerySet: https://strawberry-graphql.github.io/strawberry-django/guide/filters/#and-or-not-distinct * Custom filters can be defined using a method with the `@strawberry_django.filter_field` decorator: https://strawberry-graphql.github.io/strawberry-django/guide/filters/#custom-filter-methods * The default filter method can be overriden also by using a `@strawberry_django.filter_field` decorator: https://strawberry-graphql.github.io/strawberry-django/guide/filters/#overriding-the-default-filter-method * Lookups have been separated into multiple types to make sure the API is not exposing an invalid lookup for a given attribute (e.g. trying to filter a `BooleanField` by `__range`): https://strawberry-graphql.github.io/strawberry-django/guide/filters/#generic-lookup-reference **_IMPORTANT NOTE_**: If you find any issues and/or can't migrate your codebase yet, the old behaviour can still be achieved by setting `USE_DEPRECATED_FILTERS=True` in your django settings: https://strawberry-graphql.github.io/strawberry-django/guide/filters/#legacy-filtering Also, make sure to [report any issues](https://github.com/strawberry-graphql/strawberry-django/issues/new/choose) you find with the new API. ### Ordering * It is now possible to define custom ordering methods: https://strawberry-graphql.github.io/strawberry-django/guide/ordering/#custom-order-methods * The `Ordering` enum have 4 more options: `ASC_NULLS_FIRST`, `ASC_NULLS_LAST`, `DESC_NULLS_FIRST` and `DESC_NULLS_LAST`: https://strawberry-graphql.github.io/strawberry-django/guide/ordering/#ordering * The default order method can now be overridden for the entire resolution: https://strawberry-graphql.github.io/strawberry-django/guide/ordering/#overriding-the-default-order-method There are no breaking changes in the new ordering API, but please [report any issues](https://github.com/strawberry-graphql/strawberry-django/issues/new/choose) you find when using it. 0.34.0 - 2024-03-16 ------------------- ## What's Changed * Fix `_perm_cache` processing by [@vecchp](https://github.com/vecchp) in [#498](https://github.com/strawberry-graphql/strawberry-django/pull/498) * feat: Add support for generated enums in mutation input by [@cngai](https://github.com/cngai) in [#497](https://github.com/strawberry-graphql/strawberry-django/pull/497) 0.33.0 - 2024-03-05 ------------------- ## What's Changed * chore: update and improve github workflows by [@bellini666](https://github.com/bellini666) in [#492](https://github.com/strawberry-graphql/strawberry-django/pull/492) * fix: use str() to trigger eventual django's gettext_lazy string by [@fabien-michel](https://github.com/fabien-michel) in [#493](https://github.com/strawberry-graphql/strawberry-django/pull/493) * Fix auto enum value allowed chars by [@fabien-michel](https://github.com/fabien-michel) in [#494](https://github.com/strawberry-graphql/strawberry-django/pull/494) 0.32.2 - 2024-02-27 ------------------- ## What's Changed * Add py.typed marker for mypy by [@pm-incyan](https://github.com/pm-incyan) in [#486](https://github.com/strawberry-graphql/strawberry-django/pull/486) * fix: OneToManyInput saves and runs validation on foreign key [#487](https://github.com/strawberry-graphql/strawberry-django/pull/487) by [@keithhackbarth](https://github.com/keithhackbarth) in [#490](https://github.com/strawberry-graphql/strawberry-django/pull/490) 0.32.0 - 2024-02-19 ------------------- ## What's Changed * Expose pagination api publicly by [@fireteam99](https://github.com/fireteam99) in [#476](https://github.com/strawberry-graphql/strawberry-django/pull/476) * Fix permissioned pagination by [@vecchp](https://github.com/vecchp) in [#480](https://github.com/strawberry-graphql/strawberry-django/pull/480) * feat: allow extensions to prevent results from being fetched by [@bellini666](https://github.com/bellini666) in [#481](https://github.com/strawberry-graphql/strawberry-django/pull/481) 0.31.0 - 2024-02-07 ------------------- ## What's Changed * chore: Rename all links to the new repository name by [@bellini666](https://github.com/bellini666) in [#477](https://github.com/strawberry-graphql/strawberry-django/pull/477) * fix: cache definitions in optimizer by [@yergom](https://github.com/yergom) in [#474](https://github.com/strawberry-graphql/strawberry-django/pull/474) 0.30.1 - 2024-02-05 ------------------- ## What's Changed * validate files at dummy-instance level by [@sdobbelaere](https://github.com/sdobbelaere) in [#469](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/469) 0.30.0 - 2024-01-27 ------------------- ## What's Changed * fix: fix files not being saved on create mutation by [@bellini666](https://github.com/bellini666) in [#464](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/464) * feat(optimizer): Do not defer select_related fields if no only was specified by [@bellini666](https://github.com/bellini666) in [#465](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/465) * fix: Return `null` on empty files/images by [@bellini666](https://github.com/bellini666) in [#466](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/466) 0.29.0 - 2024-01-23 ------------------- ## What's Changed * Documentation improvements by [@thclark](https://github.com/thclark) in [#456](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/456) * fix(docs): Add missing import to resolver snippet by [@lewisjared](https://github.com/lewisjared) in [#457](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/457) * Allow updates of nested fields by [@tokr-bit](https://github.com/tokr-bit) in [#449](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/449) 0.28.3 - 2023-12-23 ------------------- ## What's Changed * fix(docs): Standardising the use of strawberry_django throughout the documentation. by [@ArcD7](https://github.com/ArcD7) in [#440](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/440) * Fix code example on updating `field_type_map`. by [@alimony](https://github.com/alimony) in [#441](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/441) * fix: support for fields using async only extensions by [@bellini666](https://github.com/bellini666) in [#444](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/444) 0.28.2 - 2023-12-08 ------------------- ## What's Changed * fix: HasPerm on async fields, fix missing query in another test by [@devkral](https://github.com/devkral) in [#437](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/437) * fix(docs): resolvers.md strawberry_django import by [@hkfi](https://github.com/hkfi) in [#436](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/436) 0.28.1 - 2023-12-06 ------------------- ## What's Changed * fix: really push OR, AND and NOT to the end by [@devkral](https://github.com/devkral) in [#435](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/435) 0.28.0 - 2023-12-06 ------------------- ## What's changed * Official support for Django 5.0 0.27.0 - 2023-12-04 ------------------- ## What's Changed * Fix: ordering when dealing with camelCased field by [@he0119](https://github.com/he0119) in [#430](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/430) * Guarantee 'AND', 'OR', and 'NOT' filter fields get evaluated last by … by [@TWeidi](https://github.com/TWeidi) in [#424](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/424) 0.26.0 - 2023-11-29 ------------------- ## What's Changed * Login and CurrentUser queries yield broken responses by [@sdobbelaere](https://github.com/sdobbelaere) in [#421](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/421) 0.25.0 - 2023-11-18 ------------------- ## What's Changed * fix small errata by [@jalvarezz13](https://github.com/jalvarezz13) in [#419](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/419) * Refactor create method to ensure proxy-model compatibility by [@sdobbelaere](https://github.com/sdobbelaere) in [#394](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/394) 0.24.4 - 2023-11-17 ------------------- ## What's Changed * Fix typing issues by [@patrick91](https://github.com/patrick91) in [#418](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/418) 0.24.3 - 2023-11-15 ------------------- ## What's Changed * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#416](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/416) * perf: cache nested import of get_user_or_annonymous to improve performance by [@bellini666](https://github.com/bellini666) in [#417](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/417) 0.24.2 - 2023-11-13 ------------------- ## What's Changed * fix: make sure custom fields are kept during inheritance by [@bellini666](https://github.com/bellini666) in [#415](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/415) 0.24.1 - 2023-11-07 ------------------- ## What's Changed * fix: Use _RESOLVER_TYPE as the type for the resolver on field, so the… by [@guizesilva](https://github.com/guizesilva) in [#412](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/412) 0.24.0 - 2023-11-07 ------------------- ## What's Changed * feat: Enforce validation for updating nested relations by [@tokr-bit](https://github.com/tokr-bit) in [#405](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/405) * feat: support for strawberry 0.212.0+ by [@bellini666](https://github.com/bellini666) in [#410](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/410) 0.23.0 - 2023-11-05 ------------------- ## What's Changed * docs: Fix typos in optimization examples by [@sjdemartini](https://github.com/sjdemartini) in [#406](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/406) * docs: Fix typos in object-level Permissions documentation by [@sjdemartini](https://github.com/sjdemartini) in [#407](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/407) * fix: keep ordering sequence by [@bellini666](https://github.com/bellini666) in [#409](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/409) 0.22.0 - 2023-10-30 ------------------- ## What's Changed * Fixed Documentation issue [#390](https://github.com/strawberry-graphql/strawberry-django/pull/390): added explanation of the error and PYTHON_CONFIGURE_OPTS: a little bit verbose, but maybe will save someone time and possibly add contributors to the project by [@thepapermen](https://github.com/thepapermen) in [#392](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/392) * docs: Added strawberry-django-extras to community-projects.md by [@m4riok](https://github.com/m4riok) in [#395](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/395) * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#400](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/400) * chore: migrate from black to ruff-formatter by [@bellini666](https://github.com/bellini666) in [#403](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/403) * compatibility ASGI/websockets get_request, login and logout by [@sdobbelaere](https://github.com/sdobbelaere) in [#393](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/393) 0.21.0 - 2023-10-11 ------------------- ## What's Changed * chore: Update docs to include changes to partial behavior by [@whardeman](https://github.com/whardeman) in [#385](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/385) * Docs improvement Subscriptions by [@sdobbelaere](https://github.com/sdobbelaere) in [#376](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/376) * New Feature: Optional custom key_attr to that can be used instead of id (pk) in to access model in Django UD mutations (Issue [#348](https://github.com/strawberry-graphql/strawberry-django/pull/348)) by [@thepapermen](https://github.com/thepapermen) in [#387](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/387) 0.20.3 - 2023-10-09 ------------------- ## What's changed * fix: fix a regression when checking permissions for an async resolver 0.20.2 - 2023-10-09 ------------------- ## What's changed * fix: ensure permissions' resolve_for_user get safely resolved inside async contexts 0.20.1 - 2023-10-06 ------------------- ## What's Changed * FIX: DEBUG_TOOLBAR_CONFIG consideration by [@bpeterman](https://github.com/bpeterman) in [#384](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/384) 0.20.0 - 2023-10-02 ------------------- ## What's Changed * feat: support for python 3.12 by [@bellini666](https://github.com/bellini666) in [#359](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/359) 0.19.0 - 2023-10-01 ------------------- ## What's Changed * feat: deprecate nSomething in favor of using NOT by [@bellini666](https://github.com/bellini666) in [#381](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/381) * Fix: DjangoOptimizerExtension corrupts nested objects' fields' prefetch objects by [@aprams](https://github.com/aprams) in [#380](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/380) 0.18.0 - 2023-09-28 ------------------- ## What's Changed * Support annotate parameter in field to allow ORM annotations by [@fjsj](https://github.com/fjsj) in [#377](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/377) 0.17.4 - 2023-09-25 ------------------- ## What's Changed * Exclude id from model fields to avoid overriding the id: type by [@sdobbelaere](https://github.com/sdobbelaere) in [#373](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/373) 0.17.3 - 2023-09-21 ------------------- ## What's Changed * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#365](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/365) * Update relay.md to working example by [@sdobbelaere](https://github.com/sdobbelaere) in [#368](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/368) * Expose disable_optimization argument on by [@Mapiarz](https://github.com/Mapiarz) in [#370](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/370) 0.17.2 - 2023-09-15 ------------------- ## What's Changed * Support inList and nInList lookup in filters on enum by [@cpontvieux-systra](https://github.com/cpontvieux-systra) in [#363](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/363) 0.17.1 - 2023-09-12 ------------------- ## What's Changed * fix: Update related objects with unique_together by [@zvyn](https://github.com/zvyn) in [#362](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/362) 0.17.0 - 2023-09-11 ------------------- ## What's Changed * feat: Add ValidationError code to OperationMessage by [@zvyn](https://github.com/zvyn) in [#358](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/358) * Docs on Mutations: Fixed issue with relay.NodeInput not existing, imported NodeInput from strawberry_django instead by [@thepapermen](https://github.com/thepapermen) in [#353](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/353) * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#355](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/355) * docs: fix sample code on 'Serving the API' by [@miyashiiii](https://github.com/miyashiiii) in [#357](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/357) 0.16.1 - 2023-08-31 ------------------- ## What's Changed * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#332](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/332) * Fix typo in optimizer docs for `strawberry.django.type` annotation by [@fireteam99](https://github.com/fireteam99) in [#334](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/334) * Adds tip regarding automatic single query filter generation to docs by [@fireteam99](https://github.com/fireteam99) in [#341](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/341) * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#342](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/342) * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#350](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/350) * refactor: strawberry.union is deprecated, use `Annotated` instead by [@bellini666](https://github.com/bellini666) in [#347](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/347) * Unwrap django lazy objects in mutation resolvers by [@ryanprobus](https://github.com/ryanprobus) in [#338](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/338) 0.16.0 - 2023-08-02 ------------------- ## What's Changed * feat: support strawberry 0.199.0+ by [@bellini666](https://github.com/bellini666) in [#326](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/326) 0.15.0 - 2023-07-31 ------------------- ## What's changed * feat: drop python 3.7 support, which EOLed on June 2023, following strawberry's 0.198.0 release * refactor: make sure to not insert duplicate permission directives to the field 0.14.1 - 2023-07-29 ------------------- ## What's Changed * refactor: make sure to also call the type's get_queryset when retrieving nodes for connection or a list of nodes * Update mutations.md by [@baseplate-admin](https://github.com/baseplate-admin) in [#319](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/319) * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#321](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/321) 0.14.0 - 2023-07-19 ------------------- ## What's Changed * filters support 'NOT' 'AND' 'OR' by [@star2000](https://github.com/star2000) in [#313](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/313) * feat: make sure to run the type's `get_queryset` when one is defined on resolve_model_node ([#316](https://github.com/strawberry-graphql/strawberry-django/pull/316)) 0.13.1 - 2023-07-19 ------------------- ## What's Changed * Fix TypeError with IntegerChoices and Add Tests for Enum Conversion without django_choices_field by [@miyashiiii](https://github.com/miyashiiii) in [#314](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/314) 0.13.0 - 2023-07-17 ------------------- ## What's Changed * docs: change one occurence of select_related to prefetch_related by [@Wartijn](https://github.com/Wartijn) in [#306](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/306) * fix: fix an issue where non dataclass annotations where being injected as fields on input types by [@bellini666](https://github.com/bellini666) in [#310](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/310) * Add new keywords "fields" and "exclude" to type decorator for auto-population of Django model fields by [@coleshaw](https://github.com/coleshaw) in [#293](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/293) * fix: fix resolving optional fields based on reverse one-to-one relations by [@bellini666](https://github.com/bellini666) in [#309](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/309) * fix: default pagination/filters/order to UNSET for fields ([#257](https://github.com/strawberry-graphql/strawberry-django/pull/257)) 0.12.0 - 2023-07-13 ------------------- ## What's Changed * refactor!: use a setting to decide if we should map fields to relay types or not by [@bellini666](https://github.com/bellini666) in [#302](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/302) NOTE: If you are using relay integration in all your types, you probably will want to set `MAP_AUTO_ID_AS_GLOBAL_ID=True` in your [strawberry django settings](https://strawberry-graphql.github.io/strawberry-graphql-django/guide/settings/) to make sure `auto` gets mapped properly to `GlobalID` on types and filters. 0.11.0 - 2023-07-12 ------------------- ## What's Changed * feat: add command export schema by [@menegasse](https://github.com/menegasse) in [#299](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/299) * feat: expose `interface` on strawberry_django/__init__.py by [@bellini666](https://github.com/bellini666) in [#300](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/300) 0.10.7 - 2023-07-12 ------------------- ## What's Changed * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#291](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/291) * fix: pass **kwargs to the type's `get_queryset` when defined by [@bellini666](https://github.com/bellini666) in [#295](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/295) * Fix missing model docstring crash by [@Mapiarz](https://github.com/Mapiarz) in [#297](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/297) * docs: update absolute path to relative in markdown file by [@miyashiiii](https://github.com/miyashiiii) in [#296](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/296) 0.10.6 - 2023-07-10 ------------------- ## What's Changed * Fixed typo __dic__ by [@selvarajrajkanna](https://github.com/selvarajrajkanna) in [#290](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/290) 0.10.5 - 2023-07-08 ------------------- ## What's Changed * Handle Django GENERATE_ENUMS_FROM_CHOICES with strawberry.auto by [@pcraciunoiu](https://github.com/pcraciunoiu) in [#286](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/286) 0.10.4 - 2023-07-08 ------------------- ## What's Changed * docs: tweak links to work with non-root path for hosting by [@DavidLemayian](https://github.com/DavidLemayian) in [#283](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/283) * Typo fix in documentation by [@paltman](https://github.com/paltman) in [#285](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/285) * Remove usage of `concrete_of` by [@patrick91](https://github.com/patrick91) in [#287](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/287) 0.10.3 - 2023-07-06 ------------------- ## What's changed * fix: make sure field_name overriding is not ignored when querying data ([#282](https://github.com/strawberry-graphql/strawberry-django/pull/282)) * fix: the type's queryset doesn't receive **kwarg * fix: make sure the type's get_queryset gets called for resolved coroutines ([#281](https://github.com/strawberry-graphql/strawberry-django/pull/281)) * chore: expose missing input_mutation in __init__ file * docs: fix some documentation examples 0.10.2 - 2023-07-05 ------------------- ## What's Changed * fix: reset annotation cache to fix some inheritance issues when using `strawberry>=0.192.2` by [@bellini666](https://github.com/bellini666) in [#278](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/278) 0.10.1 - 2023-07-05 ------------------- ## What's Changed * fix: do not import anything from `strawberry.django` that is not in this lib by [@bellini666](https://github.com/bellini666) in [#277](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/277) 0.10.0 - 2023-07-05 ------------------- ## Highlights This release is a major milestone for strawberry-django. Here are some of its highlights: * The [strawberry-django-plus](https://github.com/blb-ventures/strawberry-django-plus) lib was finally [merged](https://github.com/strawberry-graphql/strawberry-graphql-django/issues/139) into this lib, meaning all the extra features it provides are available directly in here. strawberry-django-plus is being deprecated and the development of its features is going to continue here. Here is a quick summary of all the features ported from it: * The query optimizer extension * The relay integration (based on the new official relay support from strawberry) * Enum integration with [django-choices-field](https://github.com/bellini666/django-choices-field) and auto generation from fields with choices * Lots of improvements to mutations, allowing CUD mutations to handle nested creation/updating/etc * The permissioned resolvers, designed as field extensions now instead of the custom schema directives it used * All the API has been properly typed, meaning that type checkers should be able to properly validate calls to `strawberry_django.type(...)`/`strawberry_django.field(...)`/etc * The [docs](https://strawberry-graphql.github.io/strawberry-graphql-django/) have been updated with all the new features * A major performance improvement: Due to all the refactoring and improvements, some personal benchmarks show a performance improvement of around **10x** when comparing the `v0.9.5` and **8x** when comparing to `strawberry-django-plus` ## Changes * refactor!: overall revamp of the type/field code and typing improvements by [@bellini666](https://github.com/bellini666) in [#265](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/265) * feat: relay integration by [@bellini666](https://github.com/bellini666) in [#267](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/267) * feat: ModelProperty descriptor by [@bellini666](https://github.com/bellini666) in [#268](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/268) * feat: query optimizer extension by [@bellini666](https://github.com/bellini666) in [#271](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/271) * feat: enum integration by [@bellini666](https://github.com/bellini666) in [#270](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/270) * feat: improved mutations by [@bellini666](https://github.com/bellini666) in [#272](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/272) * feat: permissions extensions using the django's permissioning system by [@bellini666](https://github.com/bellini666) in [#273](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/273) * docs: document all new features from this lib and improve existing ones by [@bellini666](https://github.com/bellini666) in [#274](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/274) 0.9.5 - 2023-06-15 ------------------- ## What's Changed * add .DS_Store to gitignore by [@capital-G](https://github.com/capital-G) in [#248](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/248) * Add kwargs to the documentation about get_queryset by [@cdroege](https://github.com/cdroege) in [#250](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/250) * Update test matrix to include django 4.2 by [@kwongtn](https://github.com/kwongtn) in [#253](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/253) * chore: migrate from flake8/isort to ruff by [@bellini666](https://github.com/bellini666) in [#237](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/237) * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#259](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/259) * add strawberry.relay tests, fix compatibility with relay, fix other issues by [@devkral](https://github.com/devkral) in [#260](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/260) 0.9.4 - 2023-04-03 ------------------- ## What's changed * refactor: replace Extension by SchemaExtension as required by strawberry 0.160.0+ * fix: do not add filters to non list fields (thanks [@g-as](https://github.com/g-as) for reporting this regression) 0.9.3 - 2023-04-02 ------------------- ## What's Changed * Update test django version from 4.2a1 to 4.2b1 by [@kwongtn](https://github.com/kwongtn) in [#241](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/241) * feature: backporting django-debug-toolbar from strawberry-django-plus by [@frleb](https://github.com/frleb) in [#239](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/239) * refactor: do not insert `pk` arguments inside non root fields by [@bellini666](https://github.com/bellini666) in [#246](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/246) 0.9.2 - 2023-02-04 ------------------- ## What's changed * chore: do not limit django/strawberry upper bound versions 0.9.1 - 2023-02-03 ------------------- ## What's Changed * Add django 4.0,4.2 to tests & updated minor versions by [@kwongtn](https://github.com/kwongtn) in [#228](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/228) * Fix private field handling by [@devkral](https://github.com/devkral) in [#231](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/231) 0.9 - 2023-01-14 ------------------- ## What's Changed * fix(typo): Fix typo in table of contents by [@rennerocha](https://github.com/rennerocha) in [#216](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/216) * removed `django-filter` from pyproject.toml, added tests matrix by [@nrbnlulu](https://github.com/nrbnlulu) in [#219](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/219) * Add actionlint for GitHub Actions files by [@kwongtn](https://github.com/kwongtn) in [#221](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/221) * Fix django version matrix. by [@nrbnlulu](https://github.com/nrbnlulu) in [#222](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/222) * Geos fields query & mutation support by [@kwongtn](https://github.com/kwongtn) in [#213](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/213) * Started docs for query by [@ccsv](https://github.com/ccsv) in [#147](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/147) 0.8.2 - 2022-11-16 ------------------- ## What's Changed * make pk argument required when querying single object by [@stygmate](https://github.com/stygmate) in [#214](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/214) 0.8.1 - 2022-11-10 ------------------- ## What's Changed * fix: Fix resolver annotation resolution by [@bellini666](https://github.com/bellini666) in [#212](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/212) 0.8 - 2022-11-06 ------------------- ## What's Changed * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#203](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/203) * Change how we set the default annotation by [@patrick91](https://github.com/patrick91) in [#206](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/206) 0.7.1 - 2022-10-28 ------------------- ## What's Changed * fix: Prevent memory leaks when checking if the search method accepts an info keyword * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#198](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/198) * Updates documentation for `get_queryset` by [@fireteam99](https://github.com/fireteam99) in [#199](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/199) * Update pagination.md by [@fabien-michel](https://github.com/fabien-michel) in [#202](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/202) * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#201](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/201) 0.7 - 2022-10-16 ------------------- ## What's Changed * Pass info for generic filter type by [@kwongtn](https://github.com/kwongtn) in [#197](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/197) 0.6 - 2022-10-11 ------------------- ## What's Changed * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#192](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/192) * Implementing filter, order and pagination in `StrawberryDjangoField` super classes by [@ManiacMaxo](https://github.com/ManiacMaxo) in [#193](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/193) * Allow passing info in filters by [@kwongtn](https://github.com/kwongtn) in [#191](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/191) 0.5.4 - 2022-10-10 ------------------- ## What's Changed * import TypedDict from typing_extensions for Python 3.7 by [@whardeman](https://github.com/whardeman) in [#189](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/189) * Fix demo app by [@moritz89](https://github.com/moritz89) in [#190](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/190) * fix: get_queryset sends self in fields.py which it shouldnt by [@deshk04](https://github.com/deshk04) in [#188](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/188) 0.5.3 - 2022-10-01 ------------------- ## What's Changed * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#178](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/178) * Fix mutations and filtering for when using strawberry-graphql >=0.132.1 by [@jkimbo](https://github.com/jkimbo) in [#183](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/183) * Broaden is_strawberry_django_field to support custom field classes by [@benhowes](https://github.com/benhowes) in [#185](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/185) 0.5.2 - 2022-09-28 ------------------- ## What's Changed * Pin strawberry-graphql to <0.132.1 by [@jkimbo](https://github.com/jkimbo) in [#184](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/184) 0.5.1 - 2022-09-12 ------------------- ## What's changed * [fix: Only append order to the resolver if it is a list](https://github.com/strawberry-graphql/strawberry-graphql-django/commit/5d85c4b43842cb401506c86954a33e94251e53c8) 0.5 - 2022-09-10 ------------------- ## What's Changed * Update docs link in README by [@q0w](https://github.com/q0w) in [#154](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/154) * Documentation for overriding the field class by [@benhowes](https://github.com/benhowes) in [#158](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/158) * fix: Raise NotImplementedError on unknown Django fields by [@noelleleigh](https://github.com/noelleleigh) in [#161](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/161) * Add many type hints by [@noelleleigh](https://github.com/noelleleigh) in [#162](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/162) * adding installation with pip by [@sisocobacho](https://github.com/sisocobacho) in [#166](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/166) * feat: Use Django textual metadata in GraphQL by [@noelleleigh](https://github.com/noelleleigh) in [#160](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/160) * Update pagination.md by [@tanaydin](https://github.com/tanaydin) in [#170](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/170) * Fix field ordering inheritance by [@DanielHuisman](https://github.com/DanielHuisman) in [#176](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/176) * fix(doc): update mkdocs.yml to point to correct branch by [@DavidLemayian](https://github.com/DavidLemayian) in [#175](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/175) 0.4 - 2022-07-09 ------------------- ## What's Changed * Update docs language and formatting by [@augustebaum](https://github.com/augustebaum) in [#124](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/124) * feature: allow overriding field class by [@benhowes](https://github.com/benhowes) in [#135](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/135) * Site for docs by [@nrbnlulu](https://github.com/nrbnlulu) in [#140](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/140) * feat: link JSONField to strawberry.scalars.JSON by [@FlickerSoul](https://github.com/FlickerSoul) in [#144](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/144) * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#141](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/141) 0.3.1 - 2022-06-29 ------------------- ## What's Changed * docs: document how to use a custom filter logic by [@devkral](https://github.com/devkral) in [#116](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/116) * fixed various typos by [@g-as](https://github.com/g-as) in [#118](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/118) * Change order of inheritance for `StrawberryDjangoField` by [@hiporox](https://github.com/hiporox) in [#122](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/122) * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#123](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/123) * feat: allow Enums to work with FilterLookup by [@hiporox](https://github.com/hiporox) in [#126](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/126) * [pre-commit.ci] pre-commit autoupdate by [@pre-commit-ci](https://github.com/pre-commit-ci) in [#127](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/127) * fix: pass through more field attributes by [@benhowes](https://github.com/benhowes) in [#129](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/129) * fix: resolve `ManyToManyRel` and `ManyToOneRel` as non-null lists by [@FlickerSoul](https://github.com/FlickerSoul) in [#131](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/131) 0.3 - 2022-05-23 ------------------- ## What's Changed * Feature: Register mutation by [@NeoLight1010](https://github.com/NeoLight1010) in [#45](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/45) * Fix filtering in `get_queryset` of types with enabled pagination by [@illia-v](https://github.com/illia-v) in [#60](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/60) * Add permissions to django mutations by [@wellzenon](https://github.com/wellzenon) in [#53](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/53) * Fix a bug related to creating users with unhashed passwords by [@illia-v](https://github.com/illia-v) in [#62](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/62) * pre-commit config file and fixes by [@la4de](https://github.com/la4de) in [#68](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/68) * Clean deprecated API by [@la4de](https://github.com/la4de) in [#69](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/69) * updated the way event loop is detected in 'is_async' by [@g-as](https://github.com/g-as) in [#72](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/72) * Fix detecting `auto` annotations when postponed evaluation is used by [@illia-v](https://github.com/illia-v) in [#73](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/73) * Updated docs by [@ccsv](https://github.com/ccsv) in [#78](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/78) * Fix incompatibility with Strawberry >= 0.92.0 related to interfaces by [@illia-v](https://github.com/illia-v) in [#76](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/76) * Fixed issue with generating order args by [@jaydensmith](https://github.com/jaydensmith) in [#90](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/90) * Update .gitignore to the python standard by [@hiporox](https://github.com/hiporox) in [#97](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/97) * feat: add Enum support to filtering by [@hiporox](https://github.com/hiporox) in [#100](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/100) * build: update packages by [@hiporox](https://github.com/hiporox) in [#94](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/94) * Caching Extensions using Django Cache by [@hiporox](https://github.com/hiporox) in [#93](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/93) * docs: filled in some missing info in the docs by [@hiporox](https://github.com/hiporox) in [#98](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/98) * Fix ordering with custom filters by [@hiporox](https://github.com/hiporox) in [#108](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/108) * bugfix: ignore filters argument if it is an arbitary argument by [@devkral](https://github.com/devkral) in [#115](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/115) * Fixing Quick Start by [@akkim2](https://github.com/akkim2) in [#114](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/114) * Fix [#110](https://github.com/strawberry-graphql/strawberry-django/pull/110) - Add **kwargs passthrough on CUD mutations, enables "description" annotation from Strawberry. by [@JoeWHoward](https://github.com/JoeWHoward) in [#111](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/111) * Use auto from strawberry instead of define our own by [@bellini666](https://github.com/bellini666) in [#101](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/101) * Fix filtering cannot use relational reflection fields by [@star2000](https://github.com/star2000) in [#109](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/109) * refactor: Change the use of "is_unset" to "is UNSET" by [@bellini666](https://github.com/bellini666) in [#117](https://github.com/strawberry-graphql/strawberry-graphql-django/pull/117)strawberry-graphql-django-0.82.1/CLAUDE.md000066400000000000000000000002001516173410200201510ustar00rootroot00000000000000# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. @AGENTS.md strawberry-graphql-django-0.82.1/LICENSE000066400000000000000000000020571516173410200177130ustar00rootroot00000000000000MIT License Copyright (c) 2020 Lauri Hintsala Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. strawberry-graphql-django-0.82.1/Makefile000066400000000000000000000002761516173410200203470ustar00rootroot00000000000000.PHONY : install test test-dist lint all: install test install: uv sync test: uv run pytest test-dist: uv run pytest -n auto lint: uv run ruff check . uv run ruff format --check . strawberry-graphql-django-0.82.1/README.md000066400000000000000000000112661516173410200201670ustar00rootroot00000000000000# Strawberry GraphQL Django Integration [![CI](https://github.com/strawberry-graphql/strawberry-django/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/strawberry-graphql/strawberry-django/actions/workflows/tests.yml?query=branch%3Amain) [![Coverage](https://codecov.io/gh/strawberry-graphql/strawberry-django/branch/main/graph/badge.svg)](https://codecov.io/gh/strawberry-graphql/strawberry-django/tree/main) [![PyPI](https://img.shields.io/pypi/v/strawberry-graphql-django)](https://pypi.org/project/strawberry-graphql-django/) [![Downloads](https://pepy.tech/badge/strawberry-graphql-django)](https://pepy.tech/project/strawberry-graphql-django) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/strawberry-graphql-django) [**Documentation**](https://strawberry.rocks/docs/django) | [**Discord**](https://strawberry.rocks/discord) Strawberry GraphQL Django integration provides powerful tools to build GraphQL APIs with Django. Automatically generate GraphQL types, queries, mutations, and resolvers from your Django models with full type safety. ## Installation ```shell pip install strawberry-graphql-django ``` ## Features - 🍓 **Automatic Type Generation** - Generate GraphQL types from Django models with full type safety - 🔍 **Advanced Filtering** - Powerful filtering system with lookups (contains, exact, in, etc.) - 📄 **Pagination** - Built-in offset and cursor-based (Relay) pagination - 📊 **Ordering** - Sort results by any field with automatic ordering support - 🔐 **Authentication & Permissions** - Django auth integration with flexible permission system - ✨ **CRUD Mutations** - Auto-generated create, update, and delete mutations with validation - ⚡ **Query Optimizer** - Automatic `select_related` and `prefetch_related` to prevent N+1 queries - 🐍 **Django Integration** - Works with Django views (sync and async), forms, and validation - 🐛 **Debug Toolbar** - GraphiQL integration with Django Debug Toolbar for query inspection ## Quick Start ```python # models.py from django.db import models class Fruit(models.Model): name = models.CharField(max_length=20) color = models.ForeignKey("Color", on_delete=models.CASCADE, related_name="fruits") class Color(models.Model): name = models.CharField(max_length=20) ``` ```python # types.py import strawberry_django from strawberry import auto from . import models @strawberry_django.type(models.Fruit) class Fruit: id: auto name: auto color: "Color" @strawberry_django.type(models.Color) class Color: id: auto name: auto fruits: list[Fruit] ``` ```python # schema.py import strawberry import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from .types import Fruit @strawberry.type class Query: fruits: list[Fruit] = strawberry_django.field() schema = strawberry.Schema( query=Query, extensions=[DjangoOptimizerExtension], ) ``` ```python # urls.py from django.urls import path from strawberry.django.views import AsyncGraphQLView from .schema import schema urlpatterns = [ path("graphql/", AsyncGraphQLView.as_view(schema=schema)), ] ``` That's it! You now have a fully functional GraphQL API with: - Automatic type inference from Django models - Optimized database queries (no N+1 problems) - Interactive GraphiQL interface at `/graphql/` Visit http://localhost:8000/graphql/ and try this query: ```graphql query { fruits { id name color { name } } } ``` ## Next Steps Check out our comprehensive documentation: - 📚 [**Getting Started Guide**](https://strawberry.rocks/docs/django) - Complete tutorial with examples - 🎓 [**Example App**](./examples/ecommerce_app/) - Full-featured e-commerce application - 📖 [**Documentation**](https://strawberry.rocks/docs/django) - In-depth guides and API reference - 💬 [**Discord Community**](https://strawberry.rocks/discord) - Get help and share your projects ## Contributing We welcome contributions! Whether you're fixing bugs, adding features, or improving documentation, your help is appreciated 😊 **Quick Start:** ```shell git clone https://github.com/strawberry-graphql/strawberry-django cd strawberry-django pre-commit install ``` Then run tests with `make test` or `make test-dist` for parallel execution. ## Community - 💬 [**Discord**](https://strawberry.rocks/discord) - Join our community for help and discussions - 🐛 [**GitHub Issues**](https://github.com/strawberry-graphql/strawberry-django/issues) - Report bugs or request features - 💡 [**GitHub Discussions**](https://github.com/strawberry-graphql/strawberry-django/discussions) - Ask questions and share ideas ## License This project is licensed under the [MIT License](LICENSE). strawberry-graphql-django-0.82.1/RELEASE.md000066400000000000000000000014331516173410200203050ustar00rootroot00000000000000--- release type: patch --- Fix `FieldError` when using the optimizer with `django-polymorphic` models. The optimizer now uses the CamelCase model name for polymorphic optimization hints (e.g., `ArtProject___field` instead of `app_label__artproject___field`). This ensures that `django-polymorphic` correctly handles mismatched optimization hints during the realization of mixed querysets by raising an `AssertionError` (which it catches) instead of an unhandled `FieldError`. This change also avoids potential name collisions with lowercase reverse relations in multi-table inheritance. A `polymorphic` optional dependency extra has been added, which sets the lower limit version to `4.0.0`. Install with `pip install strawberry-graphql-django[polymorphic]` to pull in `django-polymorphic`. strawberry-graphql-django-0.82.1/docs/000077500000000000000000000000001516173410200176325ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/docs/README.md000066400000000000000000000030561516173410200211150ustar00rootroot00000000000000--- title: Strawberry Django docs --- # Strawberry Django docs ## Getting Started - [Quick Start](./index.md) ## Core Concepts - [Types](./guide/types.md) - [Fields](./guide/fields.md) - [Queries](./guide/queries.md) - [Mutations](./guide/mutations.md) - [Subscriptions](./guide/subscriptions.md) - [Views](./guide/views.md) ## Data Management - [Filters](./guide/filters.md) - [Ordering](./guide/ordering.md) - [Legacy Ordering](./guide/legacy-ordering.md) - [Pagination](./guide/pagination.md) - [Relay](./guide/relay.md) ## Advanced Features - [Query Optimizer](./guide/optimizer.md) - [Performance](./guide/performance.md) - [DataLoaders](./guide/dataloaders.md) - [Model Properties](./guide/model-properties.md) - [Nested Mutations](./guide/nested-mutations.md) - [Resolvers](./guide/resolvers.md) ## Security & Validation - [Permissions](./guide/permissions.md) - [Authentication](./guide/authentication.md) - [Validation](./guide/validation.md) - [Error Handling](./guide/error-handling.md) ## Development & Testing - [Unit Testing](./guide/unit-testing.md) - [Export Schema](./guide/export-schema.md) - [Settings](./guide/settings.md) - [Troubleshooting](./guide/troubleshooting.md) ## Integrations - [Federation](./integrations/federation.md) - [Channels](./integrations/channels.md) - [Choices Field](./integrations/choices-field.md) - [Debug Toolbar](./integrations/debug-toolbar.md) - [Django Guardian](./integrations/guardian.md) - [GeoDjango](./integrations/geodjango.md) ## Resources - [FAQ](./faq.md) - [Community Projects](./community-projects.md) strawberry-graphql-django-0.82.1/docs/community-projects.md000066400000000000000000000017721516173410200240360ustar00rootroot00000000000000--- title: Community Projects --- # Community Projects Those are some community maintained projects worth mentioning: | Project | Description | | :-------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------: | | [🐙 strawberry-django-auth](https://github.com/nrbnlulu/strawberry-django-auth) | Authentication System for Django using Strawberry. | | [🐙 strawberry-django-extras](https://github.com/m4riok/strawberry-django-extras) | JWT Authentication, Input validation and permissions, mutation hooks and deeply nested CUD mutations | If you want your integration to be listed here, send us a [Pull Request](https://github.com/strawberry-graphql/strawberry-django/pulls) strawberry-graphql-django-0.82.1/docs/faq.md000066400000000000000000000367051516173410200207360ustar00rootroot00000000000000--- title: Frequently Asked Questions --- # Frequently Asked Questions (FAQ) ## General Usage ### How to access Django request object in resolvers? The request object is accessible via the `get_request` method. ```python from strawberry_django.utils.requests import get_request from strawberry.types import Info def resolver(root, info: Info): request = get_request(info) # Access request properties user = request.user headers = request.headers ``` ### How to access the current user object in resolvers? The current user object is accessible via the `get_current_user` method. ```python from strawberry_django.auth.utils import get_current_user from strawberry.types import Info def resolver(root, info: Info): current_user = get_current_user(info) if current_user.is_authenticated: # Do something with authenticated user pass ``` ### Where can I find example projects? Check out the [examples directory](https://github.com/strawberry-graphql/strawberry-django/tree/main/examples) in the GitHub repository for complete Django project examples. ## IDE and Development ### Type checking errors with strawberry.auto If your type checker (PyLance, mypy) shows errors with `strawberry.auto`, it's because you're returning a Django model instance while the annotation expects a GraphQL type. Use type casts to inform the type checker that the return value is compatible: ```python from typing import cast @strawberry_django.mutation def create_fruit(self, name: str) -> Fruit: # Fruit is the GraphQL type fruit = models.Fruit.objects.create(name=name) # Returns Django model return cast( Fruit, fruit ) # Tell type checker: model is compatible with GraphQL type ``` This is only needed when the annotation is a GraphQL type but you're returning a Django model instance. The cast is purely for type checking and has no runtime effect. ## Queries and Optimization ### Should I use the Query Optimizer or DataLoaders? **Use the [Query Optimizer](guide/optimizer.md)** (recommended for most cases): - Automatic optimization - Works with Django ORM - Less code to maintain - Handles most N+1 scenarios ```python from strawberry_django.optimizer import DjangoOptimizerExtension schema = strawberry.Schema( query=Query, extensions=[DjangoOptimizerExtension], ) ``` **Use DataLoaders** when: - You need custom batching logic - Fetching from external APIs - The optimizer doesn't handle your use case - You need fine-grained caching control See [DataLoaders guide](./guide/dataloaders.md) for details. ### How do I avoid N+1 queries? Three main approaches: 1. **Enable the Query Optimizer** (easiest): ```python schema = strawberry.Schema( query=Query, extensions=[DjangoOptimizerExtension], ) ``` 2. **Add optimization hints** to custom fields: ```python @strawberry_django.field(select_related=["author"], prefetch_related=["tags"]) def custom_field(self, root) -> str: return f"{root.author.name}: {root.tags.count()}" ``` 3. **Use DataLoaders** for complex scenarios (see above). ### How do I filter on related model fields? Define nested filter types: ```python @strawberry_django.filter_type(models.Author) class AuthorFilter: name: auto @strawberry_django.filter_type(models.Book) class BookFilter: title: auto author: AuthorFilter | None ``` Query: ```graphql query { books(filters: { author: { name: "John" } }) { title author { name } } } ``` ### Can I use annotated/computed fields in filters and ordering? Yes, use custom filter/order methods: ```python from django.db.models import Count, Q, QuerySet @strawberry_django.filter_type(models.Author) class AuthorFilter: name: auto @strawberry_django.filter_field def book_count( self, queryset: QuerySet, value: int, prefix: str ) -> tuple[QuerySet, Q]: queryset = queryset.alias(_book_count=Count(f"{prefix}books")) return queryset, Q(**{f"{prefix}_book_count": value}) ``` ## Mutations ### How do I create nested objects in mutations? **Recommended**: Use the automatic mutation generators which handle nested relationships automatically: ```python import strawberry import strawberry_django from strawberry_django import mutations from . import models @strawberry_django.input(models.Author) class AuthorInput: name: auto books: auto # Automatically handles nested book creation @strawberry.type class Mutation: create_author: Author = mutations.create(AuthorInput) update_author: Author = mutations.update(AuthorInput) delete_author: Author = mutations.delete() ``` These mutations automatically: - Generate appropriate input types for nested relationships - Handle create, update, and delete operations on related objects - Validate data using Django's validation system See the [tests in the repository](https://github.com/strawberry-graphql/strawberry-django/tree/main/tests) for complete examples of automatic mutations with nested relationships. **Manual approach** (when you need custom logic): ```python from django.db import transaction @strawberry_django.input(models.Author) class AuthorInputWithBooks: name: auto email: auto books: list[BookInput] | None = None @strawberry_django.mutation(handle_django_errors=True) @transaction.atomic def create_author_with_books(self, data: AuthorInputWithBooks) -> Author: author = models.Author.objects.create(name=data.name, email=data.email) if data.books: for book_data in data.books: models.Book.objects.create( author=author, title=book_data.title, ) return models.Author.objects.get(pk=author.pk) ``` See [Nested Mutations guide](./guide/nested-mutations.md) for more details. ### How do I update many-to-many relationships? Use `ListInput` with `set`, `add`, or `remove` operations: ```python from strawberry_django import ListInput, NodeInput @strawberry_django.partial(models.Article) class ArticleInputPartial(NodeInput): title: auto tags: ListInput[strawberry.ID] | None = None @strawberry_django.mutation def update_article(self, data: ArticleInputPartial) -> Article: article = models.Article.objects.get(pk=data.id) if data.tags is not strawberry.UNSET and data.tags is not None: if data.tags.set is not None: article.tags.set(data.tags.set) if data.tags.add is not None: article.tags.add(*data.tags.add) if data.tags.remove is not None: article.tags.remove(*data.tags.remove) article.save() return article ``` ### Why don't related objects appear in my mutation response? Django caches related managers. Refresh or refetch the object: ```python @strawberry_django.mutation @transaction.atomic def create_with_relations(self, data: Input) -> Model: obj = models.Model.objects.create(...) # Create related objects... # ✅ Option 1: Refresh obj.refresh_from_db() # ✅ Option 2: Refetch (better for optimizer) return models.Model.objects.get(pk=obj.pk) ``` ### How do I handle validation errors properly? Use dict-style `ValidationError` for field-specific errors: ```python from django.core.exceptions import ValidationError @strawberry_django.mutation(handle_django_errors=True) def create_user(self, email: str, age: int) -> User: errors = {} if not email or "@" not in email: errors["email"] = "Invalid email address" if age < 18: errors["age"] = "Must be at least 18 years old" if errors: raise ValidationError(errors) return models.User.objects.create(email=email, age=age) ``` See [Error Handling guide](./guide/error-handling.md) for details. ## Permissions and Authentication ### How do I protect fields based on permissions? Use permission extensions: ```python from strawberry_django.permissions import IsAuthenticated, HasPerm @strawberry_django.type(models.Document) class Document: title: auto @strawberry_django.field(extensions=[IsAuthenticated()]) def content(self) -> str: return self.content @strawberry_django.field(extensions=[HasPerm("documents.view_secret")]) def secret_data(self) -> str: return self.secret ``` See [Permissions guide](./guide/permissions.md) for more options. ### Can I use Django's object-level permissions? Yes, with django-guardian: ```python from strawberry_django.permissions import HasRetvalPerm @strawberry_django.type(models.Document) class Document: @strawberry_django.field(extensions=[HasRetvalPerm("documents.view_document")]) def content(self) -> str: # Permission checked against this specific document instance return self.content ``` See [Guardian integration](./integrations/guardian.md). ## Types and Fields ### How do I use custom Django field types? Map them in the `field_type_map`: ```python from strawberry_django.fields.types import field_type_map from django.db import models import strawberry # For django-money from djmoney.models.fields import MoneyField MoneyScalar = strawberry.scalar(...) field_type_map.update({ MoneyField: MoneyScalar, models.SlugField: str, }) ``` ### How do I add computed fields to my types? Three options: 1. **Model property** (recommended): ```python from decimal import Decimal from strawberry_django.descriptors import model_property class Order(models.Model): price = models.DecimalField(...) quantity = models.IntegerField(...) @model_property(only=["price", "quantity"]) def total(self) -> Decimal: return self.price * self.quantity ``` 2. **Custom resolver**: ```python from decimal import Decimal @strawberry_django.type(models.Order) class Order: price: auto quantity: auto @strawberry_django.field(only=["price", "quantity"]) def total(self) -> Decimal: return self.price * self.quantity ``` 3. **Annotated field**: ```python from django.db.models import F @strawberry_django.type(models.Order) class Order: price: auto quantity: auto total: auto = strawberry_django.field( annotate={"total": F("price") * F("quantity")} ) ``` See [Model Properties guide](./guide/model-properties.md) for details. ### How do I work with Django's choices fields? Use [django-choices-field](./integrations/choices-field.md): ```python from django_choices_field import TextChoicesField class Status(models.TextChoices): ACTIVE = "active", "Active" INACTIVE = "inactive", "Inactive" class Company(models.Model): status = TextChoicesField(choices_enum=Status) ``` This automatically generates GraphQL enums. ## Relay and Global IDs ### How do I use Relay-style pagination? ```python import strawberry from strawberry import relay import strawberry_django @strawberry_django.type(models.Fruit) class Fruit(relay.Node): name: auto @strawberry.type class Query: fruits: relay.Connection[Fruit] = strawberry_django.connection() ``` See [Relay guide](./guide/relay.md). ### Should I use offset or cursor pagination? **Offset pagination** (default): - Straightforward to implement - Allows jumping to any page - Works well for small to medium datasets **Cursor pagination**: - Better performance for large datasets - Prevents missing/duplicate items during pagination - Required for Relay compliance Choose based on your use case and dataset size. ## Testing ### How do I test GraphQL mutations and queries? Use the test client: ```python from strawberry_django.test.client import TestClient def test_create_fruit(db): client = TestClient("/graphql") res = client.query(""" mutation { createFruit(data: { name: "Apple" }) { id name } } """) assert res.errors is None assert res.data["createFruit"]["name"] == "Apple" ``` For authenticated tests: ```python def test_authenticated_query(db): user = User.objects.create_user(username="test") client = TestClient("/graphql") with client.login(user): res = client.query("query { me { username } }") assert res.data["me"]["username"] == "test" ``` See [Unit Testing guide](./guide/unit-testing.md). ### How do I test async resolvers? Use `pytest-asyncio`: ```python import pytest @pytest.mark.django_db @pytest.mark.asyncio async def test_async_resolver(): result = await my_async_resolver() assert result is not None ``` ## Advanced Topics ### Can I use Federation with Strawberry Django? Yes, but it requires additional setup. Check the [Federation guide](https://strawberry.rocks/docs/guides/federation) in Strawberry's documentation. ### How do I handle file uploads? Use Strawberry's `Upload` scalar: ```python from strawberry.file_uploads import Upload @strawberry.type class Mutation: @strawberry_django.mutation def upload_file(self, file: Upload) -> bool: content = file.read() # Handle the file... return True ``` ## Common Errors ### "Object has no attribute 'refresh_from_db'" You're likely returning a non-model object. Ensure you're returning actual Django model instances: ```python # ❌ Returns dict return {"id": obj.id, "name": obj.name} # ✅ Returns model instance return obj ``` ### "Interface cannot be part of Union" This happens when using interfaces with `handle_django_errors=True`. Either: 1. Return concrete types: ```python update_project: WebProject | ExternalProject = mutations.update_project(...) ``` 2. Or set error handling to false: ```python update_project: Project = mutations.update_project(handle_django_errors=False) ``` ### "AppRegistryNotReady: Apps aren't loaded yet" This occurs when importing Django models before Django is fully initialized. In your `asgi.py`: ```python import os from django.core.asgi import get_asgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings") # Initialize Django FIRST django_asgi_app = get_asgi_application() # THEN import your schema (which imports models) from myproject.schema import schema ``` ### "Cannot query field on type" Common causes: 1. **Field not exposed**: Add the field to your type definition 2. **Wrong field name**: GraphQL uses camelCase by default (`firstName` not `first_name`) 3. **Type mismatch**: Ensure the field is defined on the correct type ### "Maximum recursion depth exceeded" Usually caused by circular type references. Use string annotations: ```python @strawberry_django.type(models.Author) class AuthorType: books: list["BookType"] # String annotation for forward reference @strawberry_django.type(models.Book) class BookType: author: "AuthorType" # String annotation for back reference ``` ### Subscriptions not working 1. Ensure you're using ASGI, not WSGI 2. Check that Daphne is installed and configured 3. Verify `ASGI_APPLICATION` is set in settings 4. Import schema after `get_asgi_application()` See [Subscriptions guide](./guide/subscriptions.md) for setup details. ### DataLoaders not working with sync views DataLoaders require async execution. They don't work with: - WSGI servers (use ASGI instead) - `runserver` without Daphne - Sync resolvers Use the Query Optimizer for sync contexts, or switch to ASGI. ## Getting More Help - **Documentation**: Check the [full guides](./guide/) - **Troubleshooting**: See [Troubleshooting guide](./guide/troubleshooting.md) - **GitHub Issues**: [Search existing issues](https://github.com/strawberry-graphql/strawberry-django/issues) - **Discussions**: [GitHub Discussions](https://github.com/strawberry-graphql/strawberry-django/discussions) - **Discord**: [Join the Strawberry Discord](https://strawberry.rocks/discord) strawberry-graphql-django-0.82.1/docs/guide/000077500000000000000000000000001516173410200207275ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/docs/guide/authentication.md000066400000000000000000000156241516173410200243000ustar00rootroot00000000000000--- title: Authentication --- # Authentication `strawberry_django` provides built-in mutations and queries for session-based authentication with Django's authentication system. > [!WARNING] > This solution is designed for web browsers that support cookies. It will not work for clients that can't store cookies (e.g., mobile apps). For those scenarios, use token-based authentication methods like JWT with [strawberry-django-auth](https://github.com/nrbnlulu/strawberry-django-auth). ## Quick Start ### Define Types ```python title="types.py" import strawberry_django from strawberry import auto from django.contrib.auth import get_user_model @strawberry_django.type(get_user_model()) class User: username: auto email: auto @strawberry_django.input(get_user_model()) class UserInput: username: auto password: auto email: auto # Optional: add other fields as needed ``` ### Define Schema ```python title="schema.py" import strawberry import strawberry_django from .types import User, UserInput @strawberry.type class Query: me: User = strawberry_django.auth.current_user() @strawberry.type class Mutation: login: User = strawberry_django.auth.login() logout = strawberry_django.auth.logout() register: User = strawberry_django.auth.register(UserInput) ``` ## Available Functions ### `current_user()` A field that returns the currently authenticated user. ```python me: User = strawberry_django.auth.current_user() ``` **Behavior:** - Returns the authenticated user object - Raises `ValidationError("User is not logged in.")` if the user is not authenticated **GraphQL Usage:** ```graphql query { me { username email } } ``` ### `login()` A mutation that authenticates a user with username and password. ```python login: User = strawberry_django.auth.login() ``` **Arguments (automatically generated):** - `username: String!` - The username - `password: String!` - The password **Behavior:** - Uses Django's `authenticate()` to verify credentials - Creates a session using Django's `login()` - Supports both WSGI and ASGI (including Django Channels) - Returns the authenticated user on success - Raises `ValidationError("Incorrect username/password")` on failure **GraphQL Usage:** ```graphql mutation { login(username: "myuser", password: "mypassword") { username email } } ``` ### `logout()` A mutation that logs out the current user. ```python logout = strawberry_django.auth.logout() ``` **Behavior:** - Ends the current session using Django's `logout()` - Supports both WSGI and ASGI (including Django Channels) - Returns `true` if a user was logged out, `false` if no user was logged in **GraphQL Usage:** ```graphql mutation { logout } ``` ### `register(input_type)` A mutation that creates a new user account. ```python register: User = strawberry_django.auth.register(UserInput) ``` **Arguments:** - `input_type` - A strawberry_django input type for user creation **Behavior:** - Validates the password using Django's `validate_password()` (checks against `AUTH_PASSWORD_VALIDATORS`) - Creates the user with a properly hashed password using `set_password()` - Returns the created user object - Raises validation errors if password doesn't meet requirements **GraphQL Usage:** ```graphql mutation { register( data: { username: "newuser" password: "securepassword123" email: "user@example.com" } ) { username email } } ``` ## Password Validation The `register` mutation automatically validates passwords against Django's `AUTH_PASSWORD_VALIDATORS`. Configure validators in your settings: ```python title="settings.py" AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": { "min_length": 8, }, }, { "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] ``` ## Using with Custom User Models The auth functions work with custom user models. Ensure your type and input reference the correct model: ```python title="types.py" from django.contrib.auth import get_user_model @strawberry_django.type(get_user_model()) class User: # Your custom user fields username: auto email: auto first_name: auto last_name: auto ``` ## Optional User Return Type You can make login return `None` on failure instead of raising an error: ```python @strawberry.type class Mutation: login: User | None = strawberry_django.auth.login() ``` This way, unsuccessful logins return `null` instead of a GraphQL error. ## Accessing User in Resolvers You can access the current user in any resolver: ```python from strawberry.types import Info @strawberry.type class Query: @strawberry.field def my_data(self, info: Info) -> str: user = info.context.request.user if not user.is_authenticated: raise PermissionError("Not authenticated") return f"Hello, {user.username}!" ``` Or use the utility function: ```python from strawberry_django.auth.utils import get_current_user @strawberry.field def my_data(self, info: Info) -> str: user = get_current_user(info) # ... ``` ## Django Channels Support The login and logout mutations automatically detect Django Channels and use the appropriate authentication methods: - For standard WSGI/ASGI: Uses `django.contrib.auth.login/logout` - For Channels WebSocket: Uses `channels.auth.login/logout` This allows authentication to work seamlessly with [subscriptions](./subscriptions.md). ## Session Configuration Ensure your Django session settings are properly configured: ```python title="settings.py" # Required middleware MIDDLEWARE = [ "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", # ... ] # Session settings SESSION_ENGINE = "django.contrib.sessions.backends.db" # Or cache, file, etc. SESSION_COOKIE_SECURE = True # For HTTPS SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = "Lax" # Or 'Strict' for more security ``` ## Error Handling Authentication errors are raised as `ValidationError`: ```python from django.core.exceptions import ValidationError # Login failure ValidationError("Incorrect username/password") # Not logged in (current_user) ValidationError("User is not logged in.") # Password validation failure (register) ValidationError("This password is too short...") ``` You can catch these in your frontend or use [error handling extensions](./error-handling.md). ## See Also - [Permissions](./permissions.md) - Protecting fields and operations - [Django Channels](../integrations/channels.md) - WebSocket authentication setup - [strawberry-django-auth](https://github.com/nrbnlulu/strawberry-django-auth) - JWT authentication strawberry-graphql-django-0.82.1/docs/guide/dataloaders.md000066400000000000000000000072411516173410200235400ustar00rootroot00000000000000--- title: DataLoaders --- # DataLoaders DataLoaders help solve the N+1 query problem by batching and caching database queries. For basic information about DataLoaders, see the [Strawberry DataLoaders documentation](https://strawberry.rocks/docs/guides/dataloaders). > [!TIP] > Strawberry Django's [Query Optimizer](./optimizer.md) handles most optimization scenarios automatically. Use DataLoaders when you need custom batching logic or are working with external data sources. ## Using DataLoaders with Django ### Basic Example Here's a basic DataLoader for fetching Django models: ```python title="dataloaders.py" from strawberry.dataloader import DataLoader from asgiref.sync import sync_to_async from . import models async def load_authors(keys: list[int]) -> list[models.Author | None]: """Batch load authors by their IDs.""" # Build map from async queryset iteration author_map = { author.id: author async for author in models.Author.objects.filter(id__in=keys) } return [author_map.get(key) for key in keys] ``` ### Sharing DataLoaders Across a Request **Important**: DataLoaders must be instantiated once per request and shared across all resolvers to enable batching. Store them in the GraphQL context: ```python title="context.py" from strawberry.dataloader import DataLoader from .dataloaders import load_authors from . import models class GraphQLContext: def __init__(self, request): self.request = request self._author_loader: DataLoader[int, models.Author | None] | None = None @property def author_loader(self) -> DataLoader[int, models.Author | None]: if self._author_loader is None: self._author_loader = DataLoader(load_fn=load_authors) return self._author_loader def get_context(request): return GraphQLContext(request) ``` ```python title="urls.py" from django.urls import path from strawberry.django.views import AsyncGraphQLView from .schema import schema from .context import get_context urlpatterns = [ path( "graphql/", AsyncGraphQLView.as_view(schema=schema, context_getter=get_context) ), ] ``` ### Using the DataLoader ```python title="types.py" import strawberry_django from strawberry import field from strawberry.types import Info @strawberry_django.type(models.Book) class Book: id: strawberry.auto title: strawberry.auto author_id: strawberry.Private[int] @field async def author(self, info: Info) -> "Author": return await info.context.author_loader.load(self.author_id) ``` ## Common Patterns ### One-to-Many Relationships ```python title="dataloaders.py" from collections import defaultdict async def load_books_by_author(author_ids: list[int]) -> list[list[models.Book]]: """Load all books for multiple authors.""" books_by_author = defaultdict(list) async for book in models.Book.objects.filter(author_id__in=author_ids): books_by_author[book.author_id].append(book) return [books_by_author[author_id] for author_id in author_ids] ``` ### Many-to-Many Relationships ```python title="dataloaders.py" async def load_tags(article_ids: list[int]) -> list[list[models.Tag]]: """Load tags for multiple articles.""" tags_by_article = { article.id: await sync_to_async(list)(article.tags.all()) async for article in models.Article.objects.filter( id__in=article_ids ).prefetch_related("tags") } return [tags_by_article.get(article_id, []) for article_id in article_ids] ``` ## See Also - [Strawberry DataLoaders Docs](https://strawberry.rocks/docs/guides/dataloaders) - Official Strawberry documentation - [Query Optimizer](./optimizer.md) - Automatic query optimization strawberry-graphql-django-0.82.1/docs/guide/error-handling.md000066400000000000000000000301121516173410200241610ustar00rootroot00000000000000--- title: Error Handling --- # Error Handling Proper error handling is crucial for building robust GraphQL APIs. Strawberry Django provides several mechanisms to handle errors gracefully and return meaningful information to clients. ## Django Error Handling in Mutations Strawberry Django can automatically handle common Django errors and convert them into structured GraphQL responses. This feature is available for mutations using the `handle_django_errors` parameter. ### Basic Usage ```python title="mutations.py" import strawberry import strawberry_django from django.core.exceptions import ValidationError from . import models @strawberry.type class Mutation: @strawberry_django.mutation(handle_django_errors=True) def create_product(self, name: str, price: float) -> Product: if price < 0: raise ValidationError("Price must be positive") product = models.Product.objects.create(name=name, price=price) return product ``` ### Handled Exception Types When `handle_django_errors=True`, the following Django exceptions are automatically handled: 1. **`ValidationError`**: Field validation errors 2. **`PermissionDenied`**: Permission-related errors 3. **`ObjectDoesNotExist`**: Object lookup errors ### Generated Schema When error handling is enabled, your mutation return type becomes a union: ```graphql enum OperationMessageKind { INFO WARNING ERROR PERMISSION VALIDATION } type OperationMessage { """ The kind of this message. """ kind: OperationMessageKind! """ The error message. """ message: String! """ The field that caused the error, or null if it isn't associated with any particular field. """ field: String """ The error code, or null if no error code was set. """ code: String } type OperationInfo { """ List of messages returned by the operation. """ messages: [OperationMessage!]! } union CreateProductPayload = Product | OperationInfo type Mutation { createProduct(name: String!, price: Float!): CreateProductPayload! } ``` ### Querying with Error Handling Clients can check for errors using GraphQL fragments: ```graphql mutation CreateProduct($name: String!, $price: Float!) { createProduct(name: $name, price: $price) { ... on Product { id name price } ... on OperationInfo { messages { kind message field code } } } } ``` ## Field-Level Validation Errors Django's `ValidationError` can include field-specific errors that will be properly mapped: ```python title="mutations.py" from django.core.exceptions import ValidationError @strawberry.type class Mutation: @strawberry_django.mutation(handle_django_errors=True) def update_user(self, user_id: strawberry.ID, email: str, age: int) -> User: user = models.User.objects.get(pk=user_id) # Multiple field-specific errors errors = {} if not email or "@" not in email: errors["email"] = "Invalid email address" if age < 0 or age > 150: errors["age"] = "Age must be between 0 and 150" if errors: raise ValidationError(errors) user.email = email user.age = age user.save() return user ``` Response with field-specific errors: ```json { "data": { "updateUser": { "messages": [ { "kind": "VALIDATION", "message": "Invalid email address", "field": "email", "code": null }, { "kind": "VALIDATION", "message": "Age must be between 0 and 150", "field": "age", "code": null } ] } } } ``` ## Global Error Handling Configuration You can set error handling as the default for all mutations: ```python title="settings.py" STRAWBERRY_DJANGO = { "MUTATIONS_DEFAULT_HANDLE_ERRORS": True, } ``` With this setting, all mutations will handle Django errors by default unless explicitly turned off: ```python @strawberry_django.mutation(handle_django_errors=False) def some_mutation(self, data: str) -> Result: # This mutation will NOT handle Django errors automatically pass ``` ## Custom Error Handling ### Custom Exception Classes You can create custom exception classes for domain-specific errors: ```python title="exceptions.py" from django.core.exceptions import ValidationError class InsufficientStockError(ValidationError): """Raised when trying to order more items than available in stock.""" def __init__(self, product_name: str, requested: int, available: int): super().__init__( f"Insufficient stock for {product_name}. " f"Requested: {requested}, Available: {available}", code="insufficient_stock", ) ``` ```python title="mutations.py" from .exceptions import InsufficientStockError @strawberry.type class Mutation: @strawberry_django.mutation(handle_django_errors=True) def create_order(self, product_id: strawberry.ID, quantity: int) -> Order: product = models.Product.objects.get(pk=product_id) if product.stock < quantity: raise InsufficientStockError(product.name, quantity, product.stock) # Create order... order = models.Order.objects.create(product=product, quantity=quantity) product.stock -= quantity product.save() return order ``` ### Manual Error Handling For cases where you want more control, handle errors manually: ```python title="mutations.py" from typing import Annotated from django.core.exceptions import ValidationError @strawberry.type class ProductError: message: str code: str @strawberry.type class ProductSuccess: product: Product ProductResult = Annotated[ ProductSuccess | ProductError, strawberry.union("ProductResult") ] @strawberry.type class Mutation: @strawberry_django.mutation def create_product(self, name: str, price: float) -> ProductResult: try: if price < 0: return ProductError( message="Price must be positive", code="INVALID_PRICE" ) product = models.Product.objects.create(name=name, price=price) except Exception as e: return ProductError(message=str(e), code="UNKNOWN_ERROR") return ProductSuccess(product=product) ``` ## Permission Errors Permission errors are automatically handled when using the [Permission Extension](./permissions.md): ```python title="types.py" from strawberry_django.permissions import IsAuthenticated, HasPerm @strawberry_django.type(models.Document) class Document: title: auto @strawberry_django.field(extensions=[IsAuthenticated()]) def content(self) -> str: return self.content @strawberry_django.field(extensions=[HasPerm("documents.view_sensitive")]) def sensitive_data(self) -> str: return self.sensitive_data ``` When permission checks fail: - If the field is optional, it returns `None` - If the field is a list, it returns an empty list - If the field is required, it raises a `PermissionDenied` error - If using `handle_django_errors=True`, it returns an `OperationInfo` ## Model Validation Errors Django model's `full_clean()` validation is automatically triggered: ```python title="models.py" from django.db import models from django.core.exceptions import ValidationError class Product(models.Model): name = models.CharField(max_length=100) price = models.DecimalField(max_digits=10, decimal_places=2) discount_percentage = models.IntegerField(default=0) def clean(self): if self.discount_percentage < 0 or self.discount_percentage > 100: raise ValidationError({ "discount_percentage": "Discount must be between 0 and 100" }) if self.price < 0: raise ValidationError({"price": "Price cannot be negative"}) ``` These validation errors are automatically caught when using CUD mutations: ```python title="mutations.py" from strawberry_django import mutations @strawberry.type class Mutation: create_product: Product = mutations.create(ProductInput, handle_django_errors=True) update_product: Product = mutations.update( ProductPartialInput, handle_django_errors=True ) ``` ## Async Error Handling Error handling works the same way with async resolvers: ```python title="mutations.py" @strawberry.type class Mutation: @strawberry_django.mutation(handle_django_errors=True) async def create_user_async(self, email: str, username: str) -> User: # Validation if await models.User.objects.filter(email=email).aexists(): raise ValidationError({"email": "A user with this email already exists"}) # Creation user = await models.User.objects.acreate(email=email, username=username) return user ``` ## Error Handling in Input Mutations Input mutations also support error handling: ```python title="mutations.py" @strawberry_django.input(models.Product) class ProductInput: name: auto price: auto category_id: auto @strawberry.type class Mutation: @strawberry_django.input_mutation(handle_django_errors=True) def create_product(self, info, data: ProductInput) -> Product: # The InputMutationExtension will handle converting # the input to a proper data argument try: category = models.Category.objects.get(pk=data.category_id) except models.Category.DoesNotExist: raise ValidationError({"category_id": "Category does not exist"}) product = models.Product.objects.create( name=data.name, price=data.price, category=category ) return product ``` ## Best Practices ### 1. Use handle_django_errors for Standard Operations For CRUD operations, enable automatic error handling: ```python @strawberry.type class Mutation: create_user: User = mutations.create(UserInput, handle_django_errors=True) update_user: User = mutations.update(UserPartialInput, handle_django_errors=True) delete_user: User = mutations.delete(NodeInput, handle_django_errors=True) ``` ### 2. Provide Meaningful Error Messages Always include clear, actionable error messages: ```python # ❌ Poor error message if not valid: raise ValidationError("Invalid") # ✅ Good error message if not is_valid_email(email): raise ValidationError({ "email": "Please provide a valid email address in the format: user@example.com" }) ``` ### 3. Use Error Codes for Client Handling Include error codes for programmatic error handling: ```python raise ValidationError("Product is out of stock", code="OUT_OF_STOCK") ``` ### 4. Validate Early Validate inputs before performing expensive operations: ```python @strawberry_django.mutation(handle_django_errors=True) def bulk_create_users(self, users: list[UserInput]) -> list[User]: # Validate all inputs first errors = {} for i, user_input in enumerate(users): if not is_valid_email(user_input.email): errors[f"users.{i}.email"] = "Invalid email" if errors: raise ValidationError(errors) # Then perform the bulk operation return [models.User.objects.create(**user_input) for user_input in users] ``` ## Troubleshooting ### Error handling not working Ensure you've enabled error handling: ```python # In mutation definition @strawberry_django.mutation(handle_django_errors=True) # Or globally in settings STRAWBERRY_DJANGO = { "MUTATIONS_DEFAULT_HANDLE_ERRORS": True, } ``` ### Errors not showing field information Use Django's dict-style ValidationError: ```python # ❌ Field info not included raise ValidationError("Invalid email") # ✅ Field info included raise ValidationError({"email": "Invalid email"}) ``` ### Custom exceptions not handled Only Django's built-in exceptions are automatically handled. For custom exceptions, either: 1. Inherit from Django's exception classes 2. Catch and re-raise as Django exceptions 3. Use manual error handling with custom union types ## See Also - [Mutations](./mutations.md) - Creating and updating data - [Permissions](./permissions.md) - Authorization and access control - [Validation](./validation.md) - Input validation patterns strawberry-graphql-django-0.82.1/docs/guide/export-schema.md000066400000000000000000000033711516173410200240340ustar00rootroot00000000000000--- title: Export Schema --- # Export Schema > [!INFO] > The `export_schema` management command provided here is specifically designed for use with `strawberry_django`. The [default Strawberry export command](https://strawberry.rocks/docs/guides/schema-export) won't work with `strawberry_django` schemas because `strawberry_django` extends the base functionality of Strawberry to integrate with Django models and queries. This command ensures proper schema export functionality. The `export_schema` management command allows you to export a GraphQL schema defined using the `strawberry_django` library. This command converts the schema definition to GraphQL schema definition language (SDL), which can then be saved to a file or printed to the console. ## Usage To use the `export_schema` command, you need to specify the schema location(e.g., myapp.schema). Optionally, you can provide a file path to save the schema. If no path is provided, the schema will be printed to the console. ```sh python manage.py export_schema --path ``` ### Arguments - ``: The location of the schema module. This should be a dot-separated Python path (e.g., myapp.schema). For example, if your schema is located in the `schemas` directory in the `myapp` django app, you would use `myapp.schemas`. ### Options - `--path `: An optional argument specifying the file path where the schema should be saved. If not provided, the schema will be printed to standard output. ## Example Here's an example of how to use the export_schema command: ```sh python manage.py export_schema myapp.schema --path=output/schema.graphql ``` In this example, the schema located at `myapp.schema` will be exported to the file `output/schema.graphql`. strawberry-graphql-django-0.82.1/docs/guide/fields.md000066400000000000000000000172521516173410200225260ustar00rootroot00000000000000--- title: Defining Fields --- # Defining Fields > [!TIP] > It is highly recommended to enable the [Query Optimizer Extension](optimizer.md) > for improved performance and avoid some common pitfalls (e.g. the `n+1` issue) Fields can be defined manually or `auto` type can be used for automatic type resolution. All basic field types and relation fields are supported out of the box. If you use a library that defines a custom field you will need to define an equivalent type such as `str`, `float`, `bool`, `int` or `id`. ```python title="types.py" import strawberry_django from strawberry import auto @strawberry_django.type(models.Fruit) class Fruit: id: auto name: auto # equivalent type, inferred by `strawberry` @strawberry_django.type(models.Fruit) class Fruit2: id: strawberry.ID name: str ``` > [!TIP] > For choices using > [Django's TextChoices/IntegerChoices](https://docs.djangoproject.com/en/4.2/ref/models/fields/#enumeration-types) > it is recommended using the [django-choices-field](../integrations/choices-field.md) integration > enum handling. ## Relationships All one-to-one, one-to-many, many-to-one and many-to-many relationship types are supported, and the many-to-many relation is described using the `typing.List` annotation. The default resolver of `strawberry_django.field()` resolves the relationship based on given type information. ```python title="types.py" @strawberry_django.type(models.Fruit) class Fruit: id: auto name: auto color: "Color" @strawberry_django.type(models.Color) class Color: id: auto name: auto fruits: list[Fruit] ``` Note that all relations can naturally trigger the n+1 problem. To avoid that, you can either enable the [Optimizer Extension](./optimizer.md) which will automatically solve some general issues for you, or even use [Data Loaders](https://strawberry.rocks/docs/guides/dataloaders) for more complex situations. ## Field customization All Django types are encoded using the `strawberry_django.field()` field type by default. Fields can be customized with various parameters. ```python title="types.py" @strawberry_django.type(models.Color) class Color: another_name: auto = strawberry_django.field(field_name="name") internal_name: auto = strawberry_django.field( name="fruits", field_name="fruit_set", filters=FruitFilter, order=FruitOrder, pagination=True, description="A list of fruits with this color", ) ``` ### Relationship Traversal with `field_name` The `field_name` parameter supports Django's double-underscore (`__`) lookup syntax for traversing relationships. This allows you to create flat GraphQL schemas without intermediate types or custom resolvers. ```python title="types.py" # Django Models class Role(models.Model): name = models.CharField(max_length=100) description = models.TextField() class UserAssignedRole(models.Model): role = models.ForeignKey(Role, on_delete=models.CASCADE) user = models.OneToOneField( User, related_name="assigned_role", on_delete=models.CASCADE ) # GraphQL Types - Using field_name traversal @strawberry_django.type(Role) class RoleType: name: auto description: auto @strawberry_django.type(User) class UserType: username: auto email: auto # Direct access to role, bypassing UserAssignedRole role: Optional[RoleType] = strawberry_django.field( field_name="assigned_role__role", ) # You can also traverse to scalar fields directly role_name: Optional[str] = strawberry_django.field( field_name="assigned_role__role__name", ) ``` This creates a clean GraphQL query structure: ```graphql query { user { username role { # Direct access, no intermediate 'assignedRole' name description } roleName } } ``` **Key points:** - The optimizer will infer `select_related`/`only` for traversal paths when enabled - If any intermediate relationship is `None`, the entire field returns `None` - Works with any depth of relationship traversal (e.g., `"a__b__c__d"`) - Compatible with all optimization features (`only`, `prefetch_related`, `annotate`, etc.) ## Defining types for auto fields When using `strawberry.auto` to resolve a field's type, Strawberry Django uses a dict that maps each django field field type to its proper type. e.g.: ```python { models.CharField: str, models.IntegerField: int, ..., } ``` If you are using a custom django field that is not part of the default library, or you want to use a different type for a field, you can do that by overriding its value in the map, like: ```python from typing import NewType from django.db import models import strawberry import strawberry_django from strawberry_django.fields.types import field_type_map Slug = strawberry.scalar( NewType("Slug", str), serialize=lambda v: v, parse_value=lambda v: v, ) @strawberry.type class MyCustomFileType: ... field_type_map.update({ models.SlugField: Slug, models.FileField: MyCustomFileType, }) ``` ## Including / excluding Django model fields by name > [!WARNING] > These new keywords should be used with caution, as they may inadvertently lead to exposure of unwanted data. Especially with `fields="__all__"` or `exclude`, sensitive model attributes may be included and made available in the schema without your awareness. `strawberry_django.type` includes two optional keyword fields to help you populate fields from the Django model, `fields` and `exclude`. Valid values for `fields` are: - `__all__` to assign `strawberry.auto` as the field type for all model fields. - `[]` to assign `strawberry.auto` as the field type for the enumerated fields. These can be combined with manual type annotations if needed. ```python title="All Fields" @strawberry_django.type(models.Fruit, fields="__all__") class FruitType: pass ``` ```python title="Enumerated Fields" @strawberry_django.type(models.Fruit, fields=["name", "color"]) class FruitType: pass ``` ```python title="Overriden Fields" @strawberry_django.type(models.Fruit, fields=["color"]) class FruitType: name: str ``` Valid values for `exclude` are: - `[]` to exclude from the fields list. All other Django model fields will included and have `strawberry.auto` as the field type. These can also be overriden if another field type should be assigned. An empty list is ignored. ```python title="Exclude Fields" @strawberry_django.type(models.Fruit, exclude=["name"]) class FruitType: pass ``` ```python title="Overriden Exclude Fields" @strawberry_django.type(models.Fruit, exclude=["name"]) class FruitType: color: int ``` Note that `fields` has precedence over `exclude`, so if both are provided, then `exclude` is ignored. ## Overriding the field class (advanced) If in your project, you want to change/add some of the standard `strawberry_django.field()` behaviour, it is possible to use your own custom field class when decorating a `strawberry_django.type` with the `field_cls` argument, e.g. ```python title="types.py" class CustomStrawberryDjangoField(StrawberryDjangoField): """Your custom behaviour goes here.""" @strawberry_django.type(User, field_cls=CustomStrawberryDjangoField) class UserType: # Each of these fields will be an instance of `CustomStrawberryDjangoField`. id: int name: auto @strawberry.type class UserQuery: # You can directly create your custom field class on a plain strawberry type user: UserType = CustomStrawberryDjangoField() ``` In this example, each of the fields of the `UserType` will be automatically created by `CustomStrawberryDjangoField`, which may implement anything from custom pagination of relationships to altering the field permissions. strawberry-graphql-django-0.82.1/docs/guide/filters.md000066400000000000000000000337611516173410200227330ustar00rootroot00000000000000--- title: Filtering --- # Filtering It is possible to define filters for Django types, which will be converted into `.filter(...)` queries for the ORM: ```python title="types.py" import strawberry_django from strawberry import auto from typing_extensions import Self @strawberry_django.filter_type(models.Fruit) class FruitFilter: id: auto name: auto @strawberry_django.type(models.Fruit, filters=FruitFilter) class Fruit: ... ``` > [!TIP] > In most cases filter fields should have `Optional` annotations and default value `strawberry.UNSET` like so: > `foo: Optional[SomeType] = strawberry.UNSET` > Above `auto` annotation is wrapped in `Optional` automatically. > `UNSET` is automatically used for fields without `field` or with `strawberry_django.filter_field`. The code above would generate following schema: ```graphql title="schema.graphql" input FruitFilter { id: ID name: String AND: FruitFilter OR: FruitFilter NOT: FruitFilter DISTINCT: Boolean } ``` > [!TIP] > If you are using the [relay integration](relay.md) and working with types inheriting > from `relay.Node` and `GlobalID` for identifying objects, you might want to set > `MAP_AUTO_ID_AS_GLOBAL_ID=True` in your [strawberry django settings](./settings.md) > to make sure `auto` fields gets mapped to `GlobalID` on types and filters. ## AND, OR, NOT, DISTINCT ... To every filter `AND`, `OR`, `NOT` & `DISTINCT` fields are added to allow more complex filtering ```graphql { fruits( filters: { name: "kebab" OR: { name: "raspberry" } } ) { ... } } ``` ## List-based AND/OR/NOT Filters The `AND`, `OR`, and `NOT` operators can also be declared as lists, allowing for more complex combinations of conditions. This is particularly useful when you need to combine multiple conditions in a single operation. ```python title="types.py" @strawberry_django.filter_type(models.Vegetable, lookups=True) class VegetableFilter: id: auto name: auto AND: Optional[list[Self]] = strawberry.UNSET OR: Optional[list[Self]] = strawberry.UNSET NOT: Optional[list[Self]] = strawberry.UNSET ``` This enables queries like: ```graphql { vegetables( filters: { AND: [{ name: { contains: "blue" } }, { name: { contains: "squash" } }] } ) { id } } ``` The list-based filtering system differs from the single object filter in a few ways: 1. It allows combining multiple conditions in a single `AND`, `OR`, or `NOT` operation 2. The conditions in a list are evaluated together as a group 3. When using `AND`, all conditions in the list must be satisfied 4. When using `OR`, any condition in the list can be satisfied 5. When using `NOT`, none of the conditions in the list should be satisfied This is particularly useful for complex queries where you need to have multiple conditions against the same field. ## Lookups Lookups can be added to all fields with `lookups=True`, which will add more options to resolve each type. For example: ```python title="types.py" @strawberry_django.filter_type(models.Fruit, lookups=True) class FruitFilter: id: auto name: auto ``` The code above would generate the following schema: ```graphql title="schema.graphql" input IDBaseFilterLookup { exact: ID isNull: Boolean inList: [String!] } input StrFilterLookup { exact: ID isNull: Boolean inList: [String!] iExact: String contains: String iContains: String startsWith: String iStartsWith: String endsWith: String iEndsWith: String regex: String iRegex: String } input FruitFilter { id: IDFilterLookup name: StrFilterLookup AND: FruitFilter OR: FruitFilter NOT: FruitFilter DISTINCT: Boolean } ``` Single-field lookup can be annotated with the appropriate lookup type for the field. Use specific lookup types like `StrFilterLookup` for strings, `ComparisonFilterLookup` for numbers, etc. ```python title="types.py" from strawberry_django import StrFilterLookup @strawberry_django.filter_type(models.Fruit) class FruitFilter: name: StrFilterLookup | None ``` > [!WARNING] > Avoid using `FilterLookup[str]` directly. Use the specific lookup type (`StrFilterLookup`) > instead to prevent `DuplicatedTypeName` errors. See the [Generic Lookup reference](#generic-lookup-reference) > for the full list of available lookup types. ## Filtering over relationships ```python title="types.py" @strawberry_django.filter_type(models.Color) class ColorFilter: id: auto name: auto @strawberry_django.filter_type(models.Fruit) class FruitFilter: id: auto name: auto color: ColorFilter | None ``` The code above would generate following schema: ```graphql title="schema.graphql" input ColorFilter { id: ID name: String AND: ColorFilter OR: ColorFilter NOT: ColorFilter } input FruitFilter { id: ID name: String color: ColorFilter AND: FruitFilter OR: FruitFilter NOT: FruitFilter } ``` ## Custom filter methods You can define custom filter method by defining your own resolver. ```python title="types.py" @strawberry_django.filter_type(models.Fruit) class FruitFilter: name: auto last_name: auto @strawberry_django.filter_field def simple(self, value: str, prefix) -> Q: return Q(**{f"{prefix}name": value}) @strawberry_django.filter_field def full_name( self, queryset: QuerySet, value: str, prefix: str ) -> tuple[QuerySet, Q]: queryset = queryset.alias( _fullname=Concat(f"{prefix}name", Value(" "), f"{prefix}last_name") ) return queryset, Q(**{"_fullname": value}) @strawberry_django.filter_field def full_name_lookups( self, info: Info, queryset: QuerySet, value: strawberry_django.StrFilterLookup, prefix: str, ) -> tuple[QuerySet, Q]: queryset = queryset.alias( _fullname=Concat(f"{prefix}name", Value(" "), f"{prefix}last_name") ) return strawberry_django.process_filters( filters=value, queryset=queryset, info=info, prefix=f"{prefix}_fullname__" ) ``` > [!WARNING] > It is discouraged to use `queryset.filter()` directly. When using more > complex filtering via `NOT`, `OR` & `AND` this might lead to undesired behaviour. > [!TIP] > > #### process_filters > > As seen above `strawberry_django.process_filters` function is exposed and can be > reused in custom methods. Above it's used to resolve fields lookups > > #### null values > > By default `null` value is ignored for all filters & lookups. This applies to custom > filter methods as well. Those won't even be called (you don't have to check for `None`). > This can be modified using > `strawberry_django.filter_field(filter_none=True)` > > This also means that built in `exact` & `iExact` lookups cannot be used to filter for `None` > and `isNull` have to be used explicitly. > > #### value resolution > > - `value` parameter of type `relay.GlobalID` is resolved to its `node_id` attribute > - `value` parameter of type `Enum` is resolved to is's value > - `value` parameter wrapped in `strawberry.Some` (from [`Maybe`](https://strawberry.rocks/docs/types/maybe#maybe) type) is unwrapped and resolved > - above types are converted in `lists` as well > > resolution can modified via `strawberry_django.filter_field(resolve_value=...)` > > - True - always resolve > - False - never resolve > - UNSET (default) - resolves for filters without custom method only > > #### Virtual (non-filtering) fields > > Sometimes you need a field on the filter input that is not a database filter itself, but a > parameter consumed by a custom `filter` method (e.g. a similarity threshold, a search mode flag). > Use `strawberry_django.filter_field(skip_queryset_filter=True)` to declare such fields. They appear in > the GraphQL schema but are skipped during filter processing. Access them via `self.` in > your custom filter method. > > ```python > @strawberry_django.filter_type(models.Fruit) > class FruitFilter: > min_similarity: float | None = strawberry_django.filter_field( > default=0.3, skip_queryset_filter=True > ) > > @strawberry_django.filter_field > def filter(self, queryset: QuerySet, prefix: str): > if self.min_similarity is not None: > queryset = queryset.annotate( > similarity=TrigramSimilarity(f"{prefix}name", self.search) > ).filter(similarity__gte=self.min_similarity) > return queryset, Q() > ``` The code above generates the following schema: ```graphql title="schema.graphql" input FruitFilter { name: String lastName: String simple: str fullName: str fullNameLookups: StrFilterLookup } ``` #### Resolver arguments - `prefix` - represents the current path or position - **Required** - Important for nested filtering - In code bellow custom filter `name` ends up filtering `Fruit` instead of `Color` without applying `prefix` ```python title="Why prefix?" @strawberry_django.filter_type(models.Fruit) class FruitFilter: name: auto color: ColorFilter | None @strawberry_django.filter_type(models.Color) class ColorFilter: @strawberry_django.filter_field def name(self, value: str, prefix: str): # prefix is "fruit_set__" if unused root object is filtered instead if value: return Q(name=value) return Q() ``` ```graphql { fruits( filters: {color: {name: "blue"}} ) { ... } } ``` - `value` - represents graphql field type - **Required**, but forbidden for default `filter` method - _must_ be annotated - used instead of field's return type - `queryset` - can be used for more complex filtering - Optional, but **Required** for default `filter` method - usually used to `annotate` `QuerySet` #### Resolver return For custom field methods two return values are supported - django's `Q` object - tuple with `QuerySet` and django's `Q` object -> `tuple[QuerySet, Q]` For default `filter` method only second variant is supported. ### What about nulls? By default `null` values are ignored. This can be toggled as such `@strawberry_django.filter_field(filter_none=True)` ## Overriding the default `filter` method Works similar to field filter method, but: - is responsible for resolution of filtering for entire object - _must_ be named `filter` - argument `queryset` is **Required** - argument `value` is **Forbidden** ```python title="types.py" @strawberry_django.filter_type(models.Fruit) class FruitFilter: def ordered( self, value: int, prefix: str, queryset: QuerySet, ): queryset = queryset.alias(_ordered_num=Count(f"{prefix}orders__id")) return queryset, Q(**{f"{prefix}_ordered_num": value}) @strawberry_django.filter_field def filter( self, info: Info, queryset: QuerySet, prefix: str, ) -> tuple[QuerySet, list[Q]]: queryset = queryset.filter( ... # Do some query modification ) return strawberry_django.process_filters( self, info=info, queryset=queryset, prefix=prefix, skip_object_filter_method=True, ) ``` > [!TIP] > As seen above `strawberry_django.process_filters` function is exposed and can be > reused in custom methods. > For filter method `filter` `skip_object_filter_method` was used to avoid endless recursion. ## Adding filters to types All fields and CUD mutations inherit filters from the underlying type by default. So, if you have a field like this: ```python title="types.py" @strawberry_django.type(models.Fruit, filters=FruitFilter) class Fruit: ... @strawberry.type class Query: fruits: list[Fruit] = strawberry_django.field() ``` The `fruits` field will inherit the `filters` of the type in the same way as if it was passed to the field. ## Adding filters directly into a field Filters added into a field override the default filters of this type. ```python title="schema.py" @strawberry.type class Query: fruits: list[Fruit] = strawberry_django.field(filters=FruitFilter) ``` ## Generic Lookup reference There is 7 already defined Generic Lookup `strawberry.input` classes importable from `strawberry_django` #### `BaseFilterLookup` - contains `exact`, `isNull` & `inList` - used for `ID` & `bool` fields #### `RangeLookup` - used for `range` or `BETWEEN` filtering #### `ComparisonFilterLookup` - inherits `BaseFilterLookup` - additionaly contains `gt`, `gte`, `lt`, `lte`, & `range` - used for Numberical fields #### `FilterLookup` - inherits `BaseFilterLookup` - additionally contains `iExact`, `contains`, `iContains`, `startsWith`, `iStartsWith`, `endsWith`, `iEndsWith`, `regex` & `iRegex` - used for string based fields and as default #### `DateFilterLookup` - inherits `ComparisonFilterLookup` - additionally contains `year`,`month`,`day`,`weekDay`,`isoWeekDay`,`week`,`isoYear` & `quarter` - used for date based fields #### `TimeFilterLookup` - inherits `ComparisonFilterLookup` - additionally contains `hour`,`minute`,`second`,`date` & `time` - used for time based fields #### `DatetimeFilterLookup` - inherits `DateFilterLookup` & `TimeFilterLookup` - used for timedate based fields ## Legacy filtering The previous version of filters can be enabled via [**USE_DEPRECATED_FILTERS**](settings.md#strawberry_django) > [!WARNING] > If **USE_DEPRECATED_FILTERS** is not set to `True` legacy custom filtering > methods will be _not_ be called. When using legacy filters it is important to use legacy `strawberry_django.filters.FilterLookup` lookups as well. The correct version is applied for `auto` annotated filter field (given `lookups=True` being set). Mixing old and new lookups might lead to error `DuplicatedTypeName: Type StrFilterLookup is defined multiple times in the schema`. While legacy filtering is enabled new filtering custom methods are fully functional including default `filter` method. Migration process could be composed of these steps: - enable **USE_DEPRECATED_FILTERS** - gradually transform custom filter field methods to new version (do not forget to use old FilterLookup if applicable) - gradually transform default `filter` methods - disable **USE_DEPRECATED_FILTERS** - **_This is breaking change_** strawberry-graphql-django-0.82.1/docs/guide/legacy-ordering.md000066400000000000000000000154751516173410200243400ustar00rootroot00000000000000--- title: Ordering --- > [!WARNING] > The legacy ordering behavior described in this document is provided for backwards compatibility. > You should prefer the new [Ordering](ordering.md) system instead. # Order (Legacy) The legacy ordering system created with `@strawberry_django.order` allows sorting by multiple fields only by specifying the object keys in the order input in the desired order. This is not always feasible and contradicts the way objects are supposed to be used. ```python title="types.py" @strawberry_django.order(models.Color) class ColorOrder: name: auto @strawberry_django.order(models.Fruit) class FruitOrder: name: auto color: ColorOrder | None ``` > [!TIP] > In most cases order fields should have `Optional` annotations and default value `strawberry.UNSET`. > Above `auto` annotation is wrapped in `Optional` automatically. > `UNSET` is automatically used for fields without `field` or with `strawberry_django.order_field`. The code above generates the following schema: ```graphql title="schema.graphql" enum Ordering { ASC ASC_NULLS_FIRST ASC_NULLS_LAST DESC DESC_NULLS_FIRST DESC_NULLS_LAST } input ColorOrder { name: Ordering } input FruitOrder { name: Ordering color: ColorOrder } ``` ## Custom order methods You can define custom order method by defining your own resolver. ```python title="types.py" @strawberry_django.order(models.Fruit) class FruitOrder: name: auto @strawberry_django.order_field def discovered_by(self, value: bool, prefix: str) -> list[str]: if not value: return [] return [f"{prefix}discover_by__name", f"{prefix}name"] @strawberry_django.order_field def order_number( self, info: Info, queryset: QuerySet, value: strawberry_django.Ordering, # `auto` can be used instead prefix: str, sequence: dict[str, strawberry_django.Ordering] | None, ) -> tuple[QuerySet, list[str]] | list[str]: queryset = queryset.alias(_ordered_num=Count(f"{prefix}orders__id")) ordering = value.resolve(f"{prefix}_ordered_num") return queryset, [ordering] ``` > [!WARNING] > Do not use `queryset.order_by()` directly. Due to `order_by` not being chainable > operation, changes applied this way would be overriden later. > [!TIP] > The `strawberry_django.Ordering` type has convenient method `resolve` that can be used to > convert field's name to appropriate `F` object with correctly applied `asc()`, `desc()` method > with `nulls_first` and `nulls_last` arguments. The code above generates the following schema: ```graphql title="schema.graphql" enum Ordering { ASC ASC_NULLS_FIRST ASC_NULLS_LAST DESC DESC_NULLS_FIRST DESC_NULLS_LAST } input FruitOrder { name: Ordering discoveredBy: bool orderNumber: Ordering } ``` #### Resolver arguments - `prefix` - represents the current path or position - **Required** - Important for nested ordering - In code bellow custom order `name` ends up ordering `Fruit` instead of `Color` without applying `prefix` ```python title="Why prefix?" @strawberry_django.order(models.Fruit) class FruitOrder: name: auto color: ColorOrder | None @strawberry_django.order(models.Color) class ColorOrder: @strawberry_django.order_field def name(self, value: bool, prefix: str): # prefix is "fruit_set__" if unused root object is ordered instead if value: return ["name"] return [] ``` ```graphql { fruits( order: {color: name: ASC} ) { ... } } ``` - `value` - represents graphql field type - **Required**, but forbidden for default `order` method - _must_ be annotated - used instead of field's return type - Using `auto` is the same as `strawberry_django.Ordering`. - `queryset` - can be used for more complex ordering - Optional, but **Required** for default `order` method - usually used to `annotate` `QuerySet` - `sequence` - used to order values on the same level - elements in graphql object are not quaranteed to keep their order as defined by user thus this argument should be used in those cases [GraphQL Spec](https://spec.graphql.org/October2021/#sec-Language.Arguments) - usually for custom order field methods does not have to be used - for advanced usage, look at `strawberry_django.process_order` function #### Resolver return For custom field methods two return values are supported - iterable of values acceptable by `QuerySet.order_by` -> `Collection[F | str]` - tuple with `QuerySet` and iterable of values acceptable by `QuerySet.order_by` -> `tuple[QuerySet, Collection[F | str]]` For default `order` method only second variant is supported. ### What about nulls? By default `null` values are ignored. This can be toggled as such `@strawberry_django.order_field(order_none=True)` ## Overriding the default `order` method Works similar to field order method, but: - is responsible for resolution of ordering for entire object - _must_ be named `order` - argument `queryset` is **Required** - argument `value` is **Forbidden** - should probably use `sequence` ```python title="types.py" @strawberry_django.order(models.Fruit) class FruitOrder: name: auto @strawberry_django.order_field def ordered( self, info: Info, queryset: QuerySet, value: strawberry_django.Ordering, prefix: str, ) -> tuple[QuerySet, list[str]] | list[str]: queryset = queryset.alias(_ordered_num=Count(f"{prefix}orders__id")) return queryset, [value.resolve(f"{prefix}_ordered_num")] @strawberry_django.order_field def order( self, info: Info, queryset: QuerySet, prefix: str, sequence: dict[str, strawberry_django.Ordering] | None, ) -> tuple[QuerySet, list[str]]: queryset = queryset.filter( ... # Do some query modification ) return strawberry_django.process_order( self, info=info, queryset=queryset, sequence=sequence, prefix=prefix, skip_object_order_method=True, ) ``` > [!TIP] > As seen above `strawberry_django.process_order` function is exposed and can be > reused in custom methods. > For order method `order` `skip_object_order_method` was used to avoid endless recursion. ## Adding orderings to types All fields and mutations inherit orderings from the underlying type by default. So, if you have a field like this: ```python title="types.py" @strawberry_django.type(models.Fruit, order=FruitOrder) class Fruit: ... ``` The `fruits` field will inherit the `order` of the type same same way as if it was passed to the field. ## Adding orderings directly into a field Orderings added into a field override the default order of this type. ```python title="schema.py" @strawberry.type class Query: fruit: Fruit = strawberry_django.field(order=FruitOrder) ``` strawberry-graphql-django-0.82.1/docs/guide/model-properties.md000066400000000000000000000141321516173410200245440ustar00rootroot00000000000000--- title: Model Properties --- # Model Properties Model properties allow you to add computed fields directly to your Django models while providing optimization hints for the GraphQL query optimizer. This feature is particularly useful when you want to expose derived data in your GraphQL schema without triggering N+1 queries or deferred attribute issues. ## Overview Strawberry Django provides two decorators for adding model properties: - `@model_property`: Similar to Python's `@property` but with optimization hints - `@cached_model_property`: Similar to Django's `@cached_property` but with optimization hints Both decorators accept the same optimization parameters as `strawberry_django.field()`, allowing the optimizer to properly prefetch or select related data. ## Basic Usage ### Basic Model Property ```python title="models.py" from decimal import Decimal from django.db import models from strawberry_django.descriptors import model_property class OrderItem(models.Model): price = models.DecimalField(max_digits=10, decimal_places=2) quantity = models.IntegerField() @model_property(only=["price", "quantity"]) def total(self) -> Decimal: """Calculate the total price for this order item.""" return self.price * self.quantity ``` ```python title="types.py" import strawberry_django from strawberry import auto @strawberry_django.type(models.OrderItem) class OrderItem: price: auto quantity: auto total: auto # Automatically resolved with optimization hints ``` The `only` parameter tells the optimizer to ensure `price` and `quantity` are fetched from the database when `total` is requested. ### Cached Model Property For expensive computations that should only be calculated once per instance, use `@cached_model_property`: ```python title="models.py" from django.db import models from strawberry_django.descriptors import cached_model_property class Product(models.Model): name = models.CharField(max_length=100) @cached_model_property(prefetch_related=["reviews"]) def average_rating(self) -> float: """Calculate average rating from all reviews.""" reviews = list(self.reviews.all()) if not reviews: return 0.0 return sum(r.rating for r in reviews) / len(reviews) ``` The computed value is cached on the instance after the first access, avoiding redundant calculations. ## Optimization Parameters Model properties accept the same optimization hints as `strawberry_django.field()`. See the [Query Optimizer guide](./optimizer.md) for complete details. ## Combining with GraphQL Types Model properties integrate seamlessly with `strawberry.auto`: ```python title="models.py" from decimal import Decimal from django.db import models from django.db.models import Sum from strawberry_django.descriptors import model_property, cached_model_property class Order(models.Model): customer = models.ForeignKey("Customer", on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) status = models.CharField(max_length=20) @model_property( prefetch_related=["items"], annotate={"_total": Sum("items__total")} ) def total_amount(self) -> Decimal: """Calculate total order amount.""" return self._total or Decimal(0) # type: ignore @cached_model_property(select_related=["customer"]) def customer_name(self) -> str: """Get the customer's full name.""" return f"{self.customer.first_name} {self.customer.last_name}" ``` ```python title="types.py" import strawberry_django from strawberry import auto @strawberry_django.type(models.Order) class Order: created_at: auto status: auto total_amount: auto # Uses model_property optimization hints customer_name: auto # Uses cached_model_property hints ``` ## Best Practices 1. **Always provide optimization hints**: If your property accesses model fields or relations, specify them in the decorator parameters. 2. **Use cached_model_property for expensive operations**: If the calculation is expensive and doesn't depend on mutable data, use caching. 3. **Keep properties focused**: Complex business logic should be in separate service classes, not in model properties. 4. **Type annotations are required**: Always provide return type annotations for model properties. 5. **Document your properties**: Add clear docstrings that will appear in your GraphQL schema. 6. **Test with the optimizer**: Ensure your optimization hints actually work by checking the generated SQL queries. 7. **Use `len()` instead of `.count()` with prefetch_related**: When accessing prefetched relationships, use `len()` to avoid bypassing the prefetch cache: ```python # ❌ Bad: .count() bypasses prefetch cache and hits database @model_property(prefetch_related=["books"]) def book_count(self) -> int: return self.books.count() # Issues a COUNT(*) query! # ✅ Good: len() uses prefetch cache @model_property(prefetch_related=["books"]) def book_count(self) -> int: return len(self.books.all()) # Uses prefetched data # ✅ Best: Use database annotation when prefetch not needed @model_property(annotate={"_book_count": Count("books")}) def book_count(self) -> int: return self._book_count # type: ignore ``` ## Troubleshooting ### Property triggers extra queries If your model property is still causing N+1 queries: 1. Check that optimization hints match the actual database access 2. Ensure the [Query Optimizer Extension](./optimizer.md) is enabled 3. Verify that `only` includes all accessed fields 4. Use `select_related` for foreign keys, not `prefetch_related` ### Type resolution errors If Strawberry can't resolve the type: ```python # ❌ Missing return type annotation @model_property(only=["name"]) def display_name(self): return self.name.upper() # ✅ With return type annotation @model_property(only=["name"]) def display_name(self) -> str: return self.name.upper() ``` ## See Also - [Query Optimizer](./optimizer.md) - Understanding optimization hints - [Custom Resolvers](./resolvers.md) - Alternative approaches for computed fields - [Fields](./fields.md) - Basic field definition and customization strawberry-graphql-django-0.82.1/docs/guide/mutations.md000066400000000000000000000174201516173410200233000ustar00rootroot00000000000000--- title: Mutations --- # Mutations ## Getting started Mutations can be defined the same way as [strawberry's mutations](https://strawberry.rocks/docs/general/mutations), but instead of using `@strawberry.mutation`, use `@strawberry_django.mutation`. Here are the differences between those: - Strawberry Django's mutation will be sure that the mutation is executed in an async safe environment, meaning that if you are running ASGI and you define a `sync` resolver, it will automatically be wrapped in a `sync_to_async` call. - It will better integrate with the [permission integration](./permissions.md) - It has an option to automatically handle common django errors and return them in a standardized way (more on that below) ## Django errors handling When defining a mutation you can pass `handle_django_errors=True` to make it handle common django errors, such as `ValidationError`, `PermissionDenied` and `ObjectDoesNotExist`: ```python title="types.py" @strawberry.type class Mutation: @strawberry_django.mutation(handle_django_errors=True) def create_fruit(self, name: str, color: str) -> Fruit: if not is_valid_color(color): raise ValidationError("The color is not valid") # Creation can also raise ValidationError, if the `name` is # larger than its allowed `max_length` for example. fruit = models.Fruit.objects.create(name=name) return cast(Fruit, fruit) ``` The code above would generate following schema: ```graphql title="schema.graphql" enum OperationMessageKind { INFO WARNING ERROR PERMISSION VALIDATION } type OperationInfo { """List of messages returned by the operation.""" messages: [OperationMessage!]! } type OperationMessage { """The kind of this message.""" kind: OperationMessageKind! """The error message.""" message: String! """ The field that caused the error, or `null` if it isn't associated with any particular field. """ field: String """The error code, or `null` if no error code was set.""" code: String } type Fruit { name: String! color: String! } union CreateFruitPayload = Fruit | OperationInfo mutation { createFruit( name: String! color: String! ): CreateFruitPayload! } ``` > [!TIP] > If all or most of your mutations use this behaviour, you can change the > default behaviour for `handle_django_errors` by setting > `MUTATIONS_DEFAULT_HANDLE_ERRORS=True` in your [strawberry django settings](./settings.md) ## Input mutations Those are defined using `@strawberry_django.input_mutation` and act the same way as the `@strawberry_django.mutation`, the only difference being that it injects an [InputMutationExtension](https://strawberry.rocks/docs/general/mutations#the-input-mutation-extension) in the field, which converts its arguments in a new type (check the extension's docs for more information). ## CUD mutations The following CUD mutations are provided by this lib: - `strawberry_django.mutations.create`: Will create the model using the data from the given input - `strawberry_django.mutations.update`: Will update the model using the data from the given input - `strawberry_django.mutations.delete`: Will delete the model using the id from the given input A basic example would be: ```python title="types.py" from strawberry import auto from strawberry_django import mutations, NodeInput from strawberry.relay import Node @strawberry_django.type(SomeModel) class SomeModelType(Node): name: auto @strawberry_django.input(SomeModel) class SomeModelInput: name: auto @strawberry_django.partial(SomeModel) class SomeModelInputPartial(NodeInput): name: auto @strawberry.type class Mutation: create_model: SomeModelType = mutations.create(SomeModelInput) update_model: SomeModelType = mutations.update(SomeModelInputPartial) delete_model: SomeModelType = mutations.delete(NodeInput) ``` Some things to note here: - Those CUD mutations accept the same arguments as `@strawberry_django.mutation` accepts. This allows you to pass `handle_django_errors=True` to it for example. - The mutation will receive the type in an argument named `"data"` by default. To change it to `"info"` for example, you can change it by passing `argument_name="info"` to the mutation, or set `MUTATIONS_DEFAULT_ARGUMENT_NAME="info"` in your [strawberry django settings](./settings.md) to make it the default when not provided. - Take note that inputs using `partial` will _not_ automatically mark non-auto fields optional and instead will respect explicit type annotations; see [partial input types](./types.md#input-types) documentation for examples. - It's also possible to update or delete a model using a unique identifier other than `id` by passing a `key_attr` argument: ```python @strawberry_django.partial(SomeModel) class SomeModelInputPartial: unique_field: strawberry.auto @strawberry.type class Mutation: update_model: SomeModelType = mutations.update( SomeModelInputPartial, key_attr="unique_field", ) delete_model: SomeModelType = mutations.delete( SomeModelInputPartial, key_attr="unique_field", ) ``` ## Partial updates with Maybe When using `partial` inputs for updates, all `auto` fields become optional. However, this makes it impossible to distinguish between "field not provided" and "field explicitly set to null". The [`Maybe`](https://strawberry.rocks/docs/types/maybe) type solves this: - `Maybe[str]`: field is either absent (`None`) or has a value (`Some("hello")`) - `Maybe[str | None]`: field is absent (`None`), has a value (`Some("hello")`), or explicitly null (`Some(None)`) ```python title="types.py" from strawberry import Maybe @strawberry_django.input(models.Fruit) class FruitUpdateInput: id: strawberry.relay.GlobalID # name is required, null not allowed name: Maybe[str] # color is optional, can be explicitly set to null color: Maybe[str | None] @strawberry.type class Mutation: @strawberry_django.mutation def update_fruit(self, info, input: FruitUpdateInput) -> Fruit: fruit = input.id.resolve_node_sync(info) if input.name is not None: fruit.name = input.name.value if input.color is not None: # input.color.value is either a string or None fruit.color = input.color.value fruit.save() return cast(Fruit, fruit) ``` This pattern allows clients to: - Omit a field entirely (no change) - Set a field to a value: `{ "name": "Apple" }` - Set a nullable field to null: `{ "color": null }` (only with `Maybe[T | None]`) For more details, see the [Strawberry Maybe documentation](https://strawberry.rocks/docs/types/maybe). ## Filtering > [!CAUTION] > Filtering on mutations is discouraged as it can potentially alter your entire model collection if there are issues with the filters. Filters can be added to update and delete mutations. More information in the [filtering](filters.md) section. ```python title="schema.py" import strawberry from strawberry_django import mutations @strawberry.type class Mutation: updateFruits: list[Fruit] = mutations.update(FruitPartialInput, filters=FruitFilter) deleteFruits: list[Fruit] = mutations.delete(filters=FruitFilter) schema = strawberry.Schema(mutation=Mutation) ``` ## Batching If you need to make multiple creates, updates, or deletes as part of one atomic mutation you can use batching. Batching has a similar syntax except that the mutations take and return a list. ```python title="schema.py" import strawberry from strawberry_django import mutations @strawberry.type class Mutation: createFruits: list[Fruit] = mutations.create(list[FruitPartialInput]) updateFruits: list[Fruit] = mutations.update(list[FruitPartialInput]) deleteFruits: list[Fruit] = mutations.delete(list[FruitPartialInput]) schema = strawberry.Schema(mutation=Mutation) ``` strawberry-graphql-django-0.82.1/docs/guide/nested-mutations.md000066400000000000000000000243441516173410200245630ustar00rootroot00000000000000--- title: Nested Mutations and Relationships --- # Nested Mutations and Relationships Strawberry Django provides automatic handling for creating and updating related objects in mutations. This guide shows both the automatic approach (recommended) and manual patterns for more control. ## Automatic Nested Mutations Strawberry Django's `mutations.create` and `mutations.update` automatically handle nested relationships for you. ### Basic Example ```python title="models.py" from django.db import models class Author(models.Model): name = models.CharField(max_length=100) email = models.EmailField() class Book(models.Model): title = models.CharField(max_length=200) author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books") published_date = models.DateField() ``` ```python title="types.py" import strawberry_django from strawberry import auto @strawberry_django.type(models.Author) class Author: id: auto name: auto email: auto books: list["Book"] @strawberry_django.type(models.Book) class Book: id: auto title: auto author: Author published_date: auto @strawberry_django.input(models.Book) class BookInput: title: auto author_id: auto published_date: auto @strawberry_django.input(models.Author) class AuthorInput: name: auto email: auto ``` ```python title="mutations.py" import strawberry from strawberry_django import mutations @strawberry.type class Mutation: create_book: Book = mutations.create(BookInput) create_author: Author = mutations.create(AuthorInput) update_author: Author = mutations.update(AuthorInput) delete_author: Author = mutations.delete(AuthorInput) ``` Usage: ```graphql mutation CreateBook { createBook( data: { title: "Django for Beginners" authorId: "1" publishedDate: "2024-01-01" } ) { id title author { name } } } ``` ### Many-to-Many Relationships For many-to-many relationships, use `ListInput` to add, remove, or set related objects: ```python title="models.py" class Tag(models.Model): name = models.CharField(max_length=50, unique=True) class Article(models.Model): title = models.CharField(max_length=200) content = models.TextField() tags = models.ManyToManyField(Tag, related_name="articles") ``` ```python title="types.py" from strawberry_django import ListInput, NodeInput @strawberry_django.type(models.Article) class Article: id: auto title: auto content: auto tags: list["Tag"] @strawberry_django.type(models.Tag) class Tag: id: auto name: auto @strawberry_django.input(models.Article) class ArticleInput: title: auto content: auto tags: ListInput[strawberry.ID] | None = None @strawberry_django.partial(models.Article) class ArticleInputPartial(NodeInput): title: auto tags: ListInput[strawberry.ID] | None = None ``` ```python title="mutations.py" @strawberry.type class Mutation: create_article: Article = mutations.create(ArticleInput) update_article: Article = mutations.update(ArticleInputPartial) ``` The `ListInput` type supports three operations: ```graphql # Set tags (replaces all existing tags) mutation SetTags { updateArticle(data: { id: "1", tags: { set: ["1", "2", "3"] } }) { id tags { name } } } # Add tags (keeps existing, adds new) mutation AddTags { updateArticle(data: { id: "1", tags: { add: ["4", "5"] } }) { id tags { name } } } # Remove specific tags mutation RemoveTags { updateArticle(data: { id: "1", tags: { remove: ["2"] } }) { id tags { name } } } ``` ## Manual Nested Mutations For more control, you can write custom mutation resolvers. This is useful when you need: - Custom validation logic - Complex business rules - Non-standard relationship handling ### Creating Parent with Children ```python title="types.py" @strawberry_django.input(models.Author) class AuthorInputWithBooks: name: auto email: auto books: list[BookInput] | None = None ``` ```python title="mutations.py" from typing import cast from django.db import transaction @strawberry.type class Mutation: @strawberry_django.mutation(handle_django_errors=True) @transaction.atomic def create_author_with_books(self, data: AuthorInputWithBooks) -> Author: # Create the author author = models.Author.objects.create(name=data.name, email=data.email) # Create associated books if data.books: for book_data in data.books: models.Book.objects.create( title=book_data.title, author=author, published_date=book_data.published_date, ) # Return fresh object so relationships are loaded return models.Author.objects.get(pk=author.pk) ``` Usage: ```graphql mutation CreateAuthorWithBooks { createAuthorWithBooks( data: { name: "Jane Smith" email: "jane@example.com" books: [ { title: "Book One", publishedDate: "2024-01-01" } { title: "Book Two", publishedDate: "2024-06-01" } ] } ) { id name books { title } } } ``` ### Updating One-to-Many Relationships ```python title="models.py" class Order(models.Model): customer_name = models.CharField(max_length=100) class OrderItem(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items") product_name = models.CharField(max_length=100) quantity = models.IntegerField() ``` ```python title="types.py" @strawberry_django.input(models.OrderItem) class OrderItemInput: product_name: auto quantity: auto @strawberry_django.partial(models.OrderItem) class OrderItemInputPartial(NodeInput): product_name: auto quantity: auto @strawberry_django.partial(models.Order) class OrderInputPartial(NodeInput): customer_name: auto items: ListInput[OrderItemInputPartial] | None = None ``` ```python title="mutations.py" @strawberry.type class Mutation: @strawberry_django.mutation(handle_django_errors=True) @transaction.atomic def update_order_with_items(self, data: OrderInputPartial) -> Order: order = models.Order.objects.get(pk=data.id) # Update order fields if data.customer_name is not strawberry.UNSET: order.customer_name = data.customer_name order.save() # Handle items if data.items is not strawberry.UNSET and data.items is not None: # Add new items if data.items.add: for item_data in data.items.add: models.OrderItem.objects.create( order=order, product_name=item_data.product_name, quantity=item_data.quantity, ) # Remove items if data.items.remove: item_ids = [item.id for item in data.items.remove] models.OrderItem.objects.filter(id__in=item_ids, order=order).delete() return models.Order.objects.get(pk=order.pk) ``` ### Custom Validation ```python title="mutations.py" from django.core.exceptions import ValidationError @strawberry.type class Mutation: @strawberry_django.mutation(handle_django_errors=True) @transaction.atomic def create_order_with_items( self, customer_name: str, items: list[OrderItemInput] ) -> Order: # Validate before creating if not items: raise ValidationError({"items": "At least one item is required"}) # Create order order = models.Order.objects.create(customer_name=customer_name) # Create items for item_data in items: models.OrderItem.objects.create( order=order, product_name=item_data.product_name, quantity=item_data.quantity, ) return order ``` ## Best Practices 1. **Use automatic mutations when possible** - They handle most cases and are less error-prone 2. **Always use `@transaction.atomic`** for manual mutations that modify multiple objects 3. **Return fresh objects** from mutations by refetching from the database 4. **Validate early** before performing database operations 5. **Use `@strawberry_django.partial`** for update mutations to support optional fields ## Common Patterns ### Conditional Updates Only update relationships when explicitly provided: ```python @strawberry_django.mutation(handle_django_errors=True) def update_article(self, data: ArticleInputPartial) -> Article: article = models.Article.objects.get(pk=data.id) # Only update tags if provided (not UNSET) if data.tags is not strawberry.UNSET: if data.tags.set: article.tags.set(data.tags.set) if data.tags.add: article.tags.add(*data.tags.add) if data.tags.remove: article.tags.remove(*data.tags.remove) article.save() return article ``` ### Many-to-Many with Through Model For M2M relationships with extra fields: ```python title="models.py" class Student(models.Model): name = models.CharField(max_length=100) class Course(models.Model): name = models.CharField(max_length=100) students = models.ManyToManyField(Student, through="Enrollment") class Enrollment(models.Model): student = models.ForeignKey(Student, on_delete=models.CASCADE) course = models.ForeignKey(Course, on_delete=models.CASCADE) grade = models.CharField(max_length=2) ``` ```python title="mutations.py" @strawberry_django.input(models.Enrollment) class EnrollmentInput: student_id: auto grade: auto @strawberry.type class Mutation: @strawberry_django.mutation(handle_django_errors=True) @transaction.atomic def enroll_students( self, course_id: strawberry.ID, enrollments: list[EnrollmentInput] ) -> Course: course = models.Course.objects.get(pk=course_id) for enrollment in enrollments: models.Enrollment.objects.create( course=course, student_id=enrollment.student_id, grade=enrollment.grade ) return course ``` ## See Also - [Mutations](./mutations.md) - Basic mutation concepts - [Error Handling](./error-handling.md) - Handling validation and errors - [Relay](./relay.md) - Using Global IDs with NodeInput strawberry-graphql-django-0.82.1/docs/guide/optimizer.md000066400000000000000000000443151516173410200233020ustar00rootroot00000000000000--- title: Query Optimizer --- # Query Optimizer ## Features The query optimizer is a must-have extension for improved performance of your schema. What it does: 1. Call [QuerySet.select_related()](https://docs.djangoproject.com/en/4.2/ref/models/querysets/#select-related) on all selected foreign key relations by the query to avoid requiring an extra query to retrieve those 2. Call [QuerySet.prefetch_related()](https://docs.djangoproject.com/en/4.2/ref/models/querysets/#prefetch-related) on all selected many-to-one/many-to-many relations by the query to avoid requiring an extra query to retrieve those. 3. Call [QuerySet.only()](https://docs.djangoproject.com/en/4.2/ref/models/querysets/#only) on all selected fields to reduce the database payload and only requesting what is actually being selected 4. Call [QuerySet.annotate()](https://docs.djangoproject.com/en/4.2/ref/models/querysets/#annotate) to support any passed annotations of [Query Expressions](https://docs.djangoproject.com/en/4.2/ref/models/expressions/). Those are specially useful to avoid some common GraphQL pitfalls, like the famous `n+1` issue. ## Enabling the extension The automatic optimization can be enabled by adding the `DjangoOptimizerExtension` to your strawberry's schema config. ```python title="schema.py" import strawberry from strawberry_django.optimizer import DjangoOptimizerExtension schema = strawberry.Schema( Query, extensions=[ # other extensions... DjangoOptimizerExtension, ], ) ``` ### Extension Parameters The extension accepts several parameters to customize its behavior: ```python DjangoOptimizerExtension( enable_only_optimization=True, # Enable QuerySet.only() optimization enable_select_related_optimization=True, # Enable QuerySet.select_related() optimization enable_prefetch_related_optimization=True, # Enable QuerySet.prefetch_related() optimization enable_annotate_optimization=True, # Enable QuerySet.annotate() optimization enable_nested_relations_prefetch=True, # Enable prefetch of nested relations prefetch_custom_queryset=False, # Use default manager instead of base manager ) ``` | Parameter | Default | Description | | -------------------------------------- | ------- | -------------------------------------------------------------- | | `enable_only_optimization` | `True` | Enable `QuerySet.only()` to fetch only requested fields | | `enable_select_related_optimization` | `True` | Enable `QuerySet.select_related()` for FK relations | | `enable_prefetch_related_optimization` | `True` | Enable `QuerySet.prefetch_related()` for M2M/reverse relations | | `enable_annotate_optimization` | `True` | Enable `QuerySet.annotate()` for annotated fields | | `enable_nested_relations_prefetch` | `True` | Enable prefetch of nested relations with filters/pagination | | `prefetch_custom_queryset` | `False` | Use default manager instead of base manager for prefetches | > [!NOTE] > Setting `prefetch_custom_queryset=True` is useful when using `InheritanceManager` from django-model-utils, > as it ensures the correct manager is used for polymorphic queries. ## Usage The optimizer will try to optimize all types automatically by introspecting it. Consider the following example: ```python title="models.py" class Artist(models.Model): name = models.CharField() class Album(models.Model): name = models.CharField() release_date = models.DateTimeField() artist = models.ForeignKey("Artist", related_name="albums") class Song(models.Model): name = model.CharField() duration = models.DecimalField() album = models.ForeignKey("Album", related_name="songs") ``` ```python title="types.py" from strawberry import auto import strawberry_django @strawberry_django.type(Artist) class ArtistType: name: auto albums: list["AlbumType"] albums_count: int = strawberry_django.field(annotate=Count("albums")) @strawberry_django.type(Album) class AlbumType: name: auto release_date: auto artist: ArtistType songs: list["SongType"] @strawberry_django.type(Song) class SongType: name: auto duration: auto album_type: AlbumType @strawberry.type class Query: artist: Artist = strawberry_django.field() songs: list[SongType] = strawberry_django.field() ``` Querying for `artist` and `songs` like this: ```graphql title="schema.graphql" query { artist { id name albums { id name songs { id name } } albumsCount } song { id album { id name artist { id name albums { id name release_date } } } } } ``` Would produce an ORM query like this: ```python # For "artist" query Artist.objects.all().only("id", "name").prefetch_related( Prefetch( "albums", queryset=Album.objects .all() .only("id", "name") .prefetch_related( Prefetch( "songs", Song.objects.all().only("id", "name"), ) ), ), ).annotate(albums_count=Count("albums")) # For "songs" query Song.objects.all().only( "id", "album", "album__id", "album__name", "album__release_date", # Note about this below "album__artist", "album__artist__id", ).select_related( "album", "album__artist", ).prefetch_related( Prefetch( "album__artist__albums", Album.objects.all().only("id", "name", "release_date"), ) ) ``` > [!NOTE] > Even though `album__release_date` field was not selected here, it got selected > in the prefetch query later. Since Django caches known objects, we have to select it here or > else it would trigger extra queries latter. ## Optimization hints Sometimes you will have a custom resolver which cannot be automatically optimized by the extension. Take this for example: ```python title="models.py" class OrderItem(models.Model): price = models.DecimalField() quantity = models.IntegerField() @property def total(self) -> decimal.Decimal: return self.price * self.quantity ``` ```python title="types.py" from strawberry import auto import strawberry_django @strawberry_django.type(models.OrderItem) class OrderItem: price: auto quantity: auto total: auto ``` In this case, if only `total` is requested it would trigger an extra query for both `price` and `quantity` because both had their value retrievals [defered](https://docs.djangoproject.com/en/4.2/ref/models/querysets/#django.db.models.query.QuerySet.defer) by the optimizer. A solution in this case would be to "tell the optimizer" how to optimize that field: ```python title="types.py" from strawberry import auto import strawberry_django @strawberry_django.type(models.OrderItem) class OrderItem: price: auto quantity: auto total: auto = strawberry_django.field( only=["price", "quantity"], ) ``` Or if you are using a custom resolver: ```python title="types.py" import decimal from strawberry import auto import strawberry_django @strawberry_django.type(models.OrderItem) class OrderItem: price: auto quantity: auto @strawberry_django.field(only=["price", "quantity"]) def total(self, root: models.OrderItem) -> decimal.Decimal: return root.price * root.quantity # or root.total directly ``` The following options are accepted for optimizer hints: - `only`: a list of fields in the same format as accepted by [QuerySet.only()](https://docs.djangoproject.com/en/4.2/ref/models/querysets/#only) - `select_related`: a list of relations to join using [QuerySet.select_related()](https://docs.djangoproject.com/en/4.2/ref/models/querysets/#select-related) - `prefetch_related`: a list of relations to prefetch using [QuerySet.prefetch_related()](https://docs.djangoproject.com/en/4.2/ref/models/querysets/#prefetch-related). The options here are strings or a callable in the format of `Callable[[Info], Prefetch]` (e.g. `prefetch_related=[lambda info: Prefetch(...)]`) - `annotate`: a dict of expressions to annotate using [QuerySet.annotate()](https://docs.djangoproject.com/en/4.2/ref/models/querysets/#annotate). The keys of this dict are strings, and each value is a [Query Expression](https://docs.djangoproject.com/en/4.2/ref/models/expressions/) or a callable in the format of `Callable[[Info], BaseExpression]` (e.g. `annotate={"total": lambda info: Sum(...)}`) ## Optimization hints on model (ModelProperty) It is also possible to include type hints directly in the models' `@property` to allow it to be resolved with `auto`, while the GraphQL schema doesn't have to worry about its internal logic. For that this integration provides 2 decorators that can be used: - `strawberry_django.model_property`: similar to `@property` but accepts optimization hints - `strawberry_django.cached_model_property`: similar to `@cached_property` but accepts optimization hints The example in the previous section could be written using `@model_property` like this: ```python title="models.py" from strawberry_django.descriptors import model_property class OrderItem(models.Model): price = models.DecimalField() quantity = models.IntegerField() @model_property(only=["price", "quantity"]) def total(self) -> decimal.Decimal: return self.price * self.quantity ``` ```python title="types.py" from strawberry import auto import strawberry_django @strawberry_django.type(models.OrderItem) class OrderItem: price: auto quantity: auto total: auto ``` `total` now will be properly optimized since it points to a `@model_property` decorated attribute, which contains the required information for optimizing it. ## Optimizing polymorphic queries The optimizer has dedicated support for polymorphic queries, that is, fields which return an interface. The optimizer will handle optimizing any subtypes of the interface as necessary. This is supported on top level queries as well as relations between models. See the following sections for how this interacts with your models. ### Using Django Polymorphic If you are already using the [Django Polymorphic](https://django-polymorphic.readthedocs.io/en/stable/) library, polymorphic queries work out of the box. ```python title="models.py" from django.db import models from polymorphic.models import PolymorphicModel class Project(PolymorphicModel): topic = models.CharField(max_length=255) class ResearchProject(Project): supervisor = models.CharField(max_length=30) class ArtProject(Project): artist = models.CharField(max_length=30) ``` ```python title="types.py" import strawberry import strawberry_django from . import models @strawberry_django.interface(models.Project) class ProjectType: topic: strawberry.auto @strawberry_django.type(models.ResearchProject) class ResearchProjectType(ProjectType): supervisor: strawberry.auto @strawberry_django.type(models.ArtProject) class ArtProjectType(ProjectType): artist: strawberry.auto @strawberry.type class Query: projects: list[ProjectType] = strawberry_django.field() ``` The `projects` field will return either ResearchProjectType or ArtProjectType, matching on whether it is a ResearchProject or ArtProject. The optimizer will make sure to only select those fields from subclasses which are requested in the GraphQL query in the same way that it does normally. > [!WARNING] > The optimizer does not filter your QuerySet and Django will return > all instances of your model, regardless of whether their type exists in your GraphQL schema or not. > Make sure you have a corresponding type for every model subclass or add a `get_queryset` method to your > GraphQL interface type to filter out unwanted subtypes. > Otherwise you might receive an error like > `Abstract type 'ProjectType' must resolve to an Object type at runtime for field 'Query.projects'.` ### Using Model-Utils InheritanceManager Models using `InheritanceManager` from [django-model-utils](https://django-model-utils.readthedocs.io/en/latest/) are also supported. ```python title="models.py" from django.db import models from model_utils.managers import InheritanceManager class Project(models.Model): topic = models.CharField(max_length=255) objects = InheritanceManager() class ResearchProject(Project): supervisor = models.CharField(max_length=30) class ArtProject(Project): artist = models.CharField(max_length=30) ``` ```python title="types.py" import strawberry import strawberry_django from . import models @strawberry_django.interface(models.Project) class ProjectType: topic: strawberry.auto @strawberry_django.type(models.ResearchProject) class ResearchProjectType(ProjectType): supervisor: strawberry.auto @strawberry_django.type(models.ArtProject) class ArtProjectType(ProjectType): artist: strawberry.auto @strawberry.type class Query: projects: list[ProjectType] = strawberry_django.field() ``` The `projects` field will return either ResearchProjectType or ArtProjectType, matching on whether it is a ResearchProject or ArtProject. The optimizer automatically calls `select_subclasses`, passing in any subtypes present in your schema. > [!WARNING] > The optimizer does not filter your QuerySet and Django will return > all instances of your model, regardless of whether their type exists in your GraphQL schema or not. > Make sure you have a corresponding type for every model subclass or add a `get_queryset` method to your > GraphQL interface type to filter out unwanted subtypes. > Otherwise you might receive an error like > `Abstract type 'ProjectType' must resolve to an Object type at runtime for field 'Query.projects'.` > [!NOTE] > If you have polymorphic relations (as in: a field that points to a model with subclasses), you need to make sure > the manager being used to look up the related model is an `InheritanceManager`. > Strawberry Django uses the model's [base manager](https://docs.djangoproject.com/en/5.1/topics/db/managers/#base-managers) > by default, which is different from the standard `objects`. > Either change your base manager to also be an `InheritanceManager` or set Strawberry Django to use the default > manager: `DjangoOptimizerExtension(prefetch_custom_queryset=True)`. ### Custom polymorphic solution The optimizer also supports polymorphism even if your models are not polymorphic. `resolve_type` in the GraphQL interface type is used to tell GraphQL the actual type that should be used. ```python title="models.py" from django.db import models class Project(models.Model): topic = models.CharField(max_length=255) supervisor = models.CharField(max_length=30) artist = models.CharField(max_length=30) ``` ```python title="types.py" import strawberry import strawberry_django from . import models @strawberry_django.interface(models.Project) class ProjectType: topic: strawberry.auto @classmethod def resolve_type(cls, value, info, parent_type) -> str: if not isinstance(value, models.Project): raise TypeError() if value.artist: return "ArtProjectType" if value.supervisor: return "ResearchProjectType" raise TypeError() @classmethod def get_queryset(cls, qs, info): return qs @strawberry_django.type(models.ResearchProject) class ResearchProjectType(ProjectType): supervisor: strawberry.auto @strawberry_django.type(models.ArtProject) class ArtProjectType(ProjectType): artist: strawberry.auto @strawberry.type class Query: projects: list[ProjectType] = strawberry_django.field() ``` > [!WARNING] > Make sure to add `get_queryset` to your interface type, to force the optimizer to use > `prefetch_related`, otherwise this technique will not work for relation fields. ## Temporarily Turning Off the Optimizer You can temporarily turn off the optimizer using the `disabled()` context manager: ```python from strawberry_django.optimizer import DjangoOptimizerExtension def my_resolver(info): # Optimizer is turned off within this block with DjangoOptimizerExtension.disabled(): # Manual optimization or custom logic here return MyModel.objects.select_related("relation").all() ``` This is useful when you need complete control over the queryset optimization. ## Troubleshooting ### Extra queries still occurring 1. **Check that the extension is enabled**: Ensure `DjangoOptimizerExtension` is in your schema's extensions list 2. **Verify field names match**: The optimizer uses field names to determine what to optimize. Ensure your GraphQL field names match the model field names or use optimization hints 3. **Check for custom resolvers**: Custom resolvers bypass automatic optimization. Use optimization hints (`only`, `select_related`, `prefetch_related`) on the field ### "Deferred field" errors If you see errors about accessing deferred fields, it usually means a property or method needs fields that weren't selected: ```python # Problem: total needs price and quantity, but they might not be selected @property def total(self): return self.price * self.quantity # Solution: Use optimization hints @strawberry_django.field(only=["price", "quantity"]) def total(self) -> Decimal: return self.price * self.quantity ``` ### Prefetch not working for nested relations For nested relations with filters, ordering, or pagination, ensure `enable_nested_relations_prefetch=True` (the default). If using custom connections, note that this optimization only works automatically with `ListConnection` and `DjangoListConnection`. ### Polymorphic queries not working 1. **With InheritanceManager**: Set `prefetch_custom_queryset=True` in the extension 2. **With Django Polymorphic**: Should work out of the box 3. **Custom solution**: Implement `resolve_type` and `get_queryset` on your interface type ### Performance still slow 1. Use Django Debug Toolbar to inspect actual queries being made 2. Check if the optimizer is being bypassed by custom resolvers 3. Consider using `@strawberry_django.field(annotate=...)` for computed fields to move computation to the database ## See Also - [Fields](./fields.md) - Field-level optimization hints - [Pagination](./pagination.md) - Pagination with optimization - [Relay](./relay.md) - Relay connections with optimization strawberry-graphql-django-0.82.1/docs/guide/ordering.md000066400000000000000000000146131516173410200230670ustar00rootroot00000000000000--- title: Ordering --- # Ordering `@strawberry_django.order_type` is the decorator used for defining ordering types and allows sorting by multiple fields. ```python title="types.py" @strawberry_django.order_type(models.Color) class ColorOrder: name: auto @strawberry_django.order_type(models.Fruit) class FruitOrder: name: auto color: ColorOrder | None ``` > [!TIP] > In most cases ordering fields should have `Optional` annotations and default value `strawberry.UNSET`. > Above `auto` annotation is wrapped in `Optional` automatically. > `UNSET` is automatically used for fields without `field` or with `strawberry_django.order_field`. The code above generates the following schema: ```graphql title="schema.graphql" enum Ordering { ASC ASC_NULLS_FIRST ASC_NULLS_LAST DESC DESC_NULLS_FIRST DESC_NULLS_LAST } input ColorOrder @oneOf { name: Ordering } input FruitOrder @oneOf { name: Ordering color: ColorOrder } ``` As you can see, every input is automatically annotated with `@oneOf`. To express ordering by multiple fields, a list is passed. ## Custom order methods You can define custom order method by defining your own resolver. ```python title="types.py" @strawberry_django.order_type(models.Fruit) class FruitOrder: name: auto @strawberry_django.order_field def discovered_by(self, value: bool, prefix: str) -> list[str]: if not value: return [] return [f"{prefix}discover_by__name", f"{prefix}name"] @strawberry_django.order_field def order_number( self, info: Info, queryset: QuerySet, value: strawberry_django.Ordering, # `auto` can be used instead prefix: str, ) -> tuple[QuerySet, list[str]] | list[str]: queryset = queryset.alias(_ordered_num=Count(f"{prefix}orders__id")) ordering = value.resolve(f"{prefix}_ordered_num") return queryset, [ordering] ``` > [!WARNING] > Do not use `queryset.order_by()` directly. Due to `order_by` not being chainable > operation, changes applied this way would be overridden later. > [!TIP] > The `strawberry_django.Ordering` type has convenient method `resolve` that can be used to > convert field's name to appropriate `F` object with correctly applied `asc()`, `desc()` method > with `nulls_first` and `nulls_last` arguments. The code above generates the following schema: ```graphql title="schema.graphql" enum Ordering { ASC ASC_NULLS_FIRST ASC_NULLS_LAST DESC DESC_NULLS_FIRST DESC_NULLS_LAST } input FruitOrder @oneOf { name: Ordering discoveredBy: bool orderNumber: Ordering } ``` #### Resolver arguments - `prefix` - represents the current path or position - **Required** - Important for nested ordering - In code below custom order `name` ends up ordering `Fruit` instead of `Color` without applying `prefix` ```python title="Why prefix?" @strawberry_django.order_type(models.Fruit) class FruitOrder: name: auto color: ColorOrder | None @strawberry_django.order_type(models.Color) class ColorOrder: @strawberry_django.order_field def name(self, value: bool, prefix: str): # prefix is "fruit_set__" if unused root object is ordered instead if value: return ["name"] return [] ``` ```graphql { fruits( ordering: [{color: {name: ASC}}] ) { ... } } ``` - `value` - represents graphql field type - **Required**, but forbidden for default `order` method - _must_ be annotated - used instead of field's return type - Using `auto` is the same as `strawberry_django.Ordering`. - `queryset` - can be used for more complex ordering - Optional, but **Required** for default `order` method - usually used to `annotate` `QuerySet` #### Resolver return For custom field methods two return values are supported - iterable of values acceptable by `QuerySet.order_by` -> `Collection[F | str]` - tuple with `QuerySet` and iterable of values acceptable by `QuerySet.order_by` -> `tuple[QuerySet, Collection[F | str]]` For default `order` method only second variant is supported. ### What about nulls? By default `null` values are ignored. This can be toggled as such `@strawberry_django.order_field(order_none=True)` ## Overriding the default `order` method Works similar to field order method, but: - is responsible for resolution of ordering for entire object - _must_ be named `order` - argument `queryset` is **Required** - argument `value` is **Forbidden** ```python title="types.py" @strawberry_django.order_type(models.Fruit) class FruitOrder: name: auto @strawberry_django.order_field def ordered( self, info: Info, queryset: QuerySet, value: strawberry_django.Ordering, prefix: str, ) -> tuple[QuerySet, list[str]] | list[str]: queryset = queryset.alias(_ordered_num=Count(f"{prefix}orders__id")) return queryset, [value.resolve(f"{prefix}_ordered_num")] @strawberry_django.order_field def order( self, info: Info, queryset: QuerySet, prefix: str, ) -> tuple[QuerySet, list[str]]: queryset = queryset.filter( ... # Do some query modification ) return strawberry_django.ordering.process_ordering_default( self, info=info, queryset=queryset, prefix=prefix, ) ``` > [!TIP] > As seen above `strawberry_django.ordering.process_ordering_default` function is exposed and can be > reused in custom methods. This provides the default ordering implementation. ## Adding orderings to types All fields and mutations inherit orderings from the underlying type by default. So, if you have a field like this: ```python title="types.py" @strawberry_django.type(models.Fruit, ordering=FruitOrder) class Fruit: ... ``` The `fruits` field will inherit the `ordering` of the type the same way as if it was passed to the field. ## Adding orderings directly into a field Orderings added into a field override the default order of this type. ```python title="schema.py" @strawberry.type class Query: fruit: Fruit = strawberry_django.field(ordering=FruitOrder) ``` ## Legacy Order The previous implementation (`@strawberry_django.order`) is still available, but deprecated and only provided to allow backwards-compatible schemas. It can be used together with `@strawberry_django.ordering.ordering`, however clients may only specify one or the other. You can still read the [documentation for it](legacy-ordering). strawberry-graphql-django-0.82.1/docs/guide/pagination.md000066400000000000000000000200361516173410200234030ustar00rootroot00000000000000--- title: Pagination --- # Pagination ## Default pagination An interface for limit/offset pagination can be use for basic pagination needs: ```python title="types.py" @strawberry_django.type(models.Fruit, pagination=True) class Fruit: name: auto @strawberry.type class Query: fruits: list[Fruit] = strawberry_django.field() ``` Would produce the following schema: ```graphql title="schema.graphql" type Fruit { name: String! } input OffsetPaginationInput { offset: Int! = 0 limit: Int = null } type Query { fruits(pagination: OffsetPaginationInput): [Fruit!]! } ``` And can be queried like: ```graphql title="schema.graphql" query { fruits(pagination: { offset: 0, limit: 2 }) { name } } ``` The `pagination` argument can be given to the type, which will enforce the pagination argument every time the field is annotated as a list, but you can also give it directly to the field for more control, like: ```python title="types.py" @strawberry_django.type(models.Fruit) class Fruit: name: auto @strawberry.type class Query: fruits: list[Fruit] = strawberry_django.field(pagination=True) ``` Which will produce the exact same schema. ### Default limit for pagination The default limit for pagination is set to `100`. This can be changed in the [strawberry django settings](./settings.md) to increase or decrease that number, or even set to `None` to set it to unlimited. ### Maximum limit for pagination To prevent clients from requesting too many records at once (which could cause performance issues), you can configure a maximum limit using the `PAGINATION_MAX_LIMIT` setting. When set, this will cap any limit value requested by clients, including `None` (unlimited) requests. For example, if you set `PAGINATION_MAX_LIMIT` to `1000`, a client requesting `limit: 5000` or `limit: null` will only receive a maximum of 1000 records. ```python title="settings.py" STRAWBERRY_DJANGO = { "PAGINATION_DEFAULT_LIMIT": 100, "PAGINATION_MAX_LIMIT": 1000, # Cap all pagination requests at 1000 records } ``` By default, `PAGINATION_MAX_LIMIT` is set to `None`, which means unlimited requests are allowed. This maintains backward compatibility but is not recommended for production environments. ### Custom pagination limits per field To configure it on a per field basis, you can define your own `OffsetPaginationInput` subclass and modify its default value, like: ```python @strawberry.input class MyOffsetPaginationInput(OffsetPaginationInput): limit: int = 250 # Pass it to the pagination argument when defining the type @strawberry_django.type(models.Fruit, pagination=MyOffsetPaginationInput) class Fruit: ... @strawberry.type class Query: # Or pass it to the pagination argument when defining the field fruits: list[Fruit] = strawberry_django.field(pagination=MyOffsetPaginationInput) ``` ## OffsetPaginated Generic For more complex pagination needs, you can use the `OffsetPaginated` generic, which alongside the `pagination` argument, will wrap the results in an object that contains the results and the pagination information, together with the `totalCount` of elements excluding pagination. ```python title="types.py" from strawberry_django.pagination import OffsetPaginated @strawberry_django.type(models.Fruit) class Fruit: name: auto @strawberry.type class Query: fruits: OffsetPaginated[Fruit] = strawberry_django.offset_paginated() ``` Would produce the following schema: ```graphql title="schema.graphql" type Fruit { name: String! } type PaginationInfo { limit: Int = null offset: Int! } type FruitOffsetPaginated { pageInfo: PaginationInfo! totalCount: Int! results: [Fruit]! } input OffsetPaginationInput { offset: Int! = 0 limit: Int = null } type Query { fruits(pagination: OffsetPaginationInput): [FruitOffsetPaginated!]! } ``` Which can be queried like: ```graphql title="schema.graphql" query { fruits(pagination: { offset: 0, limit: 2 }) { totalCount pageInfo { limit offset } results { name } } } ``` > [!NOTE] > OffsetPaginated follow the same rules for the default pagination limit, and can be configured > in the same way as explained above. ### Customizing queryset resolver It is possible to define a custom resolver for the queryset to either provide a custom queryset for it, or even to receive extra arguments alongside the pagination arguments. Suppose we want to pre-filter a queryset of fruits for only available ones, while also adding [ordering](./ordering.md) to it. This can be achieved with: ```python title="types.py" @strawberry_django.type(models.Fruit) class Fruit: name: auto price: auto @strawberry_django.order_type(models.Fruit) class FruitOrder: name: auto price: auto @strawberry.type class Query: @strawberry_django.offset_paginated(OffsetPaginated[Fruit], order=FruitOrder) def fruits(self, only_available: bool = True) -> QuerySet[Fruit]: queryset = models.Fruit.objects.all() if only_available: queryset = queryset.filter(available=True) return queryset ``` This would produce the following schema: ```graphql title="schema.graphql" type Fruit { name: String! price: Decimal! } type FruitOrder { name: Ordering price: Ordering } type PaginationInfo { limit: Int! offset: Int! } type FruitOffsetPaginated { pageInfo: PaginationInfo! totalCount: Int! results: [Fruit]! } input OffsetPaginationInput { offset: Int! = 0 limit: Int = null } type Query { fruits( onlyAvailable: Boolean! = true pagination: OffsetPaginationInput order: FruitOrder ): [FruitOffsetPaginated!]! } ``` ### Customizing the pagination Like other generics, `OffsetPaginated` can be customized to modify its behavior or to add extra functionality in it. For example, suppose we want to add the average price of the fruits in the pagination: ```python title="types.py" from decimal import Decimal from strawberry_django.pagination import OffsetPaginated from django.db.models import Avg @strawberry_django.type(models.Fruit) class Fruit: name: auto price: auto @strawberry.type class FruitOffsetPaginated(OffsetPaginated[Fruit]): @strawberry_django.field def average_price(self) -> Decimal: if self.queryset is None: return Decimal(0) return self.queryset.aggregate(Avg("price"))["price__avg"] @strawberry_django.field def paginated_average_price(self) -> Decimal: paginated_queryset = self.get_paginated_queryset() if paginated_queryset is None: return Decimal(0) return paginated_queryset.aggregate(Avg("price"))["price__avg"] @strawberry.type class Query: fruits: FruitOffsetPaginated = strawberry_django.offset_paginated() ``` Would produce the following schema: ```graphql title="schema.graphql" type Fruit { name: String! } type PaginationInfo { limit: Int = null offset: Int! } type FruitOffsetPaginated { pageInfo: PaginationInfo! totalCount: Int! results: [Fruit]! averagePrice: Decimal! paginatedAveragePrice: Decimal! } input OffsetPaginationInput { offset: Int! = 0 limit: Int = null } type Query { fruits(pagination: OffsetPaginationInput): [FruitOffsetPaginated!]! } ``` The following attributes/methods can be accessed in the `OffsetPaginated` class: - `queryset`: The queryset original queryset with any filters/ordering applied, but not paginated yet - `pagination`: The `OffsetPaginationInput` object, with the `offset` and `limit` for pagination - `get_total_count()`: Returns the total count of elements in the queryset without pagination - `get_paginated_queryset()`: Returns the queryset with pagination applied - `resolve_paginated(queryset, *, info, pagination, **kwargs)`: The classmethod that strawberry-django calls to create an instance of the `OffsetPaginated` class/subclass. ## Cursor pagination (aka Relay style pagination) Another option for pagination is to use a [relay style cursor pagination](https://graphql.org/learn/pagination). For this, you can leverage the [relay integration](./relay.md) provided by strawberry to create a relay connection. strawberry-graphql-django-0.82.1/docs/guide/performance.md000066400000000000000000000323211516173410200235530ustar00rootroot00000000000000--- title: Performance Optimization --- # Performance Optimization Performance is critical for GraphQL APIs, especially when dealing with complex queries and large datasets. This guide covers strategies to optimize your Strawberry Django application for maximum performance. ## Table of Contents - [Overview](#overview) - [The N+1 Query Problem](#the-n1-query-problem) - [Query Optimizer](#query-optimizer) - [DataLoaders](#dataloaders) - [Database Optimization](#database-optimization) - [Caching Strategies](#caching-strategies) - [Query Complexity](#query-complexity) - [Pagination](#pagination) - [Monitoring and Profiling](#monitoring-and-profiling) - [Best Practices](#best-practices) - [Common Patterns](#common-patterns) - [Troubleshooting](#troubleshooting) ## Overview GraphQL's flexibility can lead to performance issues if not handled properly. Key challenges: 1. **N+1 queries** - Multiple database queries for related objects 2. **Over-fetching** - Retrieving more data than needed 3. **Complex queries** - Deeply nested or expensive operations 4. **Duplicate queries** - Same data fetched multiple times Strawberry Django provides several tools to address these: - **Query Optimizer** - Automatic `select_related()` and `prefetch_related()` - **DataLoaders** - Batching and caching for custom data fetching - **Pagination** - Limit result sets to manageable sizes - **Caching** - Store computed results ## The N+1 Query Problem The N+1 problem occurs when fetching a list of objects (1 query) and then fetching related objects for each item (N queries). ### Example Problem ```python # models.py class Author(models.Model): name = models.CharField(max_length=100) class Book(models.Model): title = models.CharField(max_length=200) author = models.ForeignKey(Author, on_delete=models.CASCADE) # schema.py import strawberry import strawberry_django @strawberry_django.type(Author) class AuthorType: name: strawberry.auto @strawberry_django.type(Book) class BookType: title: strawberry.auto author: AuthorType # N+1 problem here! @strawberry.type class Query: @strawberry.field def books(self) -> list[BookType]: return Book.objects.all() ``` ```graphql query { books { # 1 query title author { # N queries (one per book!) name } } } ``` **Without optimization**: 1 + N queries (if 100 books = 101 queries!) ### Solution: Query Optimizer Extension ```python import strawberry from strawberry_django.optimizer import DjangoOptimizerExtension schema = strawberry.Schema( query=Query, extensions=[ DjangoOptimizerExtension(), # Automatically optimizes queries ], ) ``` **With optimizer**: 2 queries (1 for books + 1 JOIN for authors) The optimizer automatically: - Uses `select_related()` for foreign keys and one-to-one relationships - Uses `prefetch_related()` for many-to-many and reverse foreign keys - Adds `only()` to fetch only requested fields (turned off for mutations) - Handles nested relationships ## Query Optimizer The query optimizer analyzes your GraphQL query and optimizes the database queries. ### Basic Usage ```python import strawberry from strawberry_django.optimizer import DjangoOptimizerExtension schema = strawberry.Schema( query=Query, mutation=Mutation, extensions=[ DjangoOptimizerExtension(), ], ) ``` ### How It Works ```python # models.py class Publisher(models.Model): name = models.CharField(max_length=100) class Author(models.Model): name = models.CharField(max_length=100) publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE) class Book(models.Model): title = models.CharField(max_length=200) author = models.ForeignKey(Author, on_delete=models.CASCADE) isbn = models.CharField(max_length=13) ``` ```graphql query { books { title isbn author { name publisher { name } } } } ``` **Without optimizer**: ```python # Query 1: Get all books Book.objects.all() # Query 2-N: Get author for each book Author.objects.get(id=book.author_id) # Query N+1-2N: Get publisher for each author Publisher.objects.get(id=author.publisher_id) ``` **With optimizer**: ```python # Single optimized query Book.objects.all().select_related("author__publisher").only( "title", "isbn", "author__name", "author__publisher__name" ) ``` ### Manual Optimization Hints You can provide hints to the optimizer using field options: ```python import strawberry from strawberry_django import field @strawberry_django.type(Book) class BookType: title: str author: AuthorType = field( # Optimization hints select_related=["author__publisher"], prefetch_related=["author__books"], only=["author__name"], ) ``` ### Disabling Optimizer for Specific Fields ```python from strawberry_django import field @strawberry_django.type(Book) class BookType: title: str # Disable optimizer for custom logic @field(disable_optimization=True) def computed_field(self) -> str: # Custom logic that doesn't benefit from optimization return self.do_custom_calculation() ``` ### Annotate for Aggregations ```python from django.db.models import Count, Avg from strawberry_django import field @strawberry_django.type(Author) class AuthorType: name: str # Annotate with aggregation book_count: int = field(annotate={"book_count": Count("books")}) avg_rating: float = field(annotate={"avg_rating": Avg("books__rating")}) ``` ## DataLoaders For complex scenarios where the optimizer isn't enough, use DataLoaders. ### When to Use DataLoaders Use DataLoaders when: - Fetching data from external APIs - Complex computed values requiring multiple queries - Custom aggregations or calculations - Non-standard relationship patterns See the [DataLoaders Guide](dataloaders.md) for comprehensive documentation. ### Basic DataLoader Pattern ```python from strawberry.dataloader import DataLoader from typing import List async def load_authors(keys: List[int]) -> List[Author]: """Batch load authors by ID""" authors = Author.objects.filter(id__in=keys) author_map = {author.id: author for author in authors} return [author_map.get(key) for key in keys] # In context def get_context(): return {"author_loader": DataLoader(load_fn=load_authors)} # In resolver @strawberry.field async def author(self, info) -> Author: loader = info.context["author_loader"] return await loader.load(self.author_id) ``` ## Database Optimization Beyond GraphQL-specific optimizations, add database indexes for fields used in GraphQL filters and ordering: ```python class Book(models.Model): title = models.CharField(max_length=200, db_index=True) publication_date = models.DateField(db_index=True) author = models.ForeignKey(Author, on_delete=models.CASCADE) class Meta: indexes = [ models.Index(fields=["author", "publication_date"]), ] ``` Use database aggregations in GraphQL resolvers: ```python from django.db.models import Count, Avg @strawberry_django.type(models.Author) class Author: name: auto book_count: int = strawberry_django.field(annotate={"book_count": Count("books")}) avg_rating: float = strawberry_django.field( annotate={"avg_rating": Avg("books__rating")} ) ``` For general Django database optimization (bulk operations, efficient queries, etc.), see the [Django database optimization documentation](https://docs.djangoproject.com/en/stable/topics/db/optimization/). ## Caching Strategies Cache expensive resolver computations using Django's cache framework: ```python from django.core.cache import cache @strawberry.field def featured_books(self) -> List[BookType]: cache_key = "featured_books" cached = cache.get(cache_key) if cached is not None: return cached books = Book.objects.filter(is_featured=True)[:10] cache.set(cache_key, books, 3600) # Cache for 1 hour return books ``` > [!WARNING] > Don't use `@lru_cache` on instance methods as it can lead to memory leaks. Use Django's cache framework or `cached_property` instead. For cache configuration and invalidation strategies, see [Django's cache documentation](https://docs.djangoproject.com/en/stable/topics/cache/). ## Query Complexity Limit query complexity to prevent expensive operations using Strawberry's built-in extensions: ```python import strawberry from strawberry.extensions import QueryDepthLimiter schema = strawberry.Schema( query=Query, extensions=[ QueryDepthLimiter(max_depth=10), # Prevent deeply nested queries ], ) ``` For custom complexity analysis and rate limiting, see [Strawberry Extensions](https://strawberry.rocks/docs/guides/extensions). ## Pagination Always paginate large result sets. ### Offset Pagination ```python from strawberry_django.pagination import OffsetPaginationInput import strawberry_django from strawberry_django.pagination import OffsetPaginated @strawberry.type class Query: # Use built-in pagination support books: OffsetPaginated[BookType] = strawberry_django.field(pagination=True) ``` > [!TIP] > For production, use the built-in pagination support instead of manual slicing. See the [Pagination guide](./pagination.md) for details. ### Cursor Pagination (Relay) ```python from strawberry import relay import strawberry_django @strawberry.type class Query: books: relay.Connection[BookType] = strawberry_django.connection() # Efficiently handles large datasets # Better for infinite scroll # Stable across data changes ``` ## Monitoring and Profiling Use Django Debug Toolbar in development to identify N+1 queries: ```python # settings.py INSTALLED_APPS = [ "debug_toolbar", # ... ] MIDDLEWARE = [ "debug_toolbar.middleware.DebugToolbarMiddleware", # ... ] ``` Enable query logging to monitor database queries: ```python # settings.py LOGGING = { "version": 1, "handlers": { "console": { "class": "logging.StreamHandler", }, }, "loggers": { "django.db.backends": { "handlers": ["console"], "level": "DEBUG", }, }, } ``` ## Best Practices ### 1. Always Use the Query Optimizer ```python # Always include the optimizer extension schema = strawberry.Schema( query=Query, extensions=[ DjangoOptimizerExtension(), ], ) ``` ### 2. Paginate All List Queries ```python # Bad: Unbounded lists @strawberry.field def books(self) -> List[BookType]: return Book.objects.all() # Could return millions! # Good: Always paginate @strawberry.field def books( self, pagination: OffsetPaginationInput = OffsetPaginationInput(offset=0, limit=20) ) -> List[BookType]: return Book.objects.all()[pagination.offset : pagination.offset + pagination.limit] ``` ### 3. Add Database Indexes ```python # Index fields used in filters and ordering class Book(models.Model): title = models.CharField(max_length=200, db_index=True) publication_date = models.DateField(db_index=True) class Meta: indexes = [ models.Index(fields=["author", "publication_date"]), ] ``` ### 4. Cache Expensive Computations ```python from django.core.cache import cache @strawberry.field def statistics(self) -> StatisticsType: cached = cache.get("statistics") if cached: return cached stats = compute_expensive_statistics() cache.set("statistics", stats, 300) # 5 minutes return stats ``` ### 5. Monitor Query Performance Use Django Debug Toolbar in development and enable query logging to identify performance bottlenecks. ## Common Patterns ### Computed Fields with Annotations ```python from django.db.models import Count, Avg @strawberry_django.type(models.Author) class Author: name: auto book_count: int = strawberry_django.field(annotate={"book_count": Count("books")}) avg_rating: float = strawberry_django.field( annotate={"avg_rating": Avg("books__rating")} ) ``` ### Model Properties with Optimization Hints ```python from strawberry_django.descriptors import model_property from django.db.models import Count class Author(models.Model): name = models.CharField(max_length=100) @model_property(annotate={"_book_count": Count("books")}) def book_count(self) -> int: return self._book_count # type: ignore ``` ## Troubleshooting ### Too Many Database Queries Enable query logging to identify N+1 queries. Ensure the [Query Optimizer](./optimizer.md) extension is registered and you're using `strawberry_django` types. ### Slow Aggregations Use database-level aggregations with `annotate` instead of Python-level counting: ```python from django.db.models import Count # ❌ Slow: N queries for author in Author.objects.all(): book_count = author.books.count() # ✅ Fast: Single query with annotation authors = Author.objects.annotate(book_count=Count("books")) ``` ### Memory Issues Always paginate large result sets. See the [Pagination guide](./pagination.md) for details. ## See Also - [Query Optimizer](optimizer.md) - Detailed optimizer documentation - [DataLoaders](dataloaders.md) - DataLoader patterns and usage - [Pagination](pagination.md) - Pagination strategies - [Django Database Optimization](https://docs.djangoproject.com/en/stable/topics/db/optimization/) - Django's optimization guide strawberry-graphql-django-0.82.1/docs/guide/permissions.md000066400000000000000000000231321516173410200236250ustar00rootroot00000000000000--- title: Permissions --- # Permissions This integration exposes field extensions to use [Django's Permission System](https://docs.djangoproject.com/en/4.2/topics/auth/default/) for checking permissions on GraphQL fields. It supports protecting any field for cases like: - The user is authenticated - The user is a superuser - The user or a group they belong to has a given permission - The user or the group they belong to has a given permission to the resolved value - The user or the group they belong to has a given permission to the parent of the field - etc ## How it works ```mermaid graph TD A[Extension Check for Permissions] --> B; B[User Passes Checks] -->|Yes| BF[Return Resolved Value]; B -->|No| C; C[Can return 'OperationInfo'?] -->|Yes| CF[Return 'OperationInfo']; C -->|No| D; D[Field is Optional] -->|Yes| DF[Return 'None']; D -->|No| E; E[Field is a 'List'] -->|Yes| EF[Return an empty 'List']; E -->|No| F; F[Field is a relay 'Connection'] -->|Yes| FF[Return an empty relay 'Connection']; F -->|No| GF[Raises 'PermissionDenied' error]; ``` ## Basic Example ```python title="types.py" import strawberry_django from strawberry_django.permissions import ( IsAuthenticated, HasPerm, HasSourcePerm, HasRetvalPerm, ) @strawberry_django.type class SomeType: login_required_field: RetType = strawberry_django.field( # will check if the user is authenticated extensions=[IsAuthenticated()], ) perm_required_field: OtherType = strawberry_django.field( # will check if the user has `"some_app.some_perm"` permission extensions=[HasPerm("some_app.some_perm")], ) obj_perm_required_field: OtherType = strawberry_django.field( # will check the permission for the resolved value extensions=[HasRetvalPerm("some_app.some_perm")], ) ``` ## Available Permission Extensions ### IsAuthenticated Checks if the user is authenticated and active. ```python from strawberry_django.permissions import IsAuthenticated @strawberry_django.field(extensions=[IsAuthenticated()]) def protected_field(self) -> str: return "secret" ``` **Parameters:** - `message: str` - Custom error message (default: "User is not authenticated.") - `fail_silently: bool` - If `True`, return `None`/empty instead of raising error (default: `True`) ### IsStaff Checks if the user is a staff member (`user.is_staff`). ```python from strawberry_django.permissions import IsStaff @strawberry_django.field(extensions=[IsStaff()]) def admin_field(self) -> str: return "admin only" ``` **Parameters:** - `message: str` - Custom error message (default: "User is not a staff member.") - `fail_silently: bool` - If `True`, return `None`/empty instead of raising error (default: `True`) ### IsSuperuser Checks if the user is a superuser (`user.is_superuser`). ```python from strawberry_django.permissions import IsSuperuser @strawberry_django.field(extensions=[IsSuperuser()]) def superuser_field(self) -> str: return "superuser only" ``` **Parameters:** - `message: str` - Custom error message (default: "User is not a superuser.") - `fail_silently: bool` - If `True`, return `None`/empty instead of raising error (default: `True`) ### HasPerm Checks if the user has global (model-level) permissions. ```python from strawberry_django.permissions import HasPerm @strawberry_django.field(extensions=[HasPerm("app.add_model")]) def create_something(self) -> Model: ... ``` **Parameters:** | Parameter | Type | Default | Description | | ---------------- | ------------------ | -------------------------------- | ------------------------------------------------------------- | | `perms` | `str \| list[str]` | required | Permission(s) to check (e.g., `"app.permission"`) | | `any_perm` | `bool` | `True` | If `True`, user needs ANY of the perms; if `False`, needs ALL | | `with_anonymous` | `bool` | `True` | If `True`, anonymous users automatically fail (optimization) | | `with_superuser` | `bool` | `False` | If `True`, superusers bypass permission checks | | `message` | `str` | `"You don't have permission..."` | Custom error message | | `fail_silently` | `bool` | `True` | If `True`, return `None`/empty instead of raising error | **Examples:** ```python # Require ALL permissions @strawberry_django.field( extensions=[HasPerm(["app.view_model", "app.change_model"], any_perm=False)] ) def sensitive_field(self) -> str: ... # Allow superusers to bypass @strawberry_django.field( extensions=[HasPerm("app.special_permission", with_superuser=True)] ) def special_field(self) -> str: ... # Raise error instead of returning None @strawberry_django.field( extensions=[HasPerm("app.required_permission", fail_silently=False)] ) def required_field(self) -> str: ... ``` ### HasSourcePerm Checks if the user has permission for the **parent/source object** of the field. This is useful when you want to check permissions on the object that contains the field, not the field's return value. ```python from strawberry_django.permissions import HasSourcePerm @strawberry_django.type(models.Document) class DocumentType: id: auto title: auto # Only show content if user has view permission on THIS document @strawberry_django.field(extensions=[HasSourcePerm("documents.view_document")]) def content(self) -> str: return self.content ``` **Parameters:** Same as `HasPerm` ### HasRetvalPerm Checks if the user has permission for the **resolved/returned value**. This is useful for: - Checking object-level permissions on query results - Filtering lists to only include objects the user can access ```python from strawberry_django.permissions import HasRetvalPerm @strawberry.type class Query: # Returns only documents the user has permission to view @strawberry_django.field(extensions=[HasRetvalPerm("documents.view_document")]) def documents(self) -> list[DocumentType]: return models.Document.objects.all() # Returns document only if user has permission, else None @strawberry_django.field(extensions=[HasRetvalPerm("documents.view_document")]) def document(self, id: int) -> DocumentType | None: return models.Document.objects.get(pk=id) ``` **Parameters:** Same as `HasPerm` **List Filtering:** When used on a field returning a list, `HasRetvalPerm` automatically filters out objects the user doesn't have permission for, rather than failing the entire query. ## Object-Level Permissions `HasSourcePerm` and `HasRetvalPerm` require an authentication backend that supports object permissions. This library works out of the box with [django-guardian](https://django-guardian.readthedocs.io/en/stable/). See the [django-guardian integration](../integrations/guardian.md) for setup instructions. ## No Permission Handling When permission checks fail, the following is returned (in priority order): 1. `OperationInfo`/`OperationMessage` if those types are allowed in the return type 2. `null` if the field is optional (e.g., `String` or `[String]`) 3. An empty list if the field is a list (e.g., `[String]!`) 4. An empty `Connection` if the return type is a relay connection 5. Otherwise, a `PermissionDenied` error is raised To always raise an error instead, set `fail_silently=False`: ```python @strawberry_django.field(extensions=[IsAuthenticated(fail_silently=False)]) def must_be_authenticated(self) -> str: ... ``` ## Combining Multiple Permissions You can apply multiple permission extensions to a single field: ```python @strawberry_django.field( extensions=[ IsAuthenticated(), HasPerm("app.special_permission"), ] ) def protected_field(self) -> str: ... ``` All extensions must pass for the field to resolve. ## Custom Error Messages All permission extensions accept a `message` parameter: ```python @strawberry_django.field( extensions=[ IsAuthenticated(message="Please log in to view this content"), HasPerm("premium.access", message="This requires a premium subscription"), ] ) def premium_content(self) -> str: ... ``` ## Custom Permission Extensions Create custom permission logic by subclassing `DjangoPermissionExtension`: ```python from strawberry_django.permissions import DjangoPermissionExtension, DjangoNoPermission class IsVerifiedEmail(DjangoPermissionExtension): DEFAULT_ERROR_MESSAGE = "Email verification required." def resolve_for_user( self, resolver, user, *, info, source, ): if not user or not user.is_authenticated: raise DjangoNoPermission if not getattr(user, "email_verified", False): raise DjangoNoPermission return resolver() ``` Usage: ```python @strawberry_django.field(extensions=[IsVerifiedEmail()]) def verified_only_field(self) -> str: ... ``` ## Schema Directives Permission extensions automatically add schema directives to your GraphQL schema, making permissions visible in introspection. This can be turned off: ```python @strawberry_django.field(extensions=[HasPerm("app.permission", use_directives=False)]) def field_without_directive(self) -> str: ... ``` ## See Also - [django-guardian Integration](../integrations/guardian.md) - Object-level permissions - [Authentication](./authentication.md) - User authentication - [Error Handling](./error-handling.md) - Handling permission errors strawberry-graphql-django-0.82.1/docs/guide/queries.md000066400000000000000000000253541516173410200227370ustar00rootroot00000000000000--- title: Queries --- # Queries Queries are the primary way to fetch data from your GraphQL API. Strawberry Django provides powerful tools to define queries that automatically map to Django models and querysets. ## Basic Queries Queries can be written using `strawberry_django.field()` to load the fields defined in your types. ```python title="schema.py" import strawberry import strawberry_django from .types import Fruit @strawberry.type class Query: fruit: Fruit = strawberry_django.field() fruits: list[Fruit] = strawberry_django.field() schema = strawberry.Schema(query=Query) ``` > [!TIP] > You must name your query class "Query" or decorate it with `@strawberry.type(name="Query")` for the single query default primary filter to work For single queries (like `fruit` above), Strawberry Django automatically provides a primary key filter. The `fruits` query returns all objects by default. ```graphql query { # Single object by primary key fruit(pk: "1") { id name } # All objects fruits { id name } } ``` ## Queries with Filters Add filters to your queries to enable flexible data filtering. Filters are defined in your types and automatically applied to queries. ```python title="types.py" import strawberry_django from strawberry import auto @strawberry_django.filter_type(models.Fruit, lookups=True) class FruitFilter: id: auto name: auto color: auto @strawberry_django.type(models.Fruit, filters=FruitFilter) class Fruit: id: auto name: auto color: auto ``` ```python title="schema.py" @strawberry.type class Query: fruits: list[Fruit] = strawberry_django.field() ``` This enables powerful filtering in your GraphQL queries: ```graphql query { # Exact match fruits(filters: { name: "apple" }) { id name } # With lookups fruits(filters: { name: { iContains: "berry" } }) { id name } # Complex filters with AND/OR fruits( filters: { OR: [{ name: { startsWith: "straw" } }, { color: { exact: "red" } }] } ) { id name } } ``` See the [Filtering Guide](./filters.md) for comprehensive filter documentation. ## Queries with Ordering Add ordering to control the sort order of your results. ```python title="types.py" import strawberry_django from strawberry import auto @strawberry_django.order_type(models.Fruit) class FruitOrder: name: auto price: auto @strawberry_django.type(models.Fruit, order=FruitOrder) class Fruit: id: auto name: auto price: auto ``` ```graphql query { # Order by name ascending fruits(order: { name: ASC }) { name price } # Order by multiple fields fruits(order: { price: DESC, name: ASC }) { name price } } ``` See the [Ordering Guide](./ordering.md) for detailed ordering documentation. ## Queries with Pagination Always paginate large result sets to prevent performance issues. ### Offset Pagination ```python title="schema.py" from strawberry_django.pagination import OffsetPaginationInput @strawberry.type class Query: @strawberry_django.field def fruits( self, pagination: OffsetPaginationInput | None = None, ) -> list[Fruit]: qs = models.Fruit.objects.all() if pagination: return qs[pagination.offset : pagination.offset + pagination.limit] return qs[:100] # Default limit ``` > [!NOTE] > Returning `list[Fruit]` means the queryset is evaluated immediately. For paginated queries with metadata (total count, page info), use `OffsetPaginated[Fruit]` with `pagination=True` instead. See the [Pagination guide](./pagination.md) for details. ```graphql query { fruits(pagination: { offset: 0, limit: 10 }) { id name } } ``` ### Relay-Style Cursor Pagination ```python title="schema.py" from strawberry_django.relay import DjangoListConnection import strawberry_django @strawberry.type class Query: fruits: DjangoListConnection[Fruit] = strawberry_django.connection() ``` ```graphql query { fruits(first: 10, after: "cursor") { edges { node { id name } cursor } pageInfo { hasNextPage endCursor } } } ``` See the [Pagination Guide](./pagination.md) for more pagination strategies. ## Custom Resolvers Override default resolvers for custom query logic. ```python title="schema.py" from strawberry.types import Info @strawberry.type class Query: @strawberry_django.field def fruits( self, info: Info, available_only: bool = True, ) -> list[Fruit]: qs = models.Fruit.objects.all() if available_only: qs = qs.filter(available=True) # Apply filters from info context return qs @strawberry_django.field def featured_fruits(self, info: Info) -> list[Fruit]: """Return only featured fruits""" return models.Fruit.objects.filter(is_featured=True, available=True).order_by( "-created_at" )[:5] ``` ```graphql query { # All available fruits fruits(availableOnly: true) { name } # Featured fruits only featuredFruits { name } } ``` See the [Custom Resolvers Guide](./resolvers.md) for advanced resolver patterns. ## Async Queries For ASGI applications, you can define async queries for improved concurrency. ```python title="schema.py" from asgiref.sync import sync_to_async @strawberry.type class Query: @strawberry_django.field async def fruits(self) -> list[Fruit]: # Django ORM calls must be wrapped in sync_to_async return await sync_to_async(list)(models.Fruit.objects.all()) @strawberry_django.field async def fruit(self, pk: int) -> Fruit | None: try: return await sync_to_async(models.Fruit.objects.get)(pk=pk) except models.Fruit.DoesNotExist: return None ``` > [!WARNING] > Always wrap Django ORM operations in `sync_to_async` when using async resolvers. The ORM is not async-native. ## Nested Queries and Relationships Strawberry Django automatically handles related objects with optimal queries using the Query Optimizer. ```python title="models.py" class Author(models.Model): name = models.CharField(max_length=100) class Book(models.Model): title = models.CharField(max_length=200) author = models.ForeignKey(Author, on_delete=models.CASCADE) ``` ```python title="types.py" @strawberry_django.type(models.Author) class Author: id: auto name: auto books: list["Book"] @strawberry_django.type(models.Book) class Book: id: auto title: auto author: Author ``` ```graphql query { books { title author { name books { title } } } } ``` The Query Optimizer automatically adds `select_related()` and `prefetch_related()` to prevent N+1 queries. See the [Query Optimizer Guide](./optimizer.md) for optimization details. ## Query Arguments Add custom arguments to your queries for flexibility. ```python title="schema.py" from datetime import date @strawberry.type class Query: @strawberry_django.field def books( self, author_id: int | None = None, published_after: date | None = None, min_rating: float | None = None, ) -> list[Book]: qs = models.Book.objects.all() if author_id: qs = qs.filter(author_id=author_id) if published_after: qs = qs.filter(publication_date__gte=published_after) if min_rating: qs = qs.filter(rating__gte=min_rating) return qs ``` ```graphql query { books(authorId: 1, publishedAfter: "2020-01-01", minRating: 4.0) { title rating } } ``` ## Aggregations and Computed Fields Use annotations for database-level aggregations. ```python title="types.py" from django.db.models import Count, Avg import strawberry_django @strawberry_django.type(models.Author) class Author: id: auto name: auto book_count: int = strawberry_django.field(annotate={"book_count": Count("books")}) avg_book_rating: float = strawberry_django.field( annotate={"avg_book_rating": Avg("books__rating")} ) ``` ```graphql query { authors { name bookCount avgBookRating } } ``` ## Best Practices 1. **Always enable the Query Optimizer** - Add `DjangoOptimizerExtension()` to your schema 2. **Paginate list queries** - Prevent loading thousands of records 3. **Use filters and ordering** - Let clients control what data they need 4. **Add appropriate indexes** - Index fields used in filters and ordering 5. **Use custom resolvers sparingly** - Default resolvers are optimized 6. **Leverage annotations** - Perform calculations in the database 7. **Test query performance** - Monitor SQL queries during development ## Complete Example ```python title="schema.py" import strawberry import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry_django.pagination import OffsetPaginationInput from . import models from .types import Fruit, FruitFilter, FruitOrder @strawberry.type class Query: # Single object query with PK filter fruit: Fruit = strawberry_django.field() # List query with filters, ordering, and pagination @strawberry_django.field def fruits( self, filters: FruitFilter | None = strawberry.UNSET, order: FruitOrder | None = strawberry.UNSET, pagination: OffsetPaginationInput | None = None, ) -> list[Fruit]: qs = models.Fruit.objects.all() # Filters and ordering are applied automatically # when using strawberry_django.field # Apply pagination if pagination: qs = qs[pagination.offset : pagination.offset + pagination.limit] else: qs = qs[:100] # Default limit return qs # Custom query with business logic @strawberry_django.field def featured_fruits(self) -> list[Fruit]: return models.Fruit.objects.filter(is_featured=True, available=True).order_by( "-created_at" )[:10] # Enable Query Optimizer schema = strawberry.Schema( query=Query, extensions=[ DjangoOptimizerExtension(), ], ) ``` ```graphql query { # Get single fruit fruit(pk: "1") { id name price } # List with filters and ordering fruits( filters: { name: { iContains: "berry" } } order: { price: DESC } pagination: { offset: 0, limit: 10 } ) { id name price } # Custom query featuredFruits { id name } } ``` ## See Also - [Filtering](./filters.md) - Comprehensive filtering guide - [Ordering](./ordering.md) - Sort and order results - [Pagination](./pagination.md) - Paginate large result sets - [Query Optimizer](./optimizer.md) - Prevent N+1 queries - [Custom Resolvers](./resolvers.md) - Advanced resolver patterns - [Relay](./relay.md) - Relay-style connections and nodes strawberry-graphql-django-0.82.1/docs/guide/relay.md000066400000000000000000000107421516173410200223710ustar00rootroot00000000000000--- title: Relay --- # Relay Support You can use the [official strawberry relay integration](https://strawberry.rocks/docs/guides/relay) directly with django types like this: ```python title="types.py" import strawberry import strawberry_django from strawberry_django.relay import DjangoListConnection class Fruit(models.Model): ... @strawberry_django.type(Fruit) class FruitType(relay.Node): ... @strawberry.type class Query: # Option 1: Default relay without totalCount # This is the default strawberry relay behaviour. # NOTE: you need to use strawberry_django.connection() - not the default strawberry.relay.connection() fruit: strawberry.relay.ListConnection[FruitType] = strawberry_django.connection() # Option 2: Strawberry django also comes with DjangoListConnection # this will allow you to get total-count on your query. fruit_with_total_count: DjangoListConnection[FruitType] = ( strawberry_django.connection() ) # Option 3: You can manually create resolver by your method manually. @strawberry_django.connection(DjangoListConnection[FruitType]) def fruit_with_custom_resolver(self) -> list[SomeModel]: return Fruit.objects.all() ``` Behind the scenes this extension is doing the following for you: - Automatically resolve the `relay.NodeID` field using the [model's pk](https://docs.djangoproject.com/en/4.2/ref/models/fields/#django.db.models.Field.primary_key) - Automatically generate resolves for connections that doesn't define one. For example, `some_model_conn` and `some_model_conn_with_total_count` will both define a custom resolver automatically that returns `SomeModel.objects.all()`. - Integrate connection resolution with all other features available in this lib. For example, [filters](filters.md), [ordering](ordering.md) and [permissions](permissions.md) can be used together with connections defined by strawberry django. You can also define your own `relay.NodeID` field and your resolve, in the same way as `some_model_conn_with_resolver` is doing. In those cases, they will not be overridden. > [!TIP] > If you are only working with types inheriting from `relay.Node` and `GlobalID` > for identifying objects, you might want to set `MAP_AUTO_ID_AS_GLOBAL_ID=True` > in your [strawberry django settings](./settings.md) to make sure `auto` fields gets > mapped to `GlobalID` on types and filters. Also, this lib exposes a `strawberry_django.relay.DjangoListConnection`, which works the same way as `strawberry.relay.ListConnection` does, but also exposes a `totalCount` attribute in the connection. For more customization options, like changing the pagination algorithm, adding extra fields to the `Connection`/`Edge` type, take a look at the [official strawberry relay integration](https://strawberry.rocks/docs/guides/relay) as those are properly explained there. ## Cursor based connections As an alternative to the default `ListConnection`, `DjangoCursorConnection` is also available. It supports pagination through a Django `QuerySet` via "true" cursors. `ListConnection` uses slicing to achieve pagination, which can negatively affect performance for huge datasets, because large page numbers require a large `OFFSET` in SQL. Instead, `DjangoCursorConnection` uses range queries such as `Q(due_date__gte=...)` for pagination. In combination with an Index, this makes for more efficient queries. `DjangoCursorConnection` requires a _strictly_ ordered `QuerySet`, that is, no two entries in the `QuerySet` must be considered equal by its ordering. `order_by('due_date')` for example is not strictly ordered, because two items could have the same due date. `DjangoCursorConnection` will automatically resolve such situations by also ordering by the primary key. When the order for the connection is configurable by the user (for example via [`@strawberry_django.order`](./ordering.md)) then cursors created by `DjangoCursorConnection` will not be compatible between different orders. The drawback of cursor based pagination is that users cannot jump to a particular page immediately. Therefor cursor based pagination is better suited for special use-cases like an infinitely scrollable list. Otherwise `DjangoCursorConnection` behaves like other connection classes: ```python @strawberry.type class Query: fruit: DjangoCursorConnection[FruitType] = strawberry_django.connection() @strawberry_django.connection(DjangoCursorConnection[FruitType]) def fruit_with_custom_resolver(self) -> list[Fruit]: return Fruit.objects.all() ``` strawberry-graphql-django-0.82.1/docs/guide/resolvers.md000066400000000000000000000050701516173410200232770ustar00rootroot00000000000000--- title: Resolvers --- # Custom Resolvers Basic resolvers are generated automatically once the types are declared. However it is possible to override them with custom resolvers. ## Sync resolvers Sync resolvers can be used in both ASGI/WSGI and will be automatically wrapped in `sync_to_async` when running async. ```python title="types.py" import strawberry_django from strawberry import auto from . import models @strawberry_django.type(models.Color) class Color: id: auto name: auto @strawberry_django.field def fruits(self) -> list[Fruit]: return self.fruit_set.filter(...) ``` ## Async resolvers Async resolvers can be used when running using ASGI. ```python title="types.py" import strawberry_django from strawberry import auto from . import models from asgiref.sync import sync_to_async @strawberry_django.type(models.Color) class Color: id: auto name: auto @strawberry_django.field async def fruits(self) -> list[Fruit]: return await sync_to_async(list)(self.fruit_set.filter(...)) ``` ## Optimizing resolvers When using custom resolvers together with the [Query Optimizer Extension](optimizer.md) you might need to give it a "hint" on how to optimize that field Take a look at the [optimization hints](optimizer.md#optimization-hints) docs for more information about this topic. ## Issues with Resolvers It is important to note that overriding resolvers also removes default capabilities (e.g. `Pagination`, `Filter`), exception for [relay connections](relay.md). You can however still add those by hand and resolve them: ```python title="types.py" import strawberry from strawberry import auto from strawberry.types import Info import strawberry_django from . import models @strawberry_django.filter_type(models.Fruit, lookups=True) class FruitFilter: id: auto name: auto @strawberry_django.order_type(models.Fruit) class FruitOrder: name: auto @strawberry_django.type(models.Fruit) class Fruit: id: auto name: auto @strawberry.type class Query: @strawberry_django.field def fruits( self, info: Info, filters: FruitFilter | None = strawberry.UNSET, order: FruitOrder | None = strawberry.UNSET, ) -> list[Fruit]: qs = models.Fruit.objects.all() # apply filters if defined if filters is not strawberry.UNSET: qs = strawberry_django.filters.apply(filters, qs, info) # apply ordering if defined if order is not strawberry.UNSET: qs = strawberry_django.ordering.apply(order, qs) return qs ``` strawberry-graphql-django-0.82.1/docs/guide/settings.md000066400000000000000000000100601516173410200231060ustar00rootroot00000000000000--- title: Settings --- # Django Settings Certain features of this library are configured using custom [Django settings](https://docs.djangoproject.com/en/4.2/topics/settings/). ## STRAWBERRY_DJANGO A dictionary with the following optional keys: - **`FIELD_DESCRIPTION_FROM_HELP_TEXT`** (default: `False`) If True, [GraphQL field's description](https://spec.graphql.org/draft/#sec-Descriptions) will be fetched from the corresponding Django model field's [`help_text` attribute](https://docs.djangoproject.com/en/4.1/ref/models/fields/#help-text). If a description is provided using [field customization](fields.md#field-customization), that description will be used instead. - **`TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING`** (default: `False`) If True, [GraphQL type descriptions](https://spec.graphql.org/draft/#sec-Descriptions) will be fetched from the corresponding Django model's [docstring](https://docs.python.org/3/glossary.html#term-docstring). If a description is provided using the [`strawberry_django.type` decorator](types.md#types-from-django-models), that description will be used instead. - **`MUTATIONS_DEFAULT_ARGUMENT_NAME`** (default: `"data"`) Change the [CUD mutations'](mutations.md#cud-mutations) default argument name when no option is passed (e.g. to `"input"`) - **`MUTATIONS_DEFAULT_HANDLE_ERRORS`** (default: `False`) Set the default behaviour of the [Django Errors Handling](mutations.md#django-errors-handling) when no option is passed. - **`GENERATE_ENUMS_FROM_CHOICES`** (default: `False`) If True, fields with `choices` will have automatically generate an enum of possibilities instead of being exposed as `String`. A better option is to use [Django's TextChoices/IntegerChoices](https://docs.djangoproject.com/en/4.2/ref/models/fields/#enumeration-types) with the [django-choices-field](../integrations/choices-field.md) integration. - **`MAP_AUTO_ID_AS_GLOBAL_ID`** (default: `False`) If True, `auto` fields that refer to model ids will be mapped to `relay.GlobalID` instead of `strawberry.ID`. This is mostly useful if all your model types inherit from `relay.Node` and you want to work only with `GlobalID`. - **`DEFAULT_PK_FIELD_NAME`** (default: `"pk"`) Change the [CRUD mutations'](mutations.md#cud-mutations) default primary key field. - **`USE_DEPRECATED_FILTERS`** (default: `False`) If True, [legacy filters](filters.md#legacy-filtering) are enabled. This is usefull for migrating from previous version. - **`PAGINATION_DEFAULT_LIMIT`** (default: `100`) Default limit for [pagination](pagination.md) when one is not provided by the client. Can be set to `None` to set it to unlimited. - **`PAGINATION_MAX_LIMIT`** (default: `None`) Maximum limit for [pagination](pagination.md) that can be requested by clients. When set, this will cap any limit value requested by clients, including `None` (unlimited) requests. This is useful to prevent clients from requesting too many records at once, which could cause performance issues. Can be set to `None` to allow unlimited requests (not recommended for production). - **`ALLOW_MUTATIONS_WITHOUT_FILTERS`** (default: `False`) If True, [CUD mutations](mutations.md#cud-mutations) will not require a filter to be specified. This is useful for cases where you want to allow mutations without any filtering, but it can lead to unintended side effects if not used carefully. These features can be enabled by adding this code to your `settings.py` file, like: ```python title="settings.py" STRAWBERRY_DJANGO = { "FIELD_DESCRIPTION_FROM_HELP_TEXT": True, "TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING": True, "MUTATIONS_DEFAULT_ARGUMENT_NAME": "input", "MUTATIONS_DEFAULT_HANDLE_ERRORS": True, "GENERATE_ENUMS_FROM_CHOICES": False, "MAP_AUTO_ID_AS_GLOBAL_ID": True, "DEFAULT_PK_FIELD_NAME": "id", "PAGINATION_DEFAULT_LIMIT": 250, "PAGINATION_MAX_LIMIT": 1000, "ALLOW_MUTATIONS_WITHOUT_FILTERS": True, } ``` strawberry-graphql-django-0.82.1/docs/guide/subscriptions.md000066400000000000000000000074661516173410200241750ustar00rootroot00000000000000--- title: Subscriptions --- # Subscriptions Subscriptions are supported using the [Strawberry Django Channels](https://strawberry.rocks/docs/integrations/channels) integration. This guide will give you a minimal working example to get you going. There are 3 parts to this guide: 1. Making Django compatible 2. Setup local testing 3. Creating your first subscription ## Making Django compatible It's important to realise that Django doesn't support websockets out of the box. To resolve this, we can help the platform along a little. This implementation is based on Django Channels - this means that should you wish - there is a lot more websockets fun to be had. If you're interested, head over to [Django Channels](https://channels.readthedocs.io). To add the base compatibility, go to your `MyProject.asgi.py` file and replace it with the following content. Ensure that you replace the relevant code with your setup. ```python # MyProject.asgi.py import os from django.core.asgi import get_asgi_application from strawberry_django.routers import AuthGraphQLProtocolTypeRouter os.environ.setdefault( "DJANGO_SETTINGS_MODULE", "MyProject.settings" ) # CHANGE the project name django_asgi_app = get_asgi_application() # Import your Strawberry schema after creating the django ASGI application # This ensures django.setup() has been called before any ORM models are imported # for the schema. from .schema import schema # CHANGE path to where you housed your schema file. application = AuthGraphQLProtocolTypeRouter( schema, django_application=django_asgi_app, ) ``` Note, django-channels allows for a lot more complexity. Here we merely cover the basic framework to get subscriptions to run on Django with minimal effort. Should you be interested in discovering the far more advanced capabilities of Django channels, head over to [channels docs](https://channels.readthedocs.io) ## Setup local testing The classic `./manage.py runserver` will not support subscriptions as it runs on WSGI mode. However, Django has ASGI server support out of the box through Daphne, which will override the runserver command to support our desired ASGI support. There are other asgi servers available, such as Uvicorn and Hypercorn. For the sake of simplicity we'll use Daphne as it comes with the runserver override. [Django Docs](https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/daphne/) This shouldn't stop you from using any of the other ASGI flavours in production or local testing like Uvicorn or Hypercorn To get started: Firstly, we need install Daphne to handle the workload, so let's install it: ```bash pip install daphne ``` Secondly, we need to add `daphne` to your settings.py file before 'django.contrib.staticfiles' ```python INSTALLED_APPS = [ ... 'daphne', 'django.contrib.staticfiles', ... ] ``` and add your `ASGI_APPLICATION` setting in your settings.py ```python # settings.py ... ASGI_APPLICATION = "MyProject.asgi.application" ... ``` Now you can run your test-server like as usual, but with ASGI support: ```bash ./manage.py runserver ``` ## Creating your first subscription Once you've taken care of those 2 setup steps, your first subscription is a breeze. Go and edit your schema-file and add: ```python import asyncio import strawberry @strawberry.type class Subscription: @strawberry.subscription async def count(self, target: int = 100) -> int: for i in range(target): yield i await asyncio.sleep(0.5) ``` That's pretty much it for this basic start. See for yourself by running your test server `./manage.py runserver` and opening `http://127.0.0.1:8000/graphql/` in your browser. Now run: ```graphql subscription { count(target: 10) } ``` You should see something like (where the count changes every .5s to a max of 9) ```json { "data": { "count": 9 } } ``` strawberry-graphql-django-0.82.1/docs/guide/troubleshooting.md000066400000000000000000000346261516173410200245130ustar00rootroot00000000000000--- title: Troubleshooting --- # Troubleshooting This guide covers common issues encountered when using Strawberry Django and their solutions. ## Installation and Setup Issues ### Module Not Found **Problem**: `ModuleNotFoundError: No module named 'strawberry_django'` **Solution**: Ensure the package is installed: ```bash pip install strawberry-graphql-django ``` ## Type and Field Resolution ### Auto Type Resolution Fails **Problem**: `strawberry.auto` doesn't resolve the correct type. **Solution**: Define custom type mapping for non-standard Django fields: ```python from strawberry_django.fields.types import field_type_map from django.db import models import strawberry # Map custom Django field to GraphQL type field_type_map.update({ models.SlugField: str, models.JSONField: strawberry.scalars.JSON, }) ``` See [Fields - Defining types for auto fields](./fields.md#defining-types-for-auto-fields) for more details. ### Forward Reference Errors **Problem**: `NameError: name 'SomeType' is not defined` **Solution**: Use string annotations for forward references: ```python @strawberry_django.type(models.Author) class Author: id: auto name: auto books: list["Book"] # String reference, not Book @strawberry_django.type(models.Book) class Book: id: auto title: auto author: "Author" # String reference ``` ### Type Annotation Issues with Ordering **Problem**: `TypeError: unsupported operand type(s) for |: 'str' and 'NoneType'` **Solution**: This occurs with self-referential ordering in Python 3.13+. Use `Optional` or proper forward references: ```python from typing import Optional @strawberry_django.order_type(models.User) class UserOrder: name: auto # ❌ creator: "UserOrder" # Fails in Python 3.13+ creator: Optional["UserOrder"] = None # ✅ Works ``` ## Query Optimization ### N+1 Query Problems **Problem**: Multiple database queries are executed for related objects. **Solution 1**: Enable the Query Optimizer Extension (recommended): ```python title="schema.py" from strawberry_django.optimizer import DjangoOptimizerExtension schema = strawberry.Schema( query=Query, extensions=[DjangoOptimizerExtension], ) ``` **Solution 2**: Use DataLoaders for custom batching: See [DataLoaders guide](./dataloaders.md) for details. **Solution 3**: Add optimization hints to model properties: ```python from strawberry_django.descriptors import model_property @model_property(select_related=["author"], only=["author__name"]) def author_name(self) -> str: return self.author.name ``` ### Annotated Fields Not Working in Nested Queries **Problem**: Annotations defined with `strawberry_django.field(annotate=...)` don't work in nested queries. **Cause**: The optimizer may not apply annotations correctly in deeply nested contexts. **Solution**: Use model-level annotations or custom DataLoaders: ```python from django.db.models import Count from strawberry_django.descriptors import model_property @model_property(annotate={"_book_count": Count("books")}) def book_count(self) -> int: return self._book_count # type: ignore ``` ### Deferred Field Access Issues **Problem**: Accessing a field triggers extra queries even with optimizer enabled. **Solution**: Add `only` hints to custom fields and model properties: ```python from decimal import Decimal @strawberry_django.field(only=["price", "quantity"]) def total(self) -> Decimal: return self.price * self.quantity ``` ## Mutations and Relationships ### Related Objects Not Appearing in Mutation Response **Problem**: After creating related objects in a mutation, they don't appear in the response. **Cause**: Django caches the related manager before the objects are created. **Solution**: Refresh the object or fetch it again: ```python from django.db import transaction @strawberry_django.mutation @transaction.atomic def create_author_with_books(self, data: AuthorInput) -> Author: author = models.Author.objects.create(name=data.name) for book_data in data.books: models.Book.objects.create( author=author, title=book_data.title, # Add other fields explicitly ) # ✅ Option 1: Refresh from database author.refresh_from_db() # ✅ Option 2: Fetch again (better for optimizer) # return models.Author.objects.get(pk=author.pk) return author ``` ### ListInput Not Updating Many-to-Many Relations **Problem**: Using `ListInput` with `set`, `add`, or `remove` doesn't update relationships. **Solution**: Ensure you're using the correct field type and handling the operations: ```python @strawberry_django.partial(models.Article) class ArticleInputPartial(NodeInput): title: auto tags: ListInput[strawberry.ID] | None = None # ✅ ListInput for M2M @strawberry_django.mutation def update_article(self, data: ArticleInputPartial) -> Article: article = models.Article.objects.get(pk=data.id) if data.tags is not strawberry.UNSET and data.tags is not None: if data.tags.set is not None: article.tags.set(data.tags.set) if data.tags.add is not None: article.tags.add(*data.tags.add) if data.tags.remove is not None: article.tags.remove(*data.tags.remove) article.save() return article ``` See [Nested Mutations guide](./nested-mutations.md) for comprehensive examples. ### Polymorphic Models in ListInput **Problem**: Updating M2M with polymorphic models removes objects incorrectly. **Cause**: Concrete model instances aren't matched with abstract base model instances in the existing set. **Workaround**: Use `InheritanceManager` with proper subclass selection: ```python from model_utils.managers import InheritanceManager class Project(models.Model): # ... objects = InheritanceManager() ``` Then ensure relationships use `select_subclasses()`: ```python existing = set( manager.select_subclasses() if isinstance(manager, InheritanceManager) else manager.all() ) ``` See [GitHub Issue #793](https://github.com/strawberry-graphql/strawberry-django/issues/793) for details. ### Validation Errors Not Showing Field Information **Problem**: Validation errors don't indicate which field had an error. **Solution**: Use Django's dict-style ValidationError: ```python from django.core.exceptions import ValidationError # ❌ No field info raise ValidationError("Invalid email") # ✅ With field info raise ValidationError({"email": "Invalid email address"}) # ✅ Multiple fields raise ValidationError({"email": "Invalid email address", "age": "Must be at least 18"}) ``` ## Permissions and Authentication ### Permission Extensions Not Working **Problem**: Permission checks are not enforced. **Cause**: Extensions may not be properly configured or the user context isn't set. **Solution**: Ensure proper setup: ```python # 1. Check extension is added to the field @strawberry_django.field(extensions=[IsAuthenticated()]) def sensitive_data(self) -> str: return self.data # 2. Ensure Django middleware is configured MIDDLEWARE = [ # ... "django.contrib.auth.middleware.AuthenticationMiddleware", # ... ] # 3. For async views, use AuthGraphQLProtocolTypeRouter from strawberry_django.routers import AuthGraphQLProtocolTypeRouter ``` ### Getting Current User Returns None **Problem**: `get_current_user(info)` returns `None` even when authenticated. **Solution**: Check request setup: ```python from strawberry_django.auth.utils import get_current_user def resolver(self, info: Info): request = info.context.request print(f"Authenticated: {request.user.is_authenticated}") # Debug user = get_current_user(info) return user ``` Ensure authentication middleware is properly configured and the view is set up correctly. ## Filters and Ordering ### DISTINCT Filter Causing Wrong totalCount **Problem**: Using `DISTINCT=true` in filters returns incorrect `totalCount` in connections. **Cause**: COUNT queries with DISTINCT on joined tables can produce incorrect results. **Workaround**: Use a subquery or custom totalCount resolver: ```python import strawberry @strawberry.field def total_count(self, root) -> int: # Custom count logic that handles DISTINCT properly return root.values("pk").distinct().count() ``` ### Filter on Relationships Not Working **Problem**: Filtering by related model fields doesn't work. **Solution**: Ensure the filter relationship chain is correct: ```python @strawberry_django.filter_type(models.Color) class ColorFilter: id: auto name: auto @strawberry_django.filter_type(models.Fruit) class FruitFilter: id: auto name: auto color: ColorFilter | None # ✅ Proper relationship filter ``` Query with correct nesting: ```graphql query { fruits(filters: { color: { name: "red" } }) { id name } } ``` ### Ordering Not Respecting Variable Order **Problem**: Multiple ordering fields don't respect the order specified in variables. **Cause**: Dict ordering may not be preserved in some Python versions or implementations. **Solution**: Use a list of ordering inputs with the new `@strawberry_django.order_type`: ```python @strawberry_django.order_type(models.Fruit) class FruitOrder: name: auto created: auto ``` Query: ```graphql query { fruits(ordering: [{ name: ASC }, { created: DESC }]) { id name } } ``` ## Relay and Global IDs ### Global ID Mapping Not Working **Problem**: `auto` fields for IDs aren't mapped to `GlobalID` even with `MAP_AUTO_ID_AS_GLOBAL_ID=True`. **Solution**: Ensure the setting is properly configured and types inherit from `relay.Node`: ```python title="settings.py" STRAWBERRY_DJANGO = { "MAP_AUTO_ID_AS_GLOBAL_ID": True, } ``` ```python title="types.py" from strawberry.relay import Node @strawberry_django.type(models.Fruit) class Fruit(Node): # ✅ Inherit from Node id: auto # Will be mapped to GlobalID name: auto ``` ### Custom Node Resolver Not Working **Problem**: Custom `resolve_node` logic isn't being called. **Solution**: Ensure you're overriding the correct method: ```python import strawberry from strawberry.relay import Node @strawberry_django.type(models.Fruit) class Fruit(Node): @classmethod def resolve_id(cls, root, info) -> strawberry.ID: # Custom ID resolution return strawberry.ID(f"custom_{root.pk}") @classmethod def resolve_nodes(cls, info, node_ids, required=False): # Custom node fetching return models.Fruit.objects.filter(pk__in=node_ids) ``` ## Subscriptions ### Subscriptions Not Working **Problem**: Websocket connections fail or subscriptions don't receive updates. **Solution**: Ensure proper ASGI and channels setup: ```python title="asgi.py" import os from django.core.asgi import get_asgi_application from strawberry_django.routers import AuthGraphQLProtocolTypeRouter os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") django_asgi_app = get_asgi_application() from .schema import schema application = AuthGraphQLProtocolTypeRouter( schema, django_application=django_asgi_app, ) ``` ```python title="settings.py" INSTALLED_APPS = [ "daphne", # Must be before 'django.contrib.staticfiles' "django.contrib.staticfiles", # ... ] ASGI_APPLICATION = "project.asgi.application" ``` ## Testing ### Async Test Errors **Problem**: `RuntimeError: no running event loop` in async tests. **Solution**: Use `pytest-asyncio` and mark tests properly: ```python import pytest @pytest.mark.django_db @pytest.mark.asyncio async def test_async_resolver(): result = await some_async_function() assert result is not None ``` ### Test Client Authentication Not Working **Problem**: Authentication doesn't persist in test client. **Solution**: Use the login context manager: ```python from strawberry_django.test.client import TestClient def test_authenticated_query(db): user = User.objects.create_user(username="test") client = TestClient("/graphql") with client.login(user): # ✅ Use context manager res = client.query(""" query { me { username } } """) assert res.data["me"]["username"] == "test" ``` ## Performance Issues ### Slow Queries with Large Datasets **Problem**: Queries are slow with large result sets. **Solutions**: 1. **Enable pagination**: ```python @strawberry_django.type(models.Fruit, pagination=True) class Fruit: name: auto ``` 2. **Use cursor-based pagination** for very large datasets: ```python from strawberry_django.relay import DjangoCursorConnection @strawberry.type class Query: fruits: DjangoCursorConnection[Fruit] = strawberry_django.connection() ``` 3. **Add database indexes** to filtered/ordered fields: ```python class Fruit(models.Model): name = models.CharField(max_length=100, db_index=True) created = models.DateTimeField(db_index=True) ``` ### Memory Issues with Large Responses **Problem**: Server runs out of memory with large queries. **Solution**: Implement pagination limits and use streaming: ```python title="settings.py" STRAWBERRY_DJANGO = { "PAGINATION_DEFAULT_LIMIT": 100, # Limit results } ``` ## IDE and Type Checking ### PyLance/Mypy Type Errors **Problem**: Type checker shows errors on `strawberry.auto` or casts. **Solution**: Use proper type annotations and casts: ```python from typing import cast @strawberry_django.mutation def create_fruit(self, name: str) -> Fruit: fruit = models.Fruit.objects.create(name=name) return cast(Fruit, fruit) # ✅ Help type checker ``` ## Getting Help If your issue isn't covered here: 1. **Check existing issues**: Search [GitHub Issues](https://github.com/strawberry-graphql/strawberry-django/issues) 2. **Check discussions**: Look at [GitHub Discussions](https://github.com/strawberry-graphql/strawberry-django/discussions) 3. **Join Discord**: Ask in the [Strawberry Discord](https://strawberry.rocks/discord) 4. **Review examples**: Check the [example app](https://github.com/strawberry-graphql/strawberry-django/tree/main/examples/django) 5. **Enable debug logging**: Add logging to see what's happening: ```python import logging logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger("strawberry_django") logger.setLevel(logging.DEBUG) ``` ## See Also - [Error Handling](./error-handling.md) - Handling errors in mutations - [Query Optimizer](./optimizer.md) - Understanding query optimization - [DataLoaders](./dataloaders.md) - Advanced data loading patterns - [FAQ](../faq.md) - Frequently asked questions strawberry-graphql-django-0.82.1/docs/guide/types.md000066400000000000000000000106461516173410200224240ustar00rootroot00000000000000--- title: Defining Types --- # Defining Types ## Output types > [!NOTE] > It is highly recommended to enable the [Query Optimizer Extension](optimizer.md) > for improved performance and avoid some common pitfalls (e.g. the `n+1` issue) Output types are generated from models. The `auto` type is used for field type auto resolution. Relational fields are described by referencing to other types generated from Django models. A many-to-many relation is described with the `typing.List` type annotation. `strawberry_django` will automatically generate resolvers for relational fields. More information about that can be read from [resolvers](resolvers.md) page. ```python title="types.py" import strawberry_django from strawberry import auto @strawberry_django.type(models.Fruit) class Fruit: id: auto name: auto color: "Color" @strawberry_django.type(models.Color) class Color: id: auto name: auto fruits: list[Fruit] ``` ## Input types Input types can be generated from Django models using the `strawberry_django.input` decorator. The first parameter is the model which the type is derived from. ```python title="types.py" @strawberry_django.input(models.Fruit) class FruitInput: id: auto name: auto color: "ColorInput" ``` A partial input type, in which all `auto`-typed fields are optional, is generated by setting the `partial` keyword argument in `input` to `True`. Partial input types can be generated from existing input types through class inheritance. Non-`auto` type annotations will be respected—and therefore required—unless explicitly marked `Optional[]`. ```python title="types.py" @strawberry_django.input(models.Fruit, partial=True) class FruitPartialInput(FruitInput): color: list["ColorPartialInput"] # Auto fields are optional @strawberry_django.input(models.Color, partial=True) class ColorPartialInput: id: auto name: auto fruits: list[FruitPartialInput] # Alternate input; "name" field will be required @strawberry_django.input(models.Color, partial=True) class ColorNameRequiredPartialInput: id: auto name: str fruits: list[FruitPartialInput] ``` ## Types from Django models Django models can be converted to `strawberry` Types with the `strawberry_django.type` decorator. Custom descriptions can be added using the `description` keyword argument (See: [`strawberry.type` decorator API](https://strawberry.rocks/docs/types/object-types#api)). ```python title="types.py" import strawberry_django @strawberry_django.type(models.Fruit, description="A tasty snack") class Fruit: ... ``` ### Adding fields to the type By default, no fields are implemented on the new type. Check the documentation on [How to define Fields](fields.md) for that. ### Customizing the returned `QuerySet` > [!WARNING] > By doing this you are modifying all automatic `QuerySet` generation for any field > that returns this type. Ideally you will want to define your own [resolver](resolvers.md) > instead, which gives you more control over it. By default, a `strawberry_django` type will get data from the default manager for its Django Model. You can implement a custom `get_queryset` classmethod to your type to do some extra processing to the default queryset, like filtering it further. ```python title="types.py" @strawberry_django.type(models.Fruit) class Berry: @classmethod def get_queryset(cls, queryset, info, **kwargs): return queryset.filter(name__contains="berry") ``` The `get_queryset` classmethod is given a `QuerySet` to filter and a `strawberry` `Info` object containing details about the request. You can use that `info` parameter to, for example, limit access to results based on the current user in the request: ```python title="types.py" from strawberry_django.auth.utils import get_current_user @strawberry_django.type(models.Fruit) class Berry: @classmethod def get_queryset(cls, queryset, info, **kwargs): user = get_current_user(info) if not user.is_staff: # Restrict access to top secret berries if the user is not a staff member queryset = queryset.filter(is_top_secret=False) return queryset.filter(name__contains="berry") ``` > [!NOTE] > Another way of limiting this is by using the [PermissionExtension](permissions.md) > provided by this lib. The `kwargs` dictionary can include other parameters that were added in a `@strawberry_django.type` definition like [filters](filters.md) or [pagination](pagination.md). strawberry-graphql-django-0.82.1/docs/guide/unit-testing.md000066400000000000000000000173401516173410200237100ustar00rootroot00000000000000--- title: Unit Testing --- # Unit Testing Unit testing can be done by following the [Strawberry's testing docs](https://strawberry.rocks/docs/operations/testing) reference. This library provides `TestClient` and `AsyncTestClient` that make it easier to run tests by mimicking calls to your GraphQL API with Django's test client. ## Installation The test clients are included with strawberry-django. No additional installation required. ## TestClient (Sync) The synchronous test client for testing regular Django views. ### Basic Usage ```python from strawberry_django.test.client import TestClient def test_query(db): client = TestClient("/graphql") response = client.query(""" query { fruits { id name } } """) assert response.errors is None assert response.data == {"fruits": [{"id": "1", "name": "Apple"}]} ``` ### Constructor ```python TestClient(path: str, client: Client | None = None) ``` | Parameter | Type | Description | | --------- | ---------------- | ------------------------------------------------------------------------------ | | `path` | `str` | The URL path to your GraphQL endpoint (e.g., `"/graphql"`) | | `client` | `Client \| None` | Optional Django test `Client` instance. If not provided, a new one is created. | ### query() Method ```python client.query( query: str, variables: dict[str, Any] | None = None, headers: dict[str, object] | None = None, files: dict[str, object] | None = None, assert_no_errors: bool = True, ) -> Response ``` | Parameter | Type | Default | Description | | ------------------ | ------ | -------- | ------------------------------------------ | | `query` | `str` | required | The GraphQL query/mutation string | | `variables` | `dict` | `None` | Variables to pass to the query | | `headers` | `dict` | `None` | HTTP headers to include in the request | | `files` | `dict` | `None` | Files for multipart uploads | | `assert_no_errors` | `bool` | `True` | Automatically assert no errors in response | ### Response Object The `query()` method returns a `Response` object: ```python @dataclass class Response: errors: list[GraphQLFormattedError] | None data: dict[str, object] | None extensions: dict[str, object] | None ``` ### Testing with Variables ```python def test_with_variables(db): client = TestClient("/graphql") response = client.query( """ query GetFruit($id: ID!) { fruit(id: $id) { name } } """, variables={"id": "1"}, ) assert response.data == {"fruit": {"name": "Apple"}} ``` ### Testing Mutations ```python def test_create_fruit(db): client = TestClient("/graphql") response = client.query( """ mutation CreateFruit($input: FruitInput!) { createFruit(input: $input) { id name } } """, variables={"input": {"name": "Banana", "color": "yellow"}}, ) assert response.errors is None assert response.data["createFruit"]["name"] == "Banana" ``` ### Testing with Authentication Use the `login()` context manager to simulate an authenticated user: ```python from django.contrib.auth import get_user_model User = get_user_model() def test_authenticated_query(db): user = User.objects.create_user(username="testuser", password="testpass") client = TestClient("/graphql") with client.login(user): response = client.query(""" query { me { username } } """) assert response.errors is None assert response.data == {"me": {"username": "testuser"}} ``` ### Testing with Custom Headers ```python def test_with_headers(db): client = TestClient("/graphql") response = client.query( """ query { protectedData } """, headers={"Authorization": "Bearer token123"}, ) assert response.errors is None ``` ### Testing File Uploads ```python from django.core.files.uploadedfile import SimpleUploadedFile def test_file_upload(db): client = TestClient("/graphql") test_file = SimpleUploadedFile( "test.txt", b"file content", content_type="text/plain" ) response = client.query( """ mutation UploadFile($file: Upload!) { uploadFile(file: $file) { success } } """, variables={"file": None}, files={"file": test_file}, ) assert response.errors is None ``` ### Expecting Errors When testing error cases, set `assert_no_errors=False`: ```python def test_validation_error(db): client = TestClient("/graphql") response = client.query( """ mutation { createFruit(input: {name: ""}) { id } } """, assert_no_errors=False, ) assert response.errors is not None assert "name" in response.errors[0]["message"].lower() ``` ## AsyncTestClient The asynchronous test client for testing async views and ASGI applications. ### Basic Usage ```python import pytest from strawberry_django.test.client import AsyncTestClient @pytest.mark.asyncio async def test_async_query(db): client = AsyncTestClient("/graphql") response = await client.query(""" query { fruits { id name } } """) assert response.errors is None ``` ### Constructor ```python AsyncTestClient(path: str, client: AsyncClient | None = None) ``` | Parameter | Type | Description | | --------- | --------------------- | -------------------------------------- | | `path` | `str` | The URL path to your GraphQL endpoint | | `client` | `AsyncClient \| None` | Optional Django `AsyncClient` instance | ### Async Login ```python @pytest.mark.asyncio async def test_async_authenticated(db): user = await sync_to_async(User.objects.create_user)( username="testuser", password="testpass" ) client = AsyncTestClient("/graphql") async with client.login(user): response = await client.query(""" query { me { username } } """) assert response.data == {"me": {"username": "testuser"}} ``` ## Using with pytest-django For pytest-django users, remember to use the `db` fixture: ```python import pytest @pytest.mark.django_db def test_with_database(): client = TestClient("/graphql") # ... your test @pytest.mark.django_db @pytest.mark.asyncio async def test_async_with_database(): client = AsyncTestClient("/graphql") # ... your async test ``` ## Using a Custom Django Client You can pass a pre-configured Django test client: ```python from django.test import Client def test_with_custom_client(db): django_client = Client(enforce_csrf_checks=True) client = TestClient("/graphql", client=django_client) # ... ``` ## Testing Subscriptions For testing subscriptions, refer to the [Strawberry WebSocket testing documentation](https://strawberry.rocks/docs/integrations/channels#testing). ## See Also - [Strawberry Testing Docs](https://strawberry.rocks/docs/operations/testing) - Core testing documentation - [Django Testing Docs](https://docs.djangoproject.com/en/4.2/topics/testing/) - Django's test framework - [pytest-django](https://pytest-django.readthedocs.io/) - pytest plugin for Django strawberry-graphql-django-0.82.1/docs/guide/validation.md000066400000000000000000000225151516173410200234100ustar00rootroot00000000000000--- title: Validation --- # Validation Strawberry Django integrates with Django's validation system to automatically validate GraphQL inputs using Django's model validation, field validators, and forms. ## Overview Validation in Strawberry Django happens automatically when using `handle_django_errors=True` in mutations. The system calls Django's `Model.full_clean()` before saving, which validates: - Field-level constraints and validators - Model-level validation in `clean()` methods - Unique constraints For complete details on Django validation, see the [Django Validators documentation](https://docs.djangoproject.com/en/stable/ref/validators/). ## Automatic Validation Use `handle_django_errors=True` to enable automatic validation: ```python import strawberry import strawberry_django from strawberry_django import mutations @strawberry_django.input(models.User) class UserInput: email: auto username: auto age: auto @strawberry.type class Mutation: create_user: User = mutations.create( UserInput, handle_django_errors=True, # Automatically validates ) ``` ## Controlling Validation with full_clean By default, mutations call `full_clean()` before saving. You can control this behavior: ### Disable Validation ```python @strawberry.type class Mutation: # Skip validation entirely create_user: User = mutations.create(UserInput, full_clean=False) ``` ### Customize Validation with FullCleanOptions Use `FullCleanOptions` to control what `full_clean()` validates: ```python from strawberry_django.mutations.types import FullCleanOptions @strawberry.type class Mutation: create_user: User = mutations.create( UserInput, full_clean=FullCleanOptions( exclude=["field_to_skip"], # Fields to exclude from validation validate_unique=True, # Check unique constraints (default: True) validate_constraints=True, # Check model constraints (default: True) ), ) ``` | Option | Type | Default | Description | | ---------------------- | ----------- | ------- | --------------------------------------- | | `exclude` | `list[str]` | `[]` | Fields to exclude from validation | | `validate_unique` | `bool` | `True` | Whether to run unique constraint checks | | `validate_constraints` | `bool` | `True` | Whether to run model constraint checks | This is useful when: - Some fields are set programmatically after initial validation - You want to skip unique checks for performance (and handle IntegrityError separately) - Certain validation rules don't apply in the GraphQL context When validation fails, errors are returned in the GraphQL response: ```graphql mutation { createUser(data: { email: "invalid", age: 15 }) { ... on User { id email } ... on OperationInfo { messages { field message kind } } } } ``` Response with validation errors: ```json { "data": { "createUser": { "messages": [ { "field": "email", "message": "Enter a valid email address", "kind": "VALIDATION" }, { "field": "age", "message": "Users must be at least 18 years old", "kind": "VALIDATION" } ] } } } ``` ## Model Validation Define validation logic in your Django models using the `clean()` method: ```python from django.db import models from django.core.exceptions import ValidationError class User(models.Model): email = models.EmailField(unique=True) age = models.IntegerField() username = models.CharField(max_length=50) def clean(self): """Custom model validation""" super().clean() # Always call parent first errors = {} if self.age < 18: errors["age"] = "Must be at least 18 years old" if self.username and len(self.username) < 3: errors["username"] = "Must be at least 3 characters" if errors: raise ValidationError(errors) ``` This validation runs automatically when using `handle_django_errors=True`. See [Django Model Validation](https://docs.djangoproject.com/en/stable/ref/models/instances/#validating-objects) for more details. ## Field Validators Django field validators work automatically with Strawberry Django: ```python from django.db import models from django.core.validators import MinValueValidator, RegexValidator class Product(models.Model): price = models.DecimalField( max_digits=10, decimal_places=2, validators=[MinValueValidator(0)] ) sku = models.CharField(max_length=20, validators=[RegexValidator(r"^[A-Z0-9-]+$")]) ``` See [Django Validators](https://docs.djangoproject.com/en/stable/ref/validators/) for built-in validators and how to create custom validators. ## Custom Mutation Validation For custom validation logic in mutations, raise `ValidationError` with field-specific errors: ```python import strawberry import strawberry_django from django.core.exceptions import ValidationError @strawberry.type class Mutation: @strawberry_django.mutation(handle_django_errors=True) def create_user(self, email: str, age: int) -> User: errors = {} if not email or "@" not in email: errors["email"] = "Invalid email address" if age < 18: errors["age"] = "Must be at least 18 years old" if errors: raise ValidationError(errors) return models.User.objects.create(email=email, age=age) ``` ## Async Validation For async mutations, use Django's async ORM methods (Django 4.1+): ```python import strawberry from django.core.exceptions import ValidationError @strawberry.type class Mutation: @strawberry.mutation async def create_article(self, title: str) -> Article: # Check for duplicate using async exists = await models.Article.objects.filter(title=title).aexists() if exists: raise ValidationError({"title": "Article with this title already exists"}) return await models.Article.objects.acreate(title=title) ``` For older Django versions, wrap ORM calls in `sync_to_async`. See [Django Async documentation](https://docs.djangoproject.com/en/stable/topics/async/) for details. ## Form Validation Integrate Django forms for complex validation: ```python import strawberry import strawberry_django from django import forms from django.core.exceptions import ValidationError class UserForm(forms.ModelForm): class Meta: model = User fields = ["email", "username", "age"] def clean_username(self): username = self.cleaned_data["username"] if User.objects.filter(username__iexact=username).exists(): raise ValidationError("Username already taken") return username @strawberry.type class Mutation: @strawberry_django.mutation def create_user(self, data: UserInput) -> User: form = UserForm({ "email": data.email, "username": data.username, "age": data.age, }) if not form.is_valid(): # Convert form errors to ValidationError error_dict = { field: error_list[0] for field, error_list in form.errors.items() } raise ValidationError(error_dict) return form.save() ``` See [Django Forms documentation](https://docs.djangoproject.com/en/stable/topics/forms/) for more on form validation. ## Best Practices 1. **Always use `handle_django_errors=True`** in mutations to enable automatic validation 2. **Put business logic in `Model.clean()`** instead of scattering it across resolvers 3. **Use dict-style ValidationError** for field-specific errors: ```python raise ValidationError({"field": "Error message"}) ``` 4. **Test validation** using the test client: ```python def test_validation(db): client = TestClient("/graphql") res = client.query(""" mutation { createUser(data: { email: "invalid", age: 15 }) { ... on OperationInfo { messages { field message } } } } """) assert res.data["createUser"]["messages"] ``` ## Common Issues ### Validation Not Running If validation isn't running automatically, ensure: 1. You're using `handle_django_errors=True` 2. You're using mutation generators or calling `full_clean()` manually ```python # ✅ Validation runs automatically create_user: User = mutations.create(UserInput, handle_django_errors=True) # ❌ Validation doesn't run user = User.objects.create(email="invalid") # Bypasses validation ``` ### Unique Constraint Errors Unique constraints raise `IntegrityError` instead of `ValidationError`. Validate in `clean()` to convert to field errors: ```python def clean(self): if User.objects.filter(email=self.email).exclude(pk=self.pk).exists(): raise ValidationError({"email": "Email already exists"}) ``` ## See Also - [Django Model Validation](https://docs.djangoproject.com/en/stable/ref/models/instances/#validating-objects) - [Django Validators](https://docs.djangoproject.com/en/stable/ref/validators/) - [Django Forms](https://docs.djangoproject.com/en/stable/topics/forms/) - [Error Handling](./error-handling.md) - Comprehensive error handling guide - [Mutations](./mutations.md) - Mutation basics strawberry-graphql-django-0.82.1/docs/guide/views.md000066400000000000000000000020571516173410200224120ustar00rootroot00000000000000--- title: Views --- # Serving the API Strawberry works both with ASGI (async) and WSGI (sync). This integration supports both ways of serving django. ASGI is the best way to enjoy everything that strawberry has to offer and is highly recommended unless you can't for some reason. By using WSGI you will be missing support for some interesting features, such as [Data Loaders](https://strawberry.rocks/docs/guides/dataloaders). # Serving as ASGI (async) Expose the strawberry API when using ASGI by setting your urls.py like this: ```python title="urls.py" from django.urls import path from strawberry.django.views import AsyncGraphQLView from .schema import schema urlpatterns = [ path("graphql", AsyncGraphQLView.as_view(schema=schema)), ] ``` # Serving WSGI (sync) Expose the strawberry API when using WSGI by setting your urls.py like this: ```python title="urls.py" from django.urls import path from strawberry.django.views import GraphQLView from .schema import schema urlpatterns = [ path("graphql", GraphQLView.as_view(schema=schema)), ] ``` strawberry-graphql-django-0.82.1/docs/images/000077500000000000000000000000001516173410200210775ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/docs/images/graphiql-with-fruit.png000066400000000000000000002636251516173410200255320ustar00rootroot00000000000000PNG  IHDR5kޣ\iCCPICC Profile(u=KBqaPHDCAD[}z})z#jji [4 p  "۹Y ?styU(nQzeÅ>&Zň$ f]RWS_pXz,x?}*Wpr22e)} m>v8bLRxP[S @_ƥ7F3ǂ 695 1169 Screenshot @IDATxE3E1O=pٿN=sn !i)@M     8-(2n4Ɋi%qG*VBZvtK8ɬ+iUy~x/tAFG!#ΎHHHHH@_d1)!!A{(L!P ;b;Pj1|[$@$@$@$@$@VHNNm„ r 'koWclx}|OVaxtbf,!Z6Q1F4Bee>+/dmUz~+)CПY+x W@Bh1= HHHH7^z ^|I@oa &ںuФPM6oܸѾ֭[&u;UWへ43fȱ=ZhR0SBnnvzhѢ>U|\<,Q:5ϖYu;Vrn^-(̑ IHHHH &OE7kWEok1 d:rw_N;]ZN}<澜 }eŴrnG H?,X@%1LMMqI׮]Bޙ:?Kv8CD^ m@D$3pqС}v"ad ~HzRH{ T|gYg%x3g͚_u6EnɢE_u['P'N?t9sLd(ඉm ppѣʚ5SeU=2\r|wyf!9D/?|e6 &۷^   h x3@vݙo+c f a΅s.݌wj޶cPCMXʔ)Gv] $S|ܹ:zvsʐye2o3&7x#vͼ/=^o(<\D4^!$a`cZ3-oJ@„ݝ}]w D#x{v#WKs=6oI&IJJJHD)S5"R>s@fϞ])zb\YQ棏>n@>}iF3?!͛7e^ȦkF| 8P9 0;{>"HJJr8 HHHBH7oJ \4j-VLVj3._満Tt_tdڰo~k]yeȑZPB_s.++K.]*ZiS .AHvQGf|7&/xfHHH#@@YQ/2s>lp ~i *ū̐tI/8^8Fn$x(!?ۧ "22SdT2wC~NUU!a̓mۮ=r~^]:"]wE$ڍ"Cbw, ̜~ ?% A HH<2#U$Wm;xĉr|p w)pićBृ?aX;<-o'pՕ.a+ȇ~h 7رcYNN Ń>x'{XBP4/ւB6lؠ N vviz<@wGj$瞓~Zo=z#<_wҽ{w=Yy?߿jO>ڕtǎ/zwȠA䪫cgpx Ἱ>O 9]Ёl`&95# 23Wmk 3}Ne0|?ѡ__},^X#T`ֹK ǓO>Y{ .;h?3f̐5kH˖-SN/XE'0`rƄJG7:0D0a M۶mmN |8# _1xAt'@0K>|G'B!ܭ^Z AkB bڃ-E^C~p~Z`x`8W 4V&T2`ʌxeu̫t5M>yKB됣&]{b!$asl푄2M,K]X=LPϖ_.|CM>Q[OgZ6OW|SkOsUl"ZgmS2 w4 ܯ`ꩧ8hxFFM^6Px ~ :2,ySЂ1 @@ڽ{P5cyu!Z 9ptgkA 1'j xC؁!jVykKce!v0kc+}$ShΜ9v%s*(?O( Wl"yB㬆oc C=Aرrs5<{g P7u4x=En=n `_$8:plx>HHH#"A<8cqT_ ye"#P:3uiG}i i,f :0c1/|<QI""2B~TUV Eܼ<=U._%'=Fkm~bBDFDj_ȷl_ģn_RzvÎ͜J5k~\~^Lf>cU?.ɜ`"=^0؞Fu݈u쪮T’@ 3\g gCQwXPǝ9实cqL5xufR5ȕIڟuX0ӿiuL 4TF4HCF0t4CU-W煯s/ W14k*")V9H/18|I q HkjG8LMN#*6Vi{ϴԹ}yk0wUyফֿ\$\ql۽Wx9IPb bԉclOL{VaU˜u~g_dl^W΃w ?#,"&$![Hr$B) AX '`|(7OL} Q^}y|c|(c2b@A(BΈ:qiO7~!&<&>\k-R֭m<\bAo_lsK$@$@$@ {bq f󾌈e@j>oۘ/}{akv]lMW"Mw=@'Yc0'S `G^'3SkbmsAKIN{RJN>A.9sϥHUN#$]{ɺ'?Z.>cܥ+tf|$/[;:8d8ֹc}]o5I IC@2|@7qi)|ԩuga?V/#=B$Jʝh[ϻX!\٧~*X a4JoIdG<9*u` u L_ ~IZ-8wٶlXq bAԂj xa9.]/$!V&Ahʐ*;~#oB8!\  +g0A袋t&?ʆaܫ16;.1i<޿x?:qV9r38CB)o p/ IzVO.WYF$@$@$@ ;ȕ46aZnyr֗vuu0^>~Bu{BY#=Yٍy(0sD^2̭7xNH+! s*%Fd_]񏊌%=ɴSOWcfßϓWT84=_2"ߥB喇, $es<mٱ[ !3?6AruWf~,#-ʉh~HnB9@-leBXkaediE*dݬm62w[Kiʔ):2ʱ) Tu]u] g |Ktغ';N?xA_`X 7`{gs-VyXʺYU u "A {衇>7޿Xc'1 oSO_|ph{5:;!(A삸o @2 ͵-lw1> >!DY\tNXa9sCBpWm~BƌU`\.MlM☵IHHH1߬H2"") $cW@϶'RU -l`r< Y~Ej! _#s@ #o#!sfm:ʂ%_Vٹ³o͒oFVƭ;{zץM[ekNSDnE2{uH>-rMWcK>g[dioesF8k>` xxڠG(5l30R}۫f+)R\Gi˴i lփ|7c>#wcOO O[x@42s]U?+W܎7>V:>)KrpEM-U{p>񠢥6 }/}Gf<[<2P g!xȸ  >ք$sO-ڬlL"x xBöLH(s'ԕue<3 -Y- 8BX!< C" uxy20W@B`}Ήo;! dcHpo%wr$@$@$@$@O5 ol~ R:V +/*M%khzɘza HCB(A\2x-e6Gh;=KHZ-wn?Ep{"D$go$4xTW|(BO%    h AD?=F2w2UV*  ,M&ȉqi-(5Sɴ G麰%p&<0ED$?#HMM['    AuA}vD$$ږV%!~tUS!&%˚ A]7Ht96BqeHj$wyAmPr81 xC_p#)[47v.,Ya=Zz"A #W !!!"’IdUPD~ςW &$ H{ ֶ+]mΰ"RyK7Px\ I(8D$h$@$@$@$@$@$WB/ v;tCYւBdpg@@ s $@$@$@$@$@'3,HKz"ԕpE<\%nP7 4HH$Ҡ5mK*|J8*lHP]#JHX6"nSL:\O @"_lcKk| `Y4ɊiE1#xYWҪ< Smd ɳMl$FnHHHHHH_X'""¾xfu t;/:] R$2QWF4=!RbkʕQ(o\}0Q6C,9WBh7M$@$@$@$@$@A#an\}4↙X?\ E@d;$@$@$@$@$@$@$@$Ј PDjF$@$@$@$@$@$@$@"@)P$ 4b孑 @D%-C$@$@$@$@$@$@$@$H Do]Homef+, &p6HHHHHHHH$@ND@$@$@$@$@$@$@$@$@        : PD+ PD{HHHHHHHHND $@$@$@$@$@$@$@$@        E: E$HHHHHHHH$@ND@$@$@$@$@$@$@$@$@        : PD+ PD{HHHHHHHHND $@$@$@$@$@$@$@$@        E: E$HHHHHHHH$]g V  H **J"񊈔Hx2 5jFjFJBeBE F$@$@$@$@$@j#'jTYYtA"H2hQF$@$@$@$@$@MHǫZy(U@L HAFIBAQLL,=B} =̑b**Z**ʵA\Dj׾ ;f`-u,e> @$&xe     L晔 :sm\D`tGjQ>Jvl&;wlZV&hZbx5 {="2B* $x*>D$ x"\3U$ @<֪.//r5֟:$@$`'$ Hv!     `T}-(_ã_:O{217R}-'E9| aX겺ji\Px7$@$@!42Ҷ W V6B$@$f0WHȇGs#K.p5oR9`>!P$Z,$ E_2w^W"$@$@ UKv ד!qGV5I~ JJKK6XO&vyqEɎ;!&?5,cYHH$`J$&Fy;x ~gD$FӀ/:)r)S )lB$@~w HH ̳sHN>ush@~AjWvU=V"F+e$́T'' EEE{Fz$@$@$oQHD{w ^MɐݔnJ'2o !9qqnβHHo[wio X-)9~TV\f?6;ͤ[^Po/M*UR:Hl uo`ڭȚ?~+}{z#C.Ңet?em9%%Uzk?_P/!a#snO]?uNDD uKKgNٸaUjN4x!}HH&ԺB 4*w2 Ԩf?qtwgd%^sݭ2#ӡl߾mf4LwEjQbՎ:\Üz5"~(Utqٳ{?psޟEl"1jG>DS&&?YUFy SmVO?& CC ҋ| ͨ @c'=S @!y/*&ƫ/=[{%#K\"gZo̸?;));S.ʝU@rUkoNTK@ҵyߛqhDFT]]  8H{V  E &&FRRS 0ͼwYY6JPc>S2|u8Gk{Ρx? =zɥ_:: n穞s/es/x&ycqrCk'xR^!F$@$@$=;H TuE9F9 LԩRw?jxs^hug/*M0/ջ=x}i֬}hq r,;̻:Yp^ylb7';oڳ'Fpݜ/digdqRVVP|D>Yx Ҥg C٢/u{ܣgo*%hx5YdRithG\uuǍ2w;`0Ǒm.u:fXK˔ AK.Z}2p r.!ϒ's՟<|xe"$@$@&=&H:=ɵ"M+PڅŁه S=/.UͅM*jgdigISڎA]v߷6#G99Xͼ7E$+i۾۵ "႗^xJnnQ*l첿\[+{=W;G%wԧ!D# Y++CMHrYmú5vZ{<ַ þMJF5`9# 2lk[@TԟC#<      FDm}c`b5 O˫/='3g!Ej%XlL oe;pxT ygg@ڢ^|Fޟ5]**mX7p7z" yἠ ~e7` +?G s!`Zjcv=nyu` *|b]=epPp|?z<&     B`3da: 5U?PstY=F/<;ACa돵kQ_kr㵗4f#EVw߬UkAA~ԢeWMj][lB ;7UDjޢn˭iV +Ɂ)HHH x'ɱcGH>EfP,;;GV^+8_f}mZ# 8ڴigL "7_}#́!/tzgD4E`& 9JreXXk塅pHz m\PKn^+wySoήrVV!QDj)CPkY]d~ Q>|YץyaBQWKiQd=7[6IB($2 hhDcߦm;hc5x OPF@bbcͮ7@1Sr:H*PwUվC'r:4,Fy YŪIMl?"50S2"ۮ8 UJ2VQD2O*_՜EfeIu/k=}P8swPIεZ엖8\܄SMl"gABَ[يlI~1E$| Zy Ka>_$`- BY*߄[BҊ'I7#&VcCs쏃/ }YZAܬ6e*-nah.5I3 @9ƌf'DٳToe|(7:s9읷^ɧNɲ:-a鼋\U/U`` 8Z/Z!y~EzlټɡZ9e-[ upWȭw+\~) h#aSHZ@ @hcƊ1c4 hJ@f ٜˍl>]' s\f~y'r!@̷]6O ?Ā;D̠l-/IG(꙱KFq 2zq⼒Bސ{Kyq&Ie?l_+1a4StkHHH*lHHa '$WC1ִ= r1U5 @$It.0xH[2jeYf櫋@կ eRBug^$F#"]q/gKLLL8qb-˹J ?9eiu{`tYe槑 @>D>XWa;\I}#'wJӏR' :A^ei=vG۶86]Cd}6ʁ/gk9Z{E\a-b|f*`9N+'r9舙x;ZM#h߾Qp)S\r#\ )Xd}hZ+z9W^t(7!#zn_^g.& ]&.-UY(-Aƕ}CDWuPr=ɚի\5s2ݮ] r}I H>$@@3ø'ȉL @ hLdga%bzjz d&  csٟ/75VŕT̜?w U ?֪\{ZmΘImEz{3޴+YPuCILnP|;Hg"]ОJj7sps[ w`Gtq{fVfz7H+2#Sz'V\&I nVz#hms6{,h2!cO'k6Oi `]݈ܝb9 "0S{@#@غnȍo(j:j7P+=~t9﮼w㼐H04ʆy1P 6ȴ7K$U Ef͛kg`'UoXP !tQjuSe} Ĺ|W1ͼ ٽK E 燲KE^Xt/swm$-DT&KYמwIKh^ G/=Jª<[j`_:**Z_/㥬Nh_E61{4XH<<>t@IDATo!|拕|/[B[wΜҲRAQ8Ep~: @hߡ7P ,  $%#*=@V.y'.5qRghXɾJ'JIzE}`H9:[#   /xM<(YK0'R}8'h!btH}L|m312ھiaکNL2R2J6C@U䋾XrU>p+niww v}iZF-0JZ#oM#   8DP×K, { BW{HFeT+ɰcFIL":BF&eJE3#oB~,+ytM:Y*oSMz8*:Ai5X'(%%r% ))66v"rM>:%C0Z^-/d/I M*l;,׵Sdk;Ro+OW/;XoQfĤΗq'IHf-ٗM?xSR~Z Ef3_*Fi_~$SIѰ1Rޡ_t􅽽ySϗoߦ>P(ԴcBߒW-u!ezIܦu:vݲAT") 0{@׫lFBB>$EfÊ6z~yZ@ݺI4{Mi{պzsAJUN EJ/9@@%||xreUn }j%o +1*GZu"ұɭuwiQ:*uTN1aojն#w͓ZD*BڔT8͓nvfxאX`I  &}r s&<;$   ϐ Giʖ'-&J>jN۷ n1f1`#좐x@ĝ;%άe 9C(z[;&1ST[w6`;iQ~w<|H*= wư [tMQ2+sf£Z}ѕ:RG'SE PHl_vN=O/XeP걻pH[% YpH8T_qs=_~nӡ-ˁu%>KHQgRx   M c|5hGJ|n@ )ˊM:$KVU(bJ`ZDdoUapu%(ociZǕԬ}byr{*yrΧv SŅȡSu@jn&(Ki<즼*ڴ;jjrW;.vGS[lrUCY =k*~i{ujzē'˖S !4ȆC|jj3DV>*[jŷ; _>wN8#OIWܶWIxV}P7G*9vN59HՍݾReT[4{uN%C^jTu)?}#hVRO豰=#B  $HɶXW% 4%on Jpxڒ-Z*J݆~? ţpe$@$@$'H~e}#RѶZRw|~"%l{:ˊ. sLFꥸ$vVI䶋,"  hh|>jϊc$ hR("5OQKL ɩ*$ O:M(ߒY L mʂeK{Ks׹BSQHHHxt@$@$@x?c DKoðzR,&B-I܉jE;?~4ݡbUJuPAaZ@'mY"JeX{KշJ[*E4W+/Un/PXu\|Wd  h(5{' h@("aED#mS ݕ1&YnV{l|ycR0}t*olZ:JQh5H&vQqz.ĶNIiU;xEtZ3K @->QKv5koҡܧd>TdI$Aѣ$+%io2`V9*5J1дk&&F8꧋ۤ?-ʢb-4ࢬ[/);H{2iS[uͼI8\<;Ao?犆s      hl'S,i*T!~! -tyJjkdT^ 㛹F۷JW2|H_?=sPt:MT.ۭ&t#`; }8oKK)[=:Ay){%~{07TMii)Jf/P;K~٥=*ZqHq4(lZ=ϥqHHHH$@Op^%6Y_Q&'.M7A76`V&Ak.)W $PJRaq{h^>Ρ^}R|*XQ-$3[;w0YE.Vީ 6rVt(:ʌV0vvk}*rxbL;L_.a]+ѓ$MLݲBvTUxH._;H_^]>GVNzH6D)UUU- @#@)Dl_m2ց0n,rYn7E !m 6+o?協Ėg_^Do>qtu9X [ΙR׍ݶI L坺ړe}UkeӦ)xIi߻ϿkFZͣsKE= 4x4[=76<ڒ9JHr kV[3f W3eKJj*L!lז}n.+e5U: l+Kc۶\ՋPS?B$EY҂\Ue&P 뚐싮%>x*-] Ɲ'm+n۬ϖk}'>v6xn1Ad:_et['X'* Gі@&4g q 2L$@$X )DOvz6[#-V\˪,b 3+i*iutUpU9:9{\F}$kGڻT~Pǰf*w9:IZiahOyʒJ}vZ]?̩mhH^Ңj3QqIdqT'&k%`7x*+ [UHƝH\MArc ٵ:ٷ@ة[,^ /-yy[^cw<*K!T]r%GhBf|}znbXX&x?! ʇQbĻMEUc N"e|yH%Ös"U"cоΛdOe!rX]syn;ŒlѣRD֯_ET[2av[vla*GQ|m?gb9(?f9f]gwI {z\}uH//Fjkq{e/ud%}WYZ=j'*&N?cEyq2Q?#f?_\jK|Ò֩j^1ٿz;EEdٿ/Yiٮ-W"Ru"X՘1b?p)tɢrk  0$pH5!/˗GUJVy>[.R9i[u7+g"ʣפEDۣvT>ݮyW5Wuj++lJ{K:DzC 6&VlYY˛nޢ 8ſ-BJ*=i5X{w}s7_,=NJ:9Wһ WJ+$Qbem-߿;s__KƥЬ6EzH 1sH5U>e"=]_}xJ՟j{c!G UQ/kXnA$@$("c _s=c#ϑ7?D~'Wg{`roZ!䬬[/cDUQ$|jܶ~OeHުUITawٻWI#pI؛RZ#Q.Ka\ C=.ê8s@V._Z E"R]qI޸{,tZ-5Q3T1է}>abp>]oe4y]#"٪ddKؾAؼitPC~BWYV,xUڲ}Rcx!]gջko@dڣjsdٲ},)ڧD<a 'M%OHLU˕\^f[YӛkYY|*7p=F-0K~BdUWX h$@$@$ BAC]'P%$V={'U Iw_jEȢ].=aRVo$d #VydwOZ!F$@$@ R]2Qj/MĿ/ P}jWr[aOڿm\O^oHR (WH }۫QΔѺOү+%hr#:D޵SHQOH1+o}de'zTMhB)Ͼ1YU @t(:ptQ;ۻŷ0sDPF$@N 99EF'" ~Yr[x#UUU7/rbji{OTGXSYak3v[U-`zY2ܦk9p0vqq7Y4kC?m%oOR|PbK+%N U\X I !l@dkА$99\dJrSF_@k? @E$# ȕU0$LP`#i'3h#簽%d=N"#"%KӦm{*wի6oM7Hyg% DlJsiu1Yki5h 4qH/*ikt}N&h+ͺڋKi`=]/g2{ ߹Vһ./(YJKmKd[Vo\_Jԋg'1)I_VTkH18bj%mxEJL^Sd UPiJINvJ|V!6r !-< Ԋ-[fznU\( ы ޽{HHB "R]tY ^t>8Z$ջ 23[;4ԞD$T7R8YL)_.ȓ n[w?,?xLC}=k=#.{B;?NzOޡl?)[~>zG.ےeIlY{lt $BS $p !$cq"V{/9ծZ={̝yVs|`t/ROU¶8iɞmۉ%+\]\U77<! "Ϳ~J?2:yt caw`=D/-eoj6I=s eRdd}f§k k kmUei@iC\!KHrΒP$"a[ Ǩ8cTC@X+Bŝ\}CLf^܅ܽkr3&g7;LOw㨠 S(<A`83ʚ:΅&JJDq=o!pκm7h؂@twչ[ݺDו IL0ŹFBLp=H$gcU fXJrIr"$KM>E 6,`1FjkkSZd8Ԥ5<}&"In#mյ\!CA@@7Z8dm:|h4|OSzHK1=mձ# m0}*"zi1ݦ11\@@zK@),  Hd[kHN+dv-@w $B2!M4Ҽ\+vҶm@@@@@`(zߞ=$.6 >[MD _06?j+V^A'r9ojZm\g2m:؂p "/1e/* C@H2G-Kގ&'L[7 9X# >dK):vMINtpsg}*"X5!IKΣ+HBٺc'A@@@@`$:A9٧ G  sI!‘$UiAb˂hkk..  0${#u4^&g=jL]Jn|Cy=[2֚;8'Qkc=mWZu@@@@@@@l@H=e%۶~GB1I>XGήE޺ߦFΞJvbZzm<΀ YCFDHZ5I,AX%?f؅PC%dܯ#j-O\ۙO՝<_ǃA@@@@@@H"AiԛPd:{Sya% ɈEPGgBGUh*:e ^KlA@@@@@@`4ID#-tMD#G=k'N>;K7R]Iw ~|M֫7HUGs~R em:'O_I?$tms[egK7SMaQג{(wyDI?b[ԾrTh ?%}Zkᰳޡqns(|%쩩J8sH\a%x''I-s]rqoS2ښMQF͹\%ۿ\)trs5LxHprv%\HؠFA!tp*))AH^n[{T0vMD$ZjUN"ʒ$:52ɋ$. :ar[1Q_ov8~ގ#̙'j~ښ>輩~*/YeZ 0$475QnN)Ғb51c)~r"H%;FR-VۑEpyoh,p۱HoMBa0W rr!Q:u9ЪLZExI8!E" ^=&cN~X7ITO&UIkagV ԗ֎LKs:sl3d\Xm^\ '>D$WsPuRżjmS>u+i㭰Hjo(WU~6)1g6E)KTHJYM<,#]O.^Jۤu^Z%@q/rylki֋H2ϒo]Y_^@,~\kBֆϽ=27=HFVׇ;p1q@ZNӷE>~4yT%t~[  )9i?͘MȜefQ$sء+1}oo:%lwg2OoGG{5ڂUTT8`JKHR9a$ɹ6je$ZkcE:_rj79oT"KʺtZ[HB_ųHBaK(W\]FS=*QTSB_CWϝ}Io6LQKWs1;=昪'9r QBv}Y{jՕSYǀ)'"¦_Hy6"jmj'8G fγG8lOp9'O6g  ̠q)諏ɾkoN;Vwǥ0j&%{F egO>o@8P1֍@@{W@;{HΔmjy* ;`(mW̍>P/VRxc\HIG)b& ~yʙ&@?wOCE'wҁC|yV" @_G$5c?TYu^:2 m5p͏L۟Zj!R72AO_R5P~.4qH'>z^ a^QxzZxps} n /9_.svUmD{D% @$1[/ -ZN_M R%Pk&n /XFm, A.:NW]ܫau,QQ^FǏևw=-4HRyy)ܿHH4"eA|oM%$O<jN+Og/ 0$ PCCIVi0ax Z;{?g V)nBΪRثh*\}U`a]&{ a;$~9$%3 >դ36A@)'dm-@iy!$+`1INOV]ġ /F%^O^|*guwժFyi[uOLLϗA  0S F5!iμ$ {(a&$/'֨WPwfP9Swk[}@7r',&ϰh+Ρ,\1n|)GoT:s|*?yӜܶH <SuO 0T 9E69!dʑd>t83EqWF G .& =1~G?Ѕ4T2 r./4/1YxК*IqxIZWKI2r*8-L9_/"9 @$,$|--5Y&<̅$J۷g*S hbCm 967Nhwt";3^3;; skI4$H{[M*CKLۇFW6Fɩkwf wIbcI~PFC(!q:9Hskx 4aY|"ɇGNE:?Ume3QTa"*M$J*a">$TN93vɍ*AQT!I{IH$Ẳjr;]C-IrI~'YRBW=C"b$d8 'im>'(jնN,ד$A8 @'sswWF‚gy!$K(@5Q]Ń`rdoÇ̻wOW"1%°7e%1xܲ~jIMm={)[idqȁge4 H98#+ʘ5 )`Wc*3 ?)?I@)LFNfaM9׈S\Ր Al@w~Wɶ^[@`8N4&b,ME{(n"ye5&6Krs_O~"]qMY:/1fŝ>"6/ R_DBoEX*NڡVyɟܼ0Y)RjyTnwDmdpJif_J;`*OCy{)C劓k]g[sI?:H%  ԤLr^02ed$LMT"{Q4kfܜy>j}׶ô%<#ٵf16SO>:6WBu9Ķ-hYUYa֑5c?ש7} A@`4^ @vC&;w`9J$IWHcO^a$Iţ#yHk!1ɽfZW+2VJNTaeP(՟x;E^a1NIؕ?g+yETn}R^\8\Ժ,m[U(f #2ۯ>7;w.L̅Y,3S&++سUmwmt_֕zOS +z@J__J^V^c7|r!=o!M@@`4\_Y @` %v!z!sVyXdM5aT2@,`lZe lPU8ogxoxZ-s;+stqY'B F*4H<, ?演uӎETU3F^H׉kU[KOUSg+5?ƾ{Y)v?Km,ġy[h 0 ]nh>@/ -E89VY\FbItzfMdɝ(0 0< %H%1$0ddZ tU"5IwvEf^M4%U}9b9Mˣm؍~Z#7㼺jo$`  0 @Dί.  @i˓+U2mND g$y/Ufo$T/s{t%Vۅ1 qZ S}VBy%f$KNKuzJkdžulu_l.$C^IztʹW{36b mTtZͥb6?Eթ=bf'q*]jɎb7o?UU۟oW[_sM|JB`  0 @Dί.  H8u5qAj(%l76Fy"$ɾaؚWUo$m>ަӿMVFڪg.K~;7QEsn:@IDATO$O9VK/>WQoSү&u?}]*ގECJZ6 gv3 35[0/-y@&w^+0 `fN8H4qTGˡy5 q 5e_r>S`,4!3H*6S'Bo*tδgڅ鱃\"M` kR9 S-U[ НUDWc' "$If( Y qӼD`D%~pə{HrmCL@zŝA6^P}% I鼺^t8**=.2i>O z8x7V$K|q3<8 C@YY)S^ ]|r5^Kg;5;L=Y5%@% GY3Rqq5qX @l@w~W^Q@@@ %ʶ#ɰx nT3' l,@@`dlp>DƓW6S]q`E }1| ΅V_JSp8m7sņ8   +zoͣ W:?5_O}ʶ2j(/G!Q|&b$v>=Օ˱3r Ӽ)%u<@J"䶷t9렉I&Oo~Z Çx!I2mI-9vI>$y?mlнĎΥ&T[yr(! v@@@`@@D}s.Ls u7t*"IYHR9IDQ/#zX/9I΋',)^Wi$?ӸQ}iJ+>hȞhO_Q?4swJAcz3iίN;_^Kio^ n}-U%S8v'Ud9GU?FKSQ6"X#%H~_ K߽â-0~{4]ڋh&USnhi~>.}א>.$=3>ÓidH"nybi`ɉ2uˮ&__?%2IN'81z@@Va~3h|kh]4ia~_M@*%}4}?};_[>`!m%@~;(}ծ:/UKT6ǕpOQgh?SC".yWU}#Wљcԧ/)AU\E?w'֘ڢ,:/;?[HkiSbZs}5Qsm%mxx}%+܉XJ31xVxxV-PDDuZ7ґ4VW]q 0r z!I2m06WeZ_&4/${iA@@` i>V2s)T[AC&QEdh< $TE8,Lqd/xVWOKSB>*~PD~Ө$baHڲK7 OL(ToV0Gԓ<= ?WhllPI55:$ i;[c:((X>?tD.^&E<x4e%{+3|6ׇqYgzW&f$HwᣟVjRXŽMӖSUn2Uf'фu$fޣtK,Z4Im*"W&Օh⪵9瀺BԷ2|kNkU䋙X$ruspTIaG~OFI{."X_s6a #'QYY +1ˌi;82]CNTK cƊ3Tt{ NUŪnmA}q"~sH_C_>;Cǩ*306iA:o{'x7mdCYe$ a$ TP-iמ9M =Cv9Jr [e$aab˞߮UVGK} 3mA%t̻pK=27G9;?7-VN2qQ52G13FϽr]OOi"Q$~o9۱@,"9]s~ש\h?i 2%)\&9YbQD s\~AGu|Qrn:hXLxaqoL:j?Z_[Dmm[1H=F7 ŋ3,šIb HrFGU\I<Ez9ibɒGH${t"ɗ I+a3cj}ٚIEg2"-7:/du1U sDY*Kq'yi+9s.e0Tr#IrODI77¦]HM,䈸#ai"09y9 2F)UTSNA (W'\iU&ܧ}Ŭ§_DeRA'^U_fxQgTU73F֒ :/iM 7pt?'xkt(oGg{%zRs!1ߡkDz3WLG#V[NGמ$owRTO%7SWuN<=6jCoP)ēWd!;~r"p#*ϣ&b@DAC߳ GB=Z"Pi^; CLW'jn GGg&^OD0񳕧)2Ģ0idQ@0ʊ " )_e~Qn ֓h A"RP>E * /# ?^ eE8ҀD}]0Coӗ>=ngjSTmY-.xr4$_S0"pvM SN@Fq w,"-`~;ү [ruIl1 olΧqѱ=@@__S蚀x#I푔 k*5xU9$L[M)[ժhjnNm&-#TJ9jOP&9 z^'{Trb Y+0:YiM,~*wPM~*y au+)eoW݃FSScWMp@D@G⍴opѱ(U,bo 7uZIsWgټJĒh OB[ȴфNߟ0W=ښh+XK>QK(t֥ꜝ^DU>ۧP RwU@#НUD= 6-L+J#-\T{ܩTWaÖfPUAE"Rp2#`1R!OOE+\aviۯ Z8羇_OgA@@@@@@@@H1H$bXys>7=M" Ōe3Cc+>dift =R<7H[A$"Rgs 1A 5ZSc>߰      `\DꌘGH!8:'/? ~O--K kf`      È@Hnn CxޚaΤK?z:\|6CDؗW`G3|ա}9{GgΎHDLҤm쩽lfk@@@@@7VDrwWc!7Vgڧz c27D,k(Trj79ӨٗQRֽ8{߮e/Ƶ:oFn%7Eؕ˸} IXB^ItWTx")$4(L6L,@@`hSihN_p(6Ozj 3D,1E]p3~GȻQKCNwvoHT_V@^"FMQ)8{\uAO L[N''ґ Oa+U;{GtNOO$yhKZ8i^D~f$"RήO(wo4򏙡U<6?$t.?Ӓ'f Ã=;o@@_W~Vo(/T%y{ līPϦYGӲ Q]IY?+L%h%K)'I~jEvjĩD^ڗH;Ǒ9{Ә* Vɏdj֬ˇܠNxHU票1>;rO7W`.q   `z::> :;}5|x I\LvJ퉏7 YĞJvsש|'RQBRBO~Ө<5;9|#'ghyDҶ.%Ww5PְF'PWWKi)SEyZ˛FNpH]$9ӱ= ŃQe}KL CcA'du8#P_IھM'_ 3W>Lv%yTuyReV<Zƪ:N+jҿ_[*@puu&j;\_]re6y)8$AiU ӕGgxVC%#0{|/,fڷg'.hfgWTSQ@1\@@ycUbbzZK0ܷf̚CNF aDgXfكrݏbminڭ]g [`p293#Wfk(r`QW COEdZqt}$I-ktp^#!ItؠF'ÍZgTsJ.f5zJZ+bI15|joo'}L}tZM~hVUUAw,eȉŧ+(kmj Y  04 {H<Tfz*E)$=KWescIL<Ϟ@@` E|7h 9PO$HT n" 7$>}f@㔹=5Eu9kW_*>S%ͬ/0nɵ>MeTU4q+2-7BW3IO A; HR瑬PCm-غc (՟b۱J@x"eWњ(kXOQQɧ "itI2CB :A @w~W'Ro# I7~K=3#^A{^YM~&NOA Հ$uCy~pY[MWд_R5vwu-P{%&(8U*Smo-ϩUQuS!1Y 6]Q`V_[th{Qa7jdv Zmdٖʇʸ{;Co$x!uM̙%"0^W@ @Dꊐ 懲MQU~y{%ٓWX,Im 0=OD\ٲp%?s59{Wʽlԥ5m`d    0HX Ir,Fbee% {5++Sy#e aqi̘0i "W܊J.kZљu9G@ IF#UD0p71 Hi)fr+vc$ ]Mή[@_@D<2' i$$晤Ӷʵo$;k..R}}]וQ@@_[_G     `S43ɺPkhlЅMn|@@^%@@@F$&&icVHx Sm[6PsqM+A^ xh      =cKK ܾ˛  06q5NjwR F0ɉTTTHO   0 " ,o\ @@@@d6 gVgP   @" @%A@@@`aHL$Mjw%$u՟@@@ "ZpNtނ%tŗ/40}蜀λȜd( IbS] I19r>'}g8 XEKceoPT^^FyCih @I3"lA@@DA~py'Lcˋ$gTgSwv\RP=*^y dp!O݃ZX8ppp)Sg+әf$ѓ0ey/KATRrMVXdfStxHmSSNA9jdhF,id$u }D$ȄnH"\9QmS)|%{ w̍ j`/$ =*;IkjۇzWVVVXA@@s}kS=pXsx8̓;}я#,eպ|uMꬻiWZi.7Z1=j&U.uEz"4+t:[F"4t1TZH+ܠʥ漸 -t۲ڛRBi" P\?[W~X( HҧITtV9@ @Do6w$g7yC?cqݟQڧ -qpq#{Gg;&efhe U9e^1#?T%θӛWXZ9 $/ۿQK} Ma3VP0oXo$C'ιgfєADr=j\IN)6ݝRxrc;;{j)7,*]b\yy҉qfhU)*gNDJ~.9o1ljО-YFj_T^'({̞bhb۴v,5;Nޅ'%^N~6`|rpS}d Liø0W䬈TQLg~Y1g7q;ʭ$ow'ËMhiFūϺ7_v.=(n=z:}YZqt}ha1YI'>8B=]5.zox5Gw-m[ɅXk#$?EHϭ ŪJxX"Z_Du/rED2g^'X%"enٜH"eWP#XMOVMMMFLBִJH IV{42VDkm(ׅ:{j-V՝j5huߤ=˅W֞8)\س8ˢ=3$'7YU]3};|"M>YΉ,:BQs݆ki@%"mX2 o*wO6Yy H݀5j 0Q ER}Zb5E_xsx^.(KOEGx#Fji}hYγy#{pB yyh^>ެwZ~A54GAS'i2OkWuĻH"_$D˩$7oјE7tND*9yD"bTxkH=rOIrDMzgкbխ:V˕j%xh&^F_xQ/aE=9_57{ icmG<|; 0 " +0Įն96@s:jK5 s~eu&S0!_Z9$ V?by rHFqJI/uȑ4H^/Rw%bIlC'>z^=$ܹgLoj $RAQ=O/͞Ż 6`dn--Nc 4We  AW 3d8Oε97sI /͜[QĤ{'WTVq35@$bQU h{F% Q{.i Nhi{-eND>j'ɱKOКm6r%"rX/ԶO"$5W )=vwG%֖so?K9K9c?,z55sٛ{Uޣ?:=Y(Tw;CB;+:CҒYZ"ycXVJ\ cyb҇ȹ ЗG+.Fk#_G"3?⪜H;?x +v.$ ׮-' }:tnu?s@7 F𹈴|Ff,xkqpr3WUwJM1WI%Rz2׃/&#B;OJqb-2mGe[A-d!yR[w кQ_D@ njD$\P      CjK)8qڔԩ>ocZ)z͟uq Rv +@l#UA X*KD! Q|F ѡcXÈ@%9Y2jjS8I> W)lBWɉƷu,] C; "k^AzI/yp@@W q]e? !P'iB`>a)'0nmuVGE`u68t 09a*yxzu(G2fOjn~0ԕFYf+݇27ouTDVRHO.^Lu9Tv|CI#& !M^"mŅ;ݡG6Pu~Օ9"yU\'UŪ;5 !!΃KH^$R 4^! `q/rdYڕ3]VҮ7|V{ܙ33+=s X4pĢ%(kcvA!Lկעl+Ga1hɔoBEwc?~MYzقҥ+z/&ݹ_TƊh<6hǀ@;; Pb- Eq2_Z]l{^8:v!ʭT#nAqy+0(OǼu>3i1綟:kmks!>oҐ I xE$3 䮀d_PI2GWw٫}q|Hmx&[ܞNzZ*ڿq3]hs{J@Zf/",Y&YI$@$@AaHUHuRq{qOx|W8`, 0b7"<) ˕'Gɛ62-qX; so\_ʌzToƄ?wx=moaG q!@i\m8%.wrV߆#<7p.$nA#C66;ҙKp%8}fƒ}}u9?|A}ڳHH@PX${oJ׿+RD2!8"N>؝mab"R-Vl[Dĩ U'F]D<q9hhiWDJ&<0C+I|E$}Ûi(&_3!ss53`_wNi3R=Q˞xrlkI}Y斶PxD$@$LҠO;[a55cKݹ[UJtG@> V 8Tlsqnvk' lhӦ[; 9YfZhODr^XA$0aPD0H6w}ԜwQޏm6Fja;XXBVGˠb#SMwy|p$ Y=wkk3,mbN{V-@;k"&: mv6ٖ|d)"g":d=5w{'9uL1a9&&N}{zI/~=4n7;2F$@>I"O޶MS=!w["FCGR/H! mnA ,@~T; %'O%y:=GE ߊqڂyjZsuGv/ t.y$HH 8gDky:ϸ~VWI6\Kmjv>54=y5 V]pOCUԨEN%fY,==\T}fp?/q!_Ʒ'uH(ޣ/OH|@JJ*cbv.KԂrvcmYIL ,1K{!$h$Ct$kcC=v3 NYo[^ & mB$@B"iu6؃ }eNy~_|7:K${nAq "a#dYF5(\__و&?W*5 ih=?-Ւh*qte3=?EUe&p&@əףJ/@tFѐV]ioͿRל/p 3  z5 Tl8Teb! lD⌕tTT0uSZ]tPpw0_O!2+oMWZ(--9Gls f0&ʆ /]Thn5ײs("J=|ydz{1G;-" ~ںۈ[P%v$@$@$@$@cE $Ry]es>?%b"yjLῡ> }5=AHsbij!%EY꤬[nۃK[ B$01d/K*S4]*Z0yKWEb <\c <} `xH|Cڻw@iq&tYƛV{O6r3ʓі4h/%SQ=3BKsOgg; uŏ׿VfSmhUD;c6E WҴVƨtvX`Rut>$NHHHK '$'~/%EC# 1?[ʋiMM˲-@Mw*ΎD"ҐpMU{C^,$@$@$@$0TF5[Oi4(& x$=CcB GPDF!   T[Sښ (>b!X("MՐ W(>x_ɑ +*YG$@$@$@$@ANMb8v    #@i2 YaR6`@$,L> )^$  pqǦE    @]m *+O!9y:<9|lH$@$@$0rFΐH@LL, NOĭV+NԱ>Ï-t aضe#r cZZPVVa.$@$@$@%@i؏H y i? 6׿|HJp1Sۓ X49 "PP4M>@cC>>{Q1=B6;1a1;Cի}`A!贶k;tuuBpi׹$    ?#@n8K$9FQ]I,7(1ID !:yq\B"eY|cљSTҏ֚2CU9 m]yw *-?@G q9sJJ,ZOm8Rf ʷA阤5 Suc\ @|B"UVZV[XL>Tr.= E#}5I0ibl%L]+a+G3M%{Iag_t*VHF{4dO§GoK{~\'tM+!ڈ{/))2y J-Ah52m`2F%SU j7pbϠ6؀۰dٙHODZz^U [?\o(Fm=z=Z*j 0DƣV׉si>uӮ՗:.֖c_@沫= ޿ݎD۽N/Dh,ه ?E=3Xݺ HM >>5Ճ{! ږ H`("1;`VJ׿+nԡ϶ˮALe>S<q9hhiǟ;<ERLX#>5;n6HE ^`b^5ùl Lկעl+GIܟ$"##aT::zJEؽk? 5mdFB|zI9H(\IUOU V 8.utvUb'uJ}`@W؈L},?D O8 ֊pTk0!IXH`4XZَVXMX=Z;fBBթbriv*@T6>K[;F4:fb7_Gi|G/\}}oŗW Eᒛ2_J]l{^zQ6"'pR q +9Օ8Uv{:~I{cHj۷ w޲5NJ[kv؏H5P[D"i/^H'kOlcmmV1ld{ϪNnAώ$ПgIy} 1)&}EnU۵B{>@ۈ,0d=1~>;n ruGV+:]$ȈD}!OĸpaPvf &5{HE$g~t,H Hv,E}#U{,)}ufT ,M ^C2/UUZ@$ǎ[hrLΥ47!uyZ@j*;IOĊ_IW>ǎcd1H`ЂС @.w"ؽ^=d}32Wވ=W!щʛ '7o ivaMufGͯanu[v 됤GHR#?ɫoWOoŮ?W9}YyfiQ—̽lR;.\Rp!Ը1"0$E涾ę*f2"mSMXJ6pΚCGǏ^و&4kqCw]P{..1). 7FdmS0?Oܹq֎Nl=\߁U6(o}zqd#ln 秫yьcZ.,7?3)zއ˛p/>!dmمzεj[u?ܳ* '?'% bͶ-Y6^7 [sztBa&"{#I$0Dl4-l)q*hmʔt\v_(e$iƙ*v0ʋoeT P&NefZ+cUeLZ)j{I=rzbŒJ.o+G$*Bmis.1qj=scL6x2,_Ƈ[jQ8ޟkHH|ǮWKf6c{=%v`p(f~qTx >UnGkQߔT՞}+TPTl=ƶ_ݡۈy_Gz*~Q%_a SۧcsD WsgԊS_EhBbU9_| [y'O^so=EӠ̘5}Unz|$.~=4R[$xk[JqDG3&`E1 'PQ߆!Q~Ix8{esYZxiQH"Mo@ j[T WlknR Q1DZSU\g~o3 B LE1.P{}KJ-bO?7O>vYSNi9ҕZk__/>reHH`bҦWI`py݋i+#}^?.CXy8ݤ1җ^A\Oe4w?-$Zb9Ķ>RHR,-gGbD[ŃSmt Rgd&Fh!CS^D *Rv)O"^r[cuϝOT'>»?:$χޕC]~^l%HWaωzL͌X$RR&iQIe,"^IDC$~U+R~zx&-KyUVD_ܪ*0U̧h  "xH3狿V[ɂUjXT`S ņl}1,&*Fa >zG'ϝ_"ܮ03ܲ!|07?/2ӠI `*'/5J,R<-ie56G?Ge"UN?S"OǏG˖}|Q`vOyHHMD$ϤZ+O'Py쥽Zlz t~c%H_ 'E$ӛn;56Ol :.,]uNfEDD2eӅm=oQryۚq#p+`F!bS     8'gS†orޝڦx%s^5d}3vIe[]Tvש,Ç8ҦWH;>fY{4^է*yctJG+3*R3*}0=Qg^sn$:o}c$;?| Gy^SOkT 2JљS?z4Qi?@ͺ OznHH`,5C D 0\oBBG#ڒ2w\IU.7**MM==mFhrA*l͂C'dRY~$8fTԷxGN5>!Ćhٔd[l{`&`WPcQDTOo<ť(59s׿֞j}P;գ3qH_tٰln=9qX}{tR.3* .3* '|A?v> ' *|FyՄ9 G@ݶ:߸|U3(?* Gl|Pv6߹ɎR3dxӞdT3Ґ1 ࣟ^ⅠՂ}IHLv=41[2*ٷL0Cq֪0k|C`XHHHHH`4(C$0&cb$Ejk0=&A @{٬8   &y !22 VXO xE$9  `4B-bI{<>!ѯ(>S?~#J  _!E\w -G_ G?E]1iKaܣnNҖ\@%|jӫH]|h y :TVq'$@I"o7ΚH` TWU9fb0%.$&&aչdjO>BzB$@$@@ (,SνC)_Ǖ5)(, Wݦ.XK+5[wY ]!9Rp!Ԩd!#-fs2/XH|E$߿\ H,3ٽkh ^H7L$@>@`jo_ @g,g(Ӫ긽Z^gbP麿!,1 ).@l\L'8ޟ'R1'¥8g"X^U "%sǬ;~OhXM}4lL!Kp%d:h'M(Go'.~MS⃵#2iH PD^3y:Q USU j7pb;fm Xˇ?Q?As>L]@JJ*r Rp^TUV>k Apa 8hR$ APwG)r=*cC@ů]jk\%%c@m$ .2ϺG^ԭSOϩgՁ!aL-5 >qDD ?B`ŏSz>̲9v^n?mmA42wP"Q2B>J$#@n{JTH /"' A"Ss+kŵ( QttʼnxtwĠ.$$]I=/ I >>% uuؽk;ZTm/VK~ l<9['7X-(-9a?8[G/o=f, Ʌȴ|-$" υDƢdC@UJ)*k"QD@/WIǝ/xkԎp+[Tt4l^M~'rH٘|E$QX|_{sU"Dm J9Xph!9X_^'#P[[];dYmĤDDDl,HH4yqQdg~ x<WQEz|W^J3$y*D?)4.չǡé8M=S{m=RoC'7$-.YR}7Hǝ# x ϥMiE}#AElx=(+=A`ђ5{{vO$@o$Bט6hy'e 8n{s= = mJ 27TطE{#bGD2ycz=5 B$01laN˝շ;Ϲ.[V=mxf4+3. DػI&>+KzOd L^H*Pƙ7ֲ="AC&mm'fEK{S>ݺ"CAo VțҶ[揌,gdlŨ<5KY?g1D /#`IH{4&ӑ---c2 ڙ*pK@EO?AQMA'/TӣnDyw|Fv!.wnsͷXO@pz&/$_lkY =G&  5eϾ/;'nҬs %4D0u9CƑ;z⌳X}(]VV:"U=kMH(Zz괶kN"p1$&Ԡ琾֔BKdZ!*sAP;h Xш$?aPMNQ5 ݱ#C$@cH`(WQD3ҡ.K}Lq}ZUePKm.6C[+'7_?t>~/myN$0~w}1Gࠊw#3jznjnꑵM~ D"an_A{DȒ͕}eT]ʣg`L><mèW~yJteym#[h%"5zĞjEXXT&Šr"L.$>v\UàҌ̶狝x͍6L^y=B"\Bb y9z $0 a3`4EԴu=V*V<CZY>Gh'˝|[87    :?!4m93 Lե,lv$$}ԥ3 S4juHH}HE$ ֳTO )V޾m:::t`44i)((H%϶ Y'J$@$@FҠǶ6"0!V{Rf\#[RS>N4^H2`Aj+|e[C\d"͉slSm\HU6z}YW 3#b66^ lnCũrdMֳlmRsفuVm]2wΓHHƛ^OAĤ`[VWjnM5(qAڤX`em3!(4Ѩht6/*4kJ+` ޒLˌwvHl4b=/ ?H8 $թ+>ݾE""ΗĘr$@$@O#HIaǹֵ\O}c=$k*Ϣ0P\קTǐ;&tX22'cRj>jܭpN`#K{QRՂ?>o@ʄ!"m^mGҀxH`PD7H 47_ɞ-5-]/g'^Hy>3iNHH@sl~z%"@=X:5w{'9>Ik)q!l[mA"mɕh) /GXU9_QU'?~l/(A{OmWܮ:&tKDµ~Mjw @wZD%@iYH`ԗ)9jZ~̂QnllPNÇb 2 =#6$ĈmD;;HHH t1muuWP" m""*SλCKĭ:fInzDe_[֧>[nQ_ggkVdk%%& ""Reis$@$@$0! DLyAu{r]*$O-_s$@" _j=dm,ZB H]*D=;n`D$@$@$Ї޶]>$*5V -wbH[x1RfaT8JP['{iH&:;Wa,$@$@$@GNH*wDj m^ƸOJb;gdlŨU#6:sz;K'6gq8+j݋ZZZ|vEK0ǐ7DLA1\1(!B╨4K@X$ب>:|v\vgU X$$%#tQ[iX1=3'!Eei @QF v8xC: '"]EO!kwk*.CTz=s/̍na#     Ca0[yx ?œ3U"I<I1 EK̼!c mJ>'l++uu(cvgC򌕐љSa15{FEw|hr-^D$OSsFΈ$@$@$@$@Cܢ$~eI|J-6Ti EJI 1qJ= HI=+p6yQw?C[G>)gqs}K)a(^&A D6!OE@P0N{A N܃o^ JϺT'j /NŢAxr4 щ@> pD$ Կ͉4m9̾S L;HyxjGe7PU|U$*Zoxf- ehz;͸A_~ety#:Q     # XHƟEn ftڊT)"Ig^6TW1RoZ(Ϥx>Gg~3pfS#.]VtZl:-mrܣxVɶ9>5߭Eܹz H~#2ԫ`$OlvUA[OW6;w*w|uG"ՖCI -[褬}L'D>XA$@$@$@$@$@$@$0("9($ۙsoK$H Ho$$CD -6޿ڑmO9Oc#/kk3> U}XAL`Țvw(//g\; 4G2=k۽DH2$Mե6"V$ aٓNRDLJc!@Y s     $C hm[\!IGlasTwx鐎%+lBl= [ٖ&SPXԐI/B S˿2\Ygcmm2Pv8B,ZĠYsv{_yccv܎ǵȡ?  _'[@֊k]bwe tr^=g(u#kF_@ʨvnlEzy(L=u%.Ѕqcm1J47 ]Q13lFdL> ݢg@@N?ۧ+ Aۻ{4M @t{u_HNNAeeϭpu }eÚ9qX}{wHsSgԤ/I6k[3:llۻs T)/.tߴ t&Sˠm '7ӔمV90,{vz[h)!;7xeE3-C|B"ּrHH`l563($@$@$0rCF{L-ףh,?1[S Ry! ɹ3$6T mKIh'*%0ނ!He%x7_3ׇt4H$@$@$@$@$@.brQ?"""}0:;Pgb!,>խD@:3ne#=3bqU=v3JKOj">>A{`PöloJlk =ڌI)z݉G6IHp3O'VLXiH+a@Go*ĨاcH_YƦԑQo F{4LITCp<" +ǖXvD!0ﺹ~urV߆+GH뀿;yqq$0*諸3p֪aRPFMuՄ['D$@$@@ 6w{*AHt"|a9f'CQ=v7G厷G=>Ҩę5; ;RsfMFN$ 셁ضeJp'I D!`]DLO)va5X١ɨl0tMڎ=#$ң""ldz F&5M6ŽphnUM\0 T,d{\uLϡ!_i0]jBN"p$@>G 00P֦s& ,F*yG'R7#sQxNKNn|G#?&-߅(]'l);7J "|EL/nJ>ڂv; 8PEƕxG!QZڵ^܂P[v\Y $4=|55NW~(PsH6n$.(=c7&Ņᆕو );KF;"{R$J?oÆ=^^ўEGN2NǴ7lVucZۭg;/{ϕWp'hP wv92p k1 ;Qd?,*KCj<[SXI1Sf#eN~:'o ("U@Om'OA|"Qx<bg+;q?!RJ/B붓^܋QYP+cL3HEu6I*yOWTMigJP,XT% EbR Y׶᥍'po-z}L&9H ǹsR!uLSپڃqd#fxeS)K_x(5aIa`n%*hNϏ$0 *Js $@%)9flddfhF466¶,::)X'Oʺti;2Zp[=qFcI$@$@N@vh <^mk({!өͯ@^R +ϣ8j/ Xy,- 7E$b8]UtmsJubw<đןrKҒ]nbQlCBhSIFZUtGΝGxG WNI_w S]-K\; woM0*V~ivSwѩa$("IF@ T_xLLբE$sz˷[ӸgWk:a WH _j{7sTpdǕ\c!I"RO<# 475b/J=X8v6)---c54!  0N%.HbpVwX[Nʘo, WDRjX HjT4)iw/W9ޓ㰐 6G%H&@mTlH$@Kh dfMѓ(++^5G'  p@HT_el#;2-{h;1Uuu7[Z]ҧ+,g[i2C2V7U,9R%6e I`("  x8v0**Q_WGin%  Q"^ w8$$ᔖʣڣ)bR.ZNV=شXY*;[1%[NT(C~J]s hRln,$@o{يHH$SuU%$2)  ex3y&fgu\#B(t(ѨBν*YHw{^-p"#0uqsj|>|n"t}}:kҾ3q헗V j˴Gn;Tm PDr[ tXGb? jnEѵbƭ?w$^m몲ً&îz?Qudc",1MJI1mqZG?> gfÞQM%9bQf՘pۓAm]- tքN'Ͼ]{+m}fq^T+H\_9Fcxemrrr8uWN"?! S拏Lť<'  w>HFB <) M5k36It'**M*7`Gc:-N d }zL  ֒S%0("M_.|;_ЇoIH;Q4_΅HH"0m F$@$@$@$@$@$@$@$ PD/ (" HHHHHHHH("wHHHHHHHH`PE$@$@$@$@$@$@$@$@;@$@$@$@$@$@$@$@$0(H"b        H        EA @ (,ɰq>T*s$@$0bD1B     'JQ~A!1s';ΓH` _ hjjľ=pD9F#s }{?~+4AGkIqF$@$@$@$A[X,F$@$("y>m@     Caz1C ov6߾= 05B$@K"{HHHH@l G"{؊HHHHH` mR<@|BZB$@C"pU     /&7RDDBCCx xY !"l؊1C ';ΜHHHHH`u`m Hn$@~I"_v.HHHHbсWO$@C#@ihؚHHHHH`hkkիHH`l0&p(RK징V47}񇰪'e,$@$@$@$@#֦biaZ" L"޾4Fݎ7xe'}c:,KSHSHHH`0 Ť4De." q4.@:z>Ynj'22ֺs$@$@$@$0UV6yP' Vu4M iBVXlajz-قHHHH`0'Îmڷw: z"9Hg./jpNlA$@$@$@k)! ԗ kHHHHHHHHz 7ffMw} 8Ws"BClNj$@$@$@$@$@$@$@$0RFJp I}W=AZ.cƬ9>sϜ@dTԀxHHHHHHHFJ1FJpν9[҃#pep1cKi>t+ = n3gυbAWWXln!!!^_t;xIƸ͹7݆nyyh\_6/]ܔsp׿g,!      6z" }{v!yR*S&!88XJνY}Vw1#"ىSΗǟn̜5AAAho7dr\-v؊ظ8 005sOC=}!?aazn#A"꼵U+=%_W_>JNs}(\t9-YҒx;8){wgU Lf&;@"aV+*uz-Tb[Ѷ z]j{Zm=$!6?&;3;yg9|ϐyڳ/ HzpNve?s)5C:H @0I_y`ӑ}?y{ŷ:8J~ro*Ϝbs˳0O֮M?%ܟeHKY13e}O;kNӳ||^{ #]}*kz-/ٙYz׽)\yGrk{8ٌK>(g%c4q37~|{|;Y ҟ_O}o_Kub ϋ>O~.?[;z'ox~sq/Zp.rl @@/xq:y/L|h;3FL.L[nM|czp|F '-N?zk}mlD%}D3xӳ@*f)}Ŷw۰lqX{v8Nj_[ysKbc3-<1?|9=X@<ʳsČÏ82=c^٬M;>a~ *fj-y|{r"s^i) 5sfm3g=}ynl>i!_}Y41G̴Zm[vIQ<'b\#XK^sv:BX_O9zolmga"uL%(C="~=ws @ ֮y*Ѿ>s~Z.g7a5-%`&R3.c6mKςkפ9s!DQ{NÏceeRl_nm݆ Y9<en>*͙o;o~jCyYk_O?3O,g}wF[bV6>.OO.w׿)?W/_>H7x]piϪ\,}C >_4?St)ϙ|+",Ш(R$Y(qJE_So'@ymP=֨-`&Rl(qi|ʔٺ></֩Y~zѺ,jdֳmӅ#X9.a[~鑇WPҰX3[)$:#SBmyM>hdoLfEUK) N|s/~+J̰Oޟ @ĢlnZ @@9B_gfɧ~c3Ey\2%feY䁼%ߗݩ˖)f |sױvH>іk䋉nj%8qR~[Y\ww?s8.jKgfE=eE`6= fg~ͽmPIo>W4Tݶ @(3v6~hg>a7KZ1(Y:]̊ngĥYb5ޱX>s&~,☇\e6R,6ek1#'TʦbX:'}c](w~@ 7Ug/O|&~yW<=k˖BR\Jeƌ6'|<[_OEA/н?Iw时};̏ɷ"HcH#t  N&Oλ3(G@SO~~֫,k5{ϝÞR՗y矦nK8q7weJ?I1inY9y8,?qy.i1;ꋗ|*nrG}$$ŚPqeKhm{/0g:ݜaS{gz_(oQjVTa?B7q^u"{k.OےbU4P[ /ָ*影f9?|%yZd @@S T @`,dw.פ[Eމ'K_w[П<{LgnzޘObWط_eQ}xDz~Y? 㥍 @qÓr'e(Gx-1Ah͚5{pjl}mLq `Ce,]r75Xܻm4{.pħ_bW<؊ǵ39W:zDzK k[o;'yԱ{#00j]uk5ӱ%PF-e70q @lݛ- @):/X-F̚5;ZrkU]tɧLs[KbRH[6on;ɓ^`ݺk# @@*-VyWWWsiÆ-;!@@\%D*j p o @ ;jD'v(A`8\V @ P5!RFL{  @ @%J@W% @ @jB @ @J"J @ @@HU1%@ @ D*] @ @ tUK @@s ,<˖% @V+ 0iڴ=F j' @5\y/"@:3k8. Pk"Ua @ PP= @ @ B*6 @ @J"<'@ @TA@TQF @ @@B@ @ @*0JH @(Y@T @ P!RFI  @ @% J @ @ D(i# @ @dW= @-&paGi\ggLw B'@ PwY{d߷lR:^@4j$@ -?#@@ X]G^  @ @" ˮ @ @v"7 @ @`Ba`ٕ @ ЮBvy&@ @ C@4 , @ @U@Ԯ# @ @aeW @ @@ tk @gIOzuACv0i'o @h/{+]&M>#=cګzK5 j# @x{W*4y^ @H&t @!ёn NH @zy7n\_k1iLUB @L-oB)#@@yBL @izm2ii @@s k< @@)k<6oޜfΜtQǖ @@ wl @ L0!1[E P j4S+  @hvK{9=[6]ueiӗ5N&@j TZ @ ?Ja'%@@HC= @ 0juK 0i  @h3M6=8qR\w  @V!RR#@ vw @H,=R9 @hI&}M"] NE @'rZ mݺ5vMU6 @Y @Y~6lXi|^ @@ ~4 @ƍKz ѻ @B:: @N[lCm. Dj'@@δiӦv@  @RBJ  @Q` c7"@ "5)  @?ƏN;t¢SRggXThS6n fn^TM`͚5MO>O:ҔSS,u֦i @@k Zk<@[ Z 'Oidi۶m֯_4hM @@JH֭ X"A@  @F GpC @H7s @زeK"5`h@@ @)y|1^ @@ XXFD{ @ !0~4}4eʔu e3I@T'H!@ 0V9~QdqۭcUz @Hm @Rܙ'H==%) @B @@]| @@-֮E> @ @6" @ @j"բd @ @@ @  @ @jQ @ @Hm @ @ZH(ه @ B6} @ @@-BZC @hs!R> @ @!R-J!@ @t @ P% @ @\@?O@y'NV;SOٗx3?ҫW @M DjAKt%_Mt {?o_tnͽ.\N`:=ÏLqԠTyxӹtaGԍήtNo}NƁZ̝׈S;' @"5ݐhe q-#Ԕɓ}l ƚJkZ>pi)uսIG!սv @" _y7rN=/'mٲe\VfS{WQ0aBtϸqcW'L]lV-횇TlpRƹkpll'@ 0Ia,A@K <أGIgQ֛o'???>?/3`a l&ߦ7\ٯ䳎bv̔)Sӯ<}L'wݑ>O͞OsޝszT~tUJֿۋo{ϙ>q҄SOOOz3Xڰ~}Nץ.B8&.N7o=cK_۽Vz(+/N>XxeO˗-IͯO{N^gv=vt7/yowM#_hԩ{k׬I^WGYO+"|QG[ڶm[ZЃ_4 @@)'RR.Y^QoO"lشicot]"__lݒ>9"\ǞiҤiޝ K({?,X 7,p|KR}KM6ܘ. ;Bw}w_0aԝn,ڐ|hz;~Ϭq]wޖ4+[u] CM"³c[/.ܼySE_[[~[n{+M¶<Ʋ([RHO/YoH-<1?'O|=>3{bh _|Nm7]/AR9gAŒK_cӞ9,T8]S={A6e Mc1_Lv WMkfN׭K_񏾛3g4xc泎b?꽴/_K7]{͕+.˟7ga4sjC{hg= fWсKK"tq; i@DsWIZUg$&f-qSe֭y nr8 %FXz_{eM/q?ߓ~hͿzh'ku֮]CbVQ.EArE/Em1k+/KwY,f$M).kҿ)vHEH  ` @#"Αtz91 lDsF6#&J;,Dh3? ql]s!RMo_<49kV޻g!Yg9ysxgHӼUo~̢u{ߋ֢D/{GzpŲtߔfOymY8:HOevUjkK TnBݲ>fa:#5v]^؜3$X諃]@?O.n]|+.E'O.Gږ>yH[r..](W?]wAEQ.oWO&Nozv'Aɗ>NۋP쒭K⢯ yg(_t;߈EZ_ߩ֭)fk?C>i[3bVZzւC:4/юy;.u ~TxFtPV\f3F @F SN#Zhb[n!_c]eEs?pG<7@E/\?o?WRc@ekmQ$gnl&U|ugur) 3"lKwG?e^zUQϘ0S(WExP^vೞy?f=~Um)iKbmo~(qBE`d6!@0QK@KJw|~Z4ڸqco/.Jg[:RG־wanq -ΏUD |l}laqZd3/;G/̞ݩ.ʤ6uZtҩ~oȽô뙵j+? Ǟ{ޙQ5!nf%ek`) ,PHES @ X_g'@3~윢K1k?i5x,x c{\13ȣؾ/EHԷĥi1L37b^9J_t-7ewmnsɧ?6_ⲽF勇y{=?;^DۢDֿۢ/EV6'IPCFwDڼes篥MY(xۭ7>ҁBsK+2㨦X\%D PgZ>?<#@6wT:TL`8\V\ @ @@B2I @b @ @2He @ P1!RLs  @ @ePW' @ @bB  @ @"N @ @@H0%@ @! D*C] @ @ *6`K @(C@T:  @ @"Ul4 @ P uu @ @*& D؀i. @ @ !R$@ @TL@T\ @ @@B2I @b @ @2He @ P1!RLs  @ @ey4w_  @ @@uƢ'8 tE3A @ 0BHhq޴˖o|k͜H=IE( @ @ 4,D*PQB*:>w} @ @$HE(_يYK{M @ P@CyZ-] )B'AR-Z!@ @@C]49}p/ @ @@~9[1 i8.8f-ȣMo)K @ @H] ;m= S @ @ Wcm̚5;Zr^'@`&O2dM֭r; @z ;u:hp>W}MFuy  @ @l4a„qz--_d @ @@]CvǸ|qkWa8R,k$ wmCǞjCi'Gxf @ @H7&ZD@E3/pA;"=~|Z}M;l @U"UyR_  @AH0H @i'@ @FY  @ @" @ @v"(# @ @`BQ: @ Bve}$@ @R@4J@ @ @A@ @ @Q F p @ @@;a @ 0J!(N @h!R;> @ @F) D%  @ @ DjQG @ @(Ht8 @ @H0H @i'@ @FY  @ @;>wtIS<._$vگ}Ҳۏ뻏 @ @ u&RAzyo" r¢Rbo8=&q. @ @9"w{-xHDXSq.pcvu. @ @4F!R"*f ͍0iYEmisz$@ @[H# b %Υ @ @4@]֎YGQqZCqۄ v9(I(wM @ @蘿`aO=^@hkVZ|̙ozUiƍaTg"͚5;Zr6y&O2d#֭[;>v @wTt> (|LP@}KI1;){zh @ @HwG  @ @! Djs @ @ZL@b; @ @F @ @"؀ @ @,nl'8c &{J+/9sM>*mܸ1~.o<7g͚VZ938F r܊munh/Z~G @@󹪫EeK7N}{_oxK-]b(qxQ @ @kb&QYD1hWPl+bb6SDϤ @ @H%l}IE}y&  @ @c#P)ŸB/_  @ @+9mƜ UOPB)~Goi1k)c})۔)Sڵku!@<͛ @wG%0U ;[A۱>P5 Z)飻TqF; .cj @@;;ZC`8"UPTv/î]/P @1\U5g%@ @([@T @ P!RI  @ @e  @ @* D i" @ @l!R#~ @ @@H$M$@ @- D*{O @ @ @He  @ @"U`4 @ PP? @ @B & @ @"='@ @T@@TAD @ @@BG@ @ @ *0HH @([@T @ P!RI  @ @e  @ @* D i" @ @l!R#~ @ @@H$M$@ @- D*{O @ @ @He  @ @"U`4 @ PP? @ @B & @ @"='@ @T@@TAD @ @@BG@ @ @ *0HH @([@T @ P!RI  @ @e  @ @* D i" @ @l!R#~ @ @@H$M$@ @- D*{O @@COZ< `< @ @t5NXLtV7ضޝwm~B b@ < v.hm50bRh 4=2AsWz"I Q4ӷў \|,!E ~縜YFC; @|[O]C'BqT[?кWZO;2C P@-!R,ݿ @vz?-dpwwفF ~ט4A @@ y"NkCB߿M)6q; ZN mq!Jpggn69Dm6 @@ m-J@eE <:@ux\l Icg(M  @VsV>j ~*V\V+t_guܹo]z {œYfUVEU @]]i݉w۶k+4쏻:) @,<073*<*k$KشiSɂE68H:c@A1#ugaR%>l P1~ @@4):!REE`ҶUi؜]]cGuPM @hl <5Ob}ێźIE "څGشicΌi; @ZU f #@ HEh4BكmkՁ/#lIY @ P ѮԿ ]Xe4B`du"@ @@ ĝ.lqzDgCč7lt yM @@; g|mkR#I ٙ-ݝ?iTF @bQy-YHunT ,Ќ@i\|uKeoq_7 @ɚӓj(q'h,z%D eu 0}1\ @hAq-']"@ @ΠNG @hE!R+> @ @, D3 @ @ZQ@ԊO @ @:  t @ @V" @ @B:: @ @H8D @LIENDB`strawberry-graphql-django-0.82.1/docs/images/logo.png000066400000000000000000000161671516173410200225600ustar00rootroot00000000000000PNG  IHDR||Ծ pHYsy(qtEXtSoftwarewww.inkscape.org<IDATxyx]U?-mIR^T(" "^DN 8\@u\QE&Cڦm:7鐜y8iڔ4 9ϓ>߳w9qsg8"m1Tw 6,s֍ QXȦŊDX"x6%x-!K#n$&TCzI:ly8Pޯ@X &x T2 jJ`SJ.o%Iaؑ&LtZ֏RdxhށۣۚW-uuH@՜'@\uWA -GF%r L&<~T5w鍡J7ٓ1- 0,+n[D,hځnkEJCXSjH8Lli|`;Sދ 3m<n6F22E`ڶ~0?A.q58'd*d3,em%T{aTMr7Okd{ki#=ܐI^ ^I=蟀sO'_۽{w@}Gz j ]hBlK~*U(=Ling#CG[HwdЫ&f<{Fn2%ęZ¼nO*^t7vLИ1[[CsNJA ƃxl2X |tޕ#(P畲4ӘQ*]bto%XgE,P},sDaJ-eȻ}<ذР|a39u bg%AQް۸,,[:Y^[,NRV&9$<6ŗ~X2nH[xV*@)QަUYAD&q>| N ݊NWѼ$(Qa/Ig+̐(z@gËL C\Gf]X_[̮_,xeEP9T|7A>S1&F*E&l:JWXH.25leً#n<[8Gpv6YcEy2<#!Gd) 3фaY݊V).ڭhX!ee=Z+ơäy1l(P.*D4{p eWe\7;=6kSn~]]]B߷ #OP p6<#k2T`jlpeg̲E~Tg0Ǹ27*to2ji 5Q:oB޷M r+QD;$3D7њ]ݞ5cm*84M9,WSݜX+Eb<z) -xx֤s;Ə*O'c|"Đse{Qꀲ:UUӜ09OZ#vsX-,q~tKJf7Z[F =~1nXN; AAQFhVueA)TV4vऻXw.c}ai2)gȎnkŐg/g.K?xۙM=NM%h{n٥v8kaIs,?XdvGU-oEr .dz`݄UKKӦ>YsғW~2:[o#;f$zt/C q \s (ˀ3ܐ=xҹz&#Q+I'dO-:0f=dPqWyM3Bپkg^GUP?>qhDr .HQm>ƭ9r`ɱnN/2?LY ˅I*Z@ۙ5wH8T ]{zsm[\W=Ϧ慂!pͼ_ES7~"%(@ƏI30i܄[984Q}U'pń_)z9J럹FƫƫSwв蠈Y^AL;Cz~Yt,¶,,*ќaC|ozvn?ME5xn |OgYCu^O(=;\FѢZu+־Mw~c3$ȷ8X9"KeS}u|$SIoFV&#͝7TTwTӚ_|s9QU\~6oe4ߌWAWte|9zWbU/9'a`Qgo߁>Y3/ԤuT7]p>řW{~;2Ǝ |>}} ا5veWqWn+߻N=>Eo;Yߘq2lز5Xմ׮o3D핹-)ñכоz76j}O<}OR?q'ϙW}\G#y |)?/W ¡V.q)} WW/77ߨ7#B"ŊlޡȧCc 7}#~'*?縞+6-$z}d_!B5߾n)hC7֯eG"'VlӨZ. DY `_i D/~ ]o}tﶬءc_f7.aOk?u|σ:aF{ -e9I*5/omO"E>:Nd7,|_w-&Ŋ[t}Q\֜w7H❗@,b7i+G0eR=+nز7-a^;,c/FRqlƳU>#;EIgmȃxazÔFՖ?q[wibr"zE.y& <߾CU,Y|_!FW1:|?|;y#$;;-EV؄g՝-=J>z\2+ϼ/GeA-/'̩PчTk(W]nn {dxuc&bW 6N:Ѳd5Q^}w1h?-r* 1<Q_'ҡ? ;2 iS#y C w%7X1犚z%Ž&|<(FLga`<P\PN1b7_bT?9M.ON"Lޏ 21QJt;̆*7b^yʈɎe}-_.QA3\[wGrBCME7\ mc&Wl|!$A:|X@CfXJw=nl;j_^Df՞1Q;}(dTY'O~}a%xR}9C\SI>>QfD_5KuMG@g(2<?Eަ'yzSv+{ Z@7(wrQy-6yOo; {FJF;ضULTCĘ qqu;:Ouqs89q+% `IENDB`strawberry-graphql-django-0.82.1/docs/index.md000066400000000000000000000415261516173410200212730ustar00rootroot00000000000000--- title: Quick Start --- # Quick Start In this Quick-Start, we will: - Set up a basic pair of models with a relation between them. - Add them to a graphql schema and serve the graph API. - Query the graph API for model contents. For a more advanced example of a similar setup including a set of mutations and more queries, please check the [example app](https://github.com/strawberry-graphql/strawberry-django/tree/main/examples/ecommerce_app). ## Installation ```sh poetry add strawberry-graphql-django poetry add django-choices-field # Not required but recommended ``` (Not using poetry? `pip install strawberry-graphql-django` works fine too.) ## Define your application models We'll build an example database of fruit and their colours. > [!TIP] > You'll notice that for `Fruit.category`, we use `TextChoicesField` instead of `TextField(choices=...)`. > This allows strawberry-django to automatically use an enum in the graphQL schema, instead of > a string which would be the default behaviour for TextField. > > See the [choices-field integration](./integrations/choices-field.md) for more information. ```python title="models.py" from django.db import models from django_choices_field import TextChoicesField class FruitCategory(models.TextChoices): CITRUS = "citrus", "Citrus" BERRY = "berry", "Berry" class Fruit(models.Model): """A tasty treat""" name = models.CharField(max_length=20, help_text="The name of the fruit variety") category = TextChoicesField( choices_enum=FruitCategory, help_text="The category of the fruit" ) color = models.ForeignKey( "Color", on_delete=models.CASCADE, related_name="fruits", blank=True, null=True, help_text="The color of this kind of fruit", ) class Color(models.Model): """The hue of your tasty treat""" name = models.CharField( max_length=20, help_text="The color name", ) ``` You'll need to make migrations then migrate: ```sh python manage.py makemigrations python manage.py migrate ``` Now use the django shell, the admin, the loaddata command or whatever tool you like to load some fruits and colors. I've loaded a red strawberry (predictable, right?!) ready for later. ## Define types Before creating queries, you have to define a `type` for each model. A `type` is a fundamental unit of the [schema](https://strawberry.rocks/docs/types/schema) which describes the shape of the data that can be queried from the GraphQL server. Types can represent scalar values (like String, Int, Boolean, Float, and ID), enums, or complex objects that consist of many fields. > [!TIP] > A key feature of `strawberry-graphql-django` is that it provides helpers to create types from django models, > by automatically inferring types (and even documentation!!) from the model fields. > > See the [fields guide](./guide/fields.md) for more information. ```python title="types.py" import strawberry_django from strawberry import auto from . import models @strawberry_django.type(models.Fruit) class Fruit: id: auto name: auto category: auto color: "Color" # Strawberry will understand that this refers to the "Color" type that's defined below @strawberry_django.type(models.Color) class Color: id: auto name: auto fruits: list[ Fruit ] # This tells strawberry about the ForeignKey to the Fruit model and how to represent the Fruit instances on that relation ``` ## Build the queries and schema Next we want to assemble the [schema](https://strawberry.rocks/docs/types/schema) from its building block types. > [!WARNING] > You'll notice a familiar statement, `fruits: list[Fruit]`. We already used this statement in the previous step in `types.py`. > Seeing it twice can be a point of confusion when you're first getting to grips with GraphQL and Strawberry. > > The purpose here is similar but subtly different. Previously, the syntax defined that it was possible to make a query that **traverses** within the graph, from a Color to a list of Fruits. > Here, the usage defines a [**root** query](https://strawberry.rocks/docs/general/queries) (a bit like an entrypoint into the graph). > [!TIP] > We add the `DjangoOptimizerExtension` here. Don't worry about why for now, but you're almost certain to want it. > > See the [optimizer guide](./guide/optimizer.md) for more information. ```python title="schema.py" import strawberry from strawberry_django.optimizer import DjangoOptimizerExtension from .types import Fruit @strawberry.type class Query: fruits: list[Fruit] = strawberry_django.field() schema = strawberry.Schema( query=Query, extensions=[ DjangoOptimizerExtension, ], ) ``` ## Serving the API Now we're showing off. This isn't enabled by default, since existing django applications will likely have model docstrings and help text that aren't user-oriented. But if you're starting clean (or overhauling existing docstrings and help text), setting up the following is super useful for your API users. If you don't set these true, you can always provide user-oriented descriptions. See the [types guide](./guide/types.md) and [fields guide](./guide/fields.md) for more details. ```python title="settings.py" STRAWBERRY_DJANGO = { "FIELD_DESCRIPTION_FROM_HELP_TEXT": True, "TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING": True, } ``` ```python title="urls.py" from django.urls import include, path from strawberry.django.views import AsyncGraphQLView from .schema import schema urlpatterns = [ path("graphql", AsyncGraphQLView.as_view(schema=schema)), ] ``` This generates following schema: ```graphql title="schema.graphql" enum FruitCategory { CITRUS BERRY } """ A tasty treat """ type Fruit { id: ID! name: String! category: FruitCategory! color: Color } type Color { id: ID! """ field description """ name: String! fruits: [Fruit!] } type Query { fruits: [Fruit!]! } ``` ## Using the API Start your server with: ```sh python manage.py runserver ``` Then visit [localhost:8000/graphql](http://localhost:8000/graphql) in your browser. You should see the graphql explorer being served by django. Using the interactive query tool, you can query for the fruits you added earlier: ![GraphiQL with fruit](./images/graphiql-with-fruit.png) ## Real-World Examples Now that you have a basic API running, let's explore some common real-world scenarios. ### Adding Filters Allow clients to filter fruits by category or color: ```python title="schema.py" import strawberry import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry_django import filters from typing import Optional from . import models from .types import Fruit, Color @strawberry.type class Query: @strawberry_django.field def fruits( self, category: Optional[str] = None, color_name: Optional[str] = None ) -> list[Fruit]: """Get fruits with optional filtering""" queryset = models.Fruit.objects.all() if category: queryset = queryset.filter(category=category) if color_name: queryset = queryset.filter(color__name__icontains=color_name) return queryset schema = strawberry.Schema( query=Query, extensions=[ DjangoOptimizerExtension, ], ) ``` Query with filters: ```graphql query { fruits(category: "BERRY", colorName: "red") { name category color { name } } } ``` ### Adding Mutations Allow clients to create and update fruits: ```python title="schema.py" import strawberry from strawberry_django import mutations from .types import Fruit from . import models @strawberry_django.input(models.Fruit) class FruitInput: name: auto category: auto color_id: auto @strawberry_django.partial(models.Fruit) class FruitInputPartial(strawberry.relay.NodeInput): name: auto category: auto color_id: auto @strawberry.type class Mutation: # Automatic CRUD mutations with Django error handling create_fruit: Fruit = mutations.create(FruitInput, handle_django_errors=True) update_fruit: Fruit = mutations.update(FruitInputPartial, handle_django_errors=True) delete_fruit: Fruit = mutations.delete( FruitInputPartial, # Need input type with id field handle_django_errors=True, ) schema = strawberry.Schema( query=Query, mutation=Mutation, extensions=[ DjangoOptimizerExtension, ], ) ``` Create a fruit: ```graphql mutation { createFruit(data: { name: "Blueberry", category: "BERRY", colorId: "1" }) { ... on Fruit { id name category } ... on OperationInfo { messages { field message } } } } ``` ### Adding Pagination Limit the number of results for better performance: ```python title="schema.py" from strawberry_django.pagination import OffsetPaginationInput @strawberry.type class Query: @strawberry_django.field def fruits(self, pagination: Optional[OffsetPaginationInput] = None) -> list[Fruit]: """Get fruits with pagination""" queryset = models.Fruit.objects.all() if pagination: queryset = queryset[ pagination.offset : pagination.offset + pagination.limit ] else: queryset = queryset[:20] # Default limit return queryset ``` Query with pagination: ```graphql query { fruits(pagination: { offset: 0, limit: 10 }) { name category } } ``` ### Adding Authentication Protect your API with authentication: ```python title="schema.py" from strawberry.permission import BasePermission from strawberry.types import Info class IsAuthenticated(BasePermission): message = "User is not authenticated" def has_permission(self, source, info: Info, **kwargs) -> bool: return info.context.request.user.is_authenticated @strawberry.type class Mutation: @strawberry.mutation(permission_classes=[IsAuthenticated]) def create_fruit(self, info: Info, name: str, category: str) -> Fruit: """Create a fruit (requires authentication)""" return models.Fruit.objects.create( name=name, category=category, created_by=info.context.request.user ) ``` ### Computed Fields Add fields that are computed rather than stored: ```python title="types.py" import strawberry_django from strawberry import auto @strawberry_django.type(models.Fruit) class Fruit: id: auto name: auto category: auto color: "Color" @strawberry_django.field def display_name(self) -> str: """Computed field: formatted display name""" return f"{self.name} ({self.category})" @strawberry_django.field def is_citrus(self) -> bool: """Computed field: check if fruit is citrus""" return self.category == models.FruitCategory.CITRUS ``` Query computed fields: ```graphql query { fruits { name displayName isCitrus } } ``` ### Optimizing Performance The `DjangoOptimizerExtension` automatically prevents N+1 query problems: ```python # Without optimizer: 1 query for fruits + N queries for colors (N+1 problem) # With optimizer: 2 queries total (1 for fruits + 1 JOIN for colors) @strawberry.type class Query: fruits: list[Fruit] = strawberry_django.field() # Query that fetches related data efficiently query = """ query { fruits { name color { name # No N+1 problem thanks to optimizer! } } } """ ``` See the [Performance Guide](./guide/performance.md) for more optimization strategies. ### Error Handling Handle validation and database errors gracefully: ```python title="schema.py" @strawberry.type class Mutation: create_fruit: Fruit = mutations.create( FruitInput, handle_django_errors=True, # Automatically returns structured errors ) ``` Error response format: ```json { "data": { "createFruit": { "__typename": "OperationInfo", "messages": [ { "field": "name", "message": "This field is required", "kind": "VALIDATION" } ] } } } ``` See the [Error Handling Guide](./guide/error-handling.md) for comprehensive error management. ## Complete Example Here's a complete example bringing everything together: ```python title="schema.py" import strawberry from strawberry_django import mutations from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry_django.pagination import OffsetPaginationInput from strawberry.permission import BasePermission from strawberry.types import Info from typing import Optional from .types import Fruit, Color from . import models class IsAuthenticated(BasePermission): message = "User is not authenticated" def has_permission(self, source, info: Info, **kwargs) -> bool: return info.context.request.user.is_authenticated @strawberry.type class Query: @strawberry_django.field def fruits( self, category: Optional[str] = None, pagination: Optional[OffsetPaginationInput] = None, ) -> list[Fruit]: """Get fruits with optional filtering and pagination""" queryset = models.Fruit.objects.all() if category: queryset = queryset.filter(category=category) if pagination: queryset = queryset[ pagination.offset : pagination.offset + pagination.limit ] else: queryset = queryset[:20] return queryset @strawberry_django.field def fruit(self, id: strawberry.ID) -> Optional[Fruit]: """Get a single fruit by ID""" return models.Fruit.objects.filter(id=id).first() @strawberry_django.field def colors(self) -> list[Color]: """Get all colors""" return models.Color.objects.all() @strawberry.type class Mutation: # CRUD operations with automatic error handling create_fruit: Fruit = mutations.create(FruitInput, handle_django_errors=True) update_fruit: Fruit = mutations.update(FruitInputPartial, handle_django_errors=True) @strawberry.mutation(permission_classes=[IsAuthenticated]) def delete_fruit(self, info: Info, id: strawberry.ID) -> bool: """Delete a fruit (requires authentication)""" models.Fruit.objects.filter(id=id).delete() return True schema = strawberry.Schema( query=Query, mutation=Mutation, extensions=[ DjangoOptimizerExtension, # Prevents N+1 queries ], ) ``` Example queries: ```graphql # Get all fruits with their colors query GetAllFruits { fruits { id name category displayName color { name } } } # Get filtered and paginated fruits query GetBerries { fruits(category: "BERRY", pagination: { offset: 0, limit: 5 }) { name color { name } } } # Create a new fruit mutation CreateFruit { createFruit(data: { name: "Raspberry", category: "BERRY", colorId: "1" }) { ... on Fruit { id name category } ... on OperationInfo { messages { field message kind } } } } # Update a fruit mutation UpdateFruit { updateFruit(id: "1", data: { name: "Updated Strawberry" }) { ... on Fruit { id name } ... on OperationInfo { messages { field message } } } } ``` ## Next Steps Now that you have a working GraphQL API with common features, explore these guides to learn more: ### Essential Guides 1. [Types](./guide/types.md) - Define complex GraphQL types from Django models 2. [Fields](./guide/fields.md) - Customize field behavior and add computed fields 3. [Mutations](./guide/mutations.md) - Create, update, and delete operations 4. [Filters](./guide/filters.md) - Advanced filtering capabilities 5. [Pagination](./guide/pagination.md) - Efficient data pagination strategies ### Performance & Optimization 6. [Query Optimizer](./guide/optimizer.md) - Prevent N+1 queries automatically 7. [Performance](./guide/performance.md) - Database optimization and caching 8. [DataLoaders](./guide/dataloaders.md) - Custom data loading patterns ### Security & Validation 9. [Permissions](./guide/permissions.md) - Protect your API with authorization 10. [Validation](./guide/validation.md) - Input validation and error handling 11. [Error Handling](./guide/error-handling.md) - Comprehensive error management ### Advanced Topics 12. [Relay](./guide/relay.md) - Relay-style pagination and connections 13. [Subscriptions](./guide/subscriptions.md) - Real-time updates with WebSockets 14. [Model Properties](./guide/model-properties.md) - Optimize computed properties 15. [Nested Mutations](./guide/nested-mutations.md) - Handle complex relationships 16. [Unit Testing](./guide/unit-testing.md) - Test your GraphQL API ### Help & Resources - [FAQ](./faq.md) - Frequently asked questions - [Troubleshooting](./guide/troubleshooting.md) - Common issues and solutions - [Example App](https://github.com/strawberry-graphql/strawberry-django/tree/main/examples/ecommerce_app) - Complete working example strawberry-graphql-django-0.82.1/docs/integrations/000077500000000000000000000000001516173410200223405ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/docs/integrations/channels.md000066400000000000000000000161221516173410200244570ustar00rootroot00000000000000--- title: Django Channels --- # Django Channels Strawberry provides an integration with [django-channels](https://channels.readthedocs.io/en/stable/) to enable [subscriptions](../guide/subscriptions.md) and WebSocket support with Django. ## Overview Django doesn't support WebSockets out of the box. Django Channels extends Django to handle WebSockets and other asynchronous protocols. Strawberry's Channels integration allows you to: - Use GraphQL subscriptions - Handle GraphQL over WebSocket connections - Mix regular Django HTTP requests with GraphQL ## Installation Install the required packages: ```bash pip install channels daphne ``` For production deployments, you may also need a channel layer backend: ```bash pip install channels-redis # For Redis-backed channel layers ``` ## Configuration ### 1. Update INSTALLED_APPS Add `daphne` and `channels` to your `INSTALLED_APPS` in `settings.py`: ```python title="settings.py" INSTALLED_APPS = [ "daphne", # Must be before staticfiles for runserver override "django.contrib.staticfiles", # ... other apps "channels", "strawberry_django", ] ``` ### 2. Configure ASGI Application Set the ASGI application path: ```python title="settings.py" ASGI_APPLICATION = "myproject.asgi.application" ``` ### 3. Set Up ASGI Routing Create or update your `asgi.py` file: ```python title="myproject/asgi.py" import os from django.core.asgi import get_asgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings") # Initialize Django ASGI application early to ensure AppRegistry is populated # before importing any models django_asgi_app = get_asgi_application() # Import schema AFTER django setup from myproject.schema import schema from strawberry_django.routers import AuthGraphQLProtocolTypeRouter application = AuthGraphQLProtocolTypeRouter( schema, django_application=django_asgi_app, ) ``` ## AuthGraphQLProtocolTypeRouter `strawberry_django` provides `AuthGraphQLProtocolTypeRouter`, a convenience class that sets up GraphQL on both HTTP and WebSocket with authentication support. ```python from strawberry_django.routers import AuthGraphQLProtocolTypeRouter application = AuthGraphQLProtocolTypeRouter( schema, # Your Strawberry schema django_application=django_asgi, # Optional: Django ASGI app for non-GraphQL routes url_pattern="^graphql", # Optional: URL pattern (default: "^graphql") ) ``` ### What it provides - **AuthMiddlewareStack**: Automatically populates `request.user` for authenticated sessions - **AllowedHostsOriginValidator**: WebSocket security based on `ALLOWED_HOSTS` - **Dual protocol routing**: Routes both HTTP and WebSocket to GraphQL ### Custom URL Pattern ```python # Route GraphQL to /api/graphql instead of /graphql application = AuthGraphQLProtocolTypeRouter( schema, django_application=django_asgi_app, url_pattern="^api/graphql", ) ``` ## Difference from Strawberry's GraphQLProtocolTypeRouter Strawberry core provides `GraphQLProtocolTypeRouter`, but `strawberry_django` provides `AuthGraphQLProtocolTypeRouter` with these enhancements: | Feature | GraphQLProtocolTypeRouter | AuthGraphQLProtocolTypeRouter | | ------------------------ | ------------------------- | --------------------------------- | | Auth middleware | No | Yes (AuthMiddlewareStack) | | Host validation | No | Yes (AllowedHostsOriginValidator) | | `request.user` available | No | Yes | Use `AuthGraphQLProtocolTypeRouter` when you need: - User authentication in resolvers - Permission checks - Session-based authentication ## Channel Layers For subscriptions that need to broadcast to multiple clients (e.g., chat applications), configure a channel layer: ```python title="settings.py" # Development (in-memory, single-process only) CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} # Production (Redis) CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { "hosts": [("127.0.0.1", 6379)], }, }, } ``` ## Accessing User in Resolvers With `AuthGraphQLProtocolTypeRouter`, you can access the authenticated user: ```python import strawberry from strawberry.types import Info @strawberry.type class Query: @strawberry.field def me(self, info: Info) -> str: user = info.context.request.user if user.is_authenticated: return f"Hello, {user.username}!" return "Hello, anonymous!" ``` ## Running the Server ### Development With Daphne installed and configured, `runserver` automatically uses ASGI: ```bash python manage.py runserver ``` ### Production Use an ASGI server like Daphne, Uvicorn, or Hypercorn: ```bash # Daphne daphne myproject.asgi:application # Uvicorn uvicorn myproject.asgi:application --host 0.0.0.0 --port 8000 # Hypercorn hypercorn myproject.asgi:application --bind 0.0.0.0:8000 ``` ## Advanced: Custom Routing For more complex setups, you can create custom routing: ```python title="myproject/asgi.py" import os from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter from channels.security.websocket import AllowedHostsOriginValidator from django.core.asgi import get_asgi_application from django.urls import re_path from strawberry.channels.handlers.http_handler import GraphQLHTTPConsumer from strawberry.channels.handlers.ws_handler import GraphQLWSConsumer os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings") django_asgi_app = get_asgi_application() from myproject.schema import schema application = ProtocolTypeRouter({ "http": AuthMiddlewareStack( URLRouter([ re_path(r"^graphql$", GraphQLHTTPConsumer.as_asgi(schema=schema)), re_path(r"^", django_asgi_app), ]) ), "websocket": AllowedHostsOriginValidator( AuthMiddlewareStack( URLRouter([ re_path(r"^graphql$", GraphQLWSConsumer.as_asgi(schema=schema)), ]) ) ), }) ``` ## Troubleshooting ### Subscriptions not working 1. **Check ASGI_APPLICATION** is correctly set in settings 2. **Ensure schema is imported after** `get_asgi_application()` to avoid AppRegistryNotReady errors 3. **Use an ASGI server** - standard `runserver` without Daphne uses WSGI ### User is AnonymousUser 1. Ensure you're using `AuthGraphQLProtocolTypeRouter` (not `GraphQLProtocolTypeRouter`) 2. Check that `AuthMiddlewareStack` is in your routing 3. Verify session cookies are being sent with WebSocket requests ### WebSocket connection refused 1. Check `ALLOWED_HOSTS` in settings includes your hostname 2. Ensure the URL pattern matches your client's WebSocket URL 3. Check browser console for CORS or security errors ## See Also - [Subscriptions Guide](../guide/subscriptions.md) - Creating subscriptions - [Strawberry Channels Docs](https://strawberry.rocks/docs/integrations/channels) - Core integration docs - [Django Channels Docs](https://channels.readthedocs.io/en/stable/) - Full Channels documentation strawberry-graphql-django-0.82.1/docs/integrations/choices-field.md000066400000000000000000000020161516173410200253570ustar00rootroot00000000000000--- title: Django Choices Field --- # django-choices-field This lib provides integration for enum resolution for [Django's TextChoices/IntegerChoices](https://docs.djangoproject.com/en/4.2/ref/models/fields/#enumeration-types) when defining the fields using the [django-choices-field](https://github.com/bellini666/django-choices-field) lib: ```python title="models.py" from django.db import models from django_choices_field import TextChoicesField class Status(models.TextChoices): ACTIVE = "active", "Is Active" INACTIVE = "inactive", "Inactive" class Company(models.Model): status = TextChoicesField( choices_enum=Status, default=Status.ACTIVE, ) ``` ```python title="types.py" import strawberry import strawberry_django from . import models @strawberry_django.type(models.Company) class Company: status: strawberry.auto ``` The code above would generate the following schema: ```graphql title="schema.graphql" enum Status { ACTIVE INACTIVE } type Company { status: Status } ``` strawberry-graphql-django-0.82.1/docs/integrations/debug-toolbar.md000066400000000000000000000016301516173410200254100ustar00rootroot00000000000000--- title: Django Debug Toolbar --- # django-debug-toolbar This integration provides integration between the [Django Debug Toolbar](https://github.com/jazzband/django-debug-toolbar) and `strawberry`, allowing it to display stats like `SQL Queries`, `CPU Time`, `Cache Hits`, etc for queries and mutations done inside the [graphiql page](https://github.com/graphql/graphiql). To use it, make sure you have the [Django Debug Toolbar](https://github.com/jazzband/django-debug-toolbar) installed and configured, then change its middleware settings from: ```python title="settings.py" MIDDLEWARE = [ ... "debug_toolbar.middleware.DebugToolbarMiddleware", ... ] ``` To: ```python title="settings.py" MIDDLEWARE = [ ... "strawberry_django.middlewares.debug_toolbar.DebugToolbarMiddleware", ... ] ``` Finally, ensure app `"strawberry_django"` is added to your `INSTALLED_APPS` in Django settings. strawberry-graphql-django-0.82.1/docs/integrations/federation.md000066400000000000000000000166451516173410200250160ustar00rootroot00000000000000--- title: Federation --- # Federation Strawberry Django works seamlessly with [Strawberry's Federation support](https://strawberry.rocks/docs/guides/federation). You can use either Strawberry's federation decorators directly or the Django-specific `strawberry_django.federation` module which provides auto-generated `resolve_reference` methods. ## Using `strawberry_django.federation` (Recommended) The `strawberry_django.federation` module provides Django-aware federation decorators that automatically generate `resolve_reference` methods for your entity types: ```python import strawberry import strawberry_django from strawberry.federation import Schema as FederationSchema from . import models @strawberry_django.federation.type(models.Product, keys=["upc"]) class Product: upc: strawberry.auto name: strawberry.auto price: strawberry.auto # resolve_reference is automatically generated! @strawberry_django.federation.type(models.Review, keys=["id"]) class Review: id: strawberry.auto body: strawberry.auto product: Product @strawberry.type class Query: @strawberry_django.field def products(self) -> list[Product]: return models.Product.objects.all() schema = FederationSchema(query=Query) ``` ### Federation Parameters The `@strawberry_django.federation.type` decorator accepts all standard `@strawberry_django.type` parameters plus federation-specific ones: | Parameter | Type | Description | | ----------------- | ------------------ | ------------------------------------------------------------------------------------ | | `keys` | `list[str \| Key]` | Key fields for entity resolution (e.g., `["id"]` or `["sku package"]` for composite) | | `extend` | `bool` | Whether this type extends a type from another subgraph | | `shareable` | `bool` | Whether this type can be resolved by multiple subgraphs | | `inaccessible` | `bool` | Whether this type is hidden from the public API | | `authenticated` | `bool` | Whether this type requires authentication | | `policy` | `list[list[str]]` | Access policy for this type | | `requires_scopes` | `list[list[str]]` | Required OAuth scopes for this type | | `tags` | `list[str]` | Metadata tags for this type | ### Multiple Keys You can define multiple key fields. Each string in the list creates a separate `@key` directive — the entity can be resolved by **either** key independently: ```python @strawberry_django.federation.type(models.Product, keys=["id", "upc"]) class Product: id: strawberry.auto upc: strawberry.auto name: strawberry.auto ``` ### Composite Keys For composite keys (multiple fields that together form a key), use a space-separated string: ```python @strawberry_django.federation.type(models.ProductVariant, keys=["sku package"]) class ProductVariant: sku: strawberry.auto package: strawberry.auto price: strawberry.auto ``` ### Custom `resolve_reference` If you need custom logic, you can still define your own `resolve_reference`: ```python from strawberry.types.info import Info @strawberry_django.federation.type(models.Product, keys=["upc"]) class Product: upc: strawberry.auto name: strawberry.auto @classmethod def resolve_reference(cls, upc: str, info: Info) -> "Product": # Custom implementation with select_related return models.Product.objects.select_related("category").get(upc=upc) ``` ### Federation Fields Use `strawberry_django.federation.field` for federation-specific field directives: ```python @strawberry_django.federation.type(models.Product, keys=["id"]) class Product: id: strawberry.auto name: strawberry.auto = strawberry_django.federation.field(external=True) price: strawberry.auto = strawberry_django.federation.field(shareable=True) display_name: str = strawberry_django.federation.field( requires=["name"], resolver=lambda self: f"Product: {self.name}", ) ``` Field parameters: | Parameter | Type | Description | | ----------------- | ----------------- | ------------------------------------------------ | | `authenticated` | `bool` | Whether this field requires authentication | | `external` | `bool` | Field is defined in another subgraph | | `requires` | `list[str]` | Fields required from other subgraphs | | `provides` | `list[str]` | Fields this resolver provides to other subgraphs | | `override` | `str` | Override field from another subgraph | | `policy` | `list[list[str]]` | Access policy for this field | | `requires_scopes` | `list[list[str]]` | Required OAuth scopes for this field | | `shareable` | `bool` | Field can be resolved by multiple subgraphs | | `tags` | `list[str]` | Metadata tags for this field | | `inaccessible` | `bool` | Field is hidden from the public API | ### Interfaces Federation interfaces are also supported: ```python @strawberry_django.federation.interface(models.Product, keys=["id"]) class ProductInterface: id: strawberry.auto name: strawberry.auto ``` ## Using Strawberry's Federation Directly You can also use Strawberry's federation decorators alongside `strawberry_django`: ```python import strawberry import strawberry_django from strawberry.federation.schema_directives import Key from . import models @strawberry_django.type(models.Product, directives=[Key(fields="upc")]) class Product: upc: strawberry.auto name: strawberry.auto price: strawberry.auto @classmethod def resolve_reference(cls, upc: str) -> "Product": return models.Product.objects.get(upc=upc) ``` ## Creating a Federated Schema Use `strawberry.federation.Schema` instead of the regular `strawberry.Schema`: ```python from strawberry.federation import Schema @strawberry.type class Query: @strawberry_django.field def products(self) -> list[Product]: return models.Product.objects.all() schema = Schema(query=Query) ``` ## Django-Specific Considerations ### Query Optimizer The [Query Optimizer](../guide/optimizer.md) works with federated schemas. Add the extension as usual: ```python from strawberry_django.optimizer import DjangoOptimizerExtension schema = Schema( query=Query, extensions=[DjangoOptimizerExtension], ) ``` The auto-generated `resolve_reference` methods integrate with the query optimizer when using `strawberry_django.federation`. ### Authentication When using federation with Django authentication, ensure your gateway forwards authentication headers. See [Authentication](../guide/authentication.md) for configuring authentication in your Django service. ## Further Reading For complete federation documentation, see: - [Strawberry Federation Guide](https://strawberry.rocks/docs/guides/federation) - [Federation Specification](https://www.apollographql.com/docs/federation/) strawberry-graphql-django-0.82.1/docs/integrations/geodjango.md000066400000000000000000000057651516173410200246340ustar00rootroot00000000000000--- title: GeoDjango --- # GeoDjango Strawberry Django provides built-in support for [GeoDjango](https://docs.djangoproject.com/en/stable/ref/contrib/gis/) fields, automatically mapping them to GraphQL scalar types. ## Supported Field Types | Django Field | GraphQL Scalar | Description | | ---------------------- | ----------------- | ---------------------------------- | | `PointField` | `Point` | A point as `(x, y)` or `(x, y, z)` | | `LineStringField` | `LineString` | Multiple points forming a line | | `PolygonField` | `Polygon` | One or more LinearRings | | `MultiPointField` | `MultiPoint` | Collection of Points | | `MultiLineStringField` | `MultiLineString` | Collection of LineStrings | | `MultiPolygonField` | `MultiPolygon` | Collection of Polygons | | `GeometryField` | `Geometry` | Any geometry type | ## Usage Define your model and type as usual—geographic fields are handled automatically: ```python # models.py from django.contrib.gis.db import models class Location(models.Model): name = models.CharField(max_length=100) point = models.PointField() area = models.PolygonField(null=True, blank=True) ``` ```python # types.py import strawberry_django from strawberry import auto from django.contrib.gis.geos import Point, Polygon from . import models @strawberry_django.type(models.Location) class Location: id: auto name: auto point: auto # Automatically uses Point scalar area: auto # Automatically uses Polygon scalar @strawberry_django.input(models.Location) class LocationInput: name: auto point: auto area: auto # You can also use geos types directly in annotations @strawberry_django.type(models.Location) class LocationExplicit: id: auto name: auto point: Point area: Polygon | None ``` ## GraphQL Data Format ```graphql # Point: [x, y] or [x, y, z] point: [2.2945, 48.8584] # LineString: array of points lineString: [[0, 0], [1, 1], [2, 0]] # Polygon: array of rings (first is exterior, rest are holes) polygon: [[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]] # Multi* types: arrays of the corresponding type multiPoint: [[0, 0], [1, 1]] multiPolygon: [[[[0, 0], [1, 0], [1, 1], [0, 0]]]] ``` ## Spatial Queries For spatial filtering (distance, contains, etc.), implement custom resolvers: ```python from django.contrib.gis.geos import Point from django.contrib.gis.measure import D @strawberry.type class Query: @strawberry_django.field def locations_near( self, latitude: float, longitude: float, radius_km: float ) -> list[Location]: point = Point(longitude, latitude, srid=4326) return models.Location.objects.filter( point__distance_lte=(point, D(km=radius_km)) ) ``` For GeoDjango setup and troubleshooting, see the [GeoDjango documentation](https://docs.djangoproject.com/en/stable/ref/contrib/gis/install/). strawberry-graphql-django-0.82.1/docs/integrations/guardian.md000066400000000000000000000147601516173410200244640ustar00rootroot00000000000000--- title: django-guardian --- # django-guardian This lib provides integration for per-object-permissions using [django-guardian](https://django-guardian.readthedocs.io/en/stable/). ## Installation First, install django-guardian: ```bash pip install django-guardian ``` Or with the strawberry-django extras: ```bash pip install strawberry-graphql-django pip install django-guardian ``` ## Configuration Add guardian to your Django settings: ```python title="settings.py" INSTALLED_APPS = [ # ... "guardian", ] AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", # Default backend "guardian.backends.ObjectPermissionBackend", # Guardian backend ] # Optional: Configure anonymous user handling ANONYMOUS_USER_NAME = ( None # Set to a username string to enable anonymous user permissions ) ``` Run migrations to create guardian's permission tables: ```bash python manage.py migrate guardian ``` ## Usage with Permission Extensions Once configured, you can use `HasSourcePerm` and `HasRetvalPerm` to check object-level permissions: ```python title="types.py" import strawberry_django from strawberry_django.permissions import ( HasPerm, HasSourcePerm, HasRetvalPerm, ) from . import models @strawberry_django.type(models.Document) class DocumentType: id: auto title: auto content: auto # Check object permission on the resolved document secret_content: str = strawberry_django.field( extensions=[HasRetvalPerm("documents.view_document")] ) ``` ```python title="schema.py" import strawberry import strawberry_django from strawberry_django.permissions import HasRetvalPerm, HasSourcePerm @strawberry.type class Query: # Filter documents to only those the user has permission to view @strawberry_django.field(extensions=[HasRetvalPerm("documents.view_document")]) def documents(self) -> list[DocumentType]: return models.Document.objects.all() # Check permission on parent object @strawberry_django.field(extensions=[HasSourcePerm("documents.view_document")]) def document_metadata(self, document: DocumentType) -> MetadataType: return document.metadata ``` ## Permission Extension Parameters The permission extensions accept several parameters for fine-grained control: ### HasPerm / HasSourcePerm / HasRetvalPerm ```python HasRetvalPerm( perms="app.permission", # Required: permission string or list of permissions any_perm=True, # If True, user needs ANY of the perms; if False, needs ALL with_anonymous=True, # If True, skip permission check for anonymous users (faster) with_superuser=False, # If True, superusers bypass permission checks fail_silently=True, # If True, return None/empty instead of raising error ) ``` ### Example: Multiple Permissions ```python @strawberry_django.field( extensions=[ HasRetvalPerm( ["documents.view_document", "documents.edit_document"], any_perm=False, # User must have BOTH permissions ) ] ) def sensitive_document(self, id: strawberry.ID) -> DocumentType: return models.Document.objects.get(pk=id) ``` ### Example: Superuser Bypass ```python @strawberry_django.field( extensions=[ HasRetvalPerm( "documents.view_document", with_superuser=True, # Superusers can access without the specific permission ) ] ) def document(self, id: strawberry.ID) -> DocumentType: return models.Document.objects.get(pk=id) ``` ## Assigning Object Permissions Use django-guardian's utilities to assign permissions: ```python from guardian.shortcuts import assign_perm, remove_perm, get_objects_for_user # Assign permission to a user for a specific object document = Document.objects.get(pk=1) assign_perm("documents.view_document", user, document) # Remove permission remove_perm("documents.view_document", user, document) # Get all objects a user has permission for user_documents = get_objects_for_user(user, "documents.view_document") ``` ## How List Filtering Works When using `HasRetvalPerm` on a field that returns a list, the extension automatically filters the results: ```python @strawberry_django.field(extensions=[HasRetvalPerm("documents.view_document")]) def documents(self) -> list[DocumentType]: # Returns all documents, but the extension filters to only those # the user has 'view_document' permission for return models.Document.objects.all() ``` The filtering happens after the queryset is evaluated, so for better performance with large datasets, consider filtering at the database level: ```python from guardian.shortcuts import get_objects_for_user @strawberry_django.field def my_documents(self, info: Info) -> list[DocumentType]: user = info.context.request.user return get_objects_for_user(user, "documents.view_document") ``` ## Global vs Object Permissions - **Global permissions** (`HasPerm`): Checks if user has a permission at the model level (e.g., can create any document) - **Object permissions** (`HasSourcePerm`, `HasRetvalPerm`): Checks if user has a permission for a specific object instance ```python @strawberry.type class Mutation: # Global permission: Can user create ANY document? @strawberry.mutation(extensions=[HasPerm("documents.add_document")]) def create_document(self, title: str) -> DocumentType: return Document.objects.create(title=title) # Object permission: Can user edit THIS specific document? @strawberry.mutation(extensions=[HasSourcePerm("documents.change_document")]) def update_document(self, document: DocumentType, title: str) -> DocumentType: document.title = title document.save() return document ``` ## Troubleshooting ### Permissions Not Working 1. **Check AUTHENTICATION_BACKENDS**: Ensure `guardian.backends.ObjectPermissionBackend` is in your settings 2. **Run migrations**: Make sure `python manage.py migrate guardian` was executed 3. **Verify permission assignment**: Use Django shell to check if permissions are correctly assigned ```python from guardian.shortcuts import get_perms get_perms(user, document) # Returns list of permission codenames ``` ### Anonymous User Issues If you need anonymous users to have permissions, configure `ANONYMOUS_USER_NAME` in settings and use: ```python HasRetvalPerm("app.perm", with_anonymous=False) # Don't skip check for anonymous users ``` ## See Also - [Permissions Guide](../guide/permissions.md) - Full permissions documentation - [django-guardian documentation](https://django-guardian.readthedocs.io/en/stable/) strawberry-graphql-django-0.82.1/examples/000077500000000000000000000000001516173410200205205ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/README.md000066400000000000000000000002171516173410200217770ustar00rootroot00000000000000# Strawberry Django Examples This directory contains example applications demonstrating how to use Strawberry Django in real-world scenarios. strawberry-graphql-django-0.82.1/examples/ecommerce_app/000077500000000000000000000000001516173410200233175ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/.gitignore000066400000000000000000000000141516173410200253020ustar00rootroot00000000000000/db.sqlite3 strawberry-graphql-django-0.82.1/examples/ecommerce_app/README.md000066400000000000000000000345511516173410200246060ustar00rootroot00000000000000# E-commerce Example A comprehensive GraphQL API example demonstrating **Strawberry Django** features and best practices through a realistic e-commerce application with users, products, shopping carts, and orders. ## 🎯 Learning Objectives This example is designed to help you learn Strawberry Django by demonstrating real-world patterns you'll use in production applications. After exploring this example, you'll understand: ### Core Concepts - **Modular Django app structure** - How to organize GraphQL schemas across multiple Django apps - **Type-safe GraphQL** - Using Python type hints for automatic schema generation - **Relay Node interface** - Implementing global object identification - **Multiple query patterns** - Single nodes, offset pagination, and cursor-based Relay connections ### Optimization & Performance - **Query optimization** - Using `@model_property` with optimization hints to prevent N+1 queries - **DataLoaders** - Batching and caching database queries (see `app/base/dataloaders.py`) - **Prefetching strategies** - Efficient loading of related data ### Security & Authentication - **Authentication** - Session-based login/logout - **Permissions** - Using permission extensions (`IsAuthenticated`, `IsStaff`) - **Field-level security** - Controlling access to specific fields ### Advanced Features - **Custom context** - Type-safe context with helper methods and dataloaders - **Error handling** - Automatic Django error conversion with `handle_django_errors` - **Transaction handling** - Using `@transaction.atomic` for data consistency - **Session management** - Anonymous cart with session storage - **Computed fields** - Business logic in model properties and resolvers - **Type enums** - Exposing Django TextChoices to GraphQL ## Setup 1. **Install dependencies:** ```shell poetry install ``` 2. **Run migrations:** ```shell poetry run python manage.py migrate ``` 3. **Populate sample data:** ```shell poetry run python manage.py populate_db ``` This creates: - Admin user (username: `admin`, password: `admin`) - Test user (username: `testuser`, password: `test123`) - Sample brands and products 4. **Run the server:** ```shell poetry run python manage.py runserver ``` 5. **Open GraphiQL:** Navigate to http://localhost:8000/graphql/ ## Example Queries ### Login ```graphql mutation { login(username: "testuser", password: "test123") { id name emails { email isPrimary } } } ``` ### Query Products with Filtering and Ordering ```graphql query { products( filters: { name: { iContains: "pro" } } order: { name: ASC } pagination: { limit: 10 } ) { id name brand { name } price formattedPrice kind } } ``` ### Advanced Filtering - Nested Filters ```graphql query { products( filters: { brand: { name: { iContains: "apple" } } kind: { exact: PHYSICAL } } pagination: { limit: 5 } ) { id name brand { name } kind } } ``` ### Products with Relay Connection ```graphql query { productsConn(first: 10) { edges { node { id name price } } pageInfo { hasNextPage endCursor } } } ``` ### Add to Cart ```graphql mutation { cartAddItem( product: { id: "UHJvZHVjdFR5cGU6MQ==" } quantity: 2 ) { id product { name price } quantity total } } ``` ### View Cart ```graphql query { myCart { items { product { name } quantity total } total } } ``` ### Checkout Cart Must be logged in to checkout. ```graphql mutation { cartCheckout { id user { name } items { product { name } quantity price total } total } } ``` ### View My Orders ```graphql query { myOrders(first: 10) { edges { node { id total items { product { name } quantity price } } } } } ``` ## Project Structure ``` app/ ├── base/ # Shared types and utilities │ ├── types.py # Context and Info type definitions │ └── dataloaders.py # DataLoader implementations ├── user/ # User management app │ ├── models.py # User and Email models │ ├── types.py # GraphQL types and filters │ └── schema.py # Queries and mutations ├── product/ # Product catalog app │ ├── models.py # Product, Brand, ProductImage models │ ├── types.py # GraphQL types and filters │ └── schema.py # Queries and mutations ├── order/ # Shopping and orders app │ ├── models.py # Cart, CartItem, Order, OrderItem models │ ├── types.py # GraphQL types │ └── schema.py # Queries and mutations ├── management/ │ └── commands/ │ └── populate_db.py # Sample data generator ├── schema.py # Root schema merging all apps ├── views.py # Custom async GraphQL view ├── urls.py # URL configuration └── settings.py # Django settings ``` ## Key Patterns & Best Practices ### 1. Modular Schema Organization Each Django app has its own GraphQL schema that gets merged into the root schema: ```python # app/schema.py from strawberry.tools import merge_types Query = merge_types("Query", (UserQuery, ProductQuery, OrderQuery)) Mutation = merge_types("Mutation", (UserMutation, ProductMutation, OrderMutation)) schema = strawberry.Schema(query=Query, mutation=Mutation) ``` ### 2. Custom Context with Type Safety Define a custom context class for type-safe access to request data: ```python # app/base/types.py @dataclasses.dataclass class Context(StrawberryDjangoContext): dataloaders: DataLoaders def get_user(self, *, required: Literal[True] | None = None) -> User | None: # Implementation with proper typing ... Info = info.Info["Context", None] ``` Usage in resolvers: ```python @strawberry_django.field async def my_field(self, info: Info, brand_id: int) -> SomeType: user = info.context.get_user(required=True) # Type-safe! # Use dataloaders to efficiently batch queries brand = await info.context.dataloaders.brand_loader.load(brand_id) ``` ### 3. Permission Extensions Use permission extensions instead of manual checks in resolvers: ```python # Only authenticated users @strawberry_django.connection(extensions=[IsAuthenticated()]) def my_orders(self, info: Info) -> Iterable[Order]: ... # Only staff users orders_conn: DjangoListConnection[OrderType] = strawberry_django.connection( extensions=[IsStaff()] ) ``` ### 4. Query Optimization with @model_property Use `@model_property` for computed fields to enable query optimization: ```python from strawberry_django.descriptors import model_property class OrderItem(models.Model): quantity = models.PositiveIntegerField() price = models.DecimalField(max_digits=24, decimal_places=2) @model_property(only=["quantity", "price"]) def total(self) -> decimal.Decimal: return self.quantity * self.price ``` This tells the optimizer exactly which fields to fetch, preventing unnecessary database queries. ### 5. Modern Filter and Order Types Use the current Strawberry Django APIs for filters and ordering: ```python @strawberry_django.filter_type(User) class UserFilter: username: auto email: auto @strawberry_django.order_type(User) class UserOrder: username: auto email: auto ``` Apply them to fields: ```python users: list[UserType] = strawberry_django.field( filters=UserFilter, order=UserOrder, pagination=True, ) ``` ### 6. Multiple Query Patterns Provide different ways to query data based on use case: ```python # Single node by ID product: ProductType = strawberry_django.node() # Paginated list with offset pagination products: list[ProductType] = strawberry_django.field(pagination=True) # Relay connection with cursor pagination products_conn: DjangoListConnection[ProductType] = strawberry_django.connection() ``` ### 7. Mutation Error Handling Use `handle_django_errors=True` to automatically handle Django validation errors: ```python @strawberry_django.mutation(handle_django_errors=True) @transaction.atomic def cart_add_item( self, info: Info, product: strawberry_django.NodeInput, quantity: int = 1, ) -> CartItemType: if quantity <= 0: raise ValidationError({"quantity": _("Quantity must be at least 1")}) # ... ``` ### 8. Using Relay Node Interface Implement the Node interface for global object identification: ```python @strawberry_django.type(Product) class ProductType(relay.Node): # Fields are automatically exposed pass ``` This enables queries like: ```graphql query { product(id: "UHJvZHVjdFR5cGU6MQ==") { name price } } ``` ### 9. Understanding the Query Optimizer The DjangoOptimizerExtension automatically optimizes queries based on your GraphQL selection: ```graphql # This query query { products(pagination: { limit: 10 }) { name brand { name } formattedPrice } } # Automatically generates optimal SQL with: # - SELECT only needed fields (name, price for formattedPrice, brand_id) # - JOIN brand table (select_related) # - No N+1 queries ``` The optimizer works because: - `@model_property` decorators specify field dependencies - `@strawberry_django.field` with `only`, `select_related`, `prefetch_related` - Automatic analysis of field requirements ## Testing Example tests are provided in the `tests/` directory showing how to test Strawberry Django applications: ```bash # Run tests with pytest poetry run pytest tests/ # Run with coverage poetry run pytest --cov=app tests/ ``` The tests demonstrate: - Testing queries and mutations - Handling authentication in tests - Using fixtures for test data - Testing with GraphQL test client ## Admin Interface Access the Django admin at http://localhost:8000/admin/ Default credentials: - **Username:** admin - **Password:** admin ## Debug Toolbar When running in debug mode, the Django Debug Toolbar is available to inspect queries and performance. Look for the toolbar on the right side when accessing the GraphQL endpoint through a browser. ## Learning Path If you're new to Strawberry Django, we recommend exploring the example in this order: 1. **Start with models** (`app/user/models.py`, `app/product/models.py`) - See how Django models are defined with type hints and `@model_property` 2. **Explore types** (`app/user/types.py`, `app/product/types.py`) - Learn how models are exposed as GraphQL types 3. **Study schemas** (`app/user/schema.py`, `app/order/schema.py`) - Understand queries, mutations, and permissions 4. **Check context** (`app/base/types.py`, `app/base/dataloaders.py`) - See how context and dataloaders work 5. **Review root schema** (`app/schema.py`) - Learn how to merge schemas from multiple apps 6. **Try queries** - Use GraphiQL to experiment with the example queries in this README 7. **Read tests** (`tests/`) - See how to test your GraphQL API ## Common Patterns Reference ### Adding a New Model 1. Create the Django model with type hints 2. Add `@model_property` for computed fields 3. Create a GraphQL type with `@strawberry_django.type` 4. Add queries/mutations in schema.py 5. Merge into root schema ### Adding Permissions ```python # Query-level permission @strawberry_django.field(extensions=[IsAuthenticated()]) def my_data(self, info: Info) -> list[MyType]: ... # Field-level permission @strawberry_django.field(extensions=[IsStaff()]) def sensitive_field(self, root: MyModel) -> str: return root.secret_data ``` ### Optimizing Queries ```python # For model properties @model_property( only=["field1", "field2"], # SELECT these fields select_related=["fk_relation"], # JOIN these FKs prefetch_related=["m2m_relation"], # Prefetch these M2M/reverse FKs ) def computed_value(self) -> int: ... # For custom resolvers @strawberry_django.field( only=["name"], select_related=["brand"], ) def custom_resolver(self, root: Product) -> str: return f"{root.brand.name}: {root.name}" ``` ## Common Pitfalls and Solutions ### ❌ Forgetting to specify optimization hints ```python # BAD - Will cause N+1 queries @model_property def full_name(self) -> str: return f"{self.first_name} {self.last_name}" # Deferred attribute error! ``` ```python # GOOD - Specifies required fields @model_property(only=["first_name", "last_name"]) def full_name(self) -> str: return f"{self.first_name} {self.last_name}" ``` ### ❌ Not using transactions for multi-step operations ```python # BAD - No atomicity def checkout(self, user): order = Order.objects.create(user=user, cart=self) for item in self.items.all(): order.items.create(...) # If this fails, order still exists! ``` ```python # GOOD - Atomic operation @transaction.atomic def checkout(self, user): order = Order.objects.create(user=user, cart=self) for item in self.items.all(): order.items.create(...) # All-or-nothing ``` ### ❌ Capturing variables by reference in lambdas ```python # BAD - cart.pk might change before callback runs transaction.on_commit(lambda: info.context.request.session.update({"cart_pk": cart.pk})) ``` ```python # GOOD - Capture by value with default argument transaction.on_commit( lambda pk=cart.pk: info.context.request.session.update({"cart_pk": pk}) ) ``` ### ❌ Not handling async contexts properly ```python # BAD - Sync code in async resolver async def my_resolver(self, info: Info): user = info.context.request.user # Might cause SynchronousOnlyOperation ``` ```python # GOOD - Use async helpers async def my_resolver(self, info: Info): user = await info.context.aget_user() # Properly wrapped ``` ## Additional Resources - [Strawberry Django Documentation](https://strawberry.rocks/docs/django) - [Strawberry GraphQL Documentation](https://strawberry.rocks/) - [Django Documentation](https://docs.djangoproject.com/) - [GraphQL Specification](https://spec.graphql.org/) ## Need Help? - Check the [Strawberry Django FAQ](https://strawberry.rocks/docs/django/faq) - Visit the [GitHub Discussions](https://github.com/strawberry-graphql/strawberry-django/discussions) - Join the [Discord Community](https://discord.gg/ZkRTEJQ) strawberry-graphql-django-0.82.1/examples/ecommerce_app/__init__.py000066400000000000000000000000001516173410200254160ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/000077500000000000000000000000001516173410200240775ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/__init__.py000066400000000000000000000000001516173410200261760ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/apps.py000066400000000000000000000001711516173410200254130ustar00rootroot00000000000000from django.apps import AppConfig class ExampleAppConfig(AppConfig): name = "app" verbose_name = "Example App" strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/base/000077500000000000000000000000001516173410200250115ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/base/__init__.py000066400000000000000000000000001516173410200271100ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/base/dataloaders.py000066400000000000000000000026121516173410200276470ustar00rootroot00000000000000from __future__ import annotations import dataclasses from app.product.models import Brand from app.user.models import User from asgiref.sync import sync_to_async from strawberry.dataloader import DataLoader async def load_brands(keys: list[int]) -> list[Brand | None]: """Batch load brands by their IDs.""" brands = await sync_to_async(list)(Brand.objects.filter(id__in=keys)) # Return results in the same order as keys brand_map = {brand.id: brand for brand in brands} return [brand_map.get(key) for key in keys] async def load_users(keys: list[int]) -> list[User | None]: """Batch load users by their IDs.""" users = await sync_to_async(list)(User.objects.filter(id__in=keys)) # Return results in the same order as keys user_map = {user.id: user for user in users} return [user_map.get(key) for key in keys] @dataclasses.dataclass class DataLoaders: """Container for all dataloaders in the application. DataLoaders help solve the N+1 query problem by batching and caching database queries. Each loader is instantiated once per request and shared across all resolvers. """ brand_loader: DataLoader[int, Brand | None] = dataclasses.field( default_factory=lambda: DataLoader(load_fn=load_brands) ) user_loader: DataLoader[int, User | None] = dataclasses.field( default_factory=lambda: DataLoader(load_fn=load_users) ) strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/base/types.py000066400000000000000000000064201516173410200265310ustar00rootroot00000000000000"""Custom GraphQL context and type definitions. Provides type-safe context access and utilities for resolvers. """ from __future__ import annotations import dataclasses from typing import TYPE_CHECKING, Literal, TypeAlias, cast, overload from asgiref.sync import sync_to_async from django.core.exceptions import PermissionDenied from django.utils.translation import gettext_lazy as _ from strawberry.django.context import StrawberryDjangoContext from strawberry.types import info if TYPE_CHECKING: from app.user.models import User from .dataloaders import DataLoaders # Type alias for GraphQL info with our custom Context. # Provides type safety and IDE autocompletion for context access. Info: TypeAlias = info.Info["Context", None] @dataclasses.dataclass class Context(StrawberryDjangoContext): """Custom GraphQL context with type-safe user access and dataloaders. Extends StrawberryDjangoContext to add: - dataloaders: Container for all DataLoader instances - get_user(): Type-safe user retrieval with authentication checks - aget_user(): Async version of get_user() Example usage: @strawberry_django.field def my_resolver(self, info: Info) -> SomeType: user = info.context.get_user(required=True) brand = await info.context.dataloaders.brand_loader.load(brand_id) """ dataloaders: DataLoaders @overload def get_user(self, *, required: Literal[True]) -> User: ... @overload def get_user(self, *, required: None = ...) -> User | None: ... def get_user(self, *, required: Literal[True] | None = None) -> User | None: """Get the authenticated user from the request. Args: required: If True, raises PermissionDenied when user is not authenticated. If None/False, returns None for unauthenticated requests. Returns: User instance if authenticated, None otherwise (unless required=True) Raises: PermissionDenied: If required=True and user is not authenticated/active The overload signatures ensure type safety: - get_user(required=True) -> User (never None) - get_user() -> User | None """ user = self.request.user if not user or not user.is_authenticated or not user.is_active: if required: raise PermissionDenied(_("No user logged in")) return None return cast("User", user) @overload async def aget_user(self, *, required: Literal[True]) -> User: ... @overload async def aget_user(self, *, required: None = ...) -> User | None: ... async def aget_user(self, *, required: Literal[True] | None = None) -> User | None: """Async version of get_user(). Use this in async resolvers to avoid SynchronousOnlyOperation errors. Example: @strawberry_django.field async def my_async_resolver(self, info: Info) -> UserType: user = await info.context.aget_user(required=True) return cast("UserType", user) """ # Wrap the sync method properly to handle the overload signature if required: return await sync_to_async(lambda: self.get_user(required=True))() return await sync_to_async(self.get_user)() strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/management/000077500000000000000000000000001516173410200262135ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/management/__init__.py000066400000000000000000000000001516173410200303120ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/management/commands/000077500000000000000000000000001516173410200300145ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/management/commands/__init__.py000066400000000000000000000000001516173410200321130ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/management/commands/populate_db.py000066400000000000000000000110611516173410200326630ustar00rootroot00000000000000import sys from datetime import datetime, timedelta, timezone from app.product.models import Brand, Product from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand from django.db import transaction User = get_user_model() class Command(BaseCommand): help = "Populate database with sample data" @transaction.atomic def handle(self, *args, **options): # Create superuser if not User.objects.filter(username="admin").exists(): admin = User.objects.create_superuser( username="admin", email="admin@example.com", password="admin", # nosec B106 # gitleaks:allow first_name="Admin", last_name="User", ) sys.stdout.write(f"* Created admin user: {admin.username}\n") # Create test user if not User.objects.filter(username="testuser").exists(): user = User.objects.create_user( username="testuser", email="test@example.com", password="test123", # nosec B106 # gitleaks:allow first_name="Test", last_name="User", birth_date=datetime.now(tz=timezone.utc).date() - timedelta(days=365 * 25), ) sys.stdout.write(f"* Created test user: {user.username}\n") # Create brands brands_data = ["Apple", "Samsung", "Google", "Microsoft", "Sony", "Adobe"] brands = {} for brand_name in brands_data: brand, created = Brand.objects.get_or_create(name=brand_name) brands[brand_name] = brand if created: sys.stdout.write(f"* Created brand: {brand.name}\n") # Create products products_data = [ { "name": "iPhone 15 Pro", "brand": "Apple", "kind": "physical", "description": "Latest iPhone with advanced camera system", "price": 999.99, }, { "name": "MacBook Pro 16", "brand": "Apple", "kind": "physical", "description": "Professional laptop with M3 chip", "price": 2499.99, }, { "name": "Samsung Galaxy S24", "brand": "Samsung", "kind": "physical", "description": "Flagship Android smartphone", "price": 899.99, }, { "name": "Google Pixel 8 Pro", "brand": "Google", "kind": "physical", "description": "Best Android camera phone", "price": 799.99, }, { "name": "Microsoft Surface Pro 9", "brand": "Microsoft", "kind": "physical", "description": "2-in-1 tablet and laptop", "price": 1299.99, }, { "name": "Sony WH-1000XM5", "brand": "Sony", "kind": "physical", "description": "Premium noise-cancelling headphones", "price": 399.99, }, { "name": "Microsoft 365", "brand": "Microsoft", "kind": "virtual", "description": "Productivity suite subscription", "price": 99.99, }, { "name": "Adobe Creative Cloud", "brand": "Adobe", "kind": "virtual", "description": "Complete creative software suite", "price": 54.99, }, ] for product_data in products_data: brand_name = product_data.pop("brand") product, created = Product.objects.get_or_create( name=product_data["name"], defaults={ **product_data, "brand": brands[brand_name], }, ) if created: sys.stdout.write(f"* Created product: {product.name}\n") sys.stdout.write(self.style.SUCCESS("\n✓ Database populated successfully!\n")) sys.stdout.write("\nYou can now login with:\n") sys.stdout.write(" Username: admin\n") sys.stdout.write(" Password: admin\n\n") # gitleaks:allow sys.stdout.write("Or use test user:\n") sys.stdout.write(" Username: testuser\n") sys.stdout.write(" Password: test123\n") # gitleaks:allow strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/migrations/000077500000000000000000000000001516173410200262535ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/migrations/__init__.py000066400000000000000000000000001516173410200303520ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/order/000077500000000000000000000000001516173410200252125ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/order/__init__.py000066400000000000000000000000001516173410200273110ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/order/apps.py000066400000000000000000000002461516173410200265310ustar00rootroot00000000000000from django.apps import AppConfig class OrderConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "app.order" label = "order" strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/order/migrations/000077500000000000000000000000001516173410200273665ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/order/migrations/__init__.py000066400000000000000000000000001516173410200314650ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/order/models.py000066400000000000000000000146711516173410200270600ustar00rootroot00000000000000from __future__ import annotations import decimal from typing import TYPE_CHECKING import strawberry from django.core.validators import MinValueValidator from django.db import models, transaction from django.utils.translation import gettext_lazy as _ from django_choices_field.fields import TextChoicesField from strawberry_django.descriptors import model_property if TYPE_CHECKING: from app.user.models import User from django.db.models.manager import RelatedManager class Order(models.Model): """Completed order created from a checked-out cart. Demonstrates: - One-to-one relationship with Cart - Computed total using @model_property with prefetch_related - Type hints for related managers (items: RelatedManager[OrderItem]) """ class Meta: verbose_name = _("Order") verbose_name_plural = _("Orders") items: RelatedManager[OrderItem] user_id: int user = models.ForeignKey( "user.User", verbose_name=_("Customer"), on_delete=models.RESTRICT, related_name="+", db_index=True, ) cart_id: int cart = models.OneToOneField( "Cart", verbose_name=_("Cart"), on_delete=models.RESTRICT, related_name="+", db_index=True, ) @model_property(prefetch_related=["items"]) def total(self) -> decimal.Decimal: return sum((item.total for item in self.items.all()), start=decimal.Decimal(0)) class OrderItem(models.Model): """Individual product within an order with quantity and price snapshot. Demonstrates: - Composite unique constraint (order + product) - Price snapshot (stores price at time of purchase) - Computed field (total) with optimization hints """ class Meta: verbose_name = _("Item") verbose_name_plural = _("Items") unique_together = [ # noqa: RUF012 ("order", "product"), ] order_id: int order = models.ForeignKey( Order, verbose_name=_("Order"), on_delete=models.CASCADE, related_name="items", db_index=True, ) product_id: int product = models.ForeignKey( "product.Product", verbose_name=_("Product"), on_delete=models.RESTRICT, related_name="+", db_index=True, ) quantity = models.PositiveIntegerField( verbose_name=_("Quantity"), default=1, blank=True, validators=[MinValueValidator(1)], ) price = models.DecimalField( verbose_name=_("Price"), max_digits=24, decimal_places=2, ) @model_property(only=["quantity", "price"]) def total(self) -> decimal.Decimal: """Calculate line item total (quantity x price). The only parameter tells the optimizer to fetch quantity and price when this property is accessed, preventing deferred attribute errors. """ return self.quantity * self.price class Cart(models.Model): """Shopping cart for collecting items before checkout. Demonstrates: - Session-based cart (stored in session, not tied to user) - Status enum with TextChoices - Business logic method (checkout) with transaction handling """ class Meta: verbose_name = _("Cart") verbose_name_plural = _("Carts") @strawberry.enum(name="CartStatus") class Status(models.TextChoices): PENDING = "pending", _("Pending") FINISHED = "finished", _("Finished") items: RelatedManager[CartItem] status = TextChoicesField( verbose_name=_("Status"), choices_enum=Status, default=Status.PENDING, ) @model_property(prefetch_related=["items"]) def total(self) -> decimal.Decimal: return sum((item.total for item in self.items.all()), start=decimal.Decimal(0)) @transaction.atomic def checkout(self, user: User) -> Order: """Convert cart to an order and snapshot product prices. Creates an Order and OrderItems from the cart, snapshotting current prices at checkout time. Marks the cart as finished. Args: user: The user placing the order Returns: The created Order instance Note: This method is wrapped in @transaction.atomic to ensure atomicity - either all operations succeed or none do. """ order = Order.objects.create(user=user, cart=self) for item in self.items.all(): order.items.create( product=item.product, quantity=item.quantity, price=item.product.price, ) self.status = self.Status.FINISHED self.save() return order class CartItem(models.Model): """Individual product within a cart with quantity. Demonstrates: - Composite unique constraint (cart + product) - Computed properties using related data (price from product) - select_related optimization for foreign key access """ class Meta: verbose_name = _("Item") verbose_name_plural = _("Items") unique_together = [ # noqa: RUF012 ("cart", "product"), ] cart_id: int cart = models.ForeignKey( Cart, verbose_name=_("Cart"), on_delete=models.CASCADE, related_name="items", db_index=True, ) product_id: int product = models.ForeignKey( "product.Product", verbose_name=_("Product"), on_delete=models.RESTRICT, related_name="+", db_index=True, ) quantity = models.PositiveIntegerField( verbose_name=_("Quantity"), default=1, blank=True, validators=[MinValueValidator(1)], ) @model_property(only=["product__price"], select_related=["product"]) def price(self) -> decimal.Decimal: """Get the current price from the related product. The only parameter with __ notation (product__price) tells the optimizer to fetch the price field from the related product model. select_related ensures the product is loaded efficiently with a JOIN. """ return self.product.price @model_property(only=["quantity", "product__price"], select_related=["product"]) def total(self) -> decimal.Decimal: """Calculate line item total (quantity x current price). Demonstrates accessing related model fields through __notation and combining multiple optimization parameters. """ return self.quantity * self.price strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/order/schema.py000066400000000000000000000157301516173410200270320ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, cast if TYPE_CHECKING: from collections.abc import Iterable import strawberry from app.base.types import Info from app.product.models import Product from django.core.exceptions import PermissionDenied, ValidationError from django.db import transaction from django.utils.translation import gettext_lazy as _ from strawberry import relay import strawberry_django from strawberry_django.optimizer import optimize from strawberry_django.permissions import IsAuthenticated, IsStaff from .models import Cart, CartItem, Order from .types import CartItemType, CartType, OrderType def get_current_cart(info: Info) -> Cart | None: """Get the current cart from session, handling stale/invalid cart_pk. This helper demonstrates: - Session-based cart storage (allows anonymous users to shop) - Graceful handling of stale/deleted carts with first() instead of get() - Query optimization with the optimizer's optimize() function Args: info: The GraphQL resolve info containing request context Returns: The current pending cart or None if no valid cart exists """ cart_pk = info.context.request.session.get("cart_pk") if cart_pk is None: return None # Use first() instead of get() to handle stale/deleted carts gracefully return ( optimize(Cart.objects.all(), info) .filter( pk=cart_pk, status=Cart.Status.PENDING, ) .first() ) @strawberry.type class Query: """Order and cart-related queries.""" orders_conn: strawberry_django.relay.DjangoListConnection[OrderType] = ( strawberry_django.connection(extensions=[IsStaff()]) ) """List all orders (staff only). Demonstrates permission extensions.""" @strawberry_django.connection( strawberry_django.relay.DjangoListConnection[OrderType], extensions=[IsAuthenticated()], ) def my_orders(self, info: Info) -> Iterable[Order]: """Get the current user's orders. Demonstrates: - Authentication requirement with IsAuthenticated() extension - Filtering queryset based on current user - Relay connection pattern """ user = info.context.get_user(required=True) return Order.objects.filter(user=user) @strawberry_django.field def my_cart(self, info: Info) -> CartType | None: """Get the current session's shopping cart. Works for both authenticated and anonymous users via session storage. Returns null if no cart exists. """ cart = get_current_cart(info) return cast("CartType | None", cart) @strawberry.type class Mutation: """Cart and order-related mutations.""" @strawberry_django.mutation(handle_django_errors=True) @transaction.atomic def cart_add_item( self, info: Info, product: strawberry_django.NodeInput, quantity: int = 1, ) -> CartItemType: """Add a product to the cart or increment its quantity. Demonstrates: - NodeInput for accepting global IDs - Automatic error handling with handle_django_errors - Transaction safety with @transaction.atomic - Session updates using transaction.on_commit - Get-or-create pattern for cart items Args: product: Global ID of the product to add quantity: Number of items to add (default: 1) Returns: The created or updated cart item Raises: ValidationError: If quantity is less than 1 """ if quantity <= 0: raise ValidationError({ "quantity": _("Quantity needs to be equal or greater than 1") }) cart = get_current_cart(info) if cart is None: cart = Cart.objects.create() # Use on_commit to ensure cart_pk is saved before updating session transaction.on_commit( lambda pk=cart.pk: info.context.request.session.update({"cart_pk": pk}) ) product_obj = product.id.resolve_node_sync(info, ensure_type=Product) try: cart_item = cart.items.get(product=product_obj) except CartItem.DoesNotExist: cart_item = cart.items.create( product=product_obj, quantity=quantity, ) else: cart_item.quantity += quantity cart_item.save() return cast("CartItemType", cart_item) @strawberry_django.mutation(handle_django_errors=True) @transaction.atomic def cart_update_item( self, info: Info, item: relay.GlobalID, quantity: int, ) -> CartItemType: if quantity <= 0: raise ValidationError({ "quantity": _("Quantity needs to be equal or greater than 1") }) cart_item = item.resolve_node_sync(info, ensure_type=CartItem) cart = get_current_cart(info) if cart is None or cart_item.cart != cart: raise PermissionDenied(_("You are not authorized to change this cart item")) cart_item.quantity = quantity cart_item.save() return cast("CartItemType", cart_item) @strawberry_django.mutation(handle_django_errors=True) @transaction.atomic def cart_remove_item( self, info: Info, item: relay.GlobalID, ) -> CartItemType: cart_item = item.resolve_node_sync(info, ensure_type=CartItem) cart = get_current_cart(info) if cart is None or cart_item.cart != cart: raise PermissionDenied(_("You are not authorized to change this cart item")) # Save pk to return the removed object after as django will set it to None pk = cart_item.pk cart_item.delete() cart_item.pk = pk return cast("CartItemType", cart_item) @strawberry_django.mutation(handle_django_errors=True) @transaction.atomic def cart_checkout(self, info: Info) -> OrderType: """Convert the current cart into an order. Demonstrates: - Authentication requirement (get_user with required=True) - Business logic encapsulation (cart.checkout method) - Session cleanup with transaction.on_commit - Validation before processing Returns: The created order Raises: PermissionDenied: If user is not authenticated ValidationError: If cart is empty or doesn't exist """ user = info.context.get_user(required=True) cart = get_current_cart(info) if cart is None: raise ValidationError(_("You don't have a cart to checkout")) if not cart.items.exists(): raise ValidationError(_("Can't checkout an empty cart")) order = cart.checkout(user) # Clear cart from session after successful checkout transaction.on_commit(lambda: info.context.request.session.pop("cart_pk", None)) return cast("OrderType", order) strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/order/types.py000066400000000000000000000024541516173410200267350ustar00rootroot00000000000000from __future__ import annotations from app.product.types import ProductType from app.user.types import UserType from strawberry import auto, relay import strawberry_django from .models import Cart, CartItem, Order, OrderItem @strawberry_django.type(Order, name="Order") class OrderType(relay.Node): """GraphQL type for completed orders. Demonstrates computed total field from model_property. """ user: UserType total: auto items: list[OrderItemType] @strawberry_django.type(OrderItem, name="OrderItem") class OrderItemType(relay.Node): """GraphQL type for order line items. Shows price snapshot at time of purchase and computed total. """ product: ProductType quantity: auto price: auto total: auto @strawberry_django.type(Cart, name="Cart") class CartType(relay.Node): """GraphQL type for shopping carts. Demonstrates session-based state management and computed totals. """ total: auto items: list[CartItemType] @strawberry_django.type(CartItem, name="CartItem") class CartItemType(relay.Node): """GraphQL type for cart line items. Shows computed price and total fields using model_property with related field access (product__price). """ product: ProductType quantity: auto price: auto total: auto strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/product/000077500000000000000000000000001516173410200255575ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/product/__init__.py000066400000000000000000000000001516173410200276560ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/product/apps.py000066400000000000000000000002541516173410200270750ustar00rootroot00000000000000from django.apps import AppConfig class ProductConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "app.product" label = "product" strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/product/migrations/000077500000000000000000000000001516173410200277335ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/product/migrations/__init__.py000066400000000000000000000000001516173410200320320ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/product/models.py000066400000000000000000000053261516173410200274220ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import strawberry from django.db import models from django.utils.translation import gettext_lazy as _ from django_choices_field.fields import TextChoicesField if TYPE_CHECKING: from django.db.models.manager import RelatedManager class Brand(models.Model): """Product brand/manufacturer model. Represents companies or manufacturers of products (e.g., Apple, Samsung). Demonstrates a simple model with a string representation. """ class Meta: verbose_name = _("Brand") verbose_name_plural = _("Brands") name = models.CharField( verbose_name=_("Name"), max_length=255, ) def __str__(self) -> str: return self.name class Product(models.Model): """Product model representing items available for purchase. Demonstrates: - TextChoices enum exposed to GraphQL via @strawberry.enum - ForeignKey relationship with optional brand - Type hints for foreign key IDs (brand_id: int | None) - Using django-choices-field for better enum handling """ class Meta: verbose_name = _("Product") verbose_name_plural = _("Products") @strawberry.enum(name="ProductKind") class Kind(models.TextChoices): PHYSICAL = "physical", _("Physical") VIRTUAL = "virtual", _("Virtual") images: RelatedManager[ProductImage] name = models.CharField( verbose_name=_("Name"), max_length=255, ) kind = TextChoicesField( verbose_name=_("Kind"), choices_enum=Kind, default=Kind.PHYSICAL, ) brand_id: int | None brand = models.ForeignKey( Brand, verbose_name=_("Brand"), on_delete=models.SET_NULL, related_name="products", db_index=True, null=True, blank=True, default=None, ) description = models.TextField( verbose_name=_("Description"), default="", ) price = models.DecimalField( verbose_name=_("Price"), max_digits=24, decimal_places=2, ) def __str__(self) -> str: return self.name class ProductImage(models.Model): """Product images for display in the catalog. Demonstrates handling of image fields and related_name usage for reverse relationships (product.images). """ class Meta: verbose_name = _("Image") verbose_name_plural = _("Images") product_id: int product = models.ForeignKey( Product, verbose_name=_("Product"), on_delete=models.CASCADE, related_name="images", db_index=True, ) image = models.ImageField( verbose_name=_("Image"), max_length=2000, ) strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/product/schema.py000066400000000000000000000016421516173410200273740ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import strawberry import strawberry_django if TYPE_CHECKING: from .types import ProductType @strawberry.type class Query: """Product-related queries demonstrating different query patterns.""" product: ProductType = strawberry_django.node( description="Fetch a single product by its global ID (Relay Node pattern)." ) products: list[ProductType] = strawberry_django.field( pagination=True, description="List products with offset-based pagination, filtering, and ordering.", ) products_conn: strawberry_django.relay.DjangoListConnection[ProductType] = ( strawberry_django.connection( description="List products with cursor-based Relay connection pagination." ) ) @strawberry.type class Mutation: """Product-related mutations (placeholder for future extensions).""" strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/product/types.py000066400000000000000000000046761516173410200273120ustar00rootroot00000000000000from __future__ import annotations from strawberry import UNSET, auto, relay import strawberry_django from .models import Brand, Product, ProductImage @strawberry_django.filter_type(Brand, lookups=True) class BrandFilter: """Filter type for Brand queries with lookup support.""" name: auto @strawberry_django.type( Brand, name="Brand", filters=BrandFilter, ) class BrandType(relay.Node): """GraphQL type for Brand model. Demonstrates reverse relationship (products) from ForeignKey. """ name: auto products: list[ProductType] @strawberry_django.filter_type(Product, lookups=True) class ProductFilter: """Filter type for Product queries. Demonstrates nested filtering with the brand field, allowing queries like: filters: { brand: { name: { iContains: "apple" } } } """ name: auto kind: auto brand: BrandFilter | None = UNSET @strawberry_django.order_type(Product) class ProductOrdering: """Ordering type for Product queries.""" name: auto kind: auto @strawberry_django.type( Product, name="Product", filters=ProductFilter, order=ProductOrdering, ) class ProductType(relay.Node): """GraphQL type for Product model. Demonstrates: - Relay Node interface - Optional foreign key relationship (brand) - Enum field (kind) - Related objects list (images) - Custom computed fields """ name: auto brand: BrandType | None kind: auto description: auto price: auto images: list[ProductImageType] @strawberry_django.field(only=["price"]) def formatted_price(self, root: Product) -> str: """Return price formatted as currency string. Demonstrates a simple computed field for display formatting. """ return f"${root.price:.2f}" @strawberry_django.type(ProductImage, name="ProductImage") class ProductImageType(relay.Node): """GraphQL type for ProductImage model.""" @strawberry_django.field(only=["image"]) def image(self, root: ProductImage) -> str | None: """Return the image URL if available.""" return root.image.url if root.image else None @strawberry_django.input(Product) class ProductInput: """Input type for creating products.""" name: auto brand: relay.GlobalID | None = None kind: auto description: auto price: auto @strawberry_django.input(Brand) class BrandInput: """Input type for creating brands.""" name: auto strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/schema.py000066400000000000000000000037451516173410200257220ustar00rootroot00000000000000"""Root GraphQL schema merging all app-specific schemas. This module demonstrates the modular schema pattern where each Django app defines its own queries and mutations, which are then merged into a single root schema using strawberry.tools.merge_types. Benefits of this approach: - Clear separation of concerns - Easy to add/remove features - Schemas stay focused and maintainable - Each app is self-contained """ import strawberry from app.order.schema import Mutation as OrderMutation from app.order.schema import Query as OrderQuery from app.product.schema import Mutation as ProductMutation from app.product.schema import Query as ProductQuery from app.user.schema import Mutation as UserMutation from app.user.schema import Query as UserQuery from strawberry.tools import merge_types from strawberry_django.optimizer import DjangoOptimizerExtension # Root Query type combining all app-specific queries. # # Available queries: # - User queries: user, users, me # - Product queries: product, products, productsConn # - Order queries: ordersConn, myOrders, myCart Query = merge_types( "Query", ( OrderQuery, ProductQuery, UserQuery, ), ) # Root Mutation type combining all app-specific mutations. # # Available mutations: # - User mutations: login, logout # - Cart mutations: cartAddItem, cartUpdateItem, cartRemoveItem, cartCheckout Mutation = merge_types( "Mutation", ( OrderMutation, ProductMutation, UserMutation, ), ) # Main GraphQL schema with DjangoOptimizerExtension. # # The DjangoOptimizerExtension automatically optimizes database queries by: # - Analyzing the GraphQL query and selecting only needed fields # - Using select_related() for foreign keys # - Using prefetch_related() for many-to-many and reverse foreign keys # - Respecting optimization hints from @model_property and field decorators schema = strawberry.Schema( query=Query, mutation=Mutation, extensions=[ DjangoOptimizerExtension, ], ) strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/settings.py000066400000000000000000000070071516173410200263150ustar00rootroot00000000000000"""Django settings for example project.""" import pathlib from django.db import models from django.db.models.manager import BaseManager from django.db.models.query import QuerySet, RawQuerySet # Enable __class_getitem__ for better type hints with Django models for cls in [ QuerySet, RawQuerySet, BaseManager, models.ForeignKey, models.JSONField, models.ManyToManyField, ]: cls.__class_getitem__ = classmethod(lambda cls, *args, **kwargs: cls) # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = pathlib.Path(__file__).resolve().parent.parent # Quick-start development settings - unsuitable for production # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = "django-insecure-example-key-change-in-production" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True ALLOWED_HOSTS = [] INTERNAL_IPS = ["127.0.0.1"] # Application definition INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "strawberry_django", "django_extensions", "debug_toolbar", "app", "app.user", "app.product", "app.order", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "strawberry_django.middlewares.debug_toolbar.DebugToolbarMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] ROOT_URLCONF = "app.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ], }, }, ] WSGI_APPLICATION = "app.wsgi.application" # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3", }, } # Password validation # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] # Internationalization # https://docs.djangoproject.com/en/5.0/topics/i18n/ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True USE_TZ = True AUTH_USER_MODEL = "user.User" # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.0/howto/static-files/ STATIC_URL = "static/" MEDIA_ROOT = "media/" MEDIA_URL = "/media/" # Default primary key field type # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/urls.py000066400000000000000000000011641516173410200254400ustar00rootroot00000000000000"""URL configuration for example project.""" from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path from . import settings from .schema import schema from .views import GraphQLView urlpatterns = [ path("admin/", admin.site.urls), path( "graphql/", GraphQLView.as_view( graphql_ide="graphiql" if settings.DEBUG else None, schema=schema, ), ), *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT), ] if settings.DEBUG: urlpatterns.append(path("__debug__/", include("debug_toolbar.urls"))) strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/user/000077500000000000000000000000001516173410200250555ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/user/__init__.py000066400000000000000000000000001516173410200271540ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/user/apps.py000066400000000000000000000002431516173410200263710ustar00rootroot00000000000000from django.apps import AppConfig class UserConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "app.user" label = "user" strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/user/migrations/000077500000000000000000000000001516173410200272315ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/user/migrations/__init__.py000066400000000000000000000000001516173410200313300ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/user/models.py000066400000000000000000000041461516173410200267170ustar00rootroot00000000000000from __future__ import annotations from datetime import timedelta from django.contrib.auth.models import AbstractUser from django.db import models from django.db.models import ImageField from django.utils import timezone from django.utils.translation import gettext_lazy as _ from strawberry_django.descriptors import model_property class User(AbstractUser): """User model extending Django's AbstractUser. This model demonstrates: - Custom fields (avatar, birth_date) - Computed properties with @model_property - Type hints for better IDE support """ class Meta: verbose_name = _("User") verbose_name_plural = _("Users") avatar = ImageField( verbose_name=_("Avatar"), max_length=2000, default=None, blank=True, null=True, ) birth_date = models.DateField( verbose_name=_("Birth Date"), blank=True, null=True, ) @model_property(only=["birth_date"]) def age(self) -> int | None: """Calculate user's age from birth_date. The @model_property decorator with only=["birth_date"] tells the optimizer to fetch birth_date when this property is requested, preventing N+1 queries. """ if self.birth_date is None: return None days = timezone.now().date() - self.birth_date return days // timedelta(days=365) class Email(models.Model): """Email addresses associated with users. Demonstrates a one-to-many relationship where users can have multiple email addresses, with one marked as primary for communication. """ class Meta: verbose_name = _("Email") verbose_name_plural = _("Emails") user = models.ForeignKey( User, verbose_name=_("User"), related_name="emails", on_delete=models.CASCADE, ) email = models.EmailField( verbose_name=_("Email"), unique=True, blank=False, null=False, ) is_primary = models.BooleanField( verbose_name=_("Is Primary"), default=False, blank=False, null=False, ) strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/user/schema.py000066400000000000000000000044471516173410200267000ustar00rootroot00000000000000from __future__ import annotations from typing import cast import strawberry from app.base.types import Info from django.contrib import auth from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ import strawberry_django from .types import UserFilter, UserOrder, UserType @strawberry.type class Query: """User-related queries.""" user: UserType = strawberry_django.field() users: list[UserType] = strawberry_django.field( filters=UserFilter, order=UserOrder, pagination=True, ) @strawberry_django.field async def me(self, info: Info) -> UserType | None: """Get the current logged-in user, or null if not authenticated. This query demonstrates async resolvers and context usage for retrieving the current user from the request. """ return cast("UserType", await info.context.aget_user()) @strawberry.type class Mutation: """User-related mutations for authentication.""" @strawberry_django.mutation(handle_django_errors=True) def login( self, info: Info, username: str, password: str, ) -> UserType: """Authenticate a user and create a session. Args: username: The user's username password: The user's password Returns: The authenticated user Raises: ValidationError: If credentials are invalid The handle_django_errors=True parameter automatically converts Django ValidationErrors into GraphQL errors with proper formatting. """ request = info.context.request user = auth.authenticate(request, username=username, password=password) if user is None: raise ValidationError(_("Wrong credentials provided.")) auth.login(request, user) return cast("UserType", user) @strawberry_django.mutation def logout( self, info: Info, ) -> bool: """Log out the current user and destroy their session. Returns: True if a user was logged out, False if no user was logged in """ user = info.context.get_user() ret = user.is_authenticated if user else False auth.logout(info.context.request) return ret strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/user/types.py000066400000000000000000000041611516173410200265750ustar00rootroot00000000000000from __future__ import annotations import strawberry from strawberry import relay import strawberry_django from .models import Email, User @strawberry_django.type(Email, name="Email") class EmailType(relay.Node): """GraphQL type for Email model implementing Relay Node interface.""" email: strawberry.auto is_primary: strawberry.auto @strawberry_django.filter_type(User, lookups=True) class UserFilter: """Filter type for User queries. The lookups=True parameter enables field lookups like iContains, exact, etc. Example: filters: { firstName: { iContains: "john" } } """ id: strawberry.auto first_name: strawberry.auto birth_date: strawberry.auto @strawberry_django.order_type(User) class UserOrder: """Ordering type for User queries. Allows sorting by specified fields in ASC or DESC order. Example: order: { firstName: ASC } """ id: strawberry.auto first_name: strawberry.auto birth_date: strawberry.auto @strawberry_django.type(User, name="User") class UserType(relay.Node): """GraphQL type for User model. Demonstrates: - Relay Node interface implementation - Field deprecation (first_name, last_name -> name) - Custom field resolvers with optimization hints - Computed fields from model properties (age) """ emails: list[EmailType] birth_date: strawberry.auto age: strawberry.auto first_name: str = strawberry_django.field(deprecation_reason="Use `name` instead.") last_name: str = strawberry_django.field(deprecation_reason="Use `name` instead.") @strawberry_django.field(only=["first_name", "last_name"]) def name(self, root: User) -> str: """Return the user's full name. The only parameter ensures first_name and last_name are fetched when this field is requested, preventing deferred attribute errors. """ return f"{root.first_name} {root.last_name}".strip() @strawberry_django.field(only=["avatar"]) def avatar(self, root: User) -> str | None: """Return the user's avatar URL if available.""" return root.avatar.url if root.avatar else None strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/views.py000066400000000000000000000024411516173410200256070ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from app.base.dataloaders import DataLoaders from app.base.types import Context from strawberry.django.views import AsyncGraphQLView if TYPE_CHECKING: from strawberry.types import ExecutionContext class GraphQLView(AsyncGraphQLView[Context]): """Custom async GraphQL view with typed context. Extends AsyncGraphQLView to provide a custom Context class with: - Type-safe user access helpers - DataLoader instances for query optimization The AsyncGraphQLView is required when using async resolvers. For sync-only applications, use the standard GraphQLView instead. """ async def get_context(self, request, response) -> Context | ExecutionContext: """Create a new Context instance for each GraphQL request. IMPORTANT: DataLoaders must be instantiated per-request to ensure proper batching and caching within a single request context. Args: request: The Django HTTP request response: The Django HTTP response Returns: Context instance with request, response, and fresh dataloaders """ return Context( request=request, response=response, dataloaders=DataLoaders(), ) strawberry-graphql-django-0.82.1/examples/ecommerce_app/app/wsgi.py000066400000000000000000000006101516173410200254170ustar00rootroot00000000000000"""WSGI config for ecommerce_app project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") application = get_wsgi_application() strawberry-graphql-django-0.82.1/examples/ecommerce_app/manage.py000077500000000000000000000012251516173410200251240ustar00rootroot00000000000000#!/usr/bin/env python """Django's command-line utility for administrative tasks.""" import os import sys def main(): """Run administrative tasks.""" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?", ) from exc execute_from_command_line(sys.argv) if __name__ == "__main__": main() strawberry-graphql-django-0.82.1/examples/ecommerce_app/poetry.lock000066400000000000000000001347241516173410200255260ustar00rootroot00000000000000# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "asgiref" version = "3.11.0" description = "ASGI specs, helper code, and adapters" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ {file = "asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d"}, {file = "asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4"}, ] [package.dependencies] typing_extensions = {version = ">=4", markers = "python_version < \"3.11\""} [package.extras] tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] [[package]] name = "coverage" version = "7.12.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"}, {file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"}, {file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"}, {file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"}, {file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"}, {file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"}, {file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"}, {file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"}, {file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"}, {file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"}, {file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"}, {file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"}, {file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"}, {file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"}, {file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"}, {file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"}, {file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"}, {file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"}, {file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"}, {file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"}, {file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"}, {file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"}, {file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"}, {file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"}, {file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"}, {file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"}, {file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"}, {file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"}, {file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"}, {file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"}, {file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"}, {file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"}, {file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"}, {file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"}, {file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"}, {file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"}, {file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"}, {file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"}, {file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"}, {file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"}, {file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"}, {file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"}, {file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"}, {file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"}, {file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"}, {file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"}, {file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"}, {file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"}, {file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"}, {file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"}, {file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"}, {file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"}, {file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"}, {file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"}, {file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"}, {file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"}, {file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"}, {file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"}, {file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"}, {file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"}, {file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"}, {file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"}, {file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"}, {file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"}, ] [package.dependencies] tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "django" version = "5.2.8" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ {file = "django-5.2.8-py3-none-any.whl", hash = "sha256:37e687f7bd73ddf043e2b6b97cfe02fcbb11f2dbb3adccc6a2b18c6daa054d7f"}, {file = "django-5.2.8.tar.gz", hash = "sha256:23254866a5bb9a2cfa6004e8b809ec6246eba4b58a7589bc2772f1bcc8456c7f"}, ] [package.dependencies] asgiref = ">=3.8.1" sqlparse = ">=0.3.1" tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] [[package]] name = "django-choices-field" version = "2.3.0" description = "Django field that set/get django's new TextChoices/IntegerChoices enum." optional = false python-versions = ">=3.8,<4.0" groups = ["main"] files = [ {file = "django_choices_field-2.3.0-py3-none-any.whl", hash = "sha256:4bdcccf802bff9065af19798810de494dd16337fa46dae01680ede12a10280d6"}, {file = "django_choices_field-2.3.0.tar.gz", hash = "sha256:bb0c85c79737ab98bfb9c0d9ddf98010d612c0585be767890e25fd192c3d1694"}, ] [package.dependencies] django = ">=3.2" typing_extensions = ">=4.0.0" [[package]] name = "django-debug-toolbar" version = "4.4.6" description = "A configurable set of panels that display various debug information about the current request/response." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "django_debug_toolbar-4.4.6-py3-none-any.whl", hash = "sha256:3beb671c9ec44ffb817fad2780667f172bd1c067dbcabad6268ce39a81335f45"}, {file = "django_debug_toolbar-4.4.6.tar.gz", hash = "sha256:36e421cb908c2f0675e07f9f41e3d1d8618dc386392ec82d23bcfcd5d29c7044"}, ] [package.dependencies] django = ">=4.2.9" sqlparse = ">=0.2" [[package]] name = "django-extensions" version = "3.2.3" description = "Extensions for Django" optional = false python-versions = ">=3.6" groups = ["dev"] files = [ {file = "django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a"}, {file = "django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401"}, ] [package.dependencies] Django = ">=3.2" [[package]] name = "exceptiongroup" version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["dev"] markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, ] [package.dependencies] typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] [[package]] name = "graphql-core" version = "3.2.3" description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL." optional = false python-versions = ">=3.6,<4" groups = ["main"] files = [ {file = "graphql-core-3.2.3.tar.gz", hash = "sha256:06d2aad0ac723e35b1cb47885d3e5c45e956a53bc1b209a9fc5369007fe46676"}, {file = "graphql_core-3.2.3-py3-none-any.whl", hash = "sha256:5766780452bd5ec8ba133f8bf287dc92713e3868ddd83aee4faab9fc3e303dc3"}, ] [[package]] name = "iniconfig" version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] [[package]] name = "lia-web" version = "0.2.3" description = "A library for working with web frameworks" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "lia_web-0.2.3-py3-none-any.whl", hash = "sha256:237c779c943cd4341527fc0adfcc3d8068f992ee051f4ef059b8474ee087f641"}, {file = "lia_web-0.2.3.tar.gz", hash = "sha256:ccc9d24cdc200806ea96a20b22fb68f4759e6becdb901bd36024df7921e848d7"}, ] [package.dependencies] typing-extensions = ">=4.14.0" [[package]] name = "packaging" version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] [[package]] name = "pillow" version = "10.4.0" description = "Python Imaging Library (Fork)" optional = false python-versions = ">=3.8" groups = ["main"] files = [ {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, ] [package.extras] docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] fpx = ["olefile"] mic = ["olefile"] tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] typing = ["typing-extensions ; python_version < \"3.10\""] xmp = ["defusedxml"] [[package]] name = "pluggy" version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pytest" version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, ] [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-cov" version = "4.1.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, ] [package.dependencies] coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] [[package]] name = "pytest-django" version = "4.11.1" description = "A Django plugin for pytest." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10"}, {file = "pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991"}, ] [package.dependencies] pytest = ">=7.0.0" [package.extras] docs = ["sphinx", "sphinx_rtd_theme"] testing = ["Django", "django-configurations (>=2.0)"] [[package]] name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" groups = ["main"] files = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, ] [package.dependencies] six = ">=1.5" [[package]] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" groups = ["main"] files = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] [[package]] name = "sqlparse" version = "0.4.3" description = "A non-validating SQL parser." optional = false python-versions = ">=3.5" groups = ["main", "dev"] files = [ {file = "sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34"}, {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"}, ] [[package]] name = "strawberry-graphql" version = "0.287.0" description = "A library for creating GraphQL APIs" optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ {file = "strawberry_graphql-0.287.0-py3-none-any.whl", hash = "sha256:d85cc90101d322a641fe8f4adb8f3c4a1aa5dddb61ba1a4bbb7bf42aa4dbad8c"}, {file = "strawberry_graphql-0.287.0.tar.gz", hash = "sha256:4da748f82684f3dc914fc9ed88184e715903d7fa06248f8f702bbbab55f7ed36"}, ] [package.dependencies] graphql-core = ">=3.2.0,<3.4.0" lia-web = ">=0.2.1" packaging = ">=23" python-dateutil = ">=2.7,<3.0" typing-extensions = ">=4.5.0" [package.extras] aiohttp = ["aiohttp (>=3.7.4.post0,<4)"] asgi = ["python-multipart (>=0.0.7)", "starlette (>=0.18.0)"] chalice = ["chalice (>=1.22,<2.0)"] channels = ["asgiref (>=3.2,<4.0)", "channels (>=3.0.5)"] cli = ["libcst", "pygments (>=2.3,<3.0)", "python-multipart (>=0.0.7)", "rich (>=12.0.0)", "starlette (>=0.18.0)", "typer (>=0.12.4)", "uvicorn (>=0.11.6)", "websockets (>=15.0.1,<16)"] debug = ["libcst", "rich (>=12.0.0)"] debug-server = ["libcst", "pygments (>=2.3,<3.0)", "python-multipart (>=0.0.7)", "rich (>=12.0.0)", "starlette (>=0.18.0)", "typer (>=0.12.4)", "uvicorn (>=0.11.6)", "websockets (>=15.0.1,<16)"] django = ["Django (>=3.2)", "asgiref (>=3.2,<4.0)"] fastapi = ["fastapi (>=0.65.2)", "python-multipart (>=0.0.7)"] flask = ["flask (>=1.1)"] litestar = ["litestar (>=2) ; python_version ~= \"3.10\""] opentelemetry = ["opentelemetry-api (<2)", "opentelemetry-sdk (<2)"] pydantic = ["pydantic (>1.6.1)"] pyinstrument = ["pyinstrument (>=4.0.0)"] quart = ["quart (>=0.19.3)"] sanic = ["sanic (>=20.12.2)"] [[package]] name = "strawberry-graphql-django" version = "0.67.1" description = "Strawberry GraphQL Django extension" optional = false python-versions = ">=3.10,<4.0" groups = ["main"] files = [] develop = true [package.dependencies] asgiref = ">=3.8" django = ">=4.2" strawberry-graphql = ">=0.283.2" [package.extras] debug-toolbar = ["django-debug-toolbar (>=6.0.0)"] enum = ["django-choices-field (>=2.2.2)"] [package.source] type = "directory" url = "../.." [[package]] name = "tomli" version = "2.3.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["dev"] markers = "python_full_version <= \"3.11.0a6\"" files = [ {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, ] [[package]] name = "typing-extensions" version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] markers = {dev = "python_version == \"3.10\""} [[package]] name = "tzdata" version = "2023.4" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main", "dev"] markers = "sys_platform == \"win32\"" files = [ {file = "tzdata-2023.4-py2.py3-none-any.whl", hash = "sha256:aa3ace4329eeacda5b7beb7ea08ece826c28d761cda36e747cfbf97996d39bf3"}, {file = "tzdata-2023.4.tar.gz", hash = "sha256:dd54c94f294765522c77399649b4fefd95522479a664a0cec87f41bebc6148c9"}, ] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" content-hash = "0f8e933f72cc67ff29efa96b3443c2b062b5762c232bf21bead79a7e430e9f34" strawberry-graphql-django-0.82.1/examples/ecommerce_app/pyproject.toml000066400000000000000000000013421516173410200262330ustar00rootroot00000000000000[tool.poetry] name = "strawberry-django-example" version = "0.1.0" description = "Strawberry GraphQL Django Example Application" authors = ["Strawberry GraphQL "] [tool.poetry.dependencies] python = ">=3.10,<4.0" django = "^5.0" django-choices-field = "^2.2.2" pillow = "^10.0.0" strawberry-graphql-django = { path = "../..", develop = true } [tool.poetry.group.dev.dependencies] django-debug-toolbar = "^4.1.0" django-extensions = "^3.2.0" pytest = "^7.4.0" pytest-django = "^4.5.0" pytest-cov = "^4.1.0" [tool.pyright] pythonVersion = "3.10" useLibraryCodeForTypes = true exclude = [".venv", "**/migrations", "dist"] [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" strawberry-graphql-django-0.82.1/examples/ecommerce_app/pytest.ini000066400000000000000000000002271516173410200253510ustar00rootroot00000000000000[pytest] DJANGO_SETTINGS_MODULE = app.settings python_files = tests/test_*.py python_classes = Test* python_functions = test_* addopts = -v --tb=short strawberry-graphql-django-0.82.1/examples/ecommerce_app/tests/000077500000000000000000000000001516173410200244615ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/examples/ecommerce_app/tests/README.md000066400000000000000000000047161516173410200257500ustar00rootroot00000000000000# Tests This directory contains example tests demonstrating how to test Strawberry Django applications. ## Running Tests ```bash # Run all tests poetry run pytest # Run with verbose output poetry run pytest -v # Run specific test file poetry run pytest tests/test_user_queries.py # Run with coverage report poetry run pytest --cov=app --cov-report=html ``` ## Test Structure - `conftest.py` - Pytest fixtures and configuration - `test_user_queries.py` - Example tests for user-related queries and mutations ## Writing Tests ### Basic Query Test ```python def test_simple_query(graphql_client): query = """ query { products(pagination: { limit: 10 }) { id name } } """ result = graphql_client.query(query) assert result.errors is None assert len(result.data["products"]) <= 10 ``` ### Testing with Authentication ```python def test_authenticated_query(graphql_client, user, client): # Set up authentication client.force_login(user) query = """ query { me { id name } } """ # Pass request context to the query result = graphql_client.query(query, context_value={"request": client}) assert result.data["me"]["id"] == user.id ``` ### Testing Mutations ```python def test_mutation(graphql_client, user): mutation = """ mutation AddToCart($productId: GlobalID!, $quantity: Int!) { cartAddItem(product: $productId, quantity: $quantity) { id quantity } } """ result = graphql_client.query( mutation, variables={ "productId": "UHJvZHVjdFR5cGU6MQ==", "quantity": 2, }, ) assert result.errors is None assert result.data["cartAddItem"]["quantity"] == 2 ``` ## Best Practices 1. **Use fixtures** - Create reusable test data in `conftest.py` 2. **Test permissions** - Verify that protected queries/mutations require authentication 3. **Test error cases** - Ensure proper error handling and validation 4. **Use transactions** - Tests automatically roll back database changes 5. **Mock external services** - Isolate tests from external dependencies ## Resources - [Pytest Documentation](https://docs.pytest.org/) - [pytest-django Documentation](https://pytest-django.readthedocs.io/) - [Strawberry Testing Documentation](https://strawberry.rocks/docs/general/testing) strawberry-graphql-django-0.82.1/examples/ecommerce_app/tests/__init__.py000066400000000000000000000001611516173410200265700ustar00rootroot00000000000000"""Tests for the ecommerce example app. These tests demonstrate how to test Strawberry Django applications. """ strawberry-graphql-django-0.82.1/examples/ecommerce_app/tests/conftest.py000066400000000000000000000012611516173410200266600ustar00rootroot00000000000000"""Pytest configuration and fixtures for tests.""" import pytest from django.contrib.auth import get_user_model User = get_user_model() @pytest.fixture def user(db): """Create a test user.""" return User.objects.create_user( username="testuser", email="test@example.com", password="testpass123", # gitleaks:allow first_name="Test", last_name="User", ) @pytest.fixture def admin_user(db): """Create an admin user.""" return User.objects.create_superuser( username="admin", email="admin@example.com", password="admin123", # gitleaks:allow first_name="Admin", last_name="User", ) strawberry-graphql-django-0.82.1/examples/ecommerce_app/tests/test_user_queries.py000066400000000000000000000061221516173410200306060ustar00rootroot00000000000000"""Tests for user queries demonstrating Strawberry Django testing patterns.""" import pytest from app.schema import schema from django.contrib.auth import get_user_model from strawberry.test import BaseGraphQLTestClient User = get_user_model() @pytest.fixture def graphql_client(): """Create a GraphQL test client.""" return BaseGraphQLTestClient(schema) def test_me_query_unauthenticated(graphql_client, db): """Test that me query returns null when not authenticated.""" query = """ query { me { id name } } """ result = graphql_client.query(query) assert result.data == {"me": None} def test_me_query_authenticated(graphql_client, user, client): """Test that me query returns user data when authenticated. Demonstrates: - Testing authenticated queries - Using Django's test client to manage sessions """ client.force_login(user) query = """ query { me { id name emails { email } } } """ # Note: In a real app, you'd need to pass the request context # This is a simplified example showing the query structure result = graphql_client.query(query) # The actual assertion would depend on your context setup assert result.errors is None or len(result.errors) == 0 def test_login_mutation(graphql_client, user, db): """Test login mutation with valid credentials. Demonstrates: - Testing mutations - Handling authentication - Error handling """ mutation = """ mutation Login($username: String!, $password: String!) { login(username: $username, password: $password) { id name } } """ # Test with correct credentials graphql_client.query( mutation, variables={"username": "testuser", "password": "testpass123"}, ) # Note: Actual behavior depends on context setup # This demonstrates the structure of a login test def test_users_query_with_filters(graphql_client, user, db): """Test users query with filtering. Demonstrates: - Testing filtered queries - Pagination - Complex query structures """ # Create additional users User.objects.create_user( username="john", first_name="John", last_name="Doe", ) User.objects.create_user( username="jane", first_name="Jane", last_name="Smith", ) query = """ query Users($filters: UserFilter, $pagination: OffsetPaginationInput) { users(filters: $filters, pagination: $pagination) { id name } } """ result = graphql_client.query( query, variables={ "filters": {"firstName": {"exact": "John"}}, "pagination": {"limit": 10, "offset": 0}, }, ) # In a real test, you'd verify the filtered results assert result.errors is None or len(result.errors) == 0 strawberry-graphql-django-0.82.1/pyproject.toml000066400000000000000000000112531516173410200216200ustar00rootroot00000000000000[project] name = "strawberry-graphql-django" version = "0.82.0" description = "Strawberry GraphQL Django extension" authors = [ { name = "Lauri Hintsala", email = "lauri.hintsala@verkkopaja.fi" }, { name = "Thiago Bellini Ribeiro", email = "thiago@bellini.dev" }, ] maintainers = [ { name = "Thiago Bellini Ribeiro", email = "thiago@bellini.dev" }, ] license = { text = "MIT" } readme = "README.md" keywords = ["graphql", "api", "django", "strawberry-graphql"] classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Framework :: Django", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", "Framework :: Django :: 5.2", "Framework :: Django :: 6.0", ] requires-python = ">=3.10,<4.0" dependencies = ["django>=4.2", "asgiref>=3.8", "strawberry-graphql>=0.310.1"] [project.urls] homepage = "https://strawberry.rocks/docs/django" repository = "https://github.com/strawberry-graphql/strawberry-django" documentation = "https://strawberry.rocks/docs/django" [project.optional-dependencies] debug-toolbar = ["django-debug-toolbar>=6.0.0"] enum = ["django-choices-field>=2.2.2"] polymorphic = ["django-polymorphic>=4.0.0"] [dependency-groups] dev = [ "channels>=3.0.5", "django-choices-field>=3.1.0", "django-debug-toolbar>=6.0.0", "django-guardian>=3.2.0", "django-types>=0.22.0", "factory-boy>=3.2.1", "pillow>=12.0.0", "pytest>=9.0.1", "pytest-asyncio>=1.0.0", "pytest-cov>=7.0.0", "pytest-django>=4.1.0", "pytest-mock>=3.5.1", "pytest-snapshot>=0.9.0", "pytest-watch>=4.2.0", "pytest-xdist>=3.8.0", "ruff>=0.14.0", "django-polymorphic>=4.0.0", "setuptools>=80.1.0", "psycopg>=3.2.10", "psycopg-binary>=3.2.10", "django-tree-queries>=0.23.0", "django-model-utils>=5.0.0", ] [tool.uv] default-groups = ["dev"] [tool.hatch.build.targets.wheel] packages = ["strawberry_django"] [tool.hatch.build.targets.sdist] include = ["/strawberry_django", "/pyproject.toml", "/README.md", "/LICENSE"] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.autopub] plugins = ["autopub.plugins.github", "autopub.plugins.uv"] git-username = "Botberry" git-email = "bot@strawberry.rocks" project-name = "strawberry-graphql-django" append-github-contributor = true [tool.ruff] target-version = "py310" preview = true [tool.ruff.lint] extend-select = [ "A", "ASYNC", "B", "BLE", "C4", "COM", "D", "D2", "D3", "D4", "DTZ", "E", "ERA", "EXE", "F", "FURB", "G", "I", "ICN001", "INP", "ISC", "N", "PERF", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "Q", "RET", "RSE", "RUF", "SIM", "SLF", "SLOT", "T10", "T20", "TCH", "TID", "TRY", "UP", "W", "YTT", ] extend-ignore = [ "A005", "D1", "D203", "D213", "D417", "E203", "PGH003", "PLR09", "SLF001", "TRY003", "PLR6301", "PLC0415", "TC002", # ruff formatter recommends to disable those "COM812", "COM819", "D206", "E111", "E114", "E117", "E501", "ISC001", "Q000", "Q001", "Q002", "Q003", "W191", ] exclude = [ ".eggs", ".git", ".hg", ".mypy_cache", ".tox", ".venv", "__pycached__", "_build", "buck-out", "build", "dist", ] [tool.ruff.lint.per-file-ignores] "tests/*" = ["A003", "PLW0603", "PLR2004"] "examples/*" = ["A003"] "**/migrations/*" = ["RUF012"] [tool.ruff.lint.pylint] max-nested-blocks = 7 [tool.ruff.lint.isort] [tool.ruff.format] [tool.pyright] pythonVersion = "3.10" useLibraryCodeForTypes = true exclude = [".venv", "**/migrations", "dist", "docs", "examples"] reportCallInDefaultInitializer = "warning" reportMatchNotExhaustive = "warning" reportMissingSuperCall = "warning" reportOverlappingOverload = "warning" reportUninitializedInstanceVariable = "none" reportUnnecessaryCast = "warning" reportUnnecessaryTypeIgnoreComment = "warning" reportUntypedNamedTuple = "error" reportUnusedExpression = "warning" reportUnnecessaryComparison = "warning" reportUnnecessaryContains = "warning" strictDictionaryInference = true strictListInference = true strictSetInference = true [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "tests.django_settings" testpaths = ["tests"] filterwarnings = "ignore:.*is deprecated.*:DeprecationWarning" addopts = "--nomigrations --cov=./ --cov-report term-missing:skip-covered" asyncio_mode = "auto" strawberry-graphql-django-0.82.1/strawberry_django/000077500000000000000000000000001516173410200224305ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/__init__.py000066400000000000000000000043041516173410200245420ustar00rootroot00000000000000import warnings from typing import TYPE_CHECKING, Any from . import auth, federation, filters, mutations, ordering, pagination, relay from .fields.field import connection, field, node, offset_paginated from .fields.filter_order import filter_field, order_field from .fields.filter_types import ( BaseFilterLookup, ComparisonFilterLookup, DateFilterLookup, DatetimeFilterLookup, FilterLookup, RangeLookup, StrFilterLookup, TimeFilterLookup, ) from .fields.types import ( DjangoFileType, DjangoImageType, DjangoModelType, ListInput, ManyToManyInput, ManyToOneInput, NodeInput, NodeInputPartial, OneToManyInput, OneToOneInput, ) from .filters import filter_type, process_filters from .mutations.mutations import input_mutation, mutation from .ordering import Ordering, order, order_type, process_order from .resolvers import django_resolver from .type import input, interface, partial, type # noqa: A004 if TYPE_CHECKING: from strawberry_django.filters import filter # noqa: A004, F401 __all__ = [ "BaseFilterLookup", "ComparisonFilterLookup", "DateFilterLookup", "DatetimeFilterLookup", "DjangoFileType", "DjangoImageType", "DjangoModelType", "FilterLookup", "ListInput", "ManyToManyInput", "ManyToOneInput", "NodeInput", "NodeInputPartial", "OneToManyInput", "OneToOneInput", "Ordering", "RangeLookup", "StrFilterLookup", "TimeFilterLookup", "auth", "connection", "django_resolver", "federation", "field", "filter_field", "filter_type", "filters", "input", "input_mutation", "interface", "mutation", "mutations", "node", "offset_paginated", "order", "order_field", "order_type", "ordering", "pagination", "partial", "process_filters", "process_order", "relay", "type", ] def __getattr__(name: str) -> Any: if name == "filter": warnings.warn( "`filter` is deprecated, use `filter_type` instead.", DeprecationWarning, stacklevel=2, ) return filter_type raise AttributeError(f"module {__name__} has no attribute {name}") strawberry-graphql-django-0.82.1/strawberry_django/apps.py000066400000000000000000000002231516173410200237420ustar00rootroot00000000000000from django.apps import AppConfig class StrawberryDjangoConfig(AppConfig): name = "strawberry_django" verbose_name = "Strawberry django" strawberry-graphql-django-0.82.1/strawberry_django/arguments.py000066400000000000000000000012401516173410200250040ustar00rootroot00000000000000from typing import Optional from strawberry import UNSET from strawberry.annotation import StrawberryAnnotation from strawberry.types.arguments import StrawberryArgument def argument( name: str, type_: type, *, is_list: bool = False, is_optional: bool = False, default: object = UNSET, ): argument_type = type_ if is_list: argument_type = list[type_] if is_optional: argument_type = Optional[type_] # noqa: UP045 return StrawberryArgument( default=default, description=None, graphql_name=None, python_name=name, type_annotation=StrawberryAnnotation(argument_type), ) strawberry-graphql-django-0.82.1/strawberry_django/auth/000077500000000000000000000000001516173410200233715ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/auth/__init__.py000066400000000000000000000002141516173410200254770ustar00rootroot00000000000000from .mutations import login, logout, register from .queries import current_user __all__ = ["current_user", "login", "logout", "register"] strawberry-graphql-django-0.82.1/strawberry_django/auth/mutations.py000066400000000000000000000065551516173410200260010ustar00rootroot00000000000000from __future__ import annotations import functools from typing import TYPE_CHECKING, Any, cast import strawberry from asgiref.sync import async_to_sync from django.contrib import auth from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError from strawberry.types import Info from strawberry_django.auth.utils import get_current_user from strawberry_django.mutations import mutations, resolvers from strawberry_django.mutations.fields import DjangoCreateMutation from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry_django.resolvers import django_resolver from strawberry_django.utils.requests import get_request try: # Django-channels is not always used/intalled, # therefore it shouldn't be it a hard requirement. from channels import auth as channels_auth except ModuleNotFoundError: channels_auth = None if TYPE_CHECKING: from django.contrib.auth.base_user import AbstractBaseUser @django_resolver def resolve_login(info: Info, username: str, password: str) -> AbstractBaseUser: request = get_request(info) user = auth.authenticate(request, username=username, password=password) if user is None: raise ValidationError("Incorrect username/password") try: auth.login(request, user) except AttributeError: # ASGI in combo with websockets needs the channels login functionality. # to ensure we're talking about channels, let's veriy that our # request is actually channelsrequest try: scope = request.consumer.scope # type: ignore async_to_sync(channels_auth.login)(scope, user) # type: ignore # According to channels docs you must save the session scope["session"].save() except (AttributeError, NameError): # When Django-channels is not installed, # this code will be non-existing pass return user @django_resolver def resolve_logout(info: Info) -> bool: user = get_current_user(info) ret = user.is_authenticated try: request = get_request(info) auth.logout(request) except AttributeError: try: scope = request.consumer.scope # type: ignore async_to_sync(channels_auth.logout)(scope) # type: ignore except (AttributeError, NameError): # When Django-channels is not installed, # this code will be non-existing pass return ret class DjangoRegisterMutation(DjangoCreateMutation): def create(self, data: dict[str, Any], *, info: Info): model = cast("type[AbstractBaseUser]", self.django_model) assert model is not None password = data.pop("password") validate_password(password) # Do not optimize anything while retrieving the object to update with DjangoOptimizerExtension.disabled(): return resolvers.create( info, model, data, key_attr=self.key_attr, full_clean=self.full_clean, pre_save_hook=lambda obj: obj.set_password(password), ) login = functools.partial(strawberry.mutation, resolver=resolve_login) logout = functools.partial(strawberry.mutation, resolver=resolve_logout) register = mutations.create if TYPE_CHECKING else DjangoRegisterMutation strawberry-graphql-django-0.82.1/strawberry_django/auth/queries.py000066400000000000000000000011221516173410200254140ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from django.core.exceptions import ValidationError from strawberry.types import Info import strawberry_django from .utils import get_current_user if TYPE_CHECKING: from strawberry_django.utils.typing import UserType def resolve_current_user(info: Info) -> UserType: user = get_current_user(info) if not getattr(user, "is_authenticated", False): raise ValidationError("User is not logged in.") return user def current_user(): return strawberry_django.field(resolver=resolve_current_user) strawberry-graphql-django-0.82.1/strawberry_django/auth/utils.py000066400000000000000000000030131516173410200251000ustar00rootroot00000000000000from typing import Literal, overload from asgiref.sync import sync_to_async from strawberry.types import Info from strawberry_django.utils.requests import get_request from strawberry_django.utils.typing import UserType @overload def get_current_user(info: Info, *, strict: Literal[True]) -> UserType: ... @overload def get_current_user(info: Info, *, strict: bool = False) -> UserType: ... def get_current_user(info: Info, *, strict: bool = False) -> UserType: """Get and return the current user based on various scenarios.""" request = get_request(info) try: user = request.user except AttributeError: try: # queries/mutations in ASGI move the user into consumer scope user = request.consumer.scope["user"] # type: ignore except AttributeError: # websockets / subscriptions move scope inside of the request user = request.scope.get("user") # type: ignore if user is None: raise ValueError("No user found in the current request") # Access an attribute inside the user object to force loading it in async contexts. _ = user.is_authenticated return user @overload async def aget_current_user( info: Info, *, strict: Literal[True], ) -> UserType: ... @overload async def aget_current_user( info: Info, *, strict: bool = False, ) -> UserType: ... async def aget_current_user(info: Info, *, strict: bool = False) -> UserType: return await sync_to_async(get_current_user)(info, strict=strict) strawberry-graphql-django-0.82.1/strawberry_django/descriptors.py000066400000000000000000000137301516173410200253470ustar00rootroot00000000000000import inspect from collections.abc import Callable from typing import ( TYPE_CHECKING, Any, Generic, Optional, TypeVar, overload, ) from django.db.models.base import Model from strawberry.exceptions import MissingFieldAnnotationError from typing_extensions import Self, get_annotations if TYPE_CHECKING: from strawberry_django.optimizer import OptimizerStore from .utils.typing import AnnotateType, PrefetchType, TypeOrMapping, TypeOrSequence __all__ = [ "ModelProperty", "model_cached_property", "model_property", ] _M = TypeVar("_M", bound=Model) _R = TypeVar("_R") class ModelProperty(Generic[_M, _R]): """Model property with optimization hinting functionality.""" name: str store: "OptimizerStore" def __init__( self, func: Callable[[_M], _R], *, cached: bool = False, meta: dict[Any, Any] | None = None, only: Optional["TypeOrSequence[str]"] = None, select_related: Optional["TypeOrSequence[str]"] = None, prefetch_related: Optional["TypeOrSequence[PrefetchType]"] = None, annotate: Optional["TypeOrMapping[AnnotateType]"] = None, ): from .optimizer import OptimizerStore super().__init__() self.func = func self.cached = cached self.meta = meta self.store = OptimizerStore.with_hints( only=only, select_related=select_related, prefetch_related=prefetch_related, annotate=annotate, ) def __set_name__(self, owner: type[_M], name: str): self.origin = owner self.name = name @overload def __get__(self, obj: _M, cls: type[_M]) -> _R: ... @overload def __get__(self, obj: None, cls: type[_M]) -> Self: ... def __get__(self, obj, cls=None): if obj is None: return self if not self.cached: return self.func(obj) try: ret = obj.__dict__[self.name] except KeyError: ret = self.func(obj) obj.__dict__[self.name] = ret return ret @property def description(self) -> str | None: if not self.func.__doc__: return None return inspect.cleandoc(self.func.__doc__) @property def type_annotation(self) -> object | str: ret = get_annotations(self.func).get("return") if ret is None: raise MissingFieldAnnotationError(self.name, self.origin) return ret @overload def model_property( func: Callable[[_M], _R], *, cached: bool = False, meta: dict[Any, Any] | None = None, only: Optional["TypeOrSequence[str]"] = None, select_related: Optional["TypeOrSequence[str]"] = None, prefetch_related: Optional["TypeOrSequence[PrefetchType]"] = None, annotate: Optional["TypeOrMapping[AnnotateType]"] = None, ) -> ModelProperty[_M, _R]: ... @overload def model_property( func: None = ..., *, cached: bool = False, meta: dict[Any, Any] | None = None, only: Optional["TypeOrSequence[str]"] = None, select_related: Optional["TypeOrSequence[str]"] = None, prefetch_related: Optional["TypeOrSequence[PrefetchType]"] = None, annotate: Optional["TypeOrMapping[AnnotateType]"] = None, ) -> Callable[[Callable[[_M], _R]], ModelProperty[_M, _R]]: ... def model_property( func=None, *, cached: bool = False, meta: dict[Any, Any] | None = None, only: Optional["TypeOrSequence[str]"] = None, select_related: Optional["TypeOrSequence[str]"] = None, prefetch_related: Optional["TypeOrSequence[PrefetchType]"] = None, annotate: Optional["TypeOrMapping[AnnotateType]"] = None, ) -> Any: def wrapper(f): return ModelProperty( f, cached=cached, meta=meta, only=only, select_related=select_related, prefetch_related=prefetch_related, annotate=annotate, ) if func is not None: return wrapper(func) return wrapper def model_cached_property( func=None, *, meta: dict[Any, Any] | None = None, only: Optional["TypeOrSequence[str]"] = None, select_related: Optional["TypeOrSequence[str]"] = None, prefetch_related: Optional["TypeOrSequence[PrefetchType]"] = None, annotate: Optional["TypeOrMapping[AnnotateType]"] = None, ): """Property with gql optimization hinting. Decorate a method, just like you would do with a `@property`, and when accessing it through a graphql resolver, if `DjangoOptimizerExtension` is enabled, it will automatically optimize the hintings on this field. Args: ---- func: The method to decorate. meta: Some extra metadata to be attached to the field. only: Optional sequence of values to optimize using `QuerySet.only` select_related: Optional sequence of values to optimize using `QuerySet.select_related` prefetch_related: Optional sequence of values to optimize using `QuerySet.prefetch_related` annotate: Optional mapping of values to use in `QuerySet.annotate` Returns: ------- The decorated method. Examples: -------- In a model, define it like this to have the hintings defined in `col_b_formatted` automatically optimized. >>> class SomeModel(models.Model): ... col_a = models.CharField() ... col_b = models.CharField() ... ... @model_cached_property(only=["col_b"]) ... def col_b_formatted(self): ... return f"Formatted: {self.col_b}" ... >>> @gql.django.type(SomeModel) ... class SomeModelType ... col_a: gql.auto ... col_b_formatted: gql.auto """ return model_property( func, cached=True, meta=meta, only=only, select_related=select_related, prefetch_related=prefetch_related, annotate=annotate, ) strawberry-graphql-django-0.82.1/strawberry_django/exceptions.py000066400000000000000000000046201516173410200251650ustar00rootroot00000000000000from __future__ import annotations from functools import cached_property from typing import TYPE_CHECKING from strawberry.exceptions.exception import StrawberryException from strawberry.exceptions.utils.source_finder import SourceFinder if TYPE_CHECKING: from strawberry.exceptions.exception_source import ExceptionSource from strawberry_django.fields.filter_order import FilterOrderFieldResolver class MissingFieldArgumentError(StrawberryException): def __init__(self, field_name: str, resolver: FilterOrderFieldResolver): self.function = resolver.wrapped_func self.message = f'Missing required argument "{field_name}" in "{resolver.name}"' self.rich_message = ( f'[bold red]Missing argument [underline]"{field_name}" for field ' f"`[underline]{resolver.name}[/]`" ) self.annotation_message = "field missing argument" super().__init__(self.message) @cached_property def exception_source(self) -> ExceptionSource | None: # pragma: no cover source_finder = SourceFinder() return source_finder.find_function_from_object(self.function) # type: ignore class ForbiddenFieldArgumentError(StrawberryException): def __init__(self, resolver: FilterOrderFieldResolver, arguments: list[str]): self.extra_arguments = arguments self.function = resolver.wrapped_func self.argument_name = arguments[0] self.message = ( f'Found disallowed {self.extra_arguments_str} in field "{resolver.name}"' ) self.rich_message = ( f"Found disallowed {self.extra_arguments_str} in " f"`[underline]{resolver.name}[/]`" ) self.suggestion = "To fix this error, remove offending argument(s)" self.annotation_message = "forbidden field argument" super().__init__(self.message) @property def extra_arguments_str(self) -> str: arguments = self.extra_arguments if len(arguments) == 1: return f'argument "{arguments[0]}"' head = ", ".join(arguments[:-1]) return f'arguments "{head}" and "{arguments[-1]}"' @cached_property def exception_source(self) -> ExceptionSource | None: # pragma: no cover source_finder = SourceFinder() return source_finder.find_argument_from_object( self.function, # type: ignore self.argument_name, ) strawberry-graphql-django-0.82.1/strawberry_django/extensions/000077500000000000000000000000001516173410200246275ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/extensions/__init__.py000066400000000000000000000000001516173410200267260ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/extensions/django_cache_base.py000066400000000000000000000036731516173410200305710ustar00rootroot00000000000000from collections.abc import Callable, Hashable from functools import _make_key # noqa: PLC2701 from typing import cast from django.core.cache import caches from django.core.cache.backends.base import DEFAULT_TIMEOUT from strawberry.extensions import SchemaExtension from strawberry.types import ExecutionContext class DjangoCacheBase(SchemaExtension): """Base for a Cache that uses Django built in cache instead of an in memory cache. Arguments: --------- `cache_name: str` Name of the Django Cache to use, defaults to 'default' `timeout: Optional[int]` How long to hold items in the cache. See the Django Cache docs for details https://docs.djangoproject.com/en/4.0/topics/cache/ `hash_fn: Optional[Callable[[Tuple, Dict], str]]` A function to use to generate the cache keys Defaults to the same key generator as functools.lru_cache WARNING! The default function does NOT work with memcached and will generate warnings """ def __init__( self, cache_name: str = "default", timeout: int | None = None, hash_fn: Callable[[tuple, dict], Hashable] | None = None, *, execution_context: ExecutionContext | None = None, ): super().__init__(execution_context=cast("ExecutionContext", execution_context)) self.cache = caches[cache_name] self.timeout = timeout or DEFAULT_TIMEOUT # Use same key generating function as functools.lru_cache as default self.hash_fn = hash_fn or (lambda args, kwargs: _make_key(args, kwargs, False)) def execute_cached(self, func, *args, **kwargs): hash_key = cast("str", self.hash_fn(args, kwargs)) cache_result = self.cache.get(hash_key) if cache_result is not None: return cache_result func_result = func(*args, **kwargs) self.cache.set(hash_key, func_result, timeout=self.timeout) return func_result strawberry-graphql-django-0.82.1/strawberry_django/extensions/django_validation_cache.py000066400000000000000000000013111516173410200317740ustar00rootroot00000000000000from collections.abc import Iterator from graphql.validation import validate from strawberry.schema.validation_rules.one_of import OneOfInputValidationRule from .django_cache_base import DjangoCacheBase class DjangoValidationCache(DjangoCacheBase): def on_validate(self) -> Iterator[None]: execution_context = self.execution_context errors = self.execute_cached( validate, execution_context.schema._schema, execution_context.graphql_document, ( *execution_context.validation_rules, OneOfInputValidationRule, ), ) execution_context.pre_execution_errors = errors yield None strawberry-graphql-django-0.82.1/strawberry_django/federation/000077500000000000000000000000001516173410200245505ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/federation/__init__.py000066400000000000000000000015071516173410200266640ustar00rootroot00000000000000"""Federation support for strawberry-django. This module provides Django-aware federation decorators that combine `strawberry_django` functionality with Apollo Federation support. - `type` - Federation-aware Django type decorator - `interface` - Federation-aware Django interface decorator - `field` - Federation-aware Django field decorator The type and interface decorators automatically generate `resolve_reference` methods for entity types (those with `@key` directives). See docs/integrations/federation.md for full usage examples. """ from .field import field from .resolve import generate_resolve_reference, resolve_model_reference from .type import interface from .type import type as type # noqa: A004 __all__ = [ "field", "generate_resolve_reference", "interface", "resolve_model_reference", "type", ] strawberry-graphql-django-0.82.1/strawberry_django/federation/field.py000066400000000000000000000077231516173410200262160ustar00rootroot00000000000000"""Federation field decorator for Django models. This module provides a federation-aware field decorator that wraps `strawberry_django.field` with federation-specific parameters like `external`, `requires`, `provides`, etc. """ from __future__ import annotations import dataclasses import warnings from typing import ( TYPE_CHECKING, Any, ) from strawberry import UNSET from strawberry.annotation import StrawberryAnnotation from strawberry.extensions.field_extension import FieldExtension from strawberry.federation.params import ( FederationFieldParams, process_federation_field_directives, ) from strawberry.types.field import _RESOLVER_TYPE from typing_extensions import Unpack from strawberry_django.fields.field import StrawberryDjangoField if TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence from strawberry import BasePermission from strawberry.types.unset import UnsetType from strawberry_django.utils.typing import ( AnnotateType, PrefetchType, TypeOrMapping, TypeOrSequence, ) __all__ = ["field"] def field( resolver: _RESOLVER_TYPE[Any] | None = None, *, field_cls: type[StrawberryDjangoField] = StrawberryDjangoField, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: Sequence[FieldExtension] = (), pagination: bool | UnsetType = UNSET, filters: type | UnsetType | None = UNSET, order: type | UnsetType | None = UNSET, ordering: type | UnsetType | None = UNSET, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, # This init parameter is used by pyright to determine whether this field # is added in the constructor or not. It is not used to change # any behavior at the moment. init: bool | None = None, **federation_kwargs: Unpack[FederationFieldParams], ) -> Any: """Annotate a method or property as a federated Django GraphQL field. Wraps `strawberry_django.field` with federation-specific parameters. Federation-specific args: authenticated, external, inaccessible, policy, provides, override, requires, requires_scopes, shareable, tags. See `strawberry_django.field` for all other parameters. """ processed_directives = process_federation_field_directives( directives, **federation_kwargs ) if order: warnings.warn( "strawberry_django.order is deprecated in favor of strawberry_django.order_type.", DeprecationWarning, stacklevel=2, ) f = field_cls( python_name=None, django_name=field_name, graphql_name=name, type_annotation=StrawberryAnnotation.from_annotation(graphql_type), description=description, is_subscription=is_subscription, permission_classes=permission_classes or [], deprecation_reason=deprecation_reason, default=default, default_factory=default_factory, metadata=metadata, directives=processed_directives, filters=filters, pagination=pagination, order=order, ordering=ordering, extensions=extensions, only=only, select_related=select_related, prefetch_related=prefetch_related, annotate=annotate, disable_optimization=disable_optimization, ) if resolver: return f(resolver) return f strawberry-graphql-django-0.82.1/strawberry_django/federation/resolve.py000066400000000000000000000053371516173410200266110ustar00rootroot00000000000000"""Federation reference resolution utilities for Django models. This module provides utilities to automatically generate `resolve_reference` class methods for federation entity types backed by Django models. """ from __future__ import annotations from typing import TYPE_CHECKING, Any, TypeVar, cast from django.db import models from strawberry.types.info import Info from strawberry.utils.await_maybe import AwaitableOrValue from strawberry_django.queryset import run_type_get_queryset from strawberry_django.resolvers import django_resolver if TYPE_CHECKING: from strawberry_django.utils.typing import WithStrawberryDjangoObjectDefinition _M = TypeVar("_M", bound=models.Model) __all__ = [ "generate_resolve_reference", "resolve_model_reference", ] def resolve_model_reference( source: type[WithStrawberryDjangoObjectDefinition], *, info: Info | None = None, **key_fields: Any, ) -> AwaitableOrValue[_M]: """Resolve a Django model instance by federation key fields. Similar to `resolve_model_node` but works with arbitrary key fields. """ from strawberry_django import optimizer # avoid circular import from strawberry_django.utils.typing import get_django_definition django_type = get_django_definition(source, strict=True) model = cast("type[_M]", django_type.model) qs = model._default_manager.all() qs = run_type_get_queryset(qs, source, info) qs = qs.filter(**key_fields) if info is not None: ext = optimizer.optimizer.get() if ext is not None: # If optimizer extension is enabled, optimize this queryset qs = ext.optimize(qs, info=info) def _get_result() -> _M: return qs.get() return django_resolver(_get_result)() def generate_resolve_reference(key_fields: list[str]) -> classmethod: """Generate a resolve_reference classmethod for a federation entity type. Only *key_fields* are forwarded to the ORM query — federation may pass extra fields (e.g. from @requires) that are not valid ORM lookups. """ def resolve_reference( cls: type[WithStrawberryDjangoObjectDefinition], info: Info | None = None, **kwargs: Any, ) -> AwaitableOrValue[Any]: # Extract only the key fields from kwargs # (federation may pass additional fields) filtered_kwargs = {k: v for k, v in kwargs.items() if k in key_fields} if not filtered_kwargs: msg = ( f"No matching key fields found in kwargs. " f"Expected one of {key_fields}, got {list(kwargs.keys())}." ) raise ValueError(msg) return resolve_model_reference(cls, info=info, **filtered_kwargs) return classmethod(resolve_reference) strawberry-graphql-django-0.82.1/strawberry_django/federation/type.py000066400000000000000000000157071516173410200261150ustar00rootroot00000000000000"""Federation type decorators for Django models. This module provides federation-aware type decorators that wrap `strawberry_django.type` and `strawberry_django.interface` with federation-specific parameters like `keys`, `shareable`, etc. """ from __future__ import annotations import builtins from typing import ( TYPE_CHECKING, Literal, TypeVar, ) from django.db.models import Model from strawberry.federation.params import ( FederationInterfaceParams, FederationTypeParams, process_federation_type_directives, ) from strawberry.types.field import StrawberryField from typing_extensions import Unpack, dataclass_transform from strawberry_django.fields.field import StrawberryDjangoField from strawberry_django.fields.field import field as _field from strawberry_django.type import _process_type from .resolve import generate_resolve_reference if TYPE_CHECKING: from collections.abc import Callable, Sequence from strawberry_django.utils.typing import ( AnnotateType, PrefetchType, TypeOrMapping, TypeOrSequence, ) _T = TypeVar("_T", bound=builtins.type) __all__ = [ "interface", "type", ] def _get_keys_from_directives(directives: Sequence[object]) -> list[str]: """Extract unique key field names from Key directives. Used to build the filter kwargs for resolve_reference — federation may pass extra fields (e.g. from @requires) that are not valid ORM lookups. """ from strawberry.federation.schema_directives import Key key_fields: list[str] = [] for directive in directives: if isinstance(directive, Key): fields = str(directive.fields).split() key_fields.extend(fields) # Detect nested FieldSet syntax that we can't auto-resolve unsupported = [f for f in key_fields if any(c in f for c in "{}.(")] if unsupported: msg = ( f"Unsupported FieldSet syntax in key fields: {unsupported}. " "Nested fields (e.g. 'organization { id }') cannot be auto-resolved. " "Please provide a custom resolve_reference classmethod." ) raise ValueError(msg) # Remove duplicates while preserving order return list(dict.fromkeys(key_fields)) def _maybe_add_resolve_reference( cls: builtins.type, directives: Sequence[object] ) -> None: """Add auto-generated resolve_reference if the type has @key directives.""" key_fields = _get_keys_from_directives(directives) if not key_fields: return # Don't override existing resolve_reference defined on this class if "resolve_reference" in cls.__dict__: return # Generate and add resolve_reference resolve_ref = generate_resolve_reference(key_fields) cls.resolve_reference = resolve_ref @dataclass_transform( kw_only_default=True, order_default=True, field_specifiers=( StrawberryField, _field, ), ) def type( # noqa: A001 model: builtins.type[Model], *, # Standard strawberry_django.type parameters name: str | None = None, field_cls: builtins.type[StrawberryDjangoField] = StrawberryDjangoField, is_input: bool = False, is_interface: bool = False, is_filter: Literal["lookups"] | bool = False, description: str | None = None, directives: Sequence[object] | None = (), filters: builtins.type | None = None, order: builtins.type | None = None, ordering: builtins.type | None = None, pagination: bool = False, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, fields: list[str] | Literal["__all__"] | None = None, exclude: list[str] | None = None, **federation_kwargs: Unpack[FederationTypeParams], ) -> Callable[[_T], _T]: """Annotate a class as a federated Django GraphQL type. Wraps `strawberry_django.type` with federation support, adding directives and auto-generating `resolve_reference` for entity types. Accepts all `strawberry_django.type` parameters plus federation-specific ones: Args: keys: Federation key fields (strings or Key directives). extend: Whether this type extends a type from another subgraph. shareable: Whether this type can be resolved by multiple subgraphs. inaccessible: Whether this type is hidden from the public API. authenticated: Whether this type requires authentication. policy: Access policy for this type. requires_scopes: Required scopes for this type. tags: Metadata tags for this type. """ processed_directives, extend = process_federation_type_directives( directives, **federation_kwargs ) def wrapper(cls: _T) -> _T: # Add auto-generated resolve_reference if needed _maybe_add_resolve_reference(cls, processed_directives) return _process_type( cls, model, name=name, field_cls=field_cls, is_input=is_input, is_filter=is_filter, is_interface=is_interface, description=description, directives=processed_directives, extend=extend, filters=filters, pagination=pagination, order=order, ordering=ordering, only=only, select_related=select_related, prefetch_related=prefetch_related, annotate=annotate, disable_optimization=disable_optimization, fields=fields, exclude=exclude, ) return wrapper @dataclass_transform( kw_only_default=True, order_default=True, field_specifiers=( StrawberryField, _field, ), ) def interface( model: builtins.type[Model], *, # Standard strawberry_django.interface parameters name: str | None = None, field_cls: builtins.type[StrawberryDjangoField] = StrawberryDjangoField, description: str | None = None, directives: Sequence[object] | None = (), disable_optimization: bool = False, **federation_kwargs: Unpack[FederationInterfaceParams], ) -> Callable[[_T], _T]: """Annotate a class as a federated Django GraphQL interface. Wraps `strawberry_django.interface` with federation support. See `type()` for federation-specific parameters. """ processed_directives, _ = process_federation_type_directives( directives, **federation_kwargs ) def wrapper(cls: _T) -> _T: # Add auto-generated resolve_reference if needed _maybe_add_resolve_reference(cls, processed_directives) return _process_type( cls, model, name=name, field_cls=field_cls, is_interface=True, description=description, directives=processed_directives, disable_optimization=disable_optimization, ) return wrapper strawberry-graphql-django-0.82.1/strawberry_django/fields/000077500000000000000000000000001516173410200236765ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/fields/__init__.py000066400000000000000000000000001516173410200257750ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/fields/base.py000066400000000000000000000212631516173410200251660ustar00rootroot00000000000000from __future__ import annotations import functools from typing import TYPE_CHECKING, Any, TypeVar, cast import django from django.db.models import ForeignKey from strawberry import relay from strawberry.annotation import StrawberryAnnotation from strawberry.types import get_object_definition from strawberry.types.auto import StrawberryAuto from strawberry.types.base import ( StrawberryContainer, StrawberryList, StrawberryOptional, StrawberryType, WithStrawberryObjectDefinition, ) from strawberry.types.field import UNRESOLVED, StrawberryField from strawberry.types.union import StrawberryUnion from strawberry.utils.inspect import get_specialized_type_var_map from strawberry_django.descriptors import ModelProperty from strawberry_django.resolvers import django_resolver from strawberry_django.utils.typing import ( WithStrawberryDjangoObjectDefinition, get_django_definition, has_django_definition, unwrap_type, ) if TYPE_CHECKING: from typing import Literal from django.db import models from strawberry.types import Info from strawberry.types.object_type import StrawberryObjectDefinition from typing_extensions import Self from strawberry_django.type import StrawberryDjangoDefinition _QS = TypeVar("_QS", bound="models.QuerySet") if django.VERSION >= (5, 0): from django.db.models import GeneratedField # type: ignore else: GeneratedField = None class StrawberryDjangoFieldBase(StrawberryField): def __init__( self, django_name: str | None = None, graphql_name: str | None = None, python_name: str | None = None, **kwargs, ): self.is_relation = False self.django_name = django_name self.origin_django_type: StrawberryDjangoDefinition[Any, Any] | None = None super().__init__(graphql_name=graphql_name, python_name=python_name, **kwargs) def __copy__(self) -> Self: new_field = super().__copy__() new_field.django_name = self.django_name new_field.is_relation = self.is_relation new_field.origin_django_type = self.origin_django_type return new_field @property def is_basic_field(self) -> bool: """Mark this field as not basic. All StrawberryDjango fields define a custom resolver that needs to be run, so always return False here. """ return False @functools.cached_property def is_async(self) -> bool: # Our default resolver is sync by default but will return a coroutine # when running ASGI. If we happen to have an extension that only supports # async, make sure we mark the field as async as well to support resolving # it properly. return super().is_async or any( e.supports_async and not e.supports_sync for e in self.extensions ) @functools.cached_property def django_type(self) -> type[WithStrawberryDjangoObjectDefinition] | None: from strawberry_django.pagination import OffsetPaginated origin = unwrap_type(self.type) object_definition = get_object_definition(origin) if object_definition and issubclass( object_definition.origin, (relay.Connection, OffsetPaginated) ): origin_specialized_type_var_map = ( get_specialized_type_var_map(cast("type", origin)) or {} ) origin = origin_specialized_type_var_map.get("NodeType") if origin is None: origin = object_definition.type_var_map.get("NodeType") if origin is None: specialized_type_var_map = ( object_definition.specialized_type_var_map or {} ) origin = specialized_type_var_map["NodeType"] origin = unwrap_type(origin) if isinstance(origin, StrawberryUnion): origin_list: list[type[WithStrawberryDjangoObjectDefinition]] = [] for t in origin.types: while isinstance(t, StrawberryContainer): t = t.of_type # noqa: PLW2901 if has_django_definition(t): origin_list.append(t) origin = origin_list[0] if len(origin_list) == 1 else None return origin if has_django_definition(origin) else None @functools.cached_property def django_model(self) -> type[models.Model] | None: django_type = self.django_type return ( django_type.__strawberry_django_definition__.model if django_type is not None else None ) @functools.cached_property def is_model_property(self) -> bool: django_definition = self.origin_django_type return django_definition is not None and isinstance( getattr(django_definition.model, self.python_name, None), ModelProperty ) @functools.cached_property def is_optional(self) -> bool: return isinstance(self.type, StrawberryOptional) @functools.cached_property def is_list(self) -> bool: type_ = self.type if isinstance(type_, StrawberryOptional): type_ = type_.of_type return isinstance(type_, StrawberryList) @functools.cached_property def is_paginated(self) -> bool: from strawberry_django.pagination import OffsetPaginated type_ = self.type if isinstance(type_, StrawberryOptional): type_ = type_.of_type return isinstance(type_, type) and issubclass(type_, OffsetPaginated) @functools.cached_property def is_connection(self) -> bool: type_ = self.type if isinstance(type_, StrawberryOptional): type_ = type_.of_type return isinstance(type_, type) and issubclass(type_, relay.Connection) @functools.cached_property def safe_resolver(self): resolver = self.base_resolver assert resolver if not resolver.is_async: resolver = django_resolver(resolver, qs_hook=None) return resolver def resolve_type( self, *, type_definition: StrawberryObjectDefinition | None = None, ) -> ( StrawberryType | type[WithStrawberryObjectDefinition] | Literal[UNRESOLVED] # type: ignore ): resolved = super().resolve_type(type_definition=type_definition) if resolved is UNRESOLVED: return resolved try: resolved_django_type = get_django_definition(unwrap_type(resolved)) except (KeyError, ImportError): return UNRESOLVED if self.origin_django_type and ( # FIXME: Why does this come as Any sometimes when using future annotations? resolved is Any or isinstance(resolved, StrawberryAuto) # If the resolved type is an input but the origin is not, or vice versa, # resolve this again or ( resolved_django_type and resolved_django_type.is_input != self.origin_django_type.is_input ) ): from .types import get_model_field, is_optional, resolve_model_field_type model_field = get_model_field( self.origin_django_type.model, self.django_name or self.python_name or self.name, ) resolved_type = resolve_model_field_type( ( model_field.target_field if ( self.python_name.endswith("_id") and isinstance(model_field, ForeignKey) ) else model_field ), self.origin_django_type, ) is_generated_field = GeneratedField is not None and isinstance( model_field, GeneratedField ) field_to_check = ( model_field.output_field if is_generated_field else model_field # type: ignore ) if is_optional( field_to_check, self.origin_django_type.is_input, self.origin_django_type.is_partial, ): resolved_type |= None self.type_annotation = StrawberryAnnotation(resolved_type) resolved = super().type if isinstance(resolved, StrawberryAuto): resolved = UNRESOLVED return resolved def resolver( self, source: Any, info: Info | None, args: list[Any], kwargs: dict[str, Any], ) -> Any: return self.safe_resolver(*args, **kwargs) def get_result(self, source, info, args, kwargs): return self.resolver(info, source, args, kwargs) def get_queryset(self, queryset: _QS, info: Info, **kwargs) -> _QS: return queryset strawberry-graphql-django-0.82.1/strawberry_django/fields/field.py000066400000000000000000001246011516173410200253370ustar00rootroot00000000000000from __future__ import annotations import dataclasses import inspect import warnings from collections.abc import ( AsyncIterable, AsyncIterator, Callable, Iterable, Iterator, Mapping, Sequence, ) from functools import cached_property from typing import ( TYPE_CHECKING, Any, TypeAlias, TypeVar, _AnnotatedAlias, # type: ignore cast, overload, ) from asgiref.sync import sync_to_async from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.db.models.fields.files import FileDescriptor from django.db.models.fields.related import ( ForwardManyToOneDescriptor, ReverseManyToOneDescriptor, ReverseOneToOneDescriptor, ) from django.db.models.manager import BaseManager from django.db.models.query import MAX_GET_RESULTS # type: ignore from django.db.models.query_utils import DeferredAttribute from strawberry import UNSET, relay from strawberry.annotation import StrawberryAnnotation from strawberry.extensions.field_extension import FieldExtension from strawberry.types.field import _RESOLVER_TYPE # noqa: PLC2701 from strawberry.types.fields.resolver import StrawberryResolver from strawberry.types.info import Info from strawberry.utils.await_maybe import await_maybe from strawberry_django import optimizer from strawberry_django.arguments import argument from strawberry_django.descriptors import ModelProperty from strawberry_django.fields.base import StrawberryDjangoFieldBase from strawberry_django.filters import FILTERS_ARG, StrawberryDjangoFieldFilters from strawberry_django.optimizer import OptimizerStore, is_optimized_by_prefetching from strawberry_django.ordering import ( ORDER_ARG, ORDERING_ARG, StrawberryDjangoFieldOrdering, ) from strawberry_django.pagination import ( PAGINATION_ARG, OffsetPaginated, OffsetPaginationInput, StrawberryDjangoPagination, ) from strawberry_django.permissions import filter_with_perms from strawberry_django.queryset import run_type_get_queryset from strawberry_django.relay import resolve_model_nodes from strawberry_django.resolvers import ( default_qs_hook, django_getattr, django_resolver, resolve_base_manager, ) if TYPE_CHECKING: from typing import Literal from graphql.pyutils import AwaitableOrValue from strawberry import BasePermission from strawberry.extensions.field_extension import SyncExtensionResolver from strawberry.relay.types import NodeIterableType from strawberry.types.arguments import StrawberryArgument from strawberry.types.base import WithStrawberryObjectDefinition from strawberry.types.field import StrawberryField from strawberry.types.unset import UnsetType from typing_extensions import Self from strawberry_django.utils.typing import ( AnnotateType, PrefetchType, TypeOrMapping, TypeOrSequence, ) _T = TypeVar("_T") class StrawberryDjangoField( StrawberryDjangoPagination, StrawberryDjangoFieldOrdering, StrawberryDjangoFieldFilters, StrawberryDjangoFieldBase, ): """Basic django field. StrawberryDjangoField inherits all features from StrawberryField and implements Django specific functionalities like ordering, filtering and pagination. This field takes care of that Django ORM is always accessed from sync context. Resolver function is wrapped in sync_to_async decorator in async context. See more information about that from Django documentation. https://docs.djangoproject.com/en/3.2/topics/async/ StrawberryDjangoField has following properties * django_name - django name which is used to access the field of model instance * is_relation - True if field is resolving django model relationship * origin_django_type - pointer to the origin of this field kwargs argument is passed to ordering, filtering, pagination and StrawberryField super classes. """ def __init__( self, *args, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, **kwargs, ): self.disable_optimization = disable_optimization self.store = OptimizerStore.with_hints( only=only, select_related=select_related, prefetch_related=prefetch_related, annotate=annotate, ) # FIXME: Probably remove this when depending on graphql-core 3.3.0+ self.disable_fetch_list_results: bool = False self._cached_arguments: list[StrawberryArgument] | None = None super().__init__(*args, **kwargs) @property def arguments(self) -> list[StrawberryArgument]: if self._cached_arguments is None: self._cached_arguments = super().arguments return self._cached_arguments @arguments.setter def arguments(self, value: list[StrawberryArgument]) -> None: self._cached_arguments = None args_prop = super(StrawberryDjangoField, self.__class__).arguments args_prop.fset(self, value) # type: ignore def __copy__(self) -> Self: new_field = super().__copy__() new_field.disable_optimization = self.disable_optimization new_field.store = self.store.copy() new_field._cached_arguments = None return new_field def _need_remove_argument( self, arg_name: str, *, requires_pagination: bool = False, ) -> bool: if not self.base_resolver: return False if requires_pagination: if not self.is_paginated: return False elif not (self.is_connection or self.is_paginated): return False return not any( p.name == arg_name or p.kind == p.VAR_KEYWORD for p in self.base_resolver.signature.parameters.values() ) @cached_property def _need_remove_filters_argument(self) -> bool: return self._need_remove_argument(FILTERS_ARG) @cached_property def _need_remove_order_argument(self) -> bool: return self._need_remove_argument(ORDER_ARG) @cached_property def _need_remove_ordering_argument(self) -> bool: return self._need_remove_argument(ORDERING_ARG) @cached_property def _need_remove_pagination_argument(self) -> bool: return self._need_remove_argument(PAGINATION_ARG, requires_pagination=True) def get_result( self, source: models.Model | None, info: Info | None, args: list[Any], kwargs: dict[str, Any], ) -> AwaitableOrValue[Any]: is_awaitable = False if self.base_resolver is not None: resolver_kwargs = kwargs.copy() for check, attr in [ (self._need_remove_order_argument, ORDER_ARG), (self._need_remove_ordering_argument, ORDERING_ARG), (self._need_remove_filters_argument, FILTERS_ARG), (self._need_remove_pagination_argument, PAGINATION_ARG), ]: if check: resolver_kwargs.pop(attr, None) assert info result = self.resolver(source, info, args, resolver_kwargs) is_awaitable = inspect.isawaitable(result) elif source is None: model = self.django_model assert model is not None result = model._default_manager.all() else: # Small optimization to async resolvers avoid having to call it in an # sync_to_async context if the value is already cached, since it will not # hit the db anymore attname = self.django_name or self.python_name attr = getattr(source.__class__, attname, None) try: if isinstance(attr, ModelProperty): result = source.__dict__[attr.name] elif isinstance(attr, DeferredAttribute): # If the value is cached, retrieve it with getattr because # some fields wrap values at that time (e.g. FileField). # If this next like fails, it will raise KeyError and get # us out of the loop before we can do getattr source.__dict__[attr.field.attname] result = getattr(source, attr.field.attname) elif isinstance(attr, ForwardManyToOneDescriptor): # This will raise KeyError if it is not cached result = attr.field.get_cached_value(source) # type: ignore elif isinstance(attr, ReverseOneToOneDescriptor): # This will raise KeyError if it is not cached result = attr.related.get_cached_value(source) elif isinstance(attr, ReverseManyToOneDescriptor): # This returns a queryset, it is async safe result = getattr(source, attname) else: raise KeyError # noqa: TRY301 except KeyError: if "info" not in kwargs: kwargs["info"] = info return django_getattr( source, attname, qs_hook=self.get_queryset_hook(**kwargs), # Reversed OneToOne will raise ObjectDoesNotExist when # trying to access it if the relation doesn't exist. except_as_none=(ObjectDoesNotExist,) if self.is_optional else None, empty_file_descriptor_as_null=True, ) else: # FileField/ImageField will always return a FileDescriptor, even when the # field is "null". If it is falsy (i.e. doesn't have a file) we should # return `None` instead. if isinstance(attr, FileDescriptor) and not result: result = None if is_awaitable or self.is_async: async def async_resolver(): resolved = await await_maybe(result) if isinstance(resolved, BaseManager): resolved = resolve_base_manager(resolved) if isinstance(resolved, models.QuerySet): if "info" not in kwargs: kwargs["info"] = info resolved = await sync_to_async(self.get_queryset_hook(**kwargs))( resolved ) return resolved return async_resolver() if isinstance(result, BaseManager): result = resolve_base_manager(result) if isinstance(result, models.QuerySet): if "info" not in kwargs: kwargs["info"] = info result = django_resolver( self.get_queryset_hook(**kwargs), qs_hook=lambda qs: qs, )(result) return result def get_queryset_hook(self, info: Info, **kwargs): if self.is_connection or self.is_paginated: # We don't want to fetch results yet, those will be done by the connection/pagination def qs_hook(qs: models.QuerySet): # type: ignore return self.get_queryset(qs, info, **kwargs) elif self.is_list: def qs_hook(qs: models.QuerySet): # type: ignore qs = self.get_queryset(qs, info, **kwargs) if not self.disable_fetch_list_results: qs = default_qs_hook(qs) return qs elif self.is_optional: def qs_hook(qs: models.QuerySet): # type: ignore qs = self.get_queryset(qs, info, **kwargs) return qs.first() else: def qs_hook(qs: models.QuerySet): qs = self.get_queryset(qs, info, **kwargs) # Don't use qs.get() if the queryset is optimized by prefetching. # Calling get in that case would disregard the prefetched results, because get implicitly # adds a limit to the query if (result_cache := qs._result_cache) is not None: # type: ignore model = qs.model assert model is not None # mimic behavior of get() # the queryset is already prefetched, no issue with just using len() qs_len = len(result_cache) if qs_len == 0: raise model.DoesNotExist( f"{model._meta.object_name} matching query does not exist." ) if qs_len != 1: raise model.MultipleObjectsReturned( f"get() returned more than one {model._meta.object_name} -- it returned " f"{qs_len if qs_len < MAX_GET_RESULTS else f'more than {qs_len - 1}'}!" ) return result_cache[0] return qs.get() return qs_hook def get_queryset(self, queryset, info, **kwargs): # If the queryset been optimized at prefetch phase, this function has already been # called by the optimizer extension, meaning we don't want to call it again if is_optimized_by_prefetching(queryset): return queryset queryset = run_type_get_queryset(queryset, self.django_type, info) queryset = super().get_queryset( filter_with_perms(queryset, info), info, **kwargs ) # If optimizer extension is enabled, optimize this queryset if ( not self.disable_optimization and (ext := optimizer.optimizer.get()) is not None ): queryset = ext.optimize(queryset, info=info) return queryset def _get_field_arguments_for_extensions( field: StrawberryDjangoField, *, add_filters: bool = True, add_order: bool = True, add_pagination: bool = True, ) -> list[StrawberryArgument]: """Get a list of arguments to be set to fields using extensions. Because we have a base_resolver defined in those, our parents will not add order/filters/pagination resolvers in here, so we need to add them by hand (unless they are somewhat in there). We are not adding pagination because it doesn't make sense together with a Connection """ args: dict[str, StrawberryArgument] = {a.python_name: a for a in field.arguments} if add_filters and FILTERS_ARG not in args: filters = field.get_filters() if filters not in (None, UNSET): # noqa: PLR6201 args[FILTERS_ARG] = argument(FILTERS_ARG, filters, is_optional=True) if add_order and ORDER_ARG not in args: order = field.get_order() if order not in (None, UNSET): # noqa: PLR6201 args[ORDER_ARG] = argument(ORDER_ARG, order, is_optional=True) if add_order and ORDERING_ARG not in args: ordering = field.get_ordering() if ordering not in (None, UNSET): # noqa: PLR6201 args[ORDERING_ARG] = argument( ORDERING_ARG, ordering, is_list=True, default=[] ) if add_pagination and PAGINATION_ARG not in args: pagination = field.get_pagination() if pagination not in (None, UNSET): # noqa: PLR6201 args[PAGINATION_ARG] = argument( PAGINATION_ARG, pagination, is_optional=True, ) return list(args.values()) class StrawberryDjangoConnectionExtension(relay.ConnectionExtension): def apply(self, field: StrawberryField) -> None: if not isinstance(field, StrawberryDjangoField): raise TypeError( "The extension can only be applied to StrawberryDjangoField" ) field.arguments = _get_field_arguments_for_extensions( field, add_pagination=False, ) if field.base_resolver is None: def default_resolver( root: models.Model | None, info: Info, **kwargs: Any, ) -> Iterable[Any]: assert isinstance(field, StrawberryDjangoField) django_type = field.django_type if root is not None: # If this is a nested field, call get_result instead because we want # to retrieve the queryset from its RelatedManager retval = cast( "models.QuerySet", getattr(root, field.django_name or field.python_name).all(), ) else: if django_type is None: raise TypeError( "Django connection without a resolver needs to define a" " connection for one and only one django type. To use" " it in a union, define your own resolver that handles" " each of those", ) retval = resolve_model_nodes( django_type, info=info, required=True, ) return cast("Iterable[Any]", retval) default_resolver.can_optimize = True # type: ignore field.base_resolver = StrawberryResolver(default_resolver) return super().apply(field) def resolve( self, next_: SyncExtensionResolver, source: Any, info: Info, *, before: str | None = None, after: str | None = None, first: int | None = None, last: int | None = None, **kwargs: Any, ) -> Any: assert self.connection_type is not None nodes = cast("Iterable[relay.Node]", next_(source, info, **kwargs)) # We have a single resolver for both sync and async, so we need to check if # nodes is awaitable or not and resolve it accordingly if inspect.isawaitable(nodes): async def async_resolver(): resolved = self.connection_type.resolve_connection( await nodes, info=info, before=before, after=after, first=first, last=last, max_results=self.max_results, **kwargs, ) if inspect.isawaitable(resolved): resolved = await resolved return resolved return async_resolver() return self.connection_type.resolve_connection( nodes, info=info, before=before, after=after, first=first, last=last, max_results=self.max_results, **kwargs, ) class StrawberryOffsetPaginatedExtension(FieldExtension): paginated_type: type[OffsetPaginated] def apply(self, field: StrawberryField) -> None: if not isinstance(field, StrawberryDjangoField): raise TypeError( "The extension can only be applied to StrawberryDjangoField" ) field.arguments = _get_field_arguments_for_extensions(field) self.paginated_type = cast("type[OffsetPaginated]", field.type) def resolve( self, next_: SyncExtensionResolver, source: Any, info: Info, *, pagination: OffsetPaginationInput | None = None, order: WithStrawberryObjectDefinition | None = None, filters: WithStrawberryObjectDefinition | None = None, **kwargs: Any, ) -> Any: assert self.paginated_type is not None forwarded_kwargs = dict(kwargs) if "pagination" not in forwarded_kwargs: forwarded_kwargs["pagination"] = pagination if "order" not in forwarded_kwargs: forwarded_kwargs["order"] = order if "filters" not in forwarded_kwargs: forwarded_kwargs["filters"] = filters queryset = cast( "models.QuerySet", next_( source, info, **forwarded_kwargs, ), ) def get_queryset(queryset): return cast("StrawberryDjangoField", info._field).get_queryset( queryset, info, pagination=pagination, order=order, filters=filters, ) # We have a single resolver for both sync and async, so we need to check if # nodes is awaitable or not and resolve it accordingly if inspect.isawaitable(queryset): async def async_resolver(queryset=queryset): resolved = self.paginated_type.resolve_paginated( get_queryset(await queryset), info=info, pagination=pagination, **kwargs, ) if inspect.isawaitable(resolved): resolved = await resolved return resolved return async_resolver() return self.paginated_type.resolve_paginated( get_queryset(queryset), info=info, pagination=pagination, **kwargs, ) @overload def field( *, field_cls: type[StrawberryDjangoField] = StrawberryDjangoField, resolver: _RESOLVER_TYPE[_T], name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, init: Literal[False] = False, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: Sequence[FieldExtension] = (), pagination: bool | UnsetType = UNSET, filters: _AnnotatedAlias | type | UnsetType | None = UNSET, order: _AnnotatedAlias | type | UnsetType | None = UNSET, ordering: _AnnotatedAlias | type | UnsetType | None = UNSET, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, ) -> _T: ... @overload def field( *, field_cls: type[StrawberryDjangoField] = StrawberryDjangoField, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, init: Literal[True] = True, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: Sequence[FieldExtension] = (), pagination: bool | UnsetType = UNSET, filters: _AnnotatedAlias | type | UnsetType | None = UNSET, order: _AnnotatedAlias | type | UnsetType | None = UNSET, ordering: _AnnotatedAlias | type | UnsetType | None = UNSET, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, ) -> Any: ... @overload def field( resolver: _RESOLVER_TYPE[Any], *, field_cls: type[StrawberryDjangoField] = StrawberryDjangoField, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: Sequence[FieldExtension] = (), pagination: bool | UnsetType = UNSET, filters: _AnnotatedAlias | type | UnsetType | None = UNSET, order: _AnnotatedAlias | type | UnsetType | None = UNSET, ordering: _AnnotatedAlias | type | UnsetType | None = UNSET, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, ) -> StrawberryDjangoField: ... def field( resolver: _RESOLVER_TYPE[Any] | None = None, *, field_cls: type[StrawberryDjangoField] = StrawberryDjangoField, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: Sequence[FieldExtension] = (), pagination: bool | UnsetType = UNSET, filters: _AnnotatedAlias | type | UnsetType | None = UNSET, order: _AnnotatedAlias | type | UnsetType | None = UNSET, ordering: _AnnotatedAlias | type | UnsetType | None = UNSET, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, # This init parameter is used by pyright to determine whether this field # is added in the constructor or not. It is not used to change # any behavior at the moment. init: bool | None = None, ) -> Any: """Annotate a method or property as a Django GraphQL field. Examples -------- It can be used both as decorator and as a normal function: >>> @strawberry.django.type >>> class X: ... field_abc: str = strawberry.django.field(description="ABC") ... @strawberry.django.field(description="ABC") ... ... def field_with_resolver(self) -> str: ... return "abc" """ f = field_cls( python_name=None, django_name=field_name, graphql_name=name, type_annotation=StrawberryAnnotation.from_annotation(graphql_type), description=description, is_subscription=is_subscription, permission_classes=permission_classes or [], deprecation_reason=deprecation_reason, default=default, default_factory=default_factory, metadata=metadata, directives=directives, filters=filters, pagination=pagination, order=order, ordering=ordering, extensions=extensions, only=only, select_related=select_related, prefetch_related=prefetch_related, annotate=annotate, disable_optimization=disable_optimization, ) if order: warnings.warn( "strawberry_django.order is deprecated in favor of strawberry_django.order_type.", DeprecationWarning, stacklevel=2, ) if resolver: return f(resolver) return f def node( *, field_cls: type[StrawberryDjangoField] = StrawberryDjangoField, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: Sequence[FieldExtension] = (), only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, # This init parameter is used by pyright to determine whether this field # is added in the constructor or not. It is not used to change # any behavior at the moment. init: bool | None = None, ) -> Any: """Annotate a property to create a relay query field. Examples -------- Annotating something like this: >>> @strawberry.type >>> class X: ... some_node: SomeType = relay.node(description="ABC") Will produce a query like this that returns `SomeType` given its id. ``` query { someNode (id: ID) { id ... } } ``` """ extensions = [*extensions, relay.NodeExtension()] return field_cls( python_name=None, django_name=field_name, graphql_name=name, type_annotation=StrawberryAnnotation.from_annotation(graphql_type), description=description, is_subscription=is_subscription, permission_classes=permission_classes or [], deprecation_reason=deprecation_reason, default=default, default_factory=default_factory, metadata=metadata, directives=directives or (), extensions=extensions, ) @overload def connection( graphql_type: type[relay.Connection[relay.NodeType]] | None = None, *, field_cls: type[StrawberryDjangoField] = StrawberryDjangoField, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), extensions: Sequence[FieldExtension] = (), max_results: int | None = None, filters: _AnnotatedAlias | type | UnsetType | None = UNSET, order: _AnnotatedAlias | type | UnsetType | None = UNSET, ordering: _AnnotatedAlias | type | UnsetType | None = UNSET, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, ) -> Any: ... @overload def connection( graphql_type: type[relay.Connection[relay.NodeType]] | None = None, *, field_cls: type[StrawberryDjangoField] = StrawberryDjangoField, resolver: _RESOLVER_TYPE[NodeIterableType[Any]] | None = None, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, init: Literal[True] = True, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), extensions: Sequence[FieldExtension] = (), max_results: int | None = None, filters: _AnnotatedAlias | type | UnsetType | None = UNSET, order: _AnnotatedAlias | type | UnsetType | None = UNSET, ordering: _AnnotatedAlias | type | UnsetType | None = UNSET, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, ) -> Any: ... def connection( graphql_type: type[relay.Connection[relay.NodeType]] | None = None, *, field_cls: type[StrawberryDjangoField] = StrawberryDjangoField, resolver: _RESOLVER_TYPE[NodeIterableType[Any]] | None = None, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), extensions: Sequence[FieldExtension] = (), max_results: int | None = None, filters: _AnnotatedAlias | type | UnsetType | None = UNSET, order: _AnnotatedAlias | type | UnsetType | None = UNSET, ordering: _AnnotatedAlias | type | UnsetType | None = UNSET, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, # This init parameter is used by pyright to determine whether this field # is added in the constructor or not. It is not used to change # any behavior at the moment. init: bool | None = None, ) -> Any: """Annotate a property or a method to create a relay connection field. Relay connections_ are mostly used for pagination purposes. This decorator helps creating a complete relay endpoint that provides default arguments and has a default implementation for the connection slicing. Note that when setting a resolver to this field, it is expected for this resolver to return an iterable of the expected node type, not the connection itself. That iterable will then be paginated accordingly. So, the main use case for this is to provide a filtered iterable of nodes by using some custom filter arguments. Examples -------- Annotating something like this: >>> @strawberry.type >>> class X: ... some_node: relay.Connection[SomeType] = relay.connection( ... description="ABC", ... ) ... ... @relay.connection(description="ABC") ... def get_some_nodes(self, age: int) -> Iterable[SomeType]: ... ... Will produce a query like this: ``` query { someNode ( before: String after: String first: String after: String age: Int ) { totalCount pageInfo { hasNextPage hasPreviousPage startCursor endCursor } edges { cursor node { id ... } } } } ``` .. _Relay connections: https://relay.dev/graphql/connections.htm """ extensions = [ *extensions, StrawberryDjangoConnectionExtension(max_results=max_results), ] f = field_cls( python_name=None, django_name=field_name, graphql_name=name, type_annotation=StrawberryAnnotation.from_annotation(graphql_type), description=description, is_subscription=is_subscription, permission_classes=permission_classes or [], deprecation_reason=deprecation_reason, default=default, default_factory=default_factory, metadata=metadata, directives=directives or (), filters=filters, order=order, ordering=ordering, extensions=extensions, only=only, select_related=select_related, prefetch_related=prefetch_related, annotate=annotate, disable_optimization=disable_optimization, ) if resolver: f = f(resolver) return f _OFFSET_PAGINATED_RESOLVER_TYPE: TypeAlias = _RESOLVER_TYPE[ Iterator[models.Model] | Iterable[models.Model] | AsyncIterator[models.Model] | AsyncIterable[models.Model] ] @overload def offset_paginated( graphql_type: type[OffsetPaginated] | None = None, *, field_cls: type[StrawberryDjangoField] = StrawberryDjangoField, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), extensions: Sequence[FieldExtension] = (), filters: _AnnotatedAlias | type | UnsetType | None = UNSET, order: _AnnotatedAlias | type | UnsetType | None = UNSET, ordering: _AnnotatedAlias | type | UnsetType | None = UNSET, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, ) -> Any: ... @overload def offset_paginated( graphql_type: type[OffsetPaginated] | None = None, *, field_cls: type[StrawberryDjangoField] = StrawberryDjangoField, resolver: _OFFSET_PAGINATED_RESOLVER_TYPE | None = None, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, init: Literal[True] = True, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), extensions: Sequence[FieldExtension] = (), filters: _AnnotatedAlias | type | UnsetType | None = UNSET, order: _AnnotatedAlias | type | UnsetType | None = UNSET, ordering: _AnnotatedAlias | type | UnsetType | None = UNSET, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, ) -> Any: ... def offset_paginated( graphql_type: type[OffsetPaginated] | None = None, *, field_cls: type[StrawberryDjangoField] = StrawberryDjangoField, resolver: _OFFSET_PAGINATED_RESOLVER_TYPE | None = None, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), extensions: Sequence[FieldExtension] = (), filters: _AnnotatedAlias | type | UnsetType | None = UNSET, order: _AnnotatedAlias | type | UnsetType | None = UNSET, ordering: _AnnotatedAlias | type | UnsetType | None = UNSET, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, # This init parameter is used by pyright to determine whether this field # is added in the constructor or not. It is not used to change # any behavior at the moment. init: bool | None = None, ) -> Any: """Annotate a property or a method to create a relay connection field. Relay connections_ are mostly used for pagination purposes. This decorator helps creating a complete relay endpoint that provides default arguments and has a default implementation for the connection slicing. Note that when setting a resolver to this field, it is expected for this resolver to return an iterable of the expected node type, not the connection itself. That iterable will then be paginated accordingly. So, the main use case for this is to provide a filtered iterable of nodes by using some custom filter arguments. Examples -------- Annotating something like this: >>> @strawberry.type >>> class X: ... some_node: relay.Connection[SomeType] = relay.connection( ... description="ABC", ... ) ... ... @relay.connection(description="ABC") ... def get_some_nodes(self, age: int) -> Iterable[SomeType]: ... ... Will produce a query like this: ``` query { someNode ( before: String after: String first: String after: String age: Int ) { totalCount pageInfo { hasNextPage hasPreviousPage startCursor endCursor } edges { cursor node { id ... } } } } ``` .. _Relay connections: https://relay.dev/graphql/connections.htm """ extensions = [*extensions, StrawberryOffsetPaginatedExtension()] f = field_cls( python_name=None, django_name=field_name, graphql_name=name, type_annotation=StrawberryAnnotation.from_annotation(graphql_type), description=description, is_subscription=is_subscription, permission_classes=permission_classes or [], deprecation_reason=deprecation_reason, default=default, default_factory=default_factory, metadata=metadata, directives=directives or (), filters=filters, order=order, ordering=ordering, extensions=extensions, only=only, select_related=select_related, prefetch_related=prefetch_related, annotate=annotate, disable_optimization=disable_optimization, ) if resolver: f = f(resolver) return f strawberry-graphql-django-0.82.1/strawberry_django/fields/filter_order.py000066400000000000000000000314231516173410200267330ustar00rootroot00000000000000from __future__ import annotations import dataclasses import inspect from functools import cached_property from typing import TYPE_CHECKING, Any, Final, Literal, Optional, overload from strawberry import UNSET from strawberry.annotation import StrawberryAnnotation from strawberry.exceptions import MissingArgumentsAnnotationsError from strawberry.types.field import StrawberryField from strawberry.types.fields.resolver import ReservedName, StrawberryResolver from typing_extensions import Self from strawberry_django.exceptions import ( ForbiddenFieldArgumentError, MissingFieldArgumentError, ) from strawberry_django.utils.typing import is_auto if TYPE_CHECKING: from collections.abc import Callable, MutableMapping, Sequence from strawberry.extensions.field_extension import FieldExtension from strawberry.types import Info from strawberry.types.field import _RESOLVER_TYPE, T QUERYSET_PARAMSPEC = ReservedName("queryset") PREFIX_PARAMSPEC = ReservedName("prefix") SEQUENCE_PARAMSPEC = ReservedName("sequence") VALUE_PARAM = ReservedName("value") OBJECT_FILTER_NAME: Final[str] = "filter" OBJECT_ORDER_NAME: Final[str] = "order" WITH_NONE_META: Final[str] = "WITH_NONE_META" RESOLVE_VALUE_META: Final[str] = "RESOLVE_VALUE_META" SKIP_FILTER_META: Final[str] = "SKIP_QUERYSET_FILTER_META" class FilterOrderFieldResolver(StrawberryResolver): RESERVED_PARAMSPEC = ( *StrawberryResolver.RESERVED_PARAMSPEC, QUERYSET_PARAMSPEC, PREFIX_PARAMSPEC, SEQUENCE_PARAMSPEC, VALUE_PARAM, ) def __init__(self, *args, resolver_type: Literal["filter", "order"], **kwargs): super().__init__(*args, **kwargs) self._resolver_type = resolver_type def validate_filter_arguments(self): is_object_filter = self.name == OBJECT_FILTER_NAME is_object_order = self.name == OBJECT_ORDER_NAME if not self.reserved_parameters[PREFIX_PARAMSPEC]: raise MissingFieldArgumentError(PREFIX_PARAMSPEC.name, self) if (is_object_filter or is_object_order) and not self.reserved_parameters[ QUERYSET_PARAMSPEC ]: raise MissingFieldArgumentError(QUERYSET_PARAMSPEC.name, self) if ( self._resolver_type != OBJECT_ORDER_NAME and self.reserved_parameters[SEQUENCE_PARAMSPEC] ): raise ForbiddenFieldArgumentError(self, [SEQUENCE_PARAMSPEC.name]) value_param = self.reserved_parameters[VALUE_PARAM] if value_param: if is_object_filter or is_object_order: raise ForbiddenFieldArgumentError(self, [VALUE_PARAM.name]) annotation = self.strawberry_annotations[value_param] if annotation is None: raise MissingArgumentsAnnotationsError(self, [VALUE_PARAM.name]) elif not is_object_filter and not is_object_order: raise MissingFieldArgumentError(VALUE_PARAM.name, self) parameters = self.signature.parameters.values() reserved_parameters = set(self.reserved_parameters.values()) exta_params = [p for p in parameters if p not in reserved_parameters] if exta_params: raise ForbiddenFieldArgumentError(self, [p.name for p in exta_params]) @cached_property def type_annotation(self) -> StrawberryAnnotation | None: param = self.reserved_parameters[VALUE_PARAM] if param and param is not inspect.Signature.empty: annotation = param.annotation if is_auto(annotation) and self._resolver_type == OBJECT_ORDER_NAME: from strawberry_django import ordering annotation = ordering.Ordering return StrawberryAnnotation(Optional[annotation]) # noqa: UP045 return None def __call__( # type: ignore self, source: Any, info: Info | None, queryset=None, sequence=None, **kwargs: Any, ) -> Any: args = [] if self.self_parameter: args.append(source) if parent_parameter := self.parent_parameter: kwargs[parent_parameter.name] = source if root_parameter := self.root_parameter: kwargs[root_parameter.name] = source if info_parameter := self.info_parameter: assert info is not None kwargs[info_parameter.name] = info if info_parameter := self.reserved_parameters.get(QUERYSET_PARAMSPEC): assert queryset is not None kwargs[info_parameter.name] = queryset if info_parameter := self.reserved_parameters.get(SEQUENCE_PARAMSPEC): assert sequence is not None kwargs[info_parameter.name] = sequence return super().__call__(*args, **kwargs) class FilterOrderField(StrawberryField): base_resolver: FilterOrderFieldResolver | None # type: ignore def __call__(self, resolver: _RESOLVER_TYPE) -> Self | FilterOrderFieldResolver: # type: ignore if not isinstance(resolver, StrawberryResolver): resolver = FilterOrderFieldResolver( resolver, resolver_type=self.metadata["_FIELD_TYPE"] ) elif not isinstance(resolver, FilterOrderFieldResolver): raise TypeError( 'Expected resolver to be instance of "FilterOrderFieldResolver", ' f'found "{type(resolver)}"' ) super().__call__(resolver) self._arguments = [] resolver.validate_filter_arguments() if resolver.name in {OBJECT_FILTER_NAME, OBJECT_ORDER_NAME}: # For object filter we return resolver return resolver self.init = self.compare = self.repr = True return self @overload def filter_field( *, resolver: _RESOLVER_TYPE[T], name: str | None = None, is_subscription: bool = False, description: str | None = None, init: Literal[False] = False, deprecation_reason: str | None = None, default: Any = UNSET, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: MutableMapping[Any, Any] | None = None, directives: Sequence[object] = (), extensions: list[FieldExtension] | None = None, filter_none: bool = False, resolve_value: bool = UNSET, skip_queryset_filter: bool = False, ) -> T: ... @overload def filter_field( *, name: str | None = None, is_subscription: bool = False, description: str | None = None, init: Literal[True] = True, deprecation_reason: str | None = None, default: Any = UNSET, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: MutableMapping[Any, Any] | None = None, directives: Sequence[object] = (), extensions: list[FieldExtension] | None = None, filter_none: bool = False, resolve_value: bool = UNSET, skip_queryset_filter: bool = False, ) -> Any: ... @overload def filter_field( resolver: _RESOLVER_TYPE[T], *, name: str | None = None, is_subscription: bool = False, description: str | None = None, deprecation_reason: str | None = None, default: Any = UNSET, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: MutableMapping[Any, Any] | None = None, directives: Sequence[object] = (), extensions: list[FieldExtension] | None = None, filter_none: bool = False, resolve_value: bool = UNSET, skip_queryset_filter: bool = False, ) -> StrawberryField: ... def filter_field( resolver: _RESOLVER_TYPE[Any] | None = None, *, name: str | None = None, is_subscription: bool = False, description: str | None = None, deprecation_reason: str | None = None, default: Any = UNSET, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: MutableMapping[Any, Any] | None = None, directives: Sequence[object] = (), extensions: list[FieldExtension] | None = None, filter_none: bool = False, resolve_value: bool = UNSET, skip_queryset_filter: bool = False, # This init parameter is used by pyright to determine whether this field # is added in the constructor or not. It is not used to change # any behavior at the moment. init: bool | None = None, ) -> Any: """Annotates a method or property as a Django filter field. If using with method, these parameters are required: queryset, value, prefix Additionaly value has to be annotated with type of filter This is normally used inside a type declaration: >>> @strawberry_django.filter_type(SomeModel) >>> class X: >>> field_abc: strawberry.auto = strawberry_django.filter_field() >>> @strawberry.filter_field(description="ABC") >>> def field_with_resolver(self, queryset, info, value: str, prefix): >>> return it can be used both as decorator and as a normal function. """ metadata = metadata or {} metadata["_FIELD_TYPE"] = OBJECT_FILTER_NAME metadata[RESOLVE_VALUE_META] = resolve_value metadata[WITH_NONE_META] = filter_none metadata[SKIP_FILTER_META] = skip_queryset_filter field_ = FilterOrderField( python_name=None, graphql_name=name, is_subscription=is_subscription, description=description, deprecation_reason=deprecation_reason, default=default, default_factory=default_factory, metadata=metadata, directives=directives, extensions=extensions or [], ) if resolver: return field_(resolver) return field_ @overload def order_field( *, resolver: _RESOLVER_TYPE[T], name: str | None = None, is_subscription: bool = False, description: str | None = None, init: Literal[False] = False, deprecation_reason: str | None = None, default: Any = UNSET, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: MutableMapping[Any, Any] | None = None, directives: Sequence[object] = (), extensions: list[FieldExtension] | None = None, order_none: bool = False, ) -> T: ... @overload def order_field( *, name: str | None = None, is_subscription: bool = False, description: str | None = None, init: Literal[True] = True, deprecation_reason: str | None = None, default: Any = UNSET, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: MutableMapping[Any, Any] | None = None, directives: Sequence[object] = (), extensions: list[FieldExtension] | None = None, order_none: bool = False, ) -> Any: ... @overload def order_field( resolver: _RESOLVER_TYPE[T], *, name: str | None = None, is_subscription: bool = False, description: str | None = None, deprecation_reason: str | None = None, default: Any = UNSET, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: MutableMapping[Any, Any] | None = None, directives: Sequence[object] = (), extensions: list[FieldExtension] | None = None, order_none: bool = False, ) -> StrawberryField: ... def order_field( resolver: _RESOLVER_TYPE[Any] | None = None, *, name: str | None = None, is_subscription: bool = False, description: str | None = None, deprecation_reason: str | None = None, default: Any = UNSET, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: MutableMapping[Any, Any] | None = None, directives: Sequence[object] = (), extensions: list[FieldExtension] | None = None, order_none: bool = False, # This init parameter is used by pyright to determine whether this field # is added in the constructor or not. It is not used to change # any behavior at the moment. init: bool | None = None, ) -> Any: """Annotates a method or property as a Django filter field. If using with method, these parameters are required: queryset, value, prefix Additionaly value has to be annotated with type of filter This is normally used inside a type declaration: >>> @strawberry_django.order(SomeModel) >>> class X: >>> field_abc: strawberry.auto = strawberry_django.order_field() >>> @strawberry.order_field(description="ABC") >>> def field_with_resolver(self, queryset, info, value: str, prefix): >>> return it can be used both as decorator and as a normal function. """ metadata = metadata or {} metadata["_FIELD_TYPE"] = OBJECT_ORDER_NAME metadata[WITH_NONE_META] = order_none field_ = FilterOrderField( python_name=None, graphql_name=name, is_subscription=is_subscription, description=description, deprecation_reason=deprecation_reason, default=default, default_factory=default_factory, metadata=metadata, directives=directives, extensions=extensions or [], ) if resolver: return field_(resolver) return field_ strawberry-graphql-django-0.82.1/strawberry_django/fields/filter_types.py000066400000000000000000000125451516173410200267700ustar00rootroot00000000000000import datetime import decimal import uuid import warnings from typing import ( Any, Generic, TypeVar, ) import strawberry from django.db.models import Q from strawberry import UNSET from strawberry_django.filters import resolve_value from .filter_order import filter_field T = TypeVar("T") _SKIP_MSG = "Filter will be skipped on `null` value" @strawberry.input class BaseFilterLookup(Generic[T]): exact: T | None = filter_field(description=f"Exact match. {_SKIP_MSG}") is_null: bool | None = filter_field(description=f"Assignment test. {_SKIP_MSG}") in_list: list[T] | None = filter_field( description=f"Exact match of items in a given list. {_SKIP_MSG}" ) @strawberry.input class RangeLookup(Generic[T]): start: T | None = None end: T | None = None @filter_field def filter(self, queryset, prefix: str): return queryset, Q(**{ prefix[:-2]: [resolve_value(self.start), resolve_value(self.end)] }) @strawberry.input class ComparisonFilterLookup(BaseFilterLookup[T]): gt: T | None = filter_field(description=f"Greater than. {_SKIP_MSG}") gte: T | None = filter_field(description=f"Greater than or equal to. {_SKIP_MSG}") lt: T | None = filter_field(description=f"Less than. {_SKIP_MSG}") lte: T | None = filter_field(description=f"Less than or equal to. {_SKIP_MSG}") range: RangeLookup[T] | None = filter_field( description="Inclusive range test (between)" ) @strawberry.input class FilterLookup(BaseFilterLookup[T]): i_exact: T | None = filter_field( description=f"Case-insensitive exact match. {_SKIP_MSG}" ) contains: T | None = filter_field( description=f"Case-sensitive containment test. {_SKIP_MSG}" ) i_contains: T | None = filter_field( description=f"Case-insensitive containment test. {_SKIP_MSG}" ) starts_with: T | None = filter_field( description=f"Case-sensitive starts-with. {_SKIP_MSG}" ) i_starts_with: T | None = filter_field( description=f"Case-insensitive starts-with. {_SKIP_MSG}" ) ends_with: T | None = filter_field( description=f"Case-sensitive ends-with. {_SKIP_MSG}" ) i_ends_with: T | None = filter_field( description=f"Case-insensitive ends-with. {_SKIP_MSG}" ) regex: T | None = filter_field( description=f"Case-sensitive regular expression match. {_SKIP_MSG}" ) i_regex: T | None = filter_field( description=f"Case-insensitive regular expression match. {_SKIP_MSG}" ) def __class_getitem__(cls, item: Any) -> Any: if item is str or item is uuid.UUID: warnings.warn( f"FilterLookup[{item.__name__}] may cause DuplicatedTypeName errors. " "Use StrFilterLookup instead.", UserWarning, stacklevel=2, ) return super().__class_getitem__(item) # type: ignore[misc] @strawberry.input class StrFilterLookup(BaseFilterLookup[str]): i_exact: str | None = filter_field( description=f"Case-insensitive exact match. {_SKIP_MSG}" ) contains: str | None = filter_field( description=f"Case-sensitive containment test. {_SKIP_MSG}" ) i_contains: str | None = filter_field( description=f"Case-insensitive containment test. {_SKIP_MSG}" ) starts_with: str | None = filter_field( description=f"Case-sensitive starts-with. {_SKIP_MSG}" ) i_starts_with: str | None = filter_field( description=f"Case-insensitive starts-with. {_SKIP_MSG}" ) ends_with: str | None = filter_field( description=f"Case-sensitive ends-with. {_SKIP_MSG}" ) i_ends_with: str | None = filter_field( description=f"Case-insensitive ends-with. {_SKIP_MSG}" ) regex: str | None = filter_field( description=f"Case-sensitive regular expression match. {_SKIP_MSG}" ) i_regex: str | None = filter_field( description=f"Case-insensitive regular expression match. {_SKIP_MSG}" ) def __class_getitem__(cls, item: Any) -> type: return cls @strawberry.input class DateFilterLookup(ComparisonFilterLookup[T]): year: ComparisonFilterLookup[int] | None = UNSET month: ComparisonFilterLookup[int] | None = UNSET day: ComparisonFilterLookup[int] | None = UNSET week_day: ComparisonFilterLookup[int] | None = UNSET iso_week_day: ComparisonFilterLookup[int] | None = UNSET week: ComparisonFilterLookup[int] | None = UNSET iso_year: ComparisonFilterLookup[int] | None = UNSET quarter: ComparisonFilterLookup[int] | None = UNSET @strawberry.input class TimeFilterLookup(ComparisonFilterLookup[T]): hour: ComparisonFilterLookup[int] | None = UNSET minute: ComparisonFilterLookup[int] | None = UNSET second: ComparisonFilterLookup[int] | None = UNSET date: ComparisonFilterLookup[int] | None = UNSET time: ComparisonFilterLookup[int] | None = UNSET @strawberry.input class DatetimeFilterLookup(DateFilterLookup[T], TimeFilterLookup[T]): pass type_filter_map = { strawberry.ID: BaseFilterLookup, bool: BaseFilterLookup, datetime.date: DateFilterLookup, datetime.datetime: DatetimeFilterLookup, datetime.time: TimeFilterLookup, decimal.Decimal: ComparisonFilterLookup, float: ComparisonFilterLookup, int: ComparisonFilterLookup, str: StrFilterLookup, uuid.UUID: StrFilterLookup, } strawberry-graphql-django-0.82.1/strawberry_django/fields/types.py000066400000000000000000000502351516173410200254210ustar00rootroot00000000000000import datetime import decimal import enum import inspect import re import uuid from types import FunctionType from typing import ( TYPE_CHECKING, Any, Generic, TypeVar, cast, ) import django import strawberry from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured from django.db.models import Field, Model, fields from django.db.models.fields import files, json, related, reverse_related from strawberry import UNSET, relay from strawberry.file_uploads.scalars import Upload from strawberry.scalars import JSON from strawberry.schema.types.scalar import DEFAULT_SCALAR_REGISTRY from strawberry.types.enum import EnumValueDefinition from strawberry.utils.str_converters import capitalize_first, to_camel_case from strawberry_django import filters from strawberry_django.fields import filter_types from strawberry_django.settings import strawberry_django_settings as django_settings try: from django_choices_field import IntegerChoicesField, TextChoicesField except ImportError: # pragma: no cover IntegerChoicesField = None TextChoicesField = None 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 = None if django.VERSION >= (5, 0): from django.db.models import GeneratedField # type: ignore else: GeneratedField = None if TYPE_CHECKING: from collections.abc import Iterable from strawberry_django.type import StrawberryDjangoDefinition K = TypeVar("K") @strawberry.type class DjangoFileType: name: str path: str size: int url: str @strawberry.type class DjangoImageType(DjangoFileType): width: int height: int @strawberry.type class DjangoModelType: pk: strawberry.ID @strawberry.input class OneToOneInput: set: strawberry.ID | None @strawberry.input class OneToManyInput: set: strawberry.ID | None @strawberry.input class ManyToOneInput: add: list[strawberry.ID] | None = UNSET remove: list[strawberry.ID] | None = UNSET set: list[strawberry.ID] | None = UNSET @strawberry.input class ManyToManyInput: add: list[strawberry.ID] | None = UNSET remove: list[strawberry.ID] | None = UNSET set: list[strawberry.ID] | None = UNSET @strawberry.input( description="Input of an object that implements the `Node` interface.", ) class NodeInput: id: relay.GlobalID def __eq__(self, other: object): if not isinstance(other, NodeInput): return NotImplemented return self.id == other.id def __hash__(self): return hash((self.__class__, self.id)) @strawberry.input( description="Input of an object that implements the `Node` interface.", ) class NodeInputPartial(NodeInput): # FIXME: Without this pyright will not let any class inherit from this and define # a field that doesn't contain a default value... if TYPE_CHECKING: id: relay.GlobalID | None # type: ignore else: id: relay.GlobalID | None = UNSET @strawberry.input(description="Add/remove/set the selected nodes.") class ListInput(Generic[K]): """Add/remove/set the selected nodes. Notes ----- To pass data to an intermediate model, type the input in a `throught_defaults` key inside the input object. """ # FIXME: Without this pyright will not let any class inheric from this and define # a field that doesn't contain a default value... if TYPE_CHECKING: set: list[K] | None add: list[K] | None remove: list[K] | None else: set: list[K] | None = UNSET add: list[K] | None = UNSET remove: list[K] | None = UNSET def __eq__(self, other: object): if not isinstance(other, ListInput): return NotImplemented return self._hash_fields() == other._hash_fields() def __hash__(self): return hash((self.__class__, *self._hash_fields())) def _hash_fields(self): return ( tuple(self.set) if isinstance(self.set, list) else self.set, tuple(self.add) if isinstance(self.add, list) else self.add, tuple(self.remove) if isinstance(self.remove, list) else self.remove, ) @strawberry.type class OperationMessage: """An error that happened while executing an operation.""" @strawberry.enum(name="OperationMessageKind") class Kind(enum.Enum): """The kind of the returned message.""" INFO = "info" WARNING = "warning" ERROR = "error" PERMISSION = "permission" VALIDATION = "validation" kind: Kind = strawberry.field(description="The kind of this message.") message: str = strawberry.field(description="The error message.") field: str | None = strawberry.field( description=( "The field that caused the error, or `null` if it " "isn't associated with any particular field." ), default=None, ) code: str | None = strawberry.field( description="The error code, or `null` if no error code was set.", default=None, ) def __eq__(self, other: object): if not isinstance(other, OperationMessage): return NotImplemented return ( self.kind == other.kind and self.message == other.message and self.field == other.field and self.code == other.code ) def __hash__(self): return hash((self.__class__, self.kind, self.message, self.field, self.code)) @strawberry.type class OperationInfo: """Multiple messages returned by an operation.""" messages: list[OperationMessage] = strawberry.field( description="List of messages returned by the operation.", ) def __eq__(self, other: object): if not isinstance(other, OperationInfo): return NotImplemented return self.messages == other.messages def __hash__(self): return hash((self.__class__, *tuple(self.messages))) field_type_map: dict[ type[fields.Field] | type[related.RelatedField] | type[reverse_related.ForeignObjectRel], type | FunctionType, ] = { fields.AutoField: strawberry.ID, fields.BigAutoField: strawberry.ID, fields.BigIntegerField: int, fields.BooleanField: bool, fields.CharField: str, fields.DateField: datetime.date, fields.DateTimeField: datetime.datetime, fields.DecimalField: decimal.Decimal, fields.EmailField: str, fields.FilePathField: str, fields.FloatField: float, fields.GenericIPAddressField: str, fields.IntegerField: int, fields.PositiveIntegerField: int, fields.PositiveSmallIntegerField: int, fields.PositiveBigIntegerField: int, fields.SlugField: str, fields.SmallAutoField: strawberry.ID, fields.SmallIntegerField: int, fields.TextField: str, fields.TimeField: datetime.time, fields.URLField: str, fields.UUIDField: uuid.UUID, json.JSONField: JSON, files.FileField: DjangoFileType, files.ImageField: DjangoImageType, related.ForeignKey: DjangoModelType, related.ManyToManyField: list[DjangoModelType], related.OneToOneField: DjangoModelType, reverse_related.ManyToManyRel: list[DjangoModelType], reverse_related.ManyToOneRel: list[DjangoModelType], reverse_related.OneToOneRel: DjangoModelType, } try: from django.contrib.gis import geos from django.contrib.gis.db import models as geos_fields except ImproperlyConfigured: # If gdal is not available, skip. Point = None LineString = None LinearRing = None Polygon = None MultiPoint = None MultilineString = None MultiPolygon = None Geometry = None else: # Alias the geos types for backwards compatibility Point = geos.Point LineString = geos.LineString LinearRing = geos.LinearRing Polygon = geos.Polygon MultiPoint = geos.MultiPoint MultiLineString = geos.MultiLineString MultiPolygon = geos.MultiPolygon Geometry = geos.GEOSGeometry DEFAULT_SCALAR_REGISTRY.update({ geos.Point: strawberry.scalar( name="Point", serialize=lambda v: v.tuple if isinstance(v, geos.Point) else v, parse_value=geos.Point, description="Represents a point as `(x, y, z)` or `(x, y)`.", ), geos.LineString: strawberry.scalar( name="LineString", serialize=lambda v: v.tuple if isinstance(v, geos.LineString) else v, parse_value=geos.LineString, description=( "A geographical line that gets multiple 'x, y' or 'x, y, z'" " tuples to form a line." ), ), geos.LinearRing: strawberry.scalar( name="LinearRing", serialize=lambda v: v.tuple if isinstance(v, geos.LinearRing) else v, parse_value=geos.LinearRing, description=( "A geographical line that gets multiple 'x, y' or 'x, y, z' " "tuples to form a line. It must be a circle. " "E.g. It maps back to itself." ), ), geos.Polygon: strawberry.scalar( name="Polygon", serialize=lambda v: v.tuple if isinstance(v, geos.Polygon) else v, parse_value=lambda v: geos.Polygon(*[geos.LinearRing(x) for x in v]), description=( "A geographical object that gets 1 or 2 LinearRing objects" " as external and internal rings." ), ), geos.MultiPoint: strawberry.scalar( name="MultiPoint", serialize=lambda v: v.tuple if isinstance(v, geos.MultiPoint) else v, parse_value=lambda v: geos.MultiPoint(*[geos.Point(x) for x in v]), description="A geographical object that contains multiple Points.", ), geos.MultiLineString: strawberry.scalar( name="MultiLineString", serialize=lambda v: v.tuple if isinstance(v, geos.MultiLineString) else v, parse_value=lambda v: geos.MultiLineString(*[ geos.LineString(x) for x in v ]), description="A geographical object that contains multiple line strings.", ), geos.MultiPolygon: strawberry.scalar( name="MultiPolygon", serialize=lambda v: v.tuple if isinstance(v, geos.MultiPolygon) else v, parse_value=lambda v: geos.MultiPolygon( *[geos.Polygon(*list(x)) for x in v], ), description="A geographical object that contains multiple polygons.", ), geos.GEOSGeometry: strawberry.scalar( name="Geometry", serialize=lambda v: v.tuple if isinstance(v, geos.GEOSGeometry) else v, # type: ignore[attr-defined] parse_value=geos.GEOSGeometry, description=( "An arbitrary geographical object. One of Point, " "LineString, LinearRing, Polygon, MultiPoint, " "MultiLineString, MultiPolygon." ), ), }) field_type_map.update( { geos_fields.PointField: Point, geos_fields.LineStringField: LineString, geos_fields.PolygonField: Polygon, geos_fields.MultiPointField: MultiPoint, geos_fields.MultiLineStringField: MultiLineString, geos_fields.MultiPolygonField: MultiPolygon, geos_fields.GeometryField: Geometry, }, ) input_field_type_map: dict[ type[fields.Field] | type[related.RelatedField] | type[reverse_related.ForeignObjectRel], type | FunctionType, ] = { files.FileField: Upload, files.ImageField: Upload, related.ForeignKey: OneToManyInput, related.ManyToManyField: ManyToManyInput, related.OneToOneField: OneToOneInput, reverse_related.ManyToManyRel: ManyToManyInput, reverse_related.ManyToOneRel: ManyToOneInput, reverse_related.OneToOneRel: OneToOneInput, } relay_field_type_map: dict[ type[fields.Field] | type[related.RelatedField] | type[reverse_related.ForeignObjectRel], type | FunctionType, ] = { fields.AutoField: relay.GlobalID, fields.BigAutoField: relay.GlobalID, related.ForeignKey: relay.Node, related.ManyToManyField: list[relay.Node], related.OneToOneField: relay.Node, reverse_related.ManyToManyRel: list[relay.Node], reverse_related.ManyToOneRel: list[relay.Node], reverse_related.OneToOneRel: relay.Node, } relay_input_field_type_map: dict[ type[fields.Field] | type[related.RelatedField] | type[reverse_related.ForeignObjectRel], type | FunctionType, ] = { related.ForeignKey: NodeInput, related.ManyToManyField: ListInput[NodeInput], related.OneToOneField: NodeInput, reverse_related.ManyToManyRel: ListInput[NodeInput], reverse_related.ManyToOneRel: ListInput[NodeInput], reverse_related.OneToOneRel: NodeInput, } def _resolve_array_field_type(model_field: Field): assert ArrayField is not None if isinstance(model_field, ArrayField): return list[_resolve_array_field_type(model_field.base_field)] base_field = field_type_map.get(type(model_field), NotImplemented) if base_field is NotImplemented: raise NotImplementedError( f"GraphQL type for model field '{model_field}' has not been implemented", ) return base_field def resolve_model_field_type( model_field: Field | reverse_related.ForeignObjectRel, django_type: "StrawberryDjangoDefinition", ): settings = django_settings() # Django choices field if ( TextChoicesField is not None and IntegerChoicesField is not None and isinstance( model_field, (TextChoicesField, IntegerChoicesField), ) ): field_type = model_field.choices_enum enum_def = getattr(field_type, "__strawberry_definition__", None) if enum_def is None: doc = ( inspect.cleandoc(field_type.__doc__) if settings["TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING"] and field_type.__doc__ else None ) enum_def = strawberry.enum( field_type, description=doc ).__strawberry_definition__ field_type = enum_def.wrapped_cls # Auto enum elif ( settings["GENERATE_ENUMS_FROM_CHOICES"] and isinstance(model_field, Field) and getattr(model_field, "choices", None) and not isinstance( getattr(model_field, "choices", [])[0][0], int, ) # Exclude IntegerChoices ): field_type = getattr(model_field, "_strawberry_enum", None) if field_type is None: meta = model_field.model._meta enum_choices = {} for c in cast("Iterable[tuple[str | None, str]]", model_field.choices): # Skip empty choice (__empty__) if not c[0]: continue # replace chars not compatible with GraphQL naming convention choice_name = re.sub(r"^[^_a-zA-Z]|[^_a-zA-Z0-9]", "_", c[0]) # use str() to trigger eventual django's gettext_lazy string choice_value = EnumValueDefinition(value=c[0], description=str(c[1])) while choice_name in enum_choices: choice_name += "_" enum_choices[choice_name] = choice_value field_type = strawberry.enum( # type: ignore enum.Enum( # type: ignore "".join( ( capitalize_first(to_camel_case(meta.app_label)), str(meta.object_name), capitalize_first(to_camel_case(model_field.name)), "Enum", ), ), enum_choices, ), description=( f"{meta.verbose_name} | {model_field.verbose_name}" if settings["FIELD_DESCRIPTION_FROM_HELP_TEXT"] else None ), ) model_field._strawberry_enum = field_type # type: ignore # Generated fields elif GeneratedField is not None and isinstance(model_field, GeneratedField): model_field_type = type(model_field.output_field) # type: ignore field_type = field_type_map.get(model_field_type, NotImplemented) elif ArrayField is not None and isinstance(model_field, ArrayField): field_type = _resolve_array_field_type(model_field) # Every other Field possibility else: force_global_id = settings["MAP_AUTO_ID_AS_GLOBAL_ID"] model_field_type = type(model_field) field_type: Any = None if django_type.is_filter and model_field.is_relation: field_type = ( NodeInput if force_global_id else filters.get_django_model_filter_input_type() ) elif django_type.is_input: input_type_map = input_field_type_map if force_global_id: input_type_map = {**input_type_map, **relay_input_field_type_map} field_type = input_type_map.get(model_field_type) if field_type is None: type_map = field_type_map if force_global_id: type_map = {**type_map, **relay_field_type_map} field_type = type_map.get(model_field_type, NotImplemented) if field_type is NotImplemented: raise NotImplementedError( f"GraphQL type for model field '{model_field}' has not been implemented", ) # TODO: could this be moved into filters.py using_old_filters = settings["USE_DEPRECATED_FILTERS"] if ( django_type.is_filter == "lookups" and not model_field.is_relation and (field_type is not bool or not using_old_filters) ): if using_old_filters: field_type = filters.FilterLookup[field_type] else: field_type = filter_types.type_filter_map.get( # type: ignore field_type, filter_types.FilterLookup )[field_type] return field_type def resolve_model_field_name( model_field: Field | reverse_related.ForeignObjectRel, is_input: bool = False, is_filter: bool = False, is_fk_id: bool = False, ): if isinstance(model_field, reverse_related.ForeignObjectRel): return model_field.get_accessor_name() if is_fk_id or (is_input and not is_filter): return model_field.attname return model_field.name def get_model_field(model: type[Model], field_name: str): try: return model._meta.get_field(field_name) except FieldDoesNotExist as e: model_field_names = [] # we need to iterate through all the fields because reverse relation # fields cannot be accessed by get_field method for field in model._meta.get_fields(): model_field_name = resolve_model_field_name(field) if field_name == model_field_name: return field model_field_names.append(model_field_name) e.args = ( "{}, did you mean {}?".format( e.args[0], ", ".join([f"'{n}'" for n in model_field_names]), ), ) raise def is_optional( model_field: Field | reverse_related.ForeignObjectRel, is_input: bool, partial: bool, ): if partial: return True if not model_field: return False if is_input: if isinstance(model_field, fields.AutoField): return True if isinstance(model_field, reverse_related.OneToOneRel): return model_field.null if model_field.many_to_many or model_field.one_to_many: return True if ( getattr(model_field, "blank", None) or getattr(model_field, "default", None) is not fields.NOT_PROVIDED ): return True if not isinstance( model_field, (reverse_related.ManyToManyRel, reverse_related.ManyToOneRel), ) or isinstance(model_field, reverse_related.OneToOneRel): # OneToOneRel is the subclass of ManyToOneRel, so additional check is needed return model_field.null return False strawberry-graphql-django-0.82.1/strawberry_django/filters.py000066400000000000000000000336621516173410200244640ustar00rootroot00000000000000from __future__ import annotations import functools import inspect import operator import warnings from enum import Enum from types import FunctionType from typing import ( TYPE_CHECKING, Annotated, Any, Generic, TypeVar, cast, get_origin, ) import strawberry from django.db.models import Q, QuerySet from strawberry import UNSET, Some, relay from strawberry.annotation import StrawberryAnnotation from strawberry.tools import create_type from strawberry.types import has_object_definition from strawberry.types.base import WithStrawberryObjectDefinition from strawberry.types.field import StrawberryField, field from strawberry.types.unset import UnsetType from typing_extensions import Self, assert_never, dataclass_transform, deprecated from strawberry_django.fields.filter_order import ( RESOLVE_VALUE_META, SKIP_FILTER_META, WITH_NONE_META, FilterOrderField, FilterOrderFieldResolver, ) from strawberry_django.utils.typing import ( WithStrawberryDjangoObjectDefinition, get_type_from_lazy_annotation, has_django_definition, ) from .arguments import argument from .fields.base import StrawberryDjangoFieldBase from .settings import strawberry_django_settings if TYPE_CHECKING: from collections.abc import Callable, Sequence from types import FunctionType from django.db.models import Model from strawberry.types import Info from strawberry.types.arguments import StrawberryArgument T = TypeVar("T") _T = TypeVar("_T", bound=type) _QS = TypeVar("_QS", bound="QuerySet") FILTERS_ARG = "filters" _DjangoModelFilterInput: Any = None def get_django_model_filter_input_type(): global _DjangoModelFilterInput # noqa: PLW0603 if _DjangoModelFilterInput is None: settings = strawberry_django_settings() id_field_name = settings["DEFAULT_PK_FIELD_NAME"] id_field = StrawberryField( python_name=id_field_name, graphql_name=id_field_name, type_annotation=StrawberryAnnotation(strawberry.ID), ) _DjangoModelFilterInput = create_type( "DjangoModelFilterInput", [id_field], is_input=True, ) return _DjangoModelFilterInput @strawberry.input class FilterLookup(Generic[T]): exact: T | None = UNSET i_exact: T | None = UNSET contains: T | None = UNSET i_contains: T | None = UNSET in_list: list[T] | None = UNSET gt: T | None = UNSET gte: T | None = UNSET lt: T | None = UNSET lte: T | None = UNSET starts_with: T | None = UNSET i_starts_with: T | None = UNSET ends_with: T | None = UNSET i_ends_with: T | None = UNSET range: list[T] | None = UNSET is_null: bool | None = UNSET regex: str | None = UNSET i_regex: str | None = UNSET lookup_name_conversion_map = { "i_exact": "iexact", "i_contains": "icontains", "in_list": "in", "starts_with": "startswith", "i_starts_with": "istartswith", "ends_with": "endswith", "i_ends_with": "iendswith", "is_null": "isnull", "i_regex": "iregex", } def resolve_value(value: Any) -> Any: if isinstance(value, list): return [resolve_value(v) for v in value] # Handle strawberry.Some (the wrapped value inside Maybe) if isinstance(value, Some): # Extract .value from Some and recursively resolve it return resolve_value(value.value) if isinstance(value, relay.GlobalID): return value.node_id if isinstance(value, Enum): return value.value return value @functools.lru_cache(maxsize=256) def _function_allow_passing_info(filter_method: FunctionType) -> bool: argspec = inspect.getfullargspec(filter_method) return "info" in getattr(argspec, "args", []) or "info" in getattr( argspec, "kwargs", [], ) def _process_deprecated_filter( filter_method: FunctionType, info: Info | None, queryset: _QS ) -> _QS: kwargs = {} if _function_allow_passing_info( # Pass the original __func__ which is always the same getattr(filter_method, "__func__", filter_method), ): kwargs["info"] = info return filter_method(queryset=queryset, **kwargs) def process_filters( filters: WithStrawberryObjectDefinition, queryset: _QS, info: Info | None, prefix: str = "", skip_object_filter_method: bool = False, ) -> tuple[_QS, Q]: if prefix and not prefix.endswith("__"): warnings.warn( f"process_filters received prefix={prefix!r} which does not end with '__'. " "This will likely cause Django FieldError. " "Prefix should end with '__' for correct ORM lookups (e.g., 'my_field__').", UserWarning, stacklevel=2, ) using_old_filters = strawberry_django_settings()["USE_DEPRECATED_FILTERS"] q = Q() if not skip_object_filter_method and ( filter_method := getattr(filters, "filter", None) ): # Dedicated function for object if isinstance(filter_method, FilterOrderFieldResolver): return filter_method(filters, info, queryset=queryset, prefix=prefix) if using_old_filters: return _process_deprecated_filter(filter_method, info, queryset), q # This loop relies on the filter field order that is not quaranteed for GQL input objects: # "filter" has to be first since it overrides filtering for entire object # DISTINCT has to be last and OR has to be after because it must be # applied agains all other since default connector is AND for f in sorted( filters.__strawberry_definition__.fields, key=lambda x: len(x.name) if x.name in {"OR", "DISTINCT"} else 0, ): field_value = getattr(filters, f.name) # None is still acceptable for v1 (backwards compatibility) and filters that support it via metadata if field_value is UNSET or ( field_value is None and not f.metadata.get(WITH_NONE_META, using_old_filters) ): continue if f.metadata.get(SKIP_FILTER_META, False): continue should_resolve = f.metadata.get(RESOLVE_VALUE_META, UNSET) field_name = lookup_name_conversion_map.get(f.name, f.name) if field_name == "DISTINCT": if field_value: queryset = queryset.distinct() elif field_name in ("AND", "OR", "NOT"): # noqa: PLR6201 values = field_value if isinstance(field_value, list) else [field_value] all_q = [Q()] for value in values: assert has_object_definition(value) queryset, sub_q_for_value = process_filters( cast("WithStrawberryObjectDefinition", value), queryset, info, prefix, ) all_q.append(sub_q_for_value) if field_name == "AND": sub_q = functools.reduce(operator.and_, all_q) q &= sub_q elif field_name == "OR": sub_q = functools.reduce(operator.or_, all_q) if isinstance(field_value, list): # The behavior of AND/OR/NOT with a list of values means AND/OR/NOT # with respect to the list members but AND with respect to other # filters q &= sub_q else: q |= sub_q elif field_name == "NOT": # Whether this is an AND or OR operation is undefined in the spec and implementation specific sub_q = functools.reduce(operator.or_, all_q) q &= ~sub_q else: assert_never(field_name) elif isinstance(f, FilterOrderField) and f.base_resolver: res = f.base_resolver( filters, info, value=(resolve_value(field_value) if should_resolve else field_value), queryset=queryset, prefix=prefix, ) if isinstance(res, tuple): queryset, sub_q = res else: sub_q = res q &= sub_q elif using_old_filters and ( filter_method := getattr(filters, f"filter_{field_name}", None) ): queryset = _process_deprecated_filter(filter_method, info, queryset) elif has_object_definition(field_value): queryset, sub_q = process_filters( cast("WithStrawberryObjectDefinition", field_value), queryset, info, f"{prefix}{field_name}__", ) q &= sub_q else: q &= Q(**{ f"{prefix}{field_name}": ( resolve_value(field_value) if should_resolve or should_resolve is UNSET else field_value ) }) return queryset, q def apply( filters: object | None, queryset: _QS, info: Info | None = None, pk: Any | None = None, ) -> _QS: if pk not in (None, strawberry.UNSET): # noqa: PLR6201 settings = strawberry_django_settings() pk_field_name = settings["DEFAULT_PK_FIELD_NAME"] queryset = queryset.filter(**{pk_field_name: pk}) if filters in (None, strawberry.UNSET) or not has_django_definition(filters): # noqa: PLR6201 return queryset queryset, q = process_filters( cast("WithStrawberryObjectDefinition", filters), queryset, info ) if q: queryset = queryset.filter(q) return queryset class StrawberryDjangoFieldFilters(StrawberryDjangoFieldBase): def __init__(self, filters: type | UnsetType | None = UNSET, **kwargs): if filters and get_origin(filters) is Annotated: filters = get_type_from_lazy_annotation(filters) or filters if filters and not has_object_definition(filters): raise TypeError("filters needs to be a strawberry type") self.filters = filters super().__init__(**kwargs) def __copy__(self) -> Self: new_field = super().__copy__() new_field.filters = self.filters return new_field @property def arguments(self) -> list[StrawberryArgument]: arguments = [] if self.base_resolver is None and not self.is_model_property: filters = self.get_filters() origin = cast("WithStrawberryObjectDefinition", self.origin) is_root_query = origin.__strawberry_definition__.name == "Query" if ( self.django_model and is_root_query and isinstance(self.django_type, relay.Node) ): arguments.append( ( argument("ids", list[relay.GlobalID]) if self.is_list else argument("id", relay.GlobalID) ), ) if ( self.django_model and is_root_query and not self.is_list and not self.is_connection and not self.is_paginated ): settings = strawberry_django_settings() arguments.append( argument( settings["DEFAULT_PK_FIELD_NAME"], cast("type", strawberry.ID) ) ) elif filters is not None and self.is_list: is_optional = True from .mutations.fields import DjangoMutationBase if isinstance(self, DjangoMutationBase): settings = strawberry_django_settings() is_optional = settings["ALLOW_MUTATIONS_WITHOUT_FILTERS"] arguments.append( argument(FILTERS_ARG, filters, is_optional=is_optional) ) return super().arguments + arguments @arguments.setter def arguments(self, value: list[StrawberryArgument]): args_prop = super(StrawberryDjangoFieldFilters, self.__class__).arguments return args_prop.fset(self, value) # type: ignore def get_filters(self) -> type[WithStrawberryObjectDefinition] | None: filters = self.filters if filters is None: return None if isinstance(filters, UnsetType): django_type = self.django_type filters = ( django_type.__strawberry_django_definition__.filters if django_type is not None else None ) return filters if filters is not UNSET else None def get_queryset( self, queryset: _QS, info: Info, *, filters: WithStrawberryDjangoObjectDefinition | None = None, **kwargs, ) -> _QS: settings = strawberry_django_settings() pk = kwargs.get(settings["DEFAULT_PK_FIELD_NAME"]) queryset = super().get_queryset(queryset, info, **kwargs) return apply(filters, queryset, info, pk) @dataclass_transform( kw_only_default=True, order_default=True, field_specifiers=( StrawberryField, field, ), ) def filter_type( model: type[Model], *, name: str | None = None, description: str | None = None, directives: Sequence[object] | None = (), lookups: bool = False, ) -> Callable[[_T], _T]: from .type import input # noqa: A004 return input( model, name=name, description=description, directives=directives, is_filter="lookups" if lookups else True, partial=True, ) if TYPE_CHECKING: filter = deprecated("`filter` is deprecated, use `filter_type` instead.")( # noqa: A001 filter_type ) def __getattr__(name: str) -> Any: if name == "filter": warnings.warn( "`filter` is deprecated, use `filter_type` instead.", DeprecationWarning, stacklevel=2, ) return filter_type raise AttributeError(f"module {__name__} has no attribute {name}") strawberry-graphql-django-0.82.1/strawberry_django/integrations/000077500000000000000000000000001516173410200251365ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/integrations/__init__.py000066400000000000000000000000001516173410200272350ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/integrations/guardian.py000066400000000000000000000024021516173410200273000ustar00rootroot00000000000000import contextlib import dataclasses from typing import cast from django.contrib.auth import get_user_model from django.db import models from guardian.conf import settings as guardian_settings from guardian.models.models import GroupObjectPermissionBase, UserObjectPermissionBase from guardian.utils import get_anonymous_user as _get_anonymous_user from guardian.utils import get_group_obj_perms_model, get_user_obj_perms_model from strawberry_django.utils.typing import UserType @dataclasses.dataclass class ObjectPermissionModels: user: UserObjectPermissionBase group: GroupObjectPermissionBase def get_object_permission_models( model: models.Model | type[models.Model], ) -> ObjectPermissionModels: model = cast("models.Model", model) return ObjectPermissionModels( user=cast("UserObjectPermissionBase", get_user_obj_perms_model(model)), group=cast("GroupObjectPermissionBase", get_group_obj_perms_model(model)), ) def get_user_or_anonymous(user: UserType) -> UserType: username = guardian_settings.ANONYMOUS_USER_NAME or "" if user.is_anonymous and user.get_username() != username: with contextlib.suppress(get_user_model().DoesNotExist): return cast("UserType", _get_anonymous_user()) return user strawberry-graphql-django-0.82.1/strawberry_django/management/000077500000000000000000000000001516173410200245445ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/management/__init__.py000066400000000000000000000000001516173410200266430ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/management/commands/000077500000000000000000000000001516173410200263455ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/management/commands/__init__.py000066400000000000000000000000001516173410200304440ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/management/commands/export_schema.py000066400000000000000000000023221516173410200315570ustar00rootroot00000000000000import pathlib from django.core.management.base import BaseCommand, CommandError from strawberry import Schema from strawberry.printer import print_schema from strawberry.utils.importer import import_module_symbol class Command(BaseCommand): help = "Export the graphql schema" def add_arguments(self, parser): parser.add_argument("schema", nargs=1, type=str, help="The schema location") parser.add_argument( "--path", nargs="?", type=str, help="Optional path to export", ) def handle(self, *args, **options): try: schema_symbol = import_module_symbol( options["schema"][0], default_symbol_name="schema", ) except (ImportError, AttributeError) as e: raise CommandError(str(e)) from e if not isinstance(schema_symbol, Schema): raise CommandError("The `schema` must be an instance of strawberry.Schema") schema_output = print_schema(schema_symbol) path = options.get("path") if path: pathlib.Path(path).write_text(schema_output, encoding="utf-8") else: self.stdout.write(schema_output) strawberry-graphql-django-0.82.1/strawberry_django/middlewares/000077500000000000000000000000001516173410200247305ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/middlewares/__init__.py000066400000000000000000000000001516173410200270270ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/middlewares/debug_toolbar.py000066400000000000000000000067041516173410200301210ustar00rootroot00000000000000# Based on https://github.com/flavors/django-graphiql-debug-toolbar import collections import json from debug_toolbar.middleware import ( DebugToolbarMiddleware as _DebugToolbarMiddleware, ) from debug_toolbar.toolbar import DebugToolbar from django.core.serializers.json import DjangoJSONEncoder from django.http.request import HttpRequest from django.http.response import HttpResponse from django.template.loader import render_to_string from django.utils.encoding import force_str from strawberry.django.views import BaseView from typing_extensions import override _HTML_TYPES = {"text/html", "application/xhtml+xml"} def _get_payload( request: HttpRequest, response: HttpResponse, toolbar: DebugToolbar, ) -> dict | None: if not toolbar.request_id: return None content = force_str(response.content, encoding=response.charset) payload = json.loads(content, object_pairs_hook=collections.OrderedDict) payload["debugToolbar"] = collections.OrderedDict( [("panels", collections.OrderedDict())], ) payload["debugToolbar"]["requestId"] = toolbar.request_id for p in reversed(toolbar.enabled_panels): if p.panel_id == "TemplatesPanel": continue title = p.title if p.has_content else None sub = p.nav_subtitle payload["debugToolbar"]["panels"][p.panel_id] = { "title": title() if callable(title) else title, "subtitle": sub() if callable(sub) else sub, } return payload class DebugToolbarMiddleware(_DebugToolbarMiddleware): def process_view(self, request: HttpRequest, view_func, *args, **kwargs): view = getattr(view_func, "view_class", None) request._is_graphiql = bool(view and issubclass(view, BaseView)) # type: ignore @override def _postprocess( self, request: HttpRequest, response: HttpResponse, toolbar: DebugToolbar, ) -> HttpResponse: response = super()._postprocess(request, response, toolbar) if response.streaming: return response content_type = response.get("Content-Type", "").split(";")[0] is_html = content_type in _HTML_TYPES is_graphiql = getattr(request, "_is_graphiql", False) if is_html and is_graphiql and response.status_code == 200: # noqa: PLR2004 template = render_to_string("strawberry_django/debug_toolbar.html") response.write(template) if "Content-Length" in response: # type: ignore response["Content-Length"] = len(response.content) if is_html or not is_graphiql or content_type != "application/json": return response try: operation_name = json.loads(request.body).get("operationName") except Exception: # noqa: BLE001 operation_name = None # Do not return the payload for introspection queries, otherwise IDEs such as # apollo sandbox that query the introspection all the time will remove older # results from the history. payload = ( _get_payload(request, response, toolbar) if operation_name != "IntrospectionQuery" else None ) if payload is None: return response response.content = json.dumps(payload, cls=DjangoJSONEncoder) # type: ignore if "Content-Length" in response: # type: ignore response["Content-Length"] = len(response.content) return response strawberry-graphql-django-0.82.1/strawberry_django/mutations/000077500000000000000000000000001516173410200244535ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/mutations/__init__.py000066400000000000000000000002471516173410200265670ustar00rootroot00000000000000from .mutations import create, delete, input_mutation, mutation, update __all__ = [ "create", "delete", "input_mutation", "mutation", "update", ] strawberry-graphql-django-0.82.1/strawberry_django/mutations/fields.py000066400000000000000000000332741516173410200263040ustar00rootroot00000000000000from __future__ import annotations import inspect from typing import TYPE_CHECKING, Annotated, Any, TypeVar, Union import strawberry from django.core.exceptions import ( NON_FIELD_ERRORS, ObjectDoesNotExist, PermissionDenied, ValidationError, ) from django.db import models, transaction from strawberry import UNSET, relay from strawberry.annotation import StrawberryAnnotation from strawberry.types.field import UNRESOLVED from strawberry.utils.str_converters import capitalize_first, to_camel_case from strawberry_django.arguments import argument from strawberry_django.fields.field import ( StrawberryDjangoFieldBase, StrawberryDjangoFieldFilters, ) from strawberry_django.fields.types import OperationInfo, OperationMessage from strawberry_django.optimizer import DjangoOptimizerExtension, optimize from strawberry_django.permissions import filter_with_perms, get_with_perms from strawberry_django.resolvers import django_resolver from strawberry_django.settings import strawberry_django_settings from strawberry_django.utils.inspect import get_possible_types from . import resolvers if TYPE_CHECKING: from collections.abc import Iterable from typing import Literal from graphql.pyutils import AwaitableOrValue from strawberry.types import Info from strawberry.types.arguments import StrawberryArgument from strawberry.types.base import ( StrawberryObjectDefinition, StrawberryType, WithStrawberryObjectDefinition, ) from typing_extensions import Self from .types import FullCleanOptions _T = TypeVar("_T", bound="models.Model | list[models.Model]") def _get_validaton_error_message(error: ValidationError): if not error.message: return "Unknown error" return error.message % error.params if error.params else error.message def _get_validation_errors(error: Exception): if isinstance(error, PermissionDenied): kind = OperationMessage.Kind.PERMISSION elif isinstance(error, ValidationError): kind = OperationMessage.Kind.VALIDATION elif isinstance(error, ObjectDoesNotExist): kind = OperationMessage.Kind.ERROR else: kind = OperationMessage.Kind.ERROR if isinstance(error, ValidationError) and hasattr(error, "error_dict"): # convert field errors for field, field_errors in (error.error_dict or {}).items(): for e in field_errors: yield OperationMessage( kind=kind, field=to_camel_case(field) if field != NON_FIELD_ERRORS else None, message=_get_validaton_error_message(e), code=getattr(e, "code", None), ) elif isinstance(error, ValidationError) and hasattr(error, "error_list"): # convert non-field errors for e in error.error_list or []: yield OperationMessage( kind=kind, message=_get_validaton_error_message(e), code=getattr(error, "code", None), ) else: msg = getattr(error, "msg", None) if msg is None: msg = str(error) yield OperationMessage( kind=kind, message=msg, code=getattr(error, "code", None), ) def _handle_exception(error: Exception): if isinstance(error, (ValidationError, PermissionDenied, ObjectDoesNotExist)): return OperationInfo( messages=list(_get_validation_errors(error)), ) raise error class DjangoMutationBase(StrawberryDjangoFieldBase): def __init__( self, *args, handle_django_errors: bool | None = None, **kwargs, ): self._resolved_return_type: bool = False if handle_django_errors is None: settings = strawberry_django_settings() handle_django_errors = settings["MUTATIONS_DEFAULT_HANDLE_ERRORS"] self.handle_errors = handle_django_errors super().__init__(*args, **kwargs) def __copy__(self) -> Self: new_field = super().__copy__() new_field.handle_errors = self.handle_errors return new_field def resolve_type( self, *, type_definition: StrawberryObjectDefinition | None = None, ) -> ( StrawberryType | type[WithStrawberryObjectDefinition] | Literal[UNRESOLVED] # type: ignore ): resolved = super().resolve_type(type_definition=type_definition) if resolved is UNRESOLVED: return resolved if self.handle_errors and not self._resolved_return_type: types_ = tuple(get_possible_types(resolved)) if OperationInfo not in types_: types_ = (*types_, OperationInfo) name = capitalize_first(to_camel_case(self.python_name)) resolved = Annotated[ Union[types_], # noqa: UP007 strawberry.union(f"{name}Payload"), ] self.type_annotation = StrawberryAnnotation( resolved, namespace=getattr(self.type_annotation, "namespace", None), ) self._resolved_return_type = True return resolved def get_result( self, source: Any, info: Info | None, args: list[Any], kwargs: dict[str, Any], ) -> AwaitableOrValue[Any]: if not self.handle_errors: return self.resolver(source, info, args, kwargs) # TODO: Any other exception types that we should capture here? try: resolved = self.resolver(source, info, args, kwargs) except Exception as e: # noqa: BLE001 return _handle_exception(e) if inspect.isawaitable(resolved): async def async_resolver(): try: return await resolved except Exception as e: # noqa: BLE001 return _handle_exception(e) return async_resolver() return resolved class DjangoMutationCUD(DjangoMutationBase): def __init__( self, input_type: type | None = None, full_clean: bool | FullCleanOptions = True, argument_name: str | None = None, key_attr: str | None = None, **kwargs, ): self.full_clean = full_clean self.input_type = input_type if key_attr is None: settings = strawberry_django_settings() key_attr = settings["DEFAULT_PK_FIELD_NAME"] self.key_attr = key_attr if argument_name is None: settings = strawberry_django_settings() argument_name = settings["MUTATIONS_DEFAULT_ARGUMENT_NAME"] self.argument_name = argument_name super().__init__(**kwargs) def __copy__(self) -> Self: new_field = super().__copy__() new_field.input_type = self.input_type new_field.full_clean = self.full_clean return new_field @property def arguments(self): arguments = super().arguments if not self.input_type: return arguments return [ *arguments, argument( self.argument_name, self.input_type, ), ] @arguments.setter def arguments(self, value: list[StrawberryArgument]): args_prop = super(DjangoMutationBase, self.__class__).arguments return args_prop.fset(self, value) # type: ignore def refetch(self, resolved: _T, *, info: Info | None) -> _T: if not DjangoOptimizerExtension.enabled.get() or info is None: return resolved if isinstance(resolved, list) and resolved: model = type(resolved[0]) if issubclass(model, models.Model): original_order = {r.pk: i for i, r in enumerate(resolved)} resolved_qs = optimize( model._default_manager.filter(pk__in=[r.pk for r in resolved]), info=info, ) # sort the resolved objects in the order they were given resolved = sorted( # type: ignore resolved_qs, key=lambda r: original_order[r.pk], ) elif isinstance(resolved, models.Model): model = type(resolved) resolved = optimize( model._default_manager.filter(pk=resolved.pk), info=info, ).get() return resolved class DjangoCreateMutation(DjangoMutationCUD): @django_resolver @transaction.atomic def resolver( self, source: Any, info: Info | None, args: list[Any], kwargs: dict[str, Any], ) -> Any: assert info is not None data: list[Any] | Any = kwargs.get(self.argument_name) # Do not optimize anything while retrieving the object to create with DjangoOptimizerExtension.disabled(): if self.is_list: assert isinstance(data, list) resolved = [ self.create( resolvers.parse_input(info, vars(d), key_attr=self.key_attr), info=info, ) for d in data ] else: assert not isinstance(data, list) resolved = self.create( resolvers.parse_input(info, vars(data), key_attr=self.key_attr) if data is not None else {}, info=info, ) return self.refetch(resolved, info=info) def create(self, data: dict[str, Any], *, info: Info): model = self.django_model assert model is not None return resolvers.create( info, model, data, key_attr=self.key_attr, full_clean=self.full_clean, ) def get_vdata(data: Any) -> dict[str, Any]: return vars(data).copy() if data is not None else {} def get_pk( data: dict[str, Any], *, key_attr: str | None = None, ) -> strawberry.ID | relay.GlobalID | Literal[UNSET] | None: # type: ignore if key_attr is None: settings = strawberry_django_settings() key_attr = settings["DEFAULT_PK_FIELD_NAME"] pk = data.pop(key_attr, UNSET) if pk is UNSET: pk = data.pop("id", UNSET) return pk class DjangoUpdateMutation(DjangoMutationCUD, StrawberryDjangoFieldFilters): @django_resolver @transaction.atomic def resolver( self, source: Any, info: Info | None, args: list[Any], kwargs: dict[str, Any], ) -> Any: assert info is not None data: list[Any] | Any = kwargs.get(self.argument_name) # Do not optimize anything while retrieving the object to update with DjangoOptimizerExtension.disabled(): if isinstance(data, list): resolved = [self.instance_level_update(info, kwargs, d) for d in data] else: resolved = self.instance_level_update(info, kwargs, data) return self.refetch(resolved, info=info) def instance_level_update( self, info: Info, kwargs: dict[str, Any], data: Any, ) -> Any: model = self.django_model assert model is not None vdata = get_vdata(data) pk = get_pk(vdata, key_attr=self.key_attr) if pk not in (None, UNSET): # noqa: PLR6201 instance = get_with_perms( pk, info, required=True, model=model, key_attr=self.key_attr, ) else: instance = filter_with_perms( self.get_queryset( queryset=model._default_manager.all(), info=info, **kwargs, ), info, ) return self.update( info, instance, resolvers.parse_input(info, vdata, key_attr=self.key_attr) ) def update( self, info: Info, instance: models.Model | Iterable[models.Model], data: dict[str, Any], ): return resolvers.update( info, instance, data, key_attr=self.key_attr, full_clean=self.full_clean, ) class DjangoDeleteMutation( DjangoMutationCUD, DjangoMutationBase, StrawberryDjangoFieldFilters, ): @django_resolver @transaction.atomic def resolver( self, source: Any, info: Info | None, args: list[Any], kwargs: dict[str, Any], ) -> Any: assert info is not None model = self.django_model assert model is not None data: Any = kwargs.get(self.argument_name) vdata = get_vdata(data) pk = get_pk(vdata, key_attr=self.key_attr) if pk not in (None, UNSET): # noqa: PLR6201 instance = get_with_perms( pk, info, required=True, model=model, key_attr=self.key_attr, ) else: instance = filter_with_perms( self.get_queryset( queryset=model._default_manager.all(), info=info, **kwargs, ), info, ) return self.delete( info, instance, resolvers.parse_input(info, vdata, key_attr=self.key_attr), ) def delete( self, info: Info, instance: models.Model | Iterable[models.Model], data: dict[str, Any] | None = None, ): return resolvers.delete( info, instance, data=data, ) strawberry-graphql-django-0.82.1/strawberry_django/mutations/mutations.py000066400000000000000000000324751516173410200270630ustar00rootroot00000000000000import dataclasses from collections.abc import Callable, Mapping, Sequence from typing import ( Any, Literal, TypeVar, overload, ) from strawberry.annotation import StrawberryAnnotation from strawberry.extensions.field_extension import FieldExtension from strawberry.field_extensions import InputMutationExtension from strawberry.permission import BasePermission from strawberry.types.fields.resolver import StrawberryResolver from strawberry.types.unset import UNSET, UnsetType from .fields import ( DjangoCreateMutation, DjangoDeleteMutation, DjangoMutationBase, DjangoUpdateMutation, ) from .types import FullCleanOptions _T = TypeVar("_T") @overload def mutation( *, resolver: Callable[[], _T], name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, init: Literal[False] = False, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: list[FieldExtension] | None = None, handle_django_errors: bool | None = None, ) -> _T: ... @overload def mutation( *, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, init: Literal[True] = True, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: list[FieldExtension] | None = None, handle_django_errors: bool | None = None, ) -> Any: ... @overload def mutation( resolver: StrawberryResolver | Callable | staticmethod | classmethod, *, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: list[FieldExtension] | None = None, handle_django_errors: bool | None = None, ) -> DjangoMutationBase: ... def mutation( resolver=None, *, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: list[FieldExtension] | None = None, handle_django_errors: bool | None = None, # This init parameter is used by pyright to determine whether this field # is added in the constructor or not. It is not used to change # any behavior at the moment. init: bool | None = None, ) -> Any: """Annotate a property or a method to create a mutation field.""" f = DjangoMutationBase( python_name=None, django_name=field_name, graphql_name=name, type_annotation=StrawberryAnnotation.from_annotation(graphql_type), description=description, is_subscription=is_subscription, permission_classes=permission_classes or [], deprecation_reason=deprecation_reason, default=default, default_factory=default_factory, metadata=metadata, directives=directives, extensions=extensions or (), handle_django_errors=handle_django_errors, ) if resolver is not None: return f(resolver) return f @overload def input_mutation( *, resolver: Callable[[], _T], name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, init: Literal[False] = False, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: list[FieldExtension] | None = None, handle_django_errors: bool | None = None, ) -> _T: ... @overload def input_mutation( *, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, init: Literal[True] = True, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: list[FieldExtension] | None = None, handle_django_errors: bool | None = None, ) -> Any: ... @overload def input_mutation( resolver: StrawberryResolver | Callable | staticmethod | classmethod, *, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: list[FieldExtension] | None = None, handle_django_errors: bool | None = None, ) -> DjangoMutationBase: ... def input_mutation( resolver=None, *, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: list[FieldExtension] | None = None, handle_django_errors: bool | None = None, # This init parameter is used by pyright to determine whether this field # is added in the constructor or not. It is not used to change # any behavior at the moment. init: bool | None = None, ) -> Any: """Annotate a property or a method to create an input mutation field.""" extensions = [*(extensions or []), InputMutationExtension()] f = DjangoMutationBase( python_name=None, django_name=field_name, graphql_name=name, type_annotation=StrawberryAnnotation.from_annotation(graphql_type), description=description, is_subscription=is_subscription, permission_classes=permission_classes or [], deprecation_reason=deprecation_reason, default=default, default_factory=default_factory, metadata=metadata, directives=directives, extensions=extensions, handle_django_errors=handle_django_errors, ) if resolver is not None: return f(resolver) return f def create( input_type: type | None = None, *, name: str | None = None, field_name: str | None = None, is_subscription: bool = False, description: str | None = None, init: Literal[True] = True, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: list[FieldExtension] | None = None, argument_name: str | None = None, handle_django_errors: bool | None = None, full_clean: bool | FullCleanOptions = True, ) -> Any: """Create mutation for django input fields. Automatically create data for django input fields. Examples -------- >>> @strawberry.django.input ... class ProductInput: ... name: strawberry.auto ... price: strawberry.auto ... >>> @strawberry.mutation >>> class Mutation: ... create_product: ProductType = strawberry.django.create_mutation( ... ProductInput ... ) """ return DjangoCreateMutation( input_type, python_name=None, django_name=field_name, graphql_name=name, type_annotation=StrawberryAnnotation.from_annotation(graphql_type), description=description, is_subscription=is_subscription, permission_classes=permission_classes or [], deprecation_reason=deprecation_reason, default=default, default_factory=default_factory, metadata=metadata, directives=directives, extensions=extensions or (), argument_name=argument_name, handle_django_errors=handle_django_errors, full_clean=full_clean, ) def update( input_type: type | None = None, *, name: str | None = None, field_name: str | None = None, filters: type | UnsetType | None = UNSET, is_subscription: bool = False, description: str | None = None, init: Literal[True] = True, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), graphql_type: Any | None = None, extensions: list[FieldExtension] | None = None, argument_name: str | None = None, handle_django_errors: bool | None = None, key_attr: str | None = None, full_clean: bool | FullCleanOptions = True, ) -> Any: """Update mutation for django input fields. Examples -------- >>> @strawberry.django.input ... class ProductInput(IdInput): ... name: strawberry.auto ... price: strawberry.auto ... >>> @strawberry.mutation >>> class Mutation: ... create_product: ProductType = strawberry.django.update_mutation( ... ProductInput ... ) """ return DjangoUpdateMutation( input_type, python_name=None, django_name=field_name, graphql_name=name, type_annotation=StrawberryAnnotation.from_annotation(graphql_type), description=description, is_subscription=is_subscription, permission_classes=permission_classes or [], deprecation_reason=deprecation_reason, default=default, default_factory=default_factory, metadata=metadata, directives=directives, filters=filters, extensions=extensions or (), argument_name=argument_name, handle_django_errors=handle_django_errors, key_attr=key_attr, full_clean=full_clean, ) def delete( input_type: type | None = None, *, name: str | None = None, field_name: str | None = None, filters: type | UnsetType | None = UNSET, is_subscription: bool = False, description: str | None = None, init: Literal[True] = True, permission_classes: list[type[BasePermission]] | None = None, deprecation_reason: str | None = None, default: Any = dataclasses.MISSING, default_factory: Callable[..., object] | object = dataclasses.MISSING, metadata: Mapping[Any, Any] | None = None, directives: Sequence[object] | None = (), extensions: list[FieldExtension] | None = None, graphql_type: Any | None = None, argument_name: str | None = None, handle_django_errors: bool | None = None, key_attr: str | None = None, full_clean: bool | FullCleanOptions = True, ) -> Any: return DjangoDeleteMutation( input_type=input_type, python_name=None, django_name=field_name, graphql_name=name, type_annotation=StrawberryAnnotation.from_annotation(graphql_type), description=description, is_subscription=is_subscription, permission_classes=permission_classes or [], deprecation_reason=deprecation_reason, default=default, default_factory=default_factory, metadata=metadata, directives=directives, filters=filters, extensions=extensions or (), argument_name=argument_name, handle_django_errors=handle_django_errors, key_attr=key_attr, full_clean=full_clean, ) strawberry-graphql-django-0.82.1/strawberry_django/mutations/resolvers.py000066400000000000000000000576231516173410200270660ustar00rootroot00000000000000from __future__ import annotations import dataclasses from collections.abc import Callable, Iterable from enum import Enum from typing import ( TYPE_CHECKING, Any, TypeVar, cast, overload, ) import strawberry from django.db import models, transaction from django.db.models.base import Model from django.db.models.fields import Field from django.db.models.fields.related import ManyToManyField from django.db.models.fields.reverse_related import ( ForeignObjectRel, ManyToManyRel, ManyToOneRel, OneToOneRel, ) from django.utils.functional import LazyObject from strawberry import UNSET, Some, relay from strawberry_django.fields.types import ( ListInput, ManyToManyInput, ManyToOneInput, NodeInput, OneToManyInput, OneToOneInput, ) from strawberry_django.settings import strawberry_django_settings from strawberry_django.utils.inspect import get_model_fields from .types import ( FullCleanOptions, InputListTypes, ParsedObject, ParsedObjectList, ) if TYPE_CHECKING: from django.db.models.manager import ( BaseManager, ManyToManyRelatedManager, RelatedManager, ) from strawberry.types.info import Info _T = TypeVar("_T") _M = TypeVar("_M", bound=Model) def _parse_pk( value: ParsedObject | strawberry.ID | _M | None, model: type[_M], *, key_attr: str | None = None, ) -> tuple[_M | None, dict[str, Any] | None]: if value is None: return None, None if isinstance(value, Model): return value, None if isinstance(value, ParsedObject): return value.parse(model) if isinstance(value, dict): if key_attr is None: settings = strawberry_django_settings() key_attr = settings["DEFAULT_PK_FIELD_NAME"] if key_attr in value: obj_pk = value[key_attr] if obj_pk is not strawberry.UNSET: return model._default_manager.get(pk=obj_pk), value return None, value return model._default_manager.get(pk=value), None def _parse_data( info: Info, model: type[_M], value: Any, *, key_attr: str | None = None, full_clean: bool | FullCleanOptions = True, ): obj, data = _parse_pk(value, model, key_attr=key_attr) parsed_data = {} if data: for k, v in data.items(): if v is UNSET and k != key_attr: continue if isinstance(v, ParsedObject): if v.pk in {None, UNSET}: related_field = cast("Field", get_model_fields(model).get(k)) related_model = related_field.related_model v = create( # noqa: PLW2901 info, cast("type[Model]", related_model), v.data or {}, key_attr=key_attr, full_clean=full_clean, exclude_m2m=[related_field.name], ) elif isinstance(v.pk, models.Model) and v.data: v = update( # noqa: PLW2901 info, v.pk, v.data, key_attr=key_attr, full_clean=full_clean ) else: v = v.pk # noqa: PLW2901 if k == "through_defaults" or not obj or getattr(obj, k) != v: parsed_data[k] = v return obj, parsed_data @overload def parse_input( info: Info, data: dict[str, _T], *, key_attr: str | None = None, ) -> dict[str, _T]: ... @overload def parse_input( info: Info, data: list[_T], *, key_attr: str | None = None, ) -> list[_T]: ... @overload def parse_input( info: Info, data: relay.GlobalID, *, key_attr: str | None = None, ) -> relay.Node: ... @overload def parse_input( info: Info, data: Any, *, key_attr: str | None = None, ) -> Any: ... def parse_input( info: Info, data: Any, *, key_attr: str | None = None, ): if isinstance(data, Some): return parse_input(info, data.value, key_attr=key_attr) if isinstance(data, dict): return {k: parse_input(info, v, key_attr=key_attr) for k, v in data.items()} if isinstance(data, list): return [parse_input(info, v, key_attr=key_attr) for v in data] if isinstance(data, relay.GlobalID): return data.resolve_node_sync(info, required=True) if isinstance(data, NodeInput): pk = cast( "Any", parse_input(info, getattr(data, "id", UNSET), key_attr=key_attr) ) parsed = {} for field in dataclasses.fields(data): if field.name == "id": continue parsed[field.name] = parse_input( info, getattr(data, field.name), key_attr=key_attr ) return ParsedObject( pk=pk, data=parsed or None, ) if isinstance(data, (OneToOneInput, OneToManyInput)): return ParsedObject( pk=parse_input(info, data.set, key_attr=key_attr), ) if isinstance(data, (ManyToOneInput, ManyToManyInput, ListInput)): d = getattr(data, "data", None) if dataclasses.is_dataclass(d): d = { f.name: parse_input(info, getattr(data, f.name), key_attr=key_attr) for f in dataclasses.fields(d) } return ParsedObjectList( add=cast( "list[InputListTypes]", parse_input(info, data.add, key_attr=key_attr) ), remove=cast( "list[InputListTypes]", parse_input(info, data.remove, key_attr=key_attr), ), set=cast( "list[InputListTypes]", parse_input(info, data.set, key_attr=key_attr) ), ) if isinstance(data, Enum): return data.value if dataclasses.is_dataclass(data): return { f.name: parse_input(info, getattr(data, f.name), key_attr=key_attr) for f in dataclasses.fields(data) } return data def prepare_create_update( *, info: Info, instance: Model, data: dict[str, Any], key_attr: str | None = None, full_clean: bool | FullCleanOptions = True, exclude_m2m: list[str] | None = None, ) -> tuple[ Model, dict[str, object], list[tuple[ManyToManyField | ForeignObjectRel, Any]], ]: """Prepare data for updates and creates. This method is a helper function for the create and update resolver methods. It's to prepare the data for updating or creating. """ model = instance.__class__ fields = get_model_fields(model) # Map FK attname (e.g. "color_id") so inputs using _id column names are recognized fk_attname_fields: dict[str, models.ForeignKey] = { f.attname: f for f in fields.values() if isinstance(f, models.ForeignKey) and f.attname not in fields } m2m: list[tuple[ManyToManyField | ForeignObjectRel, Any]] = [] direct_field_values: dict[str, object] = {} exclude_m2m = exclude_m2m or [] if dataclasses.is_dataclass(data): data = vars(data) for name, value in data.items(): # FK _id fields (e.g. color_id) carry raw PK values — pass through directly if name in fk_attname_fields and value is not UNSET: fk_field = fk_attname_fields[name] setattr(instance, name, value) if fk_field.is_cached(instance): fk_field.delete_cached_value(instance) direct_field_values[name] = value continue field = fields.get(name) direct_field_value = True if field is None or value is UNSET: # Dont use these, fallback to model defaults. direct_field_value = False elif isinstance(field, models.FileField): if value is None and instance.pk is not None: # We want to reset the file field value when None was passed in the # input, but `FileField.save_form_data` ignores None values. In that # case we manually pass False which clears the file # (but only if the instance is already saved and we are updating it) value = False # noqa: PLW2901 elif isinstance(field, (ManyToManyField, ForeignObjectRel)): if name in exclude_m2m: continue # m2m will be processed later m2m.append((field, value)) direct_field_value = False elif isinstance(field, models.ForeignKey) and isinstance( value, # We are using str here because strawberry.ID can't be used for isinstance (ParsedObject, str), ): value, value_data = _parse_data( # noqa: PLW2901 info, cast("type[Model]", field.related_model), value, key_attr=key_attr, full_clean=full_clean, ) if value is None and not value_data: value = None # noqa: PLW2901 # If foreign object is not found, then create it elif value in (None, UNSET): # noqa: PLR6201 value = create( # noqa: PLW2901 info, field.related_model, value_data, key_attr=key_attr, full_clean=full_clean, ) # If foreign object does not need updating, then skip it elif isinstance(value_data, dict) and not value_data: pass else: update( info, value, value_data, full_clean=full_clean, key_attr=key_attr, ) if direct_field_value: # We want to return the direct fields for processing # sepperatly when we're creating objects. # You can see this in the create() function direct_field_values.update({name: value}) # Make sure you dont pass Many2Many and FileFields # to your update_field function. This will not work. update_field(info, instance, field, value) # type: ignore return instance, direct_field_values, m2m @overload def create( info: Info, model: type[_M], data: dict[str, Any], *, key_attr: str | None = None, full_clean: bool | FullCleanOptions = True, pre_save_hook: Callable[[_M], None] | None = None, exclude_m2m: list[str] | None = None, ) -> _M: ... @overload def create( info: Info, model: type[_M], data: list[dict[str, Any]], *, key_attr: str | None = None, full_clean: bool | FullCleanOptions = True, pre_save_hook: Callable[[_M], None] | None = None, exclude_m2m: list[str] | None = None, ) -> list[_M]: ... def create( info: Info, model: type[_M], data: dict[str, Any] | list[dict[str, Any]], *, key_attr: str | None = None, full_clean: bool | FullCleanOptions = True, pre_save_hook: Callable[[_M], None] | None = None, exclude_m2m: list[str] | None = None, ) -> list[_M] | _M: return _create( info, model._default_manager, data, key_attr=key_attr, full_clean=full_clean, pre_save_hook=pre_save_hook, exclude_m2m=exclude_m2m, ) @transaction.atomic def _create( info: Info, manager: BaseManager, data: dict[str, Any] | list[dict[str, Any]], *, key_attr: str | None = None, full_clean: bool | FullCleanOptions = True, pre_save_hook: Callable[[_M], None] | None = None, exclude_m2m: list[str] | None = None, ) -> list[_M] | _M: model = manager.model # Before creating your instance, verify this is not a bulk create # if so, add them one by one. Otherwise, get to work. if isinstance(data, list): return [ create( info, model, d, key_attr=key_attr, full_clean=full_clean, exclude_m2m=exclude_m2m, ) for d in data ] # Also, the approach below will use the manager to create the instance # rather than manually creating it. If you have a pre_save_hook # use the update method instead. if pre_save_hook: return update( info, model(), data, key_attr=key_attr, full_clean=full_clean, pre_save_hook=pre_save_hook, ) # We will use a dummy-instance to trigger form validation # However, this instance should not be saved as it will # circumvent the manager create method. dummy_instance = model() _, create_kwargs, m2m = prepare_create_update( info=info, instance=dummy_instance, data=data, full_clean=full_clean, key_attr=key_attr, exclude_m2m=exclude_m2m, ) # Creating the instance directly via create() without full-clean will # raise ugly error messages. To generate user-friendly ones, we want # full-clean() to trigger form-validation style error messages. full_clean_options = full_clean if isinstance(full_clean, dict) else {} if full_clean: dummy_instance.full_clean(**full_clean_options) # Create the instance using the manager create method to respect # manager create overrides. This also ensures support for proxy-models. instance = manager.create(**create_kwargs) for field, value in m2m: update_m2m(info, instance, field, value, key_attr) return instance @overload def update( info: Info, instance: _M, data: dict[str, Any], *, key_attr: str | None = None, full_clean: bool | FullCleanOptions = True, pre_save_hook: Callable[[_M], None] | None = None, exclude_m2m: list[str] | None = None, ) -> _M: ... @overload def update( info: Info, instance: Iterable[_M], data: dict[str, Any], *, key_attr: str | None = None, full_clean: bool | FullCleanOptions = True, pre_save_hook: Callable[[_M], None] | None = None, exclude_m2m: list[str] | None = None, ) -> list[_M]: ... @transaction.atomic def update( info: Info, instance: _M | Iterable[_M], data: dict[str, Any], *, key_attr: str | None = None, full_clean: bool | FullCleanOptions = True, pre_save_hook: Callable[[_M], None] | None = None, exclude_m2m: list[str] | None = None, ) -> _M | list[_M]: # Unwrap lazy objects since they have a proxy __iter__ method that will make # them iterables even if the wrapped object isn't if isinstance(instance, LazyObject): instance = cast("_M", instance.__reduce__()[1][0]) if isinstance(instance, Iterable): instances = list(instance) return [ update( info, instance, data, key_attr=key_attr, full_clean=full_clean, pre_save_hook=pre_save_hook, exclude_m2m=exclude_m2m, ) for instance in instances ] instance, _, m2m = prepare_create_update( info=info, instance=instance, data=data, key_attr=key_attr, full_clean=full_clean, exclude_m2m=exclude_m2m, ) if pre_save_hook is not None: pre_save_hook(instance) full_clean_options = full_clean if isinstance(full_clean, dict) else {} if full_clean: instance.full_clean(**full_clean_options) # type: ignore instance.save() if m2m: for field, value in m2m: update_m2m(info, instance, field, value, key_attr, full_clean) instance.refresh_from_db() return instance @overload def delete( info: Info, instance: _M, *, data: dict[str, Any] | None = None, ) -> _M: ... @overload def delete( info: Info, instance: Iterable[_M], *, data: dict[str, Any] | None = None, ) -> list[_M]: ... @transaction.atomic def delete(info: Info, instance: _M | Iterable[_M], *, data=None) -> _M | list[_M]: # Unwrap lazy objects since they have a proxy __iter__ method that will make # them iterables even if the wrapped object isn't if isinstance(instance, LazyObject): instance = cast("_M", instance.__reduce__()[1][0]) if isinstance(instance, Iterable): many = True instances = list(instance) else: many = False instances = [instance] for instance in instances: pk = instance.pk instance.delete() # After the instance is deleted, set its ID to the original database's # ID so that the success response contains ID of the deleted object. instance.pk = pk return instances if many else instances[0] def update_field(info: Info, instance: Model, field: models.Field, value: Any): if value is UNSET: return data = None if ( value and isinstance(field, models.ForeignObject) and not isinstance(value, Model) ): value, data = _parse_pk(value, cast("type[Model]", field.related_model)) field.save_form_data(instance, value) # If data was passed to the foreign key, update it recursively if data and value: update(info, value, data) def update_m2m( info: Info, instance: Model, field: ManyToManyField | ForeignObjectRel, value: Any, key_attr: str | None = None, full_clean: bool | FullCleanOptions = True, ): if value in (None, UNSET): # noqa: PLR6201 return # FIXME / NOTE: Should this be here? # The field can only be ManyToManyField | ForeignObjectRel according to the definition # so why are there checks for OneTOneRel? if isinstance(field, OneToOneRel): remote_field = field.remote_field value, data = _parse_pk(value, remote_field.model, key_attr=key_attr) if value is None: value = getattr(instance, field.name) else: remote_field.save_form_data(value, instance) value.save() # If data was passed to the field, update it recursively if data: update(info, value, data) return # END FIXME use_remove = True if isinstance(field, ManyToManyField): manager = cast("RelatedManager", getattr(instance, field.attname)) reverse_field_name = field.remote_field.related_name # type: ignore else: assert isinstance(field, (ManyToManyRel, ManyToOneRel)) accessor_name = field.get_accessor_name() reverse_field_name = field.field.name assert accessor_name manager = cast("RelatedManager", getattr(instance, accessor_name)) if field.one_to_many: # remove if field is nullable, otherwise delete use_remove = field.remote_field.null is True # Create a data dict containing the reference to the instance and exclude it from # nested m2m creation (to break circular references) ref_instance_data = {reverse_field_name: instance} exclude_m2m = [reverse_field_name] to_add = [] to_remove = [] to_delete = [] need_remove_cache = False full_clean_options = full_clean if isinstance(full_clean, dict) else {} values = value.set if isinstance(value, ParsedObjectList) else value if isinstance(values, list): if isinstance(value, ParsedObjectList) and getattr(value, "add", None): raise ValueError("'add' cannot be used together with 'set'") if isinstance(value, ParsedObjectList) and getattr(value, "remove", None): raise ValueError("'remove' cannot be used together with 'set'") existing = set(manager.all()) need_remove_cache = need_remove_cache or bool(values) for v in values: obj, data = _parse_data( info, cast("type[Model]", manager.model), v, key_attr=key_attr, full_clean=full_clean, ) if obj: data.pop(key_attr, None) through_defaults = data.pop("through_defaults", {}) if data: for k, inner_value in data.items(): setattr(obj, k, inner_value) if full_clean: obj.full_clean(**full_clean_options) obj.save() if hasattr(manager, "through"): manager = cast("ManyToManyRelatedManager", manager) intermediate_model = manager.through try: im = intermediate_model._default_manager.get( **{ manager.source_field_name: instance, # type: ignore manager.target_field_name: obj, # type: ignore }, ) except intermediate_model.DoesNotExist: im = intermediate_model( **{ manager.source_field_name: instance, # type: ignore manager.target_field_name: obj, # type: ignore }, ) for k, inner_value in through_defaults.items(): setattr(im, k, inner_value) if full_clean: im.full_clean(**full_clean_options) im.save() elif obj not in existing: to_add.append(obj) existing.discard(obj) else: # If we've reached here, the key_attr should be UNSET or missing. So # let's remove it if it is there. data.pop(key_attr, None) obj = _create( info, manager, data | ref_instance_data, key_attr=key_attr, full_clean=full_clean, exclude_m2m=exclude_m2m, ) existing.discard(obj) for remaining in existing: if use_remove: to_remove.append(remaining) else: to_delete.append(remaining) else: need_remove_cache = need_remove_cache or bool(value.add) for v in value.add or []: obj, data = _parse_data( info, cast("type[Model]", manager.model), v, key_attr=key_attr, full_clean=full_clean, ) if obj and data: data.pop(key_attr, None) if full_clean: obj.full_clean(**full_clean_options) manager.add(obj, **data) elif obj: # Do this later in a bulk data.pop(key_attr, None) to_add.append(obj) elif data: # If we've reached here, the key_attr should be UNSET or missing. So # let's remove it if it is there. data.pop(key_attr, None) _create( info, manager, data | ref_instance_data, key_attr=key_attr, full_clean=full_clean, exclude_m2m=exclude_m2m, ) else: raise AssertionError need_remove_cache = need_remove_cache or bool(value.remove) for v in value.remove or []: obj, data = _parse_data( info, cast("type[Model]", manager.model), v, key_attr=key_attr, full_clean=full_clean, ) data.pop(key_attr, None) assert not data to_remove.append(obj) if to_add: manager.add(*to_add) if to_remove: manager.remove(*to_remove) if to_delete: manager.filter(pk__in=[item.pk for item in to_delete]).delete() if need_remove_cache: manager._remove_prefetched_objects() # type: ignore strawberry-graphql-django-0.82.1/strawberry_django/mutations/types.py000066400000000000000000000022431516173410200261720ustar00rootroot00000000000000from __future__ import annotations import dataclasses from typing import Any, TypeAlias, TypeVar, Union import strawberry from django.db import models from django.db.models import Model from strawberry import UNSET from typing_extensions import TypedDict _T = TypeVar("_T") # noqa: PYI018 _M = TypeVar("_M", bound=Model) InputListTypes: TypeAlias = Union[strawberry.ID, "ParsedObject"] class FullCleanOptions(TypedDict, total=False): exclude: list[str] validate_unique: bool validate_constraints: bool @dataclasses.dataclass class ParsedObject: pk: strawberry.ID | Model | None data: dict[str, Any] | None = None def parse(self, model: type[_M]) -> tuple[_M | None, dict[str, Any] | None]: if self.pk is None or self.pk is UNSET: return None, self.data if isinstance(self.pk, models.Model): assert isinstance(self.pk, model) return self.pk, self.data return model._default_manager.get(pk=self.pk), self.data @dataclasses.dataclass class ParsedObjectList: add: list[InputListTypes] | None = None remove: list[InputListTypes] | None = None set: list[InputListTypes] | None = None strawberry-graphql-django-0.82.1/strawberry_django/optimizer.py000066400000000000000000001743601516173410200250370ustar00rootroot00000000000000from __future__ import annotations import contextlib import contextvars import copy import dataclasses import itertools from collections.abc import Callable from typing import ( TYPE_CHECKING, Any, TypeVar, cast, ) from django.core.exceptions import FieldDoesNotExist from django.db import models from django.db.models import Prefetch from django.db.models.constants import LOOKUP_SEP from django.db.models.expressions import BaseExpression, Combinable from django.db.models.fields.reverse_related import ( ManyToManyRel, ManyToOneRel, OneToOneRel, ) from django.db.models.manager import BaseManager from django.db.models.query import QuerySet from graphql import ( FieldNode, GraphQLInterfaceType, GraphQLObjectType, GraphQLOutputType, GraphQLWrappingType, get_argument_values, ) from graphql.language.ast import OperationType from graphql.type.definition import GraphQLResolveInfo, get_named_type from strawberry import UNSET, relay from strawberry.extensions import SchemaExtension from strawberry.relay.utils import SliceMetadata from strawberry.schema.schema import Schema from strawberry.schema.schema_converter import get_arguments from strawberry.types import get_object_definition, has_object_definition from strawberry.types.base import StrawberryContainer from strawberry.types.info import Info from strawberry.types.object_type import StrawberryObjectDefinition from typing_extensions import assert_never, assert_type from strawberry_django.fields.types import resolve_model_field_name from strawberry_django.pagination import OffsetPaginated, apply_window_pagination from strawberry_django.queryset import get_queryset_config, run_type_get_queryset from strawberry_django.relay.list_connection import DjangoListConnection from strawberry_django.resolvers import django_fetch from .descriptors import ModelProperty from .utils.gql_compat import get_sub_field_selections from .utils.inspect import ( PrefetchInspector, get_model_field, get_model_fields, get_possible_concrete_types, get_possible_type_definitions, is_inheritance_manager, is_inheritance_qs, is_polymorphic_model, ) from .utils.typing import ( AnnotateCallable, AnnotateType, PrefetchCallable, PrefetchType, TypeOrMapping, TypeOrSequence, WithStrawberryDjangoObjectDefinition, get_django_definition, has_django_definition, unwrap_type, ) if TYPE_CHECKING: from collections.abc import Generator from django.contrib.contenttypes.fields import GenericRelation from strawberry.relay import Edge from strawberry.types.execution import ExecutionContext from strawberry.types.field import StrawberryField from strawberry.utils.await_maybe import AwaitableOrValue from strawberry_django.pagination import OffsetPaginationInfo __all__ = [ "DjangoOptimizerExtension", "OptimizerConfig", "OptimizerStore", "PrefetchType", "optimize", ] _M = TypeVar("_M", bound=models.Model) _sentinel = object() _annotate_placeholder = "__annotated_placeholder__" @dataclasses.dataclass class OptimizerConfig: """Django optimization configuration. Attributes ---------- enable_only: Enable `QuerySet.only` optimizations enable_select_related: Enable `QuerySet.select_related` optimizations enable_prefetch_related: Enable `QuerySet.prefetch_related` optimizations enable_annotate: Enable `QuerySet.annotate` optimizations enable_nested_relations_prefetch: Enable prefetch of nested relations optimizations. prefetch_custom_queryset: Use custom instead of _base_manager for prefetch querysets """ enable_only: bool = dataclasses.field(default=True) enable_select_related: bool = dataclasses.field(default=True) enable_prefetch_related: bool = dataclasses.field(default=True) enable_annotate: bool = dataclasses.field(default=True) enable_nested_relations_prefetch: bool = dataclasses.field(default=True) prefetch_custom_queryset: bool = dataclasses.field(default=False) @dataclasses.dataclass class OptimizerStore: """Django optimization store. Attributes ---------- only: Set of values to optimize using `QuerySet.only` selected: Set of values to optimize using `QuerySet.select_related` prefetch_related: Set of values to optimize using `QuerySet.prefetch_related` annotate: Dict of values to use in `QuerySet.annotate` """ only: list[str] = dataclasses.field(default_factory=list) select_related: list[str] = dataclasses.field(default_factory=list) prefetch_related: list[PrefetchType] = dataclasses.field(default_factory=list) annotate: dict[str, AnnotateType] = dataclasses.field(default_factory=dict) def __bool__(self): return any( [self.only, self.select_related, self.prefetch_related, self.annotate], ) def __ior__(self, other: OptimizerStore): self.only.extend(other.only) self.select_related.extend(other.select_related) self.prefetch_related.extend(other.prefetch_related) self.annotate.update(other.annotate) return self def __or__(self, other: OptimizerStore): new = self.copy() new |= other return new def copy(self): """Create a shallow copy of the store.""" return self.__class__( only=self.only[:], select_related=self.select_related[:], prefetch_related=self.prefetch_related[:], annotate=self.annotate.copy(), ) @classmethod def with_hints( cls, *, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, ): """Create a new store with the given hints.""" return cls( only=[only] if isinstance(only, str) else list(only or []), select_related=( [select_related] if isinstance(select_related, str) else list(select_related or []) ), prefetch_related=( [prefetch_related] if isinstance(prefetch_related, (str, Prefetch, Callable)) else list(prefetch_related or []) ), annotate=( # placeholder here, # because field name is evaluated later on .annotate call: {_annotate_placeholder: annotate} if isinstance(annotate, (BaseExpression, Combinable, Callable)) else dict(annotate or {}) ), ) def with_resolved_callables(self, info: Info): """Resolve any prefetch/annotate callables using the provided info and return a new store. This is used to resolve callables using the correct info object, scoped to their respective fields. """ if not any(callable(p) for p in self.prefetch_related) and not any( callable(a) for a in self.annotate.values() ): return self prefetch_related: list[PrefetchType] = [ p(info) if callable(p) else p for p in self.prefetch_related ] annotate: dict[str, AnnotateType] = { label: annotation(info) if callable(annotation) else annotation for label, annotation in self.annotate.items() } return self.__class__( only=self.only, select_related=self.select_related, prefetch_related=prefetch_related, annotate=annotate, ) def with_prefix(self, prefix: str, *, info: Info): """Create a copy of this store with the given prefix. This is useful when we need to apply the same store to a nested field. `prefix` will be prepended to all fields in the store. Any callables will be resolved, just like with_resolved_callables, to apply the prefix to their results. """ prefetch_related = [] for p in self.prefetch_related: if isinstance(p, Callable): assert_type(p, PrefetchCallable) p = p(info) # noqa: PLW2901 if isinstance(p, str): prefetch_related.append(f"{prefix}{LOOKUP_SEP}{p}") elif isinstance(p, Prefetch): # add_prefix modifies the field's prefetch object, so we copy it before p_copy = copy.copy(p) p_copy.add_prefix(prefix) prefetch_related.append(p_copy) else: # pragma:nocover assert_never(p) annotate = {} for k, v in self.annotate.items(): if isinstance(v, Callable): assert_type(v, AnnotateCallable) v = v(info) # noqa: PLW2901 annotate[f"{prefix}{LOOKUP_SEP}{k}"] = v return self.__class__( only=[f"{prefix}{LOOKUP_SEP}{i}" for i in self.only], select_related=[f"{prefix}{LOOKUP_SEP}{i}" for i in self.select_related], prefetch_related=prefetch_related, annotate=annotate, ) def apply( self, qs: QuerySet[_M], *, info: GraphQLResolveInfo, config: OptimizerConfig | None = None, ) -> QuerySet[_M]: """Apply this store optimizations to the given queryset.""" config = config or OptimizerConfig() qs = self._apply_prefetch_related( qs, info=info, config=config, ) qs, extra_only_set = self._apply_select_related( qs, info=info, config=config, ) qs = self._apply_only( qs, info=info, config=config, extra_only_set=extra_only_set, ) qs = self._apply_annotate( qs, info=info, config=config, ) return qs # noqa: RET504 def _apply_prefetch_related( self, qs: QuerySet[_M], *, info: GraphQLResolveInfo, config: OptimizerConfig, ) -> QuerySet[_M]: if not config.enable_prefetch_related or not self.prefetch_related: return qs abort_only = set() prefetch_lists = [ qs._prefetch_related_lookups, # type: ignore self.prefetch_related, ] # Add all str at the same time to make it easier to handle Prefetch below to_prefetch: dict[str, str | Prefetch] = { p: p for p in itertools.chain(*prefetch_lists) if isinstance(p, str) } strawberry_info = _create_strawberry_info(info) # Merge already existing prefetches together for p in itertools.chain(*prefetch_lists): # Already added above if isinstance(p, str): continue if isinstance(p, Callable): assert_type(p, PrefetchCallable) p = p(strawberry_info) # noqa: PLW2901 path = p.prefetch_to existing = to_prefetch.get(path) # The simplest case. The prefetch doesn't exist or is a string. # In this case, just replace it. if not existing or isinstance(existing, str): to_prefetch[path] = p if isinstance(existing, str): abort_only.add(path) continue p1 = PrefetchInspector(existing) p2 = PrefetchInspector(p) if getattr(existing, "_optimizer_sentinel", None) is _sentinel: ret = p1.merge(p2, allow_unsafe_ops=True) elif getattr(p, "_optimizer_sentinel", None) is _sentinel: ret = p2.merge(p1, allow_unsafe_ops=True) else: # The order here doesn't matter ret = p1.merge(p2) to_prefetch[path] = ret.prefetch # Abort only optimization if one prefetch related was made for everything for ao in abort_only: # cast is safe as the loop above only adds Prefetch instances as abort_only prefetch = cast("Prefetch", to_prefetch[ao]) if prefetch.queryset is not None: # type: ignore - queryset can be None prefetch.queryset.query.deferred_loading = ( set(), True, ) if config.enable_only: try: from django.contrib.contenttypes.fields import GenericRelation except (ImportError, RuntimeError): GenericRelation = None # noqa: N806 parent_model = qs.model for prefetch in to_prefetch.values(): if isinstance(prefetch, str) or prefetch.queryset is None: # type: ignore[reportUnnecessaryComparison] continue inspector = PrefetchInspector(prefetch) if inspector.only is None: continue path = prefetch.prefetch_through if LOOKUP_SEP in path: continue try: relation = parent_model._meta.get_field(path) except FieldDoesNotExist: relation = None for f in parent_model._meta.get_fields(): if getattr(f, "related_name", None) == path: relation = f break if relation is None: continue if isinstance(relation, (models.ManyToManyField, ManyToManyRel)): continue if GenericRelation is not None and isinstance( relation, GenericRelation ): fk_fields = frozenset({ relation.object_id_field_name, relation.content_type_field_name, }) elif isinstance(relation, (ManyToOneRel, OneToOneRel)): fk_field = getattr(relation, "field", None) fk_attname = ( getattr(fk_field, "attname", None) if fk_field else None ) if fk_attname is None: continue fk_fields = frozenset({fk_attname}) else: continue missing = fk_fields - inspector.only if missing: inspector.only |= missing # First prefetch_related(None) to clear all existing prefetches, and then # add ours, which also contains them. This is to avoid the # "lookup was already seen with a different queryset" error return qs.prefetch_related(None).prefetch_related(*to_prefetch.values()) def _apply_select_related( self, qs: QuerySet[_M], *, info: GraphQLResolveInfo, config: OptimizerConfig, ) -> tuple[QuerySet[_M], set[str]]: only_set = set(self.only) extra_only_set = set() select_related_set = set(self.select_related) # inspect the queryset to find any existing select_related fields def get_related_fields_with_prefix( queryset_select_related: dict[str, Any], prefix: str = "", ): for parent, nested in queryset_select_related.items(): current_path = f"{prefix}{parent}" yield current_path if nested: # If there are nested relations, dive deeper yield from get_related_fields_with_prefix( nested, prefix=f"{current_path}{LOOKUP_SEP}", ) if isinstance(qs.query.select_related, dict): select_related_set.update( get_related_fields_with_prefix(qs.query.select_related) ) if config.enable_select_related and select_related_set: qs = qs.select_related(*select_related_set) # Update our extra_select_related_only_set with the fields that were # selected by select_related to make sure they actually get selected for select_related in select_related_set: if select_related in only_set: continue if not any(only.startswith(select_related) for only in only_set): extra_only_set.add(select_related) return qs, extra_only_set def _apply_only( self, qs: QuerySet[_M], *, info: GraphQLResolveInfo, config: OptimizerConfig, extra_only_set: set[str], ) -> QuerySet[_M]: only_set = set(self.only) | extra_only_set if config.enable_only and only_set: qs = qs.only(*only_set) return qs def _apply_annotate( self, qs: QuerySet[_M], *, info: GraphQLResolveInfo, config: OptimizerConfig, ) -> QuerySet[_M]: if not config.enable_annotate or not self.annotate: return qs strawberry_info = _create_strawberry_info(info) to_annotate = {} for k, v in self.annotate.items(): if isinstance(v, Callable): assert_type(v, AnnotateCallable) v = v(strawberry_info) # noqa: PLW2901 to_annotate[k] = v return qs.annotate(**to_annotate) def _create_strawberry_info(raw_info: GraphQLResolveInfo) -> Info: schema: Schema = raw_info.schema._strawberry_schema # type: ignore field = schema.get_field_for_type(raw_info.field_name, raw_info.parent_type.name) assert field return schema.config.info_class(raw_info, field) def _get_django_type( field: StrawberryField, ) -> type[WithStrawberryDjangoObjectDefinition] | None: f_type = unwrap_type(field.type) return f_type if has_django_definition(f_type) else None def _get_prefetch_queryset( remote_model: type[models.Model], schema: Schema, field: StrawberryField, parent_type: GraphQLObjectType | GraphQLInterfaceType, field_node: FieldNode, *, config: OptimizerConfig | None, info: GraphQLResolveInfo, related_field_id: str | None = None, ) -> QuerySet: # We usually want to use the `_base_manager` for prefetching, as it is what django # itself states we should be using: # https://docs.djangoproject.com/en/5.0/topics/db/managers/#base-managers # But in case prefetch_custom_queryset is enabled, we use the custom queryset # from _default_manager instead. if config and config.prefetch_custom_queryset: qs = remote_model._default_manager.all() else: qs = remote_model._base_manager.all() # type: ignore if f_type := _get_django_type(field): qs = run_type_get_queryset( qs, f_type, info=Info( _raw_info=info, _field=field, ), ) return _optimize_prefetch_queryset( qs, schema, field, parent_type, field_node, config=config, info=info, related_field_id=related_field_id, ) def _optimize_prefetch_queryset( qs: QuerySet[_M], schema: Schema, field: StrawberryField, parent_type: GraphQLObjectType | GraphQLInterfaceType, field_node: FieldNode, *, config: OptimizerConfig | None, info: GraphQLResolveInfo, related_field_id: str | None = None, ) -> QuerySet[_M]: from strawberry_django.fields.field import ( StrawberryDjangoConnectionExtension, StrawberryDjangoField, ) from strawberry_django.relay.cursor_connection import ( DjangoCursorConnection, apply_cursor_pagination, ) if ( not config or not config.enable_nested_relations_prefetch or related_field_id is None or not isinstance(field, StrawberryDjangoField) or is_optimized_by_prefetching(qs) ): return qs mark_optimized = True strawberry_schema = cast("Schema", info.schema._strawberry_schema) # type: ignore field_name = strawberry_schema.config.name_converter.from_field(field) field_info = Info( _raw_info=info, _field=field, ) _field_args, field_kwargs = get_arguments( field=field, source=None, info=field_info, kwargs=get_argument_values( parent_type.fields[field_name], field_node, info.variable_values, ), config=strawberry_schema.config, scalar_registry=strawberry_schema.schema_converter.scalar_registry, ) field_kwargs.pop("info", None) # Disable the optimizer to avoid doing double optimization while running get_queryset with DjangoOptimizerExtension.disabled(): qs = field.get_queryset( qs, field_info, _strawberry_related_field_id=related_field_id, **field_kwargs, ) connection_extension = next( ( e for e in field.extensions if isinstance(e, StrawberryDjangoConnectionExtension) ), None, ) if connection_extension is not None: connection_type_def = get_object_definition( connection_extension.connection_type, strict=True, ) connection_type = ( connection_type_def.concrete_of and connection_type_def.concrete_of.origin ) if ( connection_type is relay.ListConnection or connection_type is DjangoListConnection ): field_def_ = connection_type_def.get_field("edges") assert field_def_ field_ = field_def_.resolve_type(type_definition=connection_type_def) field_ = unwrap_type(field_) edge_class = cast("Edge", field_) slice_metadata = SliceMetadata.from_arguments( Info(_raw_info=info, _field=field), first=field_kwargs.get("first"), last=field_kwargs.get("last"), before=field_kwargs.get("before"), after=field_kwargs.get("after"), max_results=connection_extension.max_results, prefix=edge_class.CURSOR_PREFIX, ) mark_reversed = slice_metadata.expected is None qs = apply_window_pagination( qs, related_field_id=related_field_id, offset=slice_metadata.start, limit=( field_kwargs.get("last", UNSET) if mark_reversed else slice_metadata.end - slice_metadata.start ), max_results=connection_extension.max_results, reverse=mark_reversed, ) elif connection_type is DjangoCursorConnection: qs, _ = apply_cursor_pagination( qs, related_field_id=related_field_id, info=Info(_raw_info=info, _field=field), first=field_kwargs.get("first"), last=field_kwargs.get("last"), before=field_kwargs.get("before"), after=field_kwargs.get("after"), max_results=connection_extension.max_results, ) else: mark_optimized = False if isinstance(field.type, type) and issubclass(field.type, OffsetPaginated): pagination: OffsetPaginationInfo | None = field_kwargs.get("pagination") qs = apply_window_pagination( qs, related_field_id=related_field_id, offset=pagination.offset if pagination else 0, limit=pagination.limit if pagination else -1, ) if mark_optimized: qs = mark_optimized_by_prefetching(qs) return qs def _get_selections( info: GraphQLResolveInfo, parent_type: GraphQLObjectType | GraphQLInterfaceType, ) -> dict[str, list[FieldNode]]: return get_sub_field_selections(info, parent_type) def _get_field_arguments(node: FieldNode) -> tuple: return tuple(sorted(node.arguments or (), key=lambda a: a.name.value)) def _generate_selection_resolve_info( info: GraphQLResolveInfo, field_nodes: list[FieldNode], return_type: GraphQLOutputType, parent_type: GraphQLObjectType | GraphQLInterfaceType, ): field_node = field_nodes[0] return GraphQLResolveInfo( field_name=field_node.name.value, field_nodes=field_nodes, return_type=return_type, parent_type=cast("GraphQLObjectType", parent_type), path=info.path.add_key(0).add_key(field_node.name.value, parent_type.name), schema=info.schema, fragments=info.fragments, root_value=info.root_value, operation=info.operation, variable_values=info.variable_values, context=info.context, is_awaitable=info.is_awaitable, ) def _get_field_data( selections: list[FieldNode], object_definition: StrawberryObjectDefinition, schema: Schema, *, parent_type: GraphQLObjectType | GraphQLInterfaceType, info: GraphQLResolveInfo, ) -> tuple[StrawberryField, GraphQLObjectType, FieldNode, GraphQLResolveInfo] | None: selection = selections[0] field_name = selection.name.value for field in object_definition.fields: if schema.config.name_converter.get_graphql_name(field) == field_name: break else: return None # Do not optimize the field if the user asked not to if getattr(field, "disable_optimization", False): return None definition = parent_type.fields[selection.name.value].type while isinstance(definition, GraphQLWrappingType): definition = definition.of_type field_info = _generate_selection_resolve_info( info, selections, definition, parent_type, ) return field, definition, selection, field_info def _get_hints_from_field( field: StrawberryField, *, f_info: Info, prefix: str = "", ) -> OptimizerStore | None: if not ( field_store := cast("OptimizerStore | None", getattr(field, "store", None)) ): return None if len(field_store.annotate) == 1 and _annotate_placeholder in field_store.annotate: # This is a special case where we need to update the field name, # because when field_store was created on __init__, # the field name wasn't available. # This allows for annotate expressions to be declared as: # total: int = gql.django.field(annotate=Sum("price")) # noqa: ERA001 # Instead of the more redundant: # total: int = gql.django.field(annotate={"total": Sum("price")}) # noqa: ERA001 field_store.annotate = { field.name: field_store.annotate[_annotate_placeholder], } # with_prefix also resolves callables, so we only need one or the other return ( field_store.with_prefix(prefix, info=f_info) if prefix else field_store.with_resolved_callables(f_info) ) def _get_hints_from_model_property( model: type[models.Model], *, f_info: Info, prefix: str = "", ) -> OptimizerStore | None: model_attr = getattr(model, f_info.python_name, None) if ( model_attr is not None and isinstance(model_attr, ModelProperty) and model_attr.store ): attr_store = model_attr.store # with_prefix also resolves callables, so we only need one or the other store = ( attr_store.with_prefix(prefix, info=f_info) if prefix else attr_store.with_resolved_callables(f_info) ) else: store = None return store def _must_use_prefetch_related( config: OptimizerConfig, field: StrawberryField, model_field: models.ForeignKey | OneToOneRel, ) -> bool: f_type = _get_django_type(field) # - If the field has a get_queryset method, use Prefetch so it will be respected # - If the model is using django-polymorphic, # use Prefetch so its custom queryset will be used, returning polymorphic models return ( (f_type and hasattr(f_type, "get_queryset")) or is_polymorphic_model(model_field.related_model) or is_inheritance_manager( model_field.related_model._default_manager if config.prefetch_custom_queryset else model_field.related_model._base_manager # type: ignore ) ) def _get_hints_from_django_foreign_key( field: StrawberryField, field_definition: GraphQLObjectType, field_selection: FieldNode, model_field: models.ForeignKey | OneToOneRel, model_fieldname: str, schema: Schema, *, config: OptimizerConfig, parent_type: GraphQLObjectType | GraphQLInterfaceType, field_info: GraphQLResolveInfo, path: str, cache: dict[type[models.Model], list[tuple[int, OptimizerStore]]], level: int = 0, ) -> OptimizerStore: # First, collect hints from nested fields to check if they have annotations, # which are incompatible with select_related. # Annotations must be applied directly to the related model's queryset, not # prefixed onto the outer queryset (which is what with_prefix does). nested_stores: list[ tuple[StrawberryObjectDefinition, type[models.Model], OptimizerStore] ] = [] for f_type_def in get_possible_type_definitions(field.type): f_model = model_field.related_model f_store = _get_model_hints( f_model, schema, f_type_def, parent_type=field_definition, info=field_info, config=config, cache=cache, level=level + 1, ) if f_store is not None: nested_stores.append((f_type_def, f_model, f_store)) # Check if any nested store has annotations. # Annotations are incompatible with select_related because they need to be # applied directly to the related model's queryset (via Prefetch), not # prefixed onto the outer queryset. has_annotations = any(f_store.annotate for _, _, f_store in nested_stores) if _must_use_prefetch_related(config, field, model_field) or has_annotations: store = _get_hints_from_django_relation( field, field_selection=field_selection, model_field=model_field, model_fieldname=model_fieldname, schema=schema, config=config, parent_type=parent_type, field_info=field_info, path=path, cache=cache, level=level, ) store.only.append(path) return store store = OptimizerStore.with_hints( only=[path], select_related=[path], ) # If adding a reverse relation, make sure to select its pointer to us, # or else this might causa a refetch from the database if isinstance(model_field, OneToOneRel): remote_field = model_field.remote_field store.only.append( f"{path}{LOOKUP_SEP}{resolve_model_field_name(remote_field)}", ) strawberry_info = schema.config.info_class(_raw_info=field_info, _field=field) for _f_type_def, f_model, f_store in nested_stores: cache.setdefault(f_model, []).append((level, f_store)) store |= f_store.with_prefix(path, info=strawberry_info) return store def _get_hints_from_django_relation( field: StrawberryField, field_selection: FieldNode, model_field: ( models.ManyToManyField | ManyToManyRel | ManyToOneRel | GenericRelation | OneToOneRel | models.ForeignKey ), model_fieldname: str, schema: Schema, *, config: OptimizerConfig, parent_type: GraphQLObjectType | GraphQLInterfaceType, field_info: GraphQLResolveInfo, path: str, cache: dict[type[models.Model], list[tuple[int, OptimizerStore]]], level: int = 0, ) -> OptimizerStore: try: from django.contrib.contenttypes.fields import GenericRelation except (ImportError, RuntimeError): # pragma: no cover GenericRelation = None # noqa: N806 store = OptimizerStore() f_types = list(get_possible_type_definitions(field.type)) if len(f_types) > 1: # This might be a generic foreign key. # In this case, just prefetch it store.prefetch_related.append(model_fieldname) return store field_store = getattr(field, "store", None) if field_store and field_store.prefetch_related: # Skip optimization if 'prefetch_related' is present in the field's store. # This is necessary because 'prefetch_related' likely modifies the queryset # with filtering or annotating, making the optimization redundant and # potentially causing an extra unused query. return store remote_field = model_field.remote_field remote_model = remote_field.model field_store = None f_type = f_types[0] subclasses = [] for concrete_field_type in get_possible_concrete_types( remote_model, schema, f_type ): django_definition = get_django_definition(concrete_field_type.origin) if ( django_definition and django_definition.model != remote_model and not django_definition.model._meta.abstract and issubclass(django_definition.model, remote_model) ): subclasses.append(django_definition.model) concrete_store = _get_model_hints( remote_model, schema, concrete_field_type, parent_type=_get_gql_definition(schema, concrete_field_type), info=field_info, config=config, cache=cache, level=level + 1, ) if concrete_store is not None: field_store = ( concrete_store if field_store is None else field_store | concrete_store ) if field_store is None: return store related_field_id = getattr(remote_field, "attname", None) or getattr( remote_field, "name", None ) if ( config.enable_only and field_store.only and not isinstance(remote_field, ManyToManyRel) ): # If adding a reverse relation, make sure to select its # pointer to us, or else this might causa a refetch from # the database if GenericRelation is not None and isinstance( model_field, GenericRelation, ): field_store.only.append(model_field.object_id_field_name) field_store.only.append(model_field.content_type_field_name) elif related_field_id is not None: field_store.only.append(related_field_id) path_lookup = f"{path}{LOOKUP_SEP}" if store.only and field_store.only: extra_only = [o for o in store.only or [] if o.startswith(path_lookup)] store.only = [o for o in store.only if o not in extra_only] field_store.only.extend(o[len(path_lookup) :] for o in extra_only) if store.select_related and field_store.select_related: extra_sr = [o for o in store.select_related or [] if o.startswith(path_lookup)] store.select_related = [o for o in store.select_related if o not in extra_sr] field_store.select_related.extend(o[len(path_lookup) :] for o in extra_sr) cache.setdefault(remote_model, []).append((level, field_store)) base_qs = _get_prefetch_queryset( remote_model, schema, field, parent_type, field_selection, config=config, info=field_info, related_field_id=related_field_id, ) if is_inheritance_qs(base_qs): base_qs = base_qs.select_subclasses(*subclasses) field_qs = field_store.apply(base_qs, info=field_info, config=config) field_prefetch = Prefetch(path, queryset=field_qs) field_prefetch._optimizer_sentinel = _sentinel # type: ignore store.prefetch_related.append(field_prefetch) return store def _get_hints_from_django_field( field: StrawberryField, field_definition: GraphQLObjectType, field_selection: FieldNode, model: type[models.Model], schema: Schema, *, config: OptimizerConfig, parent_type: GraphQLObjectType | GraphQLInterfaceType, field_info: GraphQLResolveInfo, prefix: str = "", cache: dict[type[models.Model], list[tuple[int, OptimizerStore]]], level: int = 0, ) -> OptimizerStore | None: try: from django.contrib.contenttypes.fields import ( GenericForeignKey, GenericRelation, ) except (ImportError, RuntimeError): # pragma: no cover GenericForeignKey = None # noqa: N806 GenericRelation = None # noqa: N806 relation_fields = (models.ManyToManyField, ManyToManyRel, ManyToOneRel) else: relation_fields = ( models.ManyToManyField, ManyToManyRel, ManyToOneRel, GenericRelation, ) # If the field has a base resolver, don't try to optimize it. The user should # be defining custom hints in this case, which should already be in the store # GlobalID and special cases setting `can_optimize` are ok though, as those resolvers # are auto generated by us if ( field.base_resolver is not None and field.type != relay.GlobalID and not getattr(field.base_resolver.wrapped_func, "can_optimize", False) ): return None model_fieldname: str = getattr(field, "django_name", None) or field.python_name if LOOKUP_SEP in model_fieldname: model_field = None relation_prefix = "" parts = model_fieldname.split(LOOKUP_SEP) if not all(parts): return None current_model = model relation_parts: list[str] = [] for index, part in enumerate(parts): try: current_field = get_model_field(current_model, part) except FieldDoesNotExist: return None if index < len(parts) - 1: if not isinstance(current_field, (models.ForeignKey, OneToOneRel)): return None related_model = getattr(current_field, "related_model", None) if related_model is None: return None current_model = related_model relation_parts.append(part) continue model_field = current_field relation_prefix = LOOKUP_SEP.join(relation_parts) else: model_field = get_model_field(model, model_fieldname) relation_prefix = "" if model_field is None: return None lookup_prefix = prefix + LOOKUP_SEP if prefix else "" path = f"{lookup_prefix}{model_fieldname}" if isinstance(model_field, (models.ForeignKey, OneToOneRel)): store = _get_hints_from_django_foreign_key( field, field_definition=field_definition, field_selection=field_selection, model_field=model_field, model_fieldname=model_fieldname, schema=schema, config=config, parent_type=parent_type, field_info=field_info, path=path, cache=cache, level=level, ) elif GenericForeignKey and isinstance(model_field, GenericForeignKey): # There's not much we can do to optimize generic foreign keys regarding # only/select_related because they can be anything. # Just prefetch_related them store = OptimizerStore.with_hints(prefetch_related=[model_fieldname]) elif isinstance(model_field, relation_fields): store = _get_hints_from_django_relation( field, field_selection=field_selection, model_field=model_field, model_fieldname=model_fieldname, schema=schema, config=config, parent_type=parent_type, field_info=field_info, path=path, cache=cache, level=level, ) else: store = OptimizerStore.with_hints(only=[path]) if relation_prefix: relation_path = f"{lookup_prefix}{relation_prefix}" if not any(sr.startswith(relation_path) for sr in store.select_related): store.select_related.append(relation_path) return store def _get_model_hints( model: type[models.Model], schema: Schema, object_definition: StrawberryObjectDefinition, *, parent_type: GraphQLObjectType | GraphQLInterfaceType, info: GraphQLResolveInfo, config: OptimizerConfig | None = None, prefix: str = "", cache: dict[type[models.Model], list[tuple[int, OptimizerStore]]] | None = None, level: int = 0, subclass_collection: set[type[models.Model]] | None = None, ) -> OptimizerStore | None: cache = cache or {} # In case this is a relay field, the selected fields are inside edges -> node selection if issubclass(object_definition.origin, relay.Connection): return _get_model_hints_from_connection( model, schema, object_definition, parent_type=parent_type, info=info, config=config, prefix=prefix, cache=cache, level=level, subclass_collection=subclass_collection, ) # In case this is a Paginated field, the selected fields are inside results selection if issubclass(object_definition.origin, OffsetPaginated): return _get_model_hints_from_paginated( model, schema, object_definition, parent_type=parent_type, info=info, config=config, prefix=prefix, cache=cache, level=level, subclass_collection=subclass_collection, ) store = OptimizerStore() config = config or OptimizerConfig() dj_definition = get_django_definition(object_definition.origin) if dj_definition is None or dj_definition.disable_optimization: return None if not issubclass(model, dj_definition.model): # If this is a PolymorphicModel, also try to optimize fields in subclasses # of the current model. if not dj_definition.model._meta.abstract and issubclass( dj_definition.model, model ): if subclass_collection is not None: subclass_collection.add(dj_definition.model) if is_polymorphic_model(model): # These must be prefixed with app_label__ModelName___ (note three underscores) # This is a special syntax for django-polymorphic: # https://django-polymorphic.readthedocs.io/en/stable/advanced.html#polymorphic-filtering-for-fields-in-inherited-classes # "prefix" however is written in terms of not including the final LOOKUP_SEP (i.e. "__") # So we don't include the final __ here. return _get_model_hints( dj_definition.model, schema, object_definition, parent_type=parent_type, info=info, config=config, prefix=f"{prefix}{dj_definition.model.__name__}_", ) if is_inheritance_manager(model._default_manager) and ( path_from_parent := dj_definition.model._meta.get_path_from_parent( model ) ): prefix = LOOKUP_SEP.join( p.join_field.get_accessor_name() for p in path_from_parent ) return _get_model_hints( dj_definition.model, schema, object_definition, parent_type=parent_type, info=info, config=config, prefix=prefix, ) return None dj_type_store = getattr(dj_definition, "store", None) if dj_type_store: store |= dj_type_store lookup_prefix = prefix + LOOKUP_SEP if prefix else "" # Make sure that the model's pk is always selected when using only pk = model._meta.pk if pk is not None: store.only.append(lookup_prefix + pk.attname) # If this is a polymorphic Model, make sure to select its content type if is_polymorphic_model(model): store.only.extend( lookup_prefix + f for f in model.polymorphic_internal_model_fields ) # Group selections by actual field name to detect aliases selections_by_key = _get_selections(info, parent_type) field_name_groups: dict[str, list[list[FieldNode]]] = {} for field_nodes in selections_by_key.values(): name = field_nodes[0].name.value field_name_groups.setdefault(name, []).append(field_nodes) # Merge aliased selections with same arguments; skip those with different args merged_node_lists: list[list[FieldNode]] = [] for groups in field_name_groups.values(): if len(groups) == 1: merged_node_lists.append(groups[0]) else: first_args = _get_field_arguments(groups[0][0]) if all(_get_field_arguments(g[0]) == first_args for g in groups[1:]): merged_node_lists.append([node for group in groups for node in group]) selections = [ field_data for f_nodes in merged_node_lists if ( field_data := _get_field_data( f_nodes, object_definition, schema, parent_type=parent_type, info=info, ) ) is not None ] for field, f_definition, f_selection, f_info in selections: strawberry_info = schema.config.info_class(_raw_info=f_info, _field=field) # Add annotations from the field if they exist if field_store := _get_hints_from_field( field, f_info=strawberry_info, prefix=prefix ): store |= field_store # Then from the model property if one is defined if model_property_store := _get_hints_from_model_property( model, f_info=strawberry_info, prefix=prefix, ): store |= model_property_store # Lastly, from the django field itself if model_field_store := _get_hints_from_django_field( field, f_definition, f_selection, model, schema, config=config, parent_type=parent_type, field_info=f_info, prefix=prefix, cache=cache, level=level, ): store |= model_field_store # Django keeps track of known fields. That means that if one model select_related or # prefetch_related another one, and later another one select_related or # prefetch_related the model again, if the used fields there where not optimized in # this call django would have to fetch those again. By mergint those with us we are # making sure to avoid that for inner_level, inner_store in cache.get(model, []): if inner_level > level and inner_store: # We only want the only/select_related from this. prefetch_related is # something else store.only.extend(inner_store.only) store.select_related.extend(inner_store.select_related) # In case we skipped optimization for a relation, we might end up with a new QuerySet # which would not select its parent relation field on `.only()`, causing n+1 issues. # Make sure that in this case we also select it. if level == 0 and store.only: own_fk_fields = [ field for field in get_model_fields(model).values() if isinstance(field, models.ForeignKey) ] path = info.path while path: if not path.typename: path = path.prev continue type_ = schema.get_type_by_name(path.typename) if not isinstance(type_, StrawberryObjectDefinition): path = path.prev continue if strawberry_django_type := get_django_definition(type_.origin): for field in own_fk_fields: if field.related_model is strawberry_django_type.model: store.only.append(field.attname) break path = path.prev return store def _get_gql_definition( schema: Schema, definition: StrawberryObjectDefinition, ) -> GraphQLInterfaceType | GraphQLObjectType: if definition.is_interface: return schema.schema_converter.from_interface(definition) return schema.schema_converter.from_object(definition) def _get_model_hints_from_connection( model: type[models.Model], schema: Schema, object_definition: StrawberryObjectDefinition, *, parent_type: GraphQLObjectType | GraphQLInterfaceType, info: GraphQLResolveInfo, config: OptimizerConfig | None = None, prefix: str = "", cache: dict[type[models.Model], list[tuple[int, OptimizerStore]]] | None = None, level: int = 0, subclass_collection: set[type[models.Model]] | None = None, ) -> OptimizerStore | None: store = None n_type = object_definition.type_var_map.get("NodeType") if n_type is None: specialized_type_var_map = object_definition.specialized_type_var_map or {} n_type = specialized_type_var_map["NodeType"] n_type = unwrap_type(n_type) n_definition = get_object_definition(n_type, strict=True) for edges in _get_selections(info, parent_type).values(): edge = edges[0] if edge.name.value != "edges": continue e_field = object_definition.get_field("edges") if e_field is None: break e_definition = e_field.type while isinstance(e_definition, StrawberryContainer): e_definition = e_definition.of_type if has_object_definition(e_definition): e_definition = get_object_definition(e_definition, strict=True) assert isinstance(e_definition, StrawberryObjectDefinition) # Get the GraphQL definition first, then look it up in the schema # to ensure we get the properly specialized/registered type e_gql_definition = _get_gql_definition( schema, e_definition, ) assert isinstance(e_gql_definition, (GraphQLObjectType, GraphQLInterfaceType)) # For generic Edge types, we need to get the actual specialized type from the GraphQL schema # Otherwise fragments defined on the concrete Edge type (e.g. UserTypeEdge) won't match edge_type_name = f"{n_definition.name}Edge" if edge_type_name in schema.schema_converter.type_map: specialized_edge = schema.schema_converter.type_map[edge_type_name] if hasattr(specialized_edge, "implementation"): specialized_edge = specialized_edge.implementation if isinstance(specialized_edge, (GraphQLObjectType, GraphQLInterfaceType)): e_gql_definition = specialized_edge e_info = _generate_selection_resolve_info( info, edges, e_gql_definition, parent_type, ) for nodes in _get_selections(e_info, e_gql_definition).values(): node = nodes[0] if node.name.value != "node": continue for concrete_n_type in get_possible_concrete_types( model, schema, n_definition ): n_gql_definition = _get_gql_definition(schema, concrete_n_type) assert isinstance( n_gql_definition, (GraphQLObjectType, GraphQLInterfaceType), ) n_info = _generate_selection_resolve_info( info, nodes, n_gql_definition, e_gql_definition, ) concrete_store = _get_model_hints( model=model, schema=schema, object_definition=concrete_n_type, parent_type=n_gql_definition, info=n_info, config=config, prefix=prefix, cache=cache, level=level, subclass_collection=subclass_collection, ) if concrete_store is not None: store = concrete_store if store is None else store | concrete_store return store def _get_model_hints_from_paginated( model: type[models.Model], schema: Schema, object_definition: StrawberryObjectDefinition, *, parent_type: GraphQLObjectType | GraphQLInterfaceType, info: GraphQLResolveInfo, config: OptimizerConfig | None = None, prefix: str = "", cache: dict[type[models.Model], list[tuple[int, OptimizerStore]]] | None = None, level: int = 0, subclass_collection: set[type[models.Model]] | None = None, ) -> OptimizerStore | None: store = None n_type = unwrap_type(object_definition.type_var_map.get("NodeType")) n_definition = get_object_definition(n_type, strict=True) for selections in _get_selections(info, parent_type).values(): selection = selections[0] if selection.name.value != "results": continue for concrete_n_type in get_possible_concrete_types(model, schema, n_definition): n_gql_definition = _get_gql_definition( schema, concrete_n_type, ) assert isinstance( n_gql_definition, (GraphQLObjectType, GraphQLInterfaceType) ) n_info = _generate_selection_resolve_info( info, selections, n_gql_definition, n_gql_definition, ) concrete_store = _get_model_hints( model=model, schema=schema, object_definition=concrete_n_type, parent_type=n_gql_definition, info=n_info, config=config, prefix=prefix, cache=cache, level=level, subclass_collection=subclass_collection, ) if concrete_store is not None: store = concrete_store if store is None else store | concrete_store return store def optimize( qs: QuerySet[_M] | BaseManager[_M], info: GraphQLResolveInfo | Info, *, config: OptimizerConfig | None = None, store: OptimizerStore | None = None, ) -> QuerySet[_M]: """Optimize the given queryset considering the gql info. This will look through the gql selections, fields and model hints and apply `only`, `select_related`, `prefetch_related` and `annotate` optimizations according those on the `QuerySet`_. Note: ---- This do not execute the queryset, it only optimizes it for when it is actually executed. It will also avoid doing any extra optimization if the queryset already has cached results in it, to avoid triggering extra queries later. Args: ---- qs: The queryset to be optimized info: The current field execution info config: Optional config to use when doing the optimization store: Optional initial store to use for the optimization Returns: ------- The optimized queryset .. _QuerySet: https://docs.djangoproject.com/en/dev/ref/models/querysets/ """ if isinstance(qs, BaseManager): qs = cast("QuerySet[_M]", qs.all()) if isinstance(qs, list): # return sliced queryset as-is return qs # Avoid optimizing twice and also modify an already resolved queryset if is_optimized(qs) or qs._result_cache is not None: # type: ignore return qs if isinstance(info, Info): info = info._raw_info config = config or OptimizerConfig() store = store or OptimizerStore() schema = cast("Schema", info.schema._strawberry_schema) # type: ignore gql_type = get_named_type(info.return_type) strawberry_type = schema.get_type_by_name(gql_type.name) if strawberry_type is None: return qs inheritance_qs = is_inheritance_qs(qs) subclasses = set() if inheritance_qs else None for inner_object_definition in get_possible_concrete_types( qs.model, schema, strawberry_type ): parent_type = _get_gql_definition(schema, inner_object_definition) new_store = _get_model_hints( qs.model, schema, inner_object_definition, parent_type=parent_type, info=info, config=config, subclass_collection=subclasses, ) if new_store is not None: store |= new_store if store: if inheritance_qs and subclasses: qs = qs.select_subclasses(*subclasses) qs = store.apply(qs, info=info, config=config) qs_config = get_queryset_config(qs) qs_config.optimized = True return qs def is_optimized(qs: QuerySet) -> bool: config = get_queryset_config(qs) return config.optimized or config.optimized_by_prefetching def mark_optimized_by_prefetching(qs: QuerySet[_M]) -> QuerySet[_M]: get_queryset_config(qs).optimized_by_prefetching = True return qs def is_optimized_by_prefetching(qs: QuerySet) -> bool: return get_queryset_config(qs).optimized_by_prefetching optimizer: contextvars.ContextVar[DjangoOptimizerExtension | None] = ( contextvars.ContextVar( "optimizer_ctx", default=None, ) ) class DjangoOptimizerExtension(SchemaExtension): """Automatically optimize returned querysets from internal resolvers. Attributes ---------- enable_only_optimization: Enable `QuerySet.only` optimizations enable_select_related_optimization: Enable `QuerySet.select_related` optimizations enable_prefetch_related_optimization: Enable `QuerySet.prefetch_related` optimizations enable_nested_relations_prefetch: Enable prefetch of nested relations. This will allow for nested relations to be prefetched even when using filters/ordering/pagination. Note however that for connections, it will only work when for the `ListConnection` and `DjangoListConnection` types, as this optimization is not safe to be applied automatically for custom connections. enable_annotate_optimization: Enable `QuerySet.annotate` optimizations Examples -------- Add the following to your schema configuration. >>> import strawberry >>> from strawberry_django.optimizer import DjangoOptimizerExtension ... >>> schema = strawberry.Schema( ... Query, ... extensions=[ ... DjangoOptimizerExtension(), ... ] ... ) """ enabled: contextvars.ContextVar[bool] = contextvars.ContextVar( "optimizer_enabled_ctx", default=True, ) def __init__( self, *, enable_only_optimization: bool = True, enable_select_related_optimization: bool = True, enable_prefetch_related_optimization: bool = True, enable_annotate_optimization: bool = True, enable_nested_relations_prefetch: bool = True, execution_context: ExecutionContext | None = None, prefetch_custom_queryset: bool = False, ): super().__init__(execution_context=execution_context) self.enable_only = enable_only_optimization self.enable_select_related = enable_select_related_optimization self.enable_prefetch_related = enable_prefetch_related_optimization self.enable_annotate_optimization = enable_annotate_optimization self.enable_nested_relations_prefetch = enable_nested_relations_prefetch self.prefetch_custom_queryset = prefetch_custom_queryset if enable_nested_relations_prefetch: from strawberry_django.utils.patches import apply_pagination_fix apply_pagination_fix() def on_execute(self) -> Generator[None]: token = optimizer.set(self) try: yield finally: optimizer.reset(token) def resolve( self, next_: Callable, root: Any, info: GraphQLResolveInfo, *args, **kwargs, ) -> AwaitableOrValue[Any]: ret = next_(root, info, *args, **kwargs) if not self.enabled.get(): return ret if isinstance(ret, BaseManager): ret = ret.all() if isinstance(ret, QuerySet) and ret._result_cache is None: # type: ignore config = OptimizerConfig( enable_only=( self.enable_only and info.operation.operation == OperationType.QUERY ), enable_select_related=self.enable_select_related, enable_prefetch_related=self.enable_prefetch_related, enable_annotate=self.enable_annotate_optimization, prefetch_custom_queryset=self.prefetch_custom_queryset, enable_nested_relations_prefetch=self.enable_nested_relations_prefetch, ) ret = django_fetch(optimize(qs=ret, info=info, config=config)) return ret @classmethod @contextlib.contextmanager def disabled(cls): token = cls.enabled.set(False) try: yield finally: cls.enabled.reset(token) def optimize( self, qs: QuerySet[_M] | BaseManager[_M], info: GraphQLResolveInfo | Info, *, store: OptimizerStore | None = None, ) -> QuerySet[_M]: if not self.enabled.get(): return qs config = OptimizerConfig( enable_only=self.enable_only and info.operation.operation == OperationType.QUERY, enable_select_related=self.enable_select_related, enable_prefetch_related=self.enable_prefetch_related, enable_annotate=self.enable_annotate_optimization, prefetch_custom_queryset=self.prefetch_custom_queryset, ) return optimize(qs, info, config=config, store=store) strawberry-graphql-django-0.82.1/strawberry_django/ordering.py000066400000000000000000000332141516173410200246160ustar00rootroot00000000000000from __future__ import annotations import dataclasses import enum from typing import ( TYPE_CHECKING, Annotated, Any, Optional, TypeVar, cast, get_origin, ) import strawberry from django.db.models import F, OrderBy, QuerySet from graphql.language.ast import ObjectValueNode from strawberry import UNSET from strawberry.types import has_object_definition from strawberry.types.base import WithStrawberryObjectDefinition from strawberry.types.field import StrawberryField, field from strawberry.types.unset import UnsetType from strawberry.utils.str_converters import to_camel_case from typing_extensions import Self, dataclass_transform, deprecated, get_annotations from strawberry_django.fields.base import StrawberryDjangoFieldBase from strawberry_django.fields.filter_order import ( WITH_NONE_META, FilterOrderField, FilterOrderFieldResolver, ) from strawberry_django.utils.typing import ( get_type_from_lazy_annotation, is_auto, unwrap_type, ) from .arguments import argument if TYPE_CHECKING: from collections.abc import Callable, Collection, Sequence from django.db.models import Model from strawberry.types import Info from strawberry.types.arguments import StrawberryArgument _T = TypeVar("_T") _QS = TypeVar("_QS", bound="QuerySet") _SFT = TypeVar("_SFT", bound=StrawberryField) ORDER_ARG = "order" ORDERING_ARG = "ordering" @dataclasses.dataclass class OrderSequence: seq: int = 0 children: dict[str, OrderSequence] | None = None @classmethod def get_graphql_name(cls, info: Info | None, field: StrawberryField) -> str: if info is None: if field.graphql_name: return field.graphql_name return to_camel_case(field.python_name) return info.schema.config.name_converter.get_graphql_name(field) @classmethod def sorted( cls, info: Info | None, sequence: dict[str, OrderSequence] | None, fields: list[_SFT], ) -> list[_SFT]: if info is None: return fields sequence = sequence or {} def sort_key(f: _SFT) -> int: if not (seq := sequence.get(cls.get_graphql_name(info, f))): return 0 return seq.seq return sorted(fields, key=sort_key) @strawberry.enum class Ordering(enum.Enum): ASC = "ASC" ASC_NULLS_FIRST = "ASC_NULLS_FIRST" ASC_NULLS_LAST = "ASC_NULLS_LAST" DESC = "DESC" DESC_NULLS_FIRST = "DESC_NULLS_FIRST" DESC_NULLS_LAST = "DESC_NULLS_LAST" def resolve(self, value: str) -> OrderBy: nulls_first = True if "NULLS_FIRST" in self.name else None nulls_last = True if "NULLS_LAST" in self.name else None if "ASC" in self.name: return F(value).asc(nulls_first=nulls_first, nulls_last=nulls_last) return F(value).desc(nulls_first=nulls_first, nulls_last=nulls_last) def process_order( order: WithStrawberryObjectDefinition, info: Info | None, queryset: _QS, *, sequence: dict[str, OrderSequence] | None = None, prefix: str = "", skip_object_order_method: bool = False, ) -> tuple[_QS, Collection[F | OrderBy | str]]: sequence = sequence or {} args = [] if not skip_object_order_method and isinstance( order_method := getattr(order, "order", None), FilterOrderFieldResolver, ): return order_method( order, info, queryset=queryset, prefix=prefix, sequence=sequence ) for f in OrderSequence.sorted( info, sequence, order.__strawberry_definition__.fields ): f_value = getattr(order, f.name, UNSET) if f_value is UNSET or (f_value is None and not f.metadata.get(WITH_NONE_META)): continue if isinstance(f, FilterOrderField) and f.base_resolver: res = f.base_resolver( order, info, value=f_value, queryset=queryset, prefix=prefix, sequence=( (seq := sequence.get(OrderSequence.get_graphql_name(info, f))) and seq.children ), ) if isinstance(res, tuple): queryset, subargs = res else: subargs = res args.extend(subargs) elif isinstance(f_value, Ordering): args.append(f_value.resolve(f"{prefix}{f.name}")) else: queryset, subargs = process_order( f_value, info, queryset, prefix=f"{prefix}{f.name}__", sequence=( (seq := sequence.get(OrderSequence.get_graphql_name(info, f))) and seq.children ), ) args.extend(subargs) return queryset, args def apply( order: object | None, queryset: _QS, info: Info | None = None, ) -> _QS: if order in (None, strawberry.UNSET) or not has_object_definition(order): # noqa: PLR6201 return queryset sequence: dict[str, OrderSequence] = {} if info is not None and info._raw_info.field_nodes: field_node = info._raw_info.field_nodes[0] for arg in field_node.arguments: if arg.name.value != ORDER_ARG or not isinstance( arg.value, ObjectValueNode ): continue def parse_and_fill(field: ObjectValueNode, seq: dict[str, OrderSequence]): for i, f in enumerate(field.fields): f_sequence: dict[str, OrderSequence] = {} if isinstance(f.value, ObjectValueNode): parse_and_fill(f.value, f_sequence) seq[f.name.value] = OrderSequence(seq=i, children=f_sequence) parse_and_fill(arg.value, sequence) queryset, args = process_order( cast("WithStrawberryObjectDefinition", order), info, queryset, sequence=sequence ) if not args: return queryset return queryset.order_by(*args) def process_ordering_default( ordering: Any, info: Info | None, queryset: _QS, prefix: str = "", ) -> tuple[_QS, Collection[F | OrderBy | str]]: if ordering is None or not has_object_definition(ordering): return queryset, () args = [] for f in ordering.__strawberry_definition__.fields: f_value = getattr(ordering, f.name, UNSET) if f_value is UNSET or (f_value is None and not f.metadata.get(WITH_NONE_META)): continue if isinstance(f, FilterOrderField) and f.base_resolver: res = f.base_resolver( ordering, info, value=f_value, queryset=queryset, prefix=prefix, ) if isinstance(res, tuple): queryset, subargs = res else: subargs = res args.extend(subargs) elif isinstance(f_value, Ordering): args.append(f_value.resolve(f"{prefix}{f.name}")) else: ordering_cls = unwrap_type(f.type) assert isinstance(ordering_cls, type) assert has_object_definition(ordering_cls) queryset, subargs = process_ordering( ordering_cls, (f_value,), info, queryset, prefix=f"{prefix}{f.name}__", ) args.extend(subargs) return queryset, args def process_ordering( ordering_cls: type[WithStrawberryObjectDefinition], ordering: Collection[WithStrawberryObjectDefinition] | None, info: Info | None, queryset: _QS, prefix: str = "", ) -> tuple[_QS, Collection[F | OrderBy | str]]: if not ordering: return queryset, () if not isinstance( order_method := getattr(ordering_cls, "order", None), FilterOrderFieldResolver ): order_method = process_ordering_default args = [] for o in ordering: queryset, new_args = order_method(o, info, queryset=queryset, prefix=prefix) args.extend(new_args) return queryset, args def apply_ordering( ordering_cls: type[WithStrawberryObjectDefinition], ordering: Collection[WithStrawberryObjectDefinition] | None, info: Info | None, queryset: _QS, ) -> _QS: queryset, args = process_ordering(ordering_cls, ordering, info, queryset) if args: queryset = queryset.order_by(*args) return queryset class StrawberryDjangoFieldOrdering(StrawberryDjangoFieldBase): def __init__( self, order: type | UnsetType | None = UNSET, ordering: type | UnsetType | None = UNSET, **kwargs, ): if order and get_origin(order) is Annotated: order = get_type_from_lazy_annotation(order) or order if ordering and get_origin(ordering) is Annotated: ordering = get_type_from_lazy_annotation(ordering) or ordering if order and not has_object_definition(order): raise TypeError("order needs to be a strawberry type") if ordering and not has_object_definition(ordering): raise TypeError("ordering needs to be a strawberry type") self.order = order self.ordering = ordering super().__init__(**kwargs) def __copy__(self) -> Self: new_field = super().__copy__() new_field.order = self.order new_field.ordering = self.ordering return new_field @property def arguments(self) -> list[StrawberryArgument]: arguments = [] if self.base_resolver is None and self.is_list and not self.is_model_property: order = self.get_order() if order and order is not UNSET: arguments.append(argument("order", order, is_optional=True)) if self.base_resolver is None and self.is_list and not self.is_model_property: ordering = self.get_ordering() if ordering is not None: arguments.append( argument("ordering", ordering, is_list=True, default=[]) ) return super().arguments + arguments @arguments.setter def arguments(self, value: list[StrawberryArgument]): args_prop = super(StrawberryDjangoFieldOrdering, self.__class__).arguments return args_prop.fset(self, value) # type: ignore def get_order(self) -> type[WithStrawberryObjectDefinition] | None: order = self.order if order is None: return None if isinstance(order, UnsetType): django_type = self.django_type order = ( django_type.__strawberry_django_definition__.order if django_type is not None else None ) return order if order is not UNSET else None def get_ordering(self) -> type[WithStrawberryObjectDefinition] | None: ordering = self.ordering if ordering is None: return None if isinstance(ordering, UnsetType): django_type = self.django_type ordering = ( django_type.__strawberry_django_definition__.ordering if django_type is not None and django_type is not UNSET else None ) return ordering def get_queryset( self, queryset: _QS, info: Info, *, order: WithStrawberryObjectDefinition | None = None, ordering: list[WithStrawberryObjectDefinition] | None = None, **kwargs, ) -> _QS: if order and ordering: raise ValueError("Only one of `ordering` or `order` must be given.") queryset = super().get_queryset(queryset, info, **kwargs) queryset = apply(order, queryset, info=info) if ordering_cls := self.get_ordering(): queryset = apply_ordering(ordering_cls, ordering, info, queryset) return queryset @dataclass_transform( kw_only_default=True, order_default=True, field_specifiers=( StrawberryField, field, ), ) def order_type( model: type[Model], *, name: str | None = None, one_of: bool = True, description: str | None = None, directives: Sequence[object] | None = (), ) -> Callable[[_T], _T]: def wrapper(cls): for fname, type_ in get_annotations(cls).items(): if is_auto(type_): type_ = Ordering # noqa: PLW2901 cls.__annotations__[fname] = Optional[type_] # noqa: UP045 field_ = cls.__dict__.get(fname) if not isinstance(field_, StrawberryField): setattr(cls, fname, UNSET) return strawberry.input( cls, name=name, one_of=one_of, description=description, directives=directives, ) return wrapper @dataclass_transform( kw_only_default=True, order_default=True, field_specifiers=( StrawberryField, field, ), ) @deprecated( "strawberry_django.order is deprecated in favor of strawberry_django.order_type." ) def order( model: type[Model], *, name: str | None = None, description: str | None = None, directives: Sequence[object] | None = (), ) -> Callable[[_T], _T]: def wrapper(cls): for fname, type_ in get_annotations(cls).items(): if is_auto(type_): type_ = Ordering # noqa: PLW2901 cls.__annotations__[fname] = Optional[type_] # noqa: UP045 field_ = cls.__dict__.get(fname) if not isinstance(field_, StrawberryField): setattr(cls, fname, UNSET) return strawberry.input( cls, name=name, description=description, directives=directives, ) return wrapper strawberry-graphql-django-0.82.1/strawberry_django/pagination.py000066400000000000000000000343501516173410200251400ustar00rootroot00000000000000import sys import warnings from typing import Generic, TypeVar, cast import strawberry from django.db import DEFAULT_DB_ALIAS from django.db.models import Count, QuerySet, Window from django.db.models.functions import RowNumber from django.db.models.query import MAX_GET_RESULTS # type: ignore from strawberry.types import Info from strawberry.types.arguments import StrawberryArgument from strawberry.types.unset import UNSET, UnsetType from typing_extensions import Self from strawberry_django.fields.base import StrawberryDjangoFieldBase from strawberry_django.resolvers import django_resolver from .arguments import argument from .settings import strawberry_django_settings NodeType = TypeVar("NodeType") _QS = TypeVar("_QS", bound=QuerySet) PAGINATION_ARG = "pagination" def _resolve_limit( limit: int | UnsetType | None, *, max_results: int | None = None, ) -> int | None: """Calculate the effective limit to apply. Args: ---- limit: The requested limit (can be UNSET, None, or an explicit value). max_results: Override for default limit (used by relay pagination). Returns: ------- The effective limit to apply, or None for unlimited. """ settings = strawberry_django_settings() default_limit = ( max_results if max_results is not None else settings["PAGINATION_DEFAULT_LIMIT"] ) max_limit = settings["PAGINATION_MAX_LIMIT"] effective_limit: int | None if limit is UNSET or limit is None: effective_limit = default_limit else: effective_limit = cast("int", limit) if max_limit is not None: if max_limit < 0: raise ValueError( f"PAGINATION_MAX_LIMIT must be non-negative, got {max_limit}" ) if ( effective_limit is None or effective_limit < 0 or effective_limit > max_limit ): effective_limit = max_limit return effective_limit @strawberry.type class OffsetPaginationInfo: offset: int = 0 limit: int | None = UNSET @strawberry.input class OffsetPaginationInput(OffsetPaginationInfo): ... @strawberry.type class OffsetPaginated(Generic[NodeType]): queryset: strawberry.Private[QuerySet | None] pagination: strawberry.Private[OffsetPaginationInput] @strawberry.field def page_info(self) -> OffsetPaginationInfo: return OffsetPaginationInfo( limit=_resolve_limit(self.pagination.limit), offset=self.pagination.offset, ) @strawberry.field(description="Total count of existing results.") @django_resolver def total_count(self) -> int: return self.get_total_count() @strawberry.field(description="List of paginated results.") @django_resolver def results(self) -> list[NodeType]: paginated_queryset = self.get_paginated_queryset() return cast( "list[NodeType]", paginated_queryset if paginated_queryset is not None else [], ) @classmethod def resolve_paginated( cls, queryset: QuerySet, *, info: Info, pagination: OffsetPaginationInput | None = None, **kwargs, ) -> Self: """Resolve the paginated queryset. Args: queryset: The queryset to be paginated. info: The strawberry execution info resolve the type name from. pagination: The pagination input to be applied. kwargs: Additional arguments passed to the resolver. Returns: The resolved `OffsetPaginated` """ return cls( queryset=queryset, pagination=pagination or OffsetPaginationInput(), ) def get_total_count(self) -> int: """Retrieve tht total count of the queryset without pagination.""" return get_total_count(self.queryset) if self.queryset is not None else 0 def get_paginated_queryset(self) -> QuerySet | None: """Retrieve the queryset with pagination applied. This will apply the paginated arguments to the queryset and return it. To use the original queryset, access `.queryset` directly. """ from strawberry_django.optimizer import is_optimized_by_prefetching if self.queryset is None: return None return ( self.queryset._result_cache # type: ignore if is_optimized_by_prefetching(self.queryset) else apply(self.pagination, self.queryset) ) def apply( pagination: object | None, queryset: _QS, *, related_field_id: str | None = None, ) -> _QS: """Apply pagination to a queryset. Args: ---- pagination: The pagination input. queryset: The queryset to apply pagination to. related_field_id: The related field id to apply pagination to. When provided, the pagination will be applied using window functions instead of slicing the queryset. Useful for prefetches, as those cannot be sliced after being filtered """ if pagination in (None, strawberry.UNSET): # noqa: PLR6201 return queryset if not isinstance(pagination, OffsetPaginationInput): raise TypeError(f"Don't know how to resolve pagination {pagination!r}") if related_field_id is not None: queryset = apply_window_pagination( queryset, related_field_id=related_field_id, offset=pagination.offset, limit=pagination.limit, ) else: start = pagination.offset limit = _resolve_limit(pagination.limit) if limit is not None and limit >= 0: stop = start + limit queryset = queryset[start:stop] else: queryset = queryset[start:] return queryset class _PaginationWindow(Window): """Window function to be used for pagination. This is the same as django's `Window` function, but we can easily identify it in case we need to remove it from the queryset, as there might be other window functions in the queryset and no other way to identify ours. """ def apply_window_pagination( queryset: _QS, *, related_field_id: str, offset: int = 0, limit: int | None = UNSET, max_results: int | None = None, reverse: bool = False, ) -> _QS: """Apply pagination using window functions. Useful for prefetches, as those cannot be sliced after being filtered. This is based on the same solution that Django uses, which was implemented in https://github.com/django/django/pull/15957 Args: ---- queryset: The queryset to apply pagination to. related_field_id: The related field id to apply pagination to. offset: The offset to start the pagination from. limit: The limit of items to return. reverse: The need to reverse queryset ordering for backwards relay pagination """ limit = _resolve_limit(limit, max_results=max_results) order_by = [ expr for expr, _ in queryset.query.get_compiler( using=queryset._db or DEFAULT_DB_ALIAS # type: ignore ).get_order_by() ] queryset = queryset.annotate( _strawberry_row_number=_PaginationWindow( RowNumber(), partition_by=related_field_id, order_by=order_by, ), _strawberry_total_count=_PaginationWindow( Count(1), partition_by=related_field_id, ), ) if offset: queryset = queryset.filter(_strawberry_row_number__gt=offset) if reverse: order_by_reverse = [ expr for expr, _ in queryset .reverse() .query.get_compiler( using=queryset._db or DEFAULT_DB_ALIAS # type: ignore ) .get_order_by() ] queryset = queryset.annotate( _strawberry_row_number_reversed=_PaginationWindow( RowNumber(), partition_by=related_field_id, order_by=order_by_reverse, ), ) return queryset.filter(_strawberry_row_number_reversed__lte=limit) # Limit == -1 means no limit. sys.maxsize is set by relay when paginating # from the end to as a way to mimic a "not limit" as well if limit is not None and limit >= 0 and limit != sys.maxsize: queryset = queryset.filter(_strawberry_row_number__lte=offset + limit) return queryset def remove_window_pagination(queryset: _QS) -> _QS: """Remove pagination window functions from a queryset. Utility function to remove the pagination `WHERE` clause and annotations added by the `apply_window_pagination` function. Args: ---- queryset: The queryset to apply pagination to. """ queryset = queryset._chain() # type: ignore queryset.query.where.children = [ child for child in queryset.query.where.children if (not hasattr(child, "lhs") or not isinstance(child.lhs, _PaginationWindow)) ] queryset.query.annotations = { # type: ignore key: value for key, value in queryset.query.annotations.items() if not isinstance(value, _PaginationWindow) } return queryset def get_total_count(queryset: QuerySet) -> int: """Get the total count of a queryset. Try to get the total count from the queryset cache, if it's optimized by prefetching. Otherwise, fallback to the `QuerySet.count()` method. """ from strawberry_django.optimizer import is_optimized_by_prefetching if is_optimized_by_prefetching(queryset): results = queryset._result_cache # type: ignore if results: # If the queryset has DISTINCT enabled, the _strawberry_total_count # annotation won't be accurate because window functions are evaluated # before DISTINCT in SQL. Fall back to queryset.count() instead. if queryset.query.distinct: queryset = remove_window_pagination(queryset) return queryset.count() try: return results[0]._strawberry_total_count except AttributeError: warnings.warn( ( "Pagination annotations not found, falling back to QuerySet resolution. " "This might cause n+1 issues..." ), RuntimeWarning, stacklevel=2, ) # If we have no results, we can't get the total count from the cache. # In this case we will remove the pagination filter to be able to `.count()` # the whole queryset with its original filters. queryset = remove_window_pagination(queryset) return queryset.count() class StrawberryDjangoPagination(StrawberryDjangoFieldBase): def __init__(self, pagination: bool | UnsetType = UNSET, **kwargs): self.pagination = pagination super().__init__(**kwargs) def __copy__(self) -> Self: new_field = super().__copy__() new_field.pagination = self.pagination return new_field def _has_pagination(self) -> bool: if isinstance(self.pagination, bool): return self.pagination if self.is_paginated: return True django_type = self.django_type if django_type is not None and not issubclass( django_type, strawberry.relay.Node ): return django_type.__strawberry_django_definition__.pagination return False @property def arguments(self) -> list[StrawberryArgument]: arguments = [] if ( self.base_resolver is None and (self.is_list or self.is_paginated) and not self.is_model_property ): pagination = self.get_pagination() if pagination is not None: arguments.append( argument("pagination", OffsetPaginationInput, is_optional=True), ) return super().arguments + arguments @arguments.setter def arguments(self, value: list[StrawberryArgument]): args_prop = super(StrawberryDjangoPagination, self.__class__).arguments return args_prop.fset(self, value) # type: ignore def get_pagination(self) -> type | None: return OffsetPaginationInput if self._has_pagination() else None def apply_pagination( self, queryset: _QS, pagination: object | None = None, *, related_field_id: str | None = None, ) -> _QS: return apply(pagination, queryset, related_field_id=related_field_id) def get_queryset( self, queryset: _QS, info: Info, *, pagination: OffsetPaginationInput | None = None, _strawberry_related_field_id: str | None = None, **kwargs, ) -> _QS: queryset = super().get_queryset(queryset, info, **kwargs) # If the queryset is not ordered, and this field is either going to return # multiple records, or call `.first()`, then order by the primary key to ensure # deterministic results. if not queryset.ordered and ( self.is_list or self.is_paginated or self.is_connection or self.is_optional ): queryset = queryset.order_by("pk") # This is counter intuitive, but in case we are returning a `Paginated` # result, we want to set the original queryset _as is_ as it will apply # the pagination later on when resolving its `.results` field. # Check `get_wrapped_result` below for more details. if self.is_paginated: return queryset # Add implicit pagination if this field is not a list # that way when first() / get() is called on the QuerySet it does not cause extra queries # and we don't prefetch more than necessary if ( not pagination and not (self.is_list or self.is_paginated or self.is_connection) and not _strawberry_related_field_id ): if self.is_optional: pagination = OffsetPaginationInput(offset=0, limit=1) else: pagination = OffsetPaginationInput(offset=0, limit=MAX_GET_RESULTS) return self.apply_pagination( queryset, pagination, related_field_id=_strawberry_related_field_id, ) strawberry-graphql-django-0.82.1/strawberry_django/permissions.py000066400000000000000000000651501516173410200253640ustar00rootroot00000000000000import abc import contextlib import contextvars import copy import dataclasses import enum import functools import inspect from collections.abc import Callable, Hashable, Iterable from typing import ( TYPE_CHECKING, Any, ClassVar, Literal, TypeVar, cast, overload, ) import strawberry from asgiref.sync import sync_to_async from django.core.exceptions import PermissionDenied from django.db.models import Model, QuerySet from graphql.pyutils import AwaitableOrValue from strawberry import relay, schema_directive from strawberry.extensions.field_extension import ( AsyncExtensionResolver, FieldExtension, SyncExtensionResolver, ) from strawberry.schema_directive import Location from strawberry.types.base import StrawberryList, StrawberryOptional from strawberry.types.field import StrawberryField from strawberry.types.info import Info from strawberry.types.union import StrawberryUnion from typing_extensions import assert_never from strawberry_django.auth.utils import aget_current_user, get_current_user from strawberry_django.fields.types import OperationInfo, OperationMessage from strawberry_django.pagination import OffsetPaginated from strawberry_django.resolvers import django_resolver from .utils.query import filter_for_user from .utils.typing import UserType if TYPE_CHECKING: from strawberry.django.context import StrawberryDjangoContext from strawberry_django.fields.field import StrawberryDjangoField _M = TypeVar("_M", bound=Model) @functools.lru_cache def _get_user_or_anonymous_getter() -> Callable[[UserType], UserType] | None: try: from .integrations.guardian import get_user_or_anonymous except (ImportError, RuntimeError): # pragma: no cover return None return get_user_or_anonymous @dataclasses.dataclass class PermContext: is_safe_list: list[bool] = dataclasses.field(default_factory=list) checkers: list["HasPerm"] = dataclasses.field(default_factory=list) def __copy__(self): return self.__class__( is_safe_list=self.is_safe_list[:], checkers=self.checkers[:], ) @property def is_safe(self): return bool(self.is_safe_list and all(self.is_safe_list)) perm_context: contextvars.ContextVar[PermContext] = contextvars.ContextVar( "perm-safe", default=PermContext(), # noqa: B039 ) @contextlib.contextmanager def with_perm_checker(checker: "HasPerm"): context = copy.copy(perm_context.get()) context.checkers.append(checker) token = perm_context.set(context) try: yield finally: perm_context.reset(token) def set_perm_safe(value: bool): perm_context.get().is_safe_list.append(value) def filter_with_perms(qs: QuerySet[_M], info: Info) -> QuerySet[_M]: context = perm_context.get() if not context.checkers or context.is_safe: return qs if isinstance(qs, list): # return sliced queryset as-is return qs # Do not do anything is results are cached if qs._result_cache is not None: # type: ignore set_perm_safe(False) return qs user = cast("StrawberryDjangoContext", info.context).request.user # If the user is anonymous, we can't filter object permissions for it if user.is_anonymous: set_perm_safe(False) return qs.none() for check in context.checkers: if check.target != PermTarget.RETVAL: continue qs = filter_for_user( qs, user, [p.perm for p in check.perms], any_perm=check.any_perm, with_superuser=check.with_superuser, ) set_perm_safe(True) return qs @overload def get_with_perms( pk: strawberry.ID, info: Info, *, required: Literal[True], model: type[_M], key_attr: str = ..., ) -> _M: ... @overload def get_with_perms( pk: strawberry.ID, info: Info, *, required: bool = ..., model: type[_M], key_attr: str = ..., ) -> _M | None: ... @overload def get_with_perms( pk: relay.GlobalID, info: Info, *, required: Literal[True], model: type[_M], key_attr: str = ..., ) -> _M: ... @overload def get_with_perms( pk: relay.GlobalID, info: Info, *, required: bool = ..., model: type[_M], key_attr: str = ..., ) -> _M | None: ... @overload def get_with_perms( pk: relay.GlobalID, info: Info, *, required: Literal[True], key_attr: str = ..., ) -> Any: ... @overload def get_with_perms( pk: relay.GlobalID, info: Info, *, required: bool = ..., key_attr: str = ..., ) -> Any | None: ... def get_with_perms( pk, info, *, required=False, model=None, key_attr="pk", ): if isinstance(pk, relay.GlobalID): instance = pk.resolve_node_sync(info, required=required, ensure_type=model) else: assert model instance = model._default_manager.get(**{key_attr: pk}) if instance is None: return None context = perm_context.get() if not context.checkers or context.is_safe: return instance user = cast("StrawberryDjangoContext", info.context).request.user if user and (get_user_or_anonymous := _get_user_or_anonymous_getter()) is not None: user = get_user_or_anonymous(user) for check in context.checkers: f = any if check.any_perm else all checker = check.obj_perm_checker(info, user) if not f(checker(p, instance) for p in check.perms): raise PermissionDenied(check.message) return instance _return_condition = """\ When the condition fails, the following can be returned (following this priority): 1) `OperationInfo`/`OperationMessage` if those types are allowed at the return type 2) `null` in case the field is not mandatory (e.g. `String` or `[String]`) 3) An empty list in case the field is a list (e.g. `[String]!`) 4) An empty `Connection` in case the return type is a relay connection 2) Otherwise, an error will be raised """ def _desc(desc): return f"{desc}\n\n{_return_condition.strip()}" class DjangoNoPermission(Exception): # noqa: N818 """Raise to identify that the user doesn't have perms for a given retval.""" class DjangoPermissionExtension(FieldExtension, abc.ABC): """Base django permission extension.""" DEFAULT_ERROR_MESSAGE: ClassVar[str] = "User does not have permission." SCHEMA_DIRECTIVE_LOCATIONS: ClassVar[list[Location]] = [Location.FIELD_DEFINITION] SCHEMA_DIRECTIVE_DESCRIPTION: ClassVar[str | None] = None def __init__( self, *, message: str | None = None, use_directives: bool = True, fail_silently: bool = True, ): super().__init__() self.message = message if message is not None else self.DEFAULT_ERROR_MESSAGE self.fail_silently = fail_silently self.use_directives = use_directives def apply(self, field: StrawberryField) -> None: # pragma: no cover if self.use_directives: directive = self.schema_directive # Avoid interfaces duplicating the directives if directive not in field.directives: field.directives.append(self.schema_directive) @functools.cached_property def schema_directive(self) -> object: key = "__strawberry_directive_type__" directive_class = getattr(self.__class__, key, None) if directive_class is None: @schema_directive( name=self.__class__.__name__, locations=self.SCHEMA_DIRECTIVE_LOCATIONS, description=self.SCHEMA_DIRECTIVE_DESCRIPTION, repeatable=True, ) class AutoDirective: ... directive_class = AutoDirective return directive_class() @django_resolver(qs_hook=None) def resolve( self, next_: SyncExtensionResolver, source: Any, info: Info, **kwargs: dict[str, Any], ) -> Any: user = get_current_user(info) if ( user and (get_user_or_anonymous := _get_user_or_anonymous_getter()) is not None ): user = get_user_or_anonymous(user) # make sure the user is loaded user.is_authenticated # noqa: B018 try: retval = self.resolve_for_user( functools.partial(next_, source, info, **kwargs), user, info=info, source=source, ) except DjangoNoPermission as e: retval = self.handle_no_permission(e, info=info) return retval async def resolve_async( self, next_: AsyncExtensionResolver, source: Any, info: Info, **kwargs: dict[str, Any], ) -> Any: user = await aget_current_user(info) try: from .integrations.guardian import get_user_or_anonymous except (ImportError, RuntimeError): # pragma: no cover pass else: user = user and await sync_to_async(get_user_or_anonymous)(user) # make sure the user is loaded await sync_to_async(getattr)(user, "is_anonymous") try: retval = self.resolve_for_user( functools.partial(next_, source, info, **kwargs), user, info=info, source=source, ) while inspect.isawaitable(retval): retval = await retval except DjangoNoPermission as e: retval = self.handle_no_permission(e, info=info) return retval def handle_no_permission(self, exception: BaseException, *, info: Info): if not self.fail_silently: raise PermissionDenied(self.message) from exception ret_type = info.return_type if isinstance(ret_type, StrawberryOptional): ret_type = ret_type.of_type is_optional = True else: is_optional = False if isinstance(ret_type, StrawberryUnion): ret_types = [] for type_ in ret_type.types: ret_types.append(ret_type) if not isinstance(type_, type): continue if issubclass(type_, OperationInfo): return type_( messages=[ OperationMessage( kind=OperationMessage.Kind.PERMISSION, message=self.message, field=info.field_name, ), ], ) if issubclass(type_, OperationMessage): return type_( kind=OperationMessage.Kind.PERMISSION, message=self.message, field=info.field_name, ) else: ret_types = [ret_type] if is_optional: return None if isinstance(ret_type, StrawberryList): return [] if isinstance(ret_type, type) and issubclass(ret_type, OffsetPaginated): django_model = cast("StrawberryDjangoField", info._field).django_model assert django_model return django_model._default_manager.none() # If it is a Connection, try to return an empty connection, but only if # it is the only possibility available... for ret_possibility in ret_types: if isinstance(ret_possibility, type) and issubclass( ret_possibility, relay.Connection, ): return [] # In last case, raise an error raise PermissionDenied(self.message) from exception @abc.abstractmethod def resolve_for_user( # pragma: no cover self, resolver: Callable, user: UserType | None, *, info: Info, source: Any, ) -> AwaitableOrValue[Any]: ... class IsAuthenticated(DjangoPermissionExtension): """Mark a field as only resolvable by authenticated users.""" DEFAULT_ERROR_MESSAGE: ClassVar[str] = "User is not authenticated." SCHEMA_DIRECTIVE_DESCRIPTION: ClassVar[str | None] = _desc( "Can only be resolved by authenticated users.", ) @django_resolver(qs_hook=None) def resolve_for_user( self, resolver: Callable, user: UserType | None, *, info: Info, source: Any, ): if user is None or not user.is_authenticated or not user.is_active: raise DjangoNoPermission return resolver() class IsStaff(DjangoPermissionExtension): """Mark a field as only resolvable by staff users.""" DEFAULT_ERROR_MESSAGE: ClassVar[str] = "User is not a staff member." SCHEMA_DIRECTIVE_DESCRIPTION: ClassVar[str | None] = _desc( "Can only be resolved by staff users.", ) @django_resolver(qs_hook=None) def resolve_for_user( self, resolver: Callable, user: UserType | None, *, info: Info, source: Any, ): if ( user is None or not user.is_authenticated or not getattr(user, "is_staff", False) ): raise DjangoNoPermission return resolver() class IsSuperuser(DjangoPermissionExtension): """Mark a field as only resolvable by superuser users.""" DEFAULT_ERROR_MESSAGE: ClassVar[str] = "User is not a superuser." SCHEMA_DIRECTIVE_DESCRIPTION: ClassVar[str | None] = _desc( "Can only be resolved by superuser users.", ) @django_resolver(qs_hook=None) def resolve_for_user( self, resolver: Callable, user: UserType | None, *, info: Info, source: Any, ): if ( user is None or not user.is_authenticated or not getattr(user, "is_superuser", False) ): raise DjangoNoPermission return resolver() @strawberry.input(description="Permission definition for schema directives.") class PermDefinition: """Permission definition. Attributes ---------- app: The app to which we are requiring permission. permission: The permission itself """ app: str | None = strawberry.field( description=( "The app to which we are requiring permission. If this is " "empty that means that we are checking the permission directly." ), ) permission: str | None = strawberry.field( description=( "The permission itself. If this is empty that means that we " "are checking for any permission for the given app." ), ) @classmethod def from_perm(cls, perm: str): parts = perm.split(".") if len(parts) != 2: # noqa: PLR2004 raise TypeError( "Permissions need to be defined as `app_label.perm`, `app_label.`" " or `.perm`", ) return cls( app=parts[0].strip() or None, permission=parts[1].strip() or None, ) @property def perm(self): return f"{self.app or ''}.{self.permission or ''}".strip(".") def __eq__(self, other: object): if not isinstance(other, PermDefinition): return NotImplemented return self.perm == other.perm def __hash__(self): return hash((self.__class__, self.perm)) class PermTarget(enum.IntEnum): """Permission location.""" GLOBAL = enum.auto() SOURCE = enum.auto() RETVAL = enum.auto() def _default_perm_checker(info: Info, user: UserType): def perm_checker(perm: PermDefinition) -> bool: return ( user.has_perm(perm.perm) # type: ignore if perm.permission else user.has_module_perms(cast("str", perm.app)) # type: ignore ) return perm_checker def _default_obj_perm_checker(info: Info, user: UserType): def perm_checker(perm: PermDefinition, obj: Any) -> bool: # Check global perms first, then object specific return user.has_perm(perm.perm) or user.has_perm( # type: ignore perm.perm, obj=obj, ) return perm_checker class HasPerm(DjangoPermissionExtension): """Defines permissions required to access the given object/field. Given a `app` name, the user can access the decorated object/field if he has any of the permissions defined in this directive. Attributes ---------- perms: Perms required to access this app. any_perm: If any perm or all perms are required to resolve the object/field. target: The target to check for permissions. Use `HasSourcePerm` or `HasRetvalPerm` as a shortcut for this. with_anonymous: If we should optimize the permissions check and consider an anonymous user as not having any permissions. This is true by default, which means that anonymous users will not trigger has_perm checks. with_superuser: If we should optimize the permissions check and consider a superuser as having permissions foe everything. This is false by default to avoid returning unexpected results. Setting this to true will avoid triggering has_perm checks. Examples -------- To indicate that a mutation can only be done by someone who has "product.add_product" perm in the django system: >>> @strawberry.type ... class Query: ... @strawberry.mutation(directives=[HasPerm("product.add_product")]) ... def create_product(self, name: str) -> ProductType: ... ... """ DEFAULT_TARGET: ClassVar[PermTarget] = PermTarget.GLOBAL DEFAULT_ERROR_MESSAGE: ClassVar[str] = ( "You don't have permission to access this app." ) SCHEMA_DIRECTIVE_DESCRIPTION: ClassVar[str | None] = _desc( "Will check if the user has any/all permissions to resolve this.", ) def __init__( self, perms: list[str] | str, *, message: str | None = None, use_directives: bool = True, fail_silently: bool = True, target: PermTarget | None = None, any_perm: bool = True, perm_checker: Callable[[Info, UserType], Callable[[PermDefinition], bool]] | None = None, obj_perm_checker: Callable[ [Info, UserType], Callable[[PermDefinition, Any], bool] ] | None = None, with_anonymous: bool = True, with_superuser: bool = False, ): super().__init__( message=message, use_directives=use_directives, fail_silently=fail_silently, ) if isinstance(perms, str): perms = [perms] if not perms: raise TypeError(f"At least one perm is required for {self!r}") self.perms: tuple[PermDefinition, ...] = tuple( PermDefinition.from_perm(p) if isinstance(p, str) else p for p in perms ) assert all(isinstance(p, PermDefinition) for p in self.perms) self.target = target if target is not None else self.DEFAULT_TARGET self.permissions = perms self.any_perm = any_perm self.perm_checker = ( perm_checker if perm_checker is not None else _default_perm_checker ) self.obj_perm_checker = ( obj_perm_checker if obj_perm_checker is not None else _default_obj_perm_checker ) self.with_anonymous = with_anonymous self.with_superuser = with_superuser @functools.cached_property def schema_directive(self) -> object: key = "__strawberry_directive_class__" directive_class = getattr(self.__class__, key, None) if directive_class is None: @schema_directive( name=self.__class__.__name__, locations=self.SCHEMA_DIRECTIVE_LOCATIONS, description=self.SCHEMA_DIRECTIVE_DESCRIPTION, repeatable=True, ) class AutoDirective: permissions: list[PermDefinition] = strawberry.field( description="Required perms to access this resource.", default_factory=list, ) any: bool = strawberry.field( description="If any or all perms listed are required.", default=True, ) directive_class = AutoDirective return directive_class( permissions=list(self.perms), any=self.any_perm, ) @django_resolver(qs_hook=None) def resolve_for_user( self, resolver: Callable, user: UserType | None, *, info: Info, source: Any, ): if user is None or (self.with_anonymous and user.is_anonymous): raise DjangoNoPermission if ( self.with_superuser and user.is_active and getattr(user, "is_superuser", False) ): return resolver() return self.resolve_for_user_with_perms( resolver, user, info=info, source=source, ) def resolve_for_user_with_perms( self, resolver: Callable, user: UserType | None, *, info: Info, source: Any, ): if user is None: raise DjangoNoPermission if self.target == PermTarget.GLOBAL: if not self._has_perm(source, user, info=info): raise DjangoNoPermission retval = resolver() elif self.target == PermTarget.SOURCE: # Just call _resolve_obj, it will raise DjangoNoPermission # if the user doesn't have permission for it self._resolve_obj(source, user, source, info=info) retval = resolver() elif self.target == PermTarget.RETVAL: with with_perm_checker(self): obj = resolver() retval = self._resolve_obj(source, user, obj, info=info) else: assert_never(self.target) return retval def _get_cache( self, info: Info, user: UserType, ) -> dict[tuple[Hashable, ...], bool]: cache_key = "_strawberry_django_permissions_cache" cache = getattr(user, cache_key, None) if cache is None: cache = {} setattr(user, cache_key, cache) return cache def _has_perm( self, source: Any, user: UserType, *, info: Info, ) -> bool: cache = self._get_cache(info, user) # Maybe the result ended up in the cache in the meantime cache_key = (self.perms, self.any_perm) if cache_key in cache: return cache[cache_key] f = any if self.any_perm else all checker = self.perm_checker(info, user) has_perm = f(checker(p) for p in self.perms) cache[cache_key] = has_perm return has_perm def _resolve_obj( self, source: Any, user: UserType, obj: Any, *, info: Info, ) -> Any: context = perm_context.get() if context.is_safe: return obj if isinstance(obj, Iterable): return list(self._resolve_iterable_obj(source, user, obj, info=info)) cache = self._get_cache(info, user) cache_key = (self.perms, self.any_perm, obj) has_perm = cache.get(cache_key) if has_perm is None: if isinstance(obj, OperationInfo): has_perm = True else: f = any if self.any_perm else all checker = self.obj_perm_checker(info, user) has_perm = f(checker(p, obj) for p in self.perms) cache[cache_key] = has_perm if not has_perm: raise DjangoNoPermission return obj def _resolve_iterable_obj( self, source: Any, user: UserType, objs: Iterable[Any], *, info: Info, ) -> Any: cache = self._get_cache(info, user) f = any if self.any_perm else all checker = self.obj_perm_checker(info, user) for obj in objs: cache_key = (self.perms, self.any_perm, obj) has_perm = cache.get(cache_key) if has_perm is None: if isinstance(obj, OperationInfo): has_perm = True else: has_perm = f(checker(p, obj) for p in self.perms) cache[cache_key] = has_perm if has_perm: yield obj class HasSourcePerm(HasPerm): """Defines permissions required to access the given field at object level. This will check the permissions for the source object to access the given field. Unlike `HasRetvalPerm`, this uses the source value (the object where the field is defined) to resolve the field, which means that this cannot be used for source queries and types. Examples -------- To indicate that a field inside a `ProductType` can only be accessed if the user has "product.view_field" in it in the django system: >>> @gql.django.type(Product) ... class ProductType: ... some_field: str = strawberry.field( ... directives=[HasSourcePerm(".add_product")], ... ) """ DEFAULT_TARGET: ClassVar[PermTarget] = PermTarget.SOURCE SCHEMA_DIRECTIVE_DESCRIPTION: ClassVar[str | None] = _desc( "Will check if the user has any/all permissions for the parent " "of this field to resolve this.", ) class HasRetvalPerm(HasPerm): """Defines permissions required to access the given object/field at object level. Given a `app` name, the user can access the decorated object/field if he has any of the permissions defined in this directive. Note that this depends on resolving the object to check the permissions specifically for that object, unlike `HasPerm` which checks it before resolving. Examples -------- To indicate that a field that returns a `ProductType` can only be accessed by someone who has "product.view_product" has "product.view_product" perm in the django system: >>> @strawberry.type ... class SomeType: ... product: ProductType = strawberry.field( ... directives=[HasRetvalPerm(".add_product")], ... ) """ DEFAULT_TARGET: ClassVar[PermTarget] = PermTarget.RETVAL SCHEMA_DIRECTIVE_DESCRIPTION: ClassVar[str | None] = _desc( "Will check if the user has any/all permissions for the resolved " "value of this field before returning it.", ) strawberry-graphql-django-0.82.1/strawberry_django/py.typed000066400000000000000000000000001516173410200241150ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/queryset.py000066400000000000000000000032521516173410200246650ustar00rootroot00000000000000from __future__ import annotations import dataclasses from typing import TYPE_CHECKING, Any, TypeVar from django.db.models import Model from django.db.models.query import QuerySet if TYPE_CHECKING: from strawberry import Info from strawberry_django.relay.cursor_connection import OrderingDescriptor _M = TypeVar("_M", bound=Model) CONFIG_KEY = "_strawberry_django_config" @dataclasses.dataclass class StrawberryDjangoQuerySetConfig: optimized: bool = False optimized_by_prefetching: bool = False type_get_queryset_did_run: bool = False ordering_descriptors: list[OrderingDescriptor] | None = None def get_queryset_config(queryset: QuerySet) -> StrawberryDjangoQuerySetConfig: config = getattr(queryset, CONFIG_KEY, None) if config is None: setattr(queryset, CONFIG_KEY, (config := StrawberryDjangoQuerySetConfig())) return config def run_type_get_queryset( qs: QuerySet[_M], origin: Any, info: Info | None = None, ) -> QuerySet[_M]: config = get_queryset_config(qs) get_queryset = getattr(origin, "get_queryset", None) if get_queryset and not config.type_get_queryset_did_run: qs = get_queryset(qs, info) new_config = get_queryset_config(qs) new_config.type_get_queryset_did_run = True return qs _original_clone = QuerySet._clone # type: ignore def _qs_clone(self): config = get_queryset_config(self) cloned = _original_clone(self) setattr(cloned, CONFIG_KEY, dataclasses.replace(config)) return cloned # Monkey patch the QuerySet._clone method to make sure our config is copied # to the new QuerySet instance once it is cloned. QuerySet._clone = _qs_clone # type: ignore strawberry-graphql-django-0.82.1/strawberry_django/relay/000077500000000000000000000000001516173410200235445ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/relay/__init__.py000066400000000000000000000022061516173410200256550ustar00rootroot00000000000000import warnings from typing import TYPE_CHECKING, Any from .cursor_connection import ( DjangoCursorConnection, DjangoCursorEdge, OrderedCollectionCursor, OrderingDescriptor, apply_cursor_pagination, ) from .list_connection import DjangoListConnection from .utils import ( resolve_model_id, resolve_model_id_attr, resolve_model_node, resolve_model_nodes, ) if TYPE_CHECKING: from .list_connection import ListConnectionWithTotalCount # noqa: F401 __all__ = [ "DjangoCursorConnection", "DjangoCursorEdge", "DjangoListConnection", "OrderedCollectionCursor", "OrderingDescriptor", "apply_cursor_pagination", "resolve_model_id", "resolve_model_id_attr", "resolve_model_node", "resolve_model_nodes", ] def __getattr__(name: str) -> Any: if name == "ListConnectionWithTotalCount": warnings.warn( "`ListConnectionWithTotalCount` is deprecated, use `DjangoListConnection` instead.", DeprecationWarning, stacklevel=2, ) return DjangoListConnection raise AttributeError(f"module {__name__} has no attribute {name}") strawberry-graphql-django-0.82.1/strawberry_django/relay/cursor_connection.py000066400000000000000000000422221516173410200276540ustar00rootroot00000000000000import json from dataclasses import dataclass from json import JSONDecodeError from typing import Any, ClassVar, cast import strawberry from asgiref.sync import sync_to_async from django.core.exceptions import ValidationError from django.db import DEFAULT_DB_ALIAS, models from django.db.models import Expression, F, OrderBy, Q, QuerySet, Value, Window from django.db.models.constants import LOOKUP_SEP from django.db.models.expressions import Col from django.db.models.functions import RowNumber from django.db.models.sql.datastructures import BaseTable from strawberry import Info, relay from strawberry.relay import NodeType, PageInfo, from_base64 from strawberry.relay.types import NodeIterableType from strawberry.relay.utils import should_resolve_list_connection_edges from strawberry.types import get_object_definition from strawberry.types.base import StrawberryContainer from strawberry.utils.await_maybe import AwaitableOrValue from strawberry.utils.inspect import in_async_context from typing_extensions import Self from strawberry_django.pagination import apply_window_pagination, get_total_count from strawberry_django.queryset import get_queryset_config from strawberry_django.resolvers import django_resolver def _get_order_by(qs: QuerySet) -> list[OrderBy]: return [ expr for expr, _ in qs.query.get_compiler( using=qs._db or DEFAULT_DB_ALIAS # type: ignore ).get_order_by() ] @dataclass class OrderingDescriptor: attname: str order_by: OrderBy # we have to assume everything is nullable by default maybe_null: bool = True def get_comparator(self, value: Any, before: bool) -> Q | None: if value is None: # 1. When nulls are first: # 1.1 there is nothing before "null" # 1.2 after "null" comes everything non-null # 2. When nulls are last: # 2.1 there is nothing after "null" # 2.2 before "null" comes everything non-null # => 1.1 and 2.1 require no checks # => 1.2 and 2.2 require an "is not null" check if bool(self.order_by.nulls_first) ^ before: return Q((f"{self.attname}{LOOKUP_SEP}isnull", False)) return None lookup = "lt" if before ^ self.order_by.descending else "gt" cmp = Q((f"{self.attname}{LOOKUP_SEP}{lookup}", value)) if self.maybe_null and bool(self.order_by.nulls_first) == before: # if nulls are first, "before any value" can also mean "is null" # if nulls are last, "after any value" can also mean "is null" cmp |= Q((f"{self.attname}{LOOKUP_SEP}isnull", True)) return cmp def get_eq(self, value) -> Q: if value is None: return Q((f"{self.attname}{LOOKUP_SEP}isnull", True)) return Q((f"{self.attname}{LOOKUP_SEP}exact", value)) def annotate_ordering_fields( qs: QuerySet, ) -> tuple[QuerySet, list[OrderingDescriptor], list[OrderBy]]: annotations = {} descriptors = [] new_defer = None new_only = None order_bys = _get_order_by(qs) pk_in_order = False for index, order_by in enumerate(order_bys): if isinstance(order_by.expression, Col) and isinstance( # Col.alias is missing from django-types qs.query.alias_map[order_by.expression.alias], # type: ignore BaseTable, ): field_name = order_by.expression.field.name # if it's a field in the base table, just make sure it is not deferred (e.g. by the optimizer) existing, defer = qs.query.deferred_loading if defer and field_name in existing: # Query is in "defer fields" mode and our field is being deferred if new_defer is None: new_defer = set(existing) new_defer.discard(field_name) elif not defer and field_name not in existing: # Query is in "only these fields" mode and our field is not in the set of fields if new_only is None: new_only = set(existing) new_only.add(field_name) descriptors.append( OrderingDescriptor( order_by.expression.field.attname, order_by, maybe_null=order_by.expression.field.null, ) ) if order_by.expression.field.primary_key: pk_in_order = True else: dynamic_field = f"_strawberry_order_field_{index}" annotations[dynamic_field] = order_by.expression descriptors.append(OrderingDescriptor(dynamic_field, order_by)) if new_defer is not None: # defer is additive, so clear it first qs = qs.defer(None).defer(*new_defer) elif new_only is not None: # only overwrites qs = qs.only(*new_only) if not pk_in_order: # Ensure we always have a clearly defined order by ordering by pk if it isn't in the order already # We cannot use QuerySet.order_by, because it validates the order expressions again, # but we're operating on the OrderBy expressions which have already been resolved by the compiler # In case the user has previously ordered by an aggregate like so: # qs.annotate(_c=Count("foo")).order_by("_c") # noqa: ERA001 # then the OrderBy we get here would trigger a ValidationError by QuerySet.order_by. # But we only want to append to the existing order (and the existing order must be valid already) # So this is safe. pk_order = F("pk").resolve_expression(qs.query).asc() order_bys.append(pk_order) descriptors.append(OrderingDescriptor("pk", pk_order, maybe_null=False)) qs = qs._chain() # type: ignore qs.query.order_by += (pk_order,) return qs.annotate(**annotations), descriptors, order_bys def build_tuple_compare( descriptors: list[OrderingDescriptor], cursor_values: list[str | None], before: bool, ) -> Q: current = None for descriptor, field_value in zip( reversed(descriptors), reversed(cursor_values), strict=False ): if field_value is None: value_expr = None else: output_field = descriptor.order_by.expression.output_field value_expr = Value(field_value, output_field=output_field) cmp = descriptor.get_comparator(value_expr, before) if current is None: current = cmp else: eq = descriptor.get_eq(value_expr) current = cmp | (eq & current) if cmp is not None else eq & current return current if current is not None else Q() class AttrHelper: pass def _extract_expression_value( model: models.Model, expr: Expression, attname: str ) -> str | None: output_field = expr.output_field # Unfortunately Field.value_to_string operates on the object, not a direct value # So we have to potentially construct a fake object # If the output field's attname doesn't match, we have to construct a fake object # Additionally, the output field may not have an attname at all # if expressions are used field_attname = getattr(output_field, "attname", None) if not field_attname: # If the field doesn't have an attname, it's a dynamically constructed field, # for the purposes of output_field in an expression. Just set its attname, it doesn't hurt anything output_field.attname = field_attname = attname obj: Any if field_attname != attname: obj = AttrHelper() setattr(obj, output_field.attname, getattr(model, attname)) else: obj = model value = output_field.value_from_object(obj) if value is None: return None # value_to_string is missing from django-types return output_field.value_to_string(obj) # type: ignore def apply_cursor_pagination( qs: QuerySet, *, related_field_id: str | None = None, info: Info, before: str | None, after: str | None, first: int | None, last: int | None, max_results: int | None, ) -> tuple[QuerySet, list[OrderingDescriptor]]: max_results = ( max_results if max_results is not None else info.schema.config.relay_max_results ) qs, ordering_descriptors, original_order_by = annotate_ordering_fields(qs) if after: after_cursor = OrderedCollectionCursor.from_cursor(after, ordering_descriptors) qs = qs.filter( build_tuple_compare(ordering_descriptors, after_cursor.field_values, False) ) if before: before_cursor = OrderedCollectionCursor.from_cursor( before, ordering_descriptors ) qs = qs.filter( build_tuple_compare(ordering_descriptors, before_cursor.field_values, True) ) slice_: slice | None = None if first is not None and last is not None: if last > max_results: raise ValueError(f"Argument 'last' cannot be higher than {max_results}.") # if first and last are given, we have to # - reverse the order in the DB so we can use slicing to apply [:last], # otherwise we would have to know the total count to apply slicing from the end # - We still need to apply forward-direction [:first] slicing, and according to the Relay spec, # it shall happen before [:last] slicing. To do this, we use a window function with a RowNumber ordered # in the original direction, which is opposite the actual query order. # This query is likely not very efficient, but using last _and_ first together is discouraged by the # spec anyway qs = ( qs .reverse() .annotate( _strawberry_row_number_fwd=Window( RowNumber(), order_by=original_order_by, ), ) .filter( _strawberry_row_number_fwd__lte=first + 1, ) ) # we're overfetching by two, in both directions slice_ = slice(last + 2) elif first is not None: if first < 0: raise ValueError("Argument 'first' must be a non-negative integer.") if first > max_results: raise ValueError(f"Argument 'first' cannot be higher than {max_results}.") slice_ = slice(first + 1) elif last is not None: # when using last, optimize by reversing the QuerySet ordering in the DB, # then slicing from the end (which is now the start in QuerySet ordering) # and then iterating the results in reverse to restore the original order if last < 0: raise ValueError("Argument 'last' must be a non-negative integer.") if last > max_results: raise ValueError(f"Argument 'last' cannot be higher than {max_results}.") slice_ = slice(last + 1) qs = qs.reverse() if related_field_id is not None: # we always apply window pagination for nested connections, # because we want its total count annotation offset = slice_.start or 0 if slice_ is not None else 0 qs = apply_window_pagination( qs, related_field_id=related_field_id, offset=offset, limit=slice_.stop - offset if slice_ is not None else None, ) elif slice_ is not None: qs = qs[slice_] get_queryset_config(qs).ordering_descriptors = ordering_descriptors return qs, ordering_descriptors @dataclass class OrderedCollectionCursor: field_values: list[Any] @classmethod def from_model( cls, model: models.Model, descriptors: list[OrderingDescriptor] ) -> Self: values = [ _extract_expression_value( model, descriptor.order_by.expression, descriptor.attname ) for descriptor in descriptors ] return cls(field_values=values) @classmethod def from_cursor(cls, cursor: str, descriptors: list[OrderingDescriptor]) -> Self: type_, values_json = from_base64(cursor) if type_ != DjangoCursorEdge.CURSOR_PREFIX: raise ValueError("Invalid cursor") try: string_values = json.loads(values_json) except JSONDecodeError as e: raise ValueError("Invalid cursor") from e if ( not isinstance(string_values, list) or len(string_values) != len(descriptors) or any(not (v is None or isinstance(v, str)) for v in string_values) ): raise ValueError("Invalid cursor") try: decoded_values = [ d.order_by.expression.output_field.to_python(v) for d, v in zip(descriptors, string_values, strict=False) ] except ValidationError as e: raise ValueError("Invalid cursor") from e return cls(decoded_values) def __str__(self): return json.dumps(self.field_values, separators=(",", ":")) @strawberry.type(name="CursorEdge", description="An edge in a connection.") class DjangoCursorEdge(relay.Edge[relay.NodeType]): CURSOR_PREFIX: ClassVar[str] = "orderedcursor" @strawberry.type( name="CursorConnection", description="A connection to a list of items." ) class DjangoCursorConnection(relay.Connection[relay.NodeType]): total_count_qs: strawberry.Private[QuerySet | None] = None edges: list[DjangoCursorEdge[NodeType]] = strawberry.field( # type: ignore description="Contains the nodes in this connection" ) @strawberry.field(description="Total quantity of existing nodes.") @django_resolver def total_count(self) -> int: assert self.total_count_qs is not None return get_total_count(self.total_count_qs) @classmethod def resolve_connection( cls, nodes: NodeIterableType[NodeType], *, info: Info, before: str | None = None, after: str | None = None, first: int | None = None, last: int | None = None, max_results: int | None = None, **kwargs: Any, ) -> AwaitableOrValue[Self]: from strawberry_django.optimizer import is_optimized_by_prefetching if not isinstance(nodes, QuerySet): raise TypeError("DjangoCursorConnection requires a QuerySet") total_count_qs: QuerySet = nodes qs: QuerySet if not is_optimized_by_prefetching(nodes): qs, ordering_descriptors = apply_cursor_pagination( nodes, info=info, before=before, after=after, first=first, last=last, max_results=max_results, ) else: qs = nodes ordering_descriptors = get_queryset_config(qs).ordering_descriptors assert ordering_descriptors is not None type_def = get_object_definition(cls) assert type_def field_def = type_def.get_field("edges") assert field_def field = field_def.resolve_type(type_definition=type_def) while isinstance(field, StrawberryContainer): field = field.of_type edge_class = cast("DjangoCursorEdge[NodeType]", field) if not should_resolve_list_connection_edges(info): return cls( edges=[], total_count_qs=total_count_qs, page_info=PageInfo( start_cursor=None, end_cursor=None, has_previous_page=False, has_next_page=False, ), ) def finish_resolving(): nonlocal qs has_previous_page = has_next_page = False results = list(qs) if first is not None: if last is None: has_next_page = len(results) > first results = results[:first] # we're paginating forwards _and_ backwards # remove the (potentially) overfetched row in the forwards direction first elif ( results and getattr(results[0], "_strawberry_row_number_fwd", 0) > first ): has_next_page = True results = results[1:] if last is not None: has_previous_page = len(results) > last results = results[:last] it = reversed(results) if last is not None else results edges = [ edge_class.resolve_edge( cls.resolve_node(v, info=info, **kwargs), cursor=OrderedCollectionCursor.from_model(v, ordering_descriptors), ) for v in it ] return cls( edges=edges, total_count_qs=total_count_qs, page_info=PageInfo( start_cursor=edges[0].cursor if edges else None, end_cursor=edges[-1].cursor if edges else None, has_previous_page=has_previous_page, has_next_page=has_next_page, ), ) if in_async_context() and qs._result_cache is None: # type: ignore return sync_to_async(finish_resolving)() return finish_resolving() strawberry-graphql-django-0.82.1/strawberry_django/relay/list_connection.py000066400000000000000000000226571516173410200273240ustar00rootroot00000000000000import inspect import warnings from collections.abc import Sized from typing import TYPE_CHECKING, Any, cast import strawberry from django.db import models from strawberry import Info, relay from strawberry.relay.types import NodeIterableType from strawberry.types import get_object_definition from strawberry.types.base import StrawberryContainer from strawberry.types.nodes import InlineFragment, Selection from strawberry.utils.await_maybe import AwaitableOrValue from strawberry.utils.inspect import in_async_context from typing_extensions import Self, deprecated from strawberry_django.pagination import get_total_count from strawberry_django.queryset import get_queryset_config from strawberry_django.resolvers import django_resolver from strawberry_django.utils.typing import unwrap_type def _should_optimize_total_count(info: Info) -> bool: """Check if the user requested to resolve the `totalCount` field of a connection. Taken and adjusted from strawberry.relay.utils """ resolve_for_field_names = {"totalCount"} def _check_selection(selection: Selection) -> bool: """Recursively inspect the selection to check if the user requested to resolve the `edges` field. Args: selection (Selection): The selection to check. Returns: bool: True if the user requested to resolve the `edges` field of a connection, False otherwise. """ if ( not isinstance(selection, InlineFragment) and selection.name in resolve_for_field_names ): return True if selection.selections: return any( _check_selection(selection) for selection in selection.selections ) return False for selection_field in info.selected_fields: for selection in selection_field.selections: if _check_selection(selection): return True return False @strawberry.type(name="Connection", description="A connection to a list of items.") class DjangoListConnection(relay.ListConnection[relay.NodeType]): nodes: strawberry.Private[NodeIterableType[relay.NodeType] | None] = None @strawberry.field(description="Total quantity of existing nodes.") @django_resolver def total_count(self) -> int | None: assert self.nodes is not None try: return self.edges[0].node._strawberry_total_count # type: ignore except (IndexError, AttributeError): pass if isinstance(self.nodes, models.QuerySet): return get_total_count(self.nodes) return len(self.nodes) if isinstance(self.nodes, Sized) else None @classmethod def resolve_connection( cls, nodes: NodeIterableType[relay.NodeType], *, info: Info, before: str | None = None, after: str | None = None, first: int | None = None, last: int | None = None, **kwargs: Any, ) -> AwaitableOrValue[Self]: if isinstance(nodes, models.QuerySet) and ( queryset_config := get_queryset_config(nodes) ): if queryset_config.optimized_by_prefetching: try: conn = cls.resolve_optimized_connection_by_prefetch( nodes, info=info, before=before, after=after, first=first, last=last, **kwargs, ) except AttributeError: warnings.warn( ( "Pagination annotations not found, falling back to QuerySet resolution. " "This might cause N+1 issues..." ), RuntimeWarning, stacklevel=2, ) else: conn = cast("Self", conn) conn.nodes = nodes return conn if queryset_config.optimized: if (last or 0) > 0 and before is None: return cls.resolve_optimized_last_connection( nodes, info=info, before=before, after=after, first=first, last=last, **kwargs, ) if _should_optimize_total_count(info): nodes = nodes.annotate( _strawberry_total_count=models.Window( expression=models.Count(1), partition_by=None ) ) conn = super().resolve_connection( nodes, info=info, before=before, after=after, first=first, last=last, **kwargs, ) if inspect.isawaitable(conn): async def wrapper(): resolved = await conn resolved.nodes = nodes return resolved return wrapper() conn = cast("Self", conn) conn.nodes = nodes return conn @classmethod def resolve_optimized_connection_by_prefetch( cls, nodes: NodeIterableType[relay.NodeType], *, info: Info, before: str | None = None, after: str | None = None, first: int | None = None, last: int | None = None, **kwargs: Any, ) -> AwaitableOrValue[Self]: """Resolve the connection from the prefetched cache. NOTE: This will try to access `node._strawberry_total_count` and `node._strawberry_row_number` attributes from the nodes. If they don't exist, `AttriuteError` will be raised, meaning that we should fallback to the queryset resolution. """ result = nodes._result_cache # type: ignore type_def = get_object_definition(cls, strict=True) field_def = type_def.get_field("edges") assert field_def field = field_def.resolve_type(type_definition=type_def) while isinstance(field, StrawberryContainer): field = field.of_type edge_class = cast("relay.Edge[relay.NodeType]", field) edges: list[relay.Edge] = [ edge_class.resolve_edge( cls.resolve_node(node, info=info, **kwargs), cursor=node._strawberry_row_number - 1, ) for node in result ] has_previous_page = result[0]._strawberry_row_number > 1 if result else False has_next_page = ( result[-1]._strawberry_row_number < result[-1]._strawberry_total_count if result else False ) return cls( edges=edges, page_info=relay.PageInfo( start_cursor=edges[0].cursor if edges else None, end_cursor=edges[-1].cursor if edges else None, has_previous_page=has_previous_page, has_next_page=has_next_page, ), ) @classmethod def resolve_optimized_last_connection( cls, nodes: NodeIterableType[relay.NodeType], *, info: Info, before: str | None = None, after: str | None = None, first: int | None = None, last: int | None = None, **kwargs: Any, ) -> AwaitableOrValue[Self]: """Resolve the connection being paginated only via `last`. In order to prevent fetching the entire table, QuerySet is first counted & the amount is used instead of `before=None`. """ assert isinstance(nodes, models.QuerySet) type_def = get_object_definition(cls) assert type_def field_def = type_def.get_field("edges") assert field_def field = field_def.resolve_type(type_definition=type_def) field = unwrap_type(field) edge_class = cast("relay.Edge[relay.NodeType]", field) if in_async_context(): async def wrapper(): total_count = await nodes.acount() before = relay.to_base64(edge_class.CURSOR_PREFIX, total_count) conn = cls.resolve_connection( nodes, info=info, before=before, after=after, first=first, last=last, **kwargs, ) return await conn if inspect.isawaitable(conn) else conn return wrapper() total_count = nodes.count() before = relay.to_base64(edge_class.CURSOR_PREFIX, total_count) return cls.resolve_connection( nodes, info=info, before=before, after=after, first=first, last=last, **kwargs, ) if TYPE_CHECKING: @deprecated( "`ListConnectionWithTotalCount` is deprecated, use `DjangoListConnection` instead." ) class ListConnectionWithTotalCount(DjangoListConnection): ... def __getattr__(name: str) -> Any: if name == "ListConnectionWithTotalCount": warnings.warn( "`ListConnectionWithTotalCount` is deprecated, use `DjangoListConnection` instead.", DeprecationWarning, stacklevel=2, ) return DjangoListConnection raise AttributeError(f"module {__name__} has no attribute {name}") strawberry-graphql-django-0.82.1/strawberry_django/relay/utils.py000066400000000000000000000234531516173410200252650ustar00rootroot00000000000000import functools import inspect from collections.abc import Callable, Iterable from typing import ( Literal, TypeVar, cast, overload, ) import strawberry from asgiref.sync import sync_to_async from django.db import models from strawberry import relay from strawberry.relay.exceptions import NodeIDAnnotationError from strawberry.types.info import Info from strawberry.utils.await_maybe import AwaitableOrValue from strawberry_django.queryset import run_type_get_queryset from strawberry_django.resolvers import django_getattr, django_resolver from strawberry_django.utils.typing import ( WithStrawberryDjangoObjectDefinition, get_django_definition, ) _T = TypeVar("_T") _M = TypeVar("_M", bound=models.Model) __all__ = [ "resolve_model_id", "resolve_model_id_attr", "resolve_model_node", "resolve_model_nodes", ] def get_node_caster(origin: type | None) -> Callable[[_T], _T]: if origin is None: return lambda node: node return functools.partial(strawberry.cast, origin) @overload def resolve_model_nodes( source: type[WithStrawberryDjangoObjectDefinition] | type[relay.Node] | type[_M], *, info: Info | None = None, node_ids: Iterable[str | relay.GlobalID], required: Literal[True], filter_perms: bool = False, ) -> AwaitableOrValue[Iterable[_M]]: ... @overload def resolve_model_nodes( source: type[WithStrawberryDjangoObjectDefinition] | type[relay.Node] | type[_M], *, info: Info | None = None, node_ids: None = None, required: Literal[True], filter_perms: bool = False, ) -> AwaitableOrValue[models.QuerySet[_M]]: ... @overload def resolve_model_nodes( source: type[WithStrawberryDjangoObjectDefinition] | type[relay.Node] | type[_M], *, info: Info | None = None, node_ids: Iterable[str | relay.GlobalID], required: Literal[False], filter_perms: bool = False, ) -> AwaitableOrValue[Iterable[_M | None]]: ... @overload def resolve_model_nodes( source: type[WithStrawberryDjangoObjectDefinition] | type[relay.Node] | type[_M], *, info: Info | None = None, node_ids: None = None, required: Literal[False], filter_perms: bool = False, ) -> AwaitableOrValue[models.QuerySet[_M] | None]: ... @overload def resolve_model_nodes( source: type[WithStrawberryDjangoObjectDefinition] | type[relay.Node] | type[_M], *, info: Info | None = None, node_ids: Iterable[str | relay.GlobalID] | None = None, required: bool = False, filter_perms: bool = False, ) -> AwaitableOrValue[ Iterable[_M] | models.QuerySet[_M] | Iterable[_M | None] | None ]: ... def resolve_model_nodes( source, *, info=None, node_ids=None, required=False, filter_perms=False, ) -> AwaitableOrValue[Iterable[_M] | models.QuerySet[_M] | Iterable[_M | None] | None]: """Resolve model nodes, ensuring those are prefetched in a sync context. Args: ---- source: The source model or the model type that implements the `Node` interface info: Optional gql execution info. Make sure to always provide this or otherwise, the queryset cannot be optimized in case DjangoOptimizerExtension is enabled. This will also be used for `is_awaitable` check. node_ids: Optional filter by those node_ids instead of retrieving everything required: If `True`, all `node_ids` requested must exist. If they don't, an error must be raised. If `False`, missing nodes should be returned as `None`. It only makes sense when passing a list of `node_ids`, otherwise it will should ignored. Returns: ------- The resolved queryset, already prefetched from the database """ from strawberry_django import optimizer # avoid circular import from strawberry_django.permissions import filter_with_perms if issubclass(source, models.Model): origin = None else: origin = source django_type = get_django_definition(source, strict=True) source = cast("type[_M]", django_type.model) qs = cast("models.QuerySet[_M]", source._default_manager.all()) qs = run_type_get_queryset(qs, origin, info) id_attr = cast("relay.Node", origin).resolve_id_attr() if node_ids is not None: qs = qs.filter( **{ f"{id_attr}__in": [ i.node_id if isinstance(i, relay.GlobalID) else i for i in node_ids ], }, ) extra_args = {} if info is not None: if filter_perms: qs = filter_with_perms(qs, info) # Connection will filter the results when its is being resolved. # We don't want to fetch everything before it does that return_type = info.return_type if isinstance(return_type, type) and issubclass(return_type, relay.Connection): extra_args["qs_hook"] = lambda qs: qs ext = optimizer.optimizer.get() if ext is not None: # If optimizer extension is enabled, optimize this queryset qs = ext.optimize(qs, info=info) retval = cast( "AwaitableOrValue[models.QuerySet[_M]]", django_resolver(lambda _qs: _qs, **extra_args)(qs), ) if not node_ids: return retval def map_results(results: models.QuerySet[_M]) -> list[_M]: node_caster = get_node_caster(origin) results_map = {str(getattr(obj, id_attr)): node_caster(obj) for obj in results} retval: list[_M | None] = [] for node_id in node_ids: if required: retval.append(results_map[str(node_id)]) else: retval.append(results_map.get(str(node_id))) return retval # type: ignore if inspect.isawaitable(retval): async def async_resolver(): return await sync_to_async(map_results)(await retval) return async_resolver() return map_results(retval) @overload def resolve_model_node( source: type[WithStrawberryDjangoObjectDefinition] | type[relay.Node] | type[_M], node_id: str | relay.GlobalID, *, info: Info | None = ..., required: Literal[False] = ..., filter_perms: bool = False, ) -> AwaitableOrValue[_M | None]: ... @overload def resolve_model_node( source: type[WithStrawberryDjangoObjectDefinition] | type[relay.Node] | type[_M], node_id: str | relay.GlobalID, *, info: Info | None = ..., required: Literal[True], filter_perms: bool = False, ) -> AwaitableOrValue[_M]: ... def resolve_model_node( source, node_id, *, info: Info | None = None, required=False, filter_perms=False, ): """Resolve model nodes, ensuring it is retrieved in a sync context. Args: ---- source: The source model or the model type that implements the `Node` interface node_id: The node it to retrieve the model from info: Optional gql execution info. Make sure to always provide this or otherwise, the queryset cannot be optimized in case DjangoOptimizerExtension is enabled. This will also be used for `is_awaitable` check. required: If the return value is required to exist. If true, `qs.get()` will be used, which might raise `model.DoesNotExist` error if the node doesn't exist. Otherwise, `qs.first()` will be used, which might return None. Returns: ------- The resolved node, already prefetched from the database """ from strawberry_django import optimizer # avoid circular import from strawberry_django.permissions import filter_with_perms if issubclass(source, models.Model): origin = None else: origin = source django_type = get_django_definition(source, strict=True) source = cast("type[models.Model]", django_type.model) if isinstance(node_id, relay.GlobalID): node_id = node_id.node_id id_attr = cast("relay.Node", origin).resolve_id_attr() qs = source._default_manager.all() qs = run_type_get_queryset(qs, origin, info) qs = qs.filter(**{id_attr: node_id}) if info is not None: if filter_perms: qs = filter_with_perms(qs, info) ext = optimizer.optimizer.get() if ext is not None: # If optimizer extension is enabled, optimize this queryset qs = ext.optimize(qs, info=info) node_caster = get_node_caster(origin) return django_resolver(lambda: node_caster(qs.get() if required else qs.first()))() def resolve_model_id_attr(source: type) -> str: """Resolve the model id, ensuring it is retrieved in a sync context. Args: ---- source: The source model type that implements the `Node` interface Returns: ------- The resolved id attr """ try: id_attr = super(source, source).resolve_id_attr() # type: ignore except NodeIDAnnotationError: id_attr = "pk" return id_attr def resolve_model_id( source: type[WithStrawberryDjangoObjectDefinition] | type[relay.Node] | type[_M], root: models.Model, *, info: Info | None = None, ) -> AwaitableOrValue[str]: """Resolve the model id, ensuring it is retrieved in a sync context. Args: ---- source: The source model or the model type that implements the `Node` interface root: The source model object. Returns: ------- The resolved object id """ id_attr = cast("relay.Node", source).resolve_id_attr() assert isinstance(root, models.Model) if id_attr == "pk": pk = root.__class__._meta.pk assert pk id_attr = pk.attname assert id_attr try: # Prefer to retrieve this from the cache return str(root.__dict__[id_attr]) except KeyError: return django_getattr(root, id_attr) strawberry-graphql-django-0.82.1/strawberry_django/resolvers.py000066400000000000000000000173511516173410200250350ustar00rootroot00000000000000from __future__ import annotations import contextvars import functools import inspect from typing import TYPE_CHECKING, Any, TypeVar, overload from asgiref.sync import sync_to_async from django.core.exceptions import FieldDoesNotExist from django.db import models from django.db.models.constants import LOOKUP_SEP from django.db.models.fields.files import FileDescriptor from django.db.models.manager import BaseManager from strawberry.utils.inspect import in_async_context from typing_extensions import ParamSpec if TYPE_CHECKING: from collections.abc import Callable from graphql.pyutils import AwaitableOrValue _SENTINEL = object() _R = TypeVar("_R") _P = ParamSpec("_P") _M = TypeVar("_M", bound=models.Model) resolving_async: contextvars.ContextVar[bool] = contextvars.ContextVar( "resolving-async", default=False, ) def default_qs_hook(qs: models.QuerySet[_M]) -> models.QuerySet[_M]: if isinstance(qs, list): # return sliced queryset as-is return qs # FIXME: We probably won't need this anymore when we can use graphql-core 3.3.0+ # as its `complete_list_value` gives a preference to async iteration it if is # provided by the object. # This is what QuerySet does internally to fetch results. # After this, iterating over the queryset should be async safe if qs._result_cache is None: # type: ignore qs._fetch_all() # type: ignore return qs @overload def django_resolver( f: Callable[_P, _R], *, qs_hook: Callable[[models.QuerySet[_M]], Any] | None = default_qs_hook, except_as_none: tuple[type[Exception], ...] | None = None, ) -> Callable[_P, AwaitableOrValue[_R]]: ... @overload def django_resolver( *, qs_hook: Callable[[models.QuerySet[_M]], Any] | None = default_qs_hook, except_as_none: tuple[type[Exception], ...] | None = None, ) -> Callable[[Callable[_P, _R]], Callable[_P, AwaitableOrValue[_R]]]: ... def django_resolver( f=None, *, qs_hook: Callable[[models.QuerySet[_M]], Any] | None = default_qs_hook, except_as_none: tuple[type[Exception], ...] | None = None, ): """Django resolver for handling both sync and async. This decorator is used to make sure that resolver is always called from sync context. sync_to_async helper in used if function is called from async context. This is useful especially with Django ORM, which does not support async. Coroutines are not wrapped. """ def wrapper(resolver): if inspect.iscoroutinefunction(resolver) or inspect.isasyncgenfunction( resolver, ): return resolver def sync_resolver(*args, **kwargs): try: retval = resolver(*args, **kwargs) if callable(retval): retval = retval() if isinstance(retval, BaseManager): retval = retval.all() if qs_hook is not None and isinstance(retval, models.QuerySet): retval = qs_hook(retval) except Exception as e: if except_as_none is not None and isinstance(e, except_as_none): return None raise return retval @sync_to_async def async_resolver(*args, **kwargs): token = resolving_async.set(True) try: return sync_resolver(*args, **kwargs) finally: resolving_async.reset(token) @functools.wraps(resolver) def inner_wrapper(*args, **kwargs): f = ( async_resolver if in_async_context() and not resolving_async.get() else sync_resolver ) return f(*args, **kwargs) return inner_wrapper if f is not None: return wrapper(f) return wrapper @django_resolver(qs_hook=None) def django_fetch(qs: models.QuerySet[_M]) -> models.QuerySet[_M]: return default_qs_hook(qs) @overload def django_getattr( obj: Any, name: str, *, qs_hook: Callable[[models.QuerySet[_M]], Any] = default_qs_hook, except_as_none: tuple[type[Exception], ...] | None = None, empty_file_descriptor_as_null: bool = False, ) -> AwaitableOrValue[Any]: ... @overload def django_getattr( obj: Any, name: str, default: Any, *, qs_hook: Callable[[models.QuerySet[_M]], Any] = default_qs_hook, except_as_none: tuple[type[Exception], ...] | None = None, empty_file_descriptor_as_null: bool = False, ) -> AwaitableOrValue[Any]: ... def django_getattr( obj: Any, name: str, default: Any = _SENTINEL, *, qs_hook: Callable[[models.QuerySet[_M]], Any] = default_qs_hook, except_as_none: tuple[type[Exception], ...] | None = None, empty_file_descriptor_as_null: bool = False, ): return django_resolver( _django_getattr, qs_hook=qs_hook, except_as_none=except_as_none, )( obj, name, default, empty_file_descriptor_as_null=empty_file_descriptor_as_null, ) def _django_getattr( obj: Any, name: str, default: Any = _SENTINEL, *, empty_file_descriptor_as_null: bool = False, ): # Support Django's LOOKUP_SEP notation for relationship traversal # e.g., "assigned_role__role" will traverse obj.assigned_role.role if LOOKUP_SEP in name: result = _traverse_lookup_path(obj, name, default) else: args = (default,) if default is not _SENTINEL else () result = getattr(obj, name, *args) if empty_file_descriptor_as_null and isinstance(result, FileDescriptor): result = None return result def _traverse_lookup_path(obj: Any, name: str, default: Any) -> Any: """Traverse a LOOKUP_SEP path, applying default to missing attributes. The `default` value applies to missing attributes at any traversal segment. For relationships that exist but are unset, traversal returns None. """ parts = name.split(LOOKUP_SEP) if not all(parts): if default is _SENTINEL: raise AttributeError(name) return default result = obj for part in parts: current = result result = getattr(current, part, _SENTINEL) if result is _SENTINEL: if isinstance(current, models.Model): try: current._meta.get_field(part) except FieldDoesNotExist: pass else: result = None break if default is _SENTINEL: raise AttributeError(part) result = default break # If any intermediate relationship is None, return None if result is None: break return result def resolve_base_manager(manager: BaseManager) -> Any: if (result_instance := getattr(manager, "instance", None)) is not None: prefetched_cache = getattr(result_instance, "_prefetched_objects_cache", {}) # Both ManyRelatedManager and RelatedManager are defined inside functions, which # prevents us from importing and checking isinstance on them directly. try: # ManyRelatedManager return prefetched_cache[manager.prefetch_cache_name] # type: ignore except (AttributeError, KeyError): try: # RelatedManager result_field = manager.field # type: ignore cache_name = ( # 5.1+ uses "cache_name" instead of "get_cache_name() getattr(result_field.remote_field, "cache_name", None) or result_field.remote_field.get_cache_name() ) return prefetched_cache[cache_name] except (AttributeError, KeyError): pass return manager.all() strawberry-graphql-django-0.82.1/strawberry_django/routers.py000066400000000000000000000046231516173410200245120ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter from channels.security.websocket import AllowedHostsOriginValidator from django.urls import URLPattern, URLResolver, re_path from strawberry.channels.handlers.http_handler import GraphQLHTTPConsumer from strawberry.channels.handlers.ws_handler import GraphQLWSConsumer if TYPE_CHECKING: from django.core.handlers.asgi import ASGIHandler from strawberry.schema import BaseSchema class AuthGraphQLProtocolTypeRouter(ProtocolTypeRouter): """Convenience class to set up GraphQL on both HTTP and Websocket. This convenience class will include AuthMiddlewareStack and the AllowedHostsOriginValidator to ensure you have user object available. ``` from strawberry_django.routers import AuthGraphQLProtocolTypeRouter from django.core.asgi import get_asgi_application. django_asgi = get_asgi_application() from myapi import schema application = AuthGraphQLProtocolTypeRouter( schema, django_application=django_asgi, ) ``` This will route all requests to /graphql on either HTTP or websockets to us, and everything else to the Django application. """ def __init__( self, schema: BaseSchema, django_application: ASGIHandler | None = None, url_pattern: str = "^graphql", ): http_urls: list[URLPattern | URLResolver] = [ re_path(url_pattern, GraphQLHTTPConsumer.as_asgi(schema=schema)), # type: ignore ] if django_application is not None: http_urls.append(re_path(r"^", django_application)) super().__init__( { "http": AuthMiddlewareStack( URLRouter( http_urls, # type: ignore ), ), "websocket": AllowedHostsOriginValidator( AuthMiddlewareStack( URLRouter( [ re_path( url_pattern, GraphQLWSConsumer.as_asgi(schema=schema), # type: ignore ), ], ), ), ), }, ) strawberry-graphql-django-0.82.1/strawberry_django/settings.py000066400000000000000000000057021516173410200246460ustar00rootroot00000000000000"""Code for interacting with Django settings.""" from typing import cast from django.conf import settings from typing_extensions import TypedDict class StrawberryDjangoSettings(TypedDict): """Dictionary defining the shape `settings.STRAWBERRY_DJANGO` should have. All settings are optional and have defaults as described in their docstrings and defined in `DEFAULT_DJANGO_SETTINGS`. """ #: If True, field descriptions will be fetched from the #: corresponding model field's `help_text` attribute. FIELD_DESCRIPTION_FROM_HELP_TEXT: bool #: If True, type descriptions will be fetched from the #: corresponding model model's docstring. TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING: bool #: If True, fields with `choices` will have automatically generate #: an enum of possibilities instead of being exposed as `String` GENERATE_ENUMS_FROM_CHOICES: bool #: Set a custom default name for CUD mutations input type. MUTATIONS_DEFAULT_ARGUMENT_NAME: str #: If True, mutations will default to handling django errors by default #: when no option is passed to the field itself. MUTATIONS_DEFAULT_HANDLE_ERRORS: bool #: If True, `auto` fields that refer to model ids will be mapped to #: `relay.GlobalID` instead of `strawberry.ID` for types and filters. MAP_AUTO_ID_AS_GLOBAL_ID: bool #: Set a primary key default field name for Django CRUD resolvers. DEFAULT_PK_FIELD_NAME: str #: If True, deprecated way of using filters will be working USE_DEPRECATED_FILTERS: bool #: The default limit for pagination when not provided. Can be set to `None` #: to set it to unlimited. PAGINATION_DEFAULT_LIMIT: int | None #: The maximum limit for pagination that can be requested by clients. #: If set, this will cap any limit value (including None/unlimited requests). #: Can be set to `None` to allow unlimited requests (not recommended). PAGINATION_MAX_LIMIT: int | None #: If True, filters used in mutations can be omitted ALLOW_MUTATIONS_WITHOUT_FILTERS: bool DEFAULT_DJANGO_SETTINGS = StrawberryDjangoSettings( FIELD_DESCRIPTION_FROM_HELP_TEXT=False, TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING=False, GENERATE_ENUMS_FROM_CHOICES=False, MUTATIONS_DEFAULT_ARGUMENT_NAME="data", MUTATIONS_DEFAULT_HANDLE_ERRORS=False, MAP_AUTO_ID_AS_GLOBAL_ID=False, DEFAULT_PK_FIELD_NAME="pk", USE_DEPRECATED_FILTERS=False, PAGINATION_DEFAULT_LIMIT=100, PAGINATION_MAX_LIMIT=None, ALLOW_MUTATIONS_WITHOUT_FILTERS=False, ) def strawberry_django_settings() -> StrawberryDjangoSettings: """Get strawberry django settings. Return the dictionary from `settings.STRAWBERRY_DJANGO`, with defaults for missing keys. Preferred to direct access for the type hints and defaults. """ defaults = DEFAULT_DJANGO_SETTINGS return cast( "StrawberryDjangoSettings", {**defaults, **getattr(settings, "STRAWBERRY_DJANGO", {})}, ) strawberry-graphql-django-0.82.1/strawberry_django/templates/000077500000000000000000000000001516173410200244265ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/templates/strawberry_django/000077500000000000000000000000001516173410200301545ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/templates/strawberry_django/debug_toolbar.html000066400000000000000000000026511516173410200336560ustar00rootroot00000000000000 strawberry-graphql-django-0.82.1/strawberry_django/test/000077500000000000000000000000001516173410200234075ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/test/__init__.py000066400000000000000000000000001516173410200255060ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/test/client.py000066400000000000000000000051331516173410200252410ustar00rootroot00000000000000import contextlib from typing import TYPE_CHECKING, Any, cast from asgiref.sync import sync_to_async from django.contrib.auth.base_user import AbstractBaseUser from django.test.client import AsyncClient, Client from strawberry.test import BaseGraphQLTestClient from strawberry.test.client import Response from typing_extensions import override if TYPE_CHECKING: from collections.abc import Awaitable class TestClient(BaseGraphQLTestClient): __test__ = False def __init__(self, path: str, client: Client | None = None): self.path = path super().__init__(client or Client()) @property def client(self) -> Client: return self._client def request( self, body: dict[str, object], headers: dict[str, object] | None = None, files: dict[str, object] | None = None, ): kwargs: dict[str, object] = {"data": body, "headers": headers} if files: kwargs["format"] = "multipart" else: kwargs["content_type"] = "application/json" return self.client.post( self.path, **kwargs, # type: ignore ) @contextlib.contextmanager def login(self, user: AbstractBaseUser): self.client.force_login(user) yield self.client.logout() class AsyncTestClient(TestClient): def __init__(self, path: str, client: AsyncClient | None = None): super().__init__( path, client or AsyncClient(), # type: ignore ) @property def client(self) -> AsyncClient: # type: ignore[reportIncompatibleMethodOverride] return self._client @override async def query( self, query: str, variables: dict[str, Any] | None = None, headers: dict[str, object] | None = None, files: dict[str, object] | None = None, assert_no_errors: bool | None = True, ) -> Response: body = self._build_body(query, variables, files) resp = await cast("Awaitable", self.request(body, headers, files)) data = self._decode(resp, type="multipart" if files else "json") response = Response( errors=data.get("errors"), data=data.get("data"), extensions=data.get("extensions"), ) if assert_no_errors: assert response.errors is None, response.errors return response @contextlib.asynccontextmanager async def login(self, user: AbstractBaseUser): # type: ignore await sync_to_async(self.client.force_login)(user) yield await sync_to_async(self.client.logout)() strawberry-graphql-django-0.82.1/strawberry_django/type.py000066400000000000000000000536221516173410200237730ustar00rootroot00000000000000import builtins import copy import dataclasses import functools import inspect import sys import types from collections.abc import Callable, Collection, Sequence from typing import ( Generic, Literal, TypeVar, cast, ) import strawberry from django.core.exceptions import FieldDoesNotExist from django.db.models import ForeignKey from django.db.models.base import Model from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel from strawberry import UNSET, relay from strawberry.annotation import StrawberryAnnotation from strawberry.exceptions import ( MissingFieldAnnotationError, ) from strawberry.types import get_object_definition from strawberry.types.base import WithStrawberryObjectDefinition from strawberry.types.cast import get_strawberry_type_cast from strawberry.types.field import StrawberryField from strawberry.types.maybe import _annotation_is_maybe # noqa: PLC2701 from strawberry.types.private import is_private from strawberry.utils.deprecations import DeprecatedDescriptor from typing_extensions import Self, dataclass_transform, get_annotations from strawberry_django.optimizer import OptimizerStore from strawberry_django.relay import ( resolve_model_id, resolve_model_id_attr, resolve_model_node, resolve_model_nodes, ) from strawberry_django.resolvers import django_resolver from strawberry_django.utils.typing import ( AnnotateType, PrefetchType, TypeOrMapping, TypeOrSequence, WithStrawberryDjangoObjectDefinition, get_strawberry_annotations, is_auto, ) from .descriptors import ModelProperty from .fields.field import StrawberryDjangoField from .fields.field import field as _field from .fields.filter_order import SKIP_FILTER_META from .fields.types import get_model_field, resolve_model_field_name from .settings import strawberry_django_settings as django_settings __all__ = [ "StrawberryDjangoDefinition", "input", "interface", "partial", "type", ] _T = TypeVar("_T", bound=type) _O = TypeVar("_O", bound=type[WithStrawberryObjectDefinition]) _M = TypeVar("_M", bound=Model) def _process_type( cls: _T, model: type[Model], *, field_cls: type[StrawberryDjangoField] = StrawberryDjangoField, filters: type | None = None, order: type | None = None, ordering: type | None = None, pagination: bool = False, partial: bool = False, is_filter: Literal["lookups"] | bool = False, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, fields: list[str] | Literal["__all__"] | None = None, exclude: list[str] | None = None, **kwargs, ) -> _T: is_input = kwargs.get("is_input", False) if fields == "__all__": model_fields = list(model._meta.fields) elif isinstance(fields, Collection): model_fields = [f for f in model._meta.fields if f.name in fields] elif isinstance(exclude, Collection) and len(exclude) > 0: model_fields = [f for f in model._meta.fields if f.name not in exclude] else: model_fields = [] # If MAP_AUTO_ID_AS_GLOBAL_ID is True, we can no longer set the id # from fields or it will override the GlobalID and return the default # django id instead in the query-result. This adjustment however still # does not fix if the id was set to auto manually on the ModelType. if django_settings().get("MAP_AUTO_ID_AS_GLOBAL_ID", False): model_fields = [f for f in model_fields if f.name != "id"] existing_annotations = get_strawberry_annotations(cls) cls_annotations = get_annotations(cls) cls.__annotations__ = cls_annotations for f in model_fields: if existing_annotations.get(f.name): continue cls_annotations[f.name] = strawberry.auto if is_filter: cls_annotations.update( { "AND": existing_annotations.get("AND").annotation # type: ignore if existing_annotations.get("AND") else Self | None, "OR": existing_annotations.get("OR").annotation # type: ignore if existing_annotations.get("OR") else Self | None, "NOT": existing_annotations.get("NOT").annotation # type: ignore if existing_annotations.get("NOT") else Self | None, "DISTINCT": existing_annotations.get("DISTINCT").annotation # type: ignore if existing_annotations.get("DISTINCT") else bool | None, }, ) django_type = StrawberryDjangoDefinition( origin=cast("builtins.type[WithStrawberryObjectDefinition]", cls), model=model, field_cls=field_cls, is_partial=partial, is_input=is_input, is_filter=is_filter, filters=filters, order=order, ordering=ordering, pagination=pagination, disable_optimization=disable_optimization, store=OptimizerStore.with_hints( only=only, select_related=select_related, prefetch_related=prefetch_related, annotate=annotate, ), ) auto_fields: set[str] = set() for field_name, field_annotation in get_strawberry_annotations(cls).items(): annotation = field_annotation.annotation if is_private(annotation): continue if is_auto(annotation): auto_fields.add(field_name) # FIXME: For input types it is important to set the default value to UNSET # Is there a better way of doing this? if is_input: # For Maybe types, the default should be None (matching strawberry's behavior) # For other types, the default should be UNSET is_maybe = _annotation_is_maybe(annotation) default_value = None if is_maybe else UNSET # First check if the field is defined in the class. If it is, # then we just need to set its default value in case it is MISSING if field_name in cls.__dict__: field = cls.__dict__[field_name] if ( isinstance(field, dataclasses.Field) and field.default is dataclasses.MISSING ): field.default = default_value if isinstance(field, StrawberryField): field.default_value = default_value continue if not hasattr(cls, field_name): base_field = getattr(cls, "__dataclass_fields__", {}).get(field_name) if base_field is not None and isinstance(base_field, StrawberryField): new_field = copy.copy(base_field) else: new_field = _field(default=default_value) cls_annotations[field_name] = field_annotation.raw_annotation new_field.default = default_value if isinstance(base_field, StrawberryField): new_field.default_value = default_value setattr(cls, field_name, new_field) # Make sure model is also considered a "virtual subclass" of cls if "is_type_of" not in cls.__dict__: def is_type_of(obj, info): if (type_cast := get_strawberry_type_cast(obj)) is not None: return type_cast is cls return isinstance(obj, (cls, model)) cls.is_type_of = is_type_of # Default querying methods for relay if issubclass(cls, relay.Node): for attr, func in [ ("resolve_id", resolve_model_id), ("resolve_id_attr", resolve_model_id_attr), ("resolve_node", resolve_model_node), ("resolve_nodes", resolve_model_nodes), ]: existing_resolver = getattr(cls, attr, None) if ( existing_resolver is None or existing_resolver.__func__ is getattr(relay.Node, attr).__func__ ): setattr(cls, attr, types.MethodType(django_resolver(func), cls)) # type: ignore # Adjust types that inherit from other types/interfaces that implement Node # to make sure they pass themselves as the node type meth = getattr(cls, attr) if isinstance(meth, types.MethodType) and meth.__self__ is not cls: setattr( cls, attr, types.MethodType(cast("classmethod", meth).__func__, cls), ) settings = django_settings() if ( kwargs.get("description") is None and model.__doc__ and settings["TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING"] ): kwargs["description"] = inspect.cleandoc(model.__doc__) strawberry.type(cls, **kwargs) # update annotations and fields type_def = get_object_definition(cls, strict=True) description_from_doc = settings["FIELD_DESCRIPTION_FROM_HELP_TEXT"] new_fields: list[StrawberryField] = [] for f in type_def.fields: django_name: str | None = ( getattr(f, "django_name", None) or f.python_name or f.name ) assert django_name is not None description: str | None = getattr(f, "description", None) type_annotation: StrawberryAnnotation | None = getattr( f, "type_annotation", None, ) # We need to reset the `__eval_cache__` to make sure inherited types # will be forced to reevaluate the annotation on strawberry 0.192.2+ if type_annotation is not None and hasattr( type_annotation, "__resolve_cache__", ): type_annotation.__resolve_cache__ = None if f.name in auto_fields: f_is_auto = True # Force the field to be auto again for it to be re-evaluated if type_annotation: type_annotation.annotation = strawberry.auto else: f_is_auto = type_annotation is not None and is_auto( type_annotation.annotation, ) try: model_attr = get_model_field(django_type.model, django_name) except FieldDoesNotExist as e: model_attr = getattr(django_type.model, django_name, None) is_relation = False if model_attr is not None and isinstance(model_attr, ModelProperty): if type_annotation is None or f_is_auto: type_annotation = StrawberryAnnotation( model_attr.type_annotation, namespace=sys.modules[model_attr.func.__module__].__dict__, ) if description is None and description_from_doc: description = model_attr.description f_is_auto = False elif model_attr is not None and isinstance( model_attr, (property, functools.cached_property), ): func = ( model_attr.fget if isinstance(model_attr, property) else model_attr.func ) if type_annotation is None or f_is_auto: return_type = get_annotations(func).get("return") if return_type is None: raise MissingFieldAnnotationError( django_name, type_def.origin, ) from e type_annotation = StrawberryAnnotation( return_type, namespace=sys.modules[func.__module__].__dict__, ) if description is None and func.__doc__ and description_from_doc: description = inspect.cleandoc(func.__doc__) f_is_auto = False if type_annotation is None or f_is_auto: raise else: is_relation = model_attr.is_relation django_name = getattr(f, "django_name", None) or resolve_model_field_name( model_attr, is_input=django_type.is_input, is_filter=bool(django_type.is_filter), is_fk_id=( f.python_name.endswith("_id") and isinstance(model_attr, ForeignKey) ), ) if description is None and description_from_doc: try: from django.contrib.contenttypes.fields import ( GenericForeignKey, GenericRel, ) except (ImportError, RuntimeError): # pragma: no cover GenericForeignKey = None # noqa: N806 GenericRel = None # noqa: N806 if ( GenericForeignKey is not None and GenericRel is not None and isinstance(model_attr, (GenericRel, GenericForeignKey)) ): f_description = None elif isinstance(model_attr, (ManyToOneRel, ManyToManyRel)): f_description = model_attr.field.help_text else: f_description = getattr(model_attr, "help_text", None) if f_description: description = str(f_description) if isinstance(f, StrawberryDjangoField) and not f.origin_django_type: # If the field is a StrawberryDjangoField and it is the first time # seeing it, just update its annotations/description/etc f.type_annotation = type_annotation f.description = description elif isinstance(f, StrawberryDjangoField): f = copy.copy(f) # noqa: PLW2901 elif not isinstance(f, StrawberryDjangoField) and ( getattr(f, "base_resolver", None) is not None or f.metadata.get(SKIP_FILTER_META, False) ): # If this is not a StrawberryDjangoField, but has a base_resolver or is # a skip_filter field, avoid forcing it to be a StrawberryDjangoField new_fields.append(f) continue else: f = field_cls( # noqa: PLW2901 django_name=django_name, description=description, type_annotation=type_annotation, python_name=f.python_name, graphql_name=getattr(f, "graphql_name", None), origin=getattr(f, "origin", None), is_subscription=getattr(f, "is_subscription", False), base_resolver=getattr(f, "base_resolver", None), permission_classes=getattr(f, "permission_classes", ()), default=getattr(f, "default", dataclasses.MISSING), default_factory=getattr(f, "default_factory", dataclasses.MISSING), metadata=getattr(f, "metadata", None), deprecation_reason=getattr(f, "deprecation_reason", None), directives=getattr(f, "directives", ()), pagination=getattr(f, "pagination", UNSET), filters=getattr(f, "filters", UNSET), order=getattr(f, "order", UNSET), extensions=getattr(f, "extensions", ()), ) f.django_name = django_name f.is_relation = is_relation f.origin_django_type = django_type new_fields.append(f) if f.base_resolver and f.python_name: setattr(cls, f.python_name, f) type_def.fields = new_fields cls.__strawberry_django_definition__ = django_type # type: ignore # TODO: remove when deprecating _type_definition DeprecatedDescriptor( "_django_type is deprecated, use __strawberry_django_definition__ instead", cast( "WithStrawberryDjangoObjectDefinition", cls, ).__strawberry_django_definition__, "_django_type", ).inject(cls) return cast("_T", cls) @dataclasses.dataclass class StrawberryDjangoDefinition(Generic[_O, _M]): origin: _O model: type[_M] store: OptimizerStore is_input: bool = False is_partial: bool = False is_filter: Literal["lookups"] | bool = False filters: type | None = None order: type | None = None ordering: type | None = None pagination: bool = False field_cls: type[StrawberryDjangoField] = StrawberryDjangoField disable_optimization: bool = False @dataclass_transform( kw_only_default=True, order_default=True, field_specifiers=( StrawberryField, _field, ), ) def type( # noqa: A001 model: type[Model], *, name: str | None = None, field_cls: type[StrawberryDjangoField] = StrawberryDjangoField, is_input: bool = False, is_interface: bool = False, is_filter: Literal["lookups"] | bool = False, description: str | None = None, directives: Sequence[object] | None = (), extend: bool = False, filters: type | None = None, order: type | None = None, ordering: type | None = None, pagination: bool = False, only: TypeOrSequence[str] | None = None, select_related: TypeOrSequence[str] | None = None, prefetch_related: TypeOrSequence[PrefetchType] | None = None, annotate: TypeOrMapping[AnnotateType] | None = None, disable_optimization: bool = False, fields: list[str] | Literal["__all__"] | None = None, exclude: list[str] | None = None, ) -> Callable[[_T], _T]: """Annotates a class as a Django GraphQL type. Examples -------- It can be used like this: >>> @strawberry_django.type(SomeModel) ... class X: ... some_field: strawberry.auto ... otherfield: str = strawberry_django.field() """ def wrapper(cls: _T) -> _T: return _process_type( cls, model, name=name, field_cls=field_cls, is_input=is_input, is_filter=is_filter, is_interface=is_interface, description=description, directives=directives, extend=extend, filters=filters, pagination=pagination, order=order, ordering=ordering, only=only, select_related=select_related, prefetch_related=prefetch_related, annotate=annotate, disable_optimization=disable_optimization, fields=fields, exclude=exclude, ) return wrapper @dataclass_transform( kw_only_default=True, order_default=True, field_specifiers=( StrawberryField, _field, ), ) def interface( model: builtins.type[Model], *, name: str | None = None, field_cls: builtins.type[StrawberryDjangoField] = StrawberryDjangoField, description: str | None = None, directives: Sequence[object] | None = (), disable_optimization: bool = False, ) -> Callable[[_T], _T]: """Annotates a class as a Django GraphQL interface. Examples -------- It can be used like this: >>> @strawberry_django.interface(SomeModel) ... class X: ... some_field: strawberry.auto ... otherfield: str = strawberry_django.field() """ def wrapper(cls: _T) -> _T: return _process_type( cls, model, name=name, field_cls=field_cls, is_interface=True, description=description, directives=directives, disable_optimization=disable_optimization, ) return wrapper @dataclass_transform( kw_only_default=True, order_default=True, field_specifiers=( StrawberryField, _field, ), ) def input( # noqa: A001 model: builtins.type[Model], *, name: str | None = None, field_cls: builtins.type[StrawberryDjangoField] = StrawberryDjangoField, description: str | None = None, directives: Sequence[object] | None = (), is_filter: Literal["lookups"] | bool = False, partial: bool = False, fields: list[str] | Literal["__all__"] | None = None, exclude: list[str] | None = None, ) -> Callable[[_T], _T]: """Annotates a class as a Django GraphQL input. Examples -------- It can be used like this: >>> @strawberry_django.input(SomeModel) ... class X: ... some_field: strawberry.auto ... otherfield: str = strawberry_django.field() """ def wrapper(cls: _T) -> _T: return _process_type( cls, model, name=name, field_cls=field_cls, is_input=True, is_filter=is_filter, description=description, directives=directives, partial=partial, fields=fields, exclude=exclude, ) return wrapper @dataclass_transform( kw_only_default=True, order_default=True, field_specifiers=( StrawberryField, _field, ), ) def partial( model: builtins.type[Model], *, name: str | None = None, field_cls: builtins.type[StrawberryDjangoField] = StrawberryDjangoField, description: str | None = None, directives: Sequence[object] | None = (), fields: list[str] | Literal["__all__"] | None = None, exclude: list[str] | None = None, ) -> Callable[[_T], _T]: """Annotates a class as a Django GraphQL partial. Examples -------- It can be used like this: >>> @strawberry_django.partial(SomeModel) ... class X: ... some_field: strawberry.auto ... otherfield: str = strawberry_django.field() """ def wrapper(cls: _T) -> _T: return _process_type( cls, model, name=name, field_cls=field_cls, is_input=True, description=description, directives=directives, partial=True, fields=fields, exclude=exclude, ) return wrapper strawberry-graphql-django-0.82.1/strawberry_django/utils/000077500000000000000000000000001516173410200235705ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/strawberry_django/utils/__init__.py000066400000000000000000000001521516173410200256770ustar00rootroot00000000000000from strawberry_django.utils.gql_compat import IS_GQL_32, IS_GQL_33 __all__ = ["IS_GQL_32", "IS_GQL_33"] strawberry-graphql-django-0.82.1/strawberry_django/utils/gql_compat.py000066400000000000000000000036751516173410200263030ustar00rootroot00000000000000"""Compatibility layer for graphql-core 3.2.x and 3.3.x.""" from __future__ import annotations from typing import TYPE_CHECKING, Any, cast from graphql import ( FieldNode, GraphQLInterfaceType, GraphQLObjectType, ) from graphql.version import VersionInfo, version_info if TYPE_CHECKING: from graphql.type.definition import GraphQLResolveInfo IS_GQL_33 = version_info >= VersionInfo.from_str("3.3.0a0") IS_GQL_32 = not IS_GQL_33 def get_sub_field_selections( info: GraphQLResolveInfo, parent_type: GraphQLObjectType | GraphQLInterfaceType, ) -> dict[str, list[FieldNode]]: """Collect sub-fields, handling API differences between 3.2.x and 3.3.x.""" if IS_GQL_32: return _get_selections_gql32(info, parent_type) return _get_selections_gql33(info, parent_type) def _get_selections_gql32( info: GraphQLResolveInfo, parent_type: GraphQLObjectType | GraphQLInterfaceType, ) -> dict[str, list[FieldNode]]: from graphql.execution.collect_fields import ( collect_sub_fields, ) return collect_sub_fields( info.schema, info.fragments, info.variable_values, cast("GraphQLObjectType", parent_type), info.field_nodes, ) def _get_selections_gql33( info: GraphQLResolveInfo, parent_type: GraphQLObjectType | GraphQLInterfaceType, ) -> dict[str, list[FieldNode]]: from graphql.execution.collect_fields import ( FieldDetails, # type: ignore collect_subfields, # type: ignore ) field_group: list[Any] = [ FieldDetails(node=fn, defer_usage=None) for fn in info.field_nodes ] collected = collect_subfields( info.schema, info.fragments, info.variable_values, info.operation, cast("GraphQLObjectType", parent_type), field_group, ) return { key: [fd.node for fd in field_details] for key, field_details in collected.grouped_field_set.items() } strawberry-graphql-django-0.82.1/strawberry_django/utils/inspect.py000066400000000000000000000310461516173410200256130ustar00rootroot00000000000000from __future__ import annotations import dataclasses import functools import itertools import weakref from collections.abc import Iterable from typing import ( TYPE_CHECKING, Any, cast, ) from django.db.models.query import Prefetch, QuerySet from django.db.models.sql.where import WhereNode from strawberry import Schema from strawberry.types import has_object_definition from strawberry.types.base import ( StrawberryContainer, StrawberryObjectDefinition, StrawberryType, StrawberryTypeVar, ) from strawberry.types.lazy_type import LazyType from strawberry.types.union import StrawberryUnion from strawberry.utils.str_converters import to_camel_case from typing_extensions import TypeIs, assert_never from strawberry_django.fields.types import resolve_model_field_name from .pyutils import DictTree, dicttree_insersection_differs, dicttree_merge from .typing import get_django_definition if TYPE_CHECKING: from collections.abc import Generator, Iterable from django.db import models from django.db.models.expressions import Expression from django.db.models.fields import Field from django.db.models.fields.reverse_related import ForeignObjectRel from django.db.models.sql.query import Query from model_utils.managers import ( InheritanceManagerMixin, InheritanceQuerySetMixin, ) from polymorphic.models import PolymorphicModel @functools.lru_cache def get_model_fields( model: type[models.Model], *, camel_case: bool = False, is_input: bool = False, is_filter: bool = False, ) -> dict[str, Field | ForeignObjectRel]: """Get a list of model fields from the model.""" fields = {} for f in model._meta.get_fields(): name = cast( "str", resolve_model_field_name(f, is_input=is_input, is_filter=is_filter), ) if camel_case: name = to_camel_case(name) fields[name] = f return fields def get_model_field( model: type[models.Model], field_name: str, *, camel_case: bool = False, is_input: bool = False, is_filter: bool = False, ) -> Field | ForeignObjectRel | None: """Get a model fields from the model given its name.""" return get_model_fields( model, camel_case=camel_case, is_input=is_input, is_filter=is_filter, ).get(field_name) def get_possible_types( gql_type: StrawberryObjectDefinition | StrawberryType | type, *, object_definition: StrawberryObjectDefinition | None = None, ) -> Generator[type]: """Resolve all possible types for gql_type. Args: ---- gql_type: The type to retrieve possibilities from. type_def: Optional type definition to use to resolve type vars. This is used internally. Yields: ------ All possibilities for the type """ if isinstance(gql_type, StrawberryObjectDefinition): yield from get_possible_types(gql_type.origin, object_definition=gql_type) elif isinstance(gql_type, LazyType): yield from get_possible_types(gql_type.resolve_type()) elif isinstance(gql_type, StrawberryTypeVar) and object_definition is not None: resolved = object_definition.type_var_map.get(gql_type.type_var.__name__, None) if resolved is not None: yield from get_possible_types(resolved) elif isinstance(gql_type, StrawberryContainer): yield from get_possible_types(gql_type.of_type) elif isinstance(gql_type, StrawberryUnion): yield from itertools.chain.from_iterable( (get_possible_types(t) for t in gql_type.types), ) elif isinstance(gql_type, StrawberryType): # Nothing to return here pass elif isinstance(gql_type, type): yield gql_type else: assert_never(gql_type) def get_possible_type_definitions( gql_type: StrawberryObjectDefinition | StrawberryType | type, ) -> Generator[StrawberryObjectDefinition]: """Resolve all possible type definitions for gql_type. Args: ---- gql_type: The type to retrieve possibilities from. Yields: ------ All possibilities for the type """ if isinstance(gql_type, StrawberryObjectDefinition): yield gql_type return for t in get_possible_types(gql_type): if isinstance(t, StrawberryObjectDefinition): yield t elif has_object_definition(t): yield t.__strawberry_definition__ try: # Can't import PolymorphicModel, because it requires Django Apps to be ready # Import polymorphic instead to check for its existence import polymorphic # noqa: F401 def is_polymorphic_model(v: type) -> TypeIs[type[PolymorphicModel]]: return getattr(v, "polymorphic_model_marker", False) is True except ImportError: def is_polymorphic_model(v: type) -> TypeIs[type[PolymorphicModel]]: return False try: from model_utils.managers import InheritanceManagerMixin, InheritanceQuerySetMixin def is_inheritance_manager( v: Any, ) -> TypeIs[InheritanceManagerMixin]: return isinstance(v, InheritanceManagerMixin) def is_inheritance_qs( v: Any, ) -> TypeIs[InheritanceQuerySetMixin]: return isinstance(v, InheritanceQuerySetMixin) except ImportError: def is_inheritance_manager( v: Any, ) -> TypeIs[InheritanceManagerMixin]: return False def is_inheritance_qs( v: Any, ) -> TypeIs[InheritanceQuerySetMixin]: return False def _can_optimize_subtypes(model: type[models.Model]) -> bool: return is_polymorphic_model(model) or is_inheritance_manager(model._default_manager) _interfaces: weakref.WeakKeyDictionary[ Schema, dict[StrawberryObjectDefinition, list[StrawberryObjectDefinition]], ] = weakref.WeakKeyDictionary() def get_possible_concrete_types( model: type[models.Model], schema: Schema, strawberry_type: StrawberryObjectDefinition | StrawberryType, ) -> Iterable[StrawberryObjectDefinition]: """Return the object definitions the optimizer should look at when optimizing a model. Returns any object definitions attached to either the model or one of its supertypes. If the model is one that supports polymorphism, by returning subtypes from its queryset, subtypes are also looked at. Currently, this is supported for django-polymorphic and django-model-utils InheritanceManager. """ for object_definition in get_possible_type_definitions(strawberry_type): if not object_definition.is_interface: yield object_definition continue schema_interfaces = _interfaces.setdefault(schema, {}) interface_definitions = schema_interfaces.get(object_definition) if interface_definitions is None: interface_definitions = [] for t in schema.schema_converter.type_map.values(): t_definition = t.definition if isinstance(t_definition, StrawberryObjectDefinition) and issubclass( t_definition.origin, object_definition.origin ): interface_definitions.append(t_definition) schema_interfaces[object_definition] = interface_definitions for interface_definition in interface_definitions: dj_definition = get_django_definition(interface_definition.origin) if dj_definition and ( issubclass(model, dj_definition.model) or ( _can_optimize_subtypes(model) and issubclass(dj_definition.model, model) ) ): yield interface_definition @dataclasses.dataclass(eq=True) class PrefetchInspector: """Prefetch hints.""" prefetch: Prefetch qs: QuerySet = dataclasses.field(init=False, compare=False) query: Query = dataclasses.field(init=False, compare=False) def __post_init__(self): self.qs = cast("QuerySet", self.prefetch.queryset) # type: ignore self.query = self.qs.query @property def only(self) -> frozenset[str] | None: if self.query.deferred_loading[1]: return None return frozenset(self.query.deferred_loading[0]) @only.setter def only(self, value: Iterable[str | None] | None): value = frozenset(v for v in (value or []) if v is not None) self.query.deferred_loading = (value, len(value) == 0) @property def defer(self) -> frozenset[str] | None: if not self.query.deferred_loading[1]: return None return frozenset(self.query.deferred_loading[0]) @defer.setter def defer(self, value: Iterable[str | None] | None): value = frozenset(v for v in (value or []) if v is not None) self.query.deferred_loading = (value, True) @property def select_related(self) -> DictTree | None: if not isinstance(self.query.select_related, dict): return None return self.query.select_related @select_related.setter def select_related(self, value: DictTree | None): self.query.select_related = value or {} @property def prefetch_related(self) -> list[Prefetch | str]: return list(self.qs._prefetch_related_lookups) # type: ignore @prefetch_related.setter def prefetch_related(self, value: Iterable[Prefetch | str] | None): self.qs._prefetch_related_lookups = tuple(value or []) # type: ignore @property def annotations(self) -> dict[str, Expression]: return self.query.annotations @annotations.setter def annotations(self, value: dict[str, Expression] | None): self.query.annotations = value or {} # type: ignore @property def extra(self) -> DictTree: return self.query.extra @extra.setter def extra(self, value: DictTree | None): self.query.extra = value or {} # type: ignore @property def where(self) -> WhereNode: return self.query.where @where.setter def where(self, value: WhereNode | None): self.query.where = value or WhereNode() def merge(self, other: PrefetchInspector, *, allow_unsafe_ops: bool = False): if not allow_unsafe_ops and self.where != other.where: raise ValueError( "Tried to prefetch 2 queries with different filters to the " "same attribute. Use `to_attr` in this case...", ) # Merge select_related self.select_related = dicttree_merge( self.select_related or {}, other.select_related or {}, ) # Merge only/deferred if not allow_unsafe_ops and (self.defer is None) != (other.defer is None): raise ValueError( "Tried to prefetch 2 queries with different deferred " "operations. Use only `only` or `deferred`, not both...", ) if self.only is not None and other.only is not None: self.only |= other.only elif self.defer is not None and other.defer is not None: self.defer |= other.defer else: # One has defer, the other only. In this case, defer nothing self.defer = frozenset() # Merge annotations s_annotations = self.annotations o_annotations = other.annotations if not allow_unsafe_ops: for k in set(s_annotations) & set(o_annotations): if s_annotations[k] != o_annotations[k]: raise ValueError( "Tried to prefetch 2 queries with overlapping annotations.", ) self.annotations = {**s_annotations, **o_annotations} # Merge extra s_extra = self.extra o_extra = other.extra if not allow_unsafe_ops and dicttree_insersection_differs(s_extra, o_extra): raise ValueError("Tried to prefetch 2 queries with overlapping extras.") self.extra = {**s_extra, **o_extra} prefetch_related: dict[str, str | Prefetch] = {} for p in itertools.chain(self.prefetch_related, other.prefetch_related): if isinstance(p, str): if p not in prefetch_related: prefetch_related[p] = p continue path = p.prefetch_to existing = prefetch_related.get(path) if not existing or isinstance(existing, str): prefetch_related[path] = p continue inspector = self.__class__(existing).merge(PrefetchInspector(p)) prefetch_related[path] = inspector.prefetch self.prefetch_related = prefetch_related.values() return self strawberry-graphql-django-0.82.1/strawberry_django/utils/patches.py000066400000000000000000000060751516173410200256010ustar00rootroot00000000000000import django from django.db import ( DEFAULT_DB_ALIAS, NotSupportedError, connections, ) from django.db.models import Q, Window from django.db.models.fields import related_descriptors from django.db.models.functions import RowNumber from django.db.models.lookups import GreaterThan, LessThanOrEqual from django.db.models.sql import Query from django.db.models.sql.constants import INNER from django.db.models.sql.where import AND def apply_pagination_fix(): """Apply pagination fix for Django 5.1 or older. This is based on the fix in this patch, which is going to be included in Django 5.2: https://code.djangoproject.com/ticket/35677#comment:9 If can safely be removed when Django 5.2 is the minimum version we support """ if django.VERSION >= (5, 2): return # This is a copy of the function, exactly as it exists on Django 4.2, 5.0 and 5.1 # (there are no differences in this function between these versions) def _filter_prefetch_queryset(queryset, field_name, instances): predicate = Q(**{f"{field_name}__in": instances}) db = queryset._db or DEFAULT_DB_ALIAS if queryset.query.is_sliced: if not connections[db].features.supports_over_clause: raise NotSupportedError( "Prefetching from a limited queryset is only supported on backends " "that support window functions." ) low_mark, high_mark = queryset.query.low_mark, queryset.query.high_mark order_by = [ expr for expr, _ in queryset.query.get_compiler(using=db).get_order_by() ] window = Window(RowNumber(), partition_by=field_name, order_by=order_by) predicate &= GreaterThan(window, low_mark) if high_mark is not None: predicate &= LessThanOrEqual(window, high_mark) queryset.query.clear_limits() # >> ORIGINAL CODE # return queryset.filter(predicate) # noqa: ERA001 # << ORIGINAL CODE # >> PATCHED CODE queryset.query.add_q(predicate, reuse_all_aliases=True) return queryset # << PATCHED CODE related_descriptors._filter_prefetch_queryset = _filter_prefetch_queryset # type: ignore # This is a copy of the function, exactly as it exists on Django 4.2, 5.0 and 5.1 # (there are no differences in this function between these versions) def add_q(self, q_object, reuse_all_aliases=False): existing_inner = { a for a in self.alias_map if self.alias_map[a].join_type == INNER } # >> ORIGINAL CODE # clause, _ = self._add_q(q_object, self.used_aliases) # noqa: ERA001 # << ORIGINAL CODE # >> PATCHED CODE if reuse_all_aliases: # noqa: SIM108 can_reuse = set(self.alias_map) else: can_reuse = self.used_aliases clause, _ = self._add_q(q_object, can_reuse) # << PATCHED CODE if clause: self.where.add(clause, AND) self.demote_joins(existing_inner) Query.add_q = add_q strawberry-graphql-django-0.82.1/strawberry_django/utils/pyutils.py000066400000000000000000000022151516173410200256530ustar00rootroot00000000000000from collections.abc import Mapping from typing import Any, TypeAlias, TypeVar _K = TypeVar("_K", bound=Any) _V = TypeVar("_V", bound=Any) DictTree: TypeAlias = dict[str, "DictTree"] def dicttree_merge(dict1: Mapping[_K, _V], dict2: Mapping[_K, _V]) -> dict[_K, _V]: new = { **dict1, **dict2, } for k, v1 in dict1.items(): if not isinstance(v1, dict): continue v2 = dict2.get(k) if isinstance(v2, Mapping): new[k] = dicttree_merge(v1, v2) # type: ignore for k, v2 in dict2.items(): if not isinstance(v2, dict): continue v1 = dict1.get(k) if isinstance(v1, Mapping): new[k] = dicttree_merge(v1, v2) # type: ignore return new def dicttree_insersection_differs( dict1: Mapping[_K, _V], dict2: Mapping[_K, _V], ) -> bool: for k in set(dict1) & set(dict2): v1 = dict1[k] v2 = dict2[k] if isinstance(v1, Mapping) and isinstance(v2, Mapping): if dicttree_insersection_differs(v1, v2): return True elif v1 != v2: return True return False strawberry-graphql-django-0.82.1/strawberry_django/utils/query.py000066400000000000000000000153011516173410200253070ustar00rootroot00000000000000import functools from typing import TYPE_CHECKING, Optional, TypeVar, cast from django.core.exceptions import FieldDoesNotExist from django.db.models import Exists, F, Model, Q, QuerySet from django.db.models.functions import Cast from strawberry.utils.inspect import in_async_context from .typing import TypeOrIterable, UserType if TYPE_CHECKING: from django.contrib.auth.models import AbstractUser from django.contrib.contenttypes.models import ContentType from guardian.managers import ( GroupObjectPermissionManager, UserObjectPermissionManager, ) _Q = TypeVar("_Q", bound=QuerySet) def _filter( qs: _Q, perms: list[str], *, lookup: str = "", model: type[Model], any_perm: bool = True, ctype: Optional["ContentType"] = None, ) -> _Q: lookup = lookup and f"{lookup}__" ctype_attr = f"{lookup}content_type" if ctype is not None: q = Q(**{ctype_attr: ctype}) else: meta = model._meta q = Q( **{ f"{ctype_attr}__app_label": meta.app_label, f"{ctype_attr}__model": meta.model_name, }, ) if len(perms) == 1: q &= Q(**{f"{lookup}codename": perms[0]}) elif any_perm: q &= Q(**{f"{lookup}codename__in": perms}) else: q = functools.reduce( lambda acu, p: acu & Q(**{f"{lookup}codename": p}), perms, q, ) return qs.filter(q) def filter_for_user_q( qs: QuerySet, user: UserType, perms: TypeOrIterable[str], *, any_perm: bool = True, with_groups: bool = True, with_superuser: bool = False, ): if with_superuser and user.is_active and getattr(user, "is_superuser", False): return qs if user.is_anonymous: return qs.none() groups_field = None try: groups_field = cast("AbstractUser", user)._meta.get_field("groups") except FieldDoesNotExist: with_groups = False if isinstance(perms, str): perms = [perms] model = cast("type[Model]", qs.model) if model._meta.concrete_model: model = model._meta.concrete_model try: from django.contrib.contenttypes.models import ContentType except (ImportError, RuntimeError): # pragma: no cover ctype = None else: try: # We don't want to query the database here because this might not be async # safe. Try to retrieve the ContentType from cache. If it is not there, we # will query it through the queryset meta = model._meta ctype = cast( "ContentType", ContentType.objects._get_from_cache(meta), # type: ignore ) except KeyError: # pragma:nocover # If we are not running async, retrieve it ctype = ( ContentType.objects.get_for_model(model, for_concrete_model=False) if not in_async_context() else None ) app_labels = set() perms_list = [] for p in perms: parts = p.split(".") if len(parts) > 1: app_labels.add(parts[0]) perms_list.append(parts[-1]) if len(app_labels) == 1 and ctype is not None: app_label = app_labels.pop() if app_label != ctype.app_label: # pragma:nocover raise ValueError( f"Given perms must have same app label ({app_label!r} !=" f" {ctype.app_label!r})", ) elif len(app_labels) > 1: # pragma:nocover raise ValueError(f"Cannot mix app_labels ({app_labels!r})") # Small optimization if the user's permissions are cached perm_cache: set[str] | None = getattr(user, "_perm_cache", None) if perm_cache is not None: f = any if any_perm else all if f(p in perm_cache for p in perms_list): return qs q = Q() if hasattr(user, "user_permissions"): q |= Q( Exists( _filter( cast("AbstractUser", user).user_permissions, perms_list, model=model, ctype=ctype, ), ), ) if with_groups: q |= Q( Exists( _filter( cast("AbstractUser", user).groups, perms_list, lookup="permissions", model=model, ctype=ctype, ), ), ) try: from strawberry_django.integrations.guardian import ( get_object_permission_models, ) except (ImportError, RuntimeError): # pragma: no cover pass else: perm_models = get_object_permission_models(qs.model) user_model = perm_models.user user_qs = _filter( user_model.objects.filter(user=user), perms_list, lookup="permission", model=model, ctype=ctype, ) if cast("UserObjectPermissionManager", user_model.objects).is_generic(): user_qs = user_qs.filter(content_type=F("permission__content_type")) else: user_qs = user_qs.annotate(object_pk=F("content_object")) obj_qs = user_qs.values_list( Cast("object_pk", cast("str", model._meta.pk)), flat=True, ).distinct() if with_groups: assert groups_field is not None group_model = perm_models.group user_key = f"group__{groups_field.related_query_name()}" # type: ignore group_qs = _filter( group_model.objects.filter(**{user_key: user}), perms_list, lookup="permission", model=model, ctype=ctype, ) if cast("GroupObjectPermissionManager", group_model.objects).is_generic(): group_qs = group_qs.filter(content_type=F("permission__content_type")) else: group_qs = group_qs.annotate(object_pk=F("content_object")) obj_qs = obj_qs.union( group_qs.values_list( Cast("object_pk", cast("str", model._meta.pk)), flat=True, ).distinct(), ) q |= Q(pk__in=obj_qs) return q def filter_for_user( qs: QuerySet, user: UserType, perms: TypeOrIterable[str], *, any_perm: bool = True, with_groups: bool = True, with_superuser: bool = False, ): return qs & qs.filter( filter_for_user_q( qs, user, perms, any_perm=any_perm, with_groups=with_groups, with_superuser=with_superuser, ), ) strawberry-graphql-django-0.82.1/strawberry_django/utils/requests.py000066400000000000000000000006751516173410200260250ustar00rootroot00000000000000from django.http.request import HttpRequest from strawberry.types import Info def get_request(info: Info) -> HttpRequest: """Return the request from Info. description: Return the request object for both WSGI and ASGI implementations. It tends to move based on the environment. """ try: request = info.context.request except AttributeError: request = info.context.get("request") return request strawberry-graphql-django-0.82.1/strawberry_django/utils/typing.py000066400000000000000000000077671516173410200254750ustar00rootroot00000000000000from __future__ import annotations import dataclasses import sys from collections.abc import Callable, Iterable, Mapping, Sequence from typing import ( TYPE_CHECKING, Any, ClassVar, ForwardRef, TypeVar, Union, _AnnotatedAlias, # type: ignore cast, get_args, overload, ) from django.db.models.expressions import BaseExpression, Combinable from strawberry import Info from strawberry.annotation import StrawberryAnnotation from strawberry.types.auto import StrawberryAuto from strawberry.types.base import ( StrawberryContainer, StrawberryType, WithStrawberryObjectDefinition, ) from strawberry.types.lazy_type import LazyType, StrawberryLazyReference from strawberry.utils.typing import is_classvar from typing_extensions import Protocol, get_annotations if TYPE_CHECKING: from typing import Literal, TypeAlias, TypeGuard from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.models import AnonymousUser from django.db.models import Prefetch from strawberry_django.type import StrawberryDjangoDefinition _T = TypeVar("_T") _Type = TypeVar("_Type", bound="StrawberryType | type") TypeOrSequence: TypeAlias = _T | Sequence[_T] TypeOrMapping: TypeAlias = _T | Mapping[str, _T] TypeOrIterable: TypeAlias = _T | Iterable[_T] UserType: TypeAlias = Union["AbstractBaseUser", "AnonymousUser"] PrefetchCallable: TypeAlias = Callable[[Info], "Prefetch[Any]"] PrefetchType: TypeAlias = Union[str, "Prefetch[Any]", PrefetchCallable] AnnotateCallable: TypeAlias = Callable[ [Info], BaseExpression | Combinable, ] AnnotateType: TypeAlias = BaseExpression | Combinable | AnnotateCallable class WithStrawberryDjangoObjectDefinition(WithStrawberryObjectDefinition, Protocol): __strawberry_django_definition__: ClassVar[StrawberryDjangoDefinition] def has_django_definition( obj: Any, ) -> TypeGuard[type[WithStrawberryDjangoObjectDefinition]]: return hasattr(obj, "__strawberry_django_definition__") @overload def get_django_definition( obj: Any, *, strict: Literal[True], ) -> StrawberryDjangoDefinition: ... @overload def get_django_definition( obj: Any, *, strict: bool = False, ) -> StrawberryDjangoDefinition | None: ... def get_django_definition( obj: Any, *, strict: bool = False, ) -> StrawberryDjangoDefinition | None: return ( obj.__strawberry_django_definition__ if strict else getattr(obj, "__strawberry_django_definition__", None) ) def is_auto(obj: Any) -> bool: if isinstance(obj, ForwardRef): obj = obj.__forward_arg__ if isinstance(obj, str): return obj in {"auto", "strawberry.auto"} return isinstance(obj, StrawberryAuto) def get_strawberry_annotations(cls) -> dict[str, StrawberryAnnotation]: annotations: dict[str, StrawberryAnnotation] = {} for c in reversed(cls.__mro__): # Skip non dataclass bases other than cls itself if c is not cls and not dataclasses.is_dataclass(c): continue namespace = sys.modules[c.__module__].__dict__ for k, v in get_annotations(c).items(): if not is_classvar(cast("type", c), v): annotations[k] = StrawberryAnnotation(v, namespace=namespace) return annotations @overload def unwrap_type(type_: StrawberryContainer) -> type: ... @overload def unwrap_type(type_: LazyType) -> type: ... @overload def unwrap_type(type_: None) -> None: ... @overload def unwrap_type(type_: _Type) -> _Type: ... def unwrap_type(type_): while True: if isinstance(type_, LazyType): type_ = type_.resolve_type() elif isinstance(type_, StrawberryContainer): type_ = type_.of_type else: break return type_ def get_type_from_lazy_annotation(type_: _AnnotatedAlias) -> type | None: first, *rest = get_args(type_) for arg in rest: if isinstance(arg, StrawberryLazyReference): return unwrap_type(arg.resolve_forward_ref(first)) return None strawberry-graphql-django-0.82.1/tests/000077500000000000000000000000001516173410200200445ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/__init__.py000066400000000000000000000000001516173410200221430ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/auth/000077500000000000000000000000001516173410200210055ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/auth/__init__.py000066400000000000000000000000001516173410200231040ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/auth/conftest.py000066400000000000000000000010701516173410200232020ustar00rootroot00000000000000from collections import UserDict import pytest from django.contrib import auth as django_auth UserModel = django_auth.get_user_model() @pytest.fixture def context(mocker): class Session(UserDict): def cycle_key(self): pass def flush(self): pass context = mocker.Mock() context.request.session = Session() django_auth.logout(context.request) return context @pytest.fixture def user(db, group, tag): return UserModel.objects.create_user( username="user", password="password", ) strawberry-graphql-django-0.82.1/tests/auth/test_mutations.py000066400000000000000000000053211516173410200244420ustar00rootroot00000000000000import django.contrib.auth as django_auth import pytest import strawberry from django.conf import settings from django.core.exceptions import ObjectDoesNotExist import strawberry_django from strawberry_django import auth from tests import utils UserModel = django_auth.get_user_model() @strawberry_django.type(UserModel) class User: username: strawberry.auto email: strawberry.auto @strawberry_django.input(UserModel) class UserInput: username: strawberry.auto password: strawberry.auto email: strawberry.auto @strawberry.type class Mutation: login: User | None = auth.login() # type: ignore logout = auth.logout() register: User = auth.register(UserInput) @pytest.fixture def mutation(db): return utils.generate_query(mutation=Mutation) def test_login(mutation, user, context): result = mutation( '{ login(username: "user", password: "password") { username } }', context_value=context, ) assert not result.errors assert result.data["login"] == {"username": "user"} assert context.request.user == user def test_login_with_wrong_password(mutation, user, context): result = mutation( '{ login(username: "user", password: "wrong") { username } }', context_value=context, ) assert result.errors assert result.data["login"] is None assert context.request.user.is_anonymous def test_logout(mutation, user, context): django_auth.login( context.request, user, backend=settings.AUTHENTICATION_BACKENDS[0], ) result = mutation("{ logout }", context_value=context) assert not result.errors assert result.data["logout"] is True assert context.request.user.is_anonymous def test_logout_without_logged_in(mutation, user, context): result = mutation("{ logout }", context_value=context) assert not result.errors assert result.data["logout"] is False def test_register_new_user(mutation, user, context): result = mutation( '{ register(data: {username: "new_user",' ' password: "test_password"}) { username } }', context_value=context, ) assert not result.errors assert result.data["register"] == {"username": "new_user"} user = UserModel.objects.get(username="new_user") assert user.pk assert user.check_password("test_password") def test_register_with_invalid_password(mutation, user, context): result = mutation( '{ register(data: {username: "invalid_user", password: "a"}) { username } }', context_value=context, ) assert len(result.errors) == 1 assert "too short" in result.errors[0].message with pytest.raises(ObjectDoesNotExist): assert UserModel.objects.get(username="invalid_user") strawberry-graphql-django-0.82.1/tests/auth/test_queries.py000066400000000000000000000016321516173410200240750ustar00rootroot00000000000000import pytest import strawberry from django.conf import settings from django.contrib import auth as django_auth from strawberry_django import auth from tests import utils from .test_mutations import User @strawberry.type class Query: current_user: User | None = auth.current_user() # type: ignore @pytest.fixture def query(db): return utils.generate_query(Query) def test_current_user(query, user, context): django_auth.login( context.request, user, backend=settings.AUTHENTICATION_BACKENDS[0], ) result = query("{ currentUser { username } }", context_value=context) assert not result.errors assert result.data == {"currentUser": {"username": "user"}} def test_current_user_not_logged_in(query, user, context): result = query("{ currentUser { username } }", context_value=context) assert result.errors assert result.data == {"currentUser": None} strawberry-graphql-django-0.82.1/tests/auth/test_types.py000066400000000000000000000020431516173410200235610ustar00rootroot00000000000000import strawberry from django.contrib.auth.models import Group, User from strawberry.types import get_object_definition from strawberry.types.base import StrawberryList import strawberry_django from strawberry_django import DjangoModelType def test_user_type(): @strawberry_django.type(User) class Type: username: strawberry.auto email: strawberry.auto groups: strawberry.auto object_definition = get_object_definition(Type, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("username", str), ("email", str), ("groups", StrawberryList(DjangoModelType)), ] def test_group_type(): @strawberry_django.type(Group) class Type: name: strawberry.auto users: strawberry.auto = strawberry_django.field(field_name="user_set") object_definition = get_object_definition(Type, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("name", str), ("users", StrawberryList(DjangoModelType)), ] strawberry-graphql-django-0.82.1/tests/conftest.py000066400000000000000000000154261516173410200222530ustar00rootroot00000000000000import contextlib import pathlib import re import shutil from typing import cast import pytest import strawberry from django.conf import settings from django.test.client import AsyncClient, Client import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry_django.utils import IS_GQL_32, IS_GQL_33 from tests.utils import GraphQLTestClient from . import models, types, utils def normalize_sdl(sdl: str) -> str: """Normalize SDL whitespace differences between graphql-core versions. graphql-core 3.3 adds spaces inside braces in directive arguments: [{ foo }] -> [{foo}]. This normalizes to 3.2 format. """ sdl = re.sub(r"\{ ", "{", sdl) sdl = re.sub(r" \}", "}", sdl) return sdl # noqa: RET504 def skip_if_gql_32( reason: str = "Test requires graphql-core 3.3+", ) -> pytest.MarkDecorator: """Skip test if running with graphql-core 3.2.x.""" return pytest.mark.skipif(IS_GQL_32, reason=reason) def skip_if_gql_33( reason: str = "Test requires graphql-core 3.2.x", ) -> pytest.MarkDecorator: """Skip test if running with graphql-core 3.3+.""" return pytest.mark.skipif(IS_GQL_33, reason=reason) _TESTS_DIR = pathlib.Path(__file__).parent _ROOT_DIR = _TESTS_DIR.parent @pytest.fixture(scope="session", autouse=True) def _cleanup(request): def cleanup_function(): shutil.rmtree(_ROOT_DIR / ".tmp_upload", ignore_errors=True) request.addfinalizer(cleanup_function) # noqa: PT021 @pytest.fixture(params=["sync", "async", "sync_no_optimizer", "async_no_optimizer"]) def gql_client(request): client, path, with_optimizer = cast( "dict[str, tuple[type[Client | AsyncClient], str, bool]]", { "sync": (Client, "/graphql/", True), "async": (AsyncClient, "/graphql_async/", True), "sync_no_optimizer": (Client, "/graphql/", False), "async_no_optimizer": (AsyncClient, "/graphql_async/", False), }, )[request.param] if with_optimizer: optimizer_ctx = contextlib.nullcontext else: optimizer_ctx = DjangoOptimizerExtension.disabled with optimizer_ctx(), GraphQLTestClient(path, client()) as c: yield c @pytest.fixture def fruits(db): fruit_names = ["strawberry", "raspberry", "banana"] return [models.Fruit.objects.create(name=name) for name in fruit_names] @pytest.fixture def vegetables(db): vegetable_names = ["carrot", "cucumber", "onion"] vegetable_world_production = [40.0e6, 75.2e6, 102.2e6] # in tons return [ models.Vegetable.objects.create(name=n, world_production=p) for n, p in zip(vegetable_names, vegetable_world_production, strict=False) ] @pytest.fixture def tag(db): return models.Tag.objects.create(name="tag") @pytest.fixture def group(db, tag): group = models.Group.objects.create(name="group") group.tags.add(tag) return group @pytest.fixture def user(db, group, tag): return models.User.objects.create(name="user", group=group, tag=tag) @pytest.fixture def users(db): return [ models.User.objects.create(name="user1"), models.User.objects.create(name="user2"), models.User.objects.create(name="user3"), ] @pytest.fixture def groups(db): return [ models.Group.objects.create(name="group1"), models.Group.objects.create(name="group2"), models.Group.objects.create(name="group3"), ] if settings.GEOS_IMPORTED: @pytest.fixture def geofields(db): from django.contrib.gis.geos import ( LineString, MultiLineString, MultiPoint, MultiPolygon, Point, Polygon, ) return [ models.GeosFieldsModel.objects.create( point=Point(x=0, y=0), line_string=LineString((0, 0), (1, 1)), polygon=Polygon(((-1, -1), (-1, 1), (1, 1), (1, -1), (-1, -1))), multi_point=MultiPoint(Point(x=0, y=0), Point(x=1, y=1)), multi_line_string=MultiLineString( LineString((0, 0), (1, 1)), LineString((1, 1), (-1, -1)), ), multi_polygon=MultiPolygon( Polygon(((-1, -1), (-1, 1), (1, 1), (1, -1), (-1, -1))), Polygon(((-1, -1), (-1, 1), (1, 1), (1, -1), (-1, -1))), ), ), models.GeosFieldsModel.objects.create( point=Point(x=1, y=1), line_string=LineString((1, 1), (2, 2), (3, 3)), polygon=Polygon( ((-1, -1), (-1, 1), (1, 1), (1, -1), (-1, -1)), ((-2, -2), (-2, 2), (2, 2), (2, -2), (-2, -2)), ), multi_point=MultiPoint( Point(x=0, y=0), Point(x=-1, y=-1), Point(x=1, y=1), ), multi_line_string=MultiLineString( LineString((0, 0), (1, 1)), LineString((1, 1), (-1, -1)), LineString((2, 2), (-2, -2)), ), multi_polygon=MultiPolygon( Polygon( ((-1, -1), (-1, 1), (1, 1), (1, -1), (-1, -1)), ((-2, -2), (-2, 2), (2, 2), (2, -2), (-2, -2)), ), Polygon( ((-1, -1), (-1, 1), (1, 1), (1, -1), (-1, -1)), ((-2, -2), (-2, 2), (2, 2), (2, -2), (-2, -2)), ), ), ), models.GeosFieldsModel.objects.create(), ] @pytest.fixture(params=["optimizer_enabled", "optimizer_disabled"]) def schema(request): @strawberry.type class Query: user: types.User = strawberry_django.field() users: list[types.User] = strawberry_django.field() group: types.Group = strawberry_django.field() groups: list[types.Group] = strawberry_django.field() tag: types.Tag = strawberry_django.field() tags: list[types.Tag] = strawberry_django.field() if request.param == "optimizer_enabled": extensions = [DjangoOptimizerExtension()] elif request.param == "optimizer_disabled": extensions = [] else: raise AssertionError(f"Not able to handle param '{request.param}'") if settings.GEOS_IMPORTED: @strawberry.type class GeoQuery(Query): geofields: list[types.GeoField] = strawberry_django.field() return strawberry.Schema(query=GeoQuery, extensions=extensions) return strawberry.Schema(query=Query, extensions=extensions) @pytest.fixture( params=[ strawberry_django.type, strawberry_django.input, utils.dataclass, ], ) def testtype(request): return request.param strawberry-graphql-django-0.82.1/tests/django_settings.py000066400000000000000000000053011516173410200235770ustar00rootroot00000000000000from django.core.exceptions import ImproperlyConfigured from django.db import models from django.db.models.manager import BaseManager from django.db.models.query import QuerySet for cls in [QuerySet, BaseManager, models.ForeignKey, models.ManyToManyField]: if not hasattr(cls, "__class_getitem__"): cls.__class_getitem__ = classmethod( # type: ignore lambda cls, *args, **kwargs: cls, ) DEBUG = True SECRET_KEY = 1 USE_TZ = True TIME_ZONE = "UTC" DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:", }, } INSTALLED_APPS = [ "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.staticfiles", "guardian", "debug_toolbar", "strawberry_django", ] STATIC_URL = "/static/" ROOT_URLCONF = "tests.urls" AUTHENTICATION_BACKENDS = ( "django.contrib.auth.backends.ModelBackend", "guardian.backends.ObjectPermissionBackend", ) ANONYMOUS_USER_NAME = None MIDDLEWARE = [ "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "strawberry_django.middlewares.debug_toolbar.DebugToolbarMiddleware", ] AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": { "min_length": 2, }, }, ] CACHES = { "default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", "LOCATION": "unique-snowflake", }, } LOGGING = { "version": 1, "disable_existing_loggers": False, "formatters": { "simple": {"format": "%(levelname)s %(message)s"}, }, "filters": { "require_debug_true": { "()": "django.utils.log.RequireDebugTrue", }, }, "handlers": { "console": { "level": "DEBUG", "class": "logging.StreamHandler", "formatter": "simple", }, }, "loggers": { "django.db.backends": { "handlers": ["console"], "level": "INFO", }, "strawberry.execution": { "handlers": ["console"], "level": "INFO", }, }, } try: from django.contrib.gis.db import models assert models # ruff DATABASES["default"]["ENGINE"] = "django.contrib.gis.db.backends.spatialite" INSTALLED_APPS.append("django.contrib.gis") GEOS_IMPORTED = True except ImproperlyConfigured: GEOS_IMPORTED = False INSTALLED_APPS.extend( [ "tests", "tests.projects", "tests.polymorphism", "tests.polymorphism_custom", "tests.polymorphism_inheritancemanager", ], ) strawberry-graphql-django-0.82.1/tests/exceptions.py000066400000000000000000000012661516173410200226040ustar00rootroot00000000000000from strawberry_django.exceptions import ( ForbiddenFieldArgumentError, ) from strawberry_django.fields.filter_order import FilterOrderFieldResolver def test_forbidden_field_argument_extra_one(): resolver = FilterOrderFieldResolver(resolver_type="filter", func=lambda x: x) exc = ForbiddenFieldArgumentError(resolver, ["one"]) assert exc.extra_arguments_str == 'argument "one"' def test_forbidden_field_argument_extra_many(): resolver = FilterOrderFieldResolver(resolver_type="filter", func=lambda x: x) exc = ForbiddenFieldArgumentError(resolver, ["extra", "forbidden", "fields"]) assert exc.extra_arguments_str == 'arguments "extra, forbidden" and "fields"' strawberry-graphql-django-0.82.1/tests/extensions/000077500000000000000000000000001516173410200222435ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/extensions/__init__.py000066400000000000000000000000001516173410200243420ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/extensions/test_validation_cache.py000066400000000000000000000025501516173410200271330ustar00rootroot00000000000000from unittest.mock import patch import pytest import strawberry from graphql import validate from strawberry_django.extensions.django_validation_cache import DjangoValidationCache @pytest.mark.filterwarnings("ignore::django.core.cache.backends.base.CacheKeyWarning") @patch("strawberry_django.extensions.django_validation_cache.validate", wraps=validate) def test_validation_cache_extension(mock_validate): @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" @strawberry.field def ping(self) -> str: return "pong" schema = strawberry.Schema(query=Query, extensions=[DjangoValidationCache()]) query = "query { hello }" result = schema.execute_sync(query) assert not result.errors assert result.data == {"hello": "world"} assert mock_validate.call_count == 1 # Run query multiple times for _ in range(3): result = schema.execute_sync(query) assert not result.errors assert result.data == {"hello": "world"} # validate is still only called once assert mock_validate.call_count == 1 # Running a second query doesn't cache query2 = "query { ping }" result = schema.execute_sync(query2) assert not result.errors assert result.data == {"ping": "pong"} assert mock_validate.call_count == 2 strawberry-graphql-django-0.82.1/tests/federation/000077500000000000000000000000001516173410200221645ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/federation/__init__.py000066400000000000000000000000651516173410200242760ustar00rootroot00000000000000"""Tests for strawberry_django.federation module.""" strawberry-graphql-django-0.82.1/tests/federation/test_resolve_reference.py000066400000000000000000000307321516173410200272770ustar00rootroot00000000000000"""Tests for auto-generated resolve_reference functionality.""" import inspect from typing import TYPE_CHECKING, Any, cast import pytest import strawberry from strawberry.federation import Schema as FederationSchema from strawberry.types.info import Info import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from tests import models if TYPE_CHECKING: from strawberry_django.utils.typing import WithStrawberryDjangoObjectDefinition def call_resolve_reference(type_: Any, **kwargs: Any) -> Any: return type_.resolve_reference(**kwargs) # ============================================================================= # Auto-generated resolve_reference # ============================================================================= @pytest.mark.django_db def test_resolve_reference_basic(): """Test that auto-generated resolve_reference works.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto fruit = models.Fruit.objects.create(name="strawberry") result = call_resolve_reference(FruitType, id=fruit.id) assert result is not None assert result.name == "strawberry" @pytest.mark.django_db def test_resolve_reference_with_string_key(): """Test resolve_reference with a string field as key.""" @strawberry_django.federation.type(models.Fruit, keys=["name"]) class FruitType: id: strawberry.auto name: strawberry.auto fruit = models.Fruit.objects.create(name="banana") result = call_resolve_reference(FruitType, name="banana") assert result is not None assert result.id == fruit.id @pytest.mark.django_db def test_resolve_reference_with_composite_key(): """Test resolve_reference with composite key.""" @strawberry_django.federation.type(models.User, keys=["name group_id"]) class UserType: name: strawberry.auto group_id: int | None group = models.Group.objects.create(name="test-group") user = models.User.objects.create(name="testuser", group=group) result = call_resolve_reference(UserType, name="testuser", group_id=group.pk) assert result is not None assert result.id == user.pk @pytest.mark.django_db def test_resolve_reference_not_found(): """Test resolve_reference when entity doesn't exist.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto with pytest.raises(models.Fruit.DoesNotExist): call_resolve_reference(FruitType, id=99999) def test_resolve_reference_accepts_info(): """Test that resolve_reference accepts info parameter via **kwargs.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto sig = inspect.signature(cast("Any", FruitType).resolve_reference) assert any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()) @pytest.mark.django_db def test_custom_resolve_reference_preserved(): """Test that custom resolve_reference is not overwritten.""" custom_called = [] @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto @classmethod def resolve_reference( cls, id: int, # noqa: A002 info: Info | None = None, ) -> models.Fruit: custom_called.append(id) return models.Fruit.objects.get(id=id) fruit = models.Fruit.objects.create(name="custom-fruit") result = call_resolve_reference(FruitType, id=fruit.pk) assert custom_called == [fruit.id] assert result.name == "custom-fruit" @pytest.mark.django_db def test_resolve_reference_with_select_related(): """Test that resolve_reference works with types that have optimization hints.""" @strawberry_django.federation.type( models.User, keys=["id"], select_related=["group"], ) class UserType: id: strawberry.auto name: strawberry.auto group = models.Group.objects.create(name="test-group") user = models.User.objects.create(name="testuser", group=group) result = call_resolve_reference(UserType, id=user.pk) assert result is not None assert result.name == "testuser" @pytest.mark.django_db def test_resolve_reference_with_multiple_keys(): """Test type with multiple @key directives resolves whichever matching key fields are present.""" @strawberry_django.federation.type( models.Fruit, keys=["id", "name"], ) class FruitType: id: strawberry.auto name: strawberry.auto fruit = models.Fruit.objects.create(name="multi-key-fruit") result1 = call_resolve_reference(FruitType, id=fruit.pk) assert result1.name == "multi-key-fruit" result2 = call_resolve_reference(FruitType, name="multi-key-fruit") assert result2.id == fruit.id @pytest.mark.django_db def test_resolve_reference_uses_optimizer_extension(): """Test resolve_reference integrates with optimizer extension.""" optimize_calls: list[bool] = [] class RecordingOptimizerExtension(DjangoOptimizerExtension): def optimize(self, qs, info, *, store=None): optimize_calls.append(True) return super().optimize(qs, info, store=store) @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema( query=Query, types=[FruitType], extensions=[RecordingOptimizerExtension()], ) fruit = models.Fruit.objects.create(name="optimized-fruit") result = schema.execute_sync( f""" query {{ _entities(representations: [{{__typename: "FruitType", id: {fruit.id}}}]) {{ ... on FruitType {{ id name }} }} }} """ ) assert result.errors is None assert optimize_calls @pytest.mark.django_db def test_resolve_reference_ignores_non_key_kwargs(): """Ensure non-key kwargs in representations are ignored, not cause failure.""" @strawberry_django.federation.type( models.Fruit, keys=["id"], ) class FruitType: id: strawberry.auto name: strawberry.auto fruit = models.Fruit.objects.create(name="extra-field-fruit") result = call_resolve_reference( FruitType, id=fruit.pk, extra="ignored", __typename="FruitType", ) assert result is not None assert result.id == fruit.id assert result.name == "extra-field-fruit" # ============================================================================= # resolve_model_reference function # ============================================================================= @pytest.mark.django_db def test_resolve_model_reference_basic(): """Test resolve_model_reference function.""" from strawberry_django.federation.resolve import resolve_model_reference @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto fruit = models.Fruit.objects.create(name="test-fruit") result = resolve_model_reference( cast("type[WithStrawberryDjangoObjectDefinition]", FruitType), id=fruit.pk, ) assert result is not None assert cast("models.Fruit", result).name == "test-fruit" @pytest.mark.django_db def test_resolve_model_reference_with_multiple_fields(): """Test resolve_model_reference with multiple filter fields.""" from strawberry_django.federation.resolve import resolve_model_reference @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto fruit = models.Fruit.objects.create(name="specific-fruit") result = resolve_model_reference( cast("type[WithStrawberryDjangoObjectDefinition]", FruitType), id=fruit.pk, name="specific-fruit", ) assert result is not None assert cast("models.Fruit", result).id == fruit.id # ============================================================================= # get_queryset integration # ============================================================================= @pytest.mark.django_db def test_resolve_reference_respects_get_queryset(): """Test that get_queryset filters are applied during resolution.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class BerryType: id: strawberry.auto name: strawberry.auto @classmethod def get_queryset(cls, queryset, info, **kwargs): return queryset.filter(name__contains="berry") models.Fruit.objects.create(name="strawberry") apple = models.Fruit.objects.create(name="apple") with pytest.raises(models.Fruit.DoesNotExist): call_resolve_reference(BerryType, id=apple.pk) @pytest.mark.django_db def test_resolve_reference_get_queryset_via_entities(): """Test get_queryset is applied when resolving via _entities query.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class BerryType: id: strawberry.auto name: strawberry.auto @classmethod def get_queryset(cls, queryset, info, **kwargs): return queryset.filter(name__contains="berry") @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[BerryType]) berry = models.Fruit.objects.create(name="strawberry") apple = models.Fruit.objects.create(name="apple") # Berry should resolve result = schema.execute_sync( f""" query {{ _entities(representations: [{{__typename: "BerryType", id: {berry.id}}}]) {{ ... on BerryType {{ name }} }} }} """ ) assert result.errors is None assert result.data is not None assert result.data["_entities"][0]["name"] == "strawberry" # Apple should fail (filtered out by get_queryset) result = schema.execute_sync( f""" query {{ _entities(representations: [{{__typename: "BerryType", id: {apple.id}}}]) {{ ... on BerryType {{ name }} }} }} """ ) assert result.errors is not None # ============================================================================= # Edge cases # ============================================================================= @pytest.mark.django_db def test_resolve_reference_with_multiple_objects_returned(): """Test MultipleObjectsReturned when key matches more than one row.""" @strawberry_django.federation.type(models.Fruit, keys=["sweetness"]) class FruitType: id: strawberry.auto name: strawberry.auto sweetness: strawberry.auto models.Fruit.objects.create(name="apple", sweetness=5) models.Fruit.objects.create(name="pear", sweetness=5) with pytest.raises(models.Fruit.MultipleObjectsReturned): call_resolve_reference(FruitType, sweetness=5) @pytest.mark.django_db def test_resolve_reference_composite_key_via_entities(): """Test composite key resolution end-to-end via _entities.""" @strawberry_django.federation.type(models.Fruit, keys=["name sweetness"]) class FruitType: id: strawberry.auto name: strawberry.auto sweetness: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) fruit = models.Fruit.objects.create(name="mango", sweetness=8) result = schema.execute_sync( """ query { _entities(representations: [ {__typename: "FruitType", name: "mango", sweetness: 8} ]) { ... on FruitType { id name sweetness } } } """ ) assert result.errors is None assert result.data is not None assert result.data["_entities"][0]["id"] == str(fruit.id) assert result.data["_entities"][0]["sweetness"] == 8 strawberry-graphql-django-0.82.1/tests/federation/test_schema.py000066400000000000000000000621541516173410200250450ustar00rootroot00000000000000"""Tests for federation schema integration.""" import pytest import strawberry from strawberry.federation import Schema as FederationSchema from strawberry.federation.schema_directives import ( Authenticated, External, Inaccessible, Override, Policy, Provides, Requires, RequiresScopes, Shareable, Tag, ) from strawberry.types import get_object_definition import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from tests import models from tests.utils import assert_num_queries # ============================================================================= # Schema SDL generation # ============================================================================= def test_schema_contains_key_directive(): """Test that federation schema generates @key directive correctly.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry.field def fruits(self) -> list[FruitType]: return [] schema = FederationSchema(query=Query) sdl = str(schema) assert "@key" in sdl assert 'fields: "id"' in sdl def test_schema_with_multiple_keys(): """Test schema with multiple @key directives.""" @strawberry_django.federation.type(models.Fruit, keys=["id", "name"]) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) sdl = str(schema) assert sdl.count("@key") >= 2 def test_schema_with_shareable_directive(): """Test schema with @shareable directive.""" @strawberry_django.federation.type(models.Fruit, keys=["id"], shareable=True) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) sdl = str(schema) assert "@shareable" in sdl # ============================================================================= # Federation field directives # ============================================================================= def test_field_with_external(): """Test @external directive on field.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: str = strawberry_django.federation.field(external=True) type_def = get_object_definition(FruitType, strict=True) name_field = next(f for f in type_def.fields if f.name == "name") assert any(isinstance(d, External) for d in name_field.directives) def test_field_with_requires(): """Test @requires directive on field.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: str = strawberry_django.federation.field(external=True) computed: str = strawberry_django.federation.field( requires=["name"], resolver=lambda self: f"computed-{self.name}", ) type_def = get_object_definition(FruitType, strict=True) computed_field = next(f for f in type_def.fields if f.name == "computed") requires_directives = [ d for d in computed_field.directives if isinstance(d, Requires) ] assert len(requires_directives) == 1 assert str(requires_directives[0].fields) == "name" def test_field_with_provides(): """Test @provides directive on field.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry_django.federation.type(models.Color, keys=["id"]) class ColorType: id: strawberry.auto name: strawberry.auto fruit: FruitType = strawberry_django.federation.field( provides=["name"], ) type_def = get_object_definition(ColorType, strict=True) fruit_field = next(f for f in type_def.fields if f.name == "fruit") provides_directives = [d for d in fruit_field.directives if isinstance(d, Provides)] assert len(provides_directives) == 1 assert str(provides_directives[0].fields) == "name" def test_field_with_shareable(): """Test @shareable directive on field.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: str = strawberry_django.federation.field(shareable=True) type_def = get_object_definition(FruitType, strict=True) name_field = next(f for f in type_def.fields if f.name == "name") assert any(isinstance(d, Shareable) for d in name_field.directives) def test_field_with_authenticated(): """Test @authenticated directive on field.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: str = strawberry_django.federation.field(authenticated=True) type_def = get_object_definition(FruitType, strict=True) name_field = next(f for f in type_def.fields if f.name == "name") assert any(isinstance(d, Authenticated) for d in name_field.directives) def test_field_with_inaccessible(): """Test @inaccessible directive on field.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: str = strawberry_django.federation.field(inaccessible=True) type_def = get_object_definition(FruitType, strict=True) name_field = next(f for f in type_def.fields if f.name == "name") assert any(isinstance(d, Inaccessible) for d in name_field.directives) def test_field_with_override(): """Test @override directive on field.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: str = strawberry_django.federation.field(override="products") type_def = get_object_definition(FruitType, strict=True) name_field = next(f for f in type_def.fields if f.name == "name") override_directive = next( d for d in name_field.directives if isinstance(d, Override) ) assert override_directive.override_from == "products" def test_field_with_policy(): """Test @policy directive on field.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: str = strawberry_django.federation.field( policy=[["read:fruits", "admin"]], ) type_def = get_object_definition(FruitType, strict=True) name_field = next(f for f in type_def.fields if f.name == "name") policy_directive = next(d for d in name_field.directives if isinstance(d, Policy)) assert policy_directive.policies == [["read:fruits", "admin"]] def test_field_with_requires_scopes(): """Test @requiresScopes directive on field.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: str = strawberry_django.federation.field( requires_scopes=[["read:fruits"]], ) type_def = get_object_definition(FruitType, strict=True) name_field = next(f for f in type_def.fields if f.name == "name") scopes_directive = next( d for d in name_field.directives if isinstance(d, RequiresScopes) ) assert scopes_directive.scopes == [["read:fruits"]] def test_field_with_tags(): """Test @tag directive on field.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: str = strawberry_django.federation.field(tags=["internal", "public"]) type_def = get_object_definition(FruitType, strict=True) name_field = next(f for f in type_def.fields if f.name == "name") tag_directives = [d for d in name_field.directives if isinstance(d, Tag)] assert {t.name for t in tag_directives} == {"internal", "public"} # ============================================================================= # _entities resolver # ============================================================================= @pytest.mark.django_db def test_entities_resolver_basic(): """Test the federation _entities resolver works.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) fruit = models.Fruit.objects.create(name="strawberry") result = schema.execute_sync( f""" query {{ _entities(representations: [{{__typename: "FruitType", id: {fruit.id}}}]) {{ ... on FruitType {{ id name }} }} }} """ ) assert result.errors is None assert result.data is not None assert result.data["_entities"][0]["name"] == "strawberry" @pytest.mark.django_db def test_entities_resolver_with_string_key(): """Test _entities resolver with string key field.""" @strawberry_django.federation.type(models.Fruit, keys=["name"]) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) fruit = models.Fruit.objects.create(name="banana") result = schema.execute_sync( """ query { _entities(representations: [{__typename: "FruitType", name: "banana"}]) { ... on FruitType { id name } } } """ ) assert result.errors is None assert result.data is not None assert result.data["_entities"][0]["id"] == str(fruit.id) @pytest.mark.django_db def test_entities_resolver_multiple_entities(): """Test _entities resolver with multiple entities.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) fruit1 = models.Fruit.objects.create(name="apple") fruit2 = models.Fruit.objects.create(name="orange") result = schema.execute_sync( f""" query {{ _entities(representations: [ {{__typename: "FruitType", id: {fruit1.id}}}, {{__typename: "FruitType", id: {fruit2.id}}} ]) {{ ... on FruitType {{ id name }} }} }} """ ) assert result.errors is None assert result.data is not None assert len(result.data["_entities"]) == 2 names = {e["name"] for e in result.data["_entities"]} assert names == {"apple", "orange"} @pytest.mark.django_db def test_service_sdl_field(): """Test the federation _service.sdl field.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) result = schema.execute_sync( """ query { _service { sdl } } """ ) assert result.errors is None assert result.data is not None sdl = result.data["_service"]["sdl"] assert "@key" in sdl assert "FruitType" in sdl @pytest.mark.django_db def test_entities_resolver_with_composite_key(): """Test _entities resolver with composite key (space-separated fields).""" @strawberry_django.federation.type(models.Fruit, keys=["name sweetness"]) class FruitType: id: strawberry.auto name: strawberry.auto sweetness: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) models.Fruit.objects.create(name="apple", sweetness=3) result = schema.execute_sync( """ query { _entities(representations: [ {__typename: "FruitType", name: "apple", sweetness: 3} ]) { ... on FruitType { id name sweetness } } } """ ) assert result.errors is None assert result.data is not None assert result.data["_entities"][0]["name"] == "apple" assert result.data["_entities"][0]["sweetness"] == 3 @pytest.mark.django_db def test_entities_resolver_with_multiple_keys_uses_provided_key(): """Test _entities with a type that has multiple @key directives.""" @strawberry_django.federation.type(models.Fruit, keys=["id", "name"]) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) fruit = models.Fruit.objects.create(name="kiwi") result = schema.execute_sync( """ query { _entities(representations: [ {__typename: "FruitType", name: "kiwi"} ]) { ... on FruitType { id name } } } """ ) assert result.errors is None assert result.data is not None assert result.data["_entities"][0]["id"] == str(fruit.id) @pytest.mark.django_db def test_entities_resolver_not_found(): """Test _entities resolver returns error for nonexistent entity.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) result = schema.execute_sync( """ query { _entities(representations: [{__typename: "FruitType", id: 99999}]) { ... on FruitType { id name } } } """ ) assert result.errors is not None @pytest.mark.django_db def test_entities_resolver_with_fk_relationship(): """Test _entities resolver returns related FK data.""" @strawberry_django.federation.type(models.Color, keys=["id"]) class ColorType: id: strawberry.auto name: strawberry.auto @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto color: ColorType | None @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType, ColorType]) color = models.Color.objects.create(name="red") fruit = models.Fruit.objects.create(name="apple", color=color) result = schema.execute_sync( f""" query {{ _entities(representations: [{{__typename: "FruitType", id: {fruit.id}}}]) {{ ... on FruitType {{ name color {{ name }} }} }} }} """ ) assert result.errors is None assert result.data is not None entity = result.data["_entities"][0] assert entity["name"] == "apple" assert entity["color"]["name"] == "red" @pytest.mark.django_db def test_entities_resolver_multiple_types(): """Test _entities resolver with different entity types in one query.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry_django.federation.type(models.Color, keys=["id"]) class ColorType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType, ColorType]) fruit = models.Fruit.objects.create(name="grape") color = models.Color.objects.create(name="purple") result = schema.execute_sync( f""" query {{ _entities(representations: [ {{__typename: "FruitType", id: {fruit.id}}}, {{__typename: "ColorType", id: {color.pk}}} ]) {{ ... on FruitType {{ name }} ... on ColorType {{ name }} }} }} """ ) assert result.errors is None assert result.data is not None assert len(result.data["_entities"]) == 2 names = {e["name"] for e in result.data["_entities"]} assert names == {"grape", "purple"} @pytest.mark.django_db def test_entities_resolver_with_custom_resolve_reference(): """Test _entities calls custom resolve_reference when defined.""" custom_called = [] @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto @classmethod def resolve_reference(cls, id: int, **kwargs) -> models.Fruit: # noqa: A002 custom_called.append(id) return models.Fruit.objects.get(id=id) @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) fruit = models.Fruit.objects.create(name="custom") result = schema.execute_sync( f""" query {{ _entities(representations: [{{__typename: "FruitType", id: {fruit.id}}}]) {{ ... on FruitType {{ name }} }} }} """ ) assert result.errors is None assert result.data is not None assert result.data["_entities"][0]["name"] == "custom" assert custom_called == [fruit.id] @pytest.mark.django_db def test_entities_resolver_with_optimizer(): """Test _entities integrates with DjangoOptimizerExtension.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema( query=Query, types=[FruitType], extensions=[DjangoOptimizerExtension()], ) fruit = models.Fruit.objects.create(name="optimized") with assert_num_queries(1): result = schema.execute_sync( f""" query {{ _entities(representations: [ {{__typename: "FruitType", id: {fruit.id}}} ]) {{ ... on FruitType {{ id name }} }} }} """ ) assert result.errors is None assert result.data is not None assert result.data["_entities"][0]["name"] == "optimized" @pytest.mark.django_db def test_entities_resolver_with_optimizer_and_fk(): """Test _entities + optimizer resolves FK without N+1.""" @strawberry_django.federation.type(models.Color, keys=["id"]) class ColorType: id: strawberry.auto name: strawberry.auto @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto color: ColorType | None @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema( query=Query, types=[FruitType, ColorType], extensions=[DjangoOptimizerExtension()], ) color = models.Color.objects.create(name="yellow") fruit = models.Fruit.objects.create(name="banana", color=color) with assert_num_queries(1): result = schema.execute_sync( f""" query {{ _entities(representations: [ {{__typename: "FruitType", id: {fruit.id}}} ]) {{ ... on FruitType {{ name color {{ name }} }} }} }} """ ) assert result.errors is None assert result.data is not None assert result.data["_entities"][0]["color"]["name"] == "yellow" # ============================================================================= # SDL output # ============================================================================= def test_sdl_with_extend(): """Test that extend=True renders in SDL.""" @strawberry_django.federation.type(models.Fruit, keys=["id"], extend=True) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) sdl = str(schema) assert "@key" in sdl assert "extend" in sdl def test_sdl_with_field_directives(): """Test that field-level directives render correctly in SDL.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: str = strawberry_django.federation.field(external=True) sweetness: strawberry.auto = strawberry_django.federation.field(shareable=True) @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) sdl = str(schema) assert "@external" in sdl assert "@shareable" in sdl def test_sdl_with_combined_type_directives(): """Test SDL with multiple type-level directives.""" @strawberry_django.federation.type( models.Fruit, keys=["id"], shareable=True, tags=["internal"], ) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) sdl = str(schema) assert "@key" in sdl assert "@shareable" in sdl assert "@tag" in sdl def test_sdl_composite_key_renders_correctly(): """Test SDL renders composite key fields as single FieldSet.""" @strawberry_django.federation.type(models.Fruit, keys=["name sweetness"]) class FruitType: id: strawberry.auto name: strawberry.auto sweetness: strawberry.auto @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) sdl = str(schema) assert 'fields: "name sweetness"' in sdl def test_sdl_field_requires_renders_fieldset(): """Test that @requires renders its FieldSet in SDL.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: str = strawberry_django.federation.field(external=True) sweetness: strawberry.auto = strawberry_django.federation.field( requires=["name"], ) @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType]) sdl = str(schema) assert '@requires(fields: "name")' in sdl def test_sdl_field_provides_renders_fieldset(): """Test that @provides renders its FieldSet in SDL.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry_django.federation.type(models.Color, keys=["id"]) class ColorType: id: strawberry.auto name: strawberry.auto fruit: FruitType = strawberry_django.federation.field( provides=["name"], ) @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" schema = FederationSchema(query=Query, types=[FruitType, ColorType]) sdl = str(schema) assert '@provides(fields: "name")' in sdl strawberry-graphql-django-0.82.1/tests/federation/test_type.py000066400000000000000000000321651516173410200245650ustar00rootroot00000000000000"""Tests for strawberry_django.federation.type decorator.""" from typing import Any, cast import pytest import strawberry from strawberry.federation.schema_directives import ( Authenticated, Inaccessible, Key, Policy, RequiresScopes, Shareable, Tag, ) from strawberry.federation.types import FieldSet from strawberry.types import get_object_definition import strawberry_django from tests import models # ============================================================================= # Tests for federation type decorator with keys # ============================================================================= def test_federation_type_with_string_key(): """Test that @strawberry_django.federation.type accepts string keys.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto type_def = get_object_definition(FruitType, strict=True) key_directives = [d for d in type_def.directives or [] if isinstance(d, Key)] assert len(key_directives) == 1 assert str(key_directives[0].fields) == "id" def test_federation_type_with_key_directive_object(): """Test using Key directive directly for complex keys.""" @strawberry_django.federation.type( models.Fruit, keys=[Key(fields=FieldSet("id"), resolvable=False)], ) class FruitType: id: strawberry.auto type_def = get_object_definition(FruitType, strict=True) key_directive = next(d for d in type_def.directives or [] if isinstance(d, Key)) assert key_directive.resolvable is False @pytest.mark.parametrize( ("keys", "expected_fields"), [ (["id"], {"id"}), (["id", "name"], {"id", "name"}), (["id", "name", "sweetness"], {"id", "name", "sweetness"}), ], ) def test_federation_type_with_multiple_keys(keys, expected_fields): """Test type with multiple @key directives.""" @strawberry_django.federation.type(models.Fruit, keys=keys) class FruitType: id: strawberry.auto name: strawberry.auto sweetness: strawberry.auto type_def = get_object_definition(FruitType, strict=True) key_directives = [d for d in type_def.directives or [] if isinstance(d, Key)] assert len(key_directives) == len(keys) key_fields = {str(k.fields) for k in key_directives} assert key_fields == expected_fields def test_federation_type_with_composite_key(): """Test type with composite @key directive (multiple fields in one key).""" @strawberry_django.federation.type(models.Fruit, keys=["id name"]) class FruitType: id: strawberry.auto name: strawberry.auto type_def = get_object_definition(FruitType, strict=True) key_directives = [d for d in type_def.directives or [] if isinstance(d, Key)] assert len(key_directives) == 1 assert str(key_directives[0].fields) == "id name" # ============================================================================= # Tests for federation directives # ============================================================================= @pytest.mark.parametrize( ("directive_param", "directive_value", "directive_class"), [ ("shareable", True, Shareable), ("authenticated", True, Authenticated), ("inaccessible", True, Inaccessible), ], ) def test_federation_type_boolean_directives( directive_param, directive_value, directive_class ): """Test boolean federation directives (@shareable, @authenticated, @inaccessible).""" kwargs = cast("dict[str, Any]", {"keys": ["id"], directive_param: directive_value}) @strawberry_django.federation.type(models.Fruit, **kwargs) class FruitType: id: strawberry.auto type_def = get_object_definition(FruitType, strict=True) assert any(isinstance(d, directive_class) for d in type_def.directives or []) def test_federation_type_inaccessible_false_no_directive(): """Test that inaccessible=False does NOT add @inaccessible directive.""" @strawberry_django.federation.type(models.Fruit, keys=["id"], inaccessible=False) class FruitType: id: strawberry.auto type_def = get_object_definition(FruitType, strict=True) assert not any(isinstance(d, Inaccessible) for d in type_def.directives or []) def test_federation_type_with_policy(): """Test @policy directive.""" @strawberry_django.federation.type( models.Fruit, keys=["id"], policy=[["read:fruits", "admin"]], ) class FruitType: id: strawberry.auto type_def = get_object_definition(FruitType, strict=True) policy_directives = [d for d in type_def.directives or [] if isinstance(d, Policy)] assert len(policy_directives) == 1 assert policy_directives[0].policies == [["read:fruits", "admin"]] def test_federation_type_with_requires_scopes(): """Test @requiresScopes directive.""" @strawberry_django.federation.type( models.Fruit, keys=["id"], requires_scopes=[["read:fruits"]], ) class FruitType: id: strawberry.auto type_def = get_object_definition(FruitType, strict=True) scopes_directives = [ d for d in type_def.directives or [] if isinstance(d, RequiresScopes) ] assert len(scopes_directives) == 1 assert scopes_directives[0].scopes == [["read:fruits"]] @pytest.mark.parametrize( "tags", [ ["internal"], ["internal", "deprecated"], ["v1", "public", "stable"], ], ) def test_federation_type_with_tags(tags): """Test @tag directives with various tag counts.""" @strawberry_django.federation.type(models.Fruit, keys=["id"], tags=tags) class FruitType: id: strawberry.auto type_def = get_object_definition(FruitType, strict=True) tag_directives = [d for d in type_def.directives or [] if isinstance(d, Tag)] assert len(tag_directives) == len(tags) tag_names = {t.name for t in tag_directives} assert tag_names == set(tags) def test_federation_type_with_extend(): """Test that extend=True is passed through to the type definition.""" @strawberry_django.federation.type(models.Fruit, keys=["id"], extend=True) class FruitType: id: strawberry.auto name: strawberry.auto type_def = get_object_definition(FruitType, strict=True) assert type_def.extend is True def test_federation_type_without_keys(): """Test that federation.type works without keys (non-entity with directives).""" @strawberry_django.federation.type(models.Fruit, shareable=True) class FruitType: id: strawberry.auto name: strawberry.auto type_def = get_object_definition(FruitType, strict=True) # Should have @shareable but no @key assert any(isinstance(d, Shareable) for d in type_def.directives or []) assert not any(isinstance(d, Key) for d in type_def.directives or []) def test_federation_type_preserves_custom_directives(): """Test that custom directives are preserved alongside federation directives.""" from strawberry.schema_directive import Location @strawberry.schema_directive(locations=[Location.OBJECT]) class CustomDirective: name: str @strawberry_django.federation.type( models.Fruit, keys=["id"], directives=[CustomDirective(name="test")], ) class FruitType: id: strawberry.auto type_def = get_object_definition(FruitType, strict=True) # Both Key and custom directive should be present assert any(isinstance(d, Key) for d in type_def.directives or []) assert any(isinstance(d, CustomDirective) for d in type_def.directives or []) # ============================================================================= # Tests for auto-generated resolve_reference # ============================================================================= def test_federation_type_auto_generates_resolve_reference(): """Test that resolve_reference is auto-generated for keyed types.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto assert hasattr(FruitType, "resolve_reference") assert callable(cast("Any", FruitType).resolve_reference) def test_federation_type_does_not_override_custom_resolve_reference(): """Test that custom resolve_reference is not overwritten.""" @strawberry_django.federation.type(models.Fruit, keys=["id"]) class FruitType: id: strawberry.auto name: strawberry.auto @classmethod def resolve_reference(cls, id: int): # noqa: A002 """Return the Fruit with given id.""" return models.Fruit.objects.filter(id=id).first() # Verify custom method is preserved by checking docstring assert "Return the Fruit" in ( cast("Any", FruitType).resolve_reference.__doc__ or "" ) def test_federation_type_without_keys_no_resolve_reference(): """Test that types without keys don't get resolve_reference.""" @strawberry_django.federation.type(models.Fruit, shareable=True) class FruitType: id: strawberry.auto name: strawberry.auto # Should NOT have auto-generated resolve_reference type_def = get_object_definition(FruitType, strict=True) assert not any(isinstance(d, Key) for d in type_def.directives or []) assert "resolve_reference" not in FruitType.__dict__ # ============================================================================= # Tests for Django feature integration # ============================================================================= def test_federation_type_with_filters(): """Test that filters work with federation types.""" @strawberry_django.filters.filter(models.Fruit) class FruitFilter: name: strawberry.auto @strawberry_django.federation.type( models.Fruit, keys=["id"], filters=FruitFilter, ) class FruitType: id: strawberry.auto name: strawberry.auto django_def = cast("Any", FruitType).__strawberry_django_definition__ assert django_def.filters is FruitFilter def test_federation_type_with_pagination(): """Test that pagination works with federation types.""" @strawberry_django.federation.type( models.Fruit, keys=["id"], pagination=True, ) class FruitType: id: strawberry.auto name: strawberry.auto django_def = cast("Any", FruitType).__strawberry_django_definition__ assert django_def.pagination is True def test_federation_type_with_ordering(): """Test that ordering works with federation types.""" @strawberry_django.order_type(models.Fruit) class FruitOrder: name: strawberry.auto @strawberry_django.federation.type( models.Fruit, keys=["id"], ordering=FruitOrder, ) class FruitType: id: strawberry.auto name: strawberry.auto django_def = cast("Any", FruitType).__strawberry_django_definition__ assert django_def.ordering is FruitOrder def test_federation_type_with_optimization_hints(): """Test that optimization hints work with federation types.""" @strawberry_django.federation.type( models.Fruit, keys=["id"], select_related=["color"], prefetch_related=["types"], ) class FruitType: id: strawberry.auto name: strawberry.auto django_def = cast("Any", FruitType).__strawberry_django_definition__ assert "color" in (django_def.store.select_related or []) assert any("types" in str(p) for p in (django_def.store.prefetch_related or [])) # ============================================================================= # Tests for federation interface decorator # ============================================================================= def test_federation_interface_with_keys(): """Test federation interface with @key directive.""" @strawberry_django.federation.interface(models.Fruit, keys=["id"]) class FruitInterface: id: strawberry.auto type_def = get_object_definition(FruitInterface, strict=True) assert type_def.is_interface assert any(isinstance(d, Key) for d in type_def.directives or []) def test_federation_interface_auto_generates_resolve_reference(): """Test that resolve_reference is auto-generated for keyed interfaces.""" @strawberry_django.federation.interface(models.Fruit, keys=["id"]) class FruitInterface: id: strawberry.auto assert hasattr(FruitInterface, "resolve_reference") assert callable(cast("Any", FruitInterface).resolve_reference) @pytest.mark.parametrize( ("directive_param", "directive_value", "directive_class"), [ ("authenticated", True, Authenticated), ("inaccessible", True, Inaccessible), ], ) def test_federation_interface_directives( directive_param, directive_value, directive_class ): """Test federation directives on interfaces.""" kwargs = cast("dict[str, Any]", {"keys": ["id"], directive_param: directive_value}) @strawberry_django.federation.interface(models.Fruit, **kwargs) class FruitInterface: id: strawberry.auto type_def = get_object_definition(FruitInterface, strict=True) assert any(isinstance(d, directive_class) for d in type_def.directives or []) strawberry-graphql-django-0.82.1/tests/fields/000077500000000000000000000000001516173410200213125ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/fields/__init__.py000066400000000000000000000000001516173410200234110ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/fields/test_attributes.py000066400000000000000000000114321516173410200251120ustar00rootroot00000000000000import textwrap from typing import TYPE_CHECKING, cast import strawberry from django.db import models from django.test import override_settings from strawberry import BasePermission, auto, relay from strawberry.types import get_object_definition import strawberry_django from strawberry_django.settings import strawberry_django_settings if TYPE_CHECKING: from strawberry_django.fields.field import StrawberryDjangoField class FieldAttributeModel(models.Model): field = models.CharField(max_length=50) def test_default_django_name(): @strawberry_django.type(FieldAttributeModel) class Type: field: auto field2: auto = strawberry_django.field(field_name="field") assert [ (f.name, cast("StrawberryDjangoField", f).django_name) for f in get_object_definition(Type, strict=True).fields ] == [ ("field", "field"), ("field2", "field"), ] def test_field_permission_classes(): class TestPermission(BasePermission): def has_permission(self, source, info, **kwargs): return True @strawberry_django.type(FieldAttributeModel) class Type: field: auto = strawberry.field(permission_classes=[TestPermission]) @strawberry.field(permission_classes=[TestPermission]) def custom_resolved_field(self) -> str: return self.field assert sorted( [ (f.name, f.permission_classes) for f in get_object_definition(Type, strict=True).fields ], ) == sorted( [ ("field", [TestPermission]), ("custom_resolved_field", [TestPermission]), ], ) def test_auto_id(): @strawberry_django.filter_type(FieldAttributeModel) class MyTypeFilter: id: auto field: auto @strawberry_django.type(FieldAttributeModel) class MyType: id: auto other_id: auto = strawberry_django.field(field_name="id") field: auto @strawberry.type class Query: my_type: list[MyType] = strawberry_django.field(filters=MyTypeFilter) schema = strawberry.Schema(query=Query) expected = """\ type MyType { id: ID! otherId: ID! field: String! } input MyTypeFilter { id: ID field: String AND: MyTypeFilter OR: MyTypeFilter NOT: MyTypeFilter DISTINCT: Boolean } type Query { myType(filters: MyTypeFilter): [MyType!]! } """ assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() def test_auto_id_with_node(): @strawberry_django.filter_type(FieldAttributeModel) class MyTypeFilter: id: auto field: auto @strawberry_django.type(FieldAttributeModel) class MyType(relay.Node): other_id: auto = strawberry_django.field(field_name="id") field: auto @strawberry.type class Query: my_type: list[MyType] = strawberry_django.field(filters=MyTypeFilter) schema = strawberry.Schema(query=Query) expected = '''\ type MyType implements Node { """The Globally Unique ID of this object""" id: ID! otherId: ID! field: String! } input MyTypeFilter { id: ID field: String AND: MyTypeFilter OR: MyTypeFilter NOT: MyTypeFilter DISTINCT: Boolean } """An object with a Globally Unique ID""" interface Node { """The Globally Unique ID of this object""" id: ID! } type Query { myType(filters: MyTypeFilter): [MyType!]! } ''' assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() @override_settings( STRAWBERRY_DJANGO={ **strawberry_django_settings(), "MAP_AUTO_ID_AS_GLOBAL_ID": True, }, ) def test_auto_id_with_node_mapping_global_id(): @strawberry_django.filter_type(FieldAttributeModel) class MyTypeFilter: id: auto field: auto @strawberry_django.type(FieldAttributeModel) class MyType(relay.Node): other_id: auto = strawberry_django.field(field_name="id") field: auto @strawberry.type class Query: my_type: list[MyType] = strawberry_django.field(filters=MyTypeFilter) schema = strawberry.Schema(query=Query) expected = '''\ type MyType implements Node { """The Globally Unique ID of this object""" id: ID! otherId: ID! field: String! } input MyTypeFilter { id: ID field: String AND: MyTypeFilter OR: MyTypeFilter NOT: MyTypeFilter DISTINCT: Boolean } """An object with a Globally Unique ID""" interface Node { """The Globally Unique ID of this object""" id: ID! } type Query { myType(filters: MyTypeFilter): [MyType!]! } ''' assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() strawberry-graphql-django-0.82.1/tests/fields/test_get_result.py000066400000000000000000000052261516173410200251050ustar00rootroot00000000000000import pytest from django.db.models import QuerySet from strawberry import relay from strawberry.annotation import StrawberryAnnotation from strawberry.relay.types import ListConnection from strawberry_django.fields.field import StrawberryDjangoField from tests.types import FruitType @pytest.mark.django_db def test_resolve_returns_queryset_with_fetched_results(): field = StrawberryDjangoField(type_annotation=StrawberryAnnotation(list[FruitType])) result = field.get_result(None, None, [], {}) assert isinstance(result, QuerySet) assert result._result_cache is not None # type: ignore @pytest.mark.django_db async def test_resolve_returns_queryset_with_fetched_results_async(): field = StrawberryDjangoField(type_annotation=StrawberryAnnotation(list[FruitType])) result = await field.get_result(None, None, [], {}) assert isinstance(result, QuerySet) assert result._result_cache is not None # type: ignore @pytest.mark.django_db def test_resolve_returns_queryset_without_fetching_results_when_disabling_it(): field = StrawberryDjangoField(type_annotation=StrawberryAnnotation(list[FruitType])) field.disable_fetch_list_results = True result = field.get_result(None, None, [], {}) assert isinstance(result, QuerySet) assert result._result_cache is None # type: ignore @pytest.mark.django_db async def test_resolve_returns_queryset_without_fetching_results_when_disabling_it_async(): field = StrawberryDjangoField(type_annotation=StrawberryAnnotation(list[FruitType])) field.disable_fetch_list_results = True result = await field.get_result(None, None, [], {}) assert isinstance(result, QuerySet) assert result._result_cache is None # type: ignore @pytest.mark.django_db def test_resolve_returns_queryset_without_fetching_results_for_connections(): class FruitImplementingNode(relay.Node, FruitType): ... field = StrawberryDjangoField( type_annotation=StrawberryAnnotation(ListConnection[FruitImplementingNode]) ) field.disable_fetch_list_results = True result = field.get_result(None, None, [], {}) assert isinstance(result, QuerySet) assert result._result_cache is None # type: ignore @pytest.mark.django_db async def test_resolve_returns_queryset_without_fetching_results_for_connections_async(): class FruitImplementingNode(relay.Node, FruitType): ... field = StrawberryDjangoField( type_annotation=StrawberryAnnotation(ListConnection[FruitImplementingNode]) ) field.disable_fetch_list_results = True result = await field.get_result(None, None, [], {}) assert isinstance(result, QuerySet) assert result._result_cache is None # type: ignore strawberry-graphql-django-0.82.1/tests/fields/test_input.py000066400000000000000000000060311516173410200240620ustar00rootroot00000000000000from typing import cast import strawberry from django.db import models from strawberry import auto from strawberry.types import get_object_definition from strawberry.types.base import StrawberryOptional import strawberry_django class InputFieldsModel(models.Model): mandatory = models.IntegerField() default = models.IntegerField(default=1) blank = models.IntegerField(blank=True) null = models.IntegerField(null=True) def test_input_type(): @strawberry_django.input(InputFieldsModel) class InputType: id: auto mandatory: auto default: auto blank: auto null: auto assert [ (f.name, f.type) for f in get_object_definition(InputType, strict=True).fields ] == [ ("id", StrawberryOptional(cast("type", strawberry.ID))), ("mandatory", int), ("default", StrawberryOptional(int)), ("blank", StrawberryOptional(int)), ("null", StrawberryOptional(int)), ] def test_input_type_for_partial_update(): @strawberry_django.input(InputFieldsModel, partial=True) class InputType: id: auto mandatory: auto default: auto blank: auto null: auto assert [ (f.name, f.type) for f in get_object_definition(InputType, strict=True).fields ] == [ ("id", StrawberryOptional(cast("type", strawberry.ID))), ("mandatory", StrawberryOptional(int)), ("default", StrawberryOptional(int)), ("blank", StrawberryOptional(int)), ("null", StrawberryOptional(int)), ] def test_input_type_basic(): from tests import models @strawberry_django.input(models.User) class UserInput: name: auto assert [ (f.name, f.type) for f in get_object_definition(UserInput, strict=True).fields ] == [ ("name", str), ] def test_partial_input_type(): from tests import models @strawberry_django.input(models.User, partial=True) class UserPartialInput: name: auto assert [ (f.name, f.type) for f in get_object_definition(UserPartialInput, strict=True).fields ] == [ ("name", StrawberryOptional(str)), ] def test_partial_input_type_inheritance(): from tests import models @strawberry_django.input(models.User) class UserInput: name: auto @strawberry_django.input(models.User, partial=True) class UserPartialInput(UserInput): pass assert [ (f.name, f.type) for f in get_object_definition(UserPartialInput, strict=True).fields ] == [ ("name", StrawberryOptional(str)), ] def test_input_type_inheritance_from_type(): from tests import models @strawberry_django.type(models.User) class User: id: auto name: auto @strawberry_django.input(models.User) class UserInput(User): pass assert [ (f.name, f.type) for f in get_object_definition(UserInput, strict=True).fields ] == [ ("id", StrawberryOptional(cast("type", strawberry.ID))), ("name", str), ] strawberry-graphql-django-0.82.1/tests/fields/test_ref.py000066400000000000000000000011551516173410200235010ustar00rootroot00000000000000from django.db import models from strawberry import auto from strawberry.types import get_object_definition import strawberry_django def test_forward_reference(): global MyBytes class ForwardReferenceModel(models.Model): string = models.CharField(max_length=50) @strawberry_django.type(ForwardReferenceModel) class Type: bytes0: "MyBytes" string: auto class MyBytes(bytes): pass assert [ (f.name, f.type) for f in get_object_definition(Type, strict=True).fields ] == [ ("bytes0", MyBytes), ("string", str), ] del MyBytes strawberry-graphql-django-0.82.1/tests/fields/test_relations.py000066400000000000000000000065311516173410200247300ustar00rootroot00000000000000from typing import TYPE_CHECKING, Optional, cast import strawberry from django.db import models from strawberry import auto from strawberry.types import get_object_definition from strawberry.types.base import ( StrawberryList, StrawberryOptional, ) import strawberry_django if TYPE_CHECKING: from strawberry_django.fields.field import StrawberryDjangoField class ParentModel(models.Model): name = models.CharField(max_length=50) class OneToOneModel(models.Model): name = models.CharField(max_length=50) parent = models.OneToOneField( ParentModel, on_delete=models.SET_NULL, related_name="one_to_one", null=True, blank=True, ) class ChildModel(models.Model): name = models.CharField(max_length=50) parents = models.ManyToManyField(ParentModel, related_name="children") @strawberry_django.type(ParentModel) class Parent: id: auto name: auto children: list["Child"] one_to_one: Optional["OneToOne"] @strawberry_django.type(OneToOneModel) class OneToOne: id: auto name: auto parent: Optional["Parent"] @strawberry_django.type(ChildModel) class Child: id: auto name: auto parents: list[Parent] def test_relation(): assert [ (f.name, f.type, cast("StrawberryDjangoField", f).is_list) for f in get_object_definition(Parent, strict=True).fields ] == [ ("id", strawberry.ID, False), ("name", str, False), ("children", StrawberryList(Child), True), ("one_to_one", StrawberryOptional(OneToOne), False), ] def test_reversed_relation(): assert [ (f.name, f.type, cast("StrawberryDjangoField", f).is_list) for f in get_object_definition(Child, strict=True).fields ] == [ ("id", strawberry.ID, False), ("name", str, False), ("parents", StrawberryList(Parent), True), ] def test_relation_query(transactional_db): @strawberry.type class Query: parent: Parent = strawberry_django.field() one_to_one: OneToOne = strawberry_django.field() schema = strawberry.Schema(query=Query) query = """\ query Query ($pk: ID!) { parent (pk: $pk) { name oneToOne { name } children { id name } } } """ parent = ParentModel.objects.create(name="Parent") result = schema.execute_sync(query, {"pk": parent.pk}) assert result.errors is None assert result.data == { "parent": {"children": [], "name": "Parent", "oneToOne": None}, } OneToOneModel.objects.create(name="OneToOne", parent=parent) result = schema.execute_sync(query, {"pk": parent.pk}) assert result.errors is None assert result.data == { "parent": {"children": [], "name": "Parent", "oneToOne": {"name": "OneToOne"}}, } child1 = ChildModel.objects.create(name="Child1") child2 = ChildModel.objects.create(name="Child2") ChildModel.objects.create(name="Child3") child1.parents.add(parent) child2.parents.add(parent) result = schema.execute_sync(query, {"pk": parent.pk}) assert result.errors is None assert result.data == { "parent": { "children": [{"id": "1", "name": "Child1"}, {"id": "2", "name": "Child2"}], "name": "Parent", "oneToOne": {"name": "OneToOne"}, }, } strawberry-graphql-django-0.82.1/tests/fields/test_types.py000066400000000000000000000345001516173410200240710ustar00rootroot00000000000000import datetime import decimal import enum import uuid from typing import Any, cast import django import pytest import strawberry from django.conf import settings from django.contrib.postgres.fields import ArrayField from django.core.exceptions import FieldDoesNotExist from django.db import models from strawberry import auto from strawberry.scalars import JSON from strawberry.types import get_object_definition from strawberry.types.base import ( StrawberryContainer, StrawberryList, StrawberryOptional, ) from strawberry.types.enum import EnumValue, StrawberryEnumDefinition import strawberry_django from strawberry_django.fields.field import StrawberryDjangoField from strawberry_django.type import _process_type # noqa: PLC2701 if django.VERSION >= (5, 0): from django.db.models import GeneratedField # type: ignore else: GeneratedField = None class FieldTypesModel(models.Model): boolean = models.BooleanField() char = models.CharField(max_length=50) date = models.DateField() date_time = models.DateTimeField() decimal = models.DecimalField() email = models.EmailField() file = models.FileField() file_path = models.FilePathField() float = models.FloatField() generic_ip_address = models.GenericIPAddressField() integer = models.IntegerField() image = models.ImageField() positive_big_integer = models.PositiveBigIntegerField() positive_integer = models.PositiveIntegerField() positive_small_integer = models.PositiveSmallIntegerField() slug = models.SlugField() small_integer = models.SmallIntegerField() text = models.TextField() time = models.TimeField() url = models.URLField() uuid = models.UUIDField() json = models.JSONField() generated_decimal = ( GeneratedField( expression=models.F("decimal") * 2, db_persist=True, output_field=models.DecimalField(), ) if GeneratedField is not None else None ) generated_nullable_decimal = ( GeneratedField( expression=models.F("decimal") * 2, db_persist=True, output_field=models.DecimalField(null=True, blank=True), ) if GeneratedField is not None else None ) foreign_key = models.ForeignKey( "FieldTypesModel", blank=True, related_name="related_foreign_key", on_delete=models.CASCADE, ) one_to_one = models.OneToOneField( "FieldTypesModel", blank=True, related_name="related_one_to_one", on_delete=models.CASCADE, ) many_to_many = models.ManyToManyField( "FieldTypesModel", related_name="related_many_to_many", ) def test_field_types(): @strawberry_django.type(FieldTypesModel) class Type: id: auto boolean: auto char: auto date: auto date_time: auto decimal: auto email: auto file: auto file_path: auto float: auto generic_ip_address: auto integer: auto image: auto positive_big_integer: auto positive_integer: auto positive_small_integer: auto slug: auto small_integer: auto text: auto time: auto url: auto uuid: auto json: auto expected_types: list[tuple[str, Any]] = [ ("id", strawberry.ID), ("boolean", bool), ("char", str), ("date", datetime.date), ("date_time", datetime.datetime), ("decimal", decimal.Decimal), ("email", str), ("file", strawberry_django.DjangoFileType), ("file_path", str), ("float", float), ("generic_ip_address", str), ("integer", int), ("image", strawberry_django.DjangoImageType), ("positive_big_integer", int), ("positive_integer", int), ("positive_small_integer", int), ("slug", str), ("small_integer", int), ("text", str), ("time", datetime.time), ("url", str), ("uuid", uuid.UUID), ("json", JSON), ] if django.VERSION >= (5, 0): Type.__annotations__["generated_decimal"] = auto expected_types.append(("generated_decimal", decimal.Decimal)) Type.__annotations__["generated_nullable_decimal"] = auto expected_types.append(("generated_nullable_decimal", decimal.Decimal | None)) type_to_test = _process_type(Type, model=FieldTypesModel) object_definition = get_object_definition(type_to_test, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == expected_types def test_field_types_for_array_fields(): class ModelWithArrays(models.Model): str_array = ArrayField(models.CharField(max_length=50)) int_array = ArrayField(models.IntegerField()) @strawberry_django.type(ModelWithArrays) class Type: str_array: auto int_array: auto type_to_test = _process_type(Type, model=ModelWithArrays) object_definition = get_object_definition(type_to_test, strict=True) str_array_field = object_definition.get_field("str_array") assert str_array_field assert isinstance(str_array_field.type, StrawberryList) assert str_array_field.type.of_type is str int_array_field = object_definition.get_field("int_array") assert int_array_field assert isinstance(int_array_field.type, StrawberryList) assert int_array_field.type.of_type is int def test_field_types_for_matrix_fields(): class ModelWithMatrixes(models.Model): str_matrix = ArrayField(ArrayField(models.CharField(max_length=50))) int_matrix = ArrayField(ArrayField(models.IntegerField())) @strawberry_django.type(ModelWithMatrixes) class Type: str_matrix: auto int_matrix: auto type_to_test = _process_type(Type, model=ModelWithMatrixes) object_definition = get_object_definition(type_to_test, strict=True) str_matrix_field = object_definition.get_field("str_matrix") assert str_matrix_field assert isinstance(str_matrix_field.type, StrawberryList) assert isinstance(str_matrix_field.type.of_type, StrawberryList) assert str_matrix_field.type.of_type.of_type is str int_matrix_field = object_definition.get_field("int_matrix") assert int_matrix_field assert isinstance(int_matrix_field.type, StrawberryList) assert isinstance(int_matrix_field.type.of_type, StrawberryList) assert int_matrix_field.type.of_type.of_type is int def test_subset_of_fields(): @strawberry_django.type(FieldTypesModel) class Type: id: auto integer: auto text: auto object_definition = get_object_definition(Type, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("id", strawberry.ID), ("integer", int), ("text", str), ] def test_type_extension(): @strawberry_django.type(FieldTypesModel) class Type: char: auto text: bytes # override type @strawberry.field @staticmethod def my_field() -> int: return 0 object_definition = get_object_definition(Type, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("char", str), ("text", bytes), ("my_field", int), ] def test_field_does_not_exist(): with pytest.raises(FieldDoesNotExist): @strawberry_django.type(FieldTypesModel) class Type: unknown_field: auto def test_override_field_type(): @strawberry.enum class EnumType(enum.Enum): a = "A" @strawberry_django.type(FieldTypesModel) class Type: char: EnumType object_definition = get_object_definition(Type, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ( "char", StrawberryEnumDefinition( wrapped_cls=EnumType, name="EnumType", values=[EnumValue(name="a", value="A")], description=None, ), ), ] def test_override_field_default_value(): @strawberry_django.type(FieldTypesModel) class Type: char: str = "my value" object_definition = get_object_definition(Type, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("char", str), ] assert Type().char == "my value" def test_related_fields(): @strawberry_django.type(FieldTypesModel) class Type: foreign_key: auto one_to_one: auto many_to_many: auto related_foreign_key: auto related_one_to_one: auto related_many_to_many: auto object_definition = get_object_definition(Type, strict=True) assert [ (f.name, f.type, cast("StrawberryDjangoField", f).is_list) for f in object_definition.fields ] == [ ("foreign_key", strawberry_django.DjangoModelType, False), ("one_to_one", strawberry_django.DjangoModelType, False), ( "many_to_many", StrawberryList(strawberry_django.DjangoModelType), True, ), ( "related_foreign_key", StrawberryList(strawberry_django.DjangoModelType), True, ), ( "related_one_to_one", StrawberryOptional(strawberry_django.DjangoModelType), False, ), ( "related_many_to_many", StrawberryList(strawberry_django.DjangoModelType), True, ), ] def test_related_input_fields(): @strawberry_django.input(FieldTypesModel) class Input: foreign_key: auto one_to_one: auto many_to_many: auto related_foreign_key: auto related_one_to_one: auto related_many_to_many: auto expected_fields: dict[str, tuple[type | StrawberryContainer, bool]] = { "foreign_key": ( strawberry_django.OneToManyInput, True, ), "one_to_one": ( strawberry_django.OneToOneInput, True, ), "many_to_many": ( strawberry_django.ManyToManyInput, True, ), "related_foreign_key": ( strawberry_django.ManyToOneInput, True, ), "related_one_to_one": ( strawberry_django.OneToOneInput, True, ), "related_many_to_many": ( strawberry_django.ManyToManyInput, True, ), } object_definition = get_object_definition(Input, strict=True) assert len(object_definition.fields) == len(expected_fields) for f in object_definition.fields: expected_type, expected_is_optional = expected_fields[f.name] assert isinstance(f, StrawberryDjangoField) assert f.is_optional == expected_is_optional assert isinstance(f.type, StrawberryOptional) assert f.type.of_type == expected_type @pytest.mark.skipif( not settings.GEOS_IMPORTED, reason="Test requires GEOS to be imported and properly configured", ) def test_geos_fields(): from strawberry_django.fields import types from tests.models import GeosFieldsModel @strawberry_django.type(GeosFieldsModel) class GeoFieldType: point: auto line_string: auto polygon: auto multi_point: auto multi_line_string: auto multi_polygon: auto geometry: auto object_definition = get_object_definition(GeoFieldType, strict=True) assert [ (f.name, cast("StrawberryOptional", f.type).of_type) for f in object_definition.fields ] == [ ("point", types.Point), ("line_string", types.LineString), ("polygon", types.Polygon), ("multi_point", types.MultiPoint), ("multi_line_string", types.MultiLineString), ("multi_polygon", types.MultiPolygon), ("geometry", types.Geometry), ] def test_inherit_type(): global Type @strawberry_django.type(FieldTypesModel) class Base: char: auto one_to_one: "Type" @strawberry_django.type(FieldTypesModel) class Type(Base): # type: ignore many_to_many: list["Type"] object_definition = get_object_definition(Type, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("char", str), ("one_to_one", Type), ("many_to_many", StrawberryList(Type)), ] def test_inherit_input(): global Type @strawberry_django.type(FieldTypesModel) class Type: # type: ignore char: auto one_to_one: "Type" many_to_many: list["Type"] @strawberry_django.input(FieldTypesModel) class Input(Type): id: auto my_data: str object_definition = get_object_definition(Input, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("char", str), ("one_to_one", StrawberryOptional(strawberry_django.OneToOneInput)), ( "many_to_many", StrawberryOptional(strawberry_django.ManyToManyInput), ), ("id", StrawberryOptional(cast("type", strawberry.ID))), ("my_data", str), ] def test_inherit_partial_input(): global Type @strawberry_django.type(FieldTypesModel) class Type: char: auto one_to_one: "Type" @strawberry_django.input(FieldTypesModel) class Input(Type): pass @strawberry_django.input(FieldTypesModel, partial=True) class PartialInput(Input): pass object_definition = get_object_definition(PartialInput, strict=True) assert [ (f.name, f.type, cast("StrawberryDjangoField", f).is_optional) for f in object_definition.fields ] == [ ("char", StrawberryOptional(str), True), ( "one_to_one", StrawberryOptional(strawberry_django.OneToOneInput), True, ), ] def test_notimplemented(): """Test that an unrecognized field raises `NotImplementedError`.""" class UnknownField(models.Field): """A field unknown to Strawberry.""" class UnknownModel(models.Model): """A model with UnknownField.""" field = UnknownField() @strawberry_django.type(UnknownModel) class UnknownType: field: auto @strawberry.type class Query: unknown_type: UnknownType with pytest.raises(TypeError, match=r"UnknownModel\.field"): strawberry.Schema(query=Query) strawberry-graphql-django-0.82.1/tests/filters/000077500000000000000000000000001516173410200215145ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/filters/__init__.py000066400000000000000000000000001516173410200236130ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/filters/test_filters.py000066400000000000000000000436171516173410200246100ustar00rootroot00000000000000import textwrap from enum import Enum from typing import Annotated, Generic, TypeVar, cast import pytest import strawberry from django.test import override_settings from strawberry import auto from strawberry.annotation import StrawberryAnnotation from strawberry.tools import create_type from strawberry.types import ExecutionResult from strawberry.types.base import get_object_definition from strawberry.types.field import StrawberryField import strawberry_django from tests import models, utils with override_settings(STRAWBERRY_DJANGO={"USE_DEPRECATED_FILTERS": True}): @strawberry_django.filter_type(models.NameDescriptionMixin) class NameDescriptionFilter: name: auto description: auto @strawberry_django.filter_type(models.Vegetable, lookups=True) class VegetableFilter(NameDescriptionFilter): id: auto world_production: auto @strawberry_django.filters.filter_type(models.Color, lookups=True) class ColorFilter: id: auto name: auto @strawberry_django.filters.filter_type(models.Fruit, lookups=True) class FruitFilter: id: auto name: auto color: ColorFilter | None @strawberry.enum class FruitEnum(Enum): strawberry = "strawberry" banana = "banana" @strawberry_django.filters.filter_type(models.Fruit) class EnumFilter: name: FruitEnum | None = strawberry.UNSET _T = TypeVar("_T") @strawberry.input class FilterInLookup(Generic[_T]): exact: _T | None = strawberry.UNSET in_list: list[_T] | None = strawberry.UNSET @strawberry_django.filters.filter_type(models.Fruit) class EnumLookupFilter: name: FilterInLookup[FruitEnum] | None = strawberry.UNSET @strawberry.input class NonFilter: name: FruitEnum def filter(self, queryset): raise NotImplementedError @strawberry_django.filters.filter_type(models.Fruit) class FieldFilter: search: str def filter_search(self, queryset): return queryset.filter(name__icontains=self.search) @strawberry_django.filters.filter_type(models.Fruit) class TypeFilter: name: auto def filter(self, queryset): if not self.name: return queryset return queryset.filter(name__icontains=self.name) @strawberry_django.type(models.Vegetable, filters=VegetableFilter) class Vegetable: id: auto name: auto description: auto world_production: auto @strawberry_django.type(models.Fruit, filters=FruitFilter) class Fruit: id: auto name: auto @strawberry.type class Query: fruits: list[Fruit] = strawberry_django.field() field_filter: list[Fruit] = strawberry_django.field(filters=FieldFilter) type_filter: list[Fruit] = strawberry_django.field(filters=TypeFilter) type_lazy_filter: list[Fruit] = strawberry_django.field( filters=Annotated[ "TypeFilter", strawberry.lazy("tests.filters.test_filters") ] ) enum_filter: list[Fruit] = strawberry_django.field(filters=EnumFilter) enum_lookup_filter: list[Fruit] = strawberry_django.field( filters=EnumLookupFilter ) _ = strawberry.Schema(query=Query) @pytest.fixture(autouse=True) def _autouse_old_filters(settings): settings.STRAWBERRY_DJANGO = {"USE_DEPRECATED_FILTERS": True} @pytest.fixture def query(): return utils.generate_query(Query) def test_field_filter_definition(): from strawberry_django.fields.field import StrawberryDjangoField field = StrawberryDjangoField(type_annotation=StrawberryAnnotation(Fruit)) assert field.get_filters() == FruitFilter field = StrawberryDjangoField( type_annotation=StrawberryAnnotation(Fruit), filters=None, ) assert field.get_filters() is None def test_without_filtering(query, fruits): result = query("{ fruits { id name } }") assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, {"id": "2", "name": "raspberry"}, {"id": "3", "name": "banana"}, ] def test_exact(query, fruits): result = query('{ fruits(filters: { name: { exact: "strawberry" } }) { id name } }') assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, ] def test_lt_gt(query, fruits): result = query("{ fruits(filters: { id: { gt: 1, lt: 3 } }) { id name } }") assert not result.errors assert result.data["fruits"] == [ {"id": "2", "name": "raspberry"}, ] def test_in_list(query, fruits): result = query("{ fruits(filters: { id: { inList: [ 1, 3 ] } }) { id name } }") assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, {"id": "3", "name": "banana"}, ] def test_not(query, fruits): result = query("""{ fruits( filters: { NOT: { name: { endsWith: "berry" } } } ) { id name } }""") assert not result.errors assert result.data["fruits"] == [ {"id": "3", "name": "banana"}, ] def test_and(query, fruits): result = query( """{ fruits(filters: { name: { endsWith: "berry" }, AND: { id: { exact: 2 } } }) { id name } }""", ) assert not result.errors assert result.data["fruits"] == [ {"id": "2", "name": "raspberry"}, ] def test_or(query, fruits): result = query( """{ fruits(filters: { id: { exact: 1 }, OR: { id: { exact: 3 } } }) { id name } }""", ) assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, {"id": "3", "name": "banana"}, ] def test_relationship(query, fruits): color = models.Color.objects.create(name="red") color.fruits.set([fruits[0], fruits[1]]) result = query( '{ fruits(filters: { color: { name: { iExact: "RED" } } }) { id name } }', ) assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, {"id": "2", "name": "raspberry"}, ] def test_field_filter_method(query, fruits): result = query('{ fruits: fieldFilter(filters: { search: "berry" }) { id name } }') assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, {"id": "2", "name": "raspberry"}, ] def test_type_filter_method(query, fruits): result = query('{ fruits: typeFilter(filters: { name: "anana" }) { id name } }') assert not result.errors assert result.data["fruits"] == [ {"id": "3", "name": "banana"}, ] def test_type_lazy_filter_method(query, fruits): result = query('{ fruits: typeLazyFilter(filters: { name: "anana" }) { id name } }') assert not result.errors assert result.data["fruits"] == [ {"id": "3", "name": "banana"}, ] def test_resolver_filter(fruits): @strawberry.type class Query: @strawberry.field def fruits(self, filters: FruitFilter) -> list[Fruit]: queryset = models.Fruit.objects.all() return cast( "list[Fruit]", strawberry_django.filters.apply(filters, queryset) ) query = utils.generate_query(Query) result = query('{ fruits(filters: { name: { exact: "strawberry" } }) { id name } }') assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, ] def test_empty_resolver_filter(): @strawberry.type class Query: @strawberry.field def fruits(self, filters: FruitFilter) -> list[Fruit]: queryset = models.Fruit.objects.none() return cast( "list[Fruit]", strawberry_django.filters.apply(filters, queryset) ) query = utils.generate_query(Query) result = query('{ fruits(filters: { name: { exact: "strawberry" } }) { id name } }') assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert result.data["fruits"] == [] @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_async_resolver_filter(fruits): @strawberry.type class Query: @strawberry.field async def fruits(self, filters: FruitFilter) -> list[Fruit]: queryset = models.Fruit.objects.all() queryset = strawberry_django.filters.apply(filters, queryset) # cast fixes funny typing issue between list and List return cast("list[Fruit]", [fruit async for fruit in queryset]) query = utils.generate_query(Query) result = await query( # type: ignore '{ fruits(filters: { name: { exact: "strawberry" } }) { id name } }' ) assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert len(result.data["fruits"]) == 1 assert result.data["fruits"][0]["name"] == "strawberry" def test_resolver_filter_with_inheritance(vegetables): @strawberry.type class Query: @strawberry.field def vegetables(self, filters: VegetableFilter) -> list[Vegetable]: queryset = models.Vegetable.objects.all() return cast( "list[Vegetable]", strawberry_django.filters.apply(filters, queryset) ) query = utils.generate_query(Query) result = query(""" { vegetables( filters: { worldProduction: { gt: 100e6 } OR: { name: { exact: "cucumber" } } } ) { id name } } """) assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert result.data["vegetables"] == [ {"id": "2", "name": "cucumber"}, {"id": "3", "name": "onion"}, ] def test_resolver_filter_with_info(fruits): from strawberry.types.info import Info @strawberry_django.filters.filter_type(models.Fruit, lookups=True) class FruitFilterWithInfo: id: auto name: auto custom_field: bool def filter_custom_field(self, queryset, info: Info): # Test here is to prove that info can be passed properly assert isinstance(info, Info) return queryset.filter(name="banana") @strawberry.type class Query: @strawberry.field def fruits(self, filters: FruitFilterWithInfo, info: Info) -> list[Fruit]: queryset = models.Fruit.objects.all() return cast( "list[Fruit]", strawberry_django.filters.apply(filters, queryset, info=info), ) query = utils.generate_query(Query) result = query("{ fruits(filters: { customField: true }) { id name } }") assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert result.data["fruits"] == [ {"id": "3", "name": "banana"}, ] def test_resolver_filter_override_with_info(fruits): from strawberry.types.info import Info @strawberry_django.filters.filter_type(models.Fruit, lookups=True) class FruitFilterWithInfo: custom_field: bool def filter(self, queryset, info: Info): # Test here is to prove that info can be passed properly assert isinstance(info, Info) return queryset.filter(name="banana") @strawberry.type class Query: @strawberry.field def fruits(self, filters: FruitFilterWithInfo, info: Info) -> list[Fruit]: queryset = models.Fruit.objects.all() return cast( "list[Fruit]", strawberry_django.filters.apply(filters, queryset, info=info), ) query = utils.generate_query(Query) result = query("{ fruits(filters: { customField: true }) { id name } }") assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert result.data["fruits"] == [ {"id": "3", "name": "banana"}, ] def test_resolver_nonfilter(fruits): @strawberry.type class Query: @strawberry.field def fruits(self, filters: NonFilter) -> list[Fruit]: queryset = models.Fruit.objects.all() return cast( "list[Fruit]", strawberry_django.filters.apply(filters, queryset) ) query = utils.generate_query(Query) result = query("{ fruits(filters: { name: strawberry } ) { id name } }") assert isinstance(result, ExecutionResult) assert not result.errors def test_enum(query, fruits): result = query("{ fruits: enumFilter(filters: { name: strawberry }) { id name } }") assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, ] def test_enum_lookup_exact(query, fruits): result = query( """{ fruits: enumLookupFilter(filters: { name: { exact: strawberry } }) { id name } }""", ) assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, ] def test_enum_lookup_in(query, fruits): result = query( """{ fruits: enumLookupFilter(filters: { name: { inList: [strawberry] } }) { id name } }""", ) assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, ] result = query( """{ fruits: enumLookupFilter(filters: { name: { inList: [strawberry, banana] } }) { id name } }""", ) assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, {"id": "3", "name": "banana"}, ] @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("use_pk", [True, False]) def test_adds_id_filter(use_pk: bool): with override_settings( STRAWBERRY_DJANGO={"DEFAULT_PK_FIELD_NAME": "pk" if use_pk else "id"}, ): field_name = "pk" if use_pk else "id" @strawberry_django.type(models.User) class UserType: name: strawberry.auto @strawberry.type class Query: user: UserType = strawberry_django.field() schema = strawberry.Schema(query=Query) assert ( textwrap.dedent(str(schema)) == textwrap.dedent( f"""\ type Query {{ user({field_name}: ID!): UserType! }} type UserType {{ name: String! }} """, ).strip() ) user = models.User.objects.create(name="Some User") res = schema.execute_sync( f"""\ query GetUser ($id: ID!) {{ user({field_name}: $id) {{ name }} }} """, variable_values={"id": user.pk}, ) assert res.errors is None assert res.data == { "user": { "name": "Some User", }, } @pytest.mark.django_db(transaction=True) def test_pk_inserted_for_root_field_only(): @strawberry_django.filters.filter_type(models.Group) class GroupFilter: name: str @strawberry_django.type(models.Group, filters=GroupFilter) class GroupType: name: strawberry.auto @strawberry_django.type(models.User) class UserType: name: strawberry.auto group: GroupType | None get_group: GroupType group_prop: GroupType @strawberry.type class Query: user: UserType = strawberry_django.field() schema = strawberry.Schema(query=Query) assert ( textwrap.dedent(str(schema)) == textwrap.dedent( """\ type GroupType { name: String! } type Query { user(pk: ID!): UserType! } type UserType { name: String! group: GroupType getGroup: GroupType! groupProp: GroupType! } """, ).strip() ) group = models.Group.objects.create(name="Some Group") user = models.User.objects.create(name="Some User", group=group) res = schema.execute_sync( """\ query GetUser ($pk: ID!) { user(pk: $pk) { name group { name } getGroup { name } groupProp { name } } } """, variable_values={"pk": user.pk}, ) assert res.errors is None assert res.data == { "user": { "name": "Some User", "group": {"name": "Some Group"}, "getGroup": {"name": "Some Group"}, "groupProp": {"name": "Some Group"}, }, } @pytest.mark.parametrize("field_name", ["pk", "id"]) def test_django_model_filter_input_with_custom_pk_field_name(field_name: str, mocker): """Test that DjangoModelFilterInput works with both 'pk' and 'id' field names. Regression test for https://github.com/strawberry-graphql/strawberry-django/issues/770 """ mocker.patch( "strawberry_django.filters.strawberry_django_settings", return_value={"DEFAULT_PK_FIELD_NAME": field_name}, ) id_field = StrawberryField( python_name=field_name, graphql_name=field_name, type_annotation=StrawberryAnnotation(strawberry.ID), ) filter_input_type = create_type( "DjangoModelFilterInput", [id_field], is_input=True, ) defn = get_object_definition(filter_input_type, strict=True) assert len(defn.fields) == 1 field = defn.fields[0] assert field.python_name == field_name assert field.graphql_name == field_name kwargs = {field_name: "123"} obj = filter_input_type(**kwargs) assert getattr(obj, field_name) == "123" strawberry-graphql-django-0.82.1/tests/filters/test_filters_v2.py000066400000000000000000000610151516173410200252070ustar00rootroot00000000000000# ruff: noqa: B904, BLE001, F811, PT012, A001 import uuid from enum import Enum from typing import Annotated, Any, cast import pytest import strawberry from django.db.models import Case, Count, Q, QuerySet, Value, When from strawberry import Info, Some, auto from strawberry.exceptions import MissingArgumentsAnnotationsError from strawberry.relay import GlobalID from strawberry.types import ExecutionResult, get_object_definition from strawberry.types.base import WithStrawberryObjectDefinition, get_object_definition from typing_extensions import Self import strawberry_django from strawberry_django.exceptions import ( ForbiddenFieldArgumentError, MissingFieldArgumentError, ) from strawberry_django.fields import filter_types from strawberry_django.fields.field import StrawberryDjangoField from strawberry_django.fields.filter_order import ( FilterOrderField, FilterOrderFieldResolver, filter_field, ) from strawberry_django.filters import process_filters, resolve_value from tests import models, utils from tests.types import Fruit, FruitType, Vegetable @strawberry.enum class Version(Enum): ONE = "first" TWO = "second" THREE = "third" @strawberry_django.filter_type(models.Vegetable, lookups=True) class VegetableFilter: id: auto name: auto AND: list[Self] | None = strawberry.UNSET OR: list[Self] | None = strawberry.UNSET NOT: list[Self] | None = strawberry.UNSET @strawberry_django.filter_type(models.Color, lookups=True) class ColorFilter: id: auto name: auto @strawberry_django.filter_field def name_simple(self, prefix: str, value: str): return Q(**{f"{prefix}name": value}) @strawberry_django.filter_type(models.FruitType, lookups=True) class FruitTypeFilter: name: auto fruits: ( Annotated["FruitFilter", strawberry.lazy("tests.filters.test_filters_v2")] | None ) @strawberry_django.filter_type(models.Fruit, lookups=True) class FruitFilter: color_id: auto name: auto sweetness: auto types: FruitTypeFilter | None color: ColorFilter | None = filter_field(filter_none=True) @strawberry_django.filter_field def types_number( self, info: Info, queryset: QuerySet, prefix, value: filter_types.ComparisonFilterLookup[int], ): return process_filters( cast("WithStrawberryObjectDefinition", value), queryset.annotate( count=Count(f"{prefix}types__id"), count_nulls=Case( When(count=0, then=Value(None)), default="count", ), ), info, "count_nulls__", ) @strawberry_django.filter_field def double( self, queryset: QuerySet, prefix, value: bool, ): return queryset.union(queryset, all=True), Q() @strawberry_django.filter_field def filter(self, info: Info, queryset: QuerySet, prefix): return process_filters( cast("WithStrawberryObjectDefinition", self), queryset.filter(~Q(**{f"{prefix}name": "DARK_BERRY"})), info, prefix, skip_object_filter_method=True, ) @strawberry_django.filter_type(models.UUIDModel, lookups=True) class UUIDModelFilter: id: auto @strawberry_django.type(models.UUIDModel, filters=UUIDModelFilter) class UUIDModelType: id: auto text: auto @strawberry.type class Query: types: list[FruitType] = strawberry_django.field(filters=FruitTypeFilter) fruits: list[Fruit] = strawberry_django.field(filters=FruitFilter) vegetables: list[Vegetable] = strawberry_django.field(filters=VegetableFilter) items: list[UUIDModelType] = strawberry_django.field() @pytest.fixture def query(): return utils.generate_query(Query) @pytest.mark.parametrize( ("value", "resolved"), [ (None, None), (2, 2), ("something", "something"), (GlobalID("", "24"), "24"), (Version.ONE, Version.ONE.value), ( [1, "str", GlobalID("", "24"), Version.THREE], [1, "str", "24", Version.THREE.value], ), # Some (inner type of Maybe) tests (Some("test_string"), "test_string"), (Some(None), None), (Some(Version.TWO), Version.TWO.value), (Some(GlobalID("FruitNode", "42")), "42"), (Some(Some("nested")), "nested"), (Some(Some(None)), None), ( [Some(1), Some("test"), Some(None), Some(Version.ONE)], [1, "test", None, Version.ONE.value], ), ([Some(Some("foo")), Some(None)], ["foo", None]), ], ) def test_resolve_value(value, resolved): assert resolve_value(value) == resolved def test_filter_field_missing_prefix(): with pytest.raises( MissingFieldArgumentError, match=r".*\"prefix\".*\"field_method\".*" ): @strawberry_django.filter_field def field_method(): pass def test_filter_field_missing_value(): with pytest.raises( MissingFieldArgumentError, match=r".*\"value\".*\"field_method\".*" ): @strawberry_django.filter_field def field_method(prefix): pass def test_filter_field_missing_value_annotation(): with pytest.raises( MissingArgumentsAnnotationsError, match=r"Missing annotation.*\"value\".*\"field_method\".*", ): @strawberry_django.filter_field def field_method(prefix, value): pass def test_filter_field(): try: @strawberry_django.filter_field def field_method(self, root, info: Info, prefix, value: str, queryset): pass except Exception as exc: raise pytest.fail(f"DID RAISE {exc}") def test_filter_field_sequence(): with pytest.raises( ForbiddenFieldArgumentError, match=r".*\"sequence\".*\"field_method\".*", ): @strawberry_django.filter_field def field_method(prefix, value: auto, sequence, queryset): pass def test_filter_field_forbidden_param_annotation(): with pytest.raises( MissingArgumentsAnnotationsError, match=r".*\"forbidden_param\".*\"field_method\".*", ): @strawberry_django.filter_field def field_method(prefix, value: auto, queryset, forbidden_param): pass def test_filter_field_forbidden_param(): with pytest.raises( ForbiddenFieldArgumentError, match=r".*\"forbidden_param\".*\"field_method\".*", ): @strawberry_django.filter_field def field_method(prefix, value: auto, queryset, forbidden_param: str): pass def test_filter_field_missing_queryset(): with pytest.raises( MissingFieldArgumentError, match=r".*\"queryset\".*\"filter\".*" ): @strawberry_django.filter_field def filter(prefix): pass def test_filter_field_value_forbidden_on_object(): with pytest.raises(ForbiddenFieldArgumentError, match=r".*\"value\".*\"filter\".*"): @strawberry_django.filter_field def field_method(prefix, queryset, value: auto): pass @strawberry_django.filter_field def filter(prefix, queryset, value: auto): pass def test_filter_field_on_object(): try: @strawberry_django.filter_field def filter(self, root, info: Info, prefix, queryset): pass except Exception as exc: raise pytest.fail(f"DID RAISE {exc}") def test_filter_field_method(): @strawberry_django.filter_type(models.Fruit) class Filter: @strawberry_django.order_field def custom_filter(self, root, info: Info, prefix, value: auto, queryset): assert self == filter_, "Unexpected self passed" assert root == filter_, "Unexpected root passed" assert info == fake_info, "Unexpected info passed" assert prefix == "ROOT", "Unexpected prefix passed" assert value == "SOMETHING", "Unexpected value passed" assert queryset == qs, "Unexpected queryset passed" return Q(name=1) filter_: Any = Filter(custom_filter="SOMETHING") # type: ignore fake_info: Any = object() qs: Any = object() with pytest.warns(UserWarning, match="does not end with '__'"): q_object = process_filters(filter_, qs, fake_info, prefix="ROOT")[1] assert q_object, "Filter was not called" def test_filter_object_method(): @strawberry_django.filters.filter_type(models.Fruit) class Filter: @strawberry_django.filter_field def field_filter(self, value: str, prefix): raise AssertionError("Never called due to object filter override") @strawberry_django.filter_field def filter(self, root, info: Info, prefix, queryset): assert self == filter_, "Unexpected self passed" assert root == filter_, "Unexpected root passed" assert info == fake_info, "Unexpected info passed" assert prefix == "ROOT", "Unexpected prefix passed" assert queryset == qs, "Unexpected queryset passed" return queryset, Q(name=1) filter_: Any = Filter() fake_info: Any = object() qs: Any = object() with pytest.warns(UserWarning, match="does not end with '__'"): q_object = process_filters(filter_, qs, fake_info, prefix="ROOT")[1] assert q_object, "Filter was not called" def test_filter_value_resolution(): @strawberry_django.filters.filter_type(models.Fruit) class Filter: id: strawberry_django.ComparisonFilterLookup[GlobalID] | None gid = GlobalID("FruitNode", "125") filter_: Any = Filter( id=strawberry_django.ComparisonFilterLookup( exact=gid, range=strawberry_django.RangeLookup(start=gid, end=gid) ) ) object_: Any = object() q = process_filters(filter_, object_, object_)[1] assert q == Q(id__exact="125", id__range=["125", "125"]) def test_filter_method_value_resolution(): @strawberry_django.filters.filter_type(models.Fruit) class Filter: @strawberry_django.filter_field(resolve_value=True) def field_filter_resolved(self, value: GlobalID, prefix): assert isinstance(value, str) return Q() @strawberry_django.filter_field def field_filter_skip_resolved(self, value: GlobalID, prefix): assert isinstance(value, GlobalID) return Q() gid = GlobalID("FruitNode", "125") filter_: Any = Filter(field_filter_resolved=gid, field_filter_skip_resolved=gid) # type: ignore object_: Any = object() process_filters(filter_, object_, object_) def test_filter_type(): @strawberry_django.filter_type(models.Fruit, lookups=True) class FruitOrder: id: auto name: auto sweetness: auto @strawberry_django.filter_field def custom_filter(self, value: str, prefix: str): pass @strawberry_django.filter_field def custom_filter2( self, value: filter_types.BaseFilterLookup[str], prefix: str ): pass assert [ ( f.name, f.__class__, f.type.of_type.__name__, # type: ignore f.base_resolver.__class__ if f.base_resolver else None, ) for f in get_object_definition(FruitOrder, strict=True).fields if f.name not in {"NOT", "AND", "OR", "DISTINCT"} ] == [ ("id", StrawberryDjangoField, "BaseFilterLookup", None), ("name", StrawberryDjangoField, "StrFilterLookup", None), ("sweetness", StrawberryDjangoField, "ComparisonFilterLookup", None), ( "custom_filter", FilterOrderField, "str", FilterOrderFieldResolver, ), ( "custom_filter2", FilterOrderField, "BaseFilterLookup", FilterOrderFieldResolver, ), ] def test_filter_methods(query, db, fruits): t1 = models.FruitType.objects.create(name="Type1") t2 = models.FruitType.objects.create(name="Type2") f1, f2, f3 = models.Fruit.objects.all() _ = models.Fruit.objects.create(name="DARK_BERRY") f2.types.add(t1) f3.types.add(t1, t2) result = query(""" { fruits(filters: { typesNumber: { gt: 1 } NOT: { color: { nameSimple: "sample" } } OR: { typesNumber: { isNull: true } } }) { id } } """) assert not result.errors assert result.data["fruits"] == [ {"id": str(f1.id)}, {"id": str(f3.id)}, ] def test_filter_distinct(query, db, fruits): t1 = models.FruitType.objects.create(name="type_1") t2 = models.FruitType.objects.create(name="type_2") f1 = models.Fruit.objects.all()[0] f1.types.add(t1, t2) result = query(""" { fruits( filters: {types: { name: { iContains: "type" } } } ) { id name } } """) assert not result.errors assert len(result.data["fruits"]) == 2 result = query(""" { fruits( filters: { DISTINCT: true, types: { name: { iContains: "type" } } } ) { id name } } """) assert not result.errors assert len(result.data["fruits"]) == 1 def test_filter_and_or_not(query, db): v1 = models.Vegetable.objects.create( name="v1", description="d1", world_production=100 ) v2 = models.Vegetable.objects.create( name="v2", description="d2", world_production=200 ) v3 = models.Vegetable.objects.create( name="v3", description="d3", world_production=300 ) # Test impossible AND result = query(""" { vegetables(filters: { AND: [{ name: { exact: "v1" } }, { name: { exact: "v2" } }] }) { id } } """) assert not result.errors assert len(result.data["vegetables"]) == 0 # Test AND with contains result = query(""" { vegetables(filters: { AND: [{ name: { contains: "v" } }, { name: { contains: "2" } }] }) { id } } """) assert not result.errors assert len(result.data["vegetables"]) == 1 assert result.data["vegetables"][0]["id"] == str(v2.pk) # Test OR result = query(""" { vegetables(filters: { OR: [{ name: { exact: "v1" } }, { name: { exact: "v3" } }] }) { id } } """) assert not result.errors assert len(result.data["vegetables"]) == 2 assert { result.data["vegetables"][0]["id"], result.data["vegetables"][1]["id"], } == {str(v1.pk), str(v3.pk)} # Test NOT result = query(""" { vegetables(filters: { NOT: [{ name: { exact: "v1" } }, { name: { exact: "v2" } }] }) { id } } """) assert not result.errors assert len(result.data["vegetables"]) == 1 assert result.data["vegetables"][0]["id"] == str(v3.pk) # Test interaction with simple filters. No matches due to AND logic relative to simple filters. result = query( """ { vegetables(filters: { id: { exact: """ + str(v1.pk) + """ }, AND: [{ name: { exact: "v2" } }] }) { id } } """ ) assert not result.errors assert len(result.data["vegetables"]) == 0 # Test interaction with simple filters. Match on same record result = query( """ { vegetables(filters: { id: { exact: """ + str(v1.pk) + """ }, AND: [{ name: { exact: "v1" } }] }) { id } } """ ) assert not result.errors assert len(result.data["vegetables"]) == 1 assert result.data["vegetables"][0]["id"] == str(v1.pk) def test_filter_none(query, db): yellow = models.Color.objects.create(name="yellow") models.Fruit.objects.create(name="banana", color=yellow) f1 = models.Fruit.objects.create(name="unknown") f2 = models.Fruit.objects.create(name="unknown2") result = query(""" { fruits(filters: {color: null}) { id } } """) assert not result.errors assert result.data["fruits"] == [ {"id": str(f1.id)}, {"id": str(f2.id)}, ] def test_empty_resolver_filter(): @strawberry.type class Query: @strawberry.field def fruits(self, filters: FruitFilter) -> list[Fruit]: queryset = models.Fruit.objects.none() info: Any = object() return cast( "list[Fruit]", strawberry_django.filters.apply(filters, queryset, info) ) query = utils.generate_query(Query) result = query('{ fruits(filters: { name: { exact: "strawberry" } }) { name } }') assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert result.data["fruits"] == [] @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_async_resolver_filter(fruits): @strawberry.type class Query: @strawberry.field async def fruits(self, filters: FruitFilter) -> list[Fruit]: queryset = models.Fruit.objects.all() info: Any = object() queryset = strawberry_django.filters.apply(filters, queryset, info) # cast fixes funny typing issue between list and List return cast("list[Fruit]", [fruit async for fruit in queryset]) query = utils.generate_query(Query) result = await query( # type: ignore '{ fruits(filters: { name: { exact: "strawberry" } }) { name } }' ) assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert result.data["fruits"] == [ {"name": "strawberry"}, ] def test_resolve_value_some_with_range_lookup(): range_lookup = strawberry_django.RangeLookup( start=Some(GlobalID("FruitNode", "10")), end=Some(GlobalID("FruitNode", "20")), ) assert resolve_value(range_lookup.start) == "10" assert resolve_value(range_lookup.end) == "20" def test_resolve_value_some_with_comparison_filter_lookup(): gid = GlobalID("FruitNode", "125") filter_lookup = strawberry_django.ComparisonFilterLookup( exact=Some(gid), range=strawberry_django.RangeLookup(start=Some(gid), end=Some(gid)), ) @strawberry_django.filters.filter_type(models.Fruit) class Filter: id: strawberry_django.ComparisonFilterLookup[GlobalID] | None filter_: Any = Filter(id=filter_lookup) # type: ignore[arg-type] object_: Any = object() q = process_filters(filter_, object_, object_)[1] assert q == Q(id__exact="125", id__range=["125", "125"]) def test_filter_method_some_value_resolution(): received_values: dict[str, Any] = {} @strawberry_django.filters.filter_type(models.Fruit) class Filter: @strawberry_django.filter_field(resolve_value=True) def field_filter_resolved(self, value: GlobalID, prefix): received_values["resolved"] = value return Q() @strawberry_django.filter_field def field_filter_unset(self, value: GlobalID, prefix): received_values["unset"] = value return Q() gid = GlobalID("FruitNode", "125") filter_: Any = Filter( field_filter_resolved=Some(gid), # type: ignore[arg-type] field_filter_unset=Some(gid), # type: ignore[arg-type] ) object_: Any = object() process_filters(filter_, object_, object_) assert isinstance(received_values["resolved"], str) assert received_values["resolved"] == "125" # When resolve_value is UNSET for filter methods, Some wrapper is kept assert isinstance(received_values["unset"], Some) assert received_values["unset"].value == gid def test_filter_with_some_enum_value(): filter_lookup = strawberry_django.ComparisonFilterLookup( exact=Some(Version.TWO), ) assert resolve_value(filter_lookup.exact) == "second" def test_filter_with_some_in_list(): filter_lookup = strawberry_django.BaseFilterLookup( in_list=[Some(GlobalID("FruitNode", "1")), Some(GlobalID("FruitNode", "2"))], ) resolved = resolve_value(filter_lookup.in_list) assert resolved == ["1", "2"] def test_filter_with_nested_some(): nested = Some(Some(Some(GlobalID("FruitNode", "42")))) assert resolve_value(nested) == "42" def test_filter_with_some_none(): assert resolve_value(Some(None)) is None assert resolve_value(Some(Some(None))) is None def test_process_filters_with_some_wrapped_values(): @strawberry_django.filters.filter_type(models.Fruit) class Filter: name: strawberry_django.StrFilterLookup | None name_lookup = strawberry_django.StrFilterLookup( exact=Some("strawberry"), # type: ignore[arg-type] contains=Some("berry"), # type: ignore[arg-type] ) filter_: Any = Filter(name=name_lookup) object_: Any = object() _, q = process_filters(filter_, object_, object_) assert set(q.children) == { ("name__exact", "strawberry"), ("name__contains", "berry"), } def test_str_filter_lookup_without_type_parameter(): @strawberry_django.filters.filter_type(models.Fruit) class FruitFilter: name: strawberry_django.StrFilterLookup | None @strawberry_django.type(models.Fruit, filters=FruitFilter) class FruitType: name: auto @strawberry.type class Query: fruits: list[FruitType] = strawberry_django.field() schema = strawberry.Schema(query=Query) assert "input StrFilterLookup {" in schema.as_str() def test_process_filters_with_some_global_id_in_lookup(): @strawberry_django.filters.filter_type(models.Fruit) class Filter: id: strawberry_django.BaseFilterLookup[GlobalID] | None id_lookup = strawberry_django.BaseFilterLookup( exact=Some(GlobalID("FruitNode", "42")), in_list=[ Some(GlobalID("FruitNode", "1")), Some(GlobalID("FruitNode", "2")), ], ) filter_: Any = Filter(id=id_lookup) # type: ignore[arg-type] object_: Any = object() _, q = process_filters(filter_, object_, object_) assert dict(q.children) == {"id__exact": "42", "id__in": ["1", "2"]} @pytest.mark.django_db @pytest.mark.parametrize( ("lookup_name", "value_getter"), [ ("exact", lambda u: f'"{u}"'), ("iExact", lambda u: f'"{u}"'), ("inList", lambda u: f'["{u}"]'), ("contains", lambda u: f'"{u[5:10]}"'), ("iContains", lambda u: f'"{u[5:10].upper()}"'), ("startsWith", lambda u: f'"{u[:5]}"'), ("iStartsWith", lambda u: f'"{u[:5].upper()}"'), ("endsWith", lambda u: f'"{u[-5:]}"'), ("iEndsWith", lambda u: f'"{u[-5:].upper()}"'), ("regex", lambda u: f'"{u[:5]}.*"'), ("iRegex", lambda u: f'"{u[:5].upper()}.*"'), ], ) def test_uuid_lookup_string_filters(query, lookup_name, value_getter): """Test that UUID fields support string-based partial lookups and exact matches. Verifies that lookups like contains, startsWith, regex, etc., accept string inputs and correctly filter UUIDs, in addition to standard exact/inList matches. """ instance = models.UUIDModel.objects.create(text="test") uuid_str = str(instance.id) filter_value = value_getter(uuid_str) result = query(f""" query {{ items(filters: {{ id: {{ {lookup_name}: {filter_value} }} }}) {{ id text }} }} """) assert not result.errors assert len(result.data["items"]) == 1 assert result.data["items"][0]["id"] == uuid_str def test_filterlookup_str_emits_warning(): with pytest.warns(UserWarning, match="FilterLookup\\[str\\].*Use StrFilterLookup"): filter_types.FilterLookup[str] def test_filterlookup_uuid_emits_warning(): with pytest.warns(UserWarning, match="FilterLookup\\[UUID\\].*Use StrFilterLookup"): filter_types.FilterLookup[uuid.UUID] def test_skip_filter_field(): @strawberry_django.filter_type(models.Fruit) class Filter: name: auto min_similarity: float | None = strawberry_django.filter_field( default=0.3, skip_queryset_filter=True ) @strawberry_django.filter_field def filter(self, queryset: QuerySet, prefix: str): if self.min_similarity is not None: return queryset, Q(**{f"{prefix}name": self.min_similarity}) return queryset, Q() filter_: Any = Filter(name="apple", min_similarity=0.5) qs: Any = object() fake_info: Any = object() # Object-level filter method can access self.min_similarity _, q = process_filters(filter_, qs, fake_info) assert Q(name=0.5) == q # Field-iteration path: min_similarity is skipped, only name produces a Q _, q = process_filters(filter_, qs, fake_info, skip_object_filter_method=True) assert Q(name="apple") == q strawberry-graphql-django-0.82.1/tests/filters/test_process_filters_prefix.py000066400000000000000000000067521516173410200277220ustar00rootroot00000000000000from typing import cast import pytest import strawberry from django.db.models import CharField, Q, QuerySet, Value from django.db.models.functions import Concat from strawberry import auto from strawberry.types.base import WithStrawberryObjectDefinition import strawberry_django from strawberry_django.fields import filter_types from strawberry_django.filters import process_filters from tests import models def test_process_filters_prefix(db): @strawberry_django.filter_type(models.Vegetable) class VegetableFilter: name: auto @strawberry_django.filter_field def name_and_description( self, info: strawberry.Info, queryset: QuerySet, value: filter_types.StrFilterLookup[str], prefix: str, ) -> tuple[QuerySet, Q]: queryset = queryset.alias( _name_description=Concat( f"{prefix}name", Value(" "), f"{prefix}description", output_field=CharField(), ) ) return process_filters( cast("WithStrawberryObjectDefinition", value), queryset, info, prefix="_name_description__", ) @strawberry_django.type(models.Vegetable, filters=VegetableFilter) class VegetableType: id: auto name: auto @strawberry.type class Query: vegetables: list[VegetableType] = strawberry_django.field() schema = strawberry.Schema(query=Query) models.Vegetable.objects.create( name="carrot", description="orange root", world_production=40.0e6 ) result = schema.execute_sync(""" { vegetables(filters: { nameAndDescription: { iContains: "orange" } }) { name } } """) assert not result.errors assert result.data == {"vegetables": [{"name": "carrot"}]} def test_process_filters_prefix_missing_trailing_underscores(db): @strawberry_django.filter_type(models.Vegetable) class VegetableFilter: name: auto @strawberry_django.filter_field def name_and_description( self, info: strawberry.Info, queryset: QuerySet, value: filter_types.StrFilterLookup[str], prefix: str, ) -> tuple[QuerySet, Q]: queryset = queryset.alias( _name_description=Concat( f"{prefix}name", Value(" "), f"{prefix}description", output_field=CharField(), ) ) return process_filters( cast("WithStrawberryObjectDefinition", value), queryset, info, prefix="_name_description", ) @strawberry_django.type(models.Vegetable, filters=VegetableFilter) class VegetableType: id: auto name: auto @strawberry.type class Query: vegetables: list[VegetableType] = strawberry_django.field() schema = strawberry.Schema(query=Query) models.Vegetable.objects.create( name="carrot", description="orange root", world_production=40.0e6 ) with pytest.warns(UserWarning, match="does not end with '__'"): result = schema.execute_sync(""" { vegetables(filters: { nameAndDescription: { iContains: "orange" } }) { name } } """) assert result.errors strawberry-graphql-django-0.82.1/tests/filters/test_types.py000066400000000000000000000100631516173410200242710ustar00rootroot00000000000000from typing import cast import pytest import strawberry from strawberry import auto from strawberry.types import get_object_definition from strawberry.types.base import StrawberryOptional import strawberry_django from strawberry_django.filters import get_django_model_filter_input_type from strawberry_django.settings import strawberry_django_settings from tests import models DjangoModelFilterInput = get_django_model_filter_input_type() @pytest.fixture(autouse=True) def _filter_order_settings(settings): settings.STRAWBERRY_DJANGO = { **strawberry_django_settings(), "USE_DEPRECATED_FILTERS": True, } def test_filter(): @strawberry_django.filters.filter_type(models.Fruit) class Filter: id: auto name: auto color: auto types: auto object_definition = get_object_definition(Filter, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("id", StrawberryOptional(cast("type", strawberry.ID))), ("name", StrawberryOptional(str)), ("color", StrawberryOptional(DjangoModelFilterInput)), ("types", StrawberryOptional(DjangoModelFilterInput)), ("AND", StrawberryOptional(Filter)), ("OR", StrawberryOptional(Filter)), ("NOT", StrawberryOptional(Filter)), ("DISTINCT", StrawberryOptional(bool)), ] def test_lookups(): @strawberry_django.filters.filter_type(models.Fruit, lookups=True) class Filter: id: auto name: auto color: auto types: auto object_definition = get_object_definition(Filter, strict=True) assert [ (f.name, f.type.of_type.__name__) # type: ignore for f in object_definition.fields ] == [ ("id", "FilterLookup"), ("name", "FilterLookup"), ("color", "DjangoModelFilterInput"), ("types", "DjangoModelFilterInput"), ("AND", "Filter"), ("OR", "Filter"), ("NOT", "Filter"), ("DISTINCT", "bool"), ] def test_inherit(testtype): @testtype(models.Fruit) class Base: id: auto name: auto color: auto types: auto @strawberry_django.filters.filter_type(models.Fruit) class Filter(Base): pass object_definition = get_object_definition(Filter, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("id", StrawberryOptional(cast("type", strawberry.ID))), ("name", StrawberryOptional(str)), ("color", StrawberryOptional(DjangoModelFilterInput)), ("types", StrawberryOptional(DjangoModelFilterInput)), ("AND", StrawberryOptional(Filter)), ("OR", StrawberryOptional(Filter)), ("NOT", StrawberryOptional(Filter)), ("DISTINCT", StrawberryOptional(bool)), ] def test_relationship(): @strawberry_django.filters.filter_type(models.Color) class ColorFilter: name: auto @strawberry_django.filters.filter_type(models.Fruit) class Filter: color: ColorFilter | None object_definition = get_object_definition(Filter, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("color", StrawberryOptional(ColorFilter)), ("AND", StrawberryOptional(Filter)), ("OR", StrawberryOptional(Filter)), ("NOT", StrawberryOptional(Filter)), ("DISTINCT", StrawberryOptional(bool)), ] def test_relationship_with_inheritance(): @strawberry_django.filters.filter_type(models.Color) class ColorFilter: name: auto @strawberry_django.type(models.Fruit) class Base: color: auto @strawberry_django.filters.filter_type(models.Fruit) class Filter(Base): color: ColorFilter | None object_definition = get_object_definition(Filter, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("color", StrawberryOptional(ColorFilter)), ("AND", StrawberryOptional(Filter)), ("OR", StrawberryOptional(Filter)), ("NOT", StrawberryOptional(Filter)), ("DISTINCT", StrawberryOptional(bool)), ] strawberry-graphql-django-0.82.1/tests/models.py000066400000000000000000000072421516173410200217060ustar00rootroot00000000000000import uuid from typing import TYPE_CHECKING, Optional from django.core.exceptions import ImproperlyConfigured, ValidationError from django.db import models from strawberry_django.descriptors import model_property if TYPE_CHECKING: from django.db.models.manager import RelatedManager def validate_fruit_type(value: str): if "rotten" in value: raise ValidationError("We do not allow rotten fruits.") class NameDescriptionMixin(models.Model): name = models.CharField(max_length=20) description = models.TextField() class Meta: abstract = True class Vegetable(NameDescriptionMixin): world_production = models.FloatField() class Fruit(models.Model): id: int | None name = models.CharField(max_length=20) color_id: int | None color = models.ForeignKey( "Color", null=True, blank=True, related_name="fruits", on_delete=models.CASCADE, ) types = models.ManyToManyField("FruitType", related_name="fruits") sweetness = models.IntegerField( default=5, help_text="Level of sweetness, from 1 to 10", ) picture = models.ImageField( null=True, blank=True, default=None, upload_to=".tmp_upload", ) def name_upper(self): return self.name.upper() @property def name_lower(self): return self.name.lower() @model_property def name_length(self) -> int: return len(self.name) class TomatoWithRequiredPicture(models.Model): name = models.CharField(max_length=20) picture = models.ImageField( null=False, blank=False, upload_to=".tmp_upload", ) class Color(models.Model): fruits: "RelatedManager[Fruit]" name = models.CharField(max_length=20) class FruitType(models.Model): id: int | None name = models.CharField(max_length=20, validators=[validate_fruit_type]) class User(models.Model): name = models.CharField(max_length=50) group_id: int | None group = models.ForeignKey( "Group", null=True, blank=True, related_name="users", on_delete=models.CASCADE, ) tag = models.OneToOneField("Tag", null=True, on_delete=models.CASCADE) @property def group_prop(self) -> Optional["Group"]: return self.group def get_group(self) -> Optional["Group"]: return self.group class Group(models.Model): users: "RelatedManager[User]" name = models.CharField(max_length=50) tags = models.ManyToManyField("Tag", null=True, related_name="groups") class Tag(models.Model): name = models.CharField(max_length=50) class Book(models.Model): """Model with lots of extra metadata.""" title = models.CharField( max_length=20, blank=False, null=False, help_text="The name by which the book is known.", ) 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 class UUIDModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) text = models.CharField(max_length=50) strawberry-graphql-django-0.82.1/tests/mutations/000077500000000000000000000000001516173410200220675ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/mutations/__init__.py000066400000000000000000000000001516173410200241660ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/mutations/conftest.py000066400000000000000000000063161516173410200242740ustar00rootroot00000000000000from typing import cast import pytest import strawberry from django.conf import settings from django.utils.functional import SimpleLazyObject from strawberry import Info, auto import strawberry_django from strawberry_django import mutations from strawberry_django.mutations import resolvers from tests import models, utils from tests.types import ( Color, ColorInput, ColorPartialInput, Fruit, FruitInput, FruitPartialInput, FruitType, FruitTypeInput, FruitTypePartialInput, TomatoWithRequiredPictureInput, TomatoWithRequiredPicturePartialInput, TomatoWithRequiredPictureType, ) @strawberry_django.filters.filter_type(models.Fruit, lookups=True) class FruitFilter: id: auto name: auto @strawberry.type class Mutation: create_fruit: Fruit = mutations.create(FruitInput) create_fruits: list[Fruit] = mutations.create(list[FruitInput]) patch_fruits: list[Fruit] = mutations.update(list[FruitPartialInput], key_attr="id") update_fruits: list[Fruit] = mutations.update( FruitPartialInput, filters=FruitFilter, key_attr="id" ) create_tomato_with_required_picture: TomatoWithRequiredPictureType = ( mutations.create(TomatoWithRequiredPictureInput) ) update_tomato_with_required_picture: TomatoWithRequiredPictureType = ( mutations.update(TomatoWithRequiredPicturePartialInput) ) @strawberry_django.mutation def update_lazy_fruit(self, info: Info, data: FruitPartialInput) -> Fruit: fruit = SimpleLazyObject(models.Fruit.objects.get) return cast( "Fruit", resolvers.update( info, fruit, resolvers.parse_input(info, vars(data), key_attr="id"), key_attr="id", ), ) @strawberry_django.mutation def delete_lazy_fruit(self, info: Info) -> Fruit: fruit = SimpleLazyObject(models.Fruit.objects.get) return cast( "Fruit", resolvers.delete( info, fruit, ), ) delete_fruits: list[Fruit] = mutations.delete(filters=FruitFilter) create_color: Color = mutations.create(ColorInput) create_colors: list[Color] = mutations.create(ColorInput) update_colors: list[Color] = mutations.update(ColorPartialInput) delete_colors: list[Color] = mutations.delete() create_fruit_type: FruitType = mutations.create(FruitTypeInput) create_fruit_types: list[FruitType] = mutations.create(FruitTypeInput) update_fruit_types: list[FruitType] = mutations.update(FruitTypePartialInput) delete_fruit_types: list[FruitType] = mutations.delete() @pytest.fixture def mutation(db): if settings.GEOS_IMPORTED: from tests.types import GeoField, GeoFieldInput, GeoFieldPartialInput @strawberry.type class GeoMutation(Mutation): create_geo_field: GeoField = mutations.create(GeoFieldInput) update_geo_fields: list[GeoField] = mutations.update(GeoFieldPartialInput) mutation = GeoMutation else: mutation = Mutation return utils.generate_query(mutation=mutation) @pytest.fixture def fruit(db): return models.Fruit.objects.create(name="Strawberry") strawberry-graphql-django-0.82.1/tests/mutations/test_batch_mutations.py000066400000000000000000000046061516173410200266720ustar00rootroot00000000000000"""Tests for batched mutations. Batched mutations are mutations that mutate multiple objects at once. Mutations with a filter function or accept a list of objects that return a list. """ def test_batch_create(mutation, fruits): result = mutation( """ mutation { fruits: createFruits( data: [{ name: "banana" }, { name: "cherry" }] ) { id name } } """ ) assert not result.errors assert result.data["fruits"] == [ {"id": "4", "name": "banana"}, {"id": "5", "name": "cherry"}, ] def test_batch_delete_with_filter(mutation, fruits): result = mutation( """ mutation($ids: [ID!]) { fruits: deleteFruits( filters: {id: {inList: $ids}} ) { id name } } """, {"ids": ["2"]}, ) assert not result.errors assert result.data["fruits"] == [ {"id": "2", "name": "raspberry"}, ] def test_batch_delete_with_filter_empty_list(mutation, fruits): result = mutation( """ { fruits: deleteFruits( filters: {id: {inList: []}} ) { id name } } """ ) assert not result.errors def test_batch_update_with_filter(mutation, fruits): result = mutation( """ { fruits: updateFruits( data: { name: "orange" } filters: {id: {inList: [1]}} ) { id name } } """ ) assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "orange"}, ] def test_batch_update_with_filter_empty_list(mutation, fruits): result = mutation( """ { fruits: updateFruits( data: { name: "orange" } filters: {id: {inList: []}} ) { id name } } """ ) assert not result.errors def test_batch_patch(mutation, fruits): result = mutation( """ { fruits: patchFruits( data: [{ id: 2, name: "orange" }] ) { id name } } """ ) assert not result.errors assert result.data["fruits"] == [ {"id": "2", "name": "orange"}, ] strawberry-graphql-django-0.82.1/tests/mutations/test_hooks.py000066400000000000000000000000001516173410200246110ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/mutations/test_mutations.py000066400000000000000000000445071516173410200255350ustar00rootroot00000000000000import io import pytest from django.conf import settings from django.core.files.uploadedfile import SimpleUploadedFile from PIL import Image from tests import models from tests.utils import deep_tuple_to_list def prep_image(fname): """Return an SimpleUploadedFile.""" img_f = io.BytesIO() img = Image.new(mode="RGB", size=(1, 1), color="red") img.save(img_f, format="jpeg") return SimpleUploadedFile(fname, img_f.getvalue()) def test_create(mutation): result = mutation( '{ fruit: createFruit(data: { name: "strawberry" }) { id name } }', ) assert not result.errors assert result.data["fruit"] == {"id": "1", "name": "strawberry"} assert list(models.Fruit.objects.values("id", "name")) == [ {"id": 1, "name": "strawberry"}, ] def test_create_with_optional_file(mutation): fname = "test_create_with_optional_file.png" upload = prep_image(fname) result = mutation( """\ CreateFruit($picture: Upload!) { createFruit(data: { name: "strawberry", picture: $picture }) { id name picture { name } } } """, variable_values={"picture": upload}, ) assert not result.errors assert result.data["createFruit"] == { "id": "1", "name": "strawberry", "picture": {"name": f".tmp_upload/{fname}"}, } def test_create_with_optional_file_when_not_setting_it(mutation): result = mutation( """\ CreateFruit($picture: Upload) { createFruit(data: { name: "strawberry", picture: $picture }) { id name picture { name } } } """, variable_values={"picture": None}, ) assert not result.errors assert result.data["createFruit"] == { "id": "1", "name": "strawberry", "picture": None, } def test_update_with_optional_file_when_unsetting_it(mutation): fname = "test_update_with_optional_file.png" upload = prep_image(fname) fruit = models.Fruit.objects.create(name="strawberry", picture=upload) result = mutation( """\ UpdateFruit($id: ID! $picture: Upload) { updateFruits( filters: { id: { exact: $id } } data: { picture: $picture } ) { id name picture { name } } } """, variable_values={"id": fruit.pk, "picture": None}, ) assert not result.errors assert result.data["updateFruits"] == [ { "id": str(fruit.pk), "name": "strawberry", "picture": None, } ] def test_with_required_file_fails(mutation): # The query input will not have the required field listed # as we want to test the failback of the django-model full_clean # method on the create to trigger validation errors. result = mutation( """\ createTomatoWithRequiredPicture { createTomatoWithRequiredPicture(data: {name: "strawberry"}) { id name picture { name } } } """, variable_values={}, ) assert result.errors is not None assert "'This field cannot be blank" in str(result.errors) @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_create_async(mutation): result = await mutation( '{ fruit: createFruit(data: { name: "strawberry" }) { name } }', ) assert not result.errors assert result.data["fruit"] == {"name": "strawberry"} def test_create_many(mutation): result = mutation( '{ fruits: createFruits(data: [{ name: "strawberry" },' ' { name: "raspberry" }]) { id name } }', ) assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, {"id": "2", "name": "raspberry"}, ] assert list(models.Fruit.objects.values("id", "name")) == [ {"id": 1, "name": "strawberry"}, {"id": 2, "name": "raspberry"}, ] def test_update(mutation, fruits): result = mutation( '{ fruits: updateFruits(data: { name: "orange" }, filters: {}) { id name } }' ) assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "orange"}, {"id": "2", "name": "orange"}, {"id": "3", "name": "orange"}, ] assert list(models.Fruit.objects.values("id", "name")) == [ {"id": 1, "name": "orange"}, {"id": 2, "name": "orange"}, {"id": 3, "name": "orange"}, ] def test_update_m2m_with_validation_error(mutation, fruit): result = mutation( '{ fruits: updateFruits(data: { types: [{ name: "rotten"} ] }, filters: {}) { id types {' " name } }}", ) assert result.errors assert result.errors[0].message == "{'name': ['We do not allow rotten fruits.']}" def test_update_m2m_with_new_different_objects(mutation, fruit): result = mutation( '{ fruits: updateFruits(data: { types: [{name: "apple"}, {name: "strawberry"}]}, filters: {}) { id types { id name }}}' ) assert not result.errors assert result.data["fruits"][0]["types"] == [ {"id": "1", "name": "apple"}, {"id": "2", "name": "strawberry"}, ] result = mutation( '{ fruits: updateFruits(data: { types: [{id: "1", name: "apple updated"}, {name: "raspberry"}]}, filters: {}) { id types { id name }}}' ) assert result.data["fruits"][0]["types"] == [ {"id": "1", "name": "apple updated"}, {"id": "3", "name": "raspberry"}, ] def test_update_m2m_with_duplicates(mutation, fruit): result = mutation( '{ fruits: updateFruits(data: { types: [{name: "apple"}, {name: "apple"}]}, filters: {}) { id types { id name }}}' ) assert not result.errors assert result.data["fruits"][0]["types"] == [ {"id": "1", "name": "apple"}, {"id": "2", "name": "apple"}, ] def test_update_lazy_object(mutation, fruit): result = mutation( '{ fruit: updateLazyFruit(data: { name: "orange" }) { id name } }', ) assert not result.errors assert result.data["fruit"] == {"id": "1", "name": "orange"} def test_update_with_filters(mutation, fruits): result = mutation( '{ fruits: updateFruits(data: { name: "orange" },' " filters: { id: { inList: [1, 2] } } ) { id name } }", ) assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "orange"}, {"id": "2", "name": "orange"}, ] assert list(models.Fruit.objects.values("id", "name")) == [ {"id": 1, "name": "orange"}, {"id": 2, "name": "orange"}, {"id": 3, "name": "banana"}, ] def test_delete(mutation, fruits): result = mutation("{ fruits: deleteFruits(filters: {}) { id name } }") assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, {"id": "2", "name": "raspberry"}, {"id": "3", "name": "banana"}, ] assert list(models.Fruit.objects.values("id", "name")) == [] def test_delete_lazy_object(mutation, fruit): result = mutation("{ fruit: deleteLazyFruit { id name } }") assert not result.errors assert result.data["fruit"] == {"id": "1", "name": "Strawberry"} assert list(models.Fruit.objects.values("id", "name")) == [] def test_delete_with_filters(mutation, fruits): result = mutation( '{ fruits: deleteFruits(filters: { name: { contains: "berry" } }) { id' " name } }", ) assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, {"id": "2", "name": "raspberry"}, ] assert list(models.Fruit.objects.values("id", "name")) == [ {"id": 3, "name": "banana"}, ] @pytest.mark.skipif( not settings.GEOS_IMPORTED, reason="Test requires GEOS to be imported and properly configured", ) @pytest.mark.django_db(transaction=True) def test_create_geo(mutation): from tests.models import GeosFieldsModel # Test for point point = (0.0, 1.0) result = mutation( f"{{ geofield: createGeoField(data: {{ point: {list(point)} }} ) {{ id }} }}", ) assert not result.errors assert ( GeosFieldsModel.objects.get(id=result.data["geofield"]["id"]).point.tuple == point ) # Test for lineString line_string = ((0.0, 0.0), (1.0, 1.0)) result = mutation( f""" {{ geofield: createGeoField(data: {{ lineString: {deep_tuple_to_list(line_string)} }}) {{ id }} }} """, ) assert not result.errors assert ( GeosFieldsModel.objects.get(id=result.data["geofield"]["id"]).line_string.tuple == line_string ) # Test for polygon polygon = ( ((-1.0, -1.0), (-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)), ((-2.0, -2.0), (-2.0, 2.0), (2.0, 2.0), (2.0, -2.0), (-2.0, -2.0)), ) result = mutation( f""" {{ geofield: createGeoField(data: {{ polygon: {deep_tuple_to_list(polygon)} }}) {{ id }} }} """, ) assert not result.errors assert ( GeosFieldsModel.objects.get(id=result.data["geofield"]["id"]).polygon.tuple == polygon ) # Test for multi_point multi_point = ((0.0, 0.0), (-1.0, -1.0), (1.0, 1.0)) result = mutation( f""" {{ geofield: createGeoField(data: {{ multiPoint: {deep_tuple_to_list(multi_point)} }}) {{ id }} }} """, ) assert not result.errors assert ( GeosFieldsModel.objects.get(id=result.data["geofield"]["id"]).multi_point.tuple == multi_point ) # Test for multiLineString multi_line_string = ( ((0.0, 0.0), (1.0, 1.0)), ((1.0, 1.0), (-1.0, -1.0)), ((2.0, 2.0), (-2.0, -2.0)), ) result = mutation( f""" {{ geofield: createGeoField(data: {{ multiLineString: {deep_tuple_to_list(multi_line_string)} }}) {{ id }} }} """, ) assert not result.errors assert ( GeosFieldsModel.objects.get( id=result.data["geofield"]["id"], ).multi_line_string.tuple == multi_line_string ) # Test for multiPolygon multi_polygon = ( ( ((-1.0, -1.0), (-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)), ((-2.0, -2.0), (-2.0, 2.0), (2.0, 2.0), (2.0, -2.0), (-2.0, -2.0)), ), ( ((-1.0, -1.0), (-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)), ((-2.0, -2.0), (-2.0, 2.0), (2.0, 2.0), (2.0, -2.0), (-2.0, -2.0)), ), ) result = mutation( f""" {{ geofield: createGeoField(data: {{ multiPolygon: {deep_tuple_to_list(multi_polygon)} }}) {{ id }} }} """, ) assert not result.errors assert ( GeosFieldsModel.objects.get( id=result.data["geofield"]["id"], ).multi_polygon.tuple == multi_polygon ) @pytest.mark.skipif( not settings.GEOS_IMPORTED, reason="Test requires GEOS to be imported and properly configured", ) @pytest.mark.django_db(transaction=True) def test_update_geo(mutation): from tests.models import GeosFieldsModel geofield_obj = GeosFieldsModel.objects.create() assert geofield_obj.point is None assert geofield_obj.line_string is None assert geofield_obj.polygon is None assert geofield_obj.multi_point is None assert geofield_obj.multi_line_string is None assert geofield_obj.multi_polygon is None # Test for point point = [0.0, 1.0] result = mutation( f""" {{ geofield: updateGeoFields(data: {{ point: {point} }}) {{ id }} }} """, ) assert not result.errors geofield_obj.refresh_from_db() assert deep_tuple_to_list(geofield_obj.point.tuple) == point # Test for lineString line_string = [[0.0, 0.0], [1.0, 1.0]] result = mutation( f""" {{ geofield: updateGeoFields(data: {{ lineString: {line_string} }}) {{ id }} }} """, ) assert not result.errors geofield_obj.refresh_from_db() assert deep_tuple_to_list(geofield_obj.line_string.tuple) == line_string # Test for polygon polygon = [ [[-1.0, -1.0], [-1.0, 1.0], [1.0, 1.0], [1.0, -1.0], [-1.0, -1.0]], [[-2.0, -2.0], [-2.0, 2.0], [2.0, 2.0], [2.0, -2.0], [-2.0, -2.0]], ] result = mutation( f""" {{ geofield: updateGeoFields(data: {{ polygon: {polygon} }}) {{ id }} }} """, ) assert not result.errors geofield_obj.refresh_from_db() assert deep_tuple_to_list(geofield_obj.polygon.tuple) == polygon # Test for multi_point multi_point = [[0.0, 0.0], [-1.0, -1.0], [1.0, 1.0]] result = mutation( f""" {{ geofield: updateGeoFields(data: {{ multiPoint: {multi_point} }}) {{ id }} }} """, ) assert not result.errors geofield_obj.refresh_from_db() assert deep_tuple_to_list(geofield_obj.multi_point.tuple) == multi_point # Test for multiLineString multi_line_string = [ [[0.0, 0.0], [1.0, 1.0]], [[1.0, 1.0], [-1.0, -1.0]], [[2.0, 2.0], [-2.0, -2.0]], ] result = mutation( f""" {{ geofield: updateGeoFields(data: {{ multiLineString: {multi_line_string} }}) {{ id }} }} """, ) assert not result.errors geofield_obj.refresh_from_db() assert deep_tuple_to_list(geofield_obj.multi_line_string.tuple) == multi_line_string # Test for multiPolygon multi_polygon = [ [ [[-1.0, -1.0], [-1.0, 1.0], [1.0, 1.0], [1.0, -1.0], [-1.0, -1.0]], [[-2.0, -2.0], [-2.0, 2.0], [2.0, 2.0], [2.0, -2.0], [-2.0, -2.0]], ], [ [[-1.0, -1.0], [-1.0, 1.0], [1.0, 1.0], [1.0, -1.0], [-1.0, -1.0]], [[-2.0, -2.0], [-2.0, 2.0], [2.0, 2.0], [2.0, -2.0], [-2.0, -2.0]], ], ] result = mutation( f""" {{ geofield: updateGeoFields(data: {{ multiPolygon: {multi_polygon} }}) {{ id }} }} """, ) assert not result.errors geofield_obj.refresh_from_db() assert deep_tuple_to_list(geofield_obj.multi_polygon.tuple) == multi_polygon # Test everything not overwritten geofield_obj.refresh_from_db() assert deep_tuple_to_list(geofield_obj.point.tuple) == point assert deep_tuple_to_list(geofield_obj.line_string.tuple) == line_string assert deep_tuple_to_list(geofield_obj.polygon.tuple) == polygon assert deep_tuple_to_list(geofield_obj.multi_point.tuple) == multi_point assert deep_tuple_to_list(geofield_obj.multi_line_string.tuple) == multi_line_string assert deep_tuple_to_list(geofield_obj.multi_polygon.tuple) == multi_polygon def test_parse_input_unwraps_some(): from enum import Enum from typing import Any from strawberry import Some from strawberry_django.mutations.resolvers import parse_input class Color(Enum): RED = "red" GREEN = "green" info: Any = None assert parse_input(info, Some("hello")) == "hello" assert parse_input(info, Some(None)) is None assert parse_input(info, Some(Some("nested"))) == "nested" assert parse_input(info, Some(Color.RED)) == "red" assert parse_input(info, [Some("a"), Some("b")]) == ["a", "b"] assert parse_input(info, {"key": Some("value")}) == {"key": "value"} def test_create_with_fk_id_field(db): """FK _id fields (e.g. color_id) in input types should work with mutations.create().""" import strawberry import strawberry_django from strawberry_django import mutations @strawberry_django.input(models.Fruit) class FruitWithFkIdInput: name: strawberry.auto color_id: strawberry.auto @strawberry_django.type(models.Fruit) class FruitOutput: id: strawberry.auto name: strawberry.auto color_id: strawberry.auto @strawberry.type class Mutation: create_fruit: FruitOutput = mutations.create(FruitWithFkIdInput) @strawberry.type class Query: @strawberry.field def noop(self) -> str: return "" schema = strawberry.Schema(query=Query, mutation=Mutation) color = models.Color.objects.create(name="Red") result = schema.execute_sync( f'mutation {{ createFruit(data: {{ name: "strawberry", colorId: {color.pk} }}) {{ id name colorId }} }}', ) assert not result.errors assert result.data == { "createFruit": { "id": str(models.Fruit.objects.get().pk), "name": "strawberry", "colorId": str(color.pk), }, } fruit = models.Fruit.objects.get() assert fruit.color_id == color.pk def test_update_with_fk_id_field(db): """FK _id fields should work with mutations.update().""" import strawberry import strawberry_django from strawberry_django import mutations @strawberry_django.input(models.Fruit) class FruitUpdateInput: id: strawberry.auto color_id: strawberry.auto @strawberry_django.type(models.Fruit) class FruitOutput: id: strawberry.auto name: strawberry.auto color_id: strawberry.auto @strawberry.type class Mutation: update_fruit: FruitOutput = mutations.update(FruitUpdateInput) @strawberry.type class Query: @strawberry.field def noop(self) -> str: return "" schema = strawberry.Schema(query=Query, mutation=Mutation) red = models.Color.objects.create(name="Red") blue = models.Color.objects.create(name="Blue") fruit = models.Fruit.objects.create(name="strawberry", color=red) result = schema.execute_sync( "mutation($data: FruitUpdateInput!) { updateFruit(data: $data) { id name colorId } }", variable_values={"data": {"id": fruit.pk, "colorId": blue.pk}}, ) assert not result.errors assert result.data == { "updateFruit": { "id": str(fruit.pk), "name": "strawberry", "colorId": str(blue.pk), }, } fruit.refresh_from_db() assert fruit.color_id == blue.pk strawberry-graphql-django-0.82.1/tests/mutations/test_partial_updates.py000066400000000000000000001024531516173410200266660ustar00rootroot00000000000000"""Tests the behaviour of partial input optional fields in mutations. This module tests Strawberry-Django's handling of partial input optional fields in mutations, specifically when their values are omitted or explicitly set to `null`, for different variations of model fields: * Required fields * Optional and nullable fields * Optional and non-nullable fields * Required foreign key fields * Optional foreign key fields * Many-to-many fields These tests stem from the fact that the GraphQL type-system doesn't distinguish between optional and nullable. That is, type `T!` is both required and non-nullable (i.e., must be supplied and cannot be `null`), but type `T` is both optional and nullable (i.e., can be omitted and can be `null`). """ import pytest import strawberry from django.test import override_settings from strawberry.relay import to_base64 import strawberry_django from strawberry_django.settings import strawberry_django_settings from tests.projects.faker import ( IssueFactory, MilestoneFactory, ProjectFactory, TagFactory, ) from tests.projects.models import Issue, Milestone, Project, Tag from tests.utils import generate_query @pytest.fixture def mutation(db): @strawberry_django.type(Issue) class IssueType: id: strawberry.auto name: strawberry.auto kind: strawberry.auto priority: strawberry.auto milestone: strawberry.auto tags: strawberry.auto @strawberry_django.partial(Issue) class IssueInputPartial: id: strawberry.auto name: strawberry.auto kind: strawberry.auto priority: strawberry.auto milestone: strawberry.auto tags: strawberry.auto @strawberry_django.type(Milestone) class MilestoneType: id: strawberry.auto project: strawberry.auto @strawberry_django.partial(Milestone) class MilestoneInputPartial: id: strawberry.auto project: strawberry.auto @strawberry_django.type(Project) class ProjectType: id: strawberry.auto @strawberry_django.type(Tag) class TagType: id: strawberry.auto @strawberry.type class Query: issue: IssueType milestone: MilestoneType project: ProjectType tag: TagType @strawberry.type class Mutation: update_issue: IssueType = strawberry_django.mutations.update( IssueInputPartial, handle_django_errors=True, ) update_milestone: MilestoneType = strawberry_django.mutations.update( MilestoneInputPartial, handle_django_errors=True, ) return generate_query(query=Query, mutation=Mutation) def test_field_required(mutation): """Tests behaviour for a required model field.""" query = """mutation UpdateIssueName($id: ID!, $name: String) { updateIssue(data: { id: $id, name: $name }) { ...on IssueType { name } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue issue_name = "Original name" issue = IssueFactory.create(name=issue_name) # Update the issue, omitting the `name` field # We expect the mutation to succeed and the name to remain unchanged result = mutation(query, {"id": issue.pk}) assert result.errors is None assert result.data == {"updateIssue": {"name": issue_name}} issue.refresh_from_db() assert issue.name == issue_name # Update the issue, explicitly providing `null` for the `name` field # We expect the mutation to fail and the name to remain unchanged # Note that this failure occurs at the model level, not the GraphQL level result = mutation(query, {"id": issue.pk, "name": None}) assert result.errors is None assert result.data == { "updateIssue": { "messages": [ { "kind": "VALIDATION", "code": "null", "message": "This field cannot be null.", "field": "name", } ] } } issue.refresh_from_db() assert issue.name == issue_name def test_field_optional_and_non_nullable(mutation): """Tests behaviour for an optional & non-nullable model field.""" query = """mutation UpdateIssuePriority($id: ID!, $priority: Int) { updateIssue(data: { id: $id, priority: $priority }) { ...on IssueType { priority } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue issue_priority = 42 issue = IssueFactory.create(priority=issue_priority) # Update the issue, omitting the `priority` field # We expect the mutation to succeed and the priority to remain unchanged result = mutation(query, {"id": issue.pk}) assert result.errors is None assert result.data == {"updateIssue": {"priority": issue_priority}} issue.refresh_from_db() assert issue.priority == issue_priority # Update the issue, explicitly providing `null` for the `priority` field # We expect the mutation to fail and the priority to remain unchanged # Note that this failure occurs at the model level, not the GraphQL level result = mutation(query, {"id": issue.pk, "priority": None}) assert result.errors is None assert result.data == { "updateIssue": { "messages": [ { "kind": "VALIDATION", "code": "null", "message": "This field cannot be null.", "field": "priority", } ] } } issue.refresh_from_db() assert issue.priority == issue_priority def test_field_optional_and_nullable(mutation): """Tests behaviour for an optional & nullable model field.""" query = """mutation UpdateIssueKind($id: ID!, $kind: String) { updateIssue(data: { id: $id, kind: $kind }) { ...on IssueType { kind } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue issue_kind = Issue.Kind.FEATURE.value issue = IssueFactory.create(kind=issue_kind) # Update the issue, omitting the `kind` field # We expect the mutation to succeed and the kind to remain unchanged result = mutation(query, {"id": issue.pk}) assert result.errors is None assert result.data == {"updateIssue": {"kind": issue_kind}} issue.refresh_from_db() assert issue.kind == issue_kind # Update the issue, explicitly providing `null` for the `kind` field # We expect the mutation to succeed and the kind to be set to `None` result = mutation(query, {"id": issue.pk, "kind": None}) assert result.errors is None assert result.data == {"updateIssue": {"kind": None}} issue.refresh_from_db() assert issue.kind is None def test_foreign_key_required(mutation): """Tests behaviour for a required foreign key field.""" query = """mutation UpdateMilestoneProject($id: ID!, $project: OneToManyInput) { updateMilestone(data: { id: $id, project: $project }) { ...on MilestoneType { project { pk } } ... on OperationInfo { messages { kind code message field } } } } """ # Create a milestone project = ProjectFactory.create() milestone = MilestoneFactory.create(project=project) # Update the milestone, omitting the `project` field # We expect the mutation to succeed and the project to remain unchanged result = mutation(query, {"id": milestone.pk}) assert result.errors is None assert result.data == {"updateMilestone": {"project": {"pk": str(project.pk)}}} milestone.refresh_from_db() assert milestone.project == project # Update the milestone, explicitly providing `null` for the `project` field # We expect the mutation to fail and the project to remain unchanged # Note that this failure occurs at the model level, not the GraphQL level result = mutation(query, {"id": milestone.pk, "project": None}) assert result.errors is None assert result.data == { "updateMilestone": { "messages": [ { "kind": "VALIDATION", "code": "null", "message": "This field cannot be null.", "field": "project", } ] } } milestone.refresh_from_db() assert milestone.project == project def test_foreign_key_optional(mutation): """Tests behaviour for an optional foreign key field.""" query = """mutation UpdateIssueMilestone($id: ID!, $milestone: OneToManyInput) { updateIssue(data: { id: $id, milestone: $milestone }) { ...on IssueType { milestone { pk } } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue milestone = MilestoneFactory.create() issue = IssueFactory.create(milestone=milestone) # Update the issue, omitting the `milestone` field # We expect the mutation to succeed and the milestone to remain unchanged result = mutation(query, {"id": issue.pk}) assert result.errors is None assert result.data == {"updateIssue": {"milestone": {"pk": str(milestone.pk)}}} issue.refresh_from_db() assert issue.milestone == milestone # Update the issue, explicitly providing `null` for the `milestone` field # We expect the mutation to succeed and the milestone to be set to `None` result = mutation(query, {"id": issue.pk, "milestone": None}) assert result.errors is None assert result.data == {"updateIssue": {"milestone": None}} issue.refresh_from_db() assert issue.milestone is None def test_many_to_many(mutation): """Tests behaviour for a many to many field.""" query = """mutation UpdateIssueTags($id: ID!, $tags: ManyToManyInput) { updateIssue(data: { id: $id, tags: $tags }) { ...on IssueType { tags { pk } } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue tags = TagFactory.create_batch(3) issue = IssueFactory.create() issue.tags.set(tags) # Update the issue, omitting the `tags` field # We expect the mutation to succeed and the tags to remain unchanged result = mutation(query, {"id": issue.pk}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"pk": str(tag.pk)} for tag in tags]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags # Update the issue, explicitly providing `null` for the `tags` field # We expect the mutation to succeed, but the tags to remain unchanged result = mutation(query, {"id": issue.pk, "tags": None}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"pk": str(tag.pk)} for tag in tags]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags def test_many_to_many_set(mutation): """Tests behaviour for `set` on a many to many field.""" query = """mutation SetIssueTags($id: ID!, $setTags: [ID!]) { updateIssue(data: { id: $id, tags: { set: $setTags } }) { ...on IssueType { tags { pk } } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue tags = TagFactory.create_batch(3) issue = IssueFactory.create() issue.tags.set(tags) # Update the issue, omitting the `setTags` field # We expect the mutation to succeed and the tags to remain unchanged result = mutation(query, {"id": issue.pk}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"pk": str(tag.pk)} for tag in tags]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags # Update the issue, explicitly providing `null` for the `setTags` field # We expect the mutation to succeed, but the tags to remain unchanged result = mutation(query, {"id": issue.pk, "setTags": None}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"pk": str(tag.pk)} for tag in tags]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags # Update the issue, explicitly providing an empty list for the `setTags` field # We expect the mutation to succeed, and the tags to be cleared result = mutation(query, {"id": issue.pk, "setTags": []}) assert result.errors is None assert result.data == {"updateIssue": {"tags": []}} issue.refresh_from_db() assert list(issue.tags.all()) == [] def test_many_to_many_add(mutation): """Tests behaviour for `add` on a many to many field.""" query = """mutation AddIssueTags($id: ID!, $addTags: [ID!]) { updateIssue(data: { id: $id, tags: { add: $addTags } }) { ...on IssueType { tags { pk } } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue tags = TagFactory.create_batch(3) issue = IssueFactory.create() issue.tags.set(tags) # Update the issue, omitting the `addTags` field # We expect the mutation to succeed and the tags to remain unchanged result = mutation(query, {"id": issue.pk}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"pk": str(tag.pk)} for tag in tags]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags # Update the issue, explicitly providing `null` for the `addTags` field # We expect the mutation to succeed, but the tags to remain unchanged result = mutation(query, {"id": issue.pk, "addTags": None}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"pk": str(tag.pk)} for tag in tags]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags # Update the issue, explicitly providing an empty list for the `addTags` field # We expect the mutation to succeed, but the tags to remain unchanged result = mutation(query, {"id": issue.pk, "addTags": []}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"pk": str(tag.pk)} for tag in tags]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags def test_many_to_many_remove(mutation): """Tests behaviour for `remove` on a many to many field.""" query = """mutation RemoveIssueTags($id: ID!, $removeTags: [ID!]) { updateIssue(data: { id: $id, tags: { remove: $removeTags } }) { ...on IssueType { tags { pk } } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue tags = TagFactory.create_batch(3) issue = IssueFactory.create() issue.tags.set(tags) # Update the issue, omitting the `removeTags` field # We expect the mutation to succeed and the tags to remain unchanged result = mutation(query, {"id": issue.pk}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"pk": str(tag.pk)} for tag in tags]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags # Update the issue, explicitly providing `null` for the `removeTags` field # We expect the mutation to succeed, but the tags to remain unchanged result = mutation(query, {"id": issue.pk, "removeTags": None}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"pk": str(tag.pk)} for tag in tags]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags # Update the issue, explicitly providing an empty list for the `removeTags` field # We expect the mutation to succeed, but the tags to remain unchanged result = mutation(query, {"id": issue.pk, "removeTags": []}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"pk": str(tag.pk)} for tag in tags]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags @pytest.fixture @override_settings( STRAWBERRY_DJANGO={ **strawberry_django_settings(), "MAP_AUTO_ID_AS_GLOBAL_ID": True, }, ) def relay_mutation(db): @strawberry_django.type(Issue) class IssueType(strawberry.relay.Node): name: strawberry.auto kind: strawberry.auto priority: strawberry.auto milestone: strawberry.auto tags: strawberry.auto @strawberry_django.partial(Issue) class IssueInputPartial(strawberry_django.NodeInput): name: strawberry.auto kind: strawberry.auto priority: strawberry.auto milestone: strawberry.auto tags: strawberry.auto @strawberry_django.type(Milestone) class MilestoneType(strawberry.relay.Node): project: strawberry.auto @strawberry_django.partial(Milestone) class MilestoneInputPartial(strawberry_django.NodeInput): project: strawberry.auto @strawberry_django.type(Project) class ProjectType(strawberry.relay.Node): pass @strawberry_django.type(Tag) class TagType(strawberry.relay.Node): pass @strawberry.type class Query: issue: IssueType milestone: MilestoneType project: ProjectType tag: TagType @strawberry.type class Mutation: update_issue: IssueType = strawberry_django.mutations.update( IssueInputPartial, handle_django_errors=True, ) update_milestone: MilestoneType = strawberry_django.mutations.update( MilestoneInputPartial, handle_django_errors=True, ) return generate_query(query=Query, mutation=Mutation) def test_relay_field_required(relay_mutation): """Tests Relay behaviour for a required model field.""" query = """mutation UpdateIssueName($id: ID!, $name: String) { updateIssue(data: { id: $id, name: $name }) { ...on IssueType { name } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue issue_name = "Original name" issue = IssueFactory.create(name=issue_name) issue_id = to_base64("IssueType", issue.pk) # Update the issue, omitting the `name` field # We expect the mutation to succeed and the name to remain unchanged result = relay_mutation(query, {"id": issue_id}) assert result.errors is None assert result.data == {"updateIssue": {"name": issue_name}} issue.refresh_from_db() assert issue.name == issue_name # Update the issue, explicitly providing `null` for the `name` field # We expect the mutation to fail and the name to remain unchanged # Note that this failure occurs at the model level, not the GraphQL level result = relay_mutation(query, {"id": issue_id, "name": None}) assert result.errors is None assert result.data == { "updateIssue": { "messages": [ { "kind": "VALIDATION", "code": "null", "message": "This field cannot be null.", "field": "name", } ] } } issue.refresh_from_db() assert issue.name == issue_name def test_relay_field_optional_and_non_nullable(relay_mutation): """Tests Relay behaviour for an optional & non-nullable model field.""" query = """mutation UpdateIssuePriority($id: ID!, $priority: Int) { updateIssue(data: { id: $id, priority: $priority }) { ...on IssueType { priority } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue issue_priority = 42 issue = IssueFactory.create(priority=issue_priority) issue_id = to_base64("IssueType", issue.pk) # Update the issue, omitting the `priority` field # We expect the mutation to succeed and the priority to remain unchanged result = relay_mutation(query, {"id": issue_id}) assert result.errors is None assert result.data == {"updateIssue": {"priority": issue_priority}} issue.refresh_from_db() assert issue.priority == issue_priority # Update the issue, explicitly providing `null` for the `priority` field # We expect the mutation to fail and the priority to remain unchanged # Note that this failure occurs at the model level, not the GraphQL level result = relay_mutation(query, {"id": issue_id, "priority": None}) assert result.errors is None assert result.data == { "updateIssue": { "messages": [ { "kind": "VALIDATION", "code": "null", "message": "This field cannot be null.", "field": "priority", } ] } } issue.refresh_from_db() assert issue.priority == issue_priority def test_relay_field_optional_and_nullable(relay_mutation): """Tests Relay behaviour for an optional & nullable model field.""" query = """mutation UpdateIssueKind($id: ID!, $kind: String) { updateIssue(data: { id: $id, kind: $kind }) { ...on IssueType { kind } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue issue_kind = Issue.Kind.FEATURE.value issue = IssueFactory.create(kind=issue_kind) issue_id = to_base64("IssueType", issue.pk) # Update the issue, omitting the `kind` field # We expect the mutation to succeed and the kind to remain unchanged result = relay_mutation(query, {"id": issue_id}) assert result.errors is None assert result.data == {"updateIssue": {"kind": issue_kind}} issue.refresh_from_db() assert issue.kind == issue_kind # Update the issue, explicitly providing `null` for the `kind` field # We expect the mutation to succeed and the kind to be set to `None` result = relay_mutation(query, {"id": issue_id, "kind": None}) assert result.errors is None assert result.data == {"updateIssue": {"kind": None}} issue.refresh_from_db() assert issue.kind is None def test_relay_foreign_key_required(relay_mutation): """Tests Relay behaviour for a required foreign key field.""" query = """mutation UpdateMilestoneProject($id: ID!, $project: NodeInput) { updateMilestone(data: { id: $id, project: $project }) { ...on MilestoneType { project { id } } ... on OperationInfo { messages { kind code message field } } } } """ # Create a milestone project = ProjectFactory.create() project_id = to_base64("ProjectType", project.pk) milestone = MilestoneFactory.create(project=project) milestone_id = to_base64("MilestoneType", milestone.pk) # Update the milestone, omitting the `project` field # We expect the mutation to succeed and the project to remain unchanged result = relay_mutation(query, {"id": milestone_id}) assert result.errors is None assert result.data == {"updateMilestone": {"project": {"id": project_id}}} milestone.refresh_from_db() assert milestone.project == project # Update the milestone, explicitly providing `null` for the `project` field # We expect the mutation to fail and the project to remain unchanged # Note that this failure occurs at the model level, not the GraphQL level result = relay_mutation(query, {"id": milestone_id, "project": None}) assert result.errors is None assert result.data == { "updateMilestone": { "messages": [ { "kind": "VALIDATION", "code": "null", "message": "This field cannot be null.", "field": "project", } ] } } milestone.refresh_from_db() assert milestone.project == project def test_relay_foreign_key_optional(relay_mutation): """Tests Relay behaviour for an optional foreign key field.""" query = """mutation UpdateIssueMilestone($id: ID!, $milestone: NodeInput) { updateIssue(data: { id: $id, milestone: $milestone }) { ...on IssueType { milestone { id } } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue milestone = MilestoneFactory.create() milestone_id = to_base64("MilestoneType", milestone.pk) issue = IssueFactory.create(milestone=milestone) issue_id = to_base64("IssueType", issue.pk) # Update the issue, omitting the `milestone` field # We expect the mutation to succeed and the milestone to remain unchanged result = relay_mutation(query, {"id": issue_id}) assert result.errors is None assert result.data == {"updateIssue": {"milestone": {"id": milestone_id}}} issue.refresh_from_db() assert issue.milestone == milestone # Update the issue, explicitly providing `null` for the `milestone` field # We expect the mutation to succeed and the milestone to be set to `None` result = relay_mutation(query, {"id": issue_id, "milestone": None}) assert result.errors is None assert result.data == {"updateIssue": {"milestone": None}} issue.refresh_from_db() assert issue.milestone is None def test_relay_many_to_many(relay_mutation): """Tests Relay behaviour for a many to many field.""" query = """mutation UpdateIssueTags($id: ID!, $tags: NodeInputListInput) { updateIssue(data: { id: $id, tags: $tags }) { ...on IssueType { tags { id } } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue tags = TagFactory.create_batch(3) tag_ids = [to_base64("TagType", tag.pk) for tag in tags] issue = IssueFactory.create() issue.tags.set(tags) issue_id = to_base64("IssueType", issue.pk) # Update the issue, omitting the `tags` field # We expect the mutation to succeed and the tags to remain unchanged result = relay_mutation(query, {"id": issue_id}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"id": tag_id} for tag_id in tag_ids]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags # Update the issue, explicitly providing `null` for the `tags` field # We expect the mutation to succeed, but the tags to remain unchanged result = relay_mutation(query, {"id": issue_id, "tags": None}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"id": tag_id} for tag_id in tag_ids]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags def test_relay_many_to_many_set(relay_mutation): """Tests Relay behaviour for `set` on a many to many field.""" query = """mutation SetIssueTags($id: ID!, $setTags: [NodeInput!]) { updateIssue(data: { id: $id, tags: { set: $setTags } }) { ...on IssueType { tags { id } } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue tags = TagFactory.create_batch(3) tag_ids = [to_base64("TagType", tag.pk) for tag in tags] issue = IssueFactory.create() issue.tags.set(tags) issue_id = to_base64("IssueType", issue.pk) # Update the issue, omitting the `setTags` field # We expect the mutation to succeed and the tags to remain unchanged result = relay_mutation(query, {"id": issue_id}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"id": tag_id} for tag_id in tag_ids]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags # Update the issue, explicitly providing `null` for the `setTags` field # We expect the mutation to succeed, but the tags to remain unchanged result = relay_mutation(query, {"id": issue_id, "setTags": None}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"id": tag_id} for tag_id in tag_ids]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags # Update the issue, explicitly providing an empty list for the `setTags` field # We expect the mutation to succeed, and the tags to be cleared result = relay_mutation(query, {"id": issue_id, "setTags": []}) assert result.errors is None assert result.data == {"updateIssue": {"tags": []}} issue.refresh_from_db() assert list(issue.tags.all()) == [] def test_relay_many_to_many_add(relay_mutation): """Tests Relay behaviour for `add` on a many to many field.""" query = """mutation AddIssueTags($id: ID!, $addTags: [NodeInput!]) { updateIssue(data: { id: $id, tags: { add: $addTags } }) { ...on IssueType { tags { id } } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue tags = TagFactory.create_batch(3) tag_ids = [to_base64("TagType", tag.pk) for tag in tags] issue = IssueFactory.create() issue.tags.set(tags) issue_id = to_base64("IssueType", issue.pk) # Update the issue, omitting the `addTags` field # We expect the mutation to succeed and the tags to remain unchanged result = relay_mutation(query, {"id": issue_id}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"id": tag_id} for tag_id in tag_ids]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags # Update the issue, explicitly providing `null` for the `addTags` field # We expect the mutation to succeed, but the tags to remain unchanged result = relay_mutation(query, {"id": issue_id, "addTags": None}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"id": tag_id} for tag_id in tag_ids]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags # Update the issue, explicitly providing an empty list for the `addTags` field # We expect the mutation to succeed, but the tags to remain unchanged result = relay_mutation(query, {"id": issue_id, "addTags": []}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"id": tag_id} for tag_id in tag_ids]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags def test_relay_many_to_many_remove(relay_mutation): """Tests Relay behaviour for `remove` on a many to many field.""" query = """mutation RemoveIssueTags($id: ID!, $removeTags: [NodeInput!]) { updateIssue(data: { id: $id, tags: { remove: $removeTags } }) { ...on IssueType { tags { id } } ... on OperationInfo { messages { kind code message field } } } } """ # Create an issue tags = TagFactory.create_batch(3) tag_ids = [to_base64("TagType", tag.pk) for tag in tags] issue = IssueFactory.create() issue.tags.set(tags) issue_id = to_base64("IssueType", issue.pk) # Update the issue, omitting the `removeTags` field # We expect the mutation to succeed and the tags to remain unchanged result = relay_mutation(query, {"id": issue_id}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"id": tag_id} for tag_id in tag_ids]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags # Update the issue, explicitly providing `null` for the `removeTags` field # We expect the mutation to succeed, but the tags to remain unchanged result = relay_mutation(query, {"id": issue_id, "removeTags": None}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"id": tag_id} for tag_id in tag_ids]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags # Update the issue, explicitly providing an empty list for the `removeTags` field # We expect the mutation to succeed, but the tags to remain unchanged result = relay_mutation(query, {"id": issue_id, "removeTags": []}) assert result.errors is None assert result.data == { "updateIssue": {"tags": [{"id": tag_id} for tag_id in tag_ids]} } issue.refresh_from_db() assert list(issue.tags.all()) == tags strawberry-graphql-django-0.82.1/tests/mutations/test_permission_classes.py000066400000000000000000000024111516173410200274030ustar00rootroot00000000000000import pytest import strawberry from strawberry.permission import BasePermission from strawberry_django import mutations from tests import utils from tests.types import Fruit, FruitInput, FruitPartialInput class PermissionClass(BasePermission): message = "Permission Denied" def has_permission(self, source, info, **kwargs): return False @strawberry.type class Mutation: create_fruits: list[Fruit] = mutations.create( FruitInput, permission_classes=[PermissionClass], ) update_fruits: list[Fruit] = mutations.update( FruitPartialInput, permission_classes=[PermissionClass], ) delete_fruits: list[Fruit] = mutations.delete(permission_classes=[PermissionClass]) @pytest.fixture def mutation(db): return utils.generate_query(mutation=Mutation) def test_create(mutation): result = mutation('{ createFruits(data: { name: "strawberry" }) { id name } }') assert "Permission Denied" in str(result.errors) def test_update(mutation): result = mutation('{ updateFruits(data: { name: "strawberry" }) { id name } }') assert "Permission Denied" in str(result.errors) def test_delete(mutation): result = mutation("{ deleteFruits { id name } }") assert "Permission Denied" in str(result.errors) strawberry-graphql-django-0.82.1/tests/mutations/test_relationship.py000066400000000000000000000074141516173410200262070ustar00rootroot00000000000000"""Test the functionality of CUD relationships. Foreign key relationships in a GraphQL API context. It includes tests for one-to-many, many-to-one, and many-to-many relationships. """ import pytest from tests import models @pytest.fixture def color(db): return models.Color.objects.create(name="red") @pytest.fixture def fruit_type(db): return models.FruitType.objects.create(name="Berries") def test_create_one_to_many(mutation, color): result = mutation( '{ fruit: createFruit(data: { name: "strawberry",' " color: { set: 1 } }) { color { name } } }", ) assert not result.errors assert result.data["fruit"] == {"color": {"name": color.name}} def test_update_one_to_many(mutation, fruit, color): result = mutation( "{ fruits: updateFruits(data: { color: { set: 1 } }, filters: {}) { color { name } } }", ) assert not result.errors assert result.data["fruits"] == [{"color": {"name": color.name}}] result = mutation( "{ fruits: updateFruits(data: { color: { set: null } }, filters: {}) { color { name } } }", ) assert not result.errors assert result.data["fruits"] == [{"color": None}] def test_patch_one_to_many(mutation, fruit, color, django_assert_max_num_queries): # Issue 487: At maximum, 11 queries are expected to be executed: # 6x SAVEPOINT, 4x SELECT, 1x UPDATE with django_assert_max_num_queries(12): result = mutation( '{ fruits: updateFruits(filters: { id: { exact: "1"} }, ' "data: { color: { set: 1 } }) { color { name } } }", ) assert not result.errors assert result.data["fruits"] == [{"color": {"name": color.name}}] def test_update_many_to_one(mutation, fruit, color): result = mutation( "{ colors: updateColors(data: { fruits: { add: [1] } }) { fruits { name } } }", ) assert not result.errors assert result.data["colors"] == [{"fruits": [{"name": fruit.name}]}] result = mutation( "{ colors: updateColors(data: { fruits: { remove: [1] } }) { fruits { name" " } } }", ) assert not result.errors assert result.data["colors"] == [{"fruits": []}] result = mutation( "{ colors: updateColors(data: { fruits: { set: [1] } }) { fruits { name } } }", ) assert not result.errors assert result.data["colors"] == [{"fruits": [{"name": fruit.name}]}] result = mutation( "{ colors: updateColors(data: { fruits: { set: [] } }) { fruits { name } } }", ) assert not result.errors assert result.data["colors"] == [{"fruits": []}] def test_create_many_to_many(mutation, fruit): result = mutation( '{ types: createFruitType(data: { name: "Berries",' " fruits: { set: [1] } }) { fruits { name } } }", ) assert not result.errors assert result.data["types"] == {"fruits": [{"name": fruit.name}]} def test_update_many_to_many(mutation, fruit, fruit_type): result = mutation( "{ types: updateFruitTypes(data: { fruits: { add: [1] } }) { fruits { name" " } } }", ) assert not result.errors assert result.data["types"] == [{"fruits": [{"name": fruit.name}]}] result = mutation( "{ types: updateFruitTypes(data: { fruits: { remove: [1] } })" " { fruits { name } } }", ) assert not result.errors assert result.data["types"] == [{"fruits": []}] result = mutation( "{ types: updateFruitTypes(data: { fruits: { set: [1] } }) { fruits { name" " } } }", ) assert not result.errors assert result.data["types"] == [{"fruits": [{"name": fruit.name}]}] result = mutation( "{ types: updateFruitTypes(data: { fruits: { set: [] } }) { fruits { name" " } } }", ) assert not result.errors assert result.data["types"] == [{"fruits": []}] strawberry-graphql-django-0.82.1/tests/node_polymorphism/000077500000000000000000000000001516173410200236135ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/node_polymorphism/__init__.py000066400000000000000000000000001516173410200257120ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/node_polymorphism/models.py000066400000000000000000000004711516173410200254520ustar00rootroot00000000000000from django.db import models from polymorphic.models import PolymorphicModel class Project(PolymorphicModel): topic = models.CharField(max_length=30) class ArtProject(Project): artist = models.CharField(max_length=30) class ResearchProject(Project): supervisor = models.CharField(max_length=30) strawberry-graphql-django-0.82.1/tests/node_polymorphism/schema.py000066400000000000000000000014201516173410200254220ustar00rootroot00000000000000import strawberry from strawberry.relay import ListConnection import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from .models import ArtProject, Project, ResearchProject @strawberry_django.interface(Project) class ProjectType(strawberry.relay.Node): topic: strawberry.auto @strawberry_django.type(ArtProject) class ArtProjectType(ProjectType): artist: strawberry.auto @strawberry_django.type(ResearchProject) class ResearchProjectType(ProjectType): supervisor: strawberry.auto @strawberry.type class Query: projects: ListConnection[ProjectType] = strawberry_django.connection() schema = strawberry.Schema( query=Query, types=[ArtProjectType, ResearchProjectType], extensions=[DjangoOptimizerExtension], ) strawberry-graphql-django-0.82.1/tests/node_polymorphism/test_optimizer.py000066400000000000000000000026221516173410200272500ustar00rootroot00000000000000import pytest from tests.utils import assert_num_queries from .models import ArtProject, ResearchProject from .schema import schema @pytest.mark.django_db(transaction=True) def test_polymorphic_interface_query(): ap = ArtProject.objects.create(topic="Art", artist="Artist") rp = ResearchProject.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projects { edges { node { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } } } """ # ContentType, base table, two subtables = 4 queries with assert_num_queries(4): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projects": { "edges": [ { "node": { "__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist, } }, { "node": { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, } }, ] } } strawberry-graphql-django-0.82.1/tests/polymorphism/000077500000000000000000000000001516173410200226065ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/polymorphism/__init__.py000066400000000000000000000000001516173410200247050ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/polymorphism/models.py000066400000000000000000000030501516173410200244410ustar00rootroot00000000000000from django.db import models from polymorphic.models import PolymorphicModel from strawberry_django.descriptors import model_property class Company(models.Model): name = models.CharField(max_length=100) main_project = models.ForeignKey("Project", on_delete=models.CASCADE, null=True) class Meta: ordering = ("name",) class Project(PolymorphicModel): company = models.ForeignKey( Company, null=True, blank=True, on_delete=models.CASCADE, related_name="projects", ) topic = models.CharField(max_length=30) class ArtProject(Project): artist = models.CharField(max_length=30) art_style = models.CharField(max_length=30) @model_property(only=("art_style",)) def art_style_upper(self) -> str: return self.art_style.upper() class ResearchProject(Project): supervisor = models.CharField(max_length=30) research_notes = models.TextField() class TechnicalProject(Project): timeline = models.CharField(max_length=30) class Meta: # pyright: ignore [reportIncompatibleVariableOverride] abstract = True class SoftwareProject(TechnicalProject): repository = models.CharField(max_length=255) class EngineeringProject(TechnicalProject): lead_engineer = models.CharField(max_length=255) class AppProject(TechnicalProject): repository = models.CharField(max_length=255) class AndroidProject(AppProject): android_version = models.CharField(max_length=15) class IOSProject(AppProject): ios_version = models.CharField(max_length=15) strawberry-graphql-django-0.82.1/tests/polymorphism/schema.py000066400000000000000000000046601516173410200244260ustar00rootroot00000000000000import strawberry import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry_django.pagination import OffsetPaginated from .models import ( AndroidProject, AppProject, ArtProject, Company, EngineeringProject, IOSProject, Project, ResearchProject, SoftwareProject, TechnicalProject, ) @strawberry_django.interface(Project) class ProjectType: topic: strawberry.auto @strawberry_django.field(only=("topic",)) def topic_upper(self) -> str: return self.topic.upper() @strawberry_django.type(ArtProject) class ArtProjectType(ProjectType): artist: strawberry.auto art_style_upper: strawberry.auto @strawberry_django.field(only=("artist",)) def artist_upper(self) -> str: return self.artist.upper() @strawberry_django.type(ResearchProject) class ResearchProjectType(ProjectType): supervisor: strawberry.auto @strawberry_django.interface(TechnicalProject) class TechnicalProjectType(ProjectType): timeline: strawberry.auto @strawberry_django.type(SoftwareProject) class SoftwareProjectType(TechnicalProjectType): repository: strawberry.auto @strawberry_django.type(EngineeringProject) class EngineeringProjectType(TechnicalProjectType): lead_engineer: strawberry.auto @strawberry_django.interface(AppProject) class AppProjectType(TechnicalProjectType): repository: strawberry.auto @strawberry_django.type(AndroidProject) class AndroidProjectType(AppProjectType): android_version: strawberry.auto @strawberry_django.type(IOSProject) class IOSProjectType(AppProjectType): ios_version: strawberry.auto @strawberry_django.type(Company) class CompanyType: name: strawberry.auto projects: list[ProjectType] main_project: ProjectType | None @strawberry.type class Query: companies: list[CompanyType] = strawberry_django.field() projects: list[ProjectType] = strawberry_django.field() projects_paginated: list[ProjectType] = strawberry_django.field(pagination=True) projects_offset_paginated: OffsetPaginated[ProjectType] = ( strawberry_django.offset_paginated() ) schema = strawberry.Schema( query=Query, types=[ ArtProjectType, ResearchProjectType, TechnicalProjectType, EngineeringProjectType, SoftwareProjectType, AndroidProjectType, IOSProjectType, ], extensions=[DjangoOptimizerExtension], ) strawberry-graphql-django-0.82.1/tests/polymorphism/test_optimizer.py000066400000000000000000000315241516173410200262460ustar00rootroot00000000000000import pytest from django.db import DEFAULT_DB_ALIAS, connections from django.test.utils import CaptureQueriesContext from tests.utils import assert_num_queries from .models import ( AndroidProject, ArtProject, Company, EngineeringProject, IOSProject, ResearchProject, SoftwareProject, ) from .schema import schema @pytest.mark.django_db(transaction=True) def test_polymorphic_interface_query(): ap = ArtProject.objects.create(topic="Art", artist="Artist") rp = ResearchProject.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projects { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } """ # ContentType, base table, two subtables = 4 queries with assert_num_queries(4): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projects": [ {"__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist}, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_query_abstract_model(): ap = ArtProject.objects.create(topic="Art", artist="Artist") sp = SoftwareProject.objects.create( topic="Software", repository="https://example.com", timeline="3 months" ) ep = EngineeringProject.objects.create( topic="Engineering", lead_engineer="Elara Voss", timeline="6 years" ) query = """\ query { projects { __typename topic ... on ArtProjectType { artist } ...on TechnicalProjectType { timeline } ... on SoftwareProjectType { repository } ...on EngineeringProjectType { leadEngineer } } } """ with assert_num_queries(5): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projects": [ {"__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist}, { "__typename": "SoftwareProjectType", "topic": sp.topic, "repository": sp.repository, "timeline": sp.timeline, }, { "__typename": "EngineeringProjectType", "topic": ep.topic, "leadEngineer": ep.lead_engineer, "timeline": ep.timeline, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_query_multiple_inheritance_levels(): app1 = AndroidProject.objects.create( topic="Software", repository="https://example.com/android", timeline="3 months", android_version="14", ) app2 = IOSProject.objects.create( topic="Software", repository="https://example.com/ios", timeline="5 months", ios_version="16", ) ep = EngineeringProject.objects.create( topic="Engineering", lead_engineer="Elara Voss", timeline="6 years" ) query = """\ query { projects { __typename topic ...on TechnicalProjectType { timeline } ...on AppProjectType { repository } ...on AndroidProjectType { androidVersion } ...on IOSProjectType { iosVersion } ...on EngineeringProjectType { leadEngineer } } } """ # Project Table, Content Type, AndroidProject, IOSProject, EngineeringProject = 5 with assert_num_queries(5): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projects": [ { "__typename": "AndroidProjectType", "topic": app1.topic, "repository": app1.repository, "timeline": app1.timeline, "androidVersion": app1.android_version, }, { "__typename": "IOSProjectType", "topic": app2.topic, "repository": app2.repository, "timeline": app2.timeline, "iosVersion": app2.ios_version, }, { "__typename": "EngineeringProjectType", "topic": ep.topic, "leadEngineer": ep.lead_engineer, "timeline": ep.timeline, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_query_abstract_model_on_field(): ep = EngineeringProject.objects.create( topic="Engineering", lead_engineer="Elara Voss", timeline="6 years" ) company = Company.objects.create(name="Company", main_project=ep) query = """\ query { companies { name mainProject { __typename topic ...on TechnicalProjectType { timeline } ...on EngineeringProjectType { leadEngineer } } } } """ with assert_num_queries(4): result = schema.execute_sync(query) assert not result.errors assert result.data == { "companies": [ { "name": company.name, "mainProject": { "__typename": "EngineeringProjectType", "topic": ep.topic, "leadEngineer": ep.lead_engineer, "timeline": ep.timeline, }, } ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_query_optimization_working(): ap = ArtProject.objects.create(topic="Art", artist="Artist") rp = ResearchProject.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projects { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } """ with CaptureQueriesContext(connection=connections[DEFAULT_DB_ALIAS]) as ctx: result = schema.execute_sync(query) # validate that we're not selecting extra fields assert any("artist" in q["sql"] for q in ctx.captured_queries) assert not any("research_notes" in q["sql"] for q in ctx.captured_queries) assert not any("art_style" in q["sql"] for q in ctx.captured_queries) assert not result.errors assert result.data == { "projects": [ {"__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist}, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_paginated_query(): ap = ArtProject.objects.create(topic="Art", artist="Artist") rp = ResearchProject.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projectsPaginated { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } """ # ContentType, base table, two subtables = 4 queries with assert_num_queries(4): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projectsPaginated": [ {"__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist}, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_offset_paginated_query(): ap = ArtProject.objects.create(topic="Art", artist="Artist") rp = ResearchProject.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projectsOffsetPaginated { totalCount results { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } } """ # ContentType, base table, two subtables = 4 queries + 1 query for total count with assert_num_queries(5): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projectsOffsetPaginated": { "totalCount": 2, "results": [ { "__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist, }, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ], } } @pytest.mark.django_db(transaction=True) def test_polymorphic_relation(): ap = ArtProject.objects.create(topic="Art", artist="Artist") art_company = Company.objects.create(name="ArtCompany", main_project=ap) rp = ResearchProject.objects.create(topic="Research", supervisor="Supervisor") research_company = Company.objects.create(name="ResearchCompany", main_project=rp) query = """\ query { companies { name mainProject { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } } """ # Company, ContentType, base table, two subtables = 5 queries with assert_num_queries(5): result = schema.execute_sync(query) assert not result.errors assert result.data == { "companies": [ { "name": art_company.name, "mainProject": { "__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist, }, }, { "name": research_company.name, "mainProject": { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_nested_list(): company = Company.objects.create(name="Company") ap = ArtProject.objects.create(company=company, topic="Art", artist="Artist") rp = ResearchProject.objects.create( company=company, topic="Research", supervisor="Supervisor" ) query = """\ query { companies { name projects { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } } """ # Company, ContentType, base table, two subtables = 5 queries with assert_num_queries(5): result = schema.execute_sync(query) assert not result.errors assert result.data == { "companies": [ { "name": "Company", "projects": [ { "__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist, }, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ], } ] } @pytest.mark.django_db(transaction=True) def test_optimizer_hints_polymorphic(): ap = ArtProject.objects.create(topic="Art", artist="Artist", art_style="abstract") rp = ResearchProject.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projects { __typename topicUpper ... on ArtProjectType { artistUpper artStyleUpper } } } """ # ContentType, base table, two subtables = 4 queries with assert_num_queries(4): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projects": [ { "__typename": "ArtProjectType", "topicUpper": ap.topic.upper(), "artistUpper": ap.artist.upper(), "artStyleUpper": ap.art_style.upper(), }, { "__typename": "ResearchProjectType", "topicUpper": rp.topic.upper(), }, ] } strawberry-graphql-django-0.82.1/tests/polymorphism_custom/000077500000000000000000000000001516173410200242005ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/polymorphism_custom/__init__.py000066400000000000000000000000001516173410200262770ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/polymorphism_custom/models.py000066400000000000000000000024741516173410200260440ustar00rootroot00000000000000import django from django.db import models class Company(models.Model): name = models.CharField(max_length=100) main_project = models.ForeignKey( "Project", null=True, blank=True, on_delete=models.CASCADE ) class Meta: ordering = ("name",) class Project(models.Model): company = models.ForeignKey( Company, null=True, blank=True, on_delete=models.CASCADE, related_name="projects", ) topic = models.CharField(max_length=30) artist = models.CharField(max_length=30, blank=True) supervisor = models.CharField(max_length=30, blank=True) research_notes = models.TextField(blank=True) class Meta: constraints = ( models.CheckConstraint( **( { # type: ignore[arg-type] "condition": (models.Q(artist="") | models.Q(supervisor="")) & (~models.Q(topic="") | ~models.Q(topic="")) } if django.VERSION >= (5, 1) else { "check": (models.Q(artist="") | models.Q(supervisor="")) & (~models.Q(topic="") | ~models.Q(topic="")) } ), name="artist_xor_supervisor", ), ) strawberry-graphql-django-0.82.1/tests/polymorphism_custom/schema.py000066400000000000000000000035521516173410200260170ustar00rootroot00000000000000from typing import Any import strawberry from graphql import GraphQLAbstractType, GraphQLResolveInfo from strawberry import Info from strawberry.relay import Node import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry_django.pagination import OffsetPaginated from strawberry_django.relay import DjangoListConnection from .models import Company, Project @strawberry_django.interface(Project) class ProjectType(Node): topic: strawberry.auto @classmethod def resolve_type( cls, value: Any, info: GraphQLResolveInfo, parent_type: GraphQLAbstractType ) -> str: if not isinstance(value, Project): raise TypeError if value.artist: return "ArtProjectType" if value.supervisor: return "ResearchProjectType" raise TypeError @classmethod def get_queryset(cls, qs, info: Info): return qs @strawberry_django.type(Project) class ArtProjectType(ProjectType): artist: strawberry.auto @strawberry_django.type(Project) class ResearchProjectType(ProjectType): supervisor: strawberry.auto @strawberry_django.type(Company) class CompanyType: name: strawberry.auto main_project: ProjectType | None projects: list[ProjectType] @strawberry.type class Query: companies: list[CompanyType] = strawberry_django.field() projects: list[ProjectType] = strawberry_django.field() projects_paginated: list[ProjectType] = strawberry_django.field(pagination=True) projects_offset_paginated: OffsetPaginated[ProjectType] = ( strawberry_django.offset_paginated() ) projects_connection: DjangoListConnection[ProjectType] = ( strawberry_django.connection() ) schema = strawberry.Schema( query=Query, types=[ArtProjectType, ResearchProjectType], extensions=[DjangoOptimizerExtension], ) strawberry-graphql-django-0.82.1/tests/polymorphism_custom/test_optimizer.py000066400000000000000000000201011516173410200276250ustar00rootroot00000000000000import pytest from django.db import DEFAULT_DB_ALIAS, connections from django.test.utils import CaptureQueriesContext from tests.utils import assert_num_queries from .models import Company, Project from .schema import schema @pytest.mark.django_db(transaction=True) def test_polymorphic_interface_query(): ap = Project.objects.create(topic="Art", artist="Artist") rp = Project.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projects { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } """ with assert_num_queries(1): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projects": [ {"__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist}, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_query_optimization_working(): ap = Project.objects.create(topic="Art", artist="Artist") rp = Project.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projects { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } """ with CaptureQueriesContext(connection=connections[DEFAULT_DB_ALIAS]) as ctx: result = schema.execute_sync(query) # validate that we're not selecting extra fields assert any("artist" in q["sql"] for q in ctx.captured_queries) assert not any("research_notes" in q["sql"] for q in ctx.captured_queries) assert not result.errors assert result.data == { "projects": [ {"__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist}, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_interface_paginated(): ap = Project.objects.create(topic="Art", artist="Artist") rp = Project.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projectsPaginated { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } """ with assert_num_queries(1): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projectsPaginated": [ {"__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist}, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_interface_offset_paginated(): ap = Project.objects.create(topic="Art", artist="Artist") rp = Project.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projectsOffsetPaginated { totalCount results { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } } """ with assert_num_queries(2): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projectsOffsetPaginated": { "totalCount": 2, "results": [ { "__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist, }, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ], } } @pytest.mark.django_db(transaction=True) def test_polymorphic_interface_connection(): ap = Project.objects.create(topic="Art", artist="Artist") rp = Project.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projectsConnection { totalCount edges { node { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } } } """ with assert_num_queries(1): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projectsConnection": { "totalCount": 2, "edges": [ { "node": { "__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist, } }, { "node": { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, } }, ], } } @pytest.mark.django_db(transaction=True) def test_polymorphic_relation(): ap = Project.objects.create(topic="Art", artist="Artist") art_company = Company.objects.create(name="ArtCompany", main_project=ap) rp = Project.objects.create(topic="Research", supervisor="Supervisor") research_company = Company.objects.create(name="ResearchCompany", main_project=rp) query = """\ query { companies { name mainProject { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } } """ with assert_num_queries(2): result = schema.execute_sync(query) assert not result.errors assert result.data == { "companies": [ { "name": art_company.name, "mainProject": { "__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist, }, }, { "name": research_company.name, "mainProject": { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_nested_list(): company = Company.objects.create(name="Company") ap = Project.objects.create(company=company, topic="Art", artist="Artist") rp = Project.objects.create( company=company, topic="Research", supervisor="Supervisor" ) query = """\ query { companies { name projects { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } } """ with assert_num_queries(2): result = schema.execute_sync(query) assert not result.errors assert result.data == { "companies": [ { "name": "Company", "projects": [ { "__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist, }, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ], } ] } strawberry-graphql-django-0.82.1/tests/polymorphism_inheritancemanager/000077500000000000000000000000001516173410200265125ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/polymorphism_inheritancemanager/__init__.py000066400000000000000000000000001516173410200306110ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/polymorphism_inheritancemanager/models.py000066400000000000000000000032601516173410200303500ustar00rootroot00000000000000from django.db import models from model_utils.managers import InheritanceManager from strawberry_django.descriptors import model_property class Company(models.Model): name = models.CharField(max_length=100) main_project = models.ForeignKey("Project", on_delete=models.CASCADE, null=True) class Meta: ordering = ("name",) class Project(models.Model): company = models.ForeignKey( Company, null=True, blank=True, on_delete=models.CASCADE, related_name="projects", ) topic = models.CharField(max_length=30) base_objects = InheritanceManager() objects = InheritanceManager() class Meta: base_manager_name = "base_objects" class ArtProject(Project): artist = models.CharField(max_length=30) art_style = models.CharField(max_length=30) @model_property(only=("art_style",)) def art_style_upper(self) -> str: return self.art_style.upper() class ResearchProject(Project): supervisor = models.CharField(max_length=30) research_notes = models.TextField() class TechnicalProject(Project): timeline = models.CharField(max_length=30) class Meta: # pyright: ignore [reportIncompatibleVariableOverride] abstract = True class SoftwareProject(TechnicalProject): repository = models.CharField(max_length=255) class EngineeringProject(TechnicalProject): lead_engineer = models.CharField(max_length=255) class AppProject(TechnicalProject): repository = models.CharField(max_length=255) class AndroidProject(AppProject): android_version = models.CharField(max_length=15) class IOSProject(AppProject): ios_version = models.CharField(max_length=15) strawberry-graphql-django-0.82.1/tests/polymorphism_inheritancemanager/schema.py000066400000000000000000000047101516173410200303260ustar00rootroot00000000000000import strawberry import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry_django.pagination import OffsetPaginated from .models import ( AndroidProject, AppProject, ArtProject, Company, EngineeringProject, IOSProject, Project, ResearchProject, SoftwareProject, TechnicalProject, ) @strawberry_django.interface(Project) class ProjectType: topic: strawberry.auto @strawberry_django.field(only=("topic",)) def topic_upper(self) -> str: return self.topic.upper() @strawberry_django.type(ArtProject) class ArtProjectType(ProjectType): artist: strawberry.auto art_style_upper: strawberry.auto @strawberry_django.field(only=("artist",)) def artist_upper(self) -> str: return self.artist.upper() @strawberry_django.type(ResearchProject) class ResearchProjectType(ProjectType): supervisor: strawberry.auto @strawberry_django.interface(TechnicalProject) class TechnicalProjectType(ProjectType): timeline: strawberry.auto @strawberry_django.type(SoftwareProject) class SoftwareProjectType(TechnicalProjectType): repository: strawberry.auto @strawberry_django.type(EngineeringProject) class EngineeringProjectType(TechnicalProjectType): lead_engineer: strawberry.auto @strawberry_django.interface(AppProject) class AppProjectType(TechnicalProjectType): repository: strawberry.auto @strawberry_django.type(AndroidProject) class AndroidProjectType(AppProjectType): android_version: strawberry.auto @strawberry_django.type(IOSProject) class IOSProjectType(AppProjectType): ios_version: strawberry.auto @strawberry_django.type(Company) class CompanyType: name: strawberry.auto projects: list[ProjectType] main_project: ProjectType | None @strawberry.type class Query: companies: list[CompanyType] = strawberry_django.field() projects: list[ProjectType] = strawberry_django.field() projects_paginated: list[ProjectType] = strawberry_django.field(pagination=True) projects_offset_paginated: OffsetPaginated[ProjectType] = ( strawberry_django.offset_paginated() ) schema = strawberry.Schema( query=Query, types=[ ArtProjectType, ResearchProjectType, TechnicalProjectType, EngineeringProjectType, SoftwareProjectType, AppProjectType, IOSProjectType, AndroidProjectType, ], extensions=[DjangoOptimizerExtension], ) strawberry-graphql-django-0.82.1/tests/polymorphism_inheritancemanager/test_optimizer.py000066400000000000000000000304211516173410200321450ustar00rootroot00000000000000import pytest from django.db import DEFAULT_DB_ALIAS, connections from django.test.utils import CaptureQueriesContext from tests.utils import assert_num_queries from .models import ( AndroidProject, ArtProject, Company, EngineeringProject, IOSProject, ResearchProject, SoftwareProject, ) from .schema import schema @pytest.mark.django_db(transaction=True) def test_polymorphic_interface_query(): ap = ArtProject.objects.create(topic="Art", artist="Artist") rp = ResearchProject.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projects { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } """ with assert_num_queries(1): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projects": [ {"__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist}, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_query_abstract_model(): ap = ArtProject.objects.create(topic="Art", artist="Artist") sp = SoftwareProject.objects.create( topic="Software", repository="https://example.com", timeline="3 months" ) ep = EngineeringProject.objects.create( topic="Engineering", lead_engineer="Elara Voss", timeline="6 years" ) query = """\ query { projects { __typename topic ... on ArtProjectType { artist } ...on TechnicalProjectType { timeline } ... on SoftwareProjectType { repository } ...on EngineeringProjectType { leadEngineer } } } """ with assert_num_queries(1): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projects": [ {"__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist}, { "__typename": "SoftwareProjectType", "topic": sp.topic, "repository": sp.repository, "timeline": sp.timeline, }, { "__typename": "EngineeringProjectType", "topic": ep.topic, "leadEngineer": ep.lead_engineer, "timeline": ep.timeline, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_query_multiple_inheritance_levels(): app1 = AndroidProject.objects.create( topic="Software", repository="https://example.com/android", timeline="3 months", android_version="14", ) app2 = IOSProject.objects.create( topic="Software", repository="https://example.com/ios", timeline="5 months", ios_version="16", ) ep = EngineeringProject.objects.create( topic="Engineering", lead_engineer="Elara Voss", timeline="6 years" ) query = """\ query { projects { __typename topic ...on TechnicalProjectType { timeline } ...on AppProjectType { repository } ...on AndroidProjectType { androidVersion } ...on IOSProjectType { iosVersion } ...on EngineeringProjectType { leadEngineer } } } """ with assert_num_queries(1): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projects": [ { "__typename": "AndroidProjectType", "topic": app1.topic, "repository": app1.repository, "timeline": app1.timeline, "androidVersion": app1.android_version, }, { "__typename": "IOSProjectType", "topic": app2.topic, "repository": app2.repository, "timeline": app2.timeline, "iosVersion": app2.ios_version, }, { "__typename": "EngineeringProjectType", "topic": ep.topic, "leadEngineer": ep.lead_engineer, "timeline": ep.timeline, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_query_abstract_model_on_field(): ep = EngineeringProject.objects.create( topic="Engineering", lead_engineer="Elara Voss", timeline="6 years" ) company = Company.objects.create(name="Company", main_project=ep) query = """\ query { companies { name mainProject { __typename topic ...on TechnicalProjectType { timeline } ...on EngineeringProjectType { leadEngineer } } } } """ with assert_num_queries(2): result = schema.execute_sync(query) assert not result.errors assert result.data == { "companies": [ { "name": company.name, "mainProject": { "__typename": "EngineeringProjectType", "topic": ep.topic, "leadEngineer": ep.lead_engineer, "timeline": ep.timeline, }, } ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_query_optimization_working(): ap = ArtProject.objects.create(topic="Art", artist="Artist") rp = ResearchProject.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projects { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } """ with CaptureQueriesContext(connection=connections[DEFAULT_DB_ALIAS]) as ctx: result = schema.execute_sync(query) # validate that we're not selecting extra fields assert not any("research_notes" in q for q in ctx.captured_queries) assert not any("art_style" in q for q in ctx.captured_queries) assert not result.errors assert result.data == { "projects": [ {"__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist}, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_paginated_query(): ap = ArtProject.objects.create(topic="Art", artist="Artist") rp = ResearchProject.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projectsPaginated { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } """ with assert_num_queries(1): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projectsPaginated": [ {"__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist}, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_offset_paginated_query(): ap = ArtProject.objects.create(topic="Art", artist="Artist") rp = ResearchProject.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projectsOffsetPaginated { totalCount results { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } } """ with assert_num_queries(2): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projectsOffsetPaginated": { "totalCount": 2, "results": [ { "__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist, }, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ], } } @pytest.mark.django_db(transaction=True) def test_polymorphic_relation(): ap = ArtProject.objects.create(topic="Art", artist="Artist") art_company = Company.objects.create(name="ArtCompany", main_project=ap) rp = ResearchProject.objects.create(topic="Research", supervisor="Supervisor") research_company = Company.objects.create(name="ResearchCompany", main_project=rp) query = """\ query { companies { name mainProject { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } } """ with assert_num_queries(2): result = schema.execute_sync(query) assert not result.errors assert result.data == { "companies": [ { "name": art_company.name, "mainProject": { "__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist, }, }, { "name": research_company.name, "mainProject": { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, }, ] } @pytest.mark.django_db(transaction=True) def test_polymorphic_nested_list(): company = Company.objects.create(name="Company") ap = ArtProject.objects.create(company=company, topic="Art", artist="Artist") rp = ResearchProject.objects.create( company=company, topic="Research", supervisor="Supervisor" ) query = """\ query { companies { name projects { __typename topic ... on ArtProjectType { artist } ... on ResearchProjectType { supervisor } } } } """ with assert_num_queries(2): result = schema.execute_sync(query) assert not result.errors assert result.data == { "companies": [ { "name": "Company", "projects": [ { "__typename": "ArtProjectType", "topic": ap.topic, "artist": ap.artist, }, { "__typename": "ResearchProjectType", "topic": rp.topic, "supervisor": rp.supervisor, }, ], } ] } @pytest.mark.django_db(transaction=True) def test_optimizer_hints_polymorphic(): ap = ArtProject.objects.create(topic="Art", artist="Artist") rp = ResearchProject.objects.create(topic="Research", supervisor="Supervisor") query = """\ query { projects { __typename topicUpper ... on ArtProjectType { artistUpper artStyleUpper } } } """ with assert_num_queries(1): result = schema.execute_sync(query) assert not result.errors assert result.data == { "projects": [ { "__typename": "ArtProjectType", "topicUpper": ap.topic.upper(), "artistUpper": ap.artist.upper(), "artStyleUpper": ap.art_style.upper(), }, { "__typename": "ResearchProjectType", "topicUpper": rp.topic.upper(), }, ] } strawberry-graphql-django-0.82.1/tests/projects/000077500000000000000000000000001516173410200216755ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/projects/__init__.py000066400000000000000000000000001516173410200237740ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/projects/faker.py000066400000000000000000000044461516173410200233470ustar00rootroot00000000000000from typing import Any, ClassVar, Generic, TypeVar import factory from django.contrib.auth import get_user_model from django.contrib.auth.hashers import make_password from django.contrib.auth.models import Group from factory.declarations import Iterator, LazyFunction, Sequence, SubFactory from factory.faker import Faker from .models import Favorite, Issue, Milestone, Project, Quiz, Tag _T = TypeVar("_T") User = get_user_model() class _BaseFactory(factory.django.DjangoModelFactory, Generic[_T]): Meta: ClassVar[Any] @classmethod def create(cls, **kwargs) -> _T: return super().create(**kwargs) @classmethod def create_batch(cls, size: int, **kwargs) -> list[_T]: return super().create_batch(size, **kwargs) class GroupFactory(_BaseFactory[Group]): class Meta: model = Group name = Sequence(lambda n: f"Group {n}") class UserFactory(_BaseFactory["User"]): class Meta: model = User is_active = True first_name = Faker("first_name") last_name = Faker("last_name") username = Sequence(lambda n: f"user-{n}") email = Faker("email") password = LazyFunction(lambda: make_password("foobar")) class StaffUserFactory(UserFactory): is_staff = True class SuperuserUserFactory(UserFactory): is_superuser = True class ProjectFactory(_BaseFactory[Project]): class Meta: model = Project name = Sequence(lambda n: f"Project {n}") due_date = Faker("future_date") class MilestoneFactory(_BaseFactory[Milestone]): class Meta: model = Milestone name = Sequence(lambda n: f"Milestone {n}") due_date = Faker("future_date") project = SubFactory(ProjectFactory) class FavoriteFactory(_BaseFactory[Favorite]): class Meta: model = Favorite name = Sequence(lambda n: f"Favorite {n}") class IssueFactory(_BaseFactory[Issue]): class Meta: model = Issue name = Sequence(lambda n: f"Issue {n}") kind = Iterator(Issue.Kind) milestone = SubFactory(MilestoneFactory) priority = Faker("pyint", min_value=0, max_value=5) class TagFactory(_BaseFactory[Tag]): class Meta: model = Tag name = Sequence(lambda n: f"Tag {n}") class QuizFactory(_BaseFactory[Quiz]): class Meta: model = Quiz title = Sequence(lambda n: f"Quiz {n}") strawberry-graphql-django-0.82.1/tests/projects/models.py000066400000000000000000000155371516173410200235450ustar00rootroot00000000000000from typing import TYPE_CHECKING, Annotated, Any import strawberry from django.contrib.auth import get_user_model from django.db import models from django.db.models import Count, Prefetch, QuerySet from django.utils.translation import gettext_lazy as _ from django_choices_field import TextChoicesField from strawberry_django.descriptors import model_property from strawberry_django.optimizer import optimize from strawberry_django.utils.typing import UserType if TYPE_CHECKING: from django.db.models.manager import RelatedManager from .schema import MilestoneType User = get_user_model() class NamedModel(models.Model): class Meta: abstract = True name = models.CharField( max_length=255, ) class Project(NamedModel): class Status(models.TextChoices): """Project status options.""" ACTIVE = "active", "Active" INACTIVE = "inactive", "Inactive" milestones: "RelatedManager[Milestone]" id = models.BigAutoField( verbose_name="ID", primary_key=True, ) status = TextChoicesField( help_text=_("This project's status"), choices_enum=Status, default=Status.ACTIVE, ) due_date = models.DateField( null=True, blank=True, default=None, ) cost = models.DecimalField( max_digits=20, decimal_places=2, null=True, blank=True, default=None, ) @model_property(annotate={"_milestone_count": Count("milestone")}) def is_small(self) -> bool: return self._milestone_count < 3 # type: ignore @model_property( prefetch_related=lambda info: Prefetch( "milestones", queryset=optimize( Milestone.objects.all(), info, ), to_attr="custom_milestones_property", ) ) def custom_milestones_model_property( self, ) -> list[Annotated["MilestoneType", strawberry.lazy("tests.projects.schema")]]: return self.custom_milestones_property # type: ignore class Milestone(NamedModel): issues: "RelatedManager[Issue]" id = models.BigAutoField( verbose_name="ID", primary_key=True, ) due_date = models.DateField( null=True, blank=True, default=None, ) project_id: int project = models.ForeignKey[Project]( Project, on_delete=models.CASCADE, related_name="milestones", related_query_name="milestone", ) class FavoriteQuerySet(QuerySet): def by_user(self, user: UserType): if user.is_anonymous: return self.none() return self.filter(user__pk=user.pk) class Favorite(models.Model): """A user's favorite issues.""" class Meta: # Needed to allow type's get_queryset() to access a model's custom QuerySet ordering = ("name",) base_manager_name = "objects" id = models.BigAutoField( verbose_name="ID", primary_key=True, ) name = models.CharField(max_length=32) user = models.ForeignKey( User, related_name="favorite_set", on_delete=models.CASCADE, ) issue = models.ForeignKey( "Issue", related_name="favorite_set", on_delete=models.CASCADE, ) objects = FavoriteQuerySet.as_manager() class Issue(NamedModel): class Meta: # type: ignore ordering = ("id",) class Kind(models.TextChoices): """Issue kind options.""" BUG = "b", "Bug" FEATURE = "f", "Feature" comments: "RelatedManager[Issue]" issue_assignees: "RelatedManager[Assignee]" id = models.BigAutoField( verbose_name="ID", primary_key=True, ) kind = models.CharField( verbose_name="kind", help_text="the kind of the issue", choices=Kind.choices, max_length=max(len(k.value) for k in Kind), default=None, blank=True, null=True, ) priority = models.IntegerField( default=0, ) milestone_id: int | None milestone = models.ForeignKey( Milestone, on_delete=models.SET_NULL, related_name="issues", related_query_name="issue", null=True, blank=True, default=None, ) tags = models.ManyToManyField["Tag", Any]( "Tag", related_name="issues", related_query_name="issue", ) assignees = models.ManyToManyField["User", "Assignee"]( User, through="Assignee", related_name="+", ) @property def name_with_kind(self) -> str: return f"{self.kind}: {self.name}" @model_property(only=["kind", "priority"]) def name_with_priority(self) -> str: """Field doc.""" return f"{self.kind}: {self.priority}" class Assignee(models.Model): class Meta: unique_together = [ # noqa: RUF012 ("issue", "user"), ] issues: "RelatedManager[Issue]" id = models.BigAutoField( verbose_name="ID", primary_key=True, ) issue_id: int issue = models.ForeignKey[Issue]( Issue, on_delete=models.CASCADE, related_name="issue_assignees", related_query_name="issue_assignee", ) user_id: int user = models.ForeignKey["User"]( User, on_delete=models.CASCADE, related_name="issue_assignees", related_query_name="issue_assignee", ) owner = models.BooleanField( default=False, ) class Tag(NamedModel): class Meta: # type: ignore ordering = ("id",) issues: "RelatedManager[Issue]" id = models.BigAutoField( verbose_name="ID", primary_key=True, ) class Quiz(models.Model): title = models.CharField( "title", max_length=255, ) sequence = models.PositiveIntegerField( "sequence", default=1, unique=True, ) def save(self, *args, **kwargs): if self._state.adding: max_ = self.__class__.objects.aggregate(max=models.Max("sequence"))["max"] if max_ is not None: self.sequence = max_ + 1 super().save(*args, **kwargs) class Role(NamedModel): """Role model for testing field_name traversal.""" id = models.BigAutoField( verbose_name="ID", primary_key=True, ) description = models.TextField(blank=True, default="") class UserAssignedRole(models.Model): """Intermediate model for testing field_name traversal with OneToOne.""" id = models.BigAutoField( verbose_name="ID", primary_key=True, ) role = models.ForeignKey( Role, on_delete=models.CASCADE, related_name="user_assignments", ) user = models.OneToOneField( User, related_name="assigned_role", on_delete=models.CASCADE, ) assigned_at = models.DateTimeField(auto_now_add=True) strawberry-graphql-django-0.82.1/tests/projects/schema.py000066400000000000000000000523641516173410200235210ustar00rootroot00000000000000import asyncio import datetime import decimal from collections.abc import Iterable from typing import Annotated, Optional, cast import strawberry from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser from django.core.exceptions import ValidationError from django.db.models import ( BooleanField, Count, Exists, ExpressionWrapper, OuterRef, Prefetch, Q, Subquery, Value, ) from django.db.models.fields import CharField from django.db.models.functions import Now from django.db.models.query import QuerySet from strawberry import UNSET, relay from strawberry.types.info import Info import strawberry_django from strawberry_django import mutations from strawberry_django.auth.queries import get_current_user from strawberry_django.fields.types import ListInput, NodeInput, NodeInputPartial from strawberry_django.mutations import resolvers from strawberry_django.optimizer import ( DjangoOptimizerExtension, optimize, ) from strawberry_django.pagination import OffsetPaginated from strawberry_django.permissions import ( HasPerm, HasRetvalPerm, IsAuthenticated, IsStaff, IsSuperuser, filter_for_user, ) from strawberry_django.relay import DjangoListConnection from .models import ( Assignee, Favorite, FavoriteQuerySet, Issue, Milestone, NamedModel, Project, Quiz, Role, Tag, UserAssignedRole, ) UserModel = get_user_model() @strawberry_django.interface(NamedModel) class Named: name: strawberry.auto @strawberry_django.type(UserModel) class UserType(relay.Node): username: relay.NodeID[str] email: strawberry.auto is_active: strawberry.auto is_superuser: strawberry.auto is_staff: strawberry.auto @strawberry_django.field(only=["first_name", "last_name"]) def full_name(self, root: AbstractUser) -> str: return f"{root.first_name or ''} {root.last_name or ''}".strip() # Test field_name with double-underscore traversal role: Optional["RoleType"] = strawberry_django.field( field_name="assigned_role__role", ) # Test field_name traversal to scalar field role_name: str | None = strawberry_django.field( field_name="assigned_role__role__name", ) # Test field_name traversal to another scalar role_description: str | None = strawberry_django.field( field_name="assigned_role__role__description", ) @strawberry_django.type(UserModel) class StaffType(relay.Node): username: relay.NodeID[str] email: strawberry.auto is_active: strawberry.auto is_superuser: strawberry.auto is_staff: strawberry.auto @classmethod def get_queryset( cls, queryset: QuerySet[AbstractUser], info: Info, **kwargs, ) -> QuerySet[AbstractUser]: return queryset.filter(is_staff=True) @strawberry_django.filter_type(Project, lookups=True) class ProjectFilter: name: strawberry.auto due_date: strawberry.auto @strawberry_django.type(Project, filters=ProjectFilter, pagination=True) class ProjectType(relay.Node, Named): due_date: strawberry.auto is_small: strawberry.auto is_delayed: bool = strawberry_django.field( annotate=ExpressionWrapper( Q(due_date__lt=Now()), output_field=BooleanField(), ), ) cost: strawberry.auto = strawberry_django.field(extensions=[IsAuthenticated()]) milestones: list["MilestoneType"] = strawberry_django.field(pagination=True) milestones_count: int = strawberry_django.field(annotate=Count("milestone")) custom_milestones_model_property: strawberry.auto first_milestone: Optional["MilestoneType"] = strawberry_django.field( field_name="milestones" ) first_milestone_required: "MilestoneType" = strawberry_django.field( field_name="milestones" ) milestone_conn: DjangoListConnection["MilestoneType"] = ( strawberry_django.connection(field_name="milestones") ) milestones_paginated: OffsetPaginated["MilestoneType"] = ( strawberry_django.offset_paginated(field_name="milestones") ) @strawberry_django.field( prefetch_related=lambda info: Prefetch( "milestones", queryset=optimize( Milestone.objects.all(), info, ), to_attr="custom_milestones", ) ) @staticmethod def custom_milestones( parent: strawberry.Parent, info: Info ) -> list["MilestoneType"]: return parent.custom_milestones @strawberry_django.filter_type(Milestone, lookups=True) class MilestoneFilter: name: strawberry.auto project: strawberry.auto search: str | None def filter_search(self, queryset: QuerySet[Milestone]): return queryset.filter(name__contains=self.search) @strawberry_django.order(Project) class ProjectOrder: id: strawberry.auto name: strawberry.auto @strawberry_django.order(Milestone) class MilestoneOrder: name: strawberry.auto project: ProjectOrder | None @strawberry_django.filter_type(Issue, lookups=True) class IssueFilter: name: strawberry.auto @strawberry_django.filter_field() def search(self, value: str, prefix: str) -> Q: return Q(name__contains=value) @strawberry_django.order(Issue) class IssueOrder: name: strawberry.auto @strawberry_django.type( Milestone, filters=MilestoneFilter, order=MilestoneOrder, pagination=True ) class MilestoneType(relay.Node, Named): due_date: strawberry.auto project: ProjectType issues: list["IssueType"] = strawberry_django.field( filters=IssueFilter, order=IssueOrder, pagination=True, ) first_issue: Optional["IssueType"] = strawberry_django.field(field_name="issues") first_issue_required: "IssueType" = strawberry_django.field(field_name="issues") graphql_path: str = strawberry_django.field( annotate=lambda info: Value( ",".join(map(str, info.path.as_list())), output_field=CharField(max_length=255), ) ) mixed_annotated_prefetch: str = strawberry_django.field( annotate=lambda info: Value("dummy", output_field=CharField(max_length=255)), prefetch_related="issues", ) mixed_prefetch_annotated: str = strawberry_django.field( annotate=Value("dummy", output_field=CharField(max_length=255)), prefetch_related=lambda info: Prefetch("issues"), ) issues_paginated: OffsetPaginated["IssueType"] = strawberry_django.offset_paginated( field_name="issues", order=IssueOrder, ) issues_with_filters: DjangoListConnection["IssueType"] = ( strawberry_django.connection( field_name="issues", filters=IssueFilter, ) ) @strawberry_django.field( prefetch_related=[ lambda info: Prefetch( "issues", queryset=Issue.objects.filter( Exists( Assignee.objects.filter( issue=OuterRef("pk"), user_id=info.context.request.user.id, ), ), ), to_attr="_my_issues", ), ], ) def my_issues(self) -> list["IssueType"]: return self._my_issues # type: ignore @strawberry_django.field( annotate={ "_my_bugs_count": lambda info: Count( "issue", filter=Q( issue__issue_assignee__user_id=info.context.request.user.id, issue__kind=Issue.Kind.BUG, ), ), }, ) def my_bugs_count(self, root: Milestone) -> int: return root._my_bugs_count # type: ignore @strawberry_django.field async def async_field(self, value: str) -> str: await asyncio.sleep(0) return f"value: {value}" @strawberry_django.type(Favorite) class FavoriteType(relay.Node): name: strawberry.auto user: UserType issue: "IssueType" @classmethod def get_queryset(cls, queryset: FavoriteQuerySet, info: Info, **kwargs) -> QuerySet: return queryset.by_user(info.context.request.user) @strawberry_django.type(Issue) class IssueType(relay.Node, Named): milestone: MilestoneType priority: strawberry.auto kind: strawberry.auto name_with_priority: strawberry.auto name_with_kind: str = strawberry_django.field(only=["kind", "name"]) tags: list["TagType"] issue_assignees: list["AssigneeType"] staff_assignees: list["StaffType"] = strawberry_django.field(field_name="assignees") favorite_set: DjangoListConnection["FavoriteType"] = strawberry_django.connection() @strawberry_django.field(select_related="milestone", only="milestone__name") def milestone_name(self) -> str: return self.milestone.name @strawberry_django.field(select_related="milestone") def milestone_name_without_only_optimization(self) -> str: return self.milestone.name @strawberry_django.field( annotate={ "_private_name": lambda info: Subquery( filter_for_user( Issue.objects.all(), info.context.request.user, ["projects.view_issue"], ) .filter(id=OuterRef("pk")) .values("name")[:1], ), }, ) def private_name(self, root: Issue) -> str | None: return root._private_name # type: ignore @strawberry_django.type(Tag) class TagType(relay.Node, Named): issues: DjangoListConnection[IssueType] = strawberry_django.connection() @strawberry_django.field def issues_with_selected_related_milestone_and_project(self) -> list[IssueType]: # here, the `select_related` is on the queryset directly, and not on the field return ( self.issues .all() # type: ignore .select_related("milestone", "milestone__project") .order_by("id") ) @strawberry_django.type(Quiz) class QuizType(relay.Node): title: strawberry.auto sequence: strawberry.auto @classmethod def get_queryset( cls, queryset: QuerySet[Quiz], info: Info, **kwargs, ) -> QuerySet[Quiz]: return queryset.order_by("title") @strawberry_django.type(Role) class RoleType(relay.Node, Named): """Role type for testing field_name traversal.""" description: strawberry.auto @strawberry_django.type(UserAssignedRole) class UserAssignedRoleType(relay.Node): """UserAssignedRole type for testing field_name traversal.""" role: RoleType user: UserType @strawberry_django.partial(Tag) class TagInputPartial(NodeInputPartial): name: strawberry.auto @strawberry_django.input(Issue) class IssueInput: name: strawberry.auto milestone: "MilestoneInputPartial" priority: strawberry.auto kind: strawberry.auto tags: list[NodeInput] | None extra: str | None = strawberry.field(default=UNSET, graphql_type=int | None) @strawberry_django.type(Assignee) class AssigneeType(relay.Node): user: UserType owner: strawberry.auto @strawberry_django.partial(Assignee) class IssueAssigneeInputPartial(NodeInputPartial): user: NodeInputPartial | None owner: strawberry.auto @strawberry.input class AssigneeThroughInputPartial: owner: bool | None = strawberry.UNSET @strawberry_django.partial(UserModel) class AssigneeInputPartial(NodeInputPartial): through_defaults: AssigneeThroughInputPartial | None = strawberry.UNSET @strawberry_django.partial(Issue) class IssueInputPartial(NodeInput, IssueInput): tags: ListInput[TagInputPartial] | None = UNSET # type: ignore assignees: ListInput[AssigneeInputPartial] | None = UNSET issue_assignees: ListInput[IssueAssigneeInputPartial] | None = UNSET @strawberry_django.partial(Issue) class IssueInputPartialWithoutId(IssueInput): tags: ListInput[TagInputPartial] | None = UNSET # type: ignore assignees: ListInput[AssigneeInputPartial] | None = UNSET issue_assignees: ListInput[IssueAssigneeInputPartial] | None = UNSET @strawberry_django.input(Issue) class MilestoneIssueInput: name: strawberry.auto @strawberry_django.partial(Issue) class MilestoneIssueInputPartial: name: strawberry.auto tags: list[TagInputPartial] | None @strawberry_django.partial(Project) class ProjectInputPartial(NodeInputPartial): name: strawberry.auto milestones: list["MilestoneInputPartial"] | None @strawberry_django.input(Milestone) class MilestoneInput: name: strawberry.auto project: ProjectInputPartial issues: list[MilestoneIssueInput] | None @strawberry_django.partial(Milestone) class MilestoneInputPartial(NodeInputPartial): name: strawberry.auto issues: list[MilestoneIssueInputPartial] | None project: ProjectInputPartial | None @strawberry.type class ProjectConnection(DjangoListConnection[ProjectType]): """Project connection documentation.""" @strawberry.type class Query: """All available queries for this schema.""" node: relay.Node | None = strawberry_django.node() favorite: FavoriteType | None = strawberry_django.node() issue: IssueType | None = strawberry_django.node(description="Foobar") milestone: ( Annotated["MilestoneType", strawberry.lazy("tests.projects.schema")] | None ) = strawberry_django.node() milestone_mandatory: MilestoneType = strawberry_django.node() milestones: list[MilestoneType] = strawberry_django.node() project: ProjectType | None = strawberry_django.node() project_mandatory: ProjectType = strawberry_django.node() project_login_required: ProjectType | None = strawberry_django.node( extensions=[IsAuthenticated()], ) tag: TagType | None = strawberry_django.node() staff: StaffType | None = strawberry_django.node() staff_list: list[StaffType | None] = strawberry_django.node() user_list: list[UserType] = strawberry_django.field() issue_list: list[IssueType] = strawberry_django.field() issues_paginated: OffsetPaginated[IssueType] = strawberry_django.offset_paginated() milestone_list: list[MilestoneType] = strawberry_django.field( order=MilestoneOrder, filters=MilestoneFilter, pagination=True, ) project_list: list[ProjectType] = strawberry_django.field() projects_paginated: OffsetPaginated[ProjectType] = ( strawberry_django.offset_paginated() ) tag_list: list[TagType] = strawberry_django.field() favorite_conn: DjangoListConnection[FavoriteType] = strawberry_django.connection() issue_conn: DjangoListConnection[ Annotated["IssueType", strawberry.lazy("tests.projects.schema")] ] = strawberry_django.connection() milestone_conn: DjangoListConnection[MilestoneType] = strawberry_django.connection() project_conn: ProjectConnection = strawberry_django.connection() tag_conn: DjangoListConnection[TagType] = strawberry_django.connection() staff_conn: DjangoListConnection[StaffType] = strawberry_django.connection() quiz_list: list[QuizType] = strawberry_django.field() # Login required to resolve issue_login_required: IssueType = strawberry_django.node( extensions=[IsAuthenticated()], ) issue_login_required_optional: IssueType | None = strawberry_django.node( extensions=[IsAuthenticated()], ) # Staff required to resolve issue_staff_required: IssueType = strawberry_django.node(extensions=[IsStaff()]) issue_staff_required_optional: IssueType | None = strawberry_django.node( extensions=[IsStaff()], ) # Superuser required to resolve issue_superuser_required: IssueType = strawberry_django.node( extensions=[IsSuperuser()], ) issue_superuser_required_optional: IssueType | None = strawberry_django.node( extensions=[IsSuperuser()], ) # User permission on "projects.view_issue" to resolve issue_perm_required: IssueType = strawberry_django.node( extensions=[HasPerm(perms=["projects.view_issue"])], ) issue_perm_required_optional: IssueType | None = strawberry_django.node( extensions=[HasPerm(perms=["projects.view_issue"])], ) issue_list_perm_required: list[IssueType] = strawberry_django.field( extensions=[HasPerm(perms=["projects.view_issue"])], ) issues_paginated_perm_required: OffsetPaginated[IssueType] = ( strawberry_django.offset_paginated( extensions=[HasPerm(perms=["projects.view_issue"])], ) ) issue_conn_perm_required: DjangoListConnection[IssueType] = ( strawberry_django.connection( extensions=[HasPerm(perms=["projects.view_issue"])], ) ) # User permission on the resolved object for "projects.view_issue" issue_obj_perm_required: IssueType = strawberry_django.node( extensions=[HasRetvalPerm(perms=["projects.view_issue"])], ) issue_obj_perm_required_optional: IssueType | None = strawberry_django.node( extensions=[HasRetvalPerm(perms=["projects.view_issue"])], ) issue_list_obj_perm_required: list[IssueType] = strawberry_django.field( extensions=[HasRetvalPerm(perms=["projects.view_issue"])], ) issue_list_obj_perm_required_paginated: list[IssueType] = strawberry_django.field( extensions=[HasRetvalPerm(perms=["projects.view_issue"])], pagination=True ) issues_paginated_obj_perm_required: OffsetPaginated[IssueType] = ( strawberry_django.offset_paginated( extensions=[HasRetvalPerm(perms=["projects.view_issue"])], ) ) issue_conn_obj_perm_required: DjangoListConnection[IssueType] = ( strawberry_django.connection( extensions=[HasRetvalPerm(perms=["projects.view_issue"])], ) ) @strawberry_django.field( extensions=[HasPerm(perms=["projects.view_issue"], with_superuser=True)] ) async def async_user_resolve(self) -> bool: return True @strawberry_django.field def me(self, info: Info) -> UserType | None: user = get_current_user(info, strict=True) if not user.is_authenticated: return None return cast("UserType", user) @strawberry_django.connection(ProjectConnection) def project_conn_with_resolver(self, root: str, name: str) -> Iterable[Project]: return Project.objects.filter(name__contains=name) @strawberry.type class Mutation: """All available mutations for this schema.""" create_issue: IssueType = mutations.create( IssueInput, handle_django_errors=True, argument_name="input", ) update_issue: IssueType = mutations.update( IssueInputPartial, handle_django_errors=True, argument_name="input", ) update_issue_with_key_attr: IssueType = mutations.update( IssueInputPartialWithoutId, handle_django_errors=True, argument_name="input", key_attr="name", ) delete_issue: IssueType = mutations.delete( NodeInput, handle_django_errors=True, argument_name="input", ) delete_issue_with_key_attr: IssueType = mutations.delete( MilestoneIssueInput, handle_django_errors=True, argument_name="input", key_attr="name", ) create_project_with_milestones: ProjectType = mutations.create( ProjectInputPartial, handle_django_errors=True, argument_name="input", ) update_project: ProjectType = mutations.update( ProjectInputPartial, handle_django_errors=True, argument_name="input", ) create_milestone: MilestoneType = mutations.create( MilestoneInput, handle_django_errors=True, argument_name="input", ) @mutations.input_mutation(handle_django_errors=True) def create_project( self, info: Info, name: str, cost: Annotated[ decimal.Decimal, strawberry.argument(description="The project's cost"), ], due_date: datetime.datetime | None = None, ) -> ProjectType: """Create project documentation.""" if cost > 500: # Field error without error code: raise ValidationError({"cost": ["Cost cannot be higher than 500"]}) if cost < 0: # Field error with error code: raise ValidationError( { "cost": ValidationError( "Cost cannot be lower than zero", code="min_cost", ), }, ) project = Project( name=name, cost=cost, due_date=due_date, ) project.full_clean() project.save() return cast( "ProjectType", project, ) @mutations.input_mutation(handle_django_errors=True) def create_quiz( self, info: Info, title: str, full_clean_options: bool = False, ) -> QuizType: return cast( "QuizType", resolvers.create( info, Quiz, {"title": title}, full_clean={"exclude": ["sequence"]} if full_clean_options else True, key_attr="id", ), ) schema = strawberry.Schema( query=Query, mutation=Mutation, extensions=[ DjangoOptimizerExtension, ], ) strawberry-graphql-django-0.82.1/tests/projects/snapshots/000077500000000000000000000000001516173410200237175ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/projects/snapshots/schema.gql000066400000000000000000000656711516173410200257030ustar00rootroot00000000000000""" Can only be resolved by authenticated users. When the condition fails, the following can be returned (following this priority): 1) `OperationInfo`/`OperationMessage` if those types are allowed at the return type 2) `null` in case the field is not mandatory (e.g. `String` or `[String]`) 3) An empty list in case the field is a list (e.g. `[String]!`) 4) An empty `Connection` in case the return type is a relay connection 2) Otherwise, an error will be raised """ directive @isAuthenticated repeatable on FIELD_DEFINITION """ Can only be resolved by staff users. When the condition fails, the following can be returned (following this priority): 1) `OperationInfo`/`OperationMessage` if those types are allowed at the return type 2) `null` in case the field is not mandatory (e.g. `String` or `[String]`) 3) An empty list in case the field is a list (e.g. `[String]!`) 4) An empty `Connection` in case the return type is a relay connection 2) Otherwise, an error will be raised """ directive @isStaff repeatable on FIELD_DEFINITION """ Can only be resolved by superuser users. When the condition fails, the following can be returned (following this priority): 1) `OperationInfo`/`OperationMessage` if those types are allowed at the return type 2) `null` in case the field is not mandatory (e.g. `String` or `[String]`) 3) An empty list in case the field is a list (e.g. `[String]!`) 4) An empty `Connection` in case the return type is a relay connection 2) Otherwise, an error will be raised """ directive @isSuperuser repeatable on FIELD_DEFINITION """ Will check if the user has any/all permissions for the resolved value of this field before returning it. When the condition fails, the following can be returned (following this priority): 1) `OperationInfo`/`OperationMessage` if those types are allowed at the return type 2) `null` in case the field is not mandatory (e.g. `String` or `[String]`) 3) An empty list in case the field is a list (e.g. `[String]!`) 4) An empty `Connection` in case the return type is a relay connection 2) Otherwise, an error will be raised """ directive @hasRetvalPerm(permissions: [PermDefinition!]!, any: Boolean! = true) repeatable on FIELD_DEFINITION """ Will check if the user has any/all permissions to resolve this. When the condition fails, the following can be returned (following this priority): 1) `OperationInfo`/`OperationMessage` if those types are allowed at the return type 2) `null` in case the field is not mandatory (e.g. `String` or `[String]`) 3) An empty list in case the field is a list (e.g. `[String]!`) 4) An empty `Connection` in case the return type is a relay connection 2) Otherwise, an error will be raised """ directive @hasPerm(permissions: [PermDefinition!]!, any: Boolean! = true) repeatable on FIELD_DEFINITION input AssigneeInputPartial { id: ID throughDefaults: AssigneeThroughInputPartial } """Add/remove/set the selected nodes.""" input AssigneeInputPartialListInput { set: [AssigneeInputPartial!] add: [AssigneeInputPartial!] remove: [AssigneeInputPartial!] } input AssigneeThroughInputPartial { owner: Boolean } type AssigneeType implements Node { """The Globally Unique ID of this object""" id: ID! user: UserType! owner: Boolean! } union CreateIssuePayload = IssueType | OperationInfo union CreateMilestonePayload = MilestoneType | OperationInfo input CreateProjectInput { name: String! """The project's cost""" cost: Decimal! dueDate: DateTime = null } union CreateProjectPayload = ProjectType | OperationInfo union CreateProjectWithMilestonesPayload = ProjectType | OperationInfo input CreateQuizInput { title: String! fullCleanOptions: Boolean! = false } union CreateQuizPayload = QuizType | OperationInfo """Date (isoformat)""" scalar Date input DateDateFilterLookup { """Exact match. Filter will be skipped on `null` value""" exact: Date """Assignment test. Filter will be skipped on `null` value""" isNull: Boolean """ Exact match of items in a given list. Filter will be skipped on `null` value """ inList: [Date!] """Greater than. Filter will be skipped on `null` value""" gt: Date """Greater than or equal to. Filter will be skipped on `null` value""" gte: Date """Less than. Filter will be skipped on `null` value""" lt: Date """Less than or equal to. Filter will be skipped on `null` value""" lte: Date """Inclusive range test (between)""" range: DateRangeLookup year: IntComparisonFilterLookup month: IntComparisonFilterLookup day: IntComparisonFilterLookup weekDay: IntComparisonFilterLookup isoWeekDay: IntComparisonFilterLookup week: IntComparisonFilterLookup isoYear: IntComparisonFilterLookup quarter: IntComparisonFilterLookup } input DateRangeLookup { start: Date = null end: Date = null } """Date with time (isoformat)""" scalar DateTime """Decimal (fixed-point)""" scalar Decimal union DeleteIssuePayload = IssueType | OperationInfo union DeleteIssueWithKeyAttrPayload = IssueType | OperationInfo input DjangoModelFilterInput { pk: ID! } type FavoriteType implements Node { """The Globally Unique ID of this object""" id: ID! name: String! user: UserType! issue: IssueType! } """A connection to a list of items.""" type FavoriteTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" edges: [FavoriteTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" type FavoriteTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" node: FavoriteType! } input IntComparisonFilterLookup { """Exact match. Filter will be skipped on `null` value""" exact: Int """Assignment test. Filter will be skipped on `null` value""" isNull: Boolean """ Exact match of items in a given list. Filter will be skipped on `null` value """ inList: [Int!] """Greater than. Filter will be skipped on `null` value""" gt: Int """Greater than or equal to. Filter will be skipped on `null` value""" gte: Int """Less than. Filter will be skipped on `null` value""" lt: Int """Less than or equal to. Filter will be skipped on `null` value""" lte: Int """Inclusive range test (between)""" range: IntRangeLookup } input IntRangeLookup { start: Int = null end: Int = null } input IssueAssigneeInputPartial { id: ID user: NodeInputPartial owner: Boolean } """Add/remove/set the selected nodes.""" input IssueAssigneeInputPartialListInput { set: [IssueAssigneeInputPartial!] add: [IssueAssigneeInputPartial!] remove: [IssueAssigneeInputPartial!] } input IssueFilter { name: StrFilterLookup AND: IssueFilter OR: IssueFilter NOT: IssueFilter DISTINCT: Boolean search: String } input IssueInput { name: String! milestone: MilestoneInputPartial! priority: Int kind: String tags: [NodeInput!] extra: Int } input IssueInputPartial { id: ID! name: String milestone: MilestoneInputPartial! priority: Int kind: String tags: TagInputPartialListInput extra: Int assignees: AssigneeInputPartialListInput issueAssignees: IssueAssigneeInputPartialListInput } input IssueInputPartialWithoutId { name: String milestone: MilestoneInputPartial! priority: Int kind: String tags: TagInputPartialListInput extra: Int assignees: AssigneeInputPartialListInput issueAssignees: IssueAssigneeInputPartialListInput } input IssueOrder { name: Ordering } type IssueType implements Node & Named { """The Globally Unique ID of this object""" id: ID! name: String! milestone: MilestoneType! priority: Int! kind: String nameWithPriority: String! nameWithKind: String! tags: [TagType!]! issueAssignees: [AssigneeType!]! staffAssignees: [StaffType!]! favoriteSet( """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 ): FavoriteTypeConnection! milestoneName: String! milestoneNameWithoutOnlyOptimization: String! privateName: String } """A connection to a list of items.""" type IssueTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" edges: [IssueTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" type IssueTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" node: IssueType! } type IssueTypeOffsetPaginated { pageInfo: OffsetPaginationInfo! """Total count of existing results.""" totalCount: Int! """List of paginated results.""" results: [IssueType!]! } input MilestoneFilter { name: StrFilterLookup project: DjangoModelFilterInput search: String AND: MilestoneFilter OR: MilestoneFilter NOT: MilestoneFilter DISTINCT: Boolean } input MilestoneInput { name: String! project: ProjectInputPartial! issues: [MilestoneIssueInput!] } input MilestoneInputPartial { id: ID name: String issues: [MilestoneIssueInputPartial!] project: ProjectInputPartial } input MilestoneIssueInput { name: String! } input MilestoneIssueInputPartial { name: String tags: [TagInputPartial!] } input MilestoneOrder { name: Ordering project: ProjectOrder } type MilestoneType implements Node & Named { """The Globally Unique ID of this object""" id: ID! name: String! dueDate: Date project: ProjectType! issues(filters: IssueFilter, order: IssueOrder, pagination: OffsetPaginationInput): [IssueType!]! firstIssue: IssueType firstIssueRequired: IssueType! graphqlPath: String! mixedAnnotatedPrefetch: String! mixedPrefetchAnnotated: String! issuesPaginated(pagination: OffsetPaginationInput, order: IssueOrder): IssueTypeOffsetPaginated! issuesWithFilters( filters: IssueFilter """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 ): IssueTypeConnection! myIssues: [IssueType!]! myBugsCount: Int! asyncField(value: String!): String! } """A connection to a list of items.""" type MilestoneTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" edges: [MilestoneTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" type MilestoneTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" node: MilestoneType! } type MilestoneTypeOffsetPaginated { pageInfo: OffsetPaginationInfo! """Total count of existing results.""" totalCount: Int! """List of paginated results.""" results: [MilestoneType!]! } type Mutation { createIssue(input: IssueInput!): CreateIssuePayload! updateIssue(input: IssueInputPartial!): UpdateIssuePayload! updateIssueWithKeyAttr(input: IssueInputPartialWithoutId!): UpdateIssueWithKeyAttrPayload! deleteIssue(input: NodeInput!): DeleteIssuePayload! deleteIssueWithKeyAttr(input: MilestoneIssueInput!): DeleteIssueWithKeyAttrPayload! createProjectWithMilestones(input: ProjectInputPartial!): CreateProjectWithMilestonesPayload! updateProject(input: ProjectInputPartial!): UpdateProjectPayload! createMilestone(input: MilestoneInput!): CreateMilestonePayload! createProject( """Input data for `createProject` mutation""" input: CreateProjectInput! ): CreateProjectPayload! createQuiz( """Input data for `createQuiz` mutation""" input: CreateQuizInput! ): CreateQuizPayload! } interface Named { name: String! } """An object with a Globally Unique ID""" interface Node { """The Globally Unique ID of this object""" id: ID! } """Input of an object that implements the `Node` interface.""" input NodeInput { id: ID! } """Input of an object that implements the `Node` interface.""" input NodeInputPartial { id: ID } type OffsetPaginationInfo { offset: Int! limit: Int } input OffsetPaginationInput { offset: Int! = 0 limit: Int } type OperationInfo { """List of messages returned by the operation.""" messages: [OperationMessage!]! } type OperationMessage { """The kind of this message.""" kind: OperationMessageKind! """The error message.""" message: String! """ The field that caused the error, or `null` if it isn't associated with any particular field. """ field: String """The error code, or `null` if no error code was set.""" code: String } enum OperationMessageKind { INFO WARNING ERROR PERMISSION VALIDATION } enum Ordering { ASC ASC_NULLS_FIRST ASC_NULLS_LAST DESC DESC_NULLS_FIRST DESC_NULLS_LAST } """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 ProjectConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" edges: [ProjectTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } input ProjectFilter { name: StrFilterLookup dueDate: DateDateFilterLookup AND: ProjectFilter OR: ProjectFilter NOT: ProjectFilter DISTINCT: Boolean } input ProjectInputPartial { id: ID name: String milestones: [MilestoneInputPartial!] } input ProjectOrder { id: Ordering name: Ordering } type ProjectType implements Node & Named { """The Globally Unique ID of this object""" id: ID! name: String! dueDate: Date isSmall: Boolean! isDelayed: Boolean! cost: Decimal @isAuthenticated milestones(filters: MilestoneFilter, order: MilestoneOrder, pagination: OffsetPaginationInput): [MilestoneType!]! milestonesCount: Int! customMilestonesModelProperty: [MilestoneType!]! firstMilestone: MilestoneType firstMilestoneRequired: MilestoneType! milestoneConn( filters: MilestoneFilter order: MilestoneOrder """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 ): MilestoneTypeConnection! milestonesPaginated(pagination: OffsetPaginationInput, filters: MilestoneFilter, order: MilestoneOrder): MilestoneTypeOffsetPaginated! customMilestones: [MilestoneType!]! } """An edge in a connection.""" type ProjectTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" node: ProjectType! } type ProjectTypeOffsetPaginated { pageInfo: OffsetPaginationInfo! """Total count of existing results.""" totalCount: Int! """List of paginated results.""" results: [ProjectType!]! } type Query { node( """The ID of the object.""" id: ID! ): Node favorite( """The ID of the object.""" id: ID! ): FavoriteType """Foobar""" issue( """The ID of the object.""" id: ID! ): IssueType milestone( """The ID of the object.""" id: ID! ): MilestoneType milestoneMandatory( """The ID of the object.""" id: ID! ): MilestoneType! milestones( """The IDs of the objects.""" ids: [ID!]! ): [MilestoneType!]! project( """The ID of the object.""" id: ID! ): ProjectType projectMandatory( """The ID of the object.""" id: ID! ): ProjectType! projectLoginRequired( """The ID of the object.""" id: ID! ): ProjectType @isAuthenticated tag( """The ID of the object.""" id: ID! ): TagType staff( """The ID of the object.""" id: ID! ): StaffType staffList( """The IDs of the objects.""" ids: [ID!]! ): [StaffType]! userList: [UserType!]! issueList: [IssueType!]! issuesPaginated(pagination: OffsetPaginationInput): IssueTypeOffsetPaginated! milestoneList(filters: MilestoneFilter, order: MilestoneOrder, pagination: OffsetPaginationInput): [MilestoneType!]! projectList(filters: ProjectFilter): [ProjectType!]! projectsPaginated(pagination: OffsetPaginationInput, filters: ProjectFilter): ProjectTypeOffsetPaginated! tagList: [TagType!]! favoriteConn( """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 ): FavoriteTypeConnection! issueConn( """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 ): IssueTypeConnection! milestoneConn( filters: MilestoneFilter order: MilestoneOrder """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 ): MilestoneTypeConnection! projectConn( filters: ProjectFilter """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 ): ProjectConnection! tagConn( """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 ): TagTypeConnection! staffConn( """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 ): StaffTypeConnection! quizList: [QuizType!]! issueLoginRequired( """The ID of the object.""" id: ID! ): IssueType! @isAuthenticated issueLoginRequiredOptional( """The ID of the object.""" id: ID! ): IssueType @isAuthenticated issueStaffRequired( """The ID of the object.""" id: ID! ): IssueType! @isStaff issueStaffRequiredOptional( """The ID of the object.""" id: ID! ): IssueType @isStaff issueSuperuserRequired( """The ID of the object.""" id: ID! ): IssueType! @isSuperuser issueSuperuserRequiredOptional( """The ID of the object.""" id: ID! ): IssueType @isSuperuser issuePermRequired( """The ID of the object.""" id: ID! ): IssueType! @hasPerm(permissions: [{app: "projects", permission: "view_issue"}], any: true) issuePermRequiredOptional( """The ID of the object.""" id: ID! ): IssueType @hasPerm(permissions: [{app: "projects", permission: "view_issue"}], any: true) issueListPermRequired: [IssueType!]! @hasPerm(permissions: [{app: "projects", permission: "view_issue"}], any: true) issuesPaginatedPermRequired(pagination: OffsetPaginationInput): IssueTypeOffsetPaginated! @hasPerm(permissions: [{app: "projects", permission: "view_issue"}], any: true) issueConnPermRequired( """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 ): IssueTypeConnection! @hasPerm(permissions: [{app: "projects", permission: "view_issue"}], any: true) issueObjPermRequired( """The ID of the object.""" id: ID! ): IssueType! @hasRetvalPerm(permissions: [{app: "projects", permission: "view_issue"}], any: true) issueObjPermRequiredOptional( """The ID of the object.""" id: ID! ): IssueType @hasRetvalPerm(permissions: [{app: "projects", permission: "view_issue"}], any: true) issueListObjPermRequired: [IssueType!]! @hasRetvalPerm(permissions: [{app: "projects", permission: "view_issue"}], any: true) issueListObjPermRequiredPaginated(pagination: OffsetPaginationInput): [IssueType!]! @hasRetvalPerm(permissions: [{app: "projects", permission: "view_issue"}], any: true) issuesPaginatedObjPermRequired(pagination: OffsetPaginationInput): IssueTypeOffsetPaginated! @hasRetvalPerm(permissions: [{app: "projects", permission: "view_issue"}], any: true) issueConnObjPermRequired( """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 ): IssueTypeConnection! @hasRetvalPerm(permissions: [{app: "projects", permission: "view_issue"}], any: true) asyncUserResolve: Boolean! @hasPerm(permissions: [{app: "projects", permission: "view_issue"}], any: true) me: UserType projectConnWithResolver( name: String! filters: ProjectFilter """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 ): ProjectConnection! } type QuizType implements Node { """The Globally Unique ID of this object""" id: ID! title: String! sequence: Int! } type RoleType implements Node & Named { """The Globally Unique ID of this object""" id: ID! name: String! description: String! } type StaffType implements Node { """The Globally Unique ID of this object""" id: ID! email: String! isActive: Boolean! isSuperuser: Boolean! isStaff: Boolean! } """A connection to a list of items.""" type StaffTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" edges: [StaffTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" type StaffTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" node: StaffType! } input StrFilterLookup { """Exact match. Filter will be skipped on `null` value""" exact: String """Assignment test. Filter will be skipped on `null` value""" isNull: Boolean """ Exact match of items in a given list. Filter will be skipped on `null` value """ inList: [String!] """Case-insensitive exact match. Filter will be skipped on `null` value""" iExact: String """ Case-sensitive containment test. Filter will be skipped on `null` value """ contains: String """ Case-insensitive containment test. Filter will be skipped on `null` value """ iContains: String """Case-sensitive starts-with. Filter will be skipped on `null` value""" startsWith: String """Case-insensitive starts-with. Filter will be skipped on `null` value""" iStartsWith: String """Case-sensitive ends-with. Filter will be skipped on `null` value""" endsWith: String """Case-insensitive ends-with. Filter will be skipped on `null` value""" iEndsWith: String """ Case-sensitive regular expression match. Filter will be skipped on `null` value """ regex: String """ Case-insensitive regular expression match. Filter will be skipped on `null` value """ iRegex: String } input TagInputPartial { id: ID name: String } """Add/remove/set the selected nodes.""" input TagInputPartialListInput { set: [TagInputPartial!] add: [TagInputPartial!] remove: [TagInputPartial!] } type TagType implements Node & Named { """The Globally Unique ID of this object""" id: ID! name: String! issues( """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 ): IssueTypeConnection! issuesWithSelectedRelatedMilestoneAndProject: [IssueType!]! } """A connection to a list of items.""" type TagTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" edges: [TagTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" type TagTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" node: TagType! } union UpdateIssuePayload = IssueType | OperationInfo union UpdateIssueWithKeyAttrPayload = IssueType | OperationInfo union UpdateProjectPayload = ProjectType | OperationInfo type UserType implements Node { """The Globally Unique ID of this object""" id: ID! email: String! isActive: Boolean! isSuperuser: Boolean! isStaff: Boolean! role: RoleType roleName: String roleDescription: String fullName: String! } """Permission definition for schema directives.""" input PermDefinition { """ The app to which we are requiring permission. If this is empty that means that we are checking the permission directly. """ app: String """ The permission itself. If this is empty that means that we are checking for any permission for the given app. """ permission: String }strawberry-graphql-django-0.82.1/tests/projects/snapshots/schema_with_inheritance.gql000066400000000000000000000312521516173410200312730ustar00rootroot00000000000000""" Can only be resolved by authenticated users. When the condition fails, the following can be returned (following this priority): 1) `OperationInfo`/`OperationMessage` if those types are allowed at the return type 2) `null` in case the field is not mandatory (e.g. `String` or `[String]`) 3) An empty list in case the field is a list (e.g. `[String]!`) 4) An empty `Connection` in case the return type is a relay connection 2) Otherwise, an error will be raised """ directive @isAuthenticated repeatable on FIELD_DEFINITION type AssigneeType implements Node { """The Globally Unique ID of this object""" id: ID! user: UserType! owner: Boolean! } union CreateIssuePayload = IssueType | OperationInfo """Date (isoformat)""" scalar Date """Decimal (fixed-point)""" scalar Decimal input DjangoModelFilterInput { pk: ID! } type FavoriteType implements Node { """The Globally Unique ID of this object""" id: ID! name: String! user: UserType! issue: IssueType! } """A connection to a list of items.""" type FavoriteTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" edges: [FavoriteTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" type FavoriteTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" node: FavoriteType! } input IssueFilter { name: StrFilterLookup AND: IssueFilter OR: IssueFilter NOT: IssueFilter DISTINCT: Boolean search: String } input IssueInputSubclass { name: String! milestone: MilestoneInputPartial! priority: Int kind: String tags: [NodeInput!] extra: Int } input IssueOrder { name: Ordering } type IssueType implements Node & Named { """The Globally Unique ID of this object""" id: ID! name: String! milestone: MilestoneType! priority: Int! kind: String nameWithPriority: String! nameWithKind: String! tags: [TagType!]! issueAssignees: [AssigneeType!]! staffAssignees: [StaffType!]! favoriteSet( """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 ): FavoriteTypeConnection! milestoneName: String! milestoneNameWithoutOnlyOptimization: String! privateName: String } """A connection to a list of items.""" type IssueTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" edges: [IssueTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" type IssueTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" node: IssueType! } type IssueTypeOffsetPaginated { pageInfo: OffsetPaginationInfo! """Total count of existing results.""" totalCount: Int! """List of paginated results.""" results: [IssueType!]! } input MilestoneFilter { name: StrFilterLookup project: DjangoModelFilterInput search: String AND: MilestoneFilter OR: MilestoneFilter NOT: MilestoneFilter DISTINCT: Boolean } input MilestoneInputPartial { id: ID name: String issues: [MilestoneIssueInputPartial!] project: ProjectInputPartial } input MilestoneIssueInputPartial { name: String tags: [TagInputPartial!] } input MilestoneOrder { name: Ordering project: ProjectOrder } type MilestoneType implements Node & Named { """The Globally Unique ID of this object""" id: ID! name: String! dueDate: Date project: ProjectType! issues(filters: IssueFilter, order: IssueOrder, pagination: OffsetPaginationInput): [IssueType!]! firstIssue: IssueType firstIssueRequired: IssueType! graphqlPath: String! mixedAnnotatedPrefetch: String! mixedPrefetchAnnotated: String! issuesPaginated(pagination: OffsetPaginationInput, order: IssueOrder): IssueTypeOffsetPaginated! issuesWithFilters( filters: IssueFilter """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 ): IssueTypeConnection! myIssues: [IssueType!]! myBugsCount: Int! asyncField(value: String!): String! } """A connection to a list of items.""" type MilestoneTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" edges: [MilestoneTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" type MilestoneTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" node: MilestoneType! } type MilestoneTypeOffsetPaginated { pageInfo: OffsetPaginationInfo! """Total count of existing results.""" totalCount: Int! """List of paginated results.""" results: [MilestoneType!]! } type MilestoneTypeSubclass implements Node & Named { """The Globally Unique ID of this object""" id: ID! name: String! dueDate: Date project: ProjectType! issues(filters: IssueFilter, order: IssueOrder, pagination: OffsetPaginationInput): [IssueType!]! firstIssue: IssueType firstIssueRequired: IssueType! graphqlPath: String! mixedAnnotatedPrefetch: String! mixedPrefetchAnnotated: String! issuesPaginated(pagination: OffsetPaginationInput, order: IssueOrder): IssueTypeOffsetPaginated! issuesWithFilters( filters: IssueFilter """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 ): IssueTypeConnection! myIssues: [IssueType!]! myBugsCount: Int! asyncField(value: String!): String! } type Mutation { createIssue(input: IssueInputSubclass!): CreateIssuePayload! } interface Named { name: String! } """An object with a Globally Unique ID""" interface Node { """The Globally Unique ID of this object""" id: ID! } """Input of an object that implements the `Node` interface.""" input NodeInput { id: ID! } type OffsetPaginationInfo { offset: Int! limit: Int } input OffsetPaginationInput { offset: Int! = 0 limit: Int } type OperationInfo { """List of messages returned by the operation.""" messages: [OperationMessage!]! } type OperationMessage { """The kind of this message.""" kind: OperationMessageKind! """The error message.""" message: String! """ The field that caused the error, or `null` if it isn't associated with any particular field. """ field: String """The error code, or `null` if no error code was set.""" code: String } enum OperationMessageKind { INFO WARNING ERROR PERMISSION VALIDATION } enum Ordering { ASC ASC_NULLS_FIRST ASC_NULLS_LAST DESC DESC_NULLS_FIRST DESC_NULLS_LAST } """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 } input ProjectInputPartial { id: ID name: String milestones: [MilestoneInputPartial!] } input ProjectOrder { id: Ordering name: Ordering } type ProjectType implements Node & Named { """The Globally Unique ID of this object""" id: ID! name: String! dueDate: Date isSmall: Boolean! isDelayed: Boolean! cost: Decimal @isAuthenticated milestones(filters: MilestoneFilter, order: MilestoneOrder, pagination: OffsetPaginationInput): [MilestoneType!]! milestonesCount: Int! customMilestonesModelProperty: [MilestoneType!]! firstMilestone: MilestoneType firstMilestoneRequired: MilestoneType! milestoneConn( filters: MilestoneFilter order: MilestoneOrder """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 ): MilestoneTypeConnection! milestonesPaginated(pagination: OffsetPaginationInput, filters: MilestoneFilter, order: MilestoneOrder): MilestoneTypeOffsetPaginated! customMilestones: [MilestoneType!]! } type ProjectTypeSubclass implements Node & Named { """The Globally Unique ID of this object""" id: ID! name: String! dueDate: Date isSmall: Boolean! isDelayed: Boolean! cost: Decimal @isAuthenticated milestones(filters: MilestoneFilter, order: MilestoneOrder, pagination: OffsetPaginationInput): [MilestoneType!]! milestonesCount: Int! customMilestonesModelProperty: [MilestoneType!]! firstMilestone: MilestoneType firstMilestoneRequired: MilestoneType! milestoneConn( filters: MilestoneFilter order: MilestoneOrder """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 ): MilestoneTypeConnection! milestonesPaginated(pagination: OffsetPaginationInput, filters: MilestoneFilter, order: MilestoneOrder): MilestoneTypeOffsetPaginated! customMilestones: [MilestoneType!]! } type Query { project( """The ID of the object.""" id: ID! ): ProjectTypeSubclass milestone( """The ID of the object.""" id: ID! ): MilestoneTypeSubclass } type RoleType implements Node & Named { """The Globally Unique ID of this object""" id: ID! name: String! description: String! } type StaffType implements Node { """The Globally Unique ID of this object""" id: ID! email: String! isActive: Boolean! isSuperuser: Boolean! isStaff: Boolean! } input StrFilterLookup { """Exact match. Filter will be skipped on `null` value""" exact: String """Assignment test. Filter will be skipped on `null` value""" isNull: Boolean """ Exact match of items in a given list. Filter will be skipped on `null` value """ inList: [String!] """Case-insensitive exact match. Filter will be skipped on `null` value""" iExact: String """ Case-sensitive containment test. Filter will be skipped on `null` value """ contains: String """ Case-insensitive containment test. Filter will be skipped on `null` value """ iContains: String """Case-sensitive starts-with. Filter will be skipped on `null` value""" startsWith: String """Case-insensitive starts-with. Filter will be skipped on `null` value""" iStartsWith: String """Case-sensitive ends-with. Filter will be skipped on `null` value""" endsWith: String """Case-insensitive ends-with. Filter will be skipped on `null` value""" iEndsWith: String """ Case-sensitive regular expression match. Filter will be skipped on `null` value """ regex: String """ Case-insensitive regular expression match. Filter will be skipped on `null` value """ iRegex: String } input TagInputPartial { id: ID name: String } type TagType implements Node & Named { """The Globally Unique ID of this object""" id: ID! name: String! issues( """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 ): IssueTypeConnection! issuesWithSelectedRelatedMilestoneAndProject: [IssueType!]! } type UserType implements Node { """The Globally Unique ID of this object""" id: ID! email: String! isActive: Boolean! isSuperuser: Boolean! isStaff: Boolean! role: RoleType roleName: String roleDescription: String fullName: String! }strawberry-graphql-django-0.82.1/tests/projects/test_schema.py000066400000000000000000000026161516173410200245530ustar00rootroot00000000000000import pathlib import strawberry from pytest_snapshot.plugin import Snapshot import strawberry_django from strawberry_django import mutations from tests.conftest import normalize_sdl from .models import Issue, Milestone, Project from .schema import IssueInput, IssueType, MilestoneType, ProjectType, schema SNAPSHOTS_DIR = pathlib.Path(__file__).parent / "snapshots" def test_schema(snapshot: Snapshot): snapshot.snapshot_dir = SNAPSHOTS_DIR snapshot.assert_match(normalize_sdl(str(schema)), "schema.gql") def test_schema_with_inheritance(snapshot: Snapshot): @strawberry_django.type(Project) class ProjectTypeSubclass(ProjectType): ... @strawberry_django.type(Milestone) class MilestoneTypeSubclass(MilestoneType): ... @strawberry_django.input(Issue) class IssueInputSubclass(IssueInput): ... @strawberry.type class Query: project: ProjectTypeSubclass | None = strawberry_django.node() milestone: MilestoneTypeSubclass | None = strawberry_django.node() @strawberry.type class Mutation: create_issue: IssueType = mutations.create( IssueInputSubclass, handle_django_errors=True, argument_name="input", ) schema = strawberry.Schema(query=Query, mutation=Mutation) snapshot.snapshot_dir = SNAPSHOTS_DIR snapshot.assert_match(normalize_sdl(str(schema)), "schema_with_inheritance.gql") strawberry-graphql-django-0.82.1/tests/queries/000077500000000000000000000000001516173410200215215ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/queries/__init__.py000066400000000000000000000000001516173410200236200ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/queries/conftest.py000066400000000000000000000002051516173410200237150ustar00rootroot00000000000000import pytest @pytest.fixture def query(schema): def query(query): return schema.execute_sync(query) return query strawberry-graphql-django-0.82.1/tests/queries/test_async.py000066400000000000000000000014161516173410200242510ustar00rootroot00000000000000import pytest pytestmark = pytest.mark.asyncio @pytest.fixture def query(schema): async def query(query): return await schema.execute(query) return query @pytest.mark.django_db(transaction=True) async def test_query(query, user, group, tag): result = await query("{ users { id name group { id name tags { id name } } } }") assert not result.errors assert result.data["users"] == [ { "id": str(user.id), "name": "user", "group": { "id": str(group.id), "name": "group", "tags": [ { "id": str(tag.id), "name": "tag", }, ], }, }, ] strawberry-graphql-django-0.82.1/tests/queries/test_fields.py000066400000000000000000000155471516173410200244140ustar00rootroot00000000000000import textwrap from typing import cast import pytest import strawberry from django.conf import settings from strawberry.types import ExecutionResult, Info import strawberry_django from tests import models, types, utils def generate_query(user_type): @strawberry.type class Query: users: list[user_type] = strawberry_django.field() # type: ignore return utils.generate_query(Query) def test_field_name(user): @strawberry_django.type(models.User) class MyUser: my_name: str = strawberry_django.field(field_name="name") query = generate_query(MyUser) result = query("{ users { myName } }") assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert result.data["users"] == [{"myName": "user"}] def test_relational_field_name(user, group): @strawberry_django.type(models.User) class MyUser: my_group: types.Group = strawberry_django.field(field_name="group") query = generate_query(MyUser) result = query("{ users { myGroup { name } } }") assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert result.data["users"] == [{"myGroup": {"name": "group"}}] def test_foreign_key_id_with_auto(group, user): @strawberry_django.type(models.User) class MyUser: group_id: strawberry.auto @strawberry.type class Query: users: list[MyUser] = strawberry_django.field() schema = strawberry.Schema(query=Query) expected = """\ type MyUser { groupId: ID } type Query { users: [MyUser!]! } """ assert textwrap.dedent(str(schema)).strip() == textwrap.dedent(expected).strip() result = schema.execute_sync("{ users { groupId } }") assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert result.data["users"] == [{"groupId": str(group.id)}] def test_foreign_key_id_with_explicit_type(group, user): @strawberry_django.type(models.User) class MyUser: group_id: strawberry.ID | None @strawberry.type class Query: users: list[MyUser] = strawberry_django.field() schema = strawberry.Schema(query=Query) expected = """\ type MyUser { groupId: ID } type Query { users: [MyUser!]! } """ assert textwrap.dedent(str(schema)).strip() == textwrap.dedent(expected).strip() result = schema.execute_sync("{ users { groupId } }") assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert result.data["users"] == [{"groupId": str(group.id)}] @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_sync_resolver(user, group): @strawberry_django.type(models.User) class MyUser: @strawberry_django.field def my_group(self, info: Info) -> types.Group: return cast("types.Group", models.Group.objects.get()) query = generate_query(MyUser) result = await query("{ users { myGroup { name } } }") # type: ignore assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert result.data["users"] == [{"myGroup": {"name": "group"}}] @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_async_resolver(user, group): @strawberry_django.type(models.User) class MyUser: @strawberry_django.field async def my_group(self, info: Info) -> types.Group: from asgiref.sync import sync_to_async return cast("types.Group", await sync_to_async(models.Group.objects.get)()) query = generate_query(MyUser) result = await query("{ users { myGroup { name } } }") # type: ignore assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert result.data["users"] == [{"myGroup": {"name": "group"}}] @pytest.mark.skipif( not settings.GEOS_IMPORTED, reason="Test requires GEOS to be imported and properly configured", ) @pytest.mark.django_db(transaction=True) def test_geo_data(query, geofields): # Test for point result = query("{ geofields { point } }") assert not result.errors assert result.data["geofields"] == [ {"point": (0.0, 0.0)}, {"point": (1.0, 1.0)}, {"point": None}, ] # Test for lineString result = query("{ geofields { lineString } }") assert not result.errors assert result.data["geofields"] == [ {"lineString": ((0.0, 0.0), (1.0, 1.0))}, {"lineString": ((1.0, 1.0), (2.0, 2.0), (3.0, 3.0))}, {"lineString": None}, ] # Test for polygon result = query("{ geofields { polygon } }") assert not result.errors assert result.data["geofields"] == [ { "polygon": ( ((-1.0, -1.0), (-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)), ), }, { "polygon": ( ((-1.0, -1.0), (-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)), ((-2.0, -2.0), (-2.0, 2.0), (2.0, 2.0), (2.0, -2.0), (-2.0, -2.0)), ), }, {"polygon": None}, ] # Test for multiPoint result = query("{ geofields { multiPoint } }") assert not result.errors assert result.data["geofields"] == [ {"multiPoint": ((0.0, 0.0), (1.0, 1.0))}, {"multiPoint": ((0.0, 0.0), (-1.0, -1.0), (1.0, 1.0))}, {"multiPoint": None}, ] # Test for multiLineString result = query("{ geofields { multiLineString } }") assert not result.errors assert result.data["geofields"] == [ {"multiLineString": (((0.0, 0.0), (1.0, 1.0)), ((1.0, 1.0), (-1.0, -1.0)))}, { "multiLineString": ( ((0.0, 0.0), (1.0, 1.0)), ((1.0, 1.0), (-1.0, -1.0)), ((2.0, 2.0), (-2.0, -2.0)), ), }, {"multiLineString": None}, ] # Test for multiPolygon result = query("{ geofields { multiPolygon } }") assert not result.errors assert result.data["geofields"] == [ { "multiPolygon": ( ((((-1.0, -1.0), (-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0))),), ((((-1.0, -1.0), (-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0))),), ), }, { "multiPolygon": ( ( ((-1.0, -1.0), (-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)), ((-2.0, -2.0), (-2.0, 2.0), (2.0, 2.0), (2.0, -2.0), (-2.0, -2.0)), ), ( ((-1.0, -1.0), (-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)), ((-2.0, -2.0), (-2.0, 2.0), (2.0, 2.0), (2.0, -2.0), (-2.0, -2.0)), ), ), }, {"multiPolygon": None}, ] strawberry-graphql-django-0.82.1/tests/queries/test_files.py000066400000000000000000000026711516173410200242420ustar00rootroot00000000000000import pytest import strawberry from django.db import models from strawberry import auto import strawberry_django from tests import utils class FileModel(models.Model): file = models.FileField() image = models.ImageField() @strawberry_django.type(FileModel) class File: file: auto image: auto @strawberry.type class Query: files: list[File] = strawberry_django.field() @pytest.fixture def query(db): return utils.generate_query(Query) @pytest.fixture def instance(mocker): mocker.patch( "django.core.files.images.ImageFile._get_image_dimensions", ).return_value = [ 800, 600, ] mocker.patch("os.stat")().st_size = 10 return FileModel.objects.create(file="file", image="image") def test_file(query, instance): result = query("{ files { file { name size url } } }") assert not result.errors assert result.data["files"] == [ { "file": { "name": "file", "size": 10, "url": "/file", }, }, ] def test_image(query, instance): result = query("{ files { image { name size url width height } } }") assert not result.errors assert result.data["files"] == [ { "image": { "name": "image", "size": 10, "url": "/image", "width": 800, "height": 600, }, }, ] strawberry-graphql-django-0.82.1/tests/queries/test_m2m_through.py000066400000000000000000000032231516173410200253650ustar00rootroot00000000000000import strawberry from django.db import models from strawberry import auto import strawberry_django class MemberModel(models.Model): name = models.CharField(max_length=50) class ProjectModel(models.Model): name = models.CharField(max_length=50) members = models.ManyToManyField(MemberModel, through="MembershipModel") class MembershipModel(models.Model): project = models.ForeignKey(ProjectModel, on_delete=models.CASCADE) member = models.ForeignKey(MemberModel, on_delete=models.CASCADE) @strawberry_django.type(ProjectModel) class Project: name: auto membership: list["Membership"] = strawberry_django.field( field_name="membershipmodel_set", ) @strawberry_django.type(MemberModel) class Member: name: auto membership: list["Membership"] = strawberry_django.field( field_name="membershipmodel_set", ) @strawberry_django.type(MembershipModel) class Membership: project: Project member: Member @strawberry.type class Query: projects: list[Project] | None = strawberry_django.field() schema = strawberry.Schema(query=Query) def test_query(db): project = ProjectModel.objects.create(name="my project") member = MemberModel.objects.create(name="my member") MembershipModel.objects.create(project=project, member=member) result = schema.execute_sync("{ projects { membership { member { name } } } }") assert not result.errors assert result.data == { "projects": [ { "membership": [ { "member": {"name": "my member"}, }, ], }, ], } strawberry-graphql-django-0.82.1/tests/queries/test_relations.py000066400000000000000000000036131516173410200251350ustar00rootroot00000000000000def test_foreign_key_relation(query, user, group): result = query("{ users { name group { name } } }") assert not result.errors assert result.data["users"] == [ { "name": "user", "group": { "name": "group", }, }, ] def test_foreign_key_relation_reversed(query, user, group): result = query("{ groups { name users { name } } }") assert not result.errors assert result.data["groups"] == [ { "name": "group", "users": [ { "name": "user", }, ], }, ] def test_one_to_one_relation(query, user, tag): result = query("{ users { name tag { name } } }") assert not result.errors assert result.data["users"] == [ { "name": "user", "tag": { "name": "tag", }, }, ] def test_one_to_one_relation_reversed(query, user, tag): result = query("{ tags { name user { name } } }") assert not result.errors assert result.data["tags"] == [ { "name": "tag", "user": { "name": "user", }, }, ] def test_many_to_many_relation(query, group, tag): result = query("{ groups { name tags { name } } }") assert not result.errors assert result.data["groups"] == [ { "name": "group", "tags": [ { "name": "tag", }, ], }, ] def test_many_to_many_relation_reversed(query, group): result = query("{ tags { name groups { name } } }") assert not result.errors assert result.data["tags"] == [ { "name": "tag", "groups": [ { "name": "group", }, ], }, ] strawberry-graphql-django-0.82.1/tests/queries/test_sync.py000066400000000000000000000010511516173410200241030ustar00rootroot00000000000000def test_query(query, user, group, tag): result = query("{ users { id name group { id name tags { id name } } } }") assert not result.errors assert result.data["users"] == [ { "id": str(user.id), "name": "user", "group": { "id": str(group.id), "name": "group", "tags": [ { "id": str(tag.id), "name": "tag", }, ], }, }, ] strawberry-graphql-django-0.82.1/tests/relay/000077500000000000000000000000001516173410200211605ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/relay/__init__.py000066400000000000000000000000001516173410200232570ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/relay/lazy/000077500000000000000000000000001516173410200221375ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/relay/lazy/__init__.py000066400000000000000000000000001516173410200242360ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/relay/lazy/a.py000066400000000000000000000010431516173410200227270ustar00rootroot00000000000000from typing import TYPE_CHECKING, Annotated, TypeAlias import strawberry from strawberry import relay import strawberry_django from strawberry_django.relay import DjangoListConnection from .models import RelayAuthor if TYPE_CHECKING: from .b import BookConnection @strawberry_django.type(RelayAuthor) class AuthorType(relay.Node): name: str books: Annotated["BookConnection", strawberry.lazy("tests.relay.lazy.b")] = ( strawberry_django.connection() ) AuthorConnection: TypeAlias = DjangoListConnection[AuthorType] strawberry-graphql-django-0.82.1/tests/relay/lazy/b.py000066400000000000000000000012271516173410200227340ustar00rootroot00000000000000from typing import TYPE_CHECKING, Annotated, TypeAlias import strawberry from strawberry import relay import strawberry_django from strawberry_django.relay import DjangoListConnection from .models import RelayBook if TYPE_CHECKING: from .a import AuthorType @strawberry_django.filter_type(RelayBook) class BookFilter: name: str @strawberry_django.order(RelayBook) class BookOrder: name: str @strawberry_django.type(RelayBook, filters=BookFilter, order=BookOrder) class BookType(relay.Node): name: str author: Annotated["AuthorType", strawberry.lazy("tests.relay.lazy.a")] BookConnection: TypeAlias = DjangoListConnection[BookType] strawberry-graphql-django-0.82.1/tests/relay/lazy/models.py000066400000000000000000000004651516173410200240010ustar00rootroot00000000000000from django.db import models class RelayAuthor(models.Model): name = models.CharField(max_length=100) class RelayBook(models.Model): title = models.CharField(max_length=100) author = models.ForeignKey( RelayAuthor, on_delete=models.CASCADE, related_name="books", ) strawberry-graphql-django-0.82.1/tests/relay/lazy/snapshots/000077500000000000000000000000001516173410200241615ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/relay/lazy/snapshots/test_lazy_annotations/000077500000000000000000000000001516173410200306145ustar00rootroot00000000000000test_lazy_type_annotations_in_schema/000077500000000000000000000000001516173410200402375ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/relay/lazy/snapshots/test_lazy_annotationsauthors_and_books_schema.gql000066400000000000000000000077041516173410200460000ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/relay/lazy/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schematype AuthorType implements Node { """The Globally Unique ID of this object""" id: ID! name: String! books( filters: BookFilter order: BookOrder """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 ): BookTypeConnection! } """A connection to a list of items.""" type AuthorTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" edges: [AuthorTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" type AuthorTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" node: AuthorType! } input BookFilter { name: String! AND: BookFilter OR: BookFilter NOT: BookFilter DISTINCT: Boolean } input BookOrder { name: String } type BookType implements Node { """The Globally Unique ID of this object""" id: ID! name: String! author: AuthorType! } """A connection to a list of items.""" type BookTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" edges: [BookTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" type BookTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" node: BookType! } """An object with a Globally Unique ID""" interface Node { """The Globally Unique ID of this object""" id: ID! } """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: BookFilter order: BookOrder """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 ): BookTypeConnection! booksConn2( filters: BookFilter order: BookOrder """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 ): BookTypeConnection! 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 ): AuthorTypeConnection! 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 ): AuthorTypeConnection! }strawberry-graphql-django-0.82.1/tests/relay/lazy/test_lazy_annotations.py000066400000000000000000000015771516173410200271560ustar00rootroot00000000000000import pathlib import strawberry from pytest_snapshot.plugin import Snapshot import strawberry_django from strawberry_django.relay import DjangoListConnection from tests.conftest import normalize_sdl from .a import AuthorConnection, AuthorType from .b import BookConnection, BookType SNAPSHOTS_DIR = pathlib.Path(__file__).parent / "snapshots" def test_lazy_type_annotations_in_schema(snapshot: Snapshot): @strawberry.type class Query: books_conn: BookConnection = strawberry_django.connection() books_conn2: DjangoListConnection[BookType] = strawberry_django.connection() authors_conn: AuthorConnection = strawberry_django.connection() authors_conn2: DjangoListConnection[AuthorType] = strawberry_django.connection() schema = strawberry.Schema(query=Query) snapshot.assert_match(normalize_sdl(str(schema)), "authors_and_books_schema.gql") strawberry-graphql-django-0.82.1/tests/relay/schema.py000066400000000000000000000045001516173410200227710ustar00rootroot00000000000000from collections.abc import Iterable from typing import ( Annotated, Any, ClassVar, ) import strawberry from django.db import models from strawberry import relay from strawberry.permission import BasePermission from strawberry.types import Info import strawberry_django from strawberry_django.relay import DjangoListConnection class FruitModel(models.Model): class Meta: ordering: ClassVar[list[str]] = ["id"] name = models.CharField(max_length=255) color = models.CharField(max_length=255) @strawberry_django.filter_type(FruitModel, lookups=True) class FruitFilter: name: strawberry.auto color: strawberry.auto @strawberry_django.order(FruitModel) class FruitOrder: name: strawberry.auto color: strawberry.auto @strawberry_django.type(FruitModel) class Fruit(relay.Node): name: strawberry.auto color: strawberry.auto class DummyPermission(BasePermission): message = "Dummy message" async def has_permission(self, source: Any, info: Info, **kwargs: Any) -> bool: return True @strawberry.type class Query: node: relay.Node = strawberry_django.node() node_with_async_permissions: relay.Node = strawberry_django.node( permission_classes=[DummyPermission], ) nodes: list[relay.Node] = strawberry_django.node() node_optional: relay.Node | None = strawberry_django.node() nodes_optional: list[relay.Node | None] = strawberry_django.node() fruits: DjangoListConnection[Fruit] = strawberry_django.connection() fruits_lazy: DjangoListConnection[ Annotated["Fruit", strawberry.lazy("tests.relay.schema")] ] = strawberry_django.connection() fruits_with_filters_and_order: DjangoListConnection[Fruit] = ( strawberry_django.connection( filters=FruitFilter, order=FruitOrder, ) ) @strawberry_django.connection(DjangoListConnection[Fruit]) def fruits_custom_resolver(self, info: Info) -> Iterable[FruitModel]: return FruitModel.objects.all() @strawberry_django.connection( DjangoListConnection[Fruit], filters=FruitFilter, order=FruitOrder, ) def fruits_custom_resolver_with_filters_and_order( self, info: Info, ) -> Iterable[FruitModel]: return FruitModel.objects.all() schema = strawberry.Schema(query=Query) strawberry-graphql-django-0.82.1/tests/relay/snapshots/000077500000000000000000000000001516173410200232025ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/relay/snapshots/schema.gql000066400000000000000000000121231516173410200251460ustar00rootroot00000000000000type Fruit implements Node { """The Globally Unique ID of this object""" id: ID! name: String! color: String! } """A connection to a list of items.""" type FruitConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" edges: [FruitEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" type FruitEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" node: Fruit! } input FruitFilter { name: StrFilterLookup color: StrFilterLookup AND: FruitFilter OR: FruitFilter NOT: FruitFilter DISTINCT: Boolean } input FruitOrder { name: Ordering color: Ordering } """An object with a Globally Unique ID""" interface Node { """The Globally Unique ID of this object""" id: ID! } enum Ordering { ASC ASC_NULLS_FIRST ASC_NULLS_LAST DESC DESC_NULLS_FIRST DESC_NULLS_LAST } """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 { node( """The ID of the object.""" id: ID! ): Node! nodeWithAsyncPermissions( """The ID of the object.""" id: ID! ): Node! nodes( """The IDs of the objects.""" ids: [ID!]! ): [Node!]! nodeOptional( """The ID of the object.""" id: ID! ): Node nodesOptional( """The IDs of the objects.""" ids: [ID!]! ): [Node]! fruits( """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 ): FruitConnection! fruitsLazy( """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 ): FruitConnection! fruitsWithFiltersAndOrder( filters: FruitFilter order: FruitOrder """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 ): FruitConnection! fruitsCustomResolver( """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 ): FruitConnection! fruitsCustomResolverWithFiltersAndOrder( filters: FruitFilter order: FruitOrder """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 ): FruitConnection! } input StrFilterLookup { """Exact match. Filter will be skipped on `null` value""" exact: String """Assignment test. Filter will be skipped on `null` value""" isNull: Boolean """ Exact match of items in a given list. Filter will be skipped on `null` value """ inList: [String!] """Case-insensitive exact match. Filter will be skipped on `null` value""" iExact: String """ Case-sensitive containment test. Filter will be skipped on `null` value """ contains: String """ Case-insensitive containment test. Filter will be skipped on `null` value """ iContains: String """Case-sensitive starts-with. Filter will be skipped on `null` value""" startsWith: String """Case-insensitive starts-with. Filter will be skipped on `null` value""" iStartsWith: String """Case-sensitive ends-with. Filter will be skipped on `null` value""" endsWith: String """Case-insensitive ends-with. Filter will be skipped on `null` value""" iEndsWith: String """ Case-sensitive regular expression match. Filter will be skipped on `null` value """ regex: String """ Case-insensitive regular expression match. Filter will be skipped on `null` value """ iRegex: String }strawberry-graphql-django-0.82.1/tests/relay/test_cursor_pagination.py000066400000000000000000001473151516173410200263320ustar00rootroot00000000000000import datetime from typing import cast import pytest import strawberry from django.db.models import F, OrderBy, QuerySet, Value from django.db.models.aggregates import Count from pytest_mock import MockFixture from strawberry.relay import GlobalID, Node, to_base64 import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry_django.relay import ( DjangoCursorConnection, DjangoCursorEdge, ) from tests.projects.models import Milestone, Project from tests.utils import assert_num_queries @strawberry_django.order(Project) class ProjectOrder: id: strawberry.auto name: strawberry.auto due_date: strawberry.auto @strawberry_django.order_field() def milestone_count( self, queryset: QuerySet, value: strawberry_django.Ordering, prefix: str ) -> "tuple[QuerySet, list[OrderBy]]": queryset = queryset.annotate(_milestone_count=Count(f"{prefix}milestone")) return queryset, [value.resolve("_milestone_count")] @strawberry_django.order(Milestone) class MilestoneOrder: due_date: strawberry.auto project: ProjectOrder @strawberry_django.order_field() def days_left( self, queryset: QuerySet, value: strawberry_django.Ordering, prefix: str ) -> "tuple[QuerySet, list[OrderBy]]": queryset = queryset.alias( _days_left=Value(datetime.date(2025, 12, 31)) - F(f"{prefix}due_date") ) return queryset, [value.resolve("_days_left")] @strawberry_django.type(Milestone, order=MilestoneOrder) class MilestoneType(Node): due_date: strawberry.auto project: "ProjectType" @classmethod def get_queryset(cls, qs: QuerySet, info): if not qs.ordered: qs = qs.order_by("project__name", "pk") return qs @strawberry_django.type(Project, order=ProjectOrder) class ProjectType(Node): name: str due_date: datetime.date milestones: DjangoCursorConnection[MilestoneType] = strawberry_django.connection() @classmethod def get_queryset(cls, qs: QuerySet, info): if not qs.ordered: qs = qs.order_by("name", "pk") return qs @strawberry.type() class Query: project: ProjectType | None = strawberry_django.node() projects: DjangoCursorConnection[ProjectType] = strawberry_django.connection() milestones: DjangoCursorConnection[MilestoneType] = strawberry_django.connection() @strawberry_django.connection( DjangoCursorConnection[ProjectType], disable_optimization=True ) @staticmethod def deferred_projects() -> list[ProjectType]: result = Project.objects.all().order_by("name").defer("name") return cast("list[ProjectType]", result) @strawberry_django.connection(DjangoCursorConnection[ProjectType]) @staticmethod def projects_with_resolver() -> list[ProjectType]: return cast("list[ProjectType]", Project.objects.all().order_by("-pk")) schema = strawberry.Schema(query=Query, extensions=[DjangoOptimizerExtension()]) @pytest.fixture def test_objects(): pa = Project.objects.create(id=1, name="Project A") pc1 = Project.objects.create(id=2, name="Project C") Project.objects.create(id=5, name="Project C") pb = Project.objects.create(id=3, name="Project B") Project.objects.create(id=6, name="Project D") Project.objects.create(id=4, name="Project E") Milestone.objects.create(id=1, project=pb, due_date=datetime.date(2025, 6, 1)) Milestone.objects.create(id=2, project=pb, due_date=datetime.date(2025, 6, 2)) Milestone.objects.create(id=3, project=pc1, due_date=datetime.date(2025, 6, 1)) Milestone.objects.create(id=4, project=pa, due_date=datetime.date(2025, 6, 5)) @pytest.mark.django_db(transaction=True) def test_cursor_pagination(test_objects): query = """ query TestQuery { projects { edges { cursor node { id name } } } } """ with assert_num_queries(1): result = schema.execute_sync(query) assert result.data == { "projects": { "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project A","1"]' ), "node": { "id": str(GlobalID("ProjectType", "1")), "name": "Project A", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project B","3"]' ), "node": { "id": str(GlobalID("ProjectType", "3")), "name": "Project B", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project C","2"]' ), "node": { "id": str(GlobalID("ProjectType", "2")), "name": "Project C", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project C","5"]' ), "node": { "id": str(GlobalID("ProjectType", "5")), "name": "Project C", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project D","6"]' ), "node": { "id": str(GlobalID("ProjectType", "6")), "name": "Project D", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project E","4"]' ), "node": { "id": str(GlobalID("ProjectType", "4")), "name": "Project E", }, }, ] } } @pytest.mark.django_db(transaction=True) def test_cursor_pagination_custom_resolver(test_objects): query = """ query TestQuery($after: String, $first: Int) { projectsWithResolver(after: $after, first: $first) { edges { cursor node { id name } } } } """ with assert_num_queries(1): result = schema.execute_sync( query, { "after": to_base64(DjangoCursorEdge.CURSOR_PREFIX, '["6"]'), "first": 2, }, ) assert result.data == { "projectsWithResolver": { "edges": [ { "cursor": to_base64(DjangoCursorEdge.CURSOR_PREFIX, '["5"]'), "node": { "id": str(GlobalID("ProjectType", "5")), "name": "Project C", }, }, { "cursor": to_base64(DjangoCursorEdge.CURSOR_PREFIX, '["4"]'), "node": { "id": str(GlobalID("ProjectType", "4")), "name": "Project E", }, }, ] } } @pytest.mark.django_db(transaction=True) def test_forward_pagination(test_objects): query = """ query TestQuery($first: Int, $after: String) { projects(first: $first, after: $after) { edges { cursor node { id name } } pageInfo { startCursor endCursor hasNextPage } } } """ with assert_num_queries(1): result = schema.execute_sync( query, { "first": 3, "after": to_base64(DjangoCursorEdge.CURSOR_PREFIX, '["Project B","3"]'), }, ) assert result.data == { "projects": { "pageInfo": { "startCursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project C","2"]' ), "endCursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project D","6"]' ), "hasNextPage": True, }, "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project C","2"]' ), "node": { "id": str(GlobalID("ProjectType", "2")), "name": "Project C", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project C","5"]' ), "node": { "id": str(GlobalID("ProjectType", "5")), "name": "Project C", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project D","6"]' ), "node": { "id": str(GlobalID("ProjectType", "6")), "name": "Project D", }, }, ], } } @pytest.mark.django_db(transaction=True) def test_forward_pagination_first_page(test_objects): query = """ query TestQuery($first: Int, $after: String) { projects(first: $first, after: $after) { edges { cursor node { id name } } pageInfo { startCursor endCursor hasPreviousPage hasNextPage } } } """ with assert_num_queries(1): result = schema.execute_sync( query, { "first": 1, "after": None, }, ) assert result.data == { "projects": { "pageInfo": { "startCursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project A","1"]' ), "endCursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project A","1"]' ), "hasPreviousPage": False, "hasNextPage": True, }, "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project A","1"]' ), "node": { "id": str(GlobalID("ProjectType", "1")), "name": "Project A", }, }, ], } } @pytest.mark.django_db(transaction=True) def test_forward_pagination_last_page(test_objects): query = """ query TestQuery($first: Int, $after: String) { projects(first: $first, after: $after) { edges { cursor node { id name } } pageInfo { startCursor endCursor hasNextPage } } } """ with assert_num_queries(1): result = schema.execute_sync( query, { "first": 10, "after": to_base64(DjangoCursorEdge.CURSOR_PREFIX, '["Project D","6"]'), }, ) assert result.data == { "projects": { "pageInfo": { "startCursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project E","4"]' ), "endCursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project E","4"]' ), "hasNextPage": False, }, "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project E","4"]' ), "node": { "id": str(GlobalID("ProjectType", "4")), "name": "Project E", }, }, ], } } @pytest.mark.django_db(transaction=True) def test_backward_pagination(test_objects): query = """ query TestQuery($last: Int, $before: String) { projects(last: $last, before: $before) { edges { cursor node { id name } } pageInfo { startCursor endCursor hasPreviousPage } } } """ with assert_num_queries(1): result = schema.execute_sync( query, { "last": 2, "before": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project C","5"]' ), }, ) assert result.data == { "projects": { "pageInfo": { "startCursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project B","3"]' ), "endCursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project C","2"]' ), "hasPreviousPage": True, }, "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project B","3"]' ), "node": { "id": str(GlobalID("ProjectType", "3")), "name": "Project B", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project C","2"]' ), "node": { "id": str(GlobalID("ProjectType", "2")), "name": "Project C", }, }, ], } } @pytest.mark.django_db(transaction=True) def test_backward_pagination_first_page(test_objects): query = """ query TestQuery($last: Int, $before: String) { projects(last: $last, before: $before) { edges { cursor node { id name } } pageInfo { startCursor endCursor hasNextPage hasPreviousPage } } } """ with assert_num_queries(1): result = schema.execute_sync( query, { "last": 2, "before": None, }, ) assert result.data == { "projects": { "pageInfo": { "startCursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project D","6"]' ), "endCursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project E","4"]' ), "hasPreviousPage": True, "hasNextPage": False, }, "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project D","6"]' ), "node": { "id": str(GlobalID("ProjectType", "6")), "name": "Project D", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project E","4"]' ), "node": { "id": str(GlobalID("ProjectType", "4")), "name": "Project E", }, }, ], } } @pytest.mark.django_db(transaction=True) def test_backward_pagination_last_page(test_objects): query = """ query TestQuery($last: Int, $before: String) { projects(last: $last, before: $before) { edges { cursor node { id name } } pageInfo { startCursor endCursor hasPreviousPage } } } """ with assert_num_queries(1): result = schema.execute_sync( query, { "last": 2, "before": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project C","2"]' ), }, ) assert result.data == { "projects": { "pageInfo": { "startCursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project A","1"]' ), "endCursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project B","3"]' ), "hasPreviousPage": False, }, "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project A","1"]' ), "node": { "id": str(GlobalID("ProjectType", "1")), "name": "Project A", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project B","3"]' ), "node": { "id": str(GlobalID("ProjectType", "3")), "name": "Project B", }, }, ], } } @pytest.mark.parametrize( ("first", "last", "pks", "has_next", "has_previous"), [ (4, 2, [3, 4], True, True), (6, 2, [5, 6], False, True), (4, 4, [1, 2, 3, 4], True, False), (6, 6, [1, 2, 3, 4, 5, 6], False, False), (8, 4, [3, 4, 5, 6], False, True), (4, 8, [1, 2, 3, 4], True, False), ], ) @pytest.mark.django_db(transaction=True) def test_first_and_last_pagination( first, last, pks, has_next, has_previous, test_objects ): query = """ query TestQuery($first: Int, $last: Int) { projects(first: $first, last: $last, order: { id: ASC }) { edges { cursor node { id } } pageInfo { startCursor endCursor hasNextPage hasPreviousPage } } } """ with assert_num_queries(1): result = schema.execute_sync( query, { "first": first, "last": last, }, ) assert result.data == { "projects": { "pageInfo": { "startCursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, f'["{pks[0]}"]' ), "endCursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, f'["{pks[-1]}"]' ), "hasPreviousPage": has_previous, "hasNextPage": has_next, }, "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, f'["{pk}"]' ), "node": { "id": str(GlobalID("ProjectType", str(pk))), }, } for pk in pks ], } } @pytest.mark.django_db(transaction=True) def test_empty_connection(): query = """ query TestQuery { projects { edges { cursor node { id name } } pageInfo { startCursor endCursor hasNextPage hasPreviousPage } } } """ with assert_num_queries(1): result = schema.execute_sync( query, ) assert result.data == { "projects": { "pageInfo": { "startCursor": None, "endCursor": None, "hasNextPage": False, "hasPreviousPage": False, }, "edges": [], } } @pytest.mark.django_db(transaction=True) def test_cursor_pagination_custom_order(test_objects): query = """ query TestQuery($first: Int, $after: String) { projects(first: $first, after: $after, order: { name: DESC id: ASC }) { edges { cursor node { id name } } } } """ with assert_num_queries(1): result = schema.execute_sync( query, { "first": 2, "after": to_base64(DjangoCursorEdge.CURSOR_PREFIX, '["Project E","4"]'), }, ) assert result.data == { "projects": { "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project D","6"]' ), "node": { "id": str(GlobalID("ProjectType", "6")), "name": "Project D", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project C","2"]' ), "node": { "id": str(GlobalID("ProjectType", "2")), "name": "Project C", }, }, ] } } @pytest.mark.django_db(transaction=True) def test_cursor_pagination_joined_field_order(test_objects): query = """ query TestQuery { milestones(order: { dueDate: DESC, project: { name: ASC } }) { edges { cursor node { id dueDate project { id name } } } } } """ with assert_num_queries(2): result = schema.execute_sync(query) assert result.data == { "milestones": { "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["2025-06-05","Project A","4"]', ), "node": { "id": str(GlobalID("MilestoneType", "4")), "dueDate": "2025-06-05", "project": { "id": str(GlobalID("ProjectType", "1")), "name": "Project A", }, }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["2025-06-02","Project B","2"]', ), "node": { "id": str(GlobalID("MilestoneType", "2")), "dueDate": "2025-06-02", "project": { "id": str(GlobalID("ProjectType", "3")), "name": "Project B", }, }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["2025-06-01","Project B","1"]', ), "node": { "id": str(GlobalID("MilestoneType", "1")), "dueDate": "2025-06-01", "project": { "id": str(GlobalID("ProjectType", "3")), "name": "Project B", }, }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["2025-06-01","Project C","3"]', ), "node": { "id": str(GlobalID("MilestoneType", "3")), "dueDate": "2025-06-01", "project": { "id": str(GlobalID("ProjectType", "2")), "name": "Project C", }, }, }, ] } } @pytest.mark.django_db(transaction=True) def test_cursor_pagination_expression_order(test_objects): query = """ query TestQuery($after: String) { milestones(after: $after, order: { daysLeft: ASC }) { edges { cursor node { id } } } } """ with assert_num_queries(1): result = schema.execute_sync( query, { "after": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["209 00:00:00","4"]' ) }, ) assert result.data == { "milestones": { "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["212 00:00:00","2"]' ), "node": { "id": str(GlobalID("MilestoneType", "2")), }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["213 00:00:00","1"]' ), "node": { "id": str(GlobalID("MilestoneType", "1")), }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["213 00:00:00","3"]' ), "node": { "id": str(GlobalID("MilestoneType", "3")), }, }, ] } } @pytest.mark.django_db(transaction=True) def test_cursor_pagination_agg_expression_order(test_objects): query = """ query TestQuery($after: String, $first: Int) { projects(after: $after, first: $first, order: { milestoneCount: DESC }) { edges { cursor node { id } } } } """ with assert_num_queries(1): result = schema.execute_sync( query, { "after": None, "first": 4, }, ) assert result.data == { "projects": { "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["2","3"]' ), "node": { "id": str(GlobalID("ProjectType", "3")), }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["1","1"]' ), "node": { "id": str(GlobalID("ProjectType", "1")), }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["1","2"]' ), "node": { "id": str(GlobalID("ProjectType", "2")), }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["0","4"]' ), "node": { "id": str(GlobalID("ProjectType", "4")), }, }, ] } } @pytest.mark.django_db(transaction=True) def test_cursor_pagination_order_field_deferred(test_objects): query = """ query TestQuery { deferredProjects(first: 2) { edges { cursor node { id } } } } """ with assert_num_queries(1): result = schema.execute_sync(query) assert result.data == { "deferredProjects": { "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project A","1"]' ), "node": { "id": str(GlobalID("ProjectType", "1")), }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project B","3"]' ), "node": { "id": str(GlobalID("ProjectType", "3")), }, }, ] } } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize( ("order", "pks"), [ ("DESC_NULLS_FIRST", [1, 4, 3, 2]), ("DESC_NULLS_LAST", [3, 2, 1, 4]), ("ASC_NULLS_FIRST", [1, 4, 2, 3]), ("ASC_NULLS_LAST", [2, 3, 1, 4]), ], ) @pytest.mark.parametrize("offset", [0, 1, 2, 3]) def test_cursor_pagination_order_with_nulls(order, pks, offset): pa = Project.objects.create(id=1, name="Project A", due_date=None) pc = Project.objects.create( id=2, name="Project C", due_date=datetime.date(2025, 6, 2) ) pb = Project.objects.create( id=3, name="Project B", due_date=datetime.date(2025, 6, 5) ) pd = Project.objects.create(id=4, name="Project D", due_date=None) projects_lookup = {p.pk: p for p in (pa, pb, pc, pd)} projects = [projects_lookup[pk] for pk in pks] query = """ query TestQuery($after: String, $first: Int, $order: Ordering!) { projects(after: $after, first: $first, order: { dueDate: $order }) { edges { cursor node { id name } } } } """ def make_cursor(project: Project) -> str: due_date_part = ( f'"{project.due_date.isoformat()}"' if project.due_date else "null" ) return to_base64( DjangoCursorEdge.CURSOR_PREFIX, f'[{due_date_part},"{project.pk}"]' ) with assert_num_queries(1): result = schema.execute_sync( query, { "order": order, "after": make_cursor(projects[offset]), "first": 2, }, ) assert result.data == { "projects": { "edges": [ { "cursor": make_cursor(project), "node": { "id": str(GlobalID("ProjectType", str(project.pk))), "name": project.name, }, } for project in projects[offset + 1 : offset + 3] ] } } @pytest.mark.django_db(transaction=True) async def test_cursor_pagination_async(test_objects): query = """ query TestQuery { projects { edges { cursor node { id name } } } } """ result = await schema.execute(query) assert result.data == { "projects": { "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project A","1"]' ), "node": { "id": str(GlobalID("ProjectType", "1")), "name": "Project A", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project B","3"]' ), "node": { "id": str(GlobalID("ProjectType", "3")), "name": "Project B", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project C","2"]' ), "node": { "id": str(GlobalID("ProjectType", "2")), "name": "Project C", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project C","5"]' ), "node": { "id": str(GlobalID("ProjectType", "5")), "name": "Project C", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project D","6"]' ), "node": { "id": str(GlobalID("ProjectType", "6")), "name": "Project D", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project E","4"]' ), "node": { "id": str(GlobalID("ProjectType", "4")), "name": "Project E", }, }, ] } } @pytest.mark.django_db(transaction=True) def test_nested_cursor_pagination_in_single(): pa = Project.objects.create(id=1, name="Project A") pb = Project.objects.create(id=2, name="Project B") Milestone.objects.create(id=1, project=pb, due_date=datetime.date(2025, 6, 1)) Milestone.objects.create(id=2, project=pb, due_date=datetime.date(2025, 6, 2)) Milestone.objects.create(id=3, project=pb, due_date=datetime.date(2025, 6, 1)) Milestone.objects.create(id=4, project=pa, due_date=datetime.date(2025, 6, 5)) Milestone.objects.create(id=5, project=pa, due_date=datetime.date(2025, 6, 1)) query = """ query TestQuery($id: ID!) { project(id: $id) { id milestones(first: 2, order: { dueDate: ASC }) { edges { cursor node { id dueDate } } } } } """ with assert_num_queries(2): result = schema.execute_sync(query, {"id": str(GlobalID("ProjectType", "2"))}) assert result.data == { "project": { "id": str(GlobalID("ProjectType", "2")), "milestones": { "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["2025-06-01","1"]', ), "node": { "id": str(GlobalID("MilestoneType", "1")), "dueDate": "2025-06-01", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["2025-06-01","3"]', ), "node": { "id": str(GlobalID("MilestoneType", "3")), "dueDate": "2025-06-01", }, }, ] }, }, } @pytest.mark.django_db(transaction=True) def test_nested_cursor_pagination(): pa = Project.objects.create(id=1, name="Project A") pb = Project.objects.create(id=2, name="Project B") Milestone.objects.create(id=1, project=pb, due_date=datetime.date(2025, 6, 1)) Milestone.objects.create(id=2, project=pb, due_date=datetime.date(2025, 6, 2)) Milestone.objects.create(id=3, project=pb, due_date=datetime.date(2025, 6, 1)) Milestone.objects.create(id=4, project=pa, due_date=datetime.date(2025, 6, 5)) Milestone.objects.create(id=5, project=pa, due_date=datetime.date(2025, 6, 1)) query = """ query TestQuery { projects { edges { cursor node { id milestones(first: 2, order: { dueDate: ASC }) { pageInfo { hasNextPage } edges { cursor node { id dueDate } } } } } } } """ with assert_num_queries(2): result = schema.execute_sync(query) assert result.data == { "projects": { "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project A","1"]' ), "node": { "id": str(GlobalID("ProjectType", "1")), "milestones": { "pageInfo": {"hasNextPage": False}, "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["2025-06-01","5"]', ), "node": { "id": str(GlobalID("MilestoneType", "5")), "dueDate": "2025-06-01", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["2025-06-05","4"]', ), "node": { "id": str(GlobalID("MilestoneType", "4")), "dueDate": "2025-06-05", }, }, ], }, }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["Project B","2"]' ), "node": { "id": str(GlobalID("ProjectType", "2")), "milestones": { "pageInfo": {"hasNextPage": True}, "edges": [ { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["2025-06-01","1"]', ), "node": { "id": str(GlobalID("MilestoneType", "1")), "dueDate": "2025-06-01", }, }, { "cursor": to_base64( DjangoCursorEdge.CURSOR_PREFIX, '["2025-06-01","3"]', ), "node": { "id": str(GlobalID("MilestoneType", "3")), "dueDate": "2025-06-01", }, }, ], }, }, }, ] } } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("first", [None, 3]) @pytest.mark.parametrize( "after", [None, to_base64(DjangoCursorEdge.CURSOR_PREFIX, '["2"]')] ) @pytest.mark.parametrize("last", [None, 3]) @pytest.mark.parametrize( "before", [None, to_base64(DjangoCursorEdge.CURSOR_PREFIX, '["2"]')] ) def test_total_count_ignores_pagination(test_objects, first, after, before, last): query = """ query TestQuery($first: Int, $after: String, $last: Int, $before: String) { projects(first: $first, after: $after, last: $last, before: $before, order: { id: ASC }) { totalCount } } """ with assert_num_queries(1): result = schema.execute_sync( query, {"first": first, "after": after, "last": last, "before": before} ) assert result.data == {"projects": {"totalCount": 6}} @pytest.mark.django_db(transaction=True) def test_total_count_works_with_edges(test_objects): query = """ query TestQuery($first: Int, $after: String, $last: Int, $before: String) { projects(first: $first, after: $after, last: $last, before: $before, order: { id: ASC }) { totalCount edges { node { id } } } } """ with assert_num_queries(2): result = schema.execute_sync( query, {"first": 3, "after": to_base64(DjangoCursorEdge.CURSOR_PREFIX, '["2"]')}, ) assert result.data == { "projects": { "totalCount": 6, "edges": [ {"node": {"id": str(GlobalID("ProjectType", "3"))}}, {"node": {"id": str(GlobalID("ProjectType", "4"))}}, {"node": {"id": str(GlobalID("ProjectType", "5"))}}, ], } } @pytest.mark.django_db(transaction=True) def test_nested_total_count(): p1 = Project.objects.create() p2 = Project.objects.create() p1m = [Milestone.objects.create(project=p1) for _ in range(3)] p2m = [Milestone.objects.create(project=p2) for _ in range(2)] query = """ query TestQuery { projects(first: 2, order: { id: ASC }) { edges { node { id milestones { totalCount edges { node { id } } } } } } } """ with assert_num_queries(2): result = schema.execute_sync(query) assert result.data == { "projects": { "edges": [ { "node": { "id": str(GlobalID("ProjectType", str(p1.pk))), "milestones": { "totalCount": 3, "edges": [ { "node": { "id": str( GlobalID("MilestoneType", str(m.pk)) ) } } for m in p1m ], }, } }, { "node": { "id": str(GlobalID("ProjectType", str(p2.pk))), "milestones": { "totalCount": 2, "edges": [ { "node": { "id": str( GlobalID("MilestoneType", str(m.pk)) ) } } for m in p2m ], }, } }, ], } } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize( "cursor", [ *( to_base64(DjangoCursorEdge.CURSOR_PREFIX, c) for c in ("", "[]", "[1]", "{}", "foo", '["foo"]') ), to_base64("foo", "bar"), to_base64("foo", '["1"]'), ], ) def test_invalid_cursor(cursor, test_objects): query = """ query TestQuery($after: String) { projects(after: $after, order: { id: ASC }) { edges { cursor node { id } } } } """ result = schema.execute_sync(query, {"after": cursor}) assert result.data is None assert result.errors assert result.errors[0].message == "Invalid cursor" @pytest.mark.django_db(transaction=True) def test_connection_distinct_with_m2m_filters(): from tests import models @strawberry_django.filter_type(models.FruitType, lookups=True) class FruitTypeFilter: name: strawberry.auto @strawberry_django.filter_type(models.Fruit, lookups=True) class FruitFilter: name: strawberry.auto types: FruitTypeFilter | None DISTINCT: bool | None @strawberry_django.type(models.FruitType) class FruitTypeGQL(Node): name: strawberry.auto @strawberry_django.type(models.Fruit, filters=FruitFilter) class FruitGQL(Node): name: strawberry.auto types: list[FruitTypeGQL] @strawberry.type class FruitQuery: fruits: DjangoCursorConnection[FruitGQL] = strawberry_django.connection() fruit_schema = strawberry.Schema( query=FruitQuery, extensions=[DjangoOptimizerExtension()] ) ft1 = models.FruitType.objects.create(name="tropical") ft2 = models.FruitType.objects.create(name="citrus") banana = models.Fruit.objects.create(name="banana") banana.types.add(ft1, ft2) apple = models.Fruit.objects.create(name="apple") apple.types.add(ft1) orange = models.Fruit.objects.create(name="orange") orange.types.add(ft2) query = """ query TestQuery { fruits(filters: { types: { name: { inList: ["tropical", "citrus"] } } DISTINCT: true }) { edges { node { id name } } totalCount } } """ result = fruit_schema.execute_sync(query) assert not result.errors assert result.data edges = result.data["fruits"]["edges"] total_count = result.data["fruits"]["totalCount"] assert len(edges) == 3 assert total_count == 3 names = {edge["node"]["name"] for edge in edges} assert names == {"banana", "apple", "orange"} @pytest.mark.django_db(transaction=True) def test_connection_distinct_with_pagination(): from tests import models @strawberry_django.filter_type(models.FruitType, lookups=True) class FruitTypeFilter: name: strawberry.auto @strawberry_django.filter_type(models.Fruit, lookups=True) class FruitFilter: name: strawberry.auto types: FruitTypeFilter | None DISTINCT: bool | None @strawberry_django.type(models.FruitType) class FruitTypeGQL(Node): name: strawberry.auto @strawberry_django.type(models.Fruit, filters=FruitFilter) class FruitGQL(Node): name: strawberry.auto types: list[FruitTypeGQL] @strawberry.type class FruitQuery: fruits: DjangoCursorConnection[FruitGQL] = strawberry_django.connection() fruit_schema = strawberry.Schema( query=FruitQuery, extensions=[DjangoOptimizerExtension()] ) ft1 = models.FruitType.objects.create(name="tropical") ft2 = models.FruitType.objects.create(name="citrus") for i in range(5): fruit = models.Fruit.objects.create(name=f"fruit_{i}") fruit.types.add(ft1, ft2) query = """ query TestQuery { fruits( first: 2 filters: { types: { name: { inList: ["tropical", "citrus"] } } DISTINCT: true } ) { edges { node { id name } } totalCount pageInfo { hasNextPage } } } """ result = fruit_schema.execute_sync(query) assert not result.errors assert result.data edges = result.data["fruits"]["edges"] total_count = result.data["fruits"]["totalCount"] has_next = result.data["fruits"]["pageInfo"]["hasNextPage"] assert len(edges) == 2 assert total_count == 5 assert has_next is True @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize( ("first", "last", "error_message"), [ (-1, None, "Argument 'first' must be a non-negative integer."), (None, -1, "Argument 'last' must be a non-negative integer."), (150, None, "Argument 'first' cannot be higher than 100."), (None, 150, "Argument 'last' cannot be higher than 100."), (30, 150, "Argument 'last' cannot be higher than 100."), ], ) def test_invalid_offsets(first, last, error_message, test_objects): query = """ query TestQuery($first: Int, $last: Int) { projects(first: $first, last: $last, order: { id: ASC }) { edges { cursor node { id } } } } """ result = schema.execute_sync(query, {"first": first, "last": last}) assert result.data is None assert result.errors assert result.errors[0].message == error_message @pytest.mark.django_db(transaction=True) def test_cursor_connection_rejects_non_querysets(mocker: MockFixture): with pytest.raises(TypeError): DjangoCursorConnection.resolve_connection( list(Project.objects.all()), info=mocker.Mock() ) strawberry-graphql-django-0.82.1/tests/relay/test_fields.py000066400000000000000000001030331516173410200240370ustar00rootroot00000000000000import pytest from pytest_django import DjangoAssertNumQueries from strawberry.relay.utils import to_base64 from .schema import FruitModel, schema @pytest.fixture(autouse=True) def _fixtures(transactional_db): for pk, name, color in [ (1, "Banana", "yellow"), (2, "Apple", "red"), (3, "Pineapple", "yellow"), (4, "Grape", "purple"), (5, "Orange", "orange"), ]: FruitModel.objects.create( id=pk, name=name, color=color, ) def test_query_node(): result = schema.execute_sync( """ query TestQuery ($id: ID!) { node (id: $id) { ... on Node { id } ... on Fruit { name color } } } """, variable_values={ "id": to_base64("Fruit", 2), }, ) assert result.errors is None assert result.data == { "node": { "id": to_base64("Fruit", 2), "color": "red", "name": "Apple", }, } async def test_query_node_with_async_permissions(): result = await schema.execute( """ query TestQuery ($id: ID!) { nodeWithAsyncPermissions (id: $id) { ... on Node { id } ... on Fruit { name color } } } """, variable_values={ "id": to_base64("Fruit", 2), }, ) assert result.errors is None assert result.data == { "nodeWithAsyncPermissions": { "id": to_base64("Fruit", 2), "color": "red", "name": "Apple", }, } def test_query_node_optional(): result = schema.execute_sync( """ query TestQuery ($id: ID!) { nodeOptional (id: $id) { ... on Node { id } ... on Fruit { name color } } } """, variable_values={ "id": to_base64("Fruit", 999), }, ) assert result.errors is None assert result.data == {"nodeOptional": None} async def test_query_node_async(): result = await schema.execute( """ query TestQuery ($id: ID!) { node (id: $id) { ... on Node { id } ... on Fruit { name color } } } """, variable_values={ "id": to_base64("Fruit", 2), }, ) assert result.errors is None assert result.data == { "node": { "id": to_base64("Fruit", 2), "color": "red", "name": "Apple", }, } async def test_query_node_optional_async(): result = await schema.execute( """ query TestQuery ($id: ID!) { nodeOptional (id: $id) { ... on Node { id } ... on Fruit { name color } } } """, variable_values={ "id": to_base64("Fruit", 999), }, ) assert result.errors is None assert result.data == {"nodeOptional": None} def test_query_nodes(): result = schema.execute_sync( """ query TestQuery ($ids: [ID!]!) { nodes (ids: $ids) { ... on Node { id } ... on Fruit { name color } } } """, variable_values={ "ids": [to_base64("Fruit", 2), to_base64("Fruit", 4)], }, ) assert result.errors is None assert result.data == { "nodes": [ { "id": to_base64("Fruit", 2), "name": "Apple", "color": "red", }, { "id": to_base64("Fruit", 4), "name": "Grape", "color": "purple", }, ], } def test_query_nodes_optional(): result = schema.execute_sync( """ query TestQuery ($ids: [ID!]!) { nodesOptional (ids: $ids) { ... on Node { id } ... on Fruit { name color } } } """, variable_values={ "ids": [ to_base64("Fruit", 2), to_base64("Fruit", 999), to_base64("Fruit", 4), ], }, ) assert result.errors is None assert result.data == { "nodesOptional": [ { "id": to_base64("Fruit", 2), "name": "Apple", "color": "red", }, None, { "id": to_base64("Fruit", 4), "name": "Grape", "color": "purple", }, ], } async def test_query_nodes_async(): result = await schema.execute( """ query TestQuery ($ids: [ID!]!) { nodes (ids: $ids) { ... on Node { id } ... on Fruit { name color } } } """, variable_values={ "ids": [ to_base64("Fruit", 2), to_base64("Fruit", 4), ], }, ) assert result.errors is None assert result.data == { "nodes": [ { "id": to_base64("Fruit", 2), "name": "Apple", "color": "red", }, { "id": to_base64("Fruit", 4), "name": "Grape", "color": "purple", }, ], } async def test_query_nodes_optional_async(): result = await schema.execute( """ query TestQuery ($ids: [ID!]!) { nodesOptional (ids: $ids) { ... on Node { id } ... on Fruit { name color } } } """, variable_values={ "ids": [ to_base64("Fruit", 2), to_base64("Fruit", 998), to_base64("Fruit", 4), to_base64("Fruit", 999), ], }, ) assert result.errors is None assert result.data == { "nodesOptional": [ { "id": to_base64("Fruit", 2), "name": "Apple", "color": "red", }, None, { "id": to_base64("Fruit", 4), "name": "Grape", "color": "purple", }, None, ], } fruits_query = """ query TestQuery ( $first: Int = null $last: Int = null $before: String = null, $after: String = null, ) {{ {} ( first: $first last: $last before: $before after: $after ) {{ pageInfo {{ hasNextPage hasPreviousPage startCursor endCursor }} edges {{ cursor node {{ id name color }} }} }} }} """ attrs = [ "fruits", "fruitsLazy", "fruitsWithFiltersAndOrder", "fruitsCustomResolver", "fruitsCustomResolverWithFiltersAndOrder", ] @pytest.mark.parametrize("query_attr", attrs) def test_query_connection(query_attr: str): result = schema.execute_sync( fruits_query.format(query_attr), variable_values={}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjA=", "node": { "id": to_base64("Fruit", 1), "color": "yellow", "name": "Banana", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjE=", "node": { "id": to_base64("Fruit", 2), "color": "red", "name": "Apple", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjI=", "node": { "id": to_base64("Fruit", 3), "color": "yellow", "name": "Pineapple", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjM=", "node": { "id": to_base64("Fruit", 4), "color": "purple", "name": "Grape", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjQ=", "node": { "id": to_base64("Fruit", 5), "color": "orange", "name": "Orange", }, }, ], "pageInfo": { "hasNextPage": False, "hasPreviousPage": False, "startCursor": to_base64("arrayconnection", "0"), "endCursor": to_base64("arrayconnection", "4"), }, }, } @pytest.mark.parametrize("query_attr", attrs) async def test_query_connection_async(query_attr: str): result = await schema.execute( fruits_query.format(query_attr), variable_values={}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjA=", "node": { "id": to_base64("Fruit", 1), "color": "yellow", "name": "Banana", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjE=", "node": { "id": to_base64("Fruit", 2), "color": "red", "name": "Apple", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjI=", "node": { "id": to_base64("Fruit", 3), "color": "yellow", "name": "Pineapple", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjM=", "node": { "id": to_base64("Fruit", 4), "color": "purple", "name": "Grape", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjQ=", "node": { "id": to_base64("Fruit", 5), "color": "orange", "name": "Orange", }, }, ], "pageInfo": { "hasNextPage": False, "hasPreviousPage": False, "startCursor": to_base64("arrayconnection", "0"), "endCursor": to_base64("arrayconnection", "4"), }, }, } @pytest.mark.parametrize("query_attr", attrs) def test_query_connection_filtering_first(query_attr: str): result = schema.execute_sync( fruits_query.format(query_attr), variable_values={"first": 2}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjA=", "node": { "id": to_base64("Fruit", 1), "color": "yellow", "name": "Banana", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjE=", "node": { "id": to_base64("Fruit", 2), "color": "red", "name": "Apple", }, }, ], "pageInfo": { "hasNextPage": True, "hasPreviousPage": False, "startCursor": to_base64("arrayconnection", "0"), "endCursor": to_base64("arrayconnection", "1"), }, }, } @pytest.mark.parametrize("query_attr", attrs) async def test_query_connection_filtering_first_async(query_attr: str): result = await schema.execute( fruits_query.format(query_attr), variable_values={"first": 2}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjA=", "node": { "id": to_base64("Fruit", 1), "color": "yellow", "name": "Banana", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjE=", "node": { "id": to_base64("Fruit", 2), "color": "red", "name": "Apple", }, }, ], "pageInfo": { "hasNextPage": True, "hasPreviousPage": False, "startCursor": to_base64("arrayconnection", "0"), "endCursor": to_base64("arrayconnection", "1"), }, }, } @pytest.mark.parametrize("query_attr", attrs) def test_query_connection_filtering_first_with_after(query_attr: str): result = schema.execute_sync( fruits_query.format(query_attr), variable_values={"first": 2, "after": to_base64("arrayconnection", "1")}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjI=", "node": { "id": to_base64("Fruit", 3), "color": "yellow", "name": "Pineapple", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjM=", "node": { "id": to_base64("Fruit", 4), "color": "purple", "name": "Grape", }, }, ], "pageInfo": { "hasNextPage": True, "hasPreviousPage": True, "startCursor": to_base64("arrayconnection", "2"), "endCursor": to_base64("arrayconnection", "3"), }, }, } @pytest.mark.parametrize("query_attr", attrs) async def test_query_connection_filtering_first_with_after_async(query_attr: str): result = await schema.execute( fruits_query.format(query_attr), variable_values={"first": 2, "after": to_base64("arrayconnection", "1")}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjI=", "node": { "id": to_base64("Fruit", 3), "color": "yellow", "name": "Pineapple", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjM=", "node": { "id": to_base64("Fruit", 4), "color": "purple", "name": "Grape", }, }, ], "pageInfo": { "hasNextPage": True, "hasPreviousPage": True, "startCursor": to_base64("arrayconnection", "2"), "endCursor": to_base64("arrayconnection", "3"), }, }, } @pytest.mark.parametrize("query_attr", attrs) def test_query_connection_filtering_last(query_attr: str): result = schema.execute_sync( fruits_query.format(query_attr), variable_values={"last": 2}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjM=", "node": { "id": to_base64("Fruit", 4), "color": "purple", "name": "Grape", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjQ=", "node": { "id": to_base64("Fruit", 5), "color": "orange", "name": "Orange", }, }, ], "pageInfo": { "hasNextPage": False, "hasPreviousPage": True, "startCursor": to_base64("arrayconnection", "3"), "endCursor": to_base64("arrayconnection", "4"), }, }, } @pytest.mark.parametrize("query_attr", attrs) async def test_query_connection_filtering_last_async(query_attr: str): result = await schema.execute( fruits_query.format(query_attr), variable_values={"last": 2}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjM=", "node": { "id": to_base64("Fruit", 4), "color": "purple", "name": "Grape", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjQ=", "node": { "id": to_base64("Fruit", 5), "color": "orange", "name": "Orange", }, }, ], "pageInfo": { "hasNextPage": False, "hasPreviousPage": True, "startCursor": to_base64("arrayconnection", "3"), "endCursor": to_base64("arrayconnection", "4"), }, }, } @pytest.mark.parametrize("query_attr", attrs) def test_query_connection_filtering_first_with_before(query_attr: str): result = schema.execute_sync( fruits_query.format(query_attr), variable_values={"first": 1, "before": to_base64("arrayconnection", "3")}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjI=", "node": { "id": to_base64("Fruit", 3), "color": "yellow", "name": "Pineapple", }, }, ], "pageInfo": { "hasNextPage": True, "hasPreviousPage": True, "startCursor": to_base64("arrayconnection", "2"), "endCursor": to_base64("arrayconnection", "2"), }, }, } @pytest.mark.parametrize("query_attr", attrs) async def test_query_connection_filtering_first_with_before_async(query_attr: str): result = await schema.execute( fruits_query.format(query_attr), variable_values={"first": 1, "before": to_base64("arrayconnection", "3")}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjI=", "node": { "id": to_base64("Fruit", 3), "color": "yellow", "name": "Pineapple", }, }, ], "pageInfo": { "hasNextPage": True, "hasPreviousPage": True, "startCursor": to_base64("arrayconnection", "2"), "endCursor": to_base64("arrayconnection", "2"), }, }, } @pytest.mark.parametrize("query_attr", attrs) def test_query_connection_filtering_last_with_before(query_attr: str): result = schema.execute_sync( fruits_query.format(query_attr), variable_values={"last": 2, "before": to_base64("arrayconnection", "4")}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjI=", "node": { "id": to_base64("Fruit", 3), "color": "yellow", "name": "Pineapple", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjM=", "node": { "id": to_base64("Fruit", 4), "color": "purple", "name": "Grape", }, }, ], "pageInfo": { "hasNextPage": True, "hasPreviousPage": True, "startCursor": to_base64("arrayconnection", "2"), "endCursor": to_base64("arrayconnection", "3"), }, }, } @pytest.mark.parametrize("query_attr", attrs) async def test_query_connection_filtering_last_with_before_async(query_attr: str): result = await schema.execute( fruits_query.format(query_attr), variable_values={"last": 2, "before": to_base64("arrayconnection", "4")}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjI=", "node": { "id": to_base64("Fruit", 3), "color": "yellow", "name": "Pineapple", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjM=", "node": { "id": to_base64("Fruit", 4), "color": "purple", "name": "Grape", }, }, ], "pageInfo": { "hasNextPage": True, "hasPreviousPage": True, "startCursor": to_base64("arrayconnection", "2"), "endCursor": to_base64("arrayconnection", "3"), }, }, } fruits_query_filters_order = """ query TestQuery ( $first: Int = null $last: Int = null $before: String = null, $after: String = null, $filters: FruitFilter $order: FruitOrder ) {{ {} ( first: $first last: $last before: $before after: $after filters: $filters order: $order ) {{ pageInfo {{ hasNextPage hasPreviousPage startCursor endCursor }} edges {{ cursor node {{ id name color }} }} }} }} """ custom_attrs = [ "fruitsWithFiltersAndOrder", "fruitsCustomResolverWithFiltersAndOrder", ] @pytest.mark.parametrize("query_attr", custom_attrs) def test_query_connection_with_filters(query_attr: str): result = schema.execute_sync( fruits_query_filters_order.format(query_attr), variable_values={"filters": {"name": {"endsWith": "e"}}}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjA=", "node": { "id": to_base64("Fruit", 2), "color": "red", "name": "Apple", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjE=", "node": { "id": to_base64("Fruit", 3), "color": "yellow", "name": "Pineapple", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjI=", "node": { "id": to_base64("Fruit", 4), "color": "purple", "name": "Grape", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjM=", "node": { "id": to_base64("Fruit", 5), "color": "orange", "name": "Orange", }, }, ], "pageInfo": { "hasNextPage": False, "hasPreviousPage": False, "startCursor": to_base64("arrayconnection", "0"), "endCursor": to_base64("arrayconnection", "3"), }, }, } @pytest.mark.parametrize("query_attr", custom_attrs) def test_query_connection_with_filters_and_order(query_attr: str): result = schema.execute_sync( fruits_query_filters_order.format(query_attr), variable_values={ "filters": {"name": {"endsWith": "e"}}, "order": {"name": "DESC"}, }, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjA=", "node": { "id": to_base64("Fruit", 3), "color": "yellow", "name": "Pineapple", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjE=", "node": { "id": to_base64("Fruit", 5), "color": "orange", "name": "Orange", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjI=", "node": { "id": to_base64("Fruit", 4), "color": "purple", "name": "Grape", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjM=", "node": { "id": to_base64("Fruit", 2), "color": "red", "name": "Apple", }, }, ], "pageInfo": { "hasNextPage": False, "hasPreviousPage": False, "startCursor": to_base64("arrayconnection", "0"), "endCursor": to_base64("arrayconnection", "3"), }, }, } @pytest.mark.parametrize("query_attr", custom_attrs) def test_query_connection_custom_resolver_filtering_first(query_attr: str): result = schema.execute_sync( fruits_query_filters_order.format(query_attr), variable_values={"first": 2, "filters": {"name": {"endsWith": "e"}}}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjA=", "node": { "id": to_base64("Fruit", 2), "color": "red", "name": "Apple", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjE=", "node": { "id": to_base64("Fruit", 3), "color": "yellow", "name": "Pineapple", }, }, ], "pageInfo": { "hasNextPage": True, "hasPreviousPage": False, "startCursor": to_base64("arrayconnection", "0"), "endCursor": to_base64("arrayconnection", "1"), }, }, } @pytest.mark.parametrize("query_attr", custom_attrs) def test_query_connection_custom_resolver_filtering_first_with_after(query_attr: str): result = schema.execute_sync( fruits_query_filters_order.format(query_attr), variable_values={ "first": 2, "after": to_base64("arrayconnection", "1"), "filters": {"name": {"endsWith": "e"}}, }, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjI=", "node": { "id": to_base64("Fruit", 4), "color": "purple", "name": "Grape", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjM=", "node": { "id": to_base64("Fruit", 5), "color": "orange", "name": "Orange", }, }, ], "pageInfo": { "hasNextPage": False, "hasPreviousPage": True, "startCursor": to_base64("arrayconnection", "2"), "endCursor": to_base64("arrayconnection", "3"), }, }, } @pytest.mark.parametrize("query_attr", custom_attrs) def test_query_connection_custom_resolver_filtering_last(query_attr: str): result = schema.execute_sync( fruits_query_filters_order.format(query_attr), variable_values={"last": 2, "filters": {"name": {"endsWith": "e"}}}, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjI=", "node": { "id": to_base64("Fruit", 4), "color": "purple", "name": "Grape", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjM=", "node": { "id": to_base64("Fruit", 5), "color": "orange", "name": "Orange", }, }, ], "pageInfo": { "hasNextPage": False, "hasPreviousPage": True, "startCursor": to_base64("arrayconnection", "2"), "endCursor": to_base64("arrayconnection", "3"), }, }, } @pytest.mark.parametrize("query_attr", custom_attrs) def test_query_connection_custom_resolver_filtering_last_with_before(query_attr: str): result = schema.execute_sync( fruits_query_filters_order.format(query_attr), variable_values={ "last": 2, "before": to_base64("arrayconnection", "3"), "filters": {"name": {"endsWith": "e"}}, }, ) assert result.errors is None assert result.data == { query_attr: { "edges": [ { "cursor": "YXJyYXljb25uZWN0aW9uOjE=", "node": { "id": to_base64("Fruit", 3), "color": "yellow", "name": "Pineapple", }, }, { "cursor": "YXJyYXljb25uZWN0aW9uOjI=", "node": { "id": to_base64("Fruit", 4), "color": "purple", "name": "Grape", }, }, ], "pageInfo": { "hasNextPage": True, "hasPreviousPage": True, "startCursor": to_base64("arrayconnection", "1"), "endCursor": to_base64("arrayconnection", "2"), }, }, } fruits_query_total_count = """ query TestQuery ( $first: Int = null $last: Int = null $before: String = null, $after: String = null, ) {{ {} ( first: $first last: $last before: $before after: $after ) {{ totalCount }} }} """ attrs = [ "fruits", "fruitsLazy", "fruitsWithFiltersAndOrder", "fruitsCustomResolver", "fruitsCustomResolverWithFiltersAndOrder", ] @pytest.mark.parametrize("query_attr", custom_attrs) def test_query_connection_total_count_sql_queries( django_assert_num_queries: DjangoAssertNumQueries, query_attr: str ): with django_assert_num_queries(1): result = schema.execute_sync( fruits_query_total_count.format(query_attr), variable_values={}, ) assert result.errors is None assert result.data == { query_attr: {"totalCount": 5}, } strawberry-graphql-django-0.82.1/tests/relay/test_nested_pagination.py000066400000000000000000000140351516173410200262670ustar00rootroot00000000000000import sys import pytest from strawberry.relay import to_base64 from strawberry.relay.types import PREFIX from strawberry_django.optimizer import DjangoOptimizerExtension from tests import utils from tests.projects.faker import IssueFactory, MilestoneFactory @pytest.mark.django_db(transaction=True) def test_nested_pagination_first(gql_client: utils.GraphQLTestClient): # Nested pagination with the same arguments for the parent and child connections query = """ query testNestedConnectionPagination($first: Int, $after: String) { milestoneConn(first: $first, after: $after) { totalCount edges { node { id issuesWithFilters(first: $first, after: $after) { totalCount edges { node { id } } } } } } } """ # Create 4 milestones, each with 4 issues nested_data = { milestone: IssueFactory.create_batch(4, milestone=milestone) for milestone in MilestoneFactory.create_batch(4) } # Run the nested pagination query # We expect only 2 database queries if the optimizer is enabled, otherwise 3 (N+1) with utils.assert_num_queries(2 if DjangoOptimizerExtension.enabled.get() else 6): result = gql_client.query(query, {"first": 2, "after": to_base64(PREFIX, 0)}) # We expect the 2nd and 3rd milestones each with their 2nd and 3rd issues assert not result.errors assert result.data == { "milestoneConn": { "totalCount": 4, "edges": [ { "node": { "id": to_base64("MilestoneType", milestone.id), "issuesWithFilters": { "totalCount": 4, "edges": [ {"node": {"id": to_base64("IssueType", issue.id)}} for issue in issues[1:3] ], }, } } for milestone, issues in list(nested_data.items())[1:3] ], } } @pytest.mark.django_db(transaction=True) def test_nested_pagination_last_before(gql_client: utils.GraphQLTestClient): query = """ query testNestedConnectionPagination($last: Int, $before: String) { milestoneConn(last: $last, before: $before) { totalCount edges { node { id issuesWithFilters(last: $last, before: $before) { totalCount edges { node { id } } } } } } } """ nested_data = { milestone: IssueFactory.create_batch(4, milestone=milestone) for milestone in MilestoneFactory.create_batch(4) } with utils.assert_num_queries(2 if DjangoOptimizerExtension.enabled.get() else 6): result = gql_client.query(query, {"last": 2, "before": to_base64(PREFIX, 3)}) assert not result.errors assert result.data == { "milestoneConn": { "totalCount": 4, "edges": [ { "node": { "id": to_base64("MilestoneType", milestone.id), "issuesWithFilters": { "totalCount": 4, "edges": [ {"node": {"id": to_base64("IssueType", issue.id)}} for issue in issues[1:3] ], }, } } for milestone, issues in list(nested_data.items())[1:3] ], } } @pytest.mark.django_db(transaction=True) def test_nested_pagination_last(gql_client: utils.GraphQLTestClient): query = """ query testNestedConnectionPagination($last: Int) { milestoneConn(last: $last) { totalCount edges { node { id issuesWithFilters(last: $last) { totalCount edges { node { id } } } } } } } """ nested_data = { milestone: IssueFactory.create_batch(4, milestone=milestone) for milestone in MilestoneFactory.create_batch(4) } # Expecting 3 queries when optimized due to initial COUNT query used for `before` creation with utils.assert_num_queries( 3 if DjangoOptimizerExtension.enabled.get() else 6 ) as q_ctx: result = gql_client.query(query, {"last": 2}) if DjangoOptimizerExtension.enabled.get(): # Validating that the milestoneConn query is actually LIMIT sys.maxsize - limitless queries_using_maxsize = [ q["sql"] for q in q_ctx.captured_queries if f"LIMIT {sys.maxsize}" in q["sql"] ] assert not queries_using_maxsize, ( f"{len(queries_using_maxsize)} queries executed using sys.maxsize" " instead of reversed pagination\nCaptured queries were:\n" "{}".format("\n".join(queries_using_maxsize)) ) assert not result.errors assert result.data == { "milestoneConn": { "totalCount": 4, "edges": [ { "node": { "id": to_base64("MilestoneType", milestone.id), "issuesWithFilters": { "totalCount": 4, "edges": [ {"node": {"id": to_base64("IssueType", issue.id)}} for issue in issues[-2:] ], }, } } for milestone, issues in list(nested_data.items())[-2:] ], } } strawberry-graphql-django-0.82.1/tests/relay/test_query.py000066400000000000000000000022061516173410200237360ustar00rootroot00000000000000import pytest import strawberry from strawberry import relay import strawberry_django from tests.projects.models import Project @pytest.mark.parametrize("type_name", ["ProjectType", "PublicProjectObject"]) @pytest.mark.django_db(transaction=True) def test_correct_model_returned(type_name: str): @strawberry_django.type(Project) class ProjectType(relay.Node): name: relay.NodeID[str] due_date: strawberry.auto @strawberry_django.type(Project) class PublicProjectObject(relay.Node): name: relay.NodeID[str] due_date: strawberry.auto @strawberry.type class Query: node: relay.Node = relay.node() schema = strawberry.Schema(query=Query, types=[ProjectType, PublicProjectObject]) Project.objects.create(name="test") node_id = relay.to_base64(type_name, "test") result = schema.execute_sync( """ query NodeQuery($id: ID!) { node(id: $id) { __typename id } } """, {"id": node_id}, ) assert result.errors is None assert result.data == {"node": {"__typename": type_name, "id": node_id}} strawberry-graphql-django-0.82.1/tests/relay/test_schema.py000066400000000000000000000005241516173410200240320ustar00rootroot00000000000000import pathlib from pytest_snapshot.plugin import Snapshot from tests.conftest import normalize_sdl from .schema import schema SNAPSHOTS_DIR = pathlib.Path(__file__).parent / "snapshots" def test_schema(snapshot: Snapshot): snapshot.snapshot_dir = SNAPSHOTS_DIR snapshot.assert_match(normalize_sdl(str(schema)), "schema.gql") strawberry-graphql-django-0.82.1/tests/relay/test_types.py000066400000000000000000000115051516173410200237370ustar00rootroot00000000000000from typing import Any, cast import pytest from strawberry import relay from strawberry.types.info import Info from typing_extensions import assert_type from .schema import Fruit, FruitModel, schema @pytest.fixture(autouse=True) def _fixtures(transactional_db): for pk, name, color in [ (1, "Banana", "yellow"), (2, "Apple", "red"), (3, "Pineapple", "yellow"), (4, "Grape", "purple"), (5, "Orange", "orange"), ]: FruitModel.objects.create( id=pk, name=name, color=color, ) class FakeInfo: schema = schema # We only need that info contains the schema for the tests fake_info = cast("Info", FakeInfo()) @pytest.mark.parametrize("type_name", [None, 1, 1.1]) def test_global_id_wrong_type_name(type_name: Any): with pytest.raises(relay.GlobalIDValueError): relay.GlobalID(type_name=type_name, node_id="foobar") @pytest.mark.parametrize("node_id", [None, 1, 1.1]) def test_global_id_wrong_type_node_id(node_id: Any): with pytest.raises(relay.GlobalIDValueError): relay.GlobalID(type_name="foobar", node_id=node_id) def test_global_id_from_id(): gid = relay.GlobalID.from_id("Zm9vYmFyOjE=") assert gid.type_name == "foobar" assert gid.node_id == "1" @pytest.mark.parametrize("value", ["foobar", ["Zm9vYmFy"], 123]) def test_global_id_from_id_error(value: Any): with pytest.raises(relay.GlobalIDValueError): relay.GlobalID.from_id(value) def test_global_id_resolve_type(): gid = relay.GlobalID(type_name="Fruit", node_id="1") type_ = gid.resolve_type(fake_info) assert type_ is Fruit def test_global_id_resolve_node_sync(): gid = relay.GlobalID(type_name="Fruit", node_id="1") fruit = gid.resolve_node_sync(fake_info) assert isinstance(fruit, FruitModel) assert fruit.pk == 1 assert fruit.name == "Banana" def test_global_id_resolve_node_sync_non_existing(): gid = relay.GlobalID(type_name="Fruit", node_id="999") fruit = gid.resolve_node_sync(fake_info) assert_type(fruit, relay.Node | None) assert fruit is None def test_global_id_resolve_node_sync_non_existing_but_required(): gid = relay.GlobalID(type_name="Fruit", node_id="999") with pytest.raises(FruitModel.DoesNotExist): gid.resolve_node_sync(fake_info, required=True) def test_global_id_resolve_node_sync_ensure_type(): gid = relay.GlobalID(type_name="Fruit", node_id="1") fruit = gid.resolve_node_sync(fake_info, ensure_type=FruitModel) assert_type(fruit, FruitModel) assert isinstance(fruit, FruitModel) assert fruit.pk == 1 assert fruit.name == "Banana" def test_global_id_resolve_node_sync_ensure_type_with_union(): class Foo: ... gid = relay.GlobalID(type_name="Fruit", node_id="1") fruit = gid.resolve_node_sync(fake_info, ensure_type=FruitModel | Foo) assert_type(fruit, FruitModel | Foo) assert isinstance(fruit, FruitModel) assert fruit.pk == 1 assert fruit.name == "Banana" def test_global_id_resolve_node_sync_ensure_type_wrong_type(): class Foo: ... gid = relay.GlobalID(type_name="Fruit", node_id="1") with pytest.raises(TypeError): gid.resolve_node_sync(fake_info, ensure_type=Foo) async def test_global_id_resolve_node(): gid = relay.GlobalID(type_name="Fruit", node_id="1") fruit = await gid.resolve_node(fake_info) assert_type(fruit, relay.Node | None) assert isinstance(fruit, FruitModel) assert fruit.pk == 1 assert fruit.name == "Banana" async def test_global_id_resolve_node_non_existing(): gid = relay.GlobalID(type_name="Fruit", node_id="999") fruit = await gid.resolve_node(fake_info) assert_type(fruit, relay.Node | None) assert fruit is None async def test_global_id_resolve_node_non_existing_but_required(): gid = relay.GlobalID(type_name="Fruit", node_id="999") with pytest.raises(FruitModel.DoesNotExist): await gid.resolve_node(fake_info, required=True) async def test_global_id_resolve_node_ensure_type(): gid = relay.GlobalID(type_name="Fruit", node_id="1") fruit = await gid.resolve_node(fake_info, ensure_type=FruitModel) assert_type(fruit, FruitModel) assert isinstance(fruit, FruitModel) assert fruit.pk == 1 assert fruit.name == "Banana" async def test_global_id_resolve_node_ensure_type_with_union(): class Foo: ... gid = relay.GlobalID(type_name="Fruit", node_id="1") fruit = await gid.resolve_node(fake_info, ensure_type=FruitModel | Foo) assert_type(fruit, FruitModel | Foo) assert isinstance(fruit, FruitModel) assert fruit.pk == 1 assert fruit.name == "Banana" async def test_global_id_resolve_node_ensure_type_wrong_type(): class Foo: ... gid = relay.GlobalID(type_name="Fruit", node_id="1") with pytest.raises(TypeError): await gid.resolve_node(fake_info, ensure_type=Foo) strawberry-graphql-django-0.82.1/tests/relay/treenode/000077500000000000000000000000001516173410200227655ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/relay/treenode/__init__.py000066400000000000000000000000001516173410200250640ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/relay/treenode/a.py000066400000000000000000000012351516173410200235600ustar00rootroot00000000000000from typing import TYPE_CHECKING, Annotated, TypeAlias import strawberry from strawberry import relay import strawberry_django from strawberry_django.relay import DjangoListConnection 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 = DjangoListConnection[TreeNodeAuthorType] strawberry-graphql-django-0.82.1/tests/relay/treenode/b.py000066400000000000000000000013651516173410200235650ustar00rootroot00000000000000from typing import TYPE_CHECKING, Annotated, TypeAlias import strawberry from strawberry import relay import strawberry_django from strawberry_django.relay import DjangoListConnection from .models import TreeNodeBook if TYPE_CHECKING: from .a import TreeNodeAuthorType @strawberry_django.filter_type(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 = DjangoListConnection[TreeNodeBookType] strawberry-graphql-django-0.82.1/tests/relay/treenode/models.py000066400000000000000000000010721516173410200246220ustar00rootroot00000000000000from django.db import models from tree_queries.fields import TreeNodeForeignKey from tree_queries.models import TreeNode class TreeNodeAuthor(TreeNode): name = models.CharField(max_length=100) parent = TreeNodeForeignKey( to="self", on_delete=models.CASCADE, null=True, blank=True, related_name="children", ) class TreeNodeBook(models.Model): title = models.CharField(max_length=100) author = models.ForeignKey( TreeNodeAuthor, on_delete=models.CASCADE, related_name="books", ) strawberry-graphql-django-0.82.1/tests/relay/treenode/snapshots/000077500000000000000000000000001516173410200250075ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/relay/treenode/snapshots/test_lazy_annotations/000077500000000000000000000000001516173410200314425ustar00rootroot00000000000000test_lazy_type_annotations_in_schema/000077500000000000000000000000001516173410200410655ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/relay/treenode/snapshots/test_lazy_annotationsauthors_and_books_schema.gql000066400000000000000000000111121516173410200466120ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema"""An object with a Globally Unique ID""" interface Node { """The Globally Unique ID of this object""" id: ID! } """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: ID! name: String! books( 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! children( """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! } """A connection to a list of items.""" type TreeNodeAuthorTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" edges: [TreeNodeAuthorTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" type TreeNodeAuthorTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" node: TreeNodeAuthorType! } input TreeNodeBookFilter { name: String! AND: TreeNodeBookFilter OR: TreeNodeBookFilter NOT: TreeNodeBookFilter DISTINCT: Boolean } input TreeNodeBookOrder { name: String } type TreeNodeBookType implements Node { """The Globally Unique ID of this object""" id: ID! name: String! author: TreeNodeAuthorType! } """A connection to a list of items.""" type TreeNodeBookTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" edges: [TreeNodeBookTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" type TreeNodeBookTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" node: TreeNodeBookType! }strawberry-graphql-django-0.82.1/tests/relay/treenode/test_lazy_annotations.py000066400000000000000000000017571516173410200300040ustar00rootroot00000000000000import pathlib import strawberry from pytest_snapshot.plugin import Snapshot import strawberry_django from strawberry_django.relay import DjangoListConnection from tests.conftest import normalize_sdl from .a import TreeNodeAuthorConnection, TreeNodeAuthorType from .b import TreeNodeBookConnection, TreeNodeBookType SNAPSHOTS_DIR = pathlib.Path(__file__).parent / "snapshots" def test_lazy_type_annotations_in_schema(snapshot: Snapshot): @strawberry.type class Query: books_conn: TreeNodeBookConnection = strawberry_django.connection() books_conn2: DjangoListConnection[TreeNodeBookType] = ( strawberry_django.connection() ) authors_conn: TreeNodeAuthorConnection = strawberry_django.connection() authors_conn2: DjangoListConnection[TreeNodeAuthorType] = ( strawberry_django.connection() ) schema = strawberry.Schema(query=Query) snapshot.assert_match(normalize_sdl(str(schema)), "authors_and_books_schema.gql") strawberry-graphql-django-0.82.1/tests/relay/treenode/test_nested_children.py000066400000000000000000000066671516173410200275470ustar00rootroot00000000000000import pytest import strawberry from strawberry.relay.utils import to_base64 import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from .a import TreeNodeAuthorConnection from .models import TreeNodeAuthor @strawberry.type class Query: authors: TreeNodeAuthorConnection = strawberry_django.connection() schema = strawberry.Schema(query=Query, extensions=[DjangoOptimizerExtension]) @pytest.mark.django_db(transaction=True) def test_nested_children_total_count(): 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) { totalCount edges { node { id name children { totalCount edges { node { id name } } } } } } } """ result = schema.execute_sync(query) assert not result.errors assert result.data == { "authors": { "totalCount": 3, "edges": [ { "node": { "id": to_base64("TreeNodeAuthorType", parent.pk), "name": "Parent", "children": { "totalCount": 2, "edges": [ { "node": { "id": to_base64( "TreeNodeAuthorType", child1.pk ), "name": "Child1", } }, { "node": { "id": to_base64( "TreeNodeAuthorType", child2.pk ), "name": "Child2", } }, ], }, } } ], } } @pytest.mark.django_db(transaction=True) def test_nested_children_total_count_no_children(): parent = TreeNodeAuthor.objects.create(name="Parent") query = """\ query { authors { totalCount edges { node { id name children { totalCount edges { node { id name } } } } } } } """ result = schema.execute_sync(query) assert not result.errors assert result.data == { "authors": { "totalCount": 1, "edges": [ { "node": { "id": to_base64("TreeNodeAuthorType", parent.pk), "name": "Parent", "children": { "totalCount": 0, "edges": [], }, } } ], } } strawberry-graphql-django-0.82.1/tests/schema.py000066400000000000000000000003131516173410200216530ustar00rootroot00000000000000import strawberry @strawberry.type class Query: @strawberry.field def hello(self, name: str | None = None) -> str: return f"Hello {name or 'world'}" schema = strawberry.Schema(Query) strawberry-graphql-django-0.82.1/tests/test_apps.py000066400000000000000000000004001516173410200224120ustar00rootroot00000000000000from strawberry_django.apps import StrawberryDjangoConfig def test_app_name() -> None: assert StrawberryDjangoConfig.name == "strawberry_django" def test_verbose_name() -> None: assert StrawberryDjangoConfig.verbose_name == "Strawberry django" strawberry-graphql-django-0.82.1/tests/test_client.py000066400000000000000000000030411516173410200227310ustar00rootroot00000000000000import pytest from django.test.client import Client from strawberry_django.test.client import AsyncTestClient from tests.utils import GraphQLTestClient query_to_non_existed_field = "{ nonExistentField { id } }" def check_non_existed_field_error(errors): assert isinstance(errors, list) assert len(errors) == 1 error = errors[0] assert isinstance(error, dict) assert "nonExistentField" in error["message"] assert "Cannot query field" in error["message"] assert error["locations"] def test_client_assert_no_errors_verbose_message(db): """Test that GraphQLTestClient (sync) raises AssertionError with verbose error messages. This test directly uses GraphQLTestClient with a sync Client to verify the verbose error message functionality in the sync client implementation. """ client = GraphQLTestClient("/graphql/", Client()) with pytest.raises(AssertionError) as exc_info: client.query(query_to_non_existed_field) check_non_existed_field_error(exc_info.value.args[0]) @pytest.mark.asyncio async def test_async_client_assert_no_errors_verbose_message(db): """Test that AsyncTestClient raises AssertionError with verbose error messages. This test directly uses AsyncTestClient to verify the verbose error message functionality in the async client implementation. """ client = AsyncTestClient("/graphql_async/") with pytest.raises(AssertionError) as exc_info: await client.query(query_to_non_existed_field) check_non_existed_field_error(exc_info.value.args[0]) strawberry-graphql-django-0.82.1/tests/test_commands.py000066400000000000000000000021601516173410200232550ustar00rootroot00000000000000import textwrap from io import StringIO from unittest.mock import patch import pytest from django.core.management import call_command from django.core.management.base import CommandError class _FakeSchema: pass def test_django_export_schema(): out = StringIO() call_command("export_schema", "tests.schema", stdout=out) output = out.getvalue() assert output expected = """\ type Query { hello(name: String = null): String! } """ assert output == textwrap.dedent(expected) def test_django_export_schema_exception_handle(): with pytest.raises( CommandError, match=r"No module named 'tests.fake_schema'", ): call_command("export_schema", "tests.fake_schema") mock_import_module = patch( "strawberry_django.management.commands.export_schema.import_module_symbol", return_value=_FakeSchema(), ) with ( mock_import_module, pytest.raises( CommandError, match=r"The `schema` must be an instance of strawberry.Schema", ), ): call_command("export_schema", "tests.schema") strawberry-graphql-django-0.82.1/tests/test_custom_connection.py000066400000000000000000000163531516173410200252160ustar00rootroot00000000000000from collections.abc import Iterable from typing import Any import pytest import strawberry from django.db import connections from django.test.utils import CaptureQueriesContext from strawberry import Info, auto, relay import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry_django.relay import DjangoListConnection from tests.models import Group, Tag, User @strawberry_django.type(Tag) class TagType(relay.Node): name: auto @strawberry_django.type(Group) class GroupType(relay.Node): name: auto tags: list[TagType] @strawberry_django.type(User) class UserType(relay.Node): name: auto group: GroupType @strawberry.type() class CustomConnection(relay.Connection[UserType]): @classmethod def resolve_connection( cls, nodes, *, info: Info, before: str | None = None, after: str | None = None, first: int | None = None, last: int | None = None, **kwargs: Any, ): # Delegate to DjangoListConnection for the actual resolution # This mimics what users do when creating custom connections return DjangoListConnection[UserType].resolve_connection( nodes=nodes, info=info, before=before, after=after, first=first, last=last, ) @strawberry.type class Query: @strawberry_django.connection( graphql_type=CustomConnection, ) def users(self, info: Info) -> Iterable[User]: return User.objects.all() @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("enable_only_optimization", [True, False]) def test_fragment_with_standard_connection_no_n1(enable_only_optimization: bool): """Test that fragments with standard DjangoListConnection work properly (baseline).""" # Setup test data with nested relationships tag1 = Tag.objects.create(name="tag1") tag2 = Tag.objects.create(name="tag2") tag3 = Tag.objects.create(name="tag3") group1 = Group.objects.create(name="group1") group1.tags.add(tag1, tag2) group2 = Group.objects.create(name="group2") group2.tags.add(tag3) User.objects.create(name="user1", group=group1) User.objects.create(name="user2", group=group2) User.objects.create(name="user3", group=group1) # Create schema with standard DjangoListConnection (not custom) @strawberry.type class StandardQuery: users: DjangoListConnection[UserType] = strawberry_django.connection() standard_schema = strawberry.Schema( query=StandardQuery, extensions=[ DjangoOptimizerExtension(enable_only_optimization=enable_only_optimization) ], ) query = """ query MyQuery { users { edges { node { name group { name } } ...UserFragment } } } fragment UserFragment on UserTypeEdge { node { name group { name tags { name } } } } """ with CaptureQueriesContext(connections["default"]) as captured: result = standard_schema.execute_sync(query) # With standard connection, this should work properly (baseline test) assert len(captured) <= 3, ( f"Standard connection should have at most 3 queries, but got {len(captured)}." ) assert result.errors is None assert result.data is not None assert result.data == { "users": { "edges": [ { "node": { "name": "user1", "group": { "name": "group1", "tags": [{"name": "tag1"}, {"name": "tag2"}], }, } }, { "node": { "name": "user2", "group": {"name": "group2", "tags": [{"name": "tag3"}]}, } }, { "node": { "name": "user3", "group": { "name": "group1", "tags": [{"name": "tag1"}, {"name": "tag2"}], }, } }, ] } } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("enable_only_optimization", [True, False]) def test_fragment_with_custom_connection_no_n1(enable_only_optimization: bool): """Test that fragments with custom connections don't cause N+1 queries.""" # Setup test data with nested relationships tag1 = Tag.objects.create(name="tag1") tag2 = Tag.objects.create(name="tag2") tag3 = Tag.objects.create(name="tag3") group1 = Group.objects.create(name="group1") group1.tags.add(tag1, tag2) group2 = Group.objects.create(name="group2") group2.tags.add(tag3) User.objects.create(name="user1", group=group1) User.objects.create(name="user2", group=group2) User.objects.create(name="user3", group=group1) schema = strawberry.Schema( query=Query, extensions=[ DjangoOptimizerExtension(enable_only_optimization=enable_only_optimization) ], ) # Query that uses a fragment selecting deeper relationships than the main query query = """ query MyQuery { users { edges { node { name group { name } } ...UserFragment } } } fragment UserFragment on UserTypeEdge { node { name group { name tags { name } } } } """ with CaptureQueriesContext(connections["default"]) as captured: result = schema.execute_sync(query) assert len(captured) <= 3, ( f"Expected at most 3 queries, but got {len(captured)}. " "This indicates an N+1 query issue. The tags should be prefetched in a single query." ) assert result.errors is None assert result.data is not None assert result.data == { "users": { "edges": [ { "node": { "name": "user1", "group": { "name": "group1", "tags": [{"name": "tag1"}, {"name": "tag2"}], }, } }, { "node": { "name": "user2", "group": {"name": "group2", "tags": [{"name": "tag3"}]}, } }, { "node": { "name": "user3", "group": { "name": "group1", "tags": [{"name": "tag1"}, {"name": "tag2"}], }, } }, ] } } strawberry-graphql-django-0.82.1/tests/test_descriptors.py000066400000000000000000000043621516173410200240230ustar00rootroot00000000000000import textwrap import strawberry from asgiref.sync import sync_to_async import strawberry_django from tests import models def test_model_property(transactional_db): @strawberry_django.type(models.Fruit) class Fruit: name: strawberry.auto name_length: strawberry.auto @strawberry.type class Query: fruit: Fruit = strawberry_django.field() schema = strawberry.Schema(query=Query) assert ( textwrap.dedent(str(schema)) == textwrap.dedent( """\ type Fruit { name: String! nameLength: Int! } type Query { fruit(pk: ID!): Fruit! } """, ).strip() ) fruit1 = models.Fruit.objects.create(name="Banana") fruit2 = models.Fruit.objects.create(name="Apple") query = """ query Fruit($pk: ID!) { fruit(pk: $pk) { name nameLength } } """ for pk, name, length in [(fruit1.pk, "Banana", 6), (fruit2.pk, "Apple", 5)]: result = schema.execute_sync(query, variable_values={"pk": pk}) assert result.errors is None assert result.data == {"fruit": {"name": name, "nameLength": length}} async def test_model_property_async(transactional_db): @strawberry_django.type(models.Fruit) class Fruit: name: strawberry.auto name_length: strawberry.auto @strawberry.type class Query: fruit: Fruit = strawberry_django.field() schema = strawberry.Schema(query=Query) assert ( textwrap.dedent(str(schema)) == textwrap.dedent( """\ type Fruit { name: String! nameLength: Int! } type Query { fruit(pk: ID!): Fruit! } """, ).strip() ) fruit1 = await sync_to_async(models.Fruit.objects.create)(name="Banana") fruit2 = await sync_to_async(models.Fruit.objects.create)(name="Apple") query = """ query Fruit($pk: ID!) { fruit(pk: $pk) { name nameLength } } """ for pk, name, length in [(fruit1.pk, "Banana", 6), (fruit2.pk, "Apple", 5)]: result = await schema.execute(query, variable_values={"pk": pk}) assert result.errors is None assert result.data == {"fruit": {"name": name, "nameLength": length}} strawberry-graphql-django-0.82.1/tests/test_deterministic_ordering.py000066400000000000000000000251731516173410200262210ustar00rootroot00000000000000import pytest from django.db import connection from django.test.utils import CaptureQueriesContext from strawberry.relay.utils import to_base64 from tests import utils from tests.projects.faker import ( FavoriteFactory, IssueFactory, MilestoneFactory, ProjectFactory, QuizFactory, UserFactory, ) from tests.projects.models import Favorite, Milestone, Project, Quiz @pytest.mark.django_db(transaction=True) def test_required(gql_client: utils.GraphQLTestClient): # Query a required field, and a nested required field with no ordering # We expect the queries to **not** have an `ORDER BY` clause query = """ query testRequired($id: ID!) { projectMandatory(id: $id) { name firstMilestoneRequired { name } } } """ # Sanity check the models assert Project._meta.ordering == [] assert Milestone._meta.ordering == [] # Create a project and a milestone project = ProjectFactory() milestone = MilestoneFactory(project=project) # Run the query # Capture the SQL queries that are executed with CaptureQueriesContext(connection) as ctx: result = gql_client.query( query, variables={"id": to_base64("ProjectType", project.pk)}, ) # Sanity check the results assert not result.errors assert result.data == { "projectMandatory": { "name": project.name, "firstMilestoneRequired": {"name": milestone.name}, } } # Assert that the queries do **not** have an `ORDER BY` clause for query in ctx.captured_queries: assert "ORDER BY" not in query["sql"] @pytest.mark.django_db(transaction=True) def test_optional(gql_client: utils.GraphQLTestClient): # Query an optional field, and a nested optional field with no ordering # We expect the queries to have an `ORDER BY` clause query = """ query testOptional($id: ID!) { project(id: $id) { name firstMilestone { name } } } """ # Sanity check the models assert Project._meta.ordering == [] assert Milestone._meta.ordering == [] # Create a project and a milestone project = ProjectFactory() milestone = MilestoneFactory(project=project) # Run the query # Capture the SQL queries that are executed with CaptureQueriesContext(connection) as ctx: result = gql_client.query( query, variables={"id": to_base64("ProjectType", project.pk)}, ) # Sanity check the results assert not result.errors assert result.data == { "project": { "name": project.name, "firstMilestone": {"name": milestone.name}, } } # Assert that the queries do have an `ORDER BY` clause for query in ctx.captured_queries: assert "ORDER BY" in query["sql"] @pytest.mark.django_db(transaction=True) def test_list(gql_client: utils.GraphQLTestClient): # Query a list field, and a nested list field with no ordering # We expect the queries to have an `ORDER BY` clause query = """ query testList{ projectList { name milestones { name } } } """ # Sanity check the models assert Project._meta.ordering == [] assert Milestone._meta.ordering == [] # Create some projects and milestones projects = ProjectFactory.create_batch(3) milestones = [] for project in projects: milestones.extend(MilestoneFactory.create_batch(3, project=project)) # Run the query # Capture the SQL queries that are executed with CaptureQueriesContext(connection) as ctx: result = gql_client.query(query) # Sanity check the results assert not result.errors assert result.data == { "projectList": [ { "name": project.name, "milestones": [ {"name": milestone.name} for milestone in project.milestones.order_by("pk") ], } for project in Project.objects.order_by("pk") ] } # Assert that the queries do have an `ORDER BY` clause for query in ctx.captured_queries: assert "ORDER BY" in query["sql"] @pytest.mark.django_db(transaction=True) def test_connection(gql_client: utils.GraphQLTestClient): # Query a connection field, and a nested connection field with no ordering # We expect the queries to have an `ORDER BY` clause query = """ query testConnection{ projectConn { edges { node { name milestoneConn { edges { node { name } } } } } } } """ # Sanity check the models assert Project._meta.ordering == [] assert Milestone._meta.ordering == [] # Create some projects and milestones projects = ProjectFactory.create_batch(3) milestones = [] for project in projects: milestones.extend(MilestoneFactory.create_batch(3, project=project)) # Run the query # Capture the SQL queries that are executed with CaptureQueriesContext(connection) as ctx: result = gql_client.query(query) # Sanity check the results assert not result.errors assert result.data == { "projectConn": { "edges": [ { "node": { "name": project.name, "milestoneConn": { "edges": [ {"node": {"name": milestone.name}} for milestone in project.milestones.order_by("pk") ] }, } } for project in Project.objects.order_by("pk") ] } } # Assert that the queries do have an `ORDER BY` clause for query in ctx.captured_queries: assert "ORDER BY" in query["sql"] @pytest.mark.django_db(transaction=True) def test_paginated(gql_client: utils.GraphQLTestClient): # Query a paginated field, and a nested paginated field with no ordering # We expect the queries to have an `ORDER BY` clause query = """ query testPaginated{ projectsPaginated { results { name milestonesPaginated { results { name } } } } } """ # Sanity check the models assert Project._meta.ordering == [] assert Milestone._meta.ordering == [] # Create some projects and milestones projects = ProjectFactory.create_batch(3) milestones = [] for project in projects: milestones.extend(MilestoneFactory.create_batch(3, project=project)) # Run the query # Capture the SQL queries that are executed with CaptureQueriesContext(connection) as ctx: result = gql_client.query(query) # Sanity check the results assert not result.errors assert result.data == { "projectsPaginated": { "results": [ { "name": project.name, "milestonesPaginated": { "results": [ {"name": milestone.name} for milestone in project.milestones.order_by("pk") ] }, } for project in Project.objects.order_by("pk") ] } } # Assert that the queries do have an `ORDER BY` clause for query in ctx.captured_queries: assert "ORDER BY" in query["sql"] @pytest.mark.django_db(transaction=True) def test_default_ordering(gql_client: utils.GraphQLTestClient): # Query a field for a model with default ordering # We expect the default ordering to be respected query = """ query testDefaultOrdering{ favoriteConn { edges { node { name } } } } """ # Sanity check the model assert Favorite._meta.ordering == ("name",) # Create some favorites # Ensure the names are in reverse order to the primary keys user = UserFactory() issue = IssueFactory() favorites = [ FavoriteFactory(name=name, user=user, issue=issue) for name in ["c", "b", "a"] ] # Run the query # Note that we need to login to access the favorites with gql_client.login(user): result = gql_client.query(query) # Sanity check the results # We expect the favorites to be ordered by name assert not result.errors assert result.data == { "favoriteConn": { "edges": [ {"node": {"name": favorite.name}} for favorite in reversed(favorites) ] } } @pytest.mark.django_db(transaction=True) def test_get_queryset_ordering(gql_client: utils.GraphQLTestClient): # Query a field for a type with a `get_queryset` method that applies ordering # We expect the ordering to be respected query = """ query testGetQuerySetOrdering{ quizList { title } } """ # Sanity check the model assert Quiz._meta.ordering == [] # Create some quizzes # Ensure the titles are in reverse order to the primary keys quizzes = [QuizFactory(title=title) for title in ["c", "b", "a"]] # Run the query result = gql_client.query(query) # Sanity check the results # We expect the quizzes to be ordered by title assert not result.errors assert result.data == { "quizList": [{"title": quiz.title} for quiz in reversed(quizzes)] } @pytest.mark.django_db(transaction=True) def test_graphql_ordering(gql_client: utils.GraphQLTestClient): # Query a field for a type that allows ordering via GraphQL # We expect the ordering to be respected query = """ query testGraphQLOrdering{ milestoneList(order: { name: ASC }) { name } } """ # Sanity check the model assert Milestone._meta.ordering == [] # Create some milestones # Ensure the names are in reverse order to the primary keys milestones = [MilestoneFactory(name=name) for name in ["c", "b", "a"]] # Run the query result = gql_client.query(query) # Sanity check the results # We expect the milestones to be ordered by name assert not result.errors assert result.data == { "milestoneList": [ {"name": milestone.name} for milestone in reversed(milestones) ] } strawberry-graphql-django-0.82.1/tests/test_enums.py000066400000000000000000000300361516173410200226060ustar00rootroot00000000000000import textwrap from typing import cast import pytest import strawberry from django.db import models from django.test import override_settings from django.utils.translation import gettext_lazy from django_choices_field import IntegerChoicesField, TextChoicesField from pytest_mock import MockerFixture import strawberry_django from strawberry_django import mutations from strawberry_django.fields import types from strawberry_django.fields.types import field_type_map from strawberry_django.settings import strawberry_django_settings class Choice(models.TextChoices): """Choice description.""" A = "a", "A description" B = "b", "B description" C = "c", gettext_lazy("C description") D = "12d-d'éléphant_🐘", "D description" E = "_2d_d__l_phant__", "E description" __empty__ = "Empty" class IntegerChoice(models.IntegerChoices): """IntegerChoice description.""" X = 1, "1 description" Y = 2, "2 description" Z = 3, gettext_lazy("3 description") class ChoicesModel(models.Model): attr1 = TextChoicesField(choices_enum=Choice) attr2 = IntegerChoicesField(choices_enum=IntegerChoice) attr3 = models.CharField( max_length=255, choices=[ ("c", "C description"), ("d", gettext_lazy("D description")), ], ) attr4 = models.IntegerField( choices=[ (4, "4 description"), (5, gettext_lazy("5 description")), ], ) attr5 = models.CharField( max_length=255, choices=Choice.choices, ) attr6 = models.IntegerField( choices=IntegerChoice.choices, ) class ChoicesWithExtraFieldsModel(models.Model): attr1 = TextChoicesField(choices_enum=Choice) attr2 = IntegerChoicesField(choices_enum=IntegerChoice) attr3 = models.CharField( max_length=255, choices=[ ("c", "C description"), ("d", gettext_lazy("D description")), ], ) attr4 = models.IntegerField( choices=[ (4, "4 description"), (5, gettext_lazy("5 description")), ], ) attr5 = models.CharField( max_length=255, choices=Choice.choices, ) attr6 = models.IntegerField( choices=IntegerChoice.choices, ) extra1 = models.CharField(max_length=255) extra2 = models.PositiveIntegerField() def test_choices_field(): @strawberry_django.type(ChoicesModel) class ChoicesType: attr1: strawberry.auto attr2: strawberry.auto attr3: strawberry.auto attr4: strawberry.auto attr5: strawberry.auto attr6: strawberry.auto @strawberry.type class Query: @strawberry_django.field def obj(self) -> ChoicesType: return cast( "ChoicesType", ChoicesModel( attr1=Choice.A, attr2=IntegerChoice.X, attr3="c", attr4=4, attr5=Choice.A, attr6=IntegerChoice.X, ), ) expected = """\ enum Choice { A B C D E } type ChoicesType { attr1: Choice! attr2: IntegerChoice! attr3: String! attr4: Int! attr5: String! attr6: Int! } enum IntegerChoice { X Y Z } type Query { obj: ChoicesType! } """ schema = strawberry.Schema(query=Query) assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() result = schema.execute_sync( "query { obj { attr1, attr2, attr3, attr4, attr5, attr6 }}", ) assert result.errors is None assert result.data == { "obj": { "attr1": "A", "attr2": "X", "attr3": "c", "attr4": 4, "attr5": "a", "attr6": 1, }, } def test_no_choices_enum(mocker: MockerFixture): # We can't use patch with the module name directly as it tries to resolve # strawberry.fields as a function instead of the module for python versions # before 3.11 mocker.patch.object(types, "TextChoicesField", None) mocker.patch.dict(field_type_map, {TextChoicesField: str}) mocker.patch.object(types, "IntegerChoicesField", None) mocker.patch.dict(field_type_map, {IntegerChoicesField: str}) @strawberry_django.type(ChoicesModel) class ChoicesType: attr1: strawberry.auto attr2: strawberry.auto attr3: strawberry.auto attr4: strawberry.auto attr5: strawberry.auto attr6: strawberry.auto @strawberry.type class Query: @strawberry_django.field def obj(self) -> ChoicesType: return cast( "ChoicesType", ChoicesModel( attr1=Choice.A, attr2=IntegerChoice.X, attr3="c", attr4=4, attr5=Choice.A, attr6=IntegerChoice.X, ), ) expected = """\ type ChoicesType { attr1: String! attr2: String! attr3: String! attr4: Int! attr5: String! attr6: Int! } type Query { obj: ChoicesType! } """ schema = strawberry.Schema(query=Query) assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() result = schema.execute_sync( "query { obj { attr1, attr2, attr3, attr4, attr5, attr6 }}", ) assert result.errors is None assert result.data == { "obj": { "attr1": "a", "attr2": "1", "attr3": "c", "attr4": 4, "attr5": "a", "attr6": 1, }, } @override_settings( STRAWBERRY_DJANGO={ **strawberry_django_settings(), "GENERATE_ENUMS_FROM_CHOICES": True, }, ) def test_generate_choices_from_enum(): @strawberry_django.type(ChoicesModel) class ChoicesType: attr1: strawberry.auto attr2: strawberry.auto attr3: strawberry.auto attr4: strawberry.auto attr5: strawberry.auto attr6: strawberry.auto @strawberry.type class Query: @strawberry_django.field def obj(self) -> ChoicesType: return cast( "ChoicesType", ChoicesModel( attr1=Choice.A, attr2=IntegerChoice.X, attr3="c", attr4=4, attr5=Choice.A, attr6=IntegerChoice.X, ), ) expected = '''\ enum Choice { A B C D E } type ChoicesType { attr1: Choice! attr2: IntegerChoice! attr3: TestsChoicesModelAttr3Enum! attr4: Int! attr5: TestsChoicesModelAttr5Enum! attr6: Int! } enum IntegerChoice { X Y Z } type Query { obj: ChoicesType! } enum TestsChoicesModelAttr3Enum { """C description""" c """D description""" d } enum TestsChoicesModelAttr5Enum { """A description""" a """B description""" b """C description""" c """D description""" _2d_d__l_phant__ """E description""" _2d_d__l_phant___ } ''' schema = strawberry.Schema(query=Query) assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() result = schema.execute_sync( "query { obj { attr1, attr2, attr3, attr4, attr5, attr6 }}", ) assert result.errors is None assert result.data == { "obj": { "attr1": "A", "attr2": "X", "attr3": "c", "attr4": 4, "attr5": "a", "attr6": 1, }, } @override_settings( STRAWBERRY_DJANGO={ **strawberry_django_settings(), "GENERATE_ENUMS_FROM_CHOICES": True, }, ) def test_generate_choices_from_enum_with_extra_fields(): @strawberry_django.type(ChoicesWithExtraFieldsModel) class ChoicesWithExtraFieldsType: attr1: strawberry.auto attr2: strawberry.auto attr3: strawberry.auto attr4: strawberry.auto attr5: strawberry.auto attr6: strawberry.auto extra1: strawberry.auto extra2: strawberry.auto @strawberry.type class Query: @strawberry_django.field def obj(self) -> ChoicesWithExtraFieldsType: return cast( "ChoicesWithExtraFieldsType", ChoicesWithExtraFieldsModel( attr1=Choice.A, attr2=IntegerChoice.X, attr3="c", attr4=4, attr5=Choice.A, attr6=IntegerChoice.X, extra1="str1", extra2=99, ), ) expected = '''\ enum Choice { A B C D E } type ChoicesWithExtraFieldsType { attr1: Choice! attr2: IntegerChoice! attr3: TestsChoicesWithExtraFieldsModelAttr3Enum! attr4: Int! attr5: TestsChoicesWithExtraFieldsModelAttr5Enum! attr6: Int! extra1: String! extra2: Int! } enum IntegerChoice { X Y Z } type Query { obj: ChoicesWithExtraFieldsType! } enum TestsChoicesWithExtraFieldsModelAttr3Enum { """C description""" c """D description""" d } enum TestsChoicesWithExtraFieldsModelAttr5Enum { """A description""" a """B description""" b """C description""" c """D description""" _2d_d__l_phant__ """E description""" _2d_d__l_phant___ } ''' schema = strawberry.Schema(query=Query) assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() result = schema.execute_sync( "query { obj { attr1, attr2, attr3, attr4, attr5, attr6, extra1, extra2 }}", ) assert result.errors is None assert result.data == { "obj": { "attr1": "A", "attr2": "X", "attr3": "c", "attr4": 4, "attr5": "a", "attr6": 1, "extra1": "str1", "extra2": 99, }, } @override_settings( STRAWBERRY_DJANGO={ **strawberry_django_settings(), "GENERATE_ENUMS_FROM_CHOICES": True, }, ) @pytest.mark.django_db(transaction=True) def test_create_mutation_with_generated_enum_input(db): @strawberry_django.type(ChoicesModel) class ChoicesType: attr1: strawberry.auto attr2: strawberry.auto attr3: strawberry.auto attr4: strawberry.auto attr5: strawberry.auto attr6: strawberry.auto @strawberry_django.input(ChoicesModel) class ChoicesInput: attr1: strawberry.auto attr2: strawberry.auto attr3: strawberry.auto attr4: strawberry.auto attr5: strawberry.auto attr6: strawberry.auto @strawberry.type class Query: choice: ChoicesType = strawberry_django.field() @strawberry.type class Mutation: create_choice: ChoicesType = mutations.create( ChoicesInput, handle_django_errors=True, argument_name="input" ) schema = strawberry.Schema(query=Query, mutation=Mutation) variables = { "input": { "attr1": "A", "attr2": "X", "attr3": Choice.C, "attr4": 4, "attr5": "a", "attr6": 1, } } result = schema.execute_sync( """ mutation CreateChoice($input: ChoicesInput!) { createChoice(input: $input) { ... on OperationInfo { messages { kind field message } } ... on ChoicesType { attr3 } } } """, variables, ) assert result.data == {"createChoice": {"attr3": "c"}} strawberry-graphql-django-0.82.1/tests/test_field_extensions.py000066400000000000000000000041671516173410200250270ustar00rootroot00000000000000import strawberry from strawberry.annotation import StrawberryAnnotation from strawberry.extensions.field_extension import FieldExtension from strawberry.types.arguments import StrawberryArgument import strawberry_django from tests.models import Fruit class AddArgumentExtension(FieldExtension): def apply(self, field): field.arguments.append( StrawberryArgument( python_name="some_argument", graphql_name="someArgument", type_annotation=StrawberryAnnotation(annotation=bool | None), ), ) def resolve(self, next_, source, info, **kwargs): return next_(source, info, **kwargs) def test_field_extension_arguments_on_strawberry_django_field(): @strawberry.type class Query: @strawberry_django.field(extensions=[AddArgumentExtension()]) def my_field(self) -> bool: return True schema = strawberry.Schema(query=Query) schema_str = schema.as_str() assert "someArgument" in schema_str def test_field_extension_arguments_on_strawberry_django_field_list(): @strawberry_django.type(Fruit) class FruitType: id: strawberry.auto name: strawberry.auto @strawberry.type class Query: @strawberry_django.field(extensions=[AddArgumentExtension()]) def my_field(self) -> list[FruitType]: return [] schema = strawberry.Schema(query=Query) schema_str = schema.as_str() assert "someArgument" in schema_str def test_field_extension_arguments_parity_with_strawberry_field(): @strawberry.type class Query: @strawberry.field(extensions=[AddArgumentExtension()]) def strawberry_field(self) -> bool: return True @strawberry_django.field(extensions=[AddArgumentExtension()]) def django_field(self) -> bool: return True schema = strawberry.Schema(query=Query) schema_str = schema.as_str() for line in schema_str.split("\n"): if "strawberryField" in line: assert "someArgument" in line if "djangoField" in line: assert "someArgument" in line strawberry-graphql-django-0.82.1/tests/test_field_name_traversal.py000066400000000000000000000251151516173410200256270ustar00rootroot00000000000000"""Tests for field_name with double-underscore relationship traversal.""" import operator from typing import TYPE_CHECKING, Any, cast import pytest from django.contrib.auth import get_user_model from django.db.models.constants import LOOKUP_SEP from strawberry.types import get_object_definition from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry_django.resolvers import _django_getattr # noqa: PLC2701 from tests.projects.models import Role, UserAssignedRole from tests.utils import assert_num_queries if TYPE_CHECKING: from strawberry_django.fields.field import StrawberryDjangoField User = get_user_model() pytestmark = pytest.mark.django_db def test_field_name_traversal_basic(db): """Test basic relationship traversal with field_name.""" from tests.projects.schema import UserType # Create test data user = User.objects.create(username="testuser", email="test@example.com") role = Role.objects.create(name="Admin", description="Administrator role") UserAssignedRole.objects.create(user=user, role=role) # Refresh from db to test actual field resolution user = User.objects.get(pk=user.pk) # Test that we can access the role directly via field_name="assigned_role__role" definition = get_object_definition(UserType, strict=True) role_field = next(f for f in definition.fields if f.python_name == "role") role_field = cast("StrawberryDjangoField", role_field) # Verify the field uses the correct django_name assert role_field.django_name == "assigned_role__role" # Test actual resolution - simulate what would happen in GraphQL query # The field should be able to traverse the relationship result = role_field.get_result(user, None, [], {}) result = cast("Any", result) # Since we're not in an async context and the field doesn't have a custom resolver, # it should use the default getattr which now supports __ traversal assert result is not None assert result.name == "Admin" assert result.description == "Administrator role" def test_field_name_traversal_to_scalar(db): """Test relationship traversal to a scalar field.""" from tests.projects.schema import UserType # Create test data user = User.objects.create(username="testuser2", email="test2@example.com") role = Role.objects.create(name="Editor", description="Editor role") UserAssignedRole.objects.create(user=user, role=role) # Refresh from db user = User.objects.get(pk=user.pk) # Test role_name field which uses field_name="assigned_role__role__name" definition = get_object_definition(UserType, strict=True) role_name_field = next(f for f in definition.fields if f.python_name == "role_name") role_name_field = cast("StrawberryDjangoField", role_name_field) assert role_name_field.django_name == "assigned_role__role__name" # Test resolution result = role_name_field.get_result(user, None, [], {}) assert result == "Editor" def test_field_name_traversal_with_none_intermediate(db): """Test that None in intermediate relationships returns None.""" from tests.projects.schema import UserType # Create user WITHOUT assigned role user = User.objects.create(username="testuser3", email="test3@example.com") # Refresh from db user = User.objects.get(pk=user.pk) # Test role field - should return None since assigned_role doesn't exist definition = get_object_definition(UserType, strict=True) role_field = next(f for f in definition.fields if f.python_name == "role") role_field = cast("StrawberryDjangoField", role_field) # When assigned_role is None, the traversal should return None result = role_field.get_result(user, None, [], {}) assert result is None def test_field_name_traversal_with_none_scalar(db): """Test that None in intermediate relationships returns None for scalar fields.""" from tests.projects.schema import UserType # Create user WITHOUT assigned role user = User.objects.create(username="testuser4", email="test4@example.com") # Refresh from db user = User.objects.get(pk=user.pk) # Test role_name field - should return None since assigned_role doesn't exist definition = get_object_definition(UserType, strict=True) role_name_field = next(f for f in definition.fields if f.python_name == "role_name") role_name_field = cast("StrawberryDjangoField", role_name_field) result = role_name_field.get_result(user, None, [], {}) assert result is None def test_field_name_traversal_scalar_query_count(db, gql_client): """Ensure scalar traversal doesn't generate extra queries with optimizer.""" if gql_client.is_async: pytest.skip("Query counting with async client can lock sqlite tables") user1 = User.objects.create(username="query_user1", email="q1@example.com") user2 = User.objects.create(username="query_user2", email="q2@example.com") role1 = Role.objects.create(name="Role1", description="Role1 description") role2 = Role.objects.create(name="Role2", description="Role2 description") UserAssignedRole.objects.create(user=user1, role=role1) UserAssignedRole.objects.create(user=user2, role=role2) query = """ query GetUsers { userList { email roleName } } """ expected_queries = 1 if DjangoOptimizerExtension.enabled.get() else 5 with assert_num_queries(expected_queries): res = gql_client.query(query) assert res.errors is None expected_users = [ {"email": "q1@example.com", "roleName": "Role1"}, {"email": "q2@example.com", "roleName": "Role2"}, ] actual_users = sorted(res.data["userList"], key=operator.itemgetter("email")) assert actual_users == sorted(expected_users, key=operator.itemgetter("email")) def test_field_name_traversal_object_query_count(db, gql_client): """GraphQL integration test for object traversal via field_name.""" if gql_client.is_async: pytest.skip("Query counting with async client can lock sqlite tables") user1 = User.objects.create(username="role_user1", email="r1@example.com") user2 = User.objects.create(username="role_user2", email="r2@example.com") role1 = Role.objects.create(name="RoleA", description="RoleA description") role2 = Role.objects.create(name="RoleB", description="RoleB description") UserAssignedRole.objects.create(user=user1, role=role1) UserAssignedRole.objects.create(user=user2, role=role2) query = """ query GetUsersWithRoles { userList { email role { name } } } """ expected_queries = 1 if DjangoOptimizerExtension.enabled.get() else 5 with assert_num_queries(expected_queries): res = gql_client.query(query) assert res.errors is None expected_users = [ {"email": "r1@example.com", "role": {"name": "RoleA"}}, {"email": "r2@example.com", "role": {"name": "RoleB"}}, ] actual_users = sorted(res.data["userList"], key=operator.itemgetter("email")) assert actual_users == sorted(expected_users, key=operator.itemgetter("email")) def test_django_getattr_traversal(): """Unit test for _django_getattr with double-underscore notation.""" # Create a mock object structure class MockRole: name = "TestRole" description = "Test description" class MockAssignedRole: role = MockRole() class MockUser: assigned_role = MockAssignedRole() user = MockUser() # Test single level result = _django_getattr(user, "assigned_role") assert result is user.assigned_role # Test double underscore traversal result = _django_getattr(user, "assigned_role__role") assert result is user.assigned_role.role # Test triple underscore traversal to scalar result = _django_getattr(user, "assigned_role__role__name") assert result == "TestRole" # Test traversal where intermediate attribute exists but a deeper attribute is missing class EmptyMockAssignedRole: pass class UserWithEmptyAssignedRole: assigned_role = EmptyMockAssignedRole() user_with_empty = UserWithEmptyAssignedRole() with pytest.raises(AttributeError): _django_getattr(user_with_empty, "assigned_role__nonexistent") assert ( _django_getattr(user_with_empty, "assigned_role__nonexistent", "default") == "default" ) def test_django_getattr_traversal_model_branch(db): """Exercise models.Model traversal behavior for unset related fields.""" user = User.objects.create( username="model_branch_user", email="model_branch@example.com", ) assert _django_getattr(user, LOOKUP_SEP.join(["assigned_role", "role"])) is None with pytest.raises(AttributeError): _django_getattr(user, LOOKUP_SEP.join(["nonexistent", "name"])) def test_django_getattr_traversal_with_none(): """Test _django_getattr returns None when intermediate value is None.""" class MockUser: assigned_role = None user = MockUser() # Test that traversal returns None when intermediate is None result = _django_getattr(user, "assigned_role__role") assert result is None # Test with deeper traversal result = _django_getattr(user, "assigned_role__role__name") assert result is None def test_django_getattr_traversal_attribute_error(): """Test _django_getattr raises AttributeError when attribute doesn't exist.""" class MockUser: pass user = MockUser() # Should raise AttributeError for non-existent attribute with pytest.raises(AttributeError): _django_getattr(user, "nonexistent__field") with pytest.raises(AttributeError): _django_getattr(user, "__role") with pytest.raises(AttributeError): _django_getattr(user, "role__") with pytest.raises(AttributeError): _django_getattr(user, "assigned_role____name") def test_django_getattr_traversal_with_default(): """Test _django_getattr returns default for missing attributes.""" class MockUser: assigned_role = None user = MockUser() # Test that None is returned when intermediate is None result = _django_getattr(user, "assigned_role__role", "default_value") assert result is None # Test with AttributeError result = _django_getattr(user, "nonexistent__field", "default_value") assert result == "default_value" result = _django_getattr(user, "__role", "default_value") assert result == "default_value" result = _django_getattr(user, "assigned_role____name", "default_value") assert result == "default_value" strawberry-graphql-django-0.82.1/tests/test_field_permissions.py000066400000000000000000000156301516173410200252000ustar00rootroot00000000000000from collections.abc import Awaitable from typing import Any import pytest import strawberry from strawberry import BasePermission, Info import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from tests import models @pytest.mark.django_db(transaction=True) async def test_with_async_permission(db): class AsyncPermission(BasePermission): async def has_permission( # type: ignore self, source: Any, info: Info, **kwargs: Any, ) -> bool | Awaitable[bool]: return True @strawberry_django.type(models.Fruit) class Fruit: name: strawberry.auto @strawberry_django.type(models.Color) class Color: name: strawberry.auto fruits: list[Fruit] = strawberry_django.field( permission_classes=[AsyncPermission] ) @strawberry.type(name="Query") class Query: colors: list[Color] = strawberry_django.field() red = await models.Color.objects.acreate(name="Red") yellow = await models.Color.objects.acreate(name="Yellow") await models.Fruit.objects.acreate(name="Apple", color=red) await models.Fruit.objects.acreate(name="Banana", color=yellow) await models.Fruit.objects.acreate(name="Strawberry", color=red) schema = strawberry.Schema(query=Query) query = """ query { colors { name fruits { name } } } """ result = await schema.execute(query) assert result.errors is None assert result.data == { "colors": [ { "name": "Red", "fruits": [ {"name": "Apple"}, {"name": "Strawberry"}, ], }, { "name": "Yellow", "fruits": [{"name": "Banana"}], }, ] } @pytest.mark.django_db(transaction=True) async def test_with_async_permission_and_optimizer(db): class AsyncPermission(BasePermission): async def has_permission( # type: ignore self, source: Any, info: Info, **kwargs: Any, ) -> bool | Awaitable[bool]: return True @strawberry_django.type(models.Fruit) class Fruit: name: strawberry.auto @strawberry_django.type(models.Color) class Color: name: strawberry.auto fruits: list[Fruit] = strawberry_django.field( permission_classes=[AsyncPermission] ) @strawberry.type(name="Query") class Query: colors: list[Color] = strawberry_django.field() red = await models.Color.objects.acreate(name="Red") yellow = await models.Color.objects.acreate(name="Yellow") await models.Fruit.objects.acreate(name="Apple", color=red) await models.Fruit.objects.acreate(name="Banana", color=yellow) await models.Fruit.objects.acreate(name="Strawberry", color=red) schema = strawberry.Schema( query=Query, extensions=[DjangoOptimizerExtension()], ) query = """ query { colors { name fruits { name } } } """ result = await schema.execute(query) assert result.errors is None assert result.data == { "colors": [ { "name": "Red", "fruits": [ {"name": "Apple"}, {"name": "Strawberry"}, ], }, { "name": "Yellow", "fruits": [{"name": "Banana"}], }, ] } @pytest.mark.django_db(transaction=True) def test_with_sync_permission(db): class AsyncPermission(BasePermission): def has_permission( self, source: Any, info: Info, **kwargs: Any, ) -> bool | Awaitable[bool]: return True @strawberry_django.type(models.Fruit) class Fruit: name: strawberry.auto @strawberry_django.type(models.Color) class Color: name: strawberry.auto fruits: list[Fruit] = strawberry_django.field( permission_classes=[AsyncPermission] ) @strawberry.type(name="Query") class Query: colors: list[Color] = strawberry_django.field() red = models.Color.objects.create(name="Red") yellow = models.Color.objects.create(name="Yellow") models.Fruit.objects.create(name="Apple", color=red) models.Fruit.objects.create(name="Banana", color=yellow) models.Fruit.objects.create(name="Strawberry", color=red) schema = strawberry.Schema(query=Query) query = """ query { colors { name fruits { name } } } """ result = schema.execute_sync(query) assert result.errors is None assert result.data == { "colors": [ { "name": "Red", "fruits": [ {"name": "Apple"}, {"name": "Strawberry"}, ], }, { "name": "Yellow", "fruits": [{"name": "Banana"}], }, ] } @pytest.mark.django_db(transaction=True) def test_with_sync_permission_and_optimizer(db): class AsyncPermission(BasePermission): def has_permission( self, source: Any, info: Info, **kwargs: Any, ) -> bool | Awaitable[bool]: return True @strawberry_django.type(models.Fruit) class Fruit: name: strawberry.auto @strawberry_django.type(models.Color) class Color: name: strawberry.auto fruits: list[Fruit] = strawberry_django.field( permission_classes=[AsyncPermission] ) @strawberry.type(name="Query") class Query: colors: list[Color] = strawberry_django.field() red = models.Color.objects.create(name="Red") yellow = models.Color.objects.create(name="Yellow") models.Fruit.objects.create(name="Apple", color=red) models.Fruit.objects.create(name="Banana", color=yellow) models.Fruit.objects.create(name="Strawberry", color=red) schema = strawberry.Schema( query=Query, extensions=[DjangoOptimizerExtension()], ) query = """ query { colors { name fruits { name } } } """ result = schema.execute_sync(query) assert result.errors is None assert result.data == { "colors": [ { "name": "Red", "fruits": [ {"name": "Apple"}, {"name": "Strawberry"}, ], }, { "name": "Yellow", "fruits": [{"name": "Banana"}], }, ] } strawberry-graphql-django-0.82.1/tests/test_input_mutations.py000066400000000000000000001350261516173410200247260ustar00rootroot00000000000000from unittest.mock import patch import pytest from django.core.exceptions import ValidationError from strawberry.relay import from_base64, to_base64 from strawberry_django.optimizer import DjangoOptimizerExtension from tests.utils import GraphQLTestClient, assert_num_queries from .projects.faker import ( IssueFactory, MilestoneFactory, ProjectFactory, TagFactory, UserFactory, ) from .projects.models import Issue, Milestone, Project, Tag @pytest.mark.django_db(transaction=True) def test_input_mutation(db, gql_client: GraphQLTestClient): query = """ mutation CreateProject ($input: CreateProjectInput!) { createProject (input: $input) { ... on ProjectType { name cost dueDate } } } """ with assert_num_queries(1): res = gql_client.query( query, { "input": { "name": "Some Project", "cost": "12.50", "dueDate": "2030-01-01", }, }, ) assert res.data == { "createProject": { "name": "Some Project", # The cost is properly set, but this user doesn't have # permission to see it "cost": None, "dueDate": "2030-01-01", }, } @pytest.mark.django_db(transaction=True) def test_input_mutation_with_internal_error_code(db, gql_client: GraphQLTestClient): query = """ mutation CreateProject ($input: CreateProjectInput!) { createProject (input: $input) { ... on ProjectType { name cost } ... on OperationInfo { messages { field message kind code } } } } """ with assert_num_queries(0): res = gql_client.query( query, {"input": {"name": 100 * "way to long", "cost": "10.40"}}, ) assert res.data == { "createProject": { "messages": [ { "field": "name", "kind": "VALIDATION", "message": ( "Ensure this value has at most 255 characters (it has" " 1100)." ), "code": "max_length", }, ], }, } @pytest.mark.django_db(transaction=True) def test_input_mutation_with_explicit_error_code(db, gql_client: GraphQLTestClient): query = """ mutation CreateProject ($input: CreateProjectInput!) { createProject (input: $input) { ... on ProjectType { name cost } ... on OperationInfo { messages { field message kind code } } } } """ with assert_num_queries(0): res = gql_client.query( query, {"input": {"name": "Some Project", "cost": "-1"}}, ) assert res.data == { "createProject": { "messages": [ { "field": "cost", "kind": "VALIDATION", "message": "Cost cannot be lower than zero", "code": "min_cost", }, ], }, } @pytest.mark.django_db(transaction=True) def test_input_mutation_with_errors(db, gql_client: GraphQLTestClient): query = """ mutation CreateProject ($input: CreateProjectInput!) { createProject (input: $input) { ... on ProjectType { name cost } ... on OperationInfo { messages { field message kind code } } } } """ with assert_num_queries(0): res = gql_client.query( query, {"input": {"name": "Some Project", "cost": "501.50"}}, ) assert res.data == { "createProject": { "messages": [ { "field": "cost", "kind": "VALIDATION", "message": "Cost cannot be higher than 500", "code": None, }, ], }, } @pytest.mark.django_db(transaction=True) def test_input_create_mutation(db, gql_client: GraphQLTestClient): query = """ mutation CreateIssue ($input: IssueInput!) { createIssue (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on IssueType { id name milestone { id name } priority kind tags { id name } } } } """ milestone = MilestoneFactory.create() tags = TagFactory.create_batch(4) res = gql_client.query( query, { "input": { "name": "Some Issue", "milestone": {"id": to_base64("MilestoneType", milestone.pk)}, "priority": 5, "kind": Issue.Kind.FEATURE.value, "tags": [{"id": to_base64("TagType", t.pk)} for t in tags], }, }, ) assert res.data assert isinstance(res.data["createIssue"], dict) typename, pk = from_base64(res.data["createIssue"].pop("id")) assert typename == "IssueType" assert {frozenset(t.items()) for t in res.data["createIssue"].pop("tags")} == { frozenset({"id": to_base64("TagType", t.pk), "name": t.name}.items()) for t in tags } assert res.data == { "createIssue": { "__typename": "IssueType", "name": "Some Issue", "milestone": { "id": to_base64("MilestoneType", milestone.pk), "name": milestone.name, }, "priority": 5, "kind": "f", }, } issue = Issue.objects.get(pk=pk) assert issue.name == "Some Issue" assert issue.priority == 5 assert issue.kind == Issue.Kind.FEATURE assert issue.milestone == milestone assert set(issue.tags.all()) == set(tags) @pytest.mark.django_db(transaction=True) def test_input_create_mutation_nested_creation(db, gql_client: GraphQLTestClient): query = """ mutation CreateIssue ($input: IssueInput!) { createIssue (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on IssueType { id name milestone { id name project { id name } } } } } """ assert not Project.objects.filter(name="New Project").exists() assert not Milestone.objects.filter(name="New Milestone").exists() assert not Issue.objects.filter(name="New Issue").exists() res = gql_client.query( query, { "input": { "name": "New Issue", "milestone": { "name": "New Milestone", "project": { "name": "New Project", }, }, }, }, ) assert res.data assert isinstance(res.data["createIssue"], dict) typename, pk = from_base64(res.data["createIssue"]["id"]) assert typename == "IssueType" issue = Issue.objects.get(pk=pk) assert issue.name == "New Issue" milestone = Milestone.objects.get(name="New Milestone") assert milestone.name == "New Milestone" project = Project.objects.get(name="New Project") assert project.name == "New Project" assert milestone.project_id == project.pk assert issue.milestone_id == milestone.pk assert res.data == { "createIssue": { "__typename": "IssueType", "id": to_base64("IssueType", issue.pk), "name": "New Issue", "milestone": { "id": to_base64("MilestoneType", milestone.pk), "name": "New Milestone", "project": { "id": to_base64("ProjectType", project.pk), "name": "New Project", }, }, }, } @pytest.mark.django_db(transaction=True) def test_input_create_with_m2m_mutation(db, gql_client: GraphQLTestClient): query = """ mutation CreateMilestone ($input: MilestoneInput!) { createMilestone (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on MilestoneType { id name project { id name } issues { id name } } } } """ project = ProjectFactory.create() res = gql_client.query( query, { "input": { "name": "Some Milestone", "project": { "id": to_base64("ProjectType", project.pk), }, "issues": [ { "name": "Milestone Issue 1", }, { "name": "Milestone Issue 2", }, ], }, }, ) assert res.data assert isinstance(res.data["createMilestone"], dict) typename, pk = from_base64(res.data["createMilestone"].pop("id")) assert typename == "MilestoneType" issues = res.data["createMilestone"].pop("issues") assert {i["name"] for i in issues} == {"Milestone Issue 1", "Milestone Issue 2"} assert res.data == { "createMilestone": { "__typename": "MilestoneType", "name": "Some Milestone", "project": { "id": to_base64("ProjectType", project.pk), "name": project.name, }, }, } milestone = Milestone.objects.get(pk=pk) assert milestone.name == "Some Milestone" assert milestone.project == project assert {i.name for i in milestone.issues.all()} == { "Milestone Issue 1", "Milestone Issue 2", } @pytest.mark.django_db(transaction=True) def test_input_create_mutation_with_multiple_level_nested_creation( db, gql_client: GraphQLTestClient ): query = """ mutation createProjectWithMilestones ($input: ProjectInputPartial!) { createProjectWithMilestones (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on ProjectType { id name milestones { id name issues { id name tags { name } } } } } } """ shared_tag = TagFactory.create(name="Shared Tag") shared_tag_id = to_base64("TagType", shared_tag.pk) res = gql_client.query( query, { "input": { "name": "Some Project", "milestones": [ { "name": "Some Milestone", "issues": [ { "name": "Some Issue", "tags": [ {"name": "Tag 1"}, {"name": "Tag 2"}, {"name": "Tag 3"}, {"id": shared_tag_id}, ], } ], }, { "name": "Another Milestone", "issues": [ { "name": "Some Issue", "tags": [ {"name": "Tag 4"}, {"id": shared_tag_id}, ], }, { "name": "Another Issue", "tags": [ {"name": "Tag 5"}, {"id": shared_tag_id}, ], }, { "name": "Third issue", "tags": [ {"name": "Tag 6"}, {"id": shared_tag_id}, ], }, ], }, ], }, }, ) assert res.data assert isinstance(res.data["createProjectWithMilestones"], dict) projects = Project.objects.all() project_typename, project_pk = from_base64( res.data["createProjectWithMilestones"].pop("id") ) assert project_typename == "ProjectType" assert projects[0] == Project.objects.get(pk=project_pk) milestones = Milestone.objects.all() assert len(milestones) == 2 assert len(res.data["createProjectWithMilestones"]["milestones"]) == 2 some_milestone = res.data["createProjectWithMilestones"]["milestones"][0] milestone_typename, milestone_pk = from_base64(some_milestone.pop("id")) assert milestone_typename == "MilestoneType" assert milestones[0] == Milestone.objects.get(pk=milestone_pk) another_milestone = res.data["createProjectWithMilestones"]["milestones"][1] milestone_typename, milestone_pk = from_base64(another_milestone.pop("id")) assert milestone_typename == "MilestoneType" assert milestones[1] == Milestone.objects.get(pk=milestone_pk) issues = Issue.objects.all() assert len(issues) == 4 assert len(some_milestone["issues"]) == 1 assert len(another_milestone["issues"]) == 3 # Issues for first milestone fetched_issue = some_milestone["issues"][0] issue_typename, issue_pk = from_base64(fetched_issue.pop("id")) assert issue_typename == "IssueType" assert issues[0] == Issue.objects.get(pk=issue_pk) # Issues for second milestone for i in range(3): fetched_issue = another_milestone["issues"][i] issue_typename, issue_pk = from_base64(fetched_issue.pop("id")) assert issue_typename == "IssueType" assert issues[i + 1] == Issue.objects.get(pk=issue_pk) tags = Tag.objects.all() assert len(tags) == 7 assert len(issues[0].tags.all()) == 4 # 3 new tags + shared tag assert len(issues[1].tags.all()) == 2 # 1 new tag + shared tag assert len(issues[2].tags.all()) == 2 # 1 new tag + shared tag assert len(issues[3].tags.all()) == 2 # 1 new tag + shared tag assert res.data == { "createProjectWithMilestones": { "__typename": "ProjectType", "name": "Some Project", "milestones": [ { "name": "Some Milestone", "issues": [ { "name": "Some Issue", "tags": [ {"name": "Shared Tag"}, {"name": "Tag 1"}, {"name": "Tag 2"}, {"name": "Tag 3"}, ], } ], }, { "name": "Another Milestone", "issues": [ { "name": "Some Issue", "tags": [ {"name": "Shared Tag"}, {"name": "Tag 4"}, ], }, { "name": "Another Issue", "tags": [ {"name": "Shared Tag"}, {"name": "Tag 5"}, ], }, { "name": "Third issue", "tags": [ {"name": "Shared Tag"}, {"name": "Tag 6"}, ], }, ], }, ], }, } @pytest.mark.django_db(transaction=True) def test_input_update_mutation_with_multiple_level_nested_creation( db, gql_client: GraphQLTestClient ): query = """ mutation UpdateProject ($input: ProjectInputPartial!) { updateProject (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on ProjectType { id name milestones { id name issues { id name tags { name } } } } } } """ project = ProjectFactory.create(name="Some Project") shared_tag = TagFactory.create(name="Shared Tag") shared_tag_id = to_base64("TagType", shared_tag.pk) res = gql_client.query( query, { "input": { "id": to_base64("ProjectType", project.pk), "milestones": [ { "name": "Some Milestone", "issues": [ { "name": "Some Issue", "tags": [ {"name": "Tag 1"}, {"name": "Tag 2"}, {"name": "Tag 3"}, {"id": shared_tag_id}, ], } ], }, { "name": "Another Milestone", "issues": [ { "name": "Some Issue", "tags": [ {"name": "Tag 4"}, {"id": shared_tag_id}, ], }, { "name": "Another Issue", "tags": [ {"name": "Tag 5"}, {"id": shared_tag_id}, ], }, { "name": "Third issue", "tags": [ {"name": "Tag 6"}, {"id": shared_tag_id}, ], }, ], }, ], }, }, ) assert res.data assert isinstance(res.data["updateProject"], dict) project_typename, project_pk = from_base64(res.data["updateProject"].pop("id")) assert project_typename == "ProjectType" assert project.pk == int(project_pk) milestones = Milestone.objects.all() assert len(milestones) == 2 assert len(res.data["updateProject"]["milestones"]) == 2 some_milestone = res.data["updateProject"]["milestones"][0] milestone_typename, milestone_pk = from_base64(some_milestone.pop("id")) assert milestone_typename == "MilestoneType" assert milestones[0] == Milestone.objects.get(pk=milestone_pk) another_milestone = res.data["updateProject"]["milestones"][1] milestone_typename, milestone_pk = from_base64(another_milestone.pop("id")) assert milestone_typename == "MilestoneType" assert milestones[1] == Milestone.objects.get(pk=milestone_pk) issues = Issue.objects.all() assert len(issues) == 4 assert len(some_milestone["issues"]) == 1 assert len(another_milestone["issues"]) == 3 # Issues for first milestone fetched_issue = some_milestone["issues"][0] issue_typename, issue_pk = from_base64(fetched_issue.pop("id")) assert issue_typename == "IssueType" assert issues[0] == Issue.objects.get(pk=issue_pk) # Issues for second milestone for i in range(3): fetched_issue = another_milestone["issues"][i] issue_typename, issue_pk = from_base64(fetched_issue.pop("id")) assert issue_typename == "IssueType" assert issues[i + 1] == Issue.objects.get(pk=issue_pk) tags = Tag.objects.all() assert len(tags) == 7 assert len(issues[0].tags.all()) == 4 # 3 new tags + shared tag assert len(issues[1].tags.all()) == 2 # 1 new tag + shared tag assert len(issues[2].tags.all()) == 2 # 1 new tag + shared tag assert len(issues[3].tags.all()) == 2 # 1 new tag + shared tag assert res.data == { "updateProject": { "__typename": "ProjectType", "name": "Some Project", "milestones": [ { "name": "Some Milestone", "issues": [ { "name": "Some Issue", "tags": [ {"name": "Shared Tag"}, {"name": "Tag 1"}, {"name": "Tag 2"}, {"name": "Tag 3"}, ], } ], }, { "name": "Another Milestone", "issues": [ { "name": "Some Issue", "tags": [ {"name": "Shared Tag"}, {"name": "Tag 4"}, ], }, { "name": "Another Issue", "tags": [ {"name": "Shared Tag"}, {"name": "Tag 5"}, ], }, { "name": "Third issue", "tags": [ {"name": "Shared Tag"}, {"name": "Tag 6"}, ], }, ], }, ], }, } @pytest.mark.parametrize("mock_model", ["Milestone", "Issue", "Tag"]) @pytest.mark.django_db(transaction=True) def test_input_create_mutation_with_nested_calls_nested_full_clean( db, gql_client: GraphQLTestClient, mock_model: str ): query = """ mutation createProjectWithMilestones ($input: ProjectInputPartial!) { createProjectWithMilestones (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on ProjectType { id name milestones { id name issues { id name tags { name } } } } } } """ shared_tag = TagFactory.create(name="Shared Tag") shared_tag_id = to_base64("TagType", shared_tag.pk) with patch( f"tests.projects.models.{mock_model}.clean", side_effect=ValidationError({"name": ValidationError("Invalid name")}), ) as mocked_full_clean: res = gql_client.query( query, { "input": { "name": "Some Project", "milestones": [ { "name": "Some Milestone", "issues": [ { "name": "Some Issue", "tags": [ {"name": "Tag 1"}, {"name": "Tag 2"}, {"name": "Tag 3"}, {"id": shared_tag_id}, ], } ], }, { "name": "Another Milestone", "issues": [ { "name": "Some Issue", "tags": [ {"name": "Tag 4"}, {"id": shared_tag_id}, ], }, { "name": "Another Issue", "tags": [ {"name": "Tag 5"}, {"id": shared_tag_id}, ], }, { "name": "Third issue", "tags": [ {"name": "Tag 6"}, {"id": shared_tag_id}, ], }, ], }, ], }, }, ) assert res.data assert isinstance(res.data["createProjectWithMilestones"], dict) assert res.data["createProjectWithMilestones"]["__typename"] == "OperationInfo" assert mocked_full_clean.call_count == 1 assert res.data["createProjectWithMilestones"]["messages"] == [ {"field": "name", "kind": "VALIDATION", "message": "Invalid name"} ] @pytest.mark.django_db(transaction=True) def test_input_update_mutation(db, gql_client: GraphQLTestClient): query = """ mutation UpdateIssue ($input: IssueInputPartial!) { updateIssue (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on IssueType { id name milestone { id name } priority kind tags { id name } } } } """ issue = IssueFactory.create( name="Old name", milestone=MilestoneFactory.create(), priority=0, kind=Issue.Kind.BUG, ) tags = TagFactory.create_batch(4) issue.tags.set(tags) milestone = MilestoneFactory.create() add_tags = TagFactory.create_batch(2) remove_tags = tags[:2] res = gql_client.query( query, { "input": { "id": to_base64("IssueType", issue.pk), "name": "New name", "milestone": {"id": to_base64("MilestoneType", milestone.pk)}, "priority": 5, "kind": Issue.Kind.FEATURE.value, "tags": { "add": [{"id": to_base64("TagType", t.pk)} for t in add_tags], "remove": [{"id": to_base64("TagType", t.pk)} for t in remove_tags], }, }, }, ) assert res.data assert isinstance(res.data["updateIssue"], dict) expected_tags = tags + add_tags for removed in remove_tags: expected_tags.remove(removed) assert {frozenset(t.items()) for t in res.data["updateIssue"].pop("tags")} == { frozenset({"id": to_base64("TagType", t.pk), "name": t.name}.items()) for t in expected_tags } assert res.data == { "updateIssue": { "__typename": "IssueType", "id": to_base64("IssueType", issue.pk), "name": "New name", "milestone": { "id": to_base64("MilestoneType", milestone.pk), "name": milestone.name, }, "priority": 5, "kind": "f", }, } issue.refresh_from_db() assert issue.name == "New name" assert issue.priority == 5 assert issue.kind == Issue.Kind.FEATURE assert issue.milestone == milestone assert set(issue.tags.all()) == set(expected_tags) @pytest.mark.django_db(transaction=True) def test_input_nested_update_mutation(db, gql_client: GraphQLTestClient): query = """ mutation UpdateIssue ($input: IssueInputPartial!) { updateIssue (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on IssueType { id name milestone { id name } priority kind } } } """ issue = IssueFactory.create( name="Old name", milestone=MilestoneFactory.create(), priority=0, kind=Issue.Kind.BUG, ) milestone = MilestoneFactory.create(name="Something") res = gql_client.query( query, { "input": { "id": to_base64("IssueType", issue.pk), "name": "New name", "milestone": { "id": to_base64("MilestoneType", milestone.pk), "name": "foo", }, }, }, ) assert res.data assert isinstance(res.data["updateIssue"], dict) assert res.data["updateIssue"]["milestone"]["name"] == "foo" milestone.refresh_from_db() assert milestone.name == "foo" @pytest.mark.django_db(transaction=True) def test_input_update_m2m_set_not_null_mutation(db, gql_client: GraphQLTestClient): query = """ mutation UpdateProject ($input: ProjectInputPartial!, $optimizerEnabled: Boolean!) { updateProject (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on ProjectType { id name dueDate isDelayed @include(if: $optimizerEnabled) milestones { id name } cost } } } """ project = ProjectFactory.create( name="Project Name", ) milestone_1 = MilestoneFactory.create(project=project) milestone_1_id = to_base64("MilestoneType", milestone_1.pk) MilestoneFactory.create(project=project) # For mutations, having the optimizer enabled is expected to generate one extra # query for the refetch of the object with assert_num_queries(14 if DjangoOptimizerExtension.enabled.get() else 13): res = gql_client.query( query, { "input": { "id": to_base64("ProjectType", project.pk), "milestones": [{"id": milestone_1_id}], }, "optimizerEnabled": DjangoOptimizerExtension.enabled.get(), }, ) assert res.data assert isinstance(res.data["updateProject"], dict) assert len(res.data["updateProject"]["milestones"]) == 1 assert res.data["updateProject"]["milestones"][0]["id"] == milestone_1_id @pytest.mark.django_db(transaction=True) def test_input_update_m2m_set_mutation(db, gql_client: GraphQLTestClient): query = """ mutation UpdateIssue ($input: IssueInputPartial!) { updateIssue (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on IssueType { id name milestone { id name } priority kind tags { id name } issueAssignees { owner user { id } } } } } """ issue = IssueFactory.create( name="Old name", milestone=MilestoneFactory.create(), priority=0, kind=Issue.Kind.BUG, ) tags = TagFactory.create_batch(4) issue.tags.set(tags) milestone = MilestoneFactory.create() user_1 = UserFactory.create() user_2 = UserFactory.create() user_3 = UserFactory.create() assignee = issue.issue_assignees.create( user=user_3, owner=False, ) res = gql_client.query( query, { "input": { "id": to_base64("IssueType", issue.pk), "name": "New name", "milestone": {"id": to_base64("MilestoneType", milestone.pk)}, "priority": 5, "kind": Issue.Kind.FEATURE.value, "tags": { "set": [ {"id": None, "name": "Foobar"}, {"name": "Foobin"}, ], }, "issueAssignees": { "set": [ { "user": {"id": to_base64("UserType", user_1.username)}, }, { "user": {"id": to_base64("UserType", user_2.username)}, "owner": True, }, { "id": to_base64("AssigneeType", assignee.pk), "owner": True, }, ], }, }, }, ) assert res.data assert isinstance(res.data["updateIssue"], dict) tags = res.data["updateIssue"].pop("tags") assert len(tags) == 2 assert {t["name"] for t in tags} == {"Foobar", "Foobin"} assert { (r["user"]["id"], r["owner"]) for r in res.data["updateIssue"].pop("issueAssignees") } == { (to_base64("UserType", user_1.username), False), (to_base64("UserType", user_2.username), True), (to_base64("UserType", user_3.username), True), } assert res.data == { "updateIssue": { "__typename": "IssueType", "id": to_base64("IssueType", issue.pk), "name": "New name", "milestone": { "id": to_base64("MilestoneType", milestone.pk), "name": milestone.name, }, "priority": 5, "kind": "f", }, } issue.refresh_from_db() assert issue.name == "New name" assert issue.priority == 5 assert issue.kind == Issue.Kind.FEATURE assert issue.milestone == milestone @pytest.mark.django_db(transaction=True) def test_input_update_m2m_set_through_mutation(db, gql_client: GraphQLTestClient): query = """ mutation UpdateIssue ($input: IssueInputPartial!) { updateIssue (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on IssueType { id name milestone { id name } priority kind tags { id name } issueAssignees { owner user { id } } } } } """ issue = IssueFactory.create( name="Old name", milestone=MilestoneFactory.create(), priority=0, kind=Issue.Kind.BUG, ) tags = TagFactory.create_batch(4) issue.tags.set(tags) milestone = MilestoneFactory.create() user_1 = UserFactory.create() user_2 = UserFactory.create() user_3 = UserFactory.create() issue.issue_assignees.create( user=user_3, owner=False, ) res = gql_client.query( query, { "input": { "id": to_base64("IssueType", issue.pk), "name": "New name", "milestone": {"id": to_base64("MilestoneType", milestone.pk)}, "priority": 5, "kind": Issue.Kind.FEATURE.value, "tags": { "set": [ {"id": None, "name": "Foobar"}, {"name": "Foobin"}, ], }, "assignees": { "set": [ { "id": to_base64("UserType", user_1.username), }, { "id": to_base64("UserType", user_2.username), "throughDefaults": { "owner": True, }, }, { "id": to_base64("UserType", user_3.username), "throughDefaults": { "owner": True, }, }, ], }, }, }, ) assert res.data assert isinstance(res.data["updateIssue"], dict) tags = res.data["updateIssue"].pop("tags") assert len(tags) == 2 assert {t["name"] for t in tags} == {"Foobar", "Foobin"} assert { (r["user"]["id"], r["owner"]) for r in res.data["updateIssue"].pop("issueAssignees") } == { (to_base64("UserType", user_1.username), False), (to_base64("UserType", user_2.username), True), (to_base64("UserType", user_3.username), True), } assert res.data == { "updateIssue": { "__typename": "IssueType", "id": to_base64("IssueType", issue.pk), "name": "New name", "milestone": { "id": to_base64("MilestoneType", milestone.pk), "name": milestone.name, }, "priority": 5, "kind": "f", }, } issue.refresh_from_db() assert issue.name == "New name" assert issue.priority == 5 assert issue.kind == Issue.Kind.FEATURE assert issue.milestone == milestone @pytest.mark.django_db(transaction=True) def test_input_update_mutation_with_key_attr(db, gql_client: GraphQLTestClient): query = """ mutation UpdateIssueWithKeyAttr ($input: IssueInputPartialWithoutId!) { updateIssueWithKeyAttr (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on IssueType { name milestone { id name } priority kind tags { id name } } } } """ issue = IssueFactory.create( name="Unique name", milestone=MilestoneFactory.create(), priority=0, kind=Issue.Kind.BUG, ) tags = TagFactory.create_batch(4) issue.tags.set(tags) milestone = MilestoneFactory.create() add_tags = TagFactory.create_batch(2) remove_tags = tags[:2] res = gql_client.query( query, { "input": { "name": "Unique name", "milestone": {"id": to_base64("MilestoneType", milestone.pk)}, "priority": 5, "kind": Issue.Kind.FEATURE.value, "tags": { "add": [{"id": to_base64("TagType", t.pk)} for t in add_tags], "remove": [{"id": to_base64("TagType", t.pk)} for t in remove_tags], }, }, }, ) assert res.data assert isinstance(res.data["updateIssueWithKeyAttr"], dict) expected_tags = tags + add_tags for removed in remove_tags: expected_tags.remove(removed) assert { frozenset(t.items()) for t in res.data["updateIssueWithKeyAttr"].pop("tags") } == { frozenset({"id": to_base64("TagType", t.pk), "name": t.name}.items()) for t in expected_tags } assert res.data == { "updateIssueWithKeyAttr": { "__typename": "IssueType", "name": "Unique name", "milestone": { "id": to_base64("MilestoneType", milestone.pk), "name": milestone.name, }, "priority": 5, "kind": "f", }, } issue.refresh_from_db() assert issue.priority == 5 assert issue.kind == Issue.Kind.FEATURE assert issue.milestone == milestone assert set(issue.tags.all()) == set(expected_tags) @pytest.mark.django_db(transaction=True) def test_input_delete_mutation(db, gql_client: GraphQLTestClient): query = """ mutation DeleteIssue ($input: NodeInput!) { deleteIssue (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on IssueType { id name milestone { id name } priority kind } } } """ issue = IssueFactory.create() assert issue.milestone assert issue.kind res = gql_client.query( query, { "input": { "id": to_base64("IssueType", issue.pk), }, }, ) assert res.data assert isinstance(res.data["deleteIssue"], dict) assert res.data == { "deleteIssue": { "__typename": "IssueType", "id": to_base64("IssueType", issue.pk), "name": issue.name, "milestone": { "id": to_base64("MilestoneType", issue.milestone.pk), "name": issue.milestone.name, }, "priority": issue.priority, "kind": issue.kind.value, # type: ignore }, } with pytest.raises(Issue.DoesNotExist): Issue.objects.get(pk=issue.pk) @pytest.mark.django_db(transaction=True) def test_input_delete_mutation_with_key_attr(db, gql_client: GraphQLTestClient): query = """ mutation DeleteIssue ($input: MilestoneIssueInput!) { deleteIssueWithKeyAttr (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on IssueType { id name milestone { id name } priority kind } } } """ issue = IssueFactory.create() assert issue.milestone assert issue.kind res = gql_client.query( query, { "input": { "name": issue.name, }, }, ) assert res.data assert isinstance(res.data["deleteIssueWithKeyAttr"], dict) assert res.data == { "deleteIssueWithKeyAttr": { "__typename": "IssueType", "id": to_base64("IssueType", issue.pk), "name": issue.name, "milestone": { "id": to_base64("MilestoneType", issue.milestone.pk), "name": issue.milestone.name, }, "priority": issue.priority, "kind": issue.kind.value, # type: ignore }, } with pytest.raises(Issue.DoesNotExist): Issue.objects.get(pk=issue.pk) @pytest.mark.django_db(transaction=True) def test_mutation_full_clean_without_kwargs(db, gql_client: GraphQLTestClient): query = """ mutation CreateQuiz ($input: CreateQuizInput!) { createQuiz (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on QuizType { title sequence } } } """ res = gql_client.query( query, { "input": { "title": "ABC", }, }, ) expected = {"createQuiz": {"__typename": "QuizType", "sequence": 1, "title": "ABC"}} assert res.data == expected res = gql_client.query( query, { "input": { "title": "ABC", }, }, ) expected = { "createQuiz": { "__typename": "OperationInfo", "messages": [ { "field": "sequence", "kind": "VALIDATION", "message": "Quiz with this Sequence already exists.", }, ], }, } assert res.data == expected @pytest.mark.django_db(transaction=True) def test_mutation_full_clean_with_kwargs(db, gql_client: GraphQLTestClient): query = """ mutation CreateQuiz ($input: CreateQuizInput!) { createQuiz (input: $input) { __typename ... on OperationInfo { messages { kind field message } } ... on QuizType { title sequence } } } """ res = gql_client.query( query, {"input": {"title": "ABC", "fullCleanOptions": True}}, ) expected = {"createQuiz": {"__typename": "QuizType", "sequence": 1, "title": "ABC"}} assert res.data == expected res = gql_client.query( query, {"input": {"title": "ABC", "fullCleanOptions": True}}, ) expected = {"createQuiz": {"__typename": "QuizType", "sequence": 2, "title": "ABC"}} assert res.data == expected res = gql_client.query( query, {"input": {"title": "ABC", "fullCleanOptions": True}}, ) expected = {"createQuiz": {"__typename": "QuizType", "sequence": 3, "title": "ABC"}} assert res.data == expected res = gql_client.query( query, {"input": {"title": "ABC", "fullCleanOptions": True}}, ) expected = {"createQuiz": {"__typename": "QuizType", "sequence": 4, "title": "ABC"}} assert res.data == expected strawberry-graphql-django-0.82.1/tests/test_legacy_order.py000066400000000000000000000377061516173410200241310ustar00rootroot00000000000000# ruff: noqa: TRY002, B904, BLE001, F811, PT012 import warnings from typing import Any, cast from unittest import mock import pytest import strawberry from django.db.models import Case, Count, Value, When from pytest_mock import MockFixture from strawberry import Info, auto from strawberry.annotation import StrawberryAnnotation from strawberry.exceptions import MissingArgumentsAnnotationsError from strawberry.types import get_object_definition from strawberry.types.base import ( StrawberryOptional, WithStrawberryObjectDefinition, get_object_definition, ) from strawberry.types.field import StrawberryField import strawberry_django from strawberry_django.exceptions import ( ForbiddenFieldArgumentError, MissingFieldArgumentError, ) from strawberry_django.fields.field import StrawberryDjangoField from strawberry_django.fields.filter_order import ( FilterOrderField, FilterOrderFieldResolver, ) from strawberry_django.ordering import Ordering, OrderSequence, process_order from tests import models, utils from tests.types import Fruit @strawberry_django.ordering.order(models.Color) class ColorOrder: pk: auto @strawberry_django.order_field def name(self, prefix, value: auto): return [value.resolve(f"{prefix}name")] @strawberry_django.order_type(models.Fruit) class FruitOrdering: name: auto @strawberry_django.ordering.order(models.Fruit) class FruitOrder: color_id: auto name: auto sweetness: auto color: "ColorOrder | None" @strawberry_django.order_field def types_number(self, queryset, prefix, value: auto): return queryset.annotate( count=Count(f"{prefix}types__id"), count_nulls=Case( When(count=0, then=Value(None)), default="count", ), ), [value.resolve("count_nulls")] @strawberry_django.type(models.Fruit, order=FruitOrder) class FruitWithOrder: id: auto name: auto @strawberry.type class Query: fruits: list[Fruit] = strawberry_django.field(order=FruitOrder) fruits_with_ordering: list[Fruit] = strawberry_django.field( order=FruitOrder, ordering=FruitOrdering ) @pytest.fixture def query(): return utils.generate_query(Query) def test_legacy_order_argument_is_deprecated(): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always", DeprecationWarning) strawberry_django.field(order=FruitOrder) assert len(w) == 1 assert issubclass(w[-1].category, DeprecationWarning) assert ( str(w[-1].message) == "strawberry_django.order is deprecated in favor of strawberry_django.order_type." ) def test_legacy_order_type_is_deprecated(): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always", DeprecationWarning) @strawberry_django.ordering.order(models.Fruit) class TestOrder: name: auto assert len(w) == 1 assert issubclass(w[-1].category, DeprecationWarning) assert ( str(w[-1].message) == "strawberry_django.order is deprecated in favor of strawberry_django.order_type." ) def test_legacy_order_works_when_ordering_is_present(query, fruits): result = query("{ fruitsWithOrdering(order: { name: ASC }) { id name } }") assert not result.errors assert result.data["fruitsWithOrdering"] == [ {"id": "3", "name": "banana"}, {"id": "2", "name": "raspberry"}, {"id": "1", "name": "strawberry"}, ] def test_ordering_works_when_legacy_order_is_present(query, fruits): result = query("{ fruitsWithOrdering(ordering: [{ name: ASC }]) { id name } }") assert not result.errors assert result.data["fruitsWithOrdering"] == [ {"id": "3", "name": "banana"}, {"id": "2", "name": "raspberry"}, {"id": "1", "name": "strawberry"}, ] def test_error_when_ordering_and_order_given(query, fruits): result = query( "{ fruitsWithOrdering(ordering: [{ name: ASC }], order: { name: ASC }) { id name } }" ) assert result.errors is not None assert len(result.errors) == 1 assert ( result.errors[0].message == "Only one of `ordering` or `order` must be given." ) def test_field_order_definition(): field = StrawberryDjangoField(type_annotation=StrawberryAnnotation(FruitWithOrder)) assert field.get_order() == FruitOrder field = StrawberryDjangoField( type_annotation=StrawberryAnnotation(FruitWithOrder), filters=None, ) assert field.get_filters() is None def test_asc(query, fruits): result = query("{ fruits(order: { name: ASC }) { id name } }") assert not result.errors assert result.data["fruits"] == [ {"id": "3", "name": "banana"}, {"id": "2", "name": "raspberry"}, {"id": "1", "name": "strawberry"}, ] def test_desc(query, fruits): result = query("{ fruits(order: { name: DESC }) { id name } }") assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, {"id": "2", "name": "raspberry"}, {"id": "3", "name": "banana"}, ] def test_relationship(query, fruits): def add_color(fruit, color_name): fruit.color = models.Color.objects.create(name=color_name) fruit.save() color_names = ["red", "dark red", "yellow"] for fruit, color_name in zip(fruits, color_names, strict=False): add_color(fruit, color_name) result = query( "{ fruits(order: { color: { name: DESC } }) { id name color { name } } }", ) assert not result.errors assert result.data["fruits"] == [ {"id": "3", "name": "banana", "color": {"name": "yellow"}}, {"id": "1", "name": "strawberry", "color": {"name": "red"}}, {"id": "2", "name": "raspberry", "color": {"name": "dark red"}}, ] def test_arguments_order_respected(query, db): yellow = models.Color.objects.create(name="yellow") red = models.Color.objects.create(name="red") f1 = models.Fruit.objects.create( name="strawberry", sweetness=1, color=red, ) f2 = models.Fruit.objects.create( name="banana", sweetness=2, color=yellow, ) f3 = models.Fruit.objects.create( name="apple", sweetness=0, color=red, ) result = query("{ fruits(order: { name: ASC, sweetness: ASC }) { id } }") assert not result.errors assert result.data["fruits"] == [{"id": str(f.pk)} for f in [f3, f2, f1]] result = query("{ fruits(order: { sweetness: DESC, name: ASC }) { id } }") assert not result.errors assert result.data["fruits"] == [{"id": str(f.pk)} for f in [f2, f1, f3]] result = query("{ fruits(order: { color: {name: ASC}, name: ASC }) { id } }") assert not result.errors assert result.data["fruits"] == [{"id": str(f.pk)} for f in [f3, f1, f2]] result = query("{ fruits(order: { color: {pk: ASC}, name: ASC }) { id } }") assert not result.errors assert result.data["fruits"] == [{"id": str(f.pk)} for f in [f2, f3, f1]] result = query("{ fruits(order: { colorId: ASC, name: ASC }) { id } }") assert not result.errors assert result.data["fruits"] == [{"id": str(f.pk)} for f in [f2, f3, f1]] result = query("{ fruits(order: { name: ASC, colorId: ASC }) { id } }") assert not result.errors assert result.data["fruits"] == [{"id": str(f.pk)} for f in [f3, f2, f1]] def test_order_sequence(): f1 = StrawberryField(graphql_name="sOmEnAmE", python_name="some_name") f2 = StrawberryField(python_name="some_name") assert OrderSequence.get_graphql_name(None, f1) == "sOmEnAmE" assert OrderSequence.get_graphql_name(None, f2) == "someName" assert OrderSequence.sorted(None, None, fields=[f1, f2]) == [f1, f2] sequence = {"someName": OrderSequence(0, None), "sOmEnAmE": OrderSequence(1, None)} assert OrderSequence.sorted(None, sequence, fields=[f1, f2]) == [f1, f2] def test_order_type(): @strawberry_django.ordering.order(models.Fruit) class FruitOrder: color_id: auto name: auto sweetness: auto @strawberry_django.order_field def custom_order(self, value: auto, prefix: str): pass annotated_type = StrawberryOptional(Ordering.__strawberry_definition__) # type: ignore assert [ ( f.name, f.__class__, f.type, f.base_resolver.__class__ if f.base_resolver else None, ) for f in get_object_definition(FruitOrder, strict=True).fields ] == [ ("color_id", StrawberryField, annotated_type, None), ("name", StrawberryField, annotated_type, None), ("sweetness", StrawberryField, annotated_type, None), ( "custom_order", FilterOrderField, annotated_type, FilterOrderFieldResolver, ), ] def test_order_field_missing_prefix(): with pytest.raises( MissingFieldArgumentError, match=r".*\"prefix\".*\"field_method\".*" ): @strawberry_django.order_field def field_method(): pass def test_order_field_missing_value(): with pytest.raises( MissingFieldArgumentError, match=r".*\"value\".*\"field_method\".*" ): @strawberry_django.order_field def field_method(prefix): pass def test_order_field_missing_value_annotation(): with pytest.raises( MissingArgumentsAnnotationsError, match=r"Missing annotation.*\"value\".*\"field_method\".*", ): @strawberry_django.order_field def field_method(prefix, value): pass def test_order_field(): try: @strawberry_django.order_field def field_method( self, root, info: Info, prefix, value: auto, sequence, queryset ): pass except Exception as exc: raise pytest.fail(f"DID RAISE {exc}") def test_order_field_forbidden_param_annotation(): with pytest.raises( MissingArgumentsAnnotationsError, match=r".*\"forbidden_param\".*\"field_method\".*", ): @strawberry_django.order_field def field_method(prefix, value: auto, sequence, queryset, forbidden_param): pass def test_order_field_forbidden_param(): with pytest.raises( ForbiddenFieldArgumentError, match=r".*\"forbidden_param\".*\"field_method\".*", ): @strawberry_django.order_field def field_method(prefix, value: auto, sequence, queryset, forbidden_param: str): pass def test_order_field_missing_queryset(): with pytest.raises(MissingFieldArgumentError, match=r".*\"queryset\".*\"order\".*"): @strawberry_django.order_field def order(prefix): pass def test_order_field_value_forbidden_on_object(): with pytest.raises(ForbiddenFieldArgumentError, match=r".*\"value\".*\"order\".*"): @strawberry_django.order_field def field_method(prefix, queryset, value: auto): pass @strawberry_django.order_field def order(prefix, queryset, value: auto): pass def test_order_field_on_object(): try: @strawberry_django.order_field def order(self, root, info: Info, prefix, sequence, queryset): pass except Exception as exc: raise pytest.fail(f"DID RAISE {exc}") def test_order_field_method(): @strawberry_django.ordering.order(models.Fruit) class Order: @strawberry_django.order_field def custom_order( self, root, info: Info, prefix, value: auto, sequence, queryset ): assert self == order, "Unexpected self passed" assert root == order, "Unexpected root passed" assert info == fake_info, "Unexpected info passed" assert prefix == "ROOT", "Unexpected prefix passed" assert value == Ordering.ASC, "Unexpected value passed" assert sequence == sequence_inner, "Unexpected sequence passed" assert queryset == qs, "Unexpected queryset passed" raise Exception("WAS CALLED") order = cast("WithStrawberryObjectDefinition", Order(custom_order=Ordering.ASC)) # type: ignore schema = strawberry.Schema(query=Query) fake_info: Any = type("FakeInfo", (), {"schema": schema}) qs: Any = object() sequence_inner: Any = object() sequence = {"customOrder": OrderSequence(0, children=sequence_inner)} with pytest.raises(Exception, match="WAS CALLED"): process_order(order, fake_info, qs, prefix="ROOT", sequence=sequence) def test_order_method_not_called_when_not_decorated(mocker: MockFixture): @strawberry_django.ordering.order(models.Fruit) class Order: def order(self, root, info: Info, prefix, value: auto, sequence, queryset): pytest.fail("Should not have been called") mock_order_method = mocker.spy(Order, "order") process_order( cast("WithStrawberryObjectDefinition", Order()), mock.Mock(), mock.Mock() ) mock_order_method.assert_not_called() def test_order_field_not_called(mocker: MockFixture): @strawberry_django.ordering.order(models.Fruit) class Order: order: Ordering = Ordering.ASC # Calling this and no error being raised is the test, as the wrong behavior would # be for the field to be called like a method process_order( cast("WithStrawberryObjectDefinition", Order()), mock.Mock(), mock.Mock() ) def test_order_object_method(): @strawberry_django.ordering.order(models.Fruit) class Order: @strawberry_django.order_field def order(self, root, info: Info, prefix, sequence, queryset): assert self == order_, "Unexpected self passed" assert root == order_, "Unexpected root passed" assert info == fake_info, "Unexpected info passed" assert prefix == "ROOT", "Unexpected prefix passed" assert sequence == sequence_, "Unexpected sequence passed" assert queryset == qs, "Unexpected queryset passed" return queryset, ["name"] order_ = cast("WithStrawberryObjectDefinition", Order()) schema = strawberry.Schema(query=Query) fake_info: Any = type("FakeInfo", (), {"schema": schema}) qs: Any = object() sequence_: Any = {"customOrder": OrderSequence(0)} order = process_order(order_, fake_info, qs, prefix="ROOT", sequence=sequence_)[1] assert "name" in order, "order was not called" def test_order_nulls(query, db, fruits): t1 = models.FruitType.objects.create(name="Type1") t2 = models.FruitType.objects.create(name="Type2") f1, f2, f3 = models.Fruit.objects.all() f2.types.add(t1) f3.types.add(t1, t2) result = query("{ fruits(order: { typesNumber: ASC }) { id } }") assert not result.errors assert result.data["fruits"] == [ {"id": str(f1.id)}, {"id": str(f2.id)}, {"id": str(f3.id)}, ] result = query("{ fruits(order: { typesNumber: DESC }) { id } }") assert not result.errors assert result.data["fruits"] == [ {"id": str(f3.id)}, {"id": str(f2.id)}, {"id": str(f1.id)}, ] result = query("{ fruits(order: { typesNumber: ASC_NULLS_FIRST }) { id } }") assert not result.errors assert result.data["fruits"] == [ {"id": str(f1.id)}, {"id": str(f2.id)}, {"id": str(f3.id)}, ] result = query("{ fruits(order: { typesNumber: ASC_NULLS_LAST }) { id } }") assert not result.errors assert result.data["fruits"] == [ {"id": str(f2.id)}, {"id": str(f3.id)}, {"id": str(f1.id)}, ] result = query("{ fruits(order: { typesNumber: DESC_NULLS_LAST }) { id } }") assert not result.errors assert result.data["fruits"] == [ {"id": str(f3.id)}, {"id": str(f2.id)}, {"id": str(f1.id)}, ] result = query("{ fruits(order: { typesNumber: DESC_NULLS_FIRST }) { id } }") assert not result.errors assert result.data["fruits"] == [ {"id": str(f1.id)}, {"id": str(f3.id)}, {"id": str(f2.id)}, ] strawberry-graphql-django-0.82.1/tests/test_optimizer.py000066400000000000000000002552701516173410200235120ustar00rootroot00000000000000import datetime import operator from typing import Any, cast import pytest import strawberry from django.db import DEFAULT_DB_ALIAS, connections from django.db.models import ( CharField, Expression, ExpressionWrapper, F, Prefetch, QuerySet, ) from django.test.utils import CaptureQueriesContext from django.utils import timezone from pytest_mock import MockerFixture from strawberry.relay import GlobalID, to_base64 from strawberry.types import ExecutionResult, Info, get_object_definition import strawberry_django from strawberry_django.optimizer import ( DjangoOptimizerExtension, ) from tests.projects.schema import IssueType, MilestoneType, ProjectType, StaffType from . import utils from .projects.faker import ( IssueFactory, MilestoneFactory, ProjectFactory, StaffUserFactory, TagFactory, UserFactory, ) from .projects.models import Assignee, Issue, Milestone, Project from .utils import GraphQLTestClient, assert_num_queries @pytest.mark.django_db(transaction=True) def test_user_query(db, gql_client: GraphQLTestClient): query = """ query TestQuery { me { id email fullName } } """ with assert_num_queries(0): res = gql_client.query(query) assert res.data == { "me": None, } user = UserFactory.create(first_name="John", last_name="Snow") with gql_client.login(user): with assert_num_queries(2): res = gql_client.query(query) assert res.data == { "me": { "id": to_base64("UserType", user.username), "email": user.email, "fullName": "John Snow", }, } @pytest.mark.django_db(transaction=True) def test_staff_query(db, gql_client: GraphQLTestClient, mocker: MockerFixture): staff_type_get_queryset = StaffType.get_queryset mock_staff_type_get_queryset = mocker.patch( "tests.projects.schema.StaffType.get_queryset", autospec=True, side_effect=staff_type_get_queryset, ) query = """ query TestQuery { staffConn { edges { node { email } } } } """ UserFactory.create_batch(5) staff1, staff2 = StaffUserFactory.create_batch(2) res = gql_client.query(query) assert res.data == { "staffConn": { "edges": [ {"node": {"email": staff1.email}}, {"node": {"email": staff2.email}}, ], }, } mock_staff_type_get_queryset.assert_called_once() @pytest.mark.django_db(transaction=True) def test_interface_query(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($id: ID!) { node (id: $id) { __typename id ... on IssueType { name milestone { id name project { id name } } tags { id name } } } } """ issue = IssueFactory.create() assert issue.milestone tags = TagFactory.create_batch(4) issue.tags.set(tags) with assert_num_queries(2 if DjangoOptimizerExtension.enabled.get() else 4): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert isinstance(res.data, dict) assert isinstance(res.data["node"], dict) assert { frozenset(d.items()) for d in cast("list", res.data["node"].pop("tags")) } == frozenset( { frozenset( { "id": to_base64("TagType", t.pk), "name": t.name, }.items(), ) for t in tags }, ) assert res.data == { "node": { "__typename": "IssueType", "id": to_base64("IssueType", issue.pk), "name": issue.name, "milestone": { "id": to_base64("MilestoneType", issue.milestone.pk), "name": issue.milestone.name, "project": { "id": to_base64("ProjectType", issue.milestone.project.pk), "name": issue.milestone.project.name, }, }, }, } @pytest.mark.django_db(transaction=True) def test_query_forward(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($isAsync: Boolean!) { issueConn { totalCount edges { node { id name milestone { id name asyncField(value: "foo") @include (if: $isAsync) project { id name } } } } } } """ expected = [] for p in ProjectFactory.create_batch(2): for m in MilestoneFactory.create_batch(2, project=p): for i in IssueFactory.create_batch(2, milestone=m): r: dict[str, Any] = { "id": to_base64("IssueType", i.id), "name": i.name, "milestone": { "id": to_base64("MilestoneType", m.id), "name": m.name, "project": { "id": to_base64("ProjectType", p.id), "name": p.name, }, }, } if gql_client.is_async: r["milestone"]["asyncField"] = "value: foo" expected.append(r) with assert_num_queries(1 if DjangoOptimizerExtension.enabled.get() else 18): res = gql_client.query(query, {"isAsync": gql_client.is_async}) assert res.data == { "issueConn": { "totalCount": 8, "edges": [{"node": r} for r in expected], }, } @pytest.mark.django_db(transaction=True) def test_query_forward_with_interfaces(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($isAsync: Boolean!) { issueConn { totalCount edges { node { id ... on Named { name } milestone { id ... on Named { name } asyncField(value: "foo") @include (if: $isAsync) project { id ... on Named { name } } } } } } } """ expected = [] for p in ProjectFactory.create_batch(2): for m in MilestoneFactory.create_batch(2, project=p): for i in IssueFactory.create_batch(2, milestone=m): r: dict[str, Any] = { "id": to_base64("IssueType", i.id), "name": i.name, "milestone": { "id": to_base64("MilestoneType", m.id), "name": m.name, "project": { "id": to_base64("ProjectType", p.id), "name": p.name, }, }, } if gql_client.is_async: r["milestone"]["asyncField"] = "value: foo" expected.append(r) with assert_num_queries(1 if DjangoOptimizerExtension.enabled.get() else 18): res = gql_client.query(query, {"isAsync": gql_client.is_async}) assert res.data == { "issueConn": { "totalCount": 8, "edges": [{"node": r} for r in expected], }, } @pytest.mark.django_db(transaction=True) def test_query_forward_with_fragments(db, gql_client: GraphQLTestClient): query = """ fragment issueFrag on IssueType { nameWithKind nameWithPriority } fragment milestoneFrag on MilestoneType { id project { name } } query TestQuery { issueConn { totalCount edges { node { id name ... issueFrag milestone { name project { id name } ... milestoneFrag } } } } } """ expected = [] for p in ProjectFactory.create_batch(3): for m in MilestoneFactory.create_batch(3, project=p): for i in IssueFactory.create_batch(3, milestone=m): m_res = { "id": to_base64("MilestoneType", m.id), "name": m.name, "project": { "id": to_base64("ProjectType", p.id), "name": p.name, }, } expected.append( { "id": to_base64("IssueType", i.id), "name": i.name, "nameWithKind": f"{i.kind}: {i.name}", "nameWithPriority": f"{i.kind}: {i.priority}", "milestone": m_res, }, ) with assert_num_queries(1 if DjangoOptimizerExtension.enabled.get() else 56): res = gql_client.query(query) assert res.data == { "issueConn": { "totalCount": 27, "edges": [{"node": r} for r in expected], }, } @pytest.mark.django_db(transaction=True) def test_query_prefetch(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($node_id: ID!) { project (id: $node_id) { id name milestones { id name project { id name } issues { id name milestone { id name } } } } } """ expected = [] for p in ProjectFactory.create_batch(2): p_res: dict[str, Any] = { "id": to_base64("ProjectType", p.id), "name": p.name, "milestones": [], } expected.append(p_res) for m in MilestoneFactory.create_batch(2, project=p): m_res: dict[str, Any] = { "id": to_base64("MilestoneType", m.id), "name": m.name, "project": { "id": p_res["id"], "name": p_res["name"], }, "issues": [], } p_res["milestones"].append(m_res) for i in IssueFactory.create_batch(2, milestone=m): m_res["issues"].append( { "id": to_base64("IssueType", i.id), "name": i.name, "milestone": { "id": m_res["id"], "name": m_res["name"], }, }, ) assert len(expected) == 2 for e in expected: with assert_num_queries(3 if DjangoOptimizerExtension.enabled.get() else 4): res = gql_client.query(query, {"node_id": e["id"]}) assert res.data == {"project": e} @pytest.mark.django_db(transaction=True) def test_query_prefetch_with_callable(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($node_id: ID!) { project (id: $node_id) { id name milestones { id name project { id name } myIssues { id name milestone { id name } } } } } """ user = UserFactory.create() expected = [] for p in ProjectFactory.create_batch(2): p_res: dict[str, Any] = { "id": to_base64("ProjectType", p.id), "name": p.name, "milestones": [], } expected.append(p_res) for m in MilestoneFactory.create_batch(2, project=p): m_res: dict[str, Any] = { "id": to_base64("MilestoneType", m.id), "name": m.name, "project": { "id": p_res["id"], "name": p_res["name"], }, "myIssues": [], } p_res["milestones"].append(m_res) # Those issues are not assigned to the user, # thus they should not appear in the results IssueFactory.create_batch(2, milestone=m) for i in IssueFactory.create_batch(2, milestone=m): Assignee.objects.create(user=user, issue=i) m_res["myIssues"].append( { "id": to_base64("IssueType", i.id), "name": i.name, "milestone": { "id": m_res["id"], "name": m_res["name"], }, }, ) assert len(expected) == 2 for e in expected: with gql_client.login(user): if DjangoOptimizerExtension.enabled.get(): with assert_num_queries(5): res = gql_client.query(query, {"node_id": e["id"]}) assert res.data == {"project": e} else: # myIssues requires the optimizer to be turned on res = gql_client.query( query, {"node_id": e["id"]}, assert_no_errors=False, ) assert res.errors @pytest.mark.django_db(transaction=True) def test_query_prefetch_with_fragments(db, gql_client: GraphQLTestClient): query = """ fragment issueFrag on IssueType { nameWithKind nameWithPriority } fragment milestoneFrag on MilestoneType { id project { id name } } query TestQuery ($node_id: ID!) { project (id: $node_id) { id name milestones { id name project { id name } issues { id name ... issueFrag milestone { ... milestoneFrag } } } } } """ expected = [] for p in ProjectFactory.create_batch(3): p_res: dict[str, Any] = { "id": to_base64("ProjectType", p.id), "name": p.name, "milestones": [], } expected.append(p_res) for m in MilestoneFactory.create_batch(3, project=p): m_res: dict[str, Any] = { "id": to_base64("MilestoneType", m.id), "name": m.name, "project": { "id": p_res["id"], "name": p_res["name"], }, "issues": [], } p_res["milestones"].append(m_res) for i in IssueFactory.create_batch(3, milestone=m): m_res["issues"].append( { "id": to_base64("IssueType", i.id), "name": i.name, "nameWithKind": f"{i.kind}: {i.name}", "nameWithPriority": f"{i.kind}: {i.priority}", "milestone": { "id": m_res["id"], "project": { "id": p_res["id"], "name": p_res["name"], }, }, }, ) assert len(expected) == 3 for e in expected: with assert_num_queries(3 if DjangoOptimizerExtension.enabled.get() else 5): res = gql_client.query(query, {"node_id": e["id"]}) assert res.data == {"project": e} @pytest.mark.django_db(transaction=True) def test_query_connection_with_resolver(db, gql_client: GraphQLTestClient): query = """ query TestQuery { projectConnWithResolver (name: "Foo") { totalCount edges { node { id name milestones { id } } } } } """ p1 = ProjectFactory.create(name="Foo 1") p2 = ProjectFactory.create(name="2 Foo") p3 = ProjectFactory.create(name="FooBar") for i in range(10): ProjectFactory.create(name=f"Project {i}") with assert_num_queries(2 if DjangoOptimizerExtension.enabled.get() else 5): res = gql_client.query(query) assert res.data == { "projectConnWithResolver": { "totalCount": 3, "edges": [ { "node": { "id": to_base64("ProjectType", p.id), "milestones": [], "name": p.name, }, } for p in [p1, p2, p3] ], }, } @pytest.mark.django_db(transaction=True) def test_query_connection_nested(db, gql_client: GraphQLTestClient): query = """ query TestQuery { tagList { id name issues (first: 2) { totalCount edges { node { id name } } } } } """ t1 = TagFactory.create() t2 = TagFactory.create() t1_issues = IssueFactory.create_batch(10) for issue in t1_issues: t1.issues.add(issue) t2_issues = IssueFactory.create_batch(10) for issue in t2_issues: t2.issues.add(issue) with assert_num_queries(2 if DjangoOptimizerExtension.enabled.get() else 5): res = gql_client.query(query) assert res.data == { "tagList": [ { "id": to_base64("TagType", t1.id), "name": t1.name, "issues": { "totalCount": 10, "edges": [ {"node": {"id": to_base64("IssueType", t.id), "name": t.name}} for t in t1_issues[:2] ], }, }, { "id": to_base64("TagType", t2.id), "name": t2.name, "issues": { "totalCount": 10, "edges": [ {"node": {"id": to_base64("IssueType", t.id), "name": t.name}} for t in t2_issues[:2] ], }, }, ], } @pytest.mark.django_db(transaction=True) def test_query_nested_fragments(db, gql_client: GraphQLTestClient): query = """ query TestQuery { issueConn { ...IssueConnection2 ...IssueConnection1 } } fragment IssueConnection1 on IssueTypeConnection { edges { node { issueAssignees { id } } } } fragment IssueConnection2 on IssueTypeConnection { edges { node { milestone { id project { name } } } } } """ UserFactory.create() expected = {"issueConn": {"edges": []}} for i in IssueFactory.create_batch(2): assert i.milestone assert i.milestone.project assignee1 = Assignee.objects.create(user=UserFactory.create(), issue=i) assignee2 = Assignee.objects.create(user=UserFactory.create(), issue=i) expected["issueConn"]["edges"].append( { "node": { "issueAssignees": [ {"id": to_base64("AssigneeType", assignee1.pk)}, {"id": to_base64("AssigneeType", assignee2.pk)}, ], "milestone": { "id": to_base64("MilestoneType", i.milestone.pk), "project": {"name": i.milestone.project.name}, }, }, }, ) with assert_num_queries(2 if DjangoOptimizerExtension.enabled.get() else 7): res = gql_client.query(query) assert res.data == expected @pytest.mark.django_db(transaction=True) def test_query_annotate(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($node_id: ID!) { project (id: $node_id) { id name isDelayed milestonesCount isSmall } } """ expected = [] today = timezone.now().date() for p in ProjectFactory.create_batch(2): ms = MilestoneFactory.create_batch(3, project=p) assert p.due_date is not None p_res: dict[str, Any] = { "id": to_base64("ProjectType", p.id), "name": p.name, "isDelayed": p.due_date < today, "milestonesCount": len(ms), "isSmall": len(ms) < 3, } expected.append(p_res) for p in ProjectFactory.create_batch( 2, due_date=today - datetime.timedelta(days=1), ): ms = MilestoneFactory.create_batch(2, project=p) assert p.due_date is not None p_res: dict[str, Any] = { "id": to_base64("ProjectType", p.id), "name": p.name, "isDelayed": p.due_date < today, "milestonesCount": len(ms), "isSmall": len(ms) < 3, } expected.append(p_res) assert len(expected) == 4 for e in expected: if DjangoOptimizerExtension.enabled.get(): with assert_num_queries(1): res = gql_client.query(query, {"node_id": e["id"]}) assert res.data == {"project": e} else: # isDelayed and milestonesCount requires the optimizer to be turned on res = gql_client.query( query, {"node_id": e["id"]}, assert_no_errors=False, ) assert res.errors @pytest.mark.django_db(transaction=True) def test_query_annotate_with_callable(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($node_id: ID!) { project (id: $node_id) { id name milestones { id name myBugsCount } } } """ user = UserFactory.create() expected = [] for p in ProjectFactory.create_batch(2): p_res: dict[str, Any] = { "id": to_base64("ProjectType", p.id), "name": p.name, "milestones": [], } expected.append(p_res) for m in MilestoneFactory.create_batch(2, project=p): m_res: dict[str, Any] = { "id": to_base64("MilestoneType", m.id), "name": m.name, "myBugsCount": 0, } p_res["milestones"].append(m_res) # Those issues are not assigned to the user, # thus they should not be counted IssueFactory.create_batch(2, milestone=m, kind=Issue.Kind.BUG) # Those issues are not bugs, # thus they should not be counted IssueFactory.create_batch(3, milestone=m, kind=Issue.Kind.FEATURE) # Those issues are bugs assigned to the user, # thus they will be counted for i in IssueFactory.create_batch(4, milestone=m, kind=Issue.Kind.BUG): Assignee.objects.create(user=user, issue=i) m_res["myBugsCount"] += 1 assert len(expected) == 2 for e in expected: with gql_client.login(user): if DjangoOptimizerExtension.enabled.get(): with assert_num_queries(4): res = gql_client.query(query, {"node_id": e["id"]}) assert res.data == {"project": e} else: # myBugsCount requires the optimizer to be turned on res = gql_client.query( query, {"node_id": e["id"]}, assert_no_errors=False, ) assert res.errors @pytest.mark.django_db(transaction=True) def test_user_query_with_prefetch(): @strawberry_django.type( Project, ) class ProjectTypeWithPrefetch: @strawberry_django.field( prefetch_related=[ Prefetch( "milestones", queryset=Milestone.objects.all(), to_attr="prefetched_milestones", ), ], ) def custom_field(self, info: Info) -> str: if hasattr(self, "prefetched_milestones"): return "prefetched" return "not prefetched" @strawberry_django.type( Milestone, ) class MilestoneTypeWithNestedPrefetch: project: ProjectTypeWithPrefetch MilestoneFactory.create() @strawberry.type class Query: milestones: list[MilestoneTypeWithNestedPrefetch] = strawberry_django.field() query = utils.generate_query(Query, enable_optimizer=True) query_str = """ query TestQuery { milestones { project { customField } } } """ assert DjangoOptimizerExtension.enabled.get() result = query(query_str) assert isinstance(result, ExecutionResult) assert not result.errors assert result.data == { "milestones": [ { "project": { "customField": "prefetched", }, }, ], } result2 = query(query_str) assert isinstance(result2, ExecutionResult) assert not result2.errors assert result2.data == { "milestones": [ { "project": { "customField": "prefetched", }, }, ], } @pytest.mark.django_db(transaction=True) def test_strawberry_info_is_passed_to_prefetch_related_and_annotate_callables(): captured_info_prefetch = None captured_info_annotate = None def get_prefetch_queryset(info: Info) -> QuerySet: nonlocal captured_info_prefetch captured_info_prefetch = info return Milestone.objects.all() def get_annotate_expression(info: Info) -> Expression: nonlocal captured_info_annotate captured_info_annotate = info return ExpressionWrapper(F("name"), output_field=CharField()) @strawberry_django.type( Project, ) class ProjectTypeWithPrefetch: @strawberry_django.field( prefetch_related=[ lambda info: Prefetch( "milestones", queryset=get_prefetch_queryset(info), to_attr="prefetched_milestones", ), ], ) def prefetch_custom_field(self, info: Info) -> str: if hasattr(self, "prefetched_milestones"): return "prefetched" return "not prefetched" @strawberry_django.field(annotate={"annotated_value": get_annotate_expression}) def annotate_custom_field(self, info: Info) -> str: return "annotated" @strawberry_django.type( Milestone, ) class MilestoneTypeWithNestedPrefetch: project: ProjectTypeWithPrefetch MilestoneFactory.create() @strawberry.type class Query: milestones: list[MilestoneTypeWithNestedPrefetch] = strawberry_django.field() query = utils.generate_query(Query, enable_optimizer=True) query_str = """ query TestQuery { milestones { project { prefetchCustomField annotateCustomField } } } """ assert DjangoOptimizerExtension.enabled.get() query(query_str) assert isinstance(captured_info_prefetch, Info) assert isinstance(captured_info_annotate, Info) @pytest.mark.django_db(transaction=True) def test_query_select_related_with_only(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($id: ID!) { issue (id: $id) { id milestoneName } } """ milestone = MilestoneFactory.create() issue = IssueFactory.create(milestone=milestone) with assert_num_queries(1 if DjangoOptimizerExtension.enabled.get() else 2): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == { "issue": { "id": to_base64("IssueType", issue.id), "milestoneName": milestone.name, }, } @pytest.mark.django_db(transaction=True) def test_query_select_related_without_only(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($id: ID!) { issue (id: $id) { id milestoneNameWithoutOnlyOptimization } } """ milestone = MilestoneFactory.create() issue = IssueFactory.create(milestone=milestone) with assert_num_queries(1 if DjangoOptimizerExtension.enabled.get() else 2): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == { "issue": { "id": to_base64("IssueType", issue.id), "milestoneNameWithoutOnlyOptimization": milestone.name, }, } @pytest.mark.django_db(transaction=True) def test_handles_existing_select_related(db, gql_client: GraphQLTestClient): """select_related should not cause errors, even if the field does not get queried.""" # We're *not* querying the issues' milestones, even though it's # prefetched. query = """ query TestQuery { tagList { issuesWithSelectedRelatedMilestoneAndProject { id name } } } """ tag = TagFactory.create() issues = IssueFactory.create_batch(3) for issue in issues: tag.issues.add(issue) with assert_num_queries(2): res = gql_client.query(query) assert res.data == { "tagList": [ { "issuesWithSelectedRelatedMilestoneAndProject": [ {"id": to_base64("IssueType", t.id), "name": t.name} for t in sorted(issues, key=lambda i: i.pk) ], }, ], } @pytest.mark.django_db(transaction=True) def test_query_nested_connection_with_filter(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($id: ID!) { milestone(id: $id) { id issuesWithFilters (filters: {search: "Foo"}) { edges { node { id } } } } } """ milestone = MilestoneFactory.create() issue1 = IssueFactory.create(milestone=milestone, name="Foo") issue2 = IssueFactory.create(milestone=milestone, name="Foo Bar") issue3 = IssueFactory.create(milestone=milestone, name="Bar Foo") IssueFactory.create(milestone=milestone, name="Bar Bin") with assert_num_queries(2): res = gql_client.query(query, {"id": to_base64("MilestoneType", milestone.pk)}) assert isinstance(res.data, dict) result = res.data["milestone"] assert isinstance(result, dict) expected = {to_base64("IssueType", i.pk) for i in [issue1, issue2, issue3]} assert { edge["node"]["id"] for edge in result["issuesWithFilters"]["edges"] } == expected @pytest.mark.django_db(transaction=True) def test_query_nested_connection_with_filter_and_alias( db, gql_client: GraphQLTestClient ): query = """ query TestQuery ($id: ID!) { milestone(id: $id) { id fooIssues: issuesWithFilters (filters: {search: "Foo"}) { edges { node { id } } } barIssues: issuesWithFilters (filters: {search: "Bar"}) { edges { node { id } } } } } """ milestone = MilestoneFactory.create() issue1 = IssueFactory.create(milestone=milestone, name="Foo") issue2 = IssueFactory.create(milestone=milestone, name="Foo Bar") issue3 = IssueFactory.create(milestone=milestone, name="Bar Foo") issue4 = IssueFactory.create(milestone=milestone, name="Bar Bin") with assert_num_queries(3): res = gql_client.query(query, {"id": to_base64("MilestoneType", milestone.pk)}) assert isinstance(res.data, dict) result = res.data["milestone"] assert isinstance(result, dict) foo_expected = {to_base64("IssueType", i.pk) for i in [issue1, issue2, issue3]} assert {edge["node"]["id"] for edge in result["fooIssues"]["edges"]} == foo_expected bar_expected = {to_base64("IssueType", i.pk) for i in [issue2, issue3, issue4]} assert {edge["node"]["id"] for edge in result["barIssues"]["edges"]} == bar_expected @pytest.mark.django_db(transaction=True) def test_query_prefetch_with_aliases_same_field(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($node_id: ID!) { project (id: $node_id) { id a: milestones { id name } b: milestones { id name } } } """ project = ProjectFactory.create() milestones = MilestoneFactory.create_batch(3, project=project) expected_milestones = [ { "id": to_base64("MilestoneType", m.id), "name": m.name, } for m in milestones ] node_id = to_base64("ProjectType", project.pk) with assert_num_queries(2 if DjangoOptimizerExtension.enabled.get() else 3): res = gql_client.query(query, {"node_id": node_id}) assert res.data == { "project": { "id": node_id, "a": expected_milestones, "b": expected_milestones, }, } @pytest.mark.django_db(transaction=True) def test_query_prefetch_with_aliases_different_subselections( db, gql_client: GraphQLTestClient ): query = """ query TestQuery ($node_id: ID!) { project (id: $node_id) { id a: milestones { id } b: milestones { id name } } } """ project = ProjectFactory.create() milestones = MilestoneFactory.create_batch(3, project=project) node_id = to_base64("ProjectType", project.pk) with assert_num_queries(2 if DjangoOptimizerExtension.enabled.get() else 3): res = gql_client.query(query, {"node_id": node_id}) assert res.data == { "project": { "id": node_id, "a": [{"id": to_base64("MilestoneType", m.id)} for m in milestones], "b": [ {"id": to_base64("MilestoneType", m.id), "name": m.name} for m in milestones ], }, } @pytest.mark.django_db(transaction=True) def test_query_with_optimizer_paginated_prefetch(): @strawberry_django.type(Milestone, pagination=True) class MilestoneTypeWithNestedPrefetch: @strawberry_django.field() def name(self, info: Info) -> str: return self.name @strawberry_django.type( Project, ) class ProjectTypeWithPrefetch: @strawberry_django.field() def name(self, info: Info) -> str: return self.name milestones: list[MilestoneTypeWithNestedPrefetch] milestone1 = MilestoneFactory.create() project = milestone1.project MilestoneFactory.create(project=project) @strawberry.type class Query: projects: list[ProjectTypeWithPrefetch] = strawberry_django.field() query1 = utils.generate_query(Query, enable_optimizer=False) query_str = """ fragment f on ProjectTypeWithPrefetch { milestones (pagination: {limit: 1}) { name } } query TestQuery { projects { name ...f } } """ # NOTE: The following assertion doesn't work because the # DjangoOptimizerExtension instance is not the one within the # generate_query wrapper """ assert DjangoOptimizerExtension.enabled.get() """ result1 = query1(query_str) assert isinstance(result1, ExecutionResult) assert not result1.errors assert result1.data == { "projects": [ { "name": project.name, "milestones": [ { "name": milestone1.name, }, ], }, ], } query2 = utils.generate_query(Query, enable_optimizer=True) result2 = query2(query_str) assert isinstance(result2, ExecutionResult) assert result2.data == { "projects": [ { "name": project.name, "milestones": [ { "name": milestone1.name, }, ], }, ], } @pytest.mark.django_db(transaction=True) def test_nested_prefetch_with_filter(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($id: ID!) { milestone(id: $id) { id name issues (filters: {search: "Foo"}) { id name } } } """ milestone = MilestoneFactory.create() issue1 = IssueFactory.create(milestone=milestone, name="Foo") issue2 = IssueFactory.create(milestone=milestone, name="Foo Bar") IssueFactory.create(milestone=milestone, name="Bar") issue4 = IssueFactory.create(milestone=milestone, name="Bar Foo") IssueFactory.create(milestone=milestone, name="Bar Bin") with assert_num_queries(2): res = gql_client.query( query, {"id": to_base64("MilestoneType", milestone.pk)}, ) assert isinstance(res.data, dict) assert res.data == { "milestone": { "id": to_base64("MilestoneType", milestone.pk), "name": milestone.name, "issues": [ { "id": to_base64("IssueType", issue.pk), "name": issue.name, } for issue in [issue1, issue2, issue4] ], }, } @pytest.mark.django_db(transaction=True) def test_nested_prefetch_with_filter_and_pagination(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($id: ID!) { milestone(id: $id) { id name issues (filters: {search: "Foo"}, pagination: {limit: 2}) { id name } } } """ milestone = MilestoneFactory.create() issue1 = IssueFactory.create(milestone=milestone, name="Foo") issue2 = IssueFactory.create(milestone=milestone, name="Foo Bar") IssueFactory.create(milestone=milestone, name="Bar") IssueFactory.create(milestone=milestone, name="Bar Foo") IssueFactory.create(milestone=milestone, name="Bar Bin") with assert_num_queries(2): res = gql_client.query( query, {"id": to_base64("MilestoneType", milestone.pk)}, ) assert isinstance(res.data, dict) assert res.data == { "milestone": { "id": to_base64("MilestoneType", milestone.pk), "name": milestone.name, "issues": [ { "id": to_base64("IssueType", issue.pk), "name": issue.name, } for issue in [issue1, issue2] ], }, } @pytest.mark.django_db(transaction=True) def test_nested_prefetch_with_multiple_levels(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($id: ID!) { milestone(id: $id) { id name issues (order: { name: ASC }) { id name tags { id name } } } } """ milestone = MilestoneFactory.create() issue1 = IssueFactory.create(milestone=milestone, name="2Foo") issue2 = IssueFactory.create(milestone=milestone, name="1Foo") issue3 = IssueFactory.create(milestone=milestone, name="4Foo") issue4 = IssueFactory.create(milestone=milestone, name="3Foo") issue5 = IssueFactory.create(milestone=milestone, name="5Foo") tag1 = TagFactory.create() issue1.tags.add(tag1) issue2.tags.add(tag1) tag2 = TagFactory.create() issue2.tags.add(tag2) issue3.tags.add(tag2) with assert_num_queries(3 if DjangoOptimizerExtension.enabled.get() else 7): res = gql_client.query( query, {"id": to_base64("MilestoneType", milestone.pk)}, ) expected_issues = [ { "id": to_base64("IssueType", issue.pk), "name": issue.name, "tags": [ {"id": to_base64("TagType", tag.pk), "name": tag.name} for tag in tags ], } for issue, tags in [ (issue2, [tag1, tag2]), (issue1, [tag1]), (issue4, []), (issue3, [tag2]), (issue5, []), ] ] assert isinstance(res.data, dict) assert res.data == { "milestone": { "id": to_base64("MilestoneType", milestone.pk), "name": milestone.name, "issues": expected_issues, }, } @pytest.mark.django_db(transaction=True) def test_nested_prefetch_with_get_queryset( db, gql_client: GraphQLTestClient, mocker: MockerFixture, ): mock_get_queryset = mocker.spy(StaffType, "get_queryset") query = """ query TestQuery ($id: ID!) { issue(id: $id) { id staffAssignees { id } } } """ issue = IssueFactory.create() user = UserFactory.create() staff = StaffUserFactory.create() for u in [user, staff]: Assignee.objects.create(user=u, issue=issue) res = gql_client.query( query, {"id": to_base64("IssueType", issue.pk)}, ) assert isinstance(res.data, dict) assert res.data == { "issue": { "id": to_base64("IssueType", issue.pk), "staffAssignees": [{"id": to_base64("StaffType", staff.username)}], }, } mock_get_queryset.assert_called_once() @pytest.mark.django_db(transaction=True) def test_prefetch_hint_with_same_name_field_no_extra_queries( db, ): @strawberry_django.type(Issue) class IssueType: pk: strawberry.ID @strawberry_django.type(Milestone) class MilestoneType: pk: strawberry.ID @strawberry_django.field( prefetch_related=[ lambda info: Prefetch( "issues", queryset=Issue.objects.filter(name__startswith="Foo"), to_attr="_my_issues", ), ], ) def issues(self) -> list[IssueType]: return self._my_issues # type: ignore @strawberry.type class Query: milestone: MilestoneType = strawberry_django.field() schema = strawberry.Schema(query=Query, extensions=[DjangoOptimizerExtension]) milestone1 = MilestoneFactory.create() milestone2 = MilestoneFactory.create() issue1 = IssueFactory.create(name="Foo", milestone=milestone1) IssueFactory.create(name="Bar", milestone=milestone1) IssueFactory.create(name="Foo", milestone=milestone2) query = """\ query TestQuery ($pk: ID!) { milestone(pk: $pk) { pk issues { pk } } } """ with assert_num_queries(2): res = schema.execute_sync(query, {"pk": milestone1.pk}) assert res.errors is None assert res.data == { "milestone": { "pk": str(milestone1.pk), "issues": [{"pk": str(issue1.pk)}], }, } @pytest.mark.django_db(transaction=True) def test_query_paginated(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($pagination: OffsetPaginationInput) { issuesPaginated (pagination: $pagination) { totalCount results { name milestone { name } } } } """ milestone1 = MilestoneFactory.create() milestone2 = MilestoneFactory.create() issue1 = IssueFactory.create(milestone=milestone1) issue2 = IssueFactory.create(milestone=milestone1) issue3 = IssueFactory.create(milestone=milestone1) issue4 = IssueFactory.create(milestone=milestone2) issue5 = IssueFactory.create(milestone=milestone2) with assert_num_queries(2 if DjangoOptimizerExtension.enabled.get() else 7): res = gql_client.query(query) assert res.data == { "issuesPaginated": { "totalCount": 5, "results": [ {"name": issue1.name, "milestone": {"name": milestone1.name}}, {"name": issue2.name, "milestone": {"name": milestone1.name}}, {"name": issue3.name, "milestone": {"name": milestone1.name}}, {"name": issue4.name, "milestone": {"name": milestone2.name}}, {"name": issue5.name, "milestone": {"name": milestone2.name}}, ], } } with assert_num_queries(2 if DjangoOptimizerExtension.enabled.get() else 4): res = gql_client.query(query, variables={"pagination": {"limit": 2}}) assert res.data == { "issuesPaginated": { "totalCount": 5, "results": [ {"name": issue1.name, "milestone": {"name": milestone1.name}}, {"name": issue2.name, "milestone": {"name": milestone1.name}}, ], } } with assert_num_queries(2 if DjangoOptimizerExtension.enabled.get() else 4): res = gql_client.query( query, variables={"pagination": {"limit": 2, "offset": 2}} ) assert res.data == { "issuesPaginated": { "totalCount": 5, "results": [ {"name": issue3.name, "milestone": {"name": milestone1.name}}, {"name": issue4.name, "milestone": {"name": milestone2.name}}, ], } } @pytest.mark.django_db(transaction=True) def test_query_paginated_nested(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($pagination: OffsetPaginationInput) { milestoneList { name issuesPaginated (pagination: $pagination) { totalCount results { name milestone { name } } } } } """ milestone1 = MilestoneFactory.create() milestone2 = MilestoneFactory.create() issue1 = IssueFactory.create(milestone=milestone1) issue2 = IssueFactory.create(milestone=milestone1) issue3 = IssueFactory.create(milestone=milestone1) issue4 = IssueFactory.create(milestone=milestone2) issue5 = IssueFactory.create(milestone=milestone2) with assert_num_queries(2 if DjangoOptimizerExtension.enabled.get() else 5): res = gql_client.query(query) assert res.data == { "milestoneList": [ { "name": milestone1.name, "issuesPaginated": { "totalCount": 3, "results": [ {"name": issue1.name, "milestone": {"name": milestone1.name}}, {"name": issue2.name, "milestone": {"name": milestone1.name}}, {"name": issue3.name, "milestone": {"name": milestone1.name}}, ], }, }, { "name": milestone2.name, "issuesPaginated": { "totalCount": 2, "results": [ {"name": issue4.name, "milestone": {"name": milestone2.name}}, {"name": issue5.name, "milestone": {"name": milestone2.name}}, ], }, }, ] } with assert_num_queries(2 if DjangoOptimizerExtension.enabled.get() else 5): res = gql_client.query(query, variables={"pagination": {"limit": 1}}) assert res.data == { "milestoneList": [ { "name": milestone1.name, "issuesPaginated": { "totalCount": 3, "results": [ {"name": issue1.name, "milestone": {"name": milestone1.name}}, ], }, }, { "name": milestone2.name, "issuesPaginated": { "totalCount": 2, "results": [ {"name": issue4.name, "milestone": {"name": milestone2.name}}, ], }, }, ] } with assert_num_queries(3 if DjangoOptimizerExtension.enabled.get() else 5): res = gql_client.query( query, variables={"pagination": {"limit": 1, "offset": 2}} ) assert res.data == { "milestoneList": [ { "name": milestone1.name, "issuesPaginated": { "totalCount": 3, "results": [ {"name": issue3.name, "milestone": {"name": milestone1.name}}, ], }, }, { "name": milestone2.name, "issuesPaginated": { "totalCount": 2, "results": [], }, }, ] } @pytest.mark.django_db(transaction=True) def test_prefetch_multi_field_single_optional(db, gql_client: GraphQLTestClient): milestone1 = MilestoneFactory.create() milestone2 = MilestoneFactory.create() issue = IssueFactory.create(name="Foo", milestone=milestone1) issue_id = str( GlobalID(get_object_definition(IssueType, strict=True).name, str(issue.id)) ) milestone_id_1 = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone1.id) ) ) milestone_id_2 = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone2.id) ) ) query = """\ query TestQuery($id1: ID!, $id2: ID!) { a: milestone(id: $id1) { firstIssue { id } } b: milestone(id: $id2) { firstIssue { id } } } """ with assert_num_queries(4): res = gql_client.query( query, variables={"id1": milestone_id_1, "id2": milestone_id_2} ) assert res.errors is None assert res.data == { "a": { "firstIssue": { "id": issue_id, }, }, "b": { "firstIssue": None, }, } @pytest.mark.django_db(transaction=True) def test_prefetch_multi_field_single_required(db, gql_client: GraphQLTestClient): milestone = MilestoneFactory.create() issue = IssueFactory.create(name="Foo", milestone=milestone) issue_id = str( GlobalID(get_object_definition(IssueType, strict=True).name, str(issue.id)) ) milestone_id = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone.id) ) ) query = """\ query TestQuery($id: ID!) { milestone(id: $id) { firstIssueRequired { id } } } """ with assert_num_queries(2): res = gql_client.query(query, variables={"id": milestone_id}) assert res.errors is None assert res.data == { "milestone": { "firstIssueRequired": { "id": issue_id, }, }, } @pytest.mark.django_db(transaction=True) def test_prefetch_multi_field_single_required_missing( db, gql_client: GraphQLTestClient ): milestone1 = MilestoneFactory.create() milestone_id = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone1.id) ) ) query = """\ query TestQuery($id: ID!) { milestone(id: $id) { firstIssueRequired { id } } } """ with assert_num_queries(2): res = gql_client.query( query, variables={"id": milestone_id}, assert_no_errors=False ) assert res.errors is not None assert res.errors == [ { "locations": [{"column": 11, "line": 3}], "message": "Issue matching query does not exist.", "path": ["milestone", "firstIssueRequired"], } ] @pytest.mark.django_db(transaction=True) def test_prefetch_multi_field_single_required_multiple_returned( db, gql_client: GraphQLTestClient ): milestone = MilestoneFactory.create() milestone_id = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone.id) ) ) IssueFactory.create(name="Foo", milestone=milestone) IssueFactory.create(name="Bar", milestone=milestone) query = """\ query TestQuery($id: ID!) { milestone(id: $id) { firstIssueRequired { id } } } """ with assert_num_queries(2): res = gql_client.query( query, variables={"id": milestone_id}, assert_no_errors=False ) assert res.errors is not None assert res.errors == [ { "locations": [{"column": 11, "line": 3}], "message": "get() returned more than one Issue -- it returned 2!", "path": ["milestone", "firstIssueRequired"], } ] @pytest.mark.django_db(transaction=True) def test_no_window_function_for_normal_prefetch( db, ): @strawberry_django.type(Project) class ProjectType: pk: strawberry.ID name: str @staticmethod def get_queryset(qs, info): # get_queryset exists to force the optimizer to use prefetch instead of select_related return qs @strawberry_django.type(Milestone) class MilestoneType: pk: strawberry.ID project: ProjectType @strawberry.type class Query: milestones: list[MilestoneType] = strawberry_django.field() schema = strawberry.Schema(query=Query, extensions=[DjangoOptimizerExtension]) milestone1 = MilestoneFactory.create() milestone2 = MilestoneFactory.create() query = """\ query TestQuery { milestones { pk project { pk name } } } """ with CaptureQueriesContext(connection=connections[DEFAULT_DB_ALIAS]) as ctx: res = schema.execute_sync(query) assert len(ctx.captured_queries) == 2 # Test that the Prefetch does not use Window pagination unnecessarily assert not any( '"_strawberry_row_number"' in q["sql"] for q in ctx.captured_queries ) assert res.errors is None assert res.data == { "milestones": [ { "pk": str(milestone1.pk), "project": { "pk": str(milestone1.project.pk), "name": milestone1.project.name, }, }, { "pk": str(milestone2.pk), "project": { "pk": str(milestone2.project.pk), "name": milestone2.project.name, }, }, ] } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("gql_client", ["async", "sync"], indirect=True) def test_custom_prefetch_optimization(gql_client): project = ProjectFactory.create() milestone = MilestoneFactory.create(project=project, name="Hello") project_id = str( GlobalID(get_object_definition(ProjectType, strict=True).name, str(project.id)) ) milestone_id = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone.id) ) ) query = """\ query TestQuery($id: ID!) { project(id: $id) { id customMilestones { id name } } } """ with assert_num_queries(2) as ctx: res = gql_client.query( query, variables={"id": project_id}, assert_no_errors=False ) assert Milestone._meta.db_table in ctx.captured_queries[1]["sql"] assert ( Milestone._meta.get_field("due_date").name not in ctx.captured_queries[1]["sql"] ) assert res.errors is None assert res.data == { "project": { "id": project_id, "customMilestones": [{"id": milestone_id, "name": milestone.name}], } } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("gql_client", ["async", "sync"], indirect=True) def test_custom_prefetch_optimization_nested(gql_client): project = ProjectFactory.create() milestone1 = MilestoneFactory.create(project=project, name="Hello1") milestone2 = MilestoneFactory.create(project=project, name="Hello2") project_id = str( GlobalID(get_object_definition(ProjectType, strict=True).name, str(project.id)) ) milestone1_id = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone1.id) ) ) milestone2_id = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone2.id) ) ) query = """\ query TestQuery($id: ID!) { milestone(id: $id) { id project { id customMilestones { id name } } } } """ with assert_num_queries(2) as ctx: res = gql_client.query( query, variables={"id": milestone1_id}, assert_no_errors=False ) assert Milestone._meta.db_table in ctx.captured_queries[1]["sql"] assert ( Milestone._meta.get_field("due_date").name not in ctx.captured_queries[1]["sql"] ) assert res.errors is None assert res.data == { "milestone": { "id": milestone1_id, "project": { "id": project_id, "customMilestones": [ {"id": milestone1_id, "name": milestone1.name}, {"id": milestone2_id, "name": milestone2.name}, ], }, } } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("gql_client", ["async", "sync"], indirect=True) def test_custom_prefetch_model_property_optimization(gql_client): project = ProjectFactory.create() milestone = MilestoneFactory.create(project=project, name="Hello") project_id = str( GlobalID(get_object_definition(ProjectType, strict=True).name, str(project.id)) ) milestone_id = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone.id) ) ) query = """\ query TestQuery($id: ID!) { project(id: $id) { id customMilestonesModelProperty { id name } } } """ with assert_num_queries(2) as ctx: res = gql_client.query( query, variables={"id": project_id}, assert_no_errors=False ) assert Milestone._meta.db_table in ctx.captured_queries[1]["sql"] assert ( Milestone._meta.get_field("due_date").name not in ctx.captured_queries[1]["sql"] ) assert res.errors is None assert res.data == { "project": { "id": project_id, "customMilestonesModelProperty": [ {"id": milestone_id, "name": milestone.name} ], } } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("gql_client", ["async", "sync"], indirect=True) def test_custom_prefetch_optimization_model_property_nested(gql_client): project = ProjectFactory.create() milestone1 = MilestoneFactory.create(project=project, name="Hello1") milestone2 = MilestoneFactory.create(project=project, name="Hello2") project_id = str( GlobalID(get_object_definition(ProjectType, strict=True).name, str(project.id)) ) milestone1_id = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone1.id) ) ) milestone2_id = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone2.id) ) ) query = """\ query TestQuery($id: ID!) { milestone(id: $id) { id project { id customMilestonesModelProperty { id name } } } } """ with assert_num_queries(2) as ctx: res = gql_client.query( query, variables={"id": milestone1_id}, assert_no_errors=False ) assert Milestone._meta.db_table in ctx.captured_queries[1]["sql"] assert ( Milestone._meta.get_field("due_date").name not in ctx.captured_queries[1]["sql"] ) assert res.errors is None assert res.data == { "milestone": { "id": milestone1_id, "project": { "id": project_id, "customMilestonesModelProperty": [ {"id": milestone1_id, "name": milestone1.name}, {"id": milestone2_id, "name": milestone2.name}, ], }, } } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("gql_client", ["async", "sync"], indirect=True) def test_custom_prefetch_optimize_auto_selects_fk(gql_client): """Regression test for #862: optimize() inside Prefetch auto-adds FK field to .only().""" project = ProjectFactory.create() milestone = MilestoneFactory.create(project=project, name="Hello") query = """\ query TestQuery { projectList { customMilestones { id name } } } """ with assert_num_queries(2) as ctx: res = gql_client.query(query, assert_no_errors=False) assert res.errors is None milestone_id = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone.id) ) ) assert res.data == { "projectList": [ {"customMilestones": [{"id": milestone_id, "name": milestone.name}]} ] } prefetch_sql = next( q["sql"] for q in ctx.captured_queries if Milestone._meta.db_table in q["sql"] ) assert "project_id" in prefetch_sql assert Milestone._meta.get_field("due_date").name not in prefetch_sql @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("gql_client", ["async", "sync"], indirect=True) def test_correct_annotation_info(gql_client): project = ProjectFactory.create() milestone = MilestoneFactory.create(project=project, name="Hello") project_id = str( GlobalID(get_object_definition(ProjectType, strict=True).name, str(project.id)) ) milestone_id = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone.id) ) ) query = """\ query TestQuery($id: ID!) { project(id: $id) { id milestones { id graphqlPath } } } """ res = gql_client.query(query, variables={"id": project_id}, assert_no_errors=False) assert res.errors is None assert res.data == { "project": { "id": project_id, "milestones": [ { "id": milestone_id, "graphqlPath": "project,0,milestones,0,graphqlPath", } ], } } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("gql_client", ["async", "sync"], indirect=True) def test_correct_annotation_info_nested(gql_client): project = ProjectFactory.create() milestone1 = MilestoneFactory.create(project=project, name="Hello1") milestone2 = MilestoneFactory.create(project=project, name="Hello2") project_id = str( GlobalID(get_object_definition(ProjectType, strict=True).name, str(project.id)) ) milestone1_id = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone1.id) ) ) milestone2_id = str( GlobalID( get_object_definition(MilestoneType, strict=True).name, str(milestone2.id) ) ) query = """\ query TestQuery($id: ID!) { milestone(id: $id) { id graphqlPath project { id milestones { id graphqlPath } } } } """ res = gql_client.query( query, variables={"id": milestone1_id}, assert_no_errors=False ) assert res.errors is None assert res.data == { "milestone": { "id": milestone1_id, "graphqlPath": "milestone,0,graphqlPath", "project": { "id": project_id, "milestones": [ { "id": milestone1_id, "graphqlPath": "milestone,0,project,0,milestones,0,graphqlPath", }, { "id": milestone2_id, "graphqlPath": "milestone,0,project,0,milestones,0,graphqlPath", }, ], }, } } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("gql_client", ["async", "sync"], indirect=True) def test_mixed_annotation_prefetch(gql_client): project = ProjectFactory.create() MilestoneFactory.create(project=project, name="Hello") project_id = str( GlobalID(get_object_definition(ProjectType, strict=True).name, str(project.id)) ) query = """\ query TestQuery($id: ID!) { project(id: $id) { milestones { mixedAnnotatedPrefetch mixedPrefetchAnnotated } } } """ res = gql_client.query(query, variables={"id": project_id}, assert_no_errors=False) assert res.errors is None assert res.data == { "project": { "milestones": [ { "mixedAnnotatedPrefetch": "dummy", "mixedPrefetchAnnotated": "dummy", } ], } } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("gql_client", ["sync", "async"], indirect=True) def test_nested_annotation_via_select_related(db, gql_client: GraphQLTestClient): """Test that annotations work on nested types accessed via select_related. Regression test for https://github.com/strawberry-graphql/strawberry-django/issues/549 and https://github.com/strawberry-graphql/strawberry-django/issues/743 When querying a nested type (e.g., issue.milestone.project) via select_related, annotations on the nested type should work correctly. Previously, the optimizer would try to annotate the outer queryset with a prefixed path (e.g., `milestone__project__annotation`), but this doesn't populate the annotation on the nested object - it populates it on the root object. """ from django.db.models.functions import Upper @strawberry_django.type(Project) class ProjectTypeWithAnnotation: name: strawberry.auto name_upper: str = strawberry_django.field( annotate=Upper("name"), ) @strawberry_django.type(Milestone) class MilestoneTypeWithAnnotation: name: strawberry.auto project: ProjectTypeWithAnnotation @strawberry_django.type(Issue) class IssueTypeWithAnnotation: name: strawberry.auto milestone: MilestoneTypeWithAnnotation @strawberry.type class Query: issues: list[IssueTypeWithAnnotation] = strawberry_django.field() schema = strawberry.Schema( query=Query, extensions=[DjangoOptimizerExtension], ) project = ProjectFactory.create(name="TestProject") milestone = MilestoneFactory.create(project=project, name="Milestone1") IssueFactory.create(milestone=milestone, name="Issue1") query = """\ query TestQuery { issues { name milestone { name project { name nameUpper } } } } """ result = schema.execute_sync(query) assert result.errors is None, result.errors assert result.data == { "issues": [ { "name": "Issue1", "milestone": { "name": "Milestone1", "project": { "name": "TestProject", "nameUpper": "TESTPROJECT", }, }, }, ], } @pytest.mark.django_db(transaction=True) def test_annotation_on_type_via_immediate_fk(db): """Test that annotations work on types accessed via an immediate FK (1-level deep). Regression test for https://github.com/strawberry-graphql/strawberry-django/issues/869 When querying a nested type via FK (e.g., milestone.project) where the nested type has an annotated field, the optimizer should use prefetch_related instead of select_related so the annotation is applied to the related model's queryset. """ from django.db.models import Count @strawberry_django.type(Project) class ProjectTypeWithAnnotation: name: strawberry.auto milestone_count: int = strawberry_django.field( annotate=Count("milestone"), ) @strawberry_django.type(Milestone) class MilestoneTypeWithAnnotation: name: strawberry.auto project: ProjectTypeWithAnnotation @strawberry.type class Query: milestones: list[MilestoneTypeWithAnnotation] = strawberry_django.field() schema = strawberry.Schema( query=Query, extensions=[DjangoOptimizerExtension], ) project = ProjectFactory.create(name="TestProject") MilestoneFactory.create(project=project, name="M1") MilestoneFactory.create(project=project, name="M2") query = """\ query TestQuery { milestones { name project { name milestoneCount } } } """ result = schema.execute_sync(query) assert result.errors is None, result.errors assert result.data is not None milestones = sorted(result.data["milestones"], key=operator.itemgetter("name")) assert milestones == [ { "name": "M1", "project": { "name": "TestProject", "milestoneCount": 2, }, }, { "name": "M2", "project": { "name": "TestProject", "milestoneCount": 2, }, }, ] @pytest.mark.django_db(transaction=True) def test_annotation_on_type_via_immediate_fk_dict_style(db): """Test dict-style annotate on type accessed via FK (1-level deep). Regression test for https://github.com/strawberry-graphql/strawberry-django/issues/869 Uses annotate={"key": expr} format (as reported in the issue) instead of annotate=expr shorthand. """ from django.db.models import Count @strawberry_django.type(Milestone) class MilestoneTypeWithAnnotation: name: strawberry.auto issue_count: int = strawberry_django.field( annotate={"issue_count": Count("issue")}, ) @strawberry_django.type(Issue) class IssueTypeWithAnnotation: name: strawberry.auto milestone: MilestoneTypeWithAnnotation @strawberry.type class Query: issues: list[IssueTypeWithAnnotation] = strawberry_django.field() schema = strawberry.Schema( query=Query, extensions=[DjangoOptimizerExtension], ) project = ProjectFactory.create(name="TestProject") milestone = MilestoneFactory.create(project=project, name="Milestone1") IssueFactory.create(milestone=milestone, name="Issue1") IssueFactory.create(milestone=milestone, name="Issue2") query = """\ query TestQuery { issues { name milestone { name issueCount } } } """ result = schema.execute_sync(query) assert result.errors is None, result.errors assert result.data is not None issues = sorted(result.data["issues"], key=operator.itemgetter("name")) assert issues == [ { "name": "Issue1", "milestone": { "name": "Milestone1", "issueCount": 2, }, }, { "name": "Issue2", "milestone": { "name": "Milestone1", "issueCount": 2, }, }, ] @pytest.mark.django_db(transaction=True) def test_annotation_on_type_via_reverse_one_to_one(db): """Test annotations on type accessed via reverse OneToOne. Regression test for https://github.com/strawberry-graphql/strawberry-django/issues/869 When querying User → assigned_role (reverse OneToOne) where UserAssignedRoleType has an annotation, the optimizer should use prefetch_related instead of select_related. """ from django.contrib.auth import get_user_model from django.db.models import Value from django.db.models.functions import Upper from tests.projects.models import Role, UserAssignedRole @strawberry_django.type(UserAssignedRole) class UserAssignedRoleTypeWithAnnotation: role_name_upper: str = strawberry_django.field( annotate=Upper(Value("ADMIN")), ) @strawberry_django.type(get_user_model()) class UserTypeWithAssignedRole: username: strawberry.auto assigned_role: UserAssignedRoleTypeWithAnnotation | None @strawberry.type class Query: users: list[UserTypeWithAssignedRole] = strawberry_django.field() schema = strawberry.Schema( query=Query, extensions=[DjangoOptimizerExtension], ) user = UserFactory.create(username="testuser") role = Role.objects.create(name="Admin") UserAssignedRole.objects.create(user=user, role=role) query = """\ query TestQuery { users { username assignedRole { roleNameUpper } } } """ result = schema.execute_sync(query) assert result.errors is None, result.errors assert result.data is not None users = [u for u in result.data["users"] if u["username"] == "testuser"] assert len(users) == 1 assert users[0] == { "username": "testuser", "assignedRole": { "roleNameUpper": "ADMIN", }, } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("gql_client", ["sync"], indirect=True) def test_prefetch_related_without_explicit_ordering(db, gql_client: GraphQLTestClient): """Test that prefetch_related works correctly without explicit ordering. Regression test for https://github.com/strawberry-graphql/strawberry-django/issues/772 When using prefetch_related with a custom queryset that has no explicit ordering, the deterministic ordering added by get_queryset should not break the prefetch cache. This uses to_attr which is the recommended approach. """ @strawberry_django.type(Milestone) class MilestoneTypeTest: name: strawberry.auto @strawberry_django.type(Project) class ProjectTypeTest: name: strawberry.auto @strawberry_django.field( prefetch_related=[ Prefetch( "milestones", queryset=Milestone.objects.filter(name__startswith="Test"), to_attr="_filtered_milestones", ) ] ) def filtered_milestones(self) -> list[MilestoneTypeTest]: return self._filtered_milestones # type: ignore @strawberry.type class Query: projects: list[ProjectTypeTest] = strawberry_django.field() schema = strawberry.Schema( query=Query, extensions=[DjangoOptimizerExtension], ) # Create test data project1 = ProjectFactory.create(name="Project1") project2 = ProjectFactory.create(name="Project2") MilestoneFactory.create(project=project1, name="TestMilestone1") MilestoneFactory.create(project=project1, name="TestMilestone2") MilestoneFactory.create( project=project1, name="OtherMilestone" ) # Should be filtered out MilestoneFactory.create(project=project2, name="TestMilestone3") query = """\ query TestQuery { projects { name filteredMilestones { name } } } """ # This should not cause N+1 queries # Expected: 1 query for projects + 1 query for prefetched milestones = 2 queries if DjangoOptimizerExtension.enabled.get(): with assert_num_queries(2): result = schema.execute_sync(query) else: result = schema.execute_sync(query) assert result.errors is None, result.errors assert result.data is not None # Verify the data is correct projects_data = sorted(result.data["projects"], key=operator.itemgetter("name")) assert len(projects_data) == 2 assert projects_data[0]["name"] == "Project1" assert len(projects_data[0]["filteredMilestones"]) == 2 assert projects_data[1]["name"] == "Project2" assert len(projects_data[1]["filteredMilestones"]) == 1 @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("gql_client", ["sync"], indirect=True) def test_prefetch_related_without_to_attr(db, gql_client: GraphQLTestClient): """Test that prefetch_related works correctly without to_attr. Additional regression test for https://github.com/strawberry-graphql/strawberry-django/issues/772 When using prefetch_related without to_attr, accessing the related manager should still use the prefetch cache. """ @strawberry_django.type(Milestone) class MilestoneTypeTest: name: strawberry.auto @strawberry_django.type(Project) class ProjectTypeTest: name: strawberry.auto # Using prefetch_related without to_attr @strawberry_django.field( prefetch_related=[ Prefetch( "milestones", queryset=Milestone.objects.filter(name__startswith="Test"), ) ] ) def filtered_milestones(self) -> list[MilestoneTypeTest]: # This should use the prefetch cache, not trigger a new query return list(self.milestones.all()) # type: ignore @strawberry.type class Query: projects: list[ProjectTypeTest] = strawberry_django.field() schema = strawberry.Schema( query=Query, extensions=[DjangoOptimizerExtension], ) # Create test data project1 = ProjectFactory.create(name="Project1") project2 = ProjectFactory.create(name="Project2") MilestoneFactory.create(project=project1, name="TestMilestone1") MilestoneFactory.create(project=project1, name="TestMilestone2") MilestoneFactory.create( project=project1, name="OtherMilestone" ) # Won't be prefetched due to filter MilestoneFactory.create(project=project2, name="TestMilestone3") query = """\ query TestQuery { projects { name filteredMilestones { name } } } """ # Without to_attr, calling .all() might not use the prefetch cache # This test may fail, showing the N+1 issue if DjangoOptimizerExtension.enabled.get(): with assert_num_queries(2): result = schema.execute_sync(query) else: result = schema.execute_sync(query) assert result.errors is None, result.errors @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("gql_client", ["sync"], indirect=True) def test_merged_custom_prefetches(db, gql_client: GraphQLTestClient): """Test that duplicated prefetch_related hints are correctly merged.""" @strawberry_django.type(Issue) class IssueTypeTest: name: strawberry.auto kind: strawberry.auto @strawberry_django.type(Milestone) class MilestoneTypeTest: name: strawberry.auto @strawberry_django.field() @staticmethod def bugs(parent: strawberry.Parent[Milestone]) -> list[IssueTypeTest]: # This is not the most efficient way to do this, it only exists for this test. return getattr(parent, "bugs", []) @strawberry_django.field() @staticmethod def has_bugs(parent: strawberry.Parent[Milestone]) -> bool: # This is not the most efficient way to do this, it only exists for this test. return bool(getattr(parent, "bugs", None)) @strawberry_django.type(Project) class ProjectTypeTest: name: strawberry.auto @staticmethod def _prefetch_custom_milestones(_: Info) -> Prefetch: return Prefetch( "milestones", to_attr="custom_milestones", queryset=Milestone.objects.all().prefetch_related( Prefetch( "issues", Issue.objects.all().filter(kind=Issue.Kind.BUG), to_attr="bugs", ) ), ) @strawberry_django.field(prefetch_related=_prefetch_custom_milestones) @staticmethod def milestones(parent: strawberry.Parent[Project]) -> list[MilestoneTypeTest]: return getattr(parent, "custom_milestones", []) @strawberry_django.field(prefetch_related=_prefetch_custom_milestones) @staticmethod def milestones2(parent: strawberry.Parent[Project]) -> list[MilestoneTypeTest]: return getattr(parent, "custom_milestones", []) @strawberry.type class Query: projects: list[ProjectTypeTest] = strawberry_django.field() schema = strawberry.Schema( query=Query, extensions=[DjangoOptimizerExtension], ) # Create test data project1 = ProjectFactory.create(name="Project1") project2 = ProjectFactory.create(name="Project2") milestone1 = MilestoneFactory.create(project=project1, name="Milestone1") milestone2 = MilestoneFactory.create(project=project1, name="Milestone2") milestone3 = MilestoneFactory.create(project=project2, name="Milestone3") IssueFactory.create(milestone=milestone1, name="TestFeat1M1", kind="f") IssueFactory.create(milestone=milestone1, name="TestBug1M1", kind="b") IssueFactory.create(milestone=milestone1, name="TestFeat2M1", kind="f") IssueFactory.create(milestone=milestone2, name="TestFeat1M2", kind="f") IssueFactory.create(milestone=milestone3, name="TestFeat1M3", kind="f") IssueFactory.create(milestone=milestone3, name="TestBug1M3", kind="b") query = """\ query TestQuery { projects { name milestones { name hasBugs bugs { name } } milestones2 { name } } } """ if DjangoOptimizerExtension.enabled.get(): with assert_num_queries(3): result = schema.execute_sync(query) else: result = schema.execute_sync(query) assert result.errors is None, result.errors assert result.data == { "projects": [ { "name": "Project1", "milestones": [ { "name": "Milestone1", "hasBugs": True, "bugs": [{"name": "TestBug1M1"}], }, {"name": "Milestone2", "hasBugs": False, "bugs": []}, ], "milestones2": [ {"name": "Milestone1"}, {"name": "Milestone2"}, ], }, { "name": "Project2", "milestones": [ { "name": "Milestone3", "hasBugs": True, "bugs": [{"name": "TestBug1M3"}], }, ], "milestones2": [ {"name": "Milestone3"}, ], }, ], } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("gql_client", ["sync"], indirect=True) def test_prefetch_with_only_injects_fk_field(db, gql_client: GraphQLTestClient): """FK field is injected into .only() on user-provided Prefetch querysets. Regression test for https://github.com/strawberry-graphql/strawberry-django/issues/862 """ @strawberry_django.type(Milestone) class MilestoneTypeTest: name: strawberry.auto @strawberry_django.type(Project) class ProjectTypeTest: name: strawberry.auto @strawberry_django.field( prefetch_related=[ lambda info: Prefetch( "milestones", queryset=Milestone.objects.filter( name__startswith="Test", ).only("id", "name"), to_attr="_optimized_milestones", ) ] ) def optimized_milestones(self) -> list[MilestoneTypeTest]: return self._optimized_milestones # type: ignore @strawberry.type class Query: projects: list[ProjectTypeTest] = strawberry_django.field() schema = strawberry.Schema( query=Query, extensions=[DjangoOptimizerExtension], ) project1 = ProjectFactory.create(name="Project1") project2 = ProjectFactory.create(name="Project2") MilestoneFactory.create(project=project1, name="TestMilestone1") MilestoneFactory.create(project=project1, name="TestMilestone2") MilestoneFactory.create(project=project1, name="OtherMilestone") MilestoneFactory.create(project=project2, name="TestMilestone3") query = """\ query TestQuery { projects { name optimizedMilestones { name } } } """ with assert_num_queries(2): result = schema.execute_sync(query) assert result.errors is None, result.errors assert result.data is not None projects_data = sorted(result.data["projects"], key=operator.itemgetter("name")) assert len(projects_data) == 2 assert projects_data[0]["name"] == "Project1" assert len(projects_data[0]["optimizedMilestones"]) == 2 assert projects_data[1]["name"] == "Project2" assert len(projects_data[1]["optimizedMilestones"]) == 1 strawberry-graphql-django-0.82.1/tests/test_ordering.py000066400000000000000000000336761516173410200233050ustar00rootroot00000000000000import textwrap from typing import Annotated import pytest import strawberry from django.db.models import Case, Count, Value, When from django.db.models.functions import Reverse from strawberry import auto from strawberry.annotation import StrawberryAnnotation from strawberry.relay import Node from strawberry.types import Info from strawberry.types.base import ( StrawberryOptional, get_object_definition, ) from strawberry.types.field import StrawberryField import strawberry_django from strawberry_django.fields.field import StrawberryDjangoField from strawberry_django.fields.filter_order import ( FilterOrderField, FilterOrderFieldResolver, ) from strawberry_django.ordering import Ordering from strawberry_django.pagination import OffsetPaginated from strawberry_django.relay import DjangoListConnection from tests import models, utils from tests.types import Fruit @strawberry_django.order_type(models.Color, name="ColorOrder") class _ColorOrder: pk: auto @strawberry_django.order_field def name(self, prefix, value: auto): return [value.resolve(f"{prefix}name")] ColorOrder = Annotated["_ColorOrder", strawberry.lazy("tests.test_ordering")] @strawberry_django.order_type(models.Fruit) class FruitOrder: color_id: auto name: auto sweetness: auto color: ColorOrder | None @strawberry_django.order_field def types_number(self, queryset, prefix, value: auto): return queryset.annotate( count=Count(f"{prefix}types__id"), count_nulls=Case( When(count=0, then=Value(None)), default="count", ), ), [value.resolve("count_nulls")] @strawberry_django.order_type(models.Fruit) class CustomFruitOrder: reverse_name: auto @strawberry_django.order_field def order(self, info: Info, queryset, prefix): queryset = queryset.annotate(reverse_name=Reverse(f"{prefix}name")) return strawberry_django.ordering.process_ordering_default( self, info, queryset, prefix ) @strawberry_django.type(models.Fruit, ordering=FruitOrder) class FruitWithOrder: id: auto name: auto @strawberry_django.type(models.Fruit) class FruitNode(Node): name: auto @strawberry_django.type(models.Fruit, ordering=FruitOrder) class FruitWithOrderNode(Node): name: auto @strawberry.type class Query: fruits: list[Fruit] = strawberry_django.field(ordering=FruitOrder) fruits_connection: DjangoListConnection[FruitNode] = strawberry_django.connection( ordering=FruitOrder ) fruits_paginated: OffsetPaginated[Fruit] = strawberry_django.offset_paginated( ordering=FruitOrder ) custom_order_fruits: list[Fruit] = strawberry_django.field( ordering=CustomFruitOrder ) fruits_with_order: list[FruitWithOrder] = strawberry_django.field() fruits_with_order_connection: DjangoListConnection[FruitWithOrderNode] = ( strawberry_django.connection() ) fruits_with_lazy_order_connection: DjangoListConnection[FruitWithOrderNode] = ( strawberry_django.connection( order=Annotated["FruitOrder", strawberry.lazy("tests.test_ordering")] ) ) fruits_with_lazy_ordering_connection: DjangoListConnection[FruitWithOrderNode] = ( strawberry_django.connection( ordering=Annotated["FruitOrder", strawberry.lazy("tests.test_ordering")] ) ) fruits_with_order_paginated: OffsetPaginated[FruitWithOrder] = ( strawberry_django.offset_paginated() ) @pytest.fixture def query(): return utils.generate_query(Query) def test_correct_ordering_schema(): @strawberry_django.type(models.Fruit, name="Fruit") class MiniFruit: id: auto name: auto @strawberry_django.order_type(models.Fruit, name="FruitOrder") class MiniFruitOrder: name: auto @strawberry.type(name="Query") class MiniQuery: fruits: list[MiniFruit] = strawberry_django.field(ordering=MiniFruitOrder) schema = strawberry.Schema(query=MiniQuery) expected = """\ directive @oneOf on INPUT_OBJECT type Fruit { id: ID! name: String! } input FruitOrder @oneOf { name: Ordering } enum Ordering { ASC ASC_NULLS_FIRST ASC_NULLS_LAST DESC DESC_NULLS_FIRST DESC_NULLS_LAST } type Query { fruits(ordering: [FruitOrder!]! = []): [Fruit!]! } """ assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() def test_custom_order_method(query, fruits): result = query( "{ customOrderFruits(ordering: [{ reverseName: ASC }]) { id name } }" ) assert not result.errors assert result.data["customOrderFruits"] == [ {"id": "3", "name": "banana"}, {"id": "2", "name": "raspberry"}, {"id": "1", "name": "strawberry"}, ] def test_field_order_definition(): field = StrawberryDjangoField(type_annotation=StrawberryAnnotation(FruitWithOrder)) assert field.get_ordering() == FruitOrder field = StrawberryDjangoField( type_annotation=StrawberryAnnotation(FruitWithOrder), ordering=None, ) assert field.get_ordering() is None def test_type_ordering(query, fruits): result = query("{ fruitsWithOrder(ordering: [{ name: ASC }]) { id name } }") assert not result.errors assert result.data["fruitsWithOrder"] == [ {"id": "3", "name": "banana"}, {"id": "2", "name": "raspberry"}, {"id": "1", "name": "strawberry"}, ] def test_type_ordering_connection(query, fruits): result = query( "{ fruitsWithOrderConnection(ordering: [{ name: ASC }]) { edges { node { name } } } }" ) assert not result.errors assert result.data["fruitsWithOrderConnection"] == { "edges": [ {"node": {"name": "banana"}}, {"node": {"name": "raspberry"}}, {"node": {"name": "strawberry"}}, ] } def test_type_lazy_ordering_connection(query, fruits): result = query( "{ fruitsWithLazyOrderingConnection(ordering: [{ name: ASC }]) { edges { node { name } } } }" ) assert not result.errors assert result.data["fruitsWithLazyOrderingConnection"] == { "edges": [ {"node": {"name": "banana"}}, {"node": {"name": "raspberry"}}, {"node": {"name": "strawberry"}}, ] } def test_type_lazy_order_connection(query, fruits): result = query( "{ fruitsWithLazyOrderConnection(ordering: [{ name: ASC }]) { edges { node { name } } } }" ) assert not result.errors assert result.data["fruitsWithLazyOrderConnection"] == { "edges": [ {"node": {"name": "banana"}}, {"node": {"name": "raspberry"}}, {"node": {"name": "strawberry"}}, ] } def test_type_ordering_paginated(query, fruits): result = query( "{ fruitsWithOrderPaginated(ordering: [{ name: ASC }]) { results { id name } } }" ) assert not result.errors assert result.data["fruitsWithOrderPaginated"] == { "results": [ {"id": "3", "name": "banana"}, {"id": "2", "name": "raspberry"}, {"id": "1", "name": "strawberry"}, ] } def test_ordering_connection(query, fruits): result = query( "{ fruitsConnection(ordering: [{ name: ASC }]) { edges { node { name } } } }" ) assert not result.errors assert result.data["fruitsConnection"] == { "edges": [ {"node": {"name": "banana"}}, {"node": {"name": "raspberry"}}, {"node": {"name": "strawberry"}}, ] } def test_ordering_paginated(query, fruits): result = query( "{ fruitsPaginated(ordering: [{ name: ASC }]) { results { id name } } }" ) assert not result.errors assert result.data["fruitsPaginated"] == { "results": [ {"id": "3", "name": "banana"}, {"id": "2", "name": "raspberry"}, {"id": "1", "name": "strawberry"}, ] } def test_asc(query, fruits): result = query("{ fruits(ordering: [{ name: ASC }]) { id name } }") assert not result.errors assert result.data["fruits"] == [ {"id": "3", "name": "banana"}, {"id": "2", "name": "raspberry"}, {"id": "1", "name": "strawberry"}, ] def test_desc(query, fruits): result = query("{ fruits(ordering: [{ name: DESC }]) { id name } }") assert not result.errors assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, {"id": "2", "name": "raspberry"}, {"id": "3", "name": "banana"}, ] def test_multi_order(query, db): for fruit in ("strawberry", "banana", "raspberry"): models.Fruit.objects.create(name=fruit, sweetness=7) result = query( "{ fruits(ordering: [{ sweetness: ASC }, { name: ASC }]) { id name sweetness } }" ) assert not result.errors assert result.data["fruits"] == [ {"id": "2", "name": "banana", "sweetness": 7}, {"id": "3", "name": "raspberry", "sweetness": 7}, {"id": "1", "name": "strawberry", "sweetness": 7}, ] def test_relationship(query, fruits): def add_color(fruit, color_name): fruit.color = models.Color.objects.create(name=color_name) fruit.save() color_names = ["red", "dark red", "yellow"] for fruit, color_name in zip(fruits, color_names, strict=False): add_color(fruit, color_name) result = query( "{ fruits(ordering: [{ color: { name: DESC } }]) { id name color { name } } }", ) assert not result.errors assert result.data["fruits"] == [ {"id": "3", "name": "banana", "color": {"name": "yellow"}}, {"id": "1", "name": "strawberry", "color": {"name": "red"}}, {"id": "2", "name": "raspberry", "color": {"name": "dark red"}}, ] def test_multi_order_respected(query, db): yellow = models.Color.objects.create(name="yellow") red = models.Color.objects.create(name="red") f1 = models.Fruit.objects.create( name="strawberry", sweetness=1, color=red, ) f2 = models.Fruit.objects.create( name="banana", sweetness=2, color=yellow, ) f3 = models.Fruit.objects.create( name="apple", sweetness=0, color=red, ) result = query("{ fruits(ordering: [{ name: ASC }, { sweetness: ASC }]) { id } }") assert not result.errors assert result.data["fruits"] == [{"id": str(f.pk)} for f in [f3, f2, f1]] result = query("{ fruits(ordering: [{ sweetness: DESC }, { name: ASC }]) { id } }") assert not result.errors assert result.data["fruits"] == [{"id": str(f.pk)} for f in [f2, f1, f3]] result = query( "{ fruits(ordering: [{ color: {name: ASC} }, { name: ASC }]) { id } }" ) assert not result.errors assert result.data["fruits"] == [{"id": str(f.pk)} for f in [f3, f1, f2]] result = query("{ fruits(ordering: [{ color: {pk: ASC} }, { name: ASC }]) { id } }") assert not result.errors assert result.data["fruits"] == [{"id": str(f.pk)} for f in [f2, f3, f1]] result = query("{ fruits(ordering: [{ colorId: ASC }, { name: ASC }]) { id } }") assert not result.errors assert result.data["fruits"] == [{"id": str(f.pk)} for f in [f2, f3, f1]] result = query("{ fruits(ordering: [{ name: ASC }, { colorId: ASC }]) { id } }") assert not result.errors assert result.data["fruits"] == [{"id": str(f.pk)} for f in [f3, f2, f1]] def test_order_type(): @strawberry_django.ordering.order_type(models.Fruit) class FruitOrder: color_id: auto name: auto sweetness: auto @strawberry_django.order_field def custom_order(self, value: auto, prefix: str): pass annotated_type = StrawberryOptional(Ordering.__strawberry_definition__) # type: ignore assert [ ( f.name, f.__class__, f.type, f.base_resolver.__class__ if f.base_resolver else None, ) for f in get_object_definition(FruitOrder, strict=True).fields ] == [ ("color_id", StrawberryField, annotated_type, None), ("name", StrawberryField, annotated_type, None), ("sweetness", StrawberryField, annotated_type, None), ( "custom_order", FilterOrderField, annotated_type, FilterOrderFieldResolver, ), ] def test_order_nulls(query, db, fruits): t1 = models.FruitType.objects.create(name="Type1") t2 = models.FruitType.objects.create(name="Type2") f1, f2, f3 = models.Fruit.objects.all() f2.types.add(t1) f3.types.add(t1, t2) result = query("{ fruits(ordering: [{ typesNumber: ASC }]) { id } }") assert not result.errors assert result.data["fruits"] == [ {"id": str(f1.id)}, {"id": str(f2.id)}, {"id": str(f3.id)}, ] result = query("{ fruits(ordering: [{ typesNumber: DESC }]) { id } }") assert not result.errors assert result.data["fruits"] == [ {"id": str(f3.id)}, {"id": str(f2.id)}, {"id": str(f1.id)}, ] result = query("{ fruits(ordering: [{ typesNumber: ASC_NULLS_FIRST }]) { id } }") assert not result.errors assert result.data["fruits"] == [ {"id": str(f1.id)}, {"id": str(f2.id)}, {"id": str(f3.id)}, ] result = query("{ fruits(ordering: [{ typesNumber: ASC_NULLS_LAST }]) { id } }") assert not result.errors assert result.data["fruits"] == [ {"id": str(f2.id)}, {"id": str(f3.id)}, {"id": str(f1.id)}, ] result = query("{ fruits(ordering: [{ typesNumber: DESC_NULLS_LAST }]) { id } }") assert not result.errors assert result.data["fruits"] == [ {"id": str(f3.id)}, {"id": str(f2.id)}, {"id": str(f1.id)}, ] result = query("{ fruits(ordering: [{ typesNumber: DESC_NULLS_FIRST }]) { id } }") assert not result.errors assert result.data["fruits"] == [ {"id": str(f1.id)}, {"id": str(f3.id)}, {"id": str(f2.id)}, ] strawberry-graphql-django-0.82.1/tests/test_paginated_type.py000066400000000000000000000752151516173410200244640ustar00rootroot00000000000000import textwrap from typing import Annotated import pytest import strawberry from django.db.models import QuerySet from django.test.utils import override_settings from strawberry.extensions.field_extension import FieldExtension import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry_django.pagination import OffsetPaginated, OffsetPaginationInput from strawberry_django.settings import StrawberryDjangoSettings from tests import models def test_paginated_schema(): @strawberry_django.type(models.Fruit) class Fruit: id: int name: str @strawberry_django.type(models.Color) class Color: id: int name: str fruits: OffsetPaginated[Fruit] @strawberry.type class Query: fruits: OffsetPaginated[Fruit] = strawberry_django.offset_paginated() colors: OffsetPaginated[Color] = strawberry_django.offset_paginated() schema = strawberry.Schema(query=Query) expected = '''\ type Color { id: Int! name: String! fruits(pagination: OffsetPaginationInput): FruitOffsetPaginated! } type ColorOffsetPaginated { pageInfo: OffsetPaginationInfo! """Total count of existing results.""" totalCount: Int! """List of paginated results.""" results: [Color!]! } type Fruit { id: Int! name: String! } type FruitOffsetPaginated { pageInfo: OffsetPaginationInfo! """Total count of existing results.""" totalCount: Int! """List of paginated results.""" results: [Fruit!]! } type OffsetPaginationInfo { offset: Int! limit: Int } input OffsetPaginationInput { offset: Int! = 0 limit: Int } type Query { fruits(pagination: OffsetPaginationInput): FruitOffsetPaginated! colors(pagination: OffsetPaginationInput): ColorOffsetPaginated! } ''' assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() @pytest.mark.django_db(transaction=True) def test_pagination_query(): @strawberry_django.type(models.Fruit) class Fruit: id: int name: str @strawberry.type class Query: fruits: OffsetPaginated[Fruit] = strawberry_django.offset_paginated() models.Fruit.objects.create(name="Apple") models.Fruit.objects.create(name="Banana") models.Fruit.objects.create(name="Strawberry") schema = strawberry.Schema(query=Query) query = """\ query GetFruits ($pagination: OffsetPaginationInput) { fruits (pagination: $pagination) { totalCount results { name } } } """ res = schema.execute_sync(query) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "results": [{"name": "Apple"}, {"name": "Banana"}, {"name": "Strawberry"}], } } res = schema.execute_sync(query, variable_values={"pagination": {"limit": 1}}) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "results": [{"name": "Apple"}], } } res = schema.execute_sync( query, variable_values={"pagination": {"limit": 1, "offset": 1}} ) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "results": [{"name": "Banana"}], } } @pytest.mark.django_db(transaction=True) async def test_pagination_query_async(): @strawberry_django.type(models.Fruit) class Fruit: id: int name: str @strawberry.type class Query: fruits: OffsetPaginated[Fruit] = strawberry_django.offset_paginated() await models.Fruit.objects.acreate(name="Apple") await models.Fruit.objects.acreate(name="Banana") await models.Fruit.objects.acreate(name="Strawberry") schema = strawberry.Schema(query=Query) query = """\ query GetFruits ($pagination: OffsetPaginationInput) { fruits (pagination: $pagination) { totalCount results { name } } } """ res = await schema.execute(query) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "results": [{"name": "Apple"}, {"name": "Banana"}, {"name": "Strawberry"}], } } res = await schema.execute(query, variable_values={"pagination": {"limit": 1}}) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "results": [{"name": "Apple"}], } } res = await schema.execute( query, variable_values={"pagination": {"limit": 1, "offset": 1}} ) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "results": [{"name": "Banana"}], } } @strawberry_django.type(models.Fruit) class FruitLazyTest: id: int name: str @strawberry_django.type(models.Color) class ColorLazyTest: id: int name: str fruits: OffsetPaginated[ Annotated["FruitLazyTest", strawberry.lazy("tests.test_paginated_type")] ] = strawberry_django.offset_paginated() @pytest.mark.django_db(transaction=True) def test_pagination_with_lazy_type_and_django_query_optimizer(): @strawberry.type class Query: colors: OffsetPaginated[ColorLazyTest] = strawberry_django.offset_paginated() red = models.Color.objects.create(name="Red") yellow = models.Color.objects.create(name="Yellow") models.Fruit.objects.create(name="Apple", color=red) models.Fruit.objects.create(name="Banana", color=yellow) models.Fruit.objects.create(name="Strawberry", color=red) schema = strawberry.Schema(query=Query, extensions=[DjangoOptimizerExtension]) query = """\ query GetColors ($pagination: OffsetPaginationInput) { colors { totalCount results { fruits (pagination: $pagination) { totalCount results { name } } } } } """ res = schema.execute_sync(query) assert res.errors is None assert res.data == { "colors": { "totalCount": 2, "results": [ { "fruits": { "totalCount": 2, "results": [{"name": "Apple"}, {"name": "Strawberry"}], } }, { "fruits": { "totalCount": 1, "results": [{"name": "Banana"}], } }, ], } } @pytest.mark.django_db(transaction=True) def test_pagination_nested_query(): @strawberry_django.type(models.Fruit) class Fruit: id: int name: str @strawberry_django.type(models.Color) class Color: id: int name: str fruits: OffsetPaginated[Fruit] = strawberry_django.offset_paginated() @strawberry.type class Query: colors: OffsetPaginated[Color] = strawberry_django.offset_paginated() red = models.Color.objects.create(name="Red") yellow = models.Color.objects.create(name="Yellow") models.Fruit.objects.create(name="Apple", color=red) models.Fruit.objects.create(name="Banana", color=yellow) models.Fruit.objects.create(name="Strawberry", color=red) schema = strawberry.Schema(query=Query) query = """\ query GetColors ($pagination: OffsetPaginationInput) { colors { totalCount results { fruits (pagination: $pagination) { totalCount results { name } } } } } """ res = schema.execute_sync(query) assert res.errors is None assert res.data == { "colors": { "totalCount": 2, "results": [ { "fruits": { "totalCount": 2, "results": [{"name": "Apple"}, {"name": "Strawberry"}], } }, { "fruits": { "totalCount": 1, "results": [{"name": "Banana"}], } }, ], } } res = schema.execute_sync(query, variable_values={"pagination": {"limit": 1}}) assert res.errors is None assert res.data == { "colors": { "totalCount": 2, "results": [ { "fruits": { "totalCount": 2, "results": [{"name": "Apple"}], } }, { "fruits": { "totalCount": 1, "results": [{"name": "Banana"}], } }, ], } } res = schema.execute_sync( query, variable_values={"pagination": {"limit": 1, "offset": 1}} ) assert res.errors is None assert res.data == { "colors": { "totalCount": 2, "results": [ { "fruits": { "totalCount": 2, "results": [{"name": "Strawberry"}], } }, { "fruits": { "totalCount": 1, "results": [], } }, ], } } @pytest.mark.django_db(transaction=True) async def test_pagination_nested_query_async(): @strawberry_django.type(models.Fruit) class Fruit: id: int name: str @strawberry_django.type(models.Color) class Color: id: int name: str fruits: OffsetPaginated[Fruit] = strawberry_django.offset_paginated() @strawberry.type class Query: colors: OffsetPaginated[Color] = strawberry_django.offset_paginated() red = await models.Color.objects.acreate(name="Red") yellow = await models.Color.objects.acreate(name="Yellow") await models.Fruit.objects.acreate(name="Apple", color=red) await models.Fruit.objects.acreate(name="Banana", color=yellow) await models.Fruit.objects.acreate(name="Strawberry", color=red) schema = strawberry.Schema(query=Query) query = """\ query GetColors ($pagination: OffsetPaginationInput) { colors { totalCount results { fruits (pagination: $pagination) { totalCount results { name } } } } } """ res = await schema.execute(query) assert res.errors is None assert res.data == { "colors": { "totalCount": 2, "results": [ { "fruits": { "totalCount": 2, "results": [{"name": "Apple"}, {"name": "Strawberry"}], } }, { "fruits": { "totalCount": 1, "results": [{"name": "Banana"}], } }, ], } } res = await schema.execute(query, variable_values={"pagination": {"limit": 1}}) assert res.errors is None assert res.data == { "colors": { "totalCount": 2, "results": [ { "fruits": { "totalCount": 2, "results": [{"name": "Apple"}], } }, { "fruits": { "totalCount": 1, "results": [{"name": "Banana"}], } }, ], } } res = await schema.execute( query, variable_values={"pagination": {"limit": 1, "offset": 1}} ) assert res.errors is None assert res.data == { "colors": { "totalCount": 2, "results": [ { "fruits": { "totalCount": 2, "results": [{"name": "Strawberry"}], } }, { "fruits": { "totalCount": 1, "results": [], } }, ], } } @pytest.mark.django_db(transaction=True) def test_pagination_query_with_subclass(): @strawberry_django.type(models.Fruit) class Fruit: id: int name: str @strawberry.type class FruitPaginated(OffsetPaginated[Fruit]): _custom_field: strawberry.Private[str] @strawberry_django.field def custom_field(self) -> str: return self._custom_field @classmethod def resolve_paginated(cls, queryset, *, info, pagination=None, **kwargs): return cls( queryset=queryset, pagination=pagination or OffsetPaginationInput(), _custom_field="pagination rocks", ) @strawberry.type class Query: fruits: FruitPaginated = strawberry_django.offset_paginated() models.Fruit.objects.create(name="Apple") models.Fruit.objects.create(name="Banana") models.Fruit.objects.create(name="Strawberry") schema = strawberry.Schema(query=Query) query = """\ query GetFruits ($pagination: OffsetPaginationInput) { fruits (pagination: $pagination) { totalCount customField results { name } } } """ res = schema.execute_sync(query) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "customField": "pagination rocks", "results": [{"name": "Apple"}, {"name": "Banana"}, {"name": "Strawberry"}], } } res = schema.execute_sync(query, variable_values={"pagination": {"limit": 1}}) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "customField": "pagination rocks", "results": [{"name": "Apple"}], } } res = schema.execute_sync( query, variable_values={"pagination": {"limit": 1, "offset": 2}} ) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "customField": "pagination rocks", "results": [{"name": "Strawberry"}], } } @pytest.mark.django_db(transaction=True) def test_pagination_query_with_resolver_schema(): @strawberry_django.type(models.Fruit) class Fruit: id: int name: str @strawberry_django.filter_type(models.Fruit) class FruitFilter: name: str @strawberry_django.order(models.Fruit) class FruitOrder: name: str @strawberry.type class Query: @strawberry_django.offset_paginated(OffsetPaginated[Fruit]) def fruits(self) -> QuerySet[models.Fruit]: ... @strawberry_django.offset_paginated( OffsetPaginated[Fruit], filters=FruitFilter, order=FruitOrder, ) def fruits_with_order_and_filter(self) -> QuerySet[models.Fruit]: ... schema = strawberry.Schema(query=Query) expected = ''' type Fruit { id: Int! name: String! } input FruitFilter { name: String! AND: FruitFilter OR: FruitFilter NOT: FruitFilter DISTINCT: Boolean } type FruitOffsetPaginated { pageInfo: OffsetPaginationInfo! """Total count of existing results.""" totalCount: Int! """List of paginated results.""" results: [Fruit!]! } input FruitOrder { name: String } type OffsetPaginationInfo { offset: Int! limit: Int } input OffsetPaginationInput { offset: Int! = 0 limit: Int } type Query { fruits(pagination: OffsetPaginationInput): FruitOffsetPaginated! fruitsWithOrderAndFilter(filters: FruitFilter, order: FruitOrder, pagination: OffsetPaginationInput): FruitOffsetPaginated! } ''' assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() @pytest.mark.django_db(transaction=True) def test_pagination_query_with_resolver(): @strawberry_django.type(models.Fruit) class Fruit: id: int name: str @strawberry_django.filter_type(models.Fruit) class FruitFilter: name: strawberry.auto @strawberry_django.order(models.Fruit) class FruitOrder: name: strawberry.auto @strawberry.type class Query: @strawberry_django.offset_paginated(OffsetPaginated[Fruit]) def fruits(self) -> QuerySet[models.Fruit]: return models.Fruit.objects.filter(name__startswith="S") @strawberry_django.offset_paginated( OffsetPaginated[Fruit], filters=FruitFilter, order=FruitOrder, ) def fruits_with_order_and_filter(self) -> QuerySet[models.Fruit]: return models.Fruit.objects.filter(name__startswith="S") models.Fruit.objects.create(name="Apple") models.Fruit.objects.create(name="Strawberry") models.Fruit.objects.create(name="Banana") models.Fruit.objects.create(name="Sugar Apple") models.Fruit.objects.create(name="Starfruit") schema = strawberry.Schema(query=Query) query = """\ query GetFruits ( $pagination: OffsetPaginationInput $filters: FruitFilter $order: FruitOrder ) { fruits (pagination: $pagination) { totalCount results { name } } fruitsWithOrderAndFilter ( pagination: $pagination filters: $filters order: $order ) { totalCount results { name } } } """ res = schema.execute_sync(query) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "results": [ {"name": "Strawberry"}, {"name": "Sugar Apple"}, {"name": "Starfruit"}, ], }, "fruitsWithOrderAndFilter": { "totalCount": 3, "results": [ {"name": "Strawberry"}, {"name": "Sugar Apple"}, {"name": "Starfruit"}, ], }, } res = schema.execute_sync(query, variable_values={"pagination": {"limit": 1}}) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "results": [ {"name": "Strawberry"}, ], }, "fruitsWithOrderAndFilter": { "totalCount": 3, "results": [ {"name": "Strawberry"}, ], }, } res = schema.execute_sync( query, variable_values={ "pagination": {"limit": 2}, "order": {"name": "ASC"}, "filters": {"name": "Strawberry"}, }, ) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "results": [ {"name": "Strawberry"}, {"name": "Sugar Apple"}, ], }, "fruitsWithOrderAndFilter": { "totalCount": 1, "results": [ {"name": "Strawberry"}, ], }, } @pytest.mark.django_db(transaction=True) def test_pagination_query_with_resolver_arguments(): @strawberry_django.type(models.Fruit) class Fruit: id: int name: str @strawberry_django.filter_type(models.Fruit) class FruitFilter: name: strawberry.auto @strawberry_django.order(models.Fruit) class FruitOrder: name: strawberry.auto @strawberry.type class Query: @strawberry_django.offset_paginated(OffsetPaginated[Fruit]) def fruits(self, starts_with: str) -> QuerySet[models.Fruit]: return models.Fruit.objects.filter(name__startswith=starts_with) @strawberry_django.offset_paginated( OffsetPaginated[Fruit], filters=FruitFilter, order=FruitOrder, ) def fruits_with_order_and_filter( self, starts_with: str ) -> QuerySet[models.Fruit]: return models.Fruit.objects.filter(name__startswith=starts_with) models.Fruit.objects.create(name="Apple") models.Fruit.objects.create(name="Strawberry") models.Fruit.objects.create(name="Banana") models.Fruit.objects.create(name="Sugar Apple") models.Fruit.objects.create(name="Starfruit") schema = strawberry.Schema(query=Query) query = """\ query GetFruits ( $pagination: OffsetPaginationInput $filters: FruitFilter $order: FruitOrder $startsWith: String! ) { fruits (startsWith: $startsWith, pagination: $pagination) { totalCount results { name } } fruitsWithOrderAndFilter ( startsWith: $startsWith pagination: $pagination filters: $filters order: $order ) { totalCount results { name } } } """ res = schema.execute_sync(query, variable_values={"startsWith": "S"}) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "results": [ {"name": "Strawberry"}, {"name": "Sugar Apple"}, {"name": "Starfruit"}, ], }, "fruitsWithOrderAndFilter": { "totalCount": 3, "results": [ {"name": "Strawberry"}, {"name": "Sugar Apple"}, {"name": "Starfruit"}, ], }, } res = schema.execute_sync( query, variable_values={"startsWith": "S", "pagination": {"limit": 1}}, ) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "results": [ {"name": "Strawberry"}, ], }, "fruitsWithOrderAndFilter": { "totalCount": 3, "results": [ {"name": "Strawberry"}, ], }, } res = schema.execute_sync( query, variable_values={ "startsWith": "S", "pagination": {"limit": 2}, "order": {"name": "ASC"}, "filters": {"name": "Strawberry"}, }, ) assert res.errors is None assert res.data == { "fruits": { "totalCount": 3, "results": [ {"name": "Strawberry"}, {"name": "Sugar Apple"}, ], }, "fruitsWithOrderAndFilter": { "totalCount": 1, "results": [ {"name": "Strawberry"}, ], }, } @pytest.mark.django_db(transaction=True) @override_settings( STRAWBERRY_DJANGO=StrawberryDjangoSettings( # type: ignore PAGINATION_DEFAULT_LIMIT=2, ), ) def test_pagination_default_limit(): @strawberry_django.type(models.Fruit) class Fruit: id: int name: str @strawberry.type class Query: fruits: OffsetPaginated[Fruit] = strawberry_django.offset_paginated() models.Fruit.objects.create(name="Apple") models.Fruit.objects.create(name="Banana") models.Fruit.objects.create(name="Strawberry") models.Fruit.objects.create(name="Watermelon") schema = strawberry.Schema(query=Query) query = """\ query GetFruits ($pagination: OffsetPaginationInput) { fruits (pagination: $pagination) { totalCount results { name } } } """ res = schema.execute_sync(query) assert res.errors is None assert res.data == { "fruits": { "totalCount": 4, "results": [{"name": "Apple"}, {"name": "Banana"}], } } res = schema.execute_sync(query, variable_values={"pagination": {"offset": 1}}) assert res.errors is None assert res.data == { "fruits": { "totalCount": 4, "results": [{"name": "Banana"}, {"name": "Strawberry"}], } } # Setting limit to None should use default limit (same as not providing limit) res = schema.execute_sync(query, variable_values={"pagination": {"limit": None}}) assert res.errors is None assert res.data == { "fruits": { "totalCount": 4, "results": [ {"name": "Apple"}, {"name": "Banana"}, ], } } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize( ("requested_limit", "max_limit", "default_limit", "expected_limit"), [ (5, None, 100, 5), # Explicit limit should be honored (None, 50, 20, 20), # None should use default_limit (None, 50, None, 50), # None with no default should use max_limit (None, None, 20, 20), # None with no max should use default_limit (100, 50, 20, 50), # Limit > max should be capped to max_limit ("UNSET", 50, 20, 20), # UNSET should use default_limit ("UNSET", None, 20, 20), # UNSET with no max should use default_limit (10, 50, 5, 10), # Explicit limit < max should be honored # Edge cases ( None, None, None, 100, ), # None with no settings should use global default (100) ("UNSET", 50, None, 50), # UNSET with no default should fall back to max_limit ( "UNSET", None, None, 100, ), # UNSET with no settings should use global default (100) (-5, 50, 20, 50), # Negative limit should clamp to max_limit when set ( -5, None, 20, -5, ), # Negative limit without max passes through (unlimited in practice) ( -5, None, None, -5, ), # Negative limit without settings passes through (unlimited in practice) ], ) def test_page_info_reflects_effective_limit( requested_limit, max_limit, default_limit, expected_limit ): """Test that pageInfo.limit reflects the actual limit applied, not the requested one.""" @strawberry_django.type(models.Fruit) class Fruit: id: int name: str @strawberry.type class Query: fruits: OffsetPaginated[Fruit] = strawberry_django.offset_paginated() for i in range(10): models.Fruit.objects.create(name=f"Fruit{i}") schema = strawberry.Schema(query=Query) query = """\ query GetFruits ($pagination: OffsetPaginationInput) { fruits (pagination: $pagination) { pageInfo { limit offset } totalCount results { name } } } """ settings_dict = {} if max_limit is not None: settings_dict["PAGINATION_MAX_LIMIT"] = max_limit if default_limit is not None: settings_dict["PAGINATION_DEFAULT_LIMIT"] = default_limit with override_settings(STRAWBERRY_DJANGO=settings_dict): if requested_limit == "UNSET": # Don't provide pagination at all res = schema.execute_sync(query) elif requested_limit is None: # Explicitly pass null res = schema.execute_sync( query, variable_values={"pagination": {"limit": None}} ) else: # Pass explicit limit res = schema.execute_sync( query, variable_values={"pagination": {"limit": requested_limit}} ) assert res.errors is None assert res.data is not None assert res.data["fruits"]["pageInfo"]["limit"] == expected_limit assert res.data["fruits"]["pageInfo"]["offset"] == 0 results = res.data["fruits"]["results"] total_fruits = 10 if expected_limit is not None and expected_limit > 0: expected_count = min(expected_limit, total_fruits) else: expected_count = total_fruits assert len(results) == expected_count @pytest.mark.django_db(transaction=True) def test_offset_paginated_extensions_receive_filters(): @strawberry_django.filter_type(models.Fruit, lookups=True) class FruitFilter: name: strawberry.auto @strawberry_django.type(models.Fruit, filters=FruitFilter) class Fruit: id: int name: str class CaptureKwargsExtension(FieldExtension): def __init__(self): super().__init__() self.seen_kwargs: dict[str, object] | None = None def resolve(self, next_, source, info, **kwargs): self.seen_kwargs = dict(kwargs) return next_(source, info, **kwargs) extension = CaptureKwargsExtension() @strawberry.type class Query: @strawberry_django.offset_paginated( OffsetPaginated[Fruit], filters=FruitFilter, extensions=[extension], ) def fruits(self) -> QuerySet[models.Fruit]: return models.Fruit.objects.all() schema = strawberry.Schema(query=Query) models.Fruit.objects.create(name="Apple") query = """ query ($filters: FruitFilter) { fruits(filters: $filters) { results { name } } } """ res = schema.execute_sync( query, variable_values={"filters": {"name": {"exact": "Apple"}}}, ) assert res.errors is None assert extension.seen_kwargs is not None assert "filters" in extension.seen_kwargs @pytest.mark.django_db(transaction=True) def test_offset_paginated_allows_filters_without_resolver_param(): @strawberry_django.filter_type(models.Fruit, lookups=True) class FruitFilter: name: strawberry.auto @strawberry_django.type(models.Fruit, filters=FruitFilter) class Fruit: id: int name: str @strawberry.type class Query: @strawberry_django.offset_paginated( OffsetPaginated[Fruit], filters=FruitFilter, ) def fruits(self) -> QuerySet[models.Fruit]: return models.Fruit.objects.all() schema = strawberry.Schema(query=Query) models.Fruit.objects.create(name="Apple") models.Fruit.objects.create(name="Banana") query = """ query ($filters: FruitFilter) { fruits(filters: $filters) { results { name } } } """ res = schema.execute_sync( query, variable_values={"filters": {"name": {"exact": "Apple"}}}, ) assert res.errors is None assert res.data == {"fruits": {"results": [{"name": "Apple"}]}} strawberry-graphql-django-0.82.1/tests/test_pagination.py000066400000000000000000000301371516173410200236120ustar00rootroot00000000000000import sys from typing import cast import pytest import strawberry from django.test import override_settings from strawberry import auto from strawberry.types import ExecutionResult import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry_django.pagination import ( OffsetPaginationInput, apply, apply_window_pagination, ) from tests import models, utils from tests.projects.faker import ( IssueFactory, MilestoneFactory, ProjectFactory, TagFactory, ) @strawberry_django.type(models.Fruit, pagination=True) class Fruit: id: auto name: auto @strawberry_django.type(models.Fruit, pagination=True) class BerryFruit: name: auto @classmethod def get_queryset(cls, queryset, info, **kwargs): return queryset.filter(name__contains="berry") @strawberry.type class Query: fruits: list[Fruit] = strawberry_django.field() berries: list[BerryFruit] = strawberry_django.field() @pytest.fixture def query(): return utils.generate_query(Query) def test_pagination(query, fruits): result = query("{ fruits(pagination: { offset: 1, limit:1 }) { name } }") assert not result.errors assert result.data["fruits"] == [ {"name": "raspberry"}, ] def test_pagination_of_filtered_query(query, fruits): result = query("{ berries(pagination: { offset: 1, limit:1 }) { name } }") assert not result.errors assert result.data["berries"] == [ {"name": "raspberry"}, ] @pytest.mark.django_db(transaction=True) def test_nested_pagination(fruits, gql_client: utils.GraphQLTestClient): # Test nested pagination with optimizer enabled # Test query color and nested fruits, paginating the nested fruits # Enable optimizer query = """ query testNestedPagination { projectList { milestones(pagination: { limit: 1 }) { name } } } """ p = ProjectFactory.create() MilestoneFactory.create_batch(2, project=p) result = gql_client.query(query) assert not result.errors assert isinstance(result.data, dict) project_list = result.data["projectList"] assert isinstance(project_list, list) assert len(project_list) == 1 assert len(project_list[0]["milestones"]) == 1 def test_resolver_pagination(fruits): @strawberry.type class Query: @strawberry.field def fruits(self, pagination: OffsetPaginationInput) -> list[Fruit]: queryset = models.Fruit.objects.all() return cast("list[Fruit]", apply(pagination, queryset)) query = utils.generate_query(Query) result = query("{ fruits(pagination: { limit: 1 }) { id name } }") assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert result.data["fruits"] == [ {"id": "1", "name": "strawberry"}, ] @pytest.mark.django_db(transaction=True) def test_apply_window_pagination(): color = models.Color.objects.create(name="Red") for i in range(10): models.Fruit.objects.create(name=f"fruit{i}", color=color) queryset = apply_window_pagination( models.Fruit.objects.all(), related_field_id="color_id", offset=1, limit=1, ) assert queryset.count() == 1 fruit = queryset.get() assert fruit.name == "fruit1" assert fruit._strawberry_row_number == 2 # type: ignore assert fruit._strawberry_total_count == 10 # type: ignore @pytest.mark.parametrize("limit", [-1, sys.maxsize]) @pytest.mark.django_db(transaction=True) def test_apply_window_pagination_with_no_limites(limit): color = models.Color.objects.create(name="Red") for i in range(10): models.Fruit.objects.create(name=f"fruit{i}", color=color) queryset = apply_window_pagination( models.Fruit.objects.all(), related_field_id="color_id", offset=2, limit=limit, ) assert queryset.count() == 8 first_fruit = queryset.first() assert first_fruit is not None assert first_fruit.name == "fruit2" assert first_fruit._strawberry_row_number == 3 # type: ignore assert first_fruit._strawberry_total_count == 10 # type: ignore @pytest.mark.django_db(transaction=True) def test_nested_pagination_m2m(gql_client: utils.GraphQLTestClient): # Create 2 tags and 3 issues tags = [TagFactory(name=f"Tag {i + 1}") for i in range(2)] issues = [IssueFactory(name=f"Issue {i + 1}") for i in range(3)] # Assign issues 1 and 2 to the 1st tag # Assign issues 2 and 3 to the 2nd tag # This means that both tags share the 2nd issue tags[0].issues.set(issues[:2]) tags[1].issues.set(issues[1:]) with utils.assert_num_queries(2 if DjangoOptimizerExtension.enabled.get() else 6): result = gql_client.query( """ query { tagConn { totalCount edges { node { name issues { totalCount edges { node { name } } } } } } } """ ) # Check the results assert not result.errors assert result.data == { "tagConn": { "totalCount": 2, "edges": [ { "node": { "name": "Tag 1", "issues": { "totalCount": 2, "edges": [ {"node": {"name": "Issue 1"}}, {"node": {"name": "Issue 2"}}, ], }, } }, { "node": { "name": "Tag 2", "issues": { "totalCount": 2, "edges": [ {"node": {"name": "Issue 2"}}, {"node": {"name": "Issue 3"}}, ], }, } }, ], } } @pytest.mark.parametrize( ("requested_limit", "max_limit", "default_limit", "expected_count"), [ (None, 5, 3, 3), # None should use default_limit (None, 5, None, 5), # None with no default should be capped to max_limit (10, 5, None, 5), # Requested limit > max_limit should be capped (3, 5, None, 3), # Requested limit < max_limit should be honored (5, 5, None, 5), # Requested limit = max_limit should be honored (-1, 5, None, 5), # Negative limit should be capped to max_limit (10, None, None, 10), # max_limit=None should allow any limit (None, None, None, 10), # Both None should return all results ( "UNSET", 5, 200, 5, ), # UNSET limit with default_limit > max_limit should be capped (None, 50, 20, 10), # None should use default_limit (only 10 fruits exist) ], ) @pytest.mark.django_db(transaction=True) def test_pagination_max_limit( requested_limit, max_limit, default_limit, expected_count ): """Test that PAGINATION_MAX_LIMIT is respected.""" # Create 10 fruits for i in range(10): models.Fruit.objects.create(name=f"fruit{i}") if requested_limit == "UNSET": # For UNSET case, test via the apply() function directly settings_dict = {"PAGINATION_MAX_LIMIT": max_limit} if default_limit is not None: settings_dict["PAGINATION_DEFAULT_LIMIT"] = default_limit with override_settings(STRAWBERRY_DJANGO=settings_dict): pagination = OffsetPaginationInput() queryset = apply(pagination, models.Fruit.objects.all()) assert queryset.count() == expected_count return @strawberry.type class Query: @strawberry.field def fruits(self, pagination: OffsetPaginationInput) -> list[Fruit]: queryset = models.Fruit.objects.all() return cast("list[Fruit]", apply(pagination, queryset)) query = utils.generate_query(Query) # Build query based on requested_limit if requested_limit is None: gql_query = "{ fruits(pagination: { limit: null }) { name } }" else: gql_query = ( f"{{ fruits(pagination: {{ limit: {requested_limit} }}) {{ name }} }}" ) settings_dict = {"PAGINATION_MAX_LIMIT": max_limit} if default_limit is not None: settings_dict["PAGINATION_DEFAULT_LIMIT"] = default_limit with override_settings(STRAWBERRY_DJANGO=settings_dict): result = query(gql_query) assert isinstance(result, ExecutionResult) assert not result.errors assert result.data is not None assert len(result.data["fruits"]) == expected_count @pytest.mark.parametrize( ("requested_limit", "max_limit", "default_limit", "expected_count"), [ (None, 5, 3, 3), # None should use default_limit (None, 5, None, 5), # None with no default should be capped to max_limit (10, 5, None, 5), # Requested limit > max_limit should be capped (3, 5, None, 3), # Requested limit < max_limit should be honored (5, 5, None, 5), # Requested limit = max_limit should be honored (-1, 5, None, 5), # Negative limit should be capped to max_limit (10, None, None, 10), # max_limit=None should allow any limit (None, None, None, 10), # Both None should return all results (None, 50, 20, 10), # None should use default_limit (only 10 fruits exist) ("UNSET", 50, 20, 10), # UNSET should use default_limit (only 10 fruits exist) ( "UNSET", 5, 200, 5, ), # UNSET limit with default_limit > max_limit should be capped (None, 5, 200, 5), # None with default_limit > max_limit should be capped ], ) @pytest.mark.django_db(transaction=True) def test_window_pagination_max_limit( requested_limit, max_limit, default_limit, expected_count ): """Test that PAGINATION_MAX_LIMIT is respected in window pagination.""" color = models.Color.objects.create(name="Red") for i in range(10): models.Fruit.objects.create(name=f"fruit{i}", color=color) settings_dict = {"PAGINATION_MAX_LIMIT": max_limit} if default_limit is not None: settings_dict["PAGINATION_DEFAULT_LIMIT"] = default_limit # Handle UNSET by not passing the limit parameter limit_arg = {} if requested_limit == "UNSET" else {"limit": requested_limit} with override_settings(STRAWBERRY_DJANGO=settings_dict): queryset = apply_window_pagination( models.Fruit.objects.all(), related_field_id="color_id", offset=0, **limit_arg, ) assert queryset.count() == expected_count @pytest.mark.django_db(transaction=True) def test_pagination_max_limit_negative_validation(): """Test that negative PAGINATION_MAX_LIMIT raises ValueError.""" for i in range(10): models.Fruit.objects.create(name=f"fruit{i}") pagination = OffsetPaginationInput(limit=10) with ( override_settings(STRAWBERRY_DJANGO={"PAGINATION_MAX_LIMIT": -5}), pytest.raises(ValueError, match="PAGINATION_MAX_LIMIT must be non-negative"), ): apply(pagination, models.Fruit.objects.all()) @pytest.mark.django_db(transaction=True) def test_window_pagination_max_limit_negative_validation(): """Test that negative PAGINATION_MAX_LIMIT raises ValueError in window pagination.""" color = models.Color.objects.create(name="Red") for i in range(10): models.Fruit.objects.create(name=f"fruit{i}", color=color) queryset = models.Fruit.objects.all() with ( override_settings(STRAWBERRY_DJANGO={"PAGINATION_MAX_LIMIT": -5}), pytest.raises(ValueError, match="PAGINATION_MAX_LIMIT must be non-negative"), ): apply_window_pagination( queryset, related_field_id="color_id", offset=0, limit=10 ) strawberry-graphql-django-0.82.1/tests/test_permissions.py000066400000000000000000001032371516173410200240360ustar00rootroot00000000000000from typing import Literal, TypeAlias import pytest from django.contrib.auth.models import Permission from guardian.shortcuts import assign_perm from strawberry.relay import to_base64 from strawberry_django.optimizer import DjangoOptimizerExtension from .projects.faker import ( GroupFactory, IssueFactory, MilestoneFactory, StaffUserFactory, SuperuserUserFactory, UserFactory, ) from .utils import GraphQLTestClient, assert_num_queries PermKind: TypeAlias = Literal["user", "group", "superuser"] perm_kinds: list[PermKind] = ["user", "group", "superuser"] @pytest.mark.django_db(transaction=True) def test_is_authenticated(db, gql_client: GraphQLTestClient): query = """ query Issue ($id: ID!) { issueLoginRequired (id: $id) { id name } } """ issue = IssueFactory.create() res = gql_client.query( query, {"id": to_base64("IssueType", issue.pk)}, assert_no_errors=False, ) assert res.data is None assert res.errors == [ { "message": "User is not authenticated.", "locations": [{"line": 3, "column": 9}], "path": ["issueLoginRequired"], }, ] user = UserFactory.create() with gql_client.login(user): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == { "issueLoginRequired": { "id": to_base64("IssueType", issue.pk), "name": issue.name, }, } @pytest.mark.django_db(transaction=True) def test_is_authenticated_optional(db, gql_client: GraphQLTestClient): query = """ query Issue ($id: ID!) { issueLoginRequiredOptional (id: $id) { id name } } """ issue = IssueFactory.create() res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == {"issueLoginRequiredOptional": None} user = UserFactory.create() with gql_client.login(user): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == { "issueLoginRequiredOptional": { "id": to_base64("IssueType", issue.pk), "name": issue.name, }, } @pytest.mark.django_db(transaction=True) def test_staff_required(db, gql_client: GraphQLTestClient): query = """ query Issue ($id: ID!) { issueStaffRequired (id: $id) { id name } } """ issue = IssueFactory.create() res = gql_client.query( query, {"id": to_base64("IssueType", issue.pk)}, assert_no_errors=False, ) assert res.data is None assert res.errors == [ { "message": "User is not a staff member.", "locations": [{"line": 3, "column": 9}], "path": ["issueStaffRequired"], }, ] user = UserFactory.create() with gql_client.login(user): assert res.data is None assert res.errors == [ { "message": "User is not a staff member.", "locations": [{"line": 3, "column": 9}], "path": ["issueStaffRequired"], }, ] staff = StaffUserFactory.create() with gql_client.login(staff): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == { "issueStaffRequired": { "id": to_base64("IssueType", issue.pk), "name": issue.name, }, } @pytest.mark.django_db(transaction=True) def test_staff_required_optional(db, gql_client: GraphQLTestClient): query = """ query Issue ($id: ID!) { issueStaffRequiredOptional (id: $id) { id name } } """ issue = IssueFactory.create() res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == {"issueStaffRequiredOptional": None} user = UserFactory.create() with gql_client.login(user): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == {"issueStaffRequiredOptional": None} staff = StaffUserFactory.create() with gql_client.login(staff): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == { "issueStaffRequiredOptional": { "id": to_base64("IssueType", issue.pk), "name": issue.name, }, } @pytest.mark.django_db(transaction=True) def test_superuser_required(db, gql_client: GraphQLTestClient): query = """ query Issue ($id: ID!) { issueSuperuserRequired (id: $id) { id name } } """ issue = IssueFactory.create() res = gql_client.query( query, {"id": to_base64("IssueType", issue.pk)}, assert_no_errors=False, ) assert res.data is None assert res.errors == [ { "message": "User is not a superuser.", "locations": [{"line": 3, "column": 9}], "path": ["issueSuperuserRequired"], }, ] user = UserFactory.create() with gql_client.login(user): res = gql_client.query( query, {"id": to_base64("IssueType", issue.pk)}, assert_no_errors=False, ) assert res.data is None assert res.errors == [ { "message": "User is not a superuser.", "locations": [{"line": 3, "column": 9}], "path": ["issueSuperuserRequired"], }, ] superuser = SuperuserUserFactory.create() with gql_client.login(superuser): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == { "issueSuperuserRequired": { "id": to_base64("IssueType", issue.pk), "name": issue.name, }, } @pytest.mark.django_db(transaction=True) def test_async_user_resolve(db, gql_client: GraphQLTestClient): query = """ query asyncUserResolve { asyncUserResolve } """ if not gql_client.is_async: pytest.skip("needs async client") user = UserFactory.create() with gql_client.login(user): res = gql_client.query( query, assert_no_errors=False, ) assert res.data is None assert res.errors == [ { "message": "You don't have permission to access this app.", "locations": [{"line": 3, "column": 9}], "path": ["asyncUserResolve"], }, ] superuser = SuperuserUserFactory.create() with gql_client.login(superuser): res = gql_client.query(query) assert res.data == {"asyncUserResolve": True} user_with_perm = UserFactory.create() user_with_perm.user_permissions.add( Permission.objects.get(codename="view_issue"), ) with gql_client.login(user_with_perm): res = gql_client.query(query) assert res.data == {"asyncUserResolve": True} @pytest.mark.django_db(transaction=True) def test_superuser_required_optional(db, gql_client: GraphQLTestClient): query = """ query Issue ($id: ID!) { issueSuperuserRequiredOptional (id: $id) { id name } } """ issue = IssueFactory.create() res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == {"issueSuperuserRequiredOptional": None} user = UserFactory.create() with gql_client.login(user): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == {"issueSuperuserRequiredOptional": None} superuser = SuperuserUserFactory.create() with gql_client.login(superuser): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == { "issueSuperuserRequiredOptional": { "id": to_base64("IssueType", issue.pk), "name": issue.name, }, } @pytest.mark.django_db(transaction=True) def test_perm_cached(db, gql_client: GraphQLTestClient): """Validates that the permission caching mechanism correctly stores permissions as a set of strings. The test targets the `_perm_cache` attribute used in `utils/query.py`. It verifies that the attribute behaves as expected, holding a `Set[str]` that represents permission codenames, rather than direct Permission objects. This test addresses a regression captured by the following error: ``` user_perms: Set[str] = {p.codename for p in perm_cache} ^^^^^^^^^^ AttributeError: 'str' object has no attribute 'codename' ``` """ query = """ query Issue ($id: ID!) { issuePermRequired (id: $id) { id privateName } } """ issue = IssueFactory.create(name="Test") # User with permission user_with_perm = UserFactory.create() user_with_perm.user_permissions.add( Permission.objects.get(codename="view_issue"), ) assign_perm("view_issue", user_with_perm, issue) with gql_client.login(user_with_perm): if DjangoOptimizerExtension.enabled.get(): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == { "issuePermRequired": { "id": to_base64("IssueType", issue.pk), "privateName": issue.name, }, } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("kind", perm_kinds) def test_perm_required(db, gql_client: GraphQLTestClient, kind: PermKind): query = """ query Issue ($id: ID!) { issuePermRequired (id: $id) { id name } } """ issue = IssueFactory.create() res = gql_client.query( query, {"id": to_base64("IssueType", issue.pk)}, assert_no_errors=False, ) assert res.data is None assert res.errors == [ { "message": "You don't have permission to access this app.", "locations": [{"line": 3, "column": 9}], "path": ["issuePermRequired"], }, ] user = UserFactory.create() with gql_client.login(user): res = gql_client.query( query, {"id": to_base64("IssueType", issue.pk)}, assert_no_errors=False, ) assert res.data is None assert res.errors == [ { "message": "You don't have permission to access this app.", "locations": [{"line": 3, "column": 9}], "path": ["issuePermRequired"], }, ] if kind == "user": user_with_perm = UserFactory.create() user_with_perm.user_permissions.add( Permission.objects.get(codename="view_issue"), ) elif kind == "group": user_with_perm = UserFactory.create() group = GroupFactory.create() group.permissions.add(Permission.objects.get(codename="view_issue")) user_with_perm.groups.add(group) elif kind == "superuser": user_with_perm = SuperuserUserFactory.create() else: # pragma:nocover raise AssertionError with gql_client.login(user_with_perm): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == { "issuePermRequired": { "id": to_base64("IssueType", issue.pk), "name": issue.name, }, } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("kind", perm_kinds) def test_perm_required_optional(db, gql_client: GraphQLTestClient, kind: PermKind): query = """ query Issue ($id: ID!) { issuePermRequiredOptional (id: $id) { id name } } """ issue = IssueFactory.create() res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == {"issuePermRequiredOptional": None} user = UserFactory.create() with gql_client.login(user): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == {"issuePermRequiredOptional": None} if kind == "user": user_with_perm = UserFactory.create() user_with_perm.user_permissions.add( Permission.objects.get(codename="view_issue"), ) elif kind == "group": user_with_perm = UserFactory.create() group = GroupFactory.create() group.permissions.add(Permission.objects.get(codename="view_issue")) user_with_perm.groups.add(group) elif kind == "superuser": user_with_perm = SuperuserUserFactory.create() else: # pragma:nocover raise AssertionError with gql_client.login(user_with_perm): res = gql_client.query(query, {"id": to_base64("IssueType", issue.pk)}) assert res.data == { "issuePermRequiredOptional": { "id": to_base64("IssueType", issue.pk), "name": issue.name, }, } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("kind", perm_kinds) def test_list_perm_required(db, gql_client: GraphQLTestClient, kind: PermKind): query = """ query Issue { issueListPermRequired { id name } } """ issue = IssueFactory.create() res = gql_client.query(query) assert res.data == {"issueListPermRequired": []} user = UserFactory.create() with gql_client.login(user): res = gql_client.query(query) assert res.data == {"issueListPermRequired": []} if kind == "user": user_with_perm = UserFactory.create() user_with_perm.user_permissions.add( Permission.objects.get(codename="view_issue"), ) elif kind == "group": user_with_perm = UserFactory.create() group = GroupFactory.create() group.permissions.add(Permission.objects.get(codename="view_issue")) user_with_perm.groups.add(group) elif kind == "superuser": user_with_perm = SuperuserUserFactory.create() else: # pragma:nocover raise AssertionError with gql_client.login(user_with_perm): res = gql_client.query(query) assert res.data == { "issueListPermRequired": [ { "id": to_base64("IssueType", issue.pk), "name": issue.name, }, ], } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("kind", perm_kinds) def test_conn_perm_required(db, gql_client: GraphQLTestClient, kind: PermKind): query = """ query Issue { issueConnPermRequired { totalCount edges { node { id name } } } } """ issue = IssueFactory.create() res = gql_client.query(query) assert res.data == {"issueConnPermRequired": {"edges": [], "totalCount": 0}} user = UserFactory.create() with gql_client.login(user): res = gql_client.query(query) assert res.data == {"issueConnPermRequired": {"edges": [], "totalCount": 0}} if kind == "user": user_with_perm = UserFactory.create() user_with_perm.user_permissions.add( Permission.objects.get(codename="view_issue"), ) elif kind == "group": user_with_perm = UserFactory.create() group = GroupFactory.create() group.permissions.add(Permission.objects.get(codename="view_issue")) user_with_perm.groups.add(group) elif kind == "superuser": user_with_perm = SuperuserUserFactory.create() else: # pragma:nocover raise AssertionError with gql_client.login(user_with_perm): res = gql_client.query(query) assert res.data == { "issueConnPermRequired": { "edges": [ { "node": { "id": to_base64("IssueType", issue.pk), "name": issue.name, }, }, ], "totalCount": 1, }, } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("kind", perm_kinds) def test_obj_perm_required(db, gql_client: GraphQLTestClient, kind: PermKind): query = """ query Issue ($id: ID!) { issueObjPermRequired (id: $id) { id name } } """ issue_no_perm = IssueFactory.create() issue_with_perm = IssueFactory.create() user = UserFactory.create() if kind == "user": user_with_perm = UserFactory.create() assign_perm("view_issue", user_with_perm, issue_with_perm) elif kind == "group": user_with_perm = UserFactory.create() group = GroupFactory.create() assign_perm("view_issue", group, issue_with_perm) user_with_perm.groups.add(group) elif kind == "superuser": user_with_perm = SuperuserUserFactory.create() else: # pragma:nocover raise AssertionError for issue in [issue_no_perm, issue_with_perm]: res = gql_client.query( query, {"id": to_base64("IssueType", issue.pk)}, assert_no_errors=False, ) assert res.data is None assert res.errors == [ { "message": "You don't have permission to access this app.", "locations": [{"line": 3, "column": 9}], "path": ["issueObjPermRequired"], }, ] for u in [user, user_with_perm]: # Superusers will have access to everything... if kind == "superuser": continue with gql_client.login(u): res = gql_client.query( query, {"id": to_base64("IssueType", issue_no_perm.pk)}, assert_no_errors=False, ) assert res.data is None assert res.errors == [ { "message": "You don't have permission to access this app.", "locations": [{"line": 3, "column": 9}], "path": ["issueObjPermRequired"], }, ] with gql_client.login(user_with_perm): res = gql_client.query( query, {"id": to_base64("IssueType", issue_with_perm.pk)}, ) assert res.data == { "issueObjPermRequired": { "id": to_base64("IssueType", issue_with_perm.pk), "name": issue_with_perm.name, }, } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("kind", perm_kinds) def test_obj_perm_required_global(db, gql_client: GraphQLTestClient, kind: PermKind): query = """ query Issue ($id: ID!) { issueObjPermRequired (id: $id) { id name } } """ issue_no_perm = IssueFactory.create() issue_with_perm = IssueFactory.create() user = UserFactory.create() if kind == "user": user_with_perm = UserFactory.create() user_with_perm.user_permissions.add( Permission.objects.get(codename="view_issue"), ) elif kind == "group": user_with_perm = UserFactory.create() group = GroupFactory.create() group.permissions.add(Permission.objects.get(codename="view_issue")) user_with_perm.groups.add(group) elif kind == "superuser": user_with_perm = SuperuserUserFactory.create() else: # pragma:nocover raise AssertionError for issue in [issue_no_perm, issue_with_perm]: res = gql_client.query( query, {"id": to_base64("IssueType", issue.pk)}, assert_no_errors=False, ) assert res.data is None assert res.errors == [ { "message": "You don't have permission to access this app.", "locations": [{"line": 3, "column": 9}], "path": ["issueObjPermRequired"], }, ] with gql_client.login(user): res = gql_client.query( query, {"id": to_base64("IssueType", issue.pk)}, assert_no_errors=False, ) assert res.data is None assert res.errors == [ { "message": "You don't have permission to access this app.", "locations": [{"line": 3, "column": 9}], "path": ["issueObjPermRequired"], }, ] with gql_client.login(user_with_perm): res = gql_client.query( query, {"id": to_base64("IssueType", issue_with_perm.pk)}, ) assert res.data == { "issueObjPermRequired": { "id": to_base64("IssueType", issue_with_perm.pk), "name": issue_with_perm.name, }, } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("kind", perm_kinds) def test_obj_perm_required_optional(db, gql_client: GraphQLTestClient, kind: PermKind): query = """ query Issue ($id: ID!) { issueObjPermRequiredOptional (id: $id) { id name } } """ issue_no_perm = IssueFactory.create() issue_with_perm = IssueFactory.create() user = UserFactory.create() if kind == "user": user_with_perm = UserFactory.create() assign_perm("view_issue", user_with_perm, issue_with_perm) elif kind == "group": user_with_perm = UserFactory.create() group = GroupFactory.create() assign_perm("view_issue", group, issue_with_perm) user_with_perm.groups.add(group) elif kind == "superuser": user_with_perm = SuperuserUserFactory.create() else: # pragma:nocover raise AssertionError for issue in [issue_no_perm, issue_with_perm]: res = gql_client.query( query, {"id": to_base64("IssueType", issue.pk)}, ) assert res.data == {"issueObjPermRequiredOptional": None} for u in [user, user_with_perm]: # Superusers will have access to everything... if kind == "superuser": continue with gql_client.login(u): res = gql_client.query( query, {"id": to_base64("IssueType", issue_no_perm.pk)}, ) assert res.data == {"issueObjPermRequiredOptional": None} with gql_client.login(user_with_perm): res = gql_client.query( query, {"id": to_base64("IssueType", issue_with_perm.pk)}, ) assert res.data == { "issueObjPermRequiredOptional": { "id": to_base64("IssueType", issue_with_perm.pk), "name": issue_with_perm.name, }, } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("kind", perm_kinds) def test_list_obj_perm_required(db, gql_client: GraphQLTestClient, kind: PermKind): query = """ query Issue { issueListObjPermRequired { id name } } """ IssueFactory.create() issue_with_perm = IssueFactory.create() user = UserFactory.create() if kind == "user": user_with_perm = UserFactory.create() assign_perm("view_issue", user_with_perm, issue_with_perm) elif kind == "group": user_with_perm = UserFactory.create() group = GroupFactory.create() assign_perm("view_issue", group, issue_with_perm) user_with_perm.groups.add(group) elif kind == "superuser": user_with_perm = SuperuserUserFactory.create() else: # pragma:nocover raise AssertionError res = gql_client.query(query) assert res.data == {"issueListObjPermRequired": []} with gql_client.login(user): res = gql_client.query(query) assert res.data == {"issueListObjPermRequired": []} if kind == "superuser": # Even though the user is a superuser, he doesn't have the permission # assigned directly to him for the listing. with gql_client.login(user_with_perm): res = gql_client.query(query) assert res.data == {"issueListObjPermRequired": []} else: with gql_client.login(user_with_perm): res = gql_client.query(query) assert res.data == { "issueListObjPermRequired": [ { "id": to_base64("IssueType", issue_with_perm.pk), "name": issue_with_perm.name, }, ], } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("kind", perm_kinds) def test_list_obj_perm_required_paginated( db, gql_client: GraphQLTestClient, kind: PermKind ): query = """ query Issue { issueListObjPermRequiredPaginated(pagination: {limit: 10, offset: 0}) { id name } } """ IssueFactory.create() issue_with_perm = IssueFactory.create() user = UserFactory.create() if kind == "user": user_with_perm = UserFactory.create() assign_perm("view_issue", user_with_perm, issue_with_perm) elif kind == "group": user_with_perm = UserFactory.create() group = GroupFactory.create() assign_perm("view_issue", group, issue_with_perm) user_with_perm.groups.add(group) elif kind == "superuser": user_with_perm = SuperuserUserFactory.create() else: # pragma:nocover raise AssertionError res = gql_client.query(query) assert res.data == {"issueListObjPermRequiredPaginated": []} with gql_client.login(user): res = gql_client.query(query) assert res.data == {"issueListObjPermRequiredPaginated": []} if kind == "superuser": # Even though the user is a superuser, he doesn't have the permission # assigned directly to him for the listing. with gql_client.login(user_with_perm): res = gql_client.query(query) assert res.data == {"issueListObjPermRequiredPaginated": []} else: with gql_client.login(user_with_perm): res = gql_client.query(query) assert res.data == { "issueListObjPermRequiredPaginated": [ { "id": to_base64("IssueType", issue_with_perm.pk), "name": issue_with_perm.name, }, ], } @pytest.mark.django_db(transaction=True) @pytest.mark.parametrize("kind", perm_kinds) def test_conn_obj_perm_required(db, gql_client: GraphQLTestClient, kind: PermKind): query = """ query Issue { issueConnObjPermRequired { totalCount edges { node { id name } } } } """ IssueFactory.create() issue_with_perm = IssueFactory.create() user = UserFactory.create() if kind == "user": user_with_perm = UserFactory.create() assign_perm("view_issue", user_with_perm, issue_with_perm) elif kind == "group": user_with_perm = UserFactory.create() group = GroupFactory.create() assign_perm("view_issue", group, issue_with_perm) user_with_perm.groups.add(group) elif kind == "superuser": user_with_perm = SuperuserUserFactory.create() else: # pragma:nocover raise AssertionError res = gql_client.query(query) assert res.data == {"issueConnObjPermRequired": {"edges": [], "totalCount": 0}} with gql_client.login(user): res = gql_client.query(query) assert res.data == {"issueConnObjPermRequired": {"edges": [], "totalCount": 0}} if kind == "superuser": # Even though the user is a superuser, he doesn't have the permission # assigned directly to him for the listing. with gql_client.login(user_with_perm): res = gql_client.query(query) assert res.data == { "issueConnObjPermRequired": {"edges": [], "totalCount": 0}, } else: with gql_client.login(user_with_perm): res = gql_client.query(query) assert res.data == { "issueConnObjPermRequired": { "edges": [ { "node": { "id": to_base64("IssueType", issue_with_perm.pk), "name": issue_with_perm.name, }, }, ], "totalCount": 1, }, } @pytest.mark.django_db(transaction=True) def test_query_paginated_with_permissions(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($pagination: OffsetPaginationInput) { issuesPaginatedPermRequired (pagination: $pagination) { totalCount results { name milestone { name } } } } """ milestone1 = MilestoneFactory.create() milestone2 = MilestoneFactory.create() issue1 = IssueFactory.create(milestone=milestone1) issue2 = IssueFactory.create(milestone=milestone1) issue3 = IssueFactory.create(milestone=milestone1) issue4 = IssueFactory.create(milestone=milestone2) issue5 = IssueFactory.create(milestone=milestone2) # No user logged in with assert_num_queries(0): res = gql_client.query(query) assert res.data == { "issuesPaginatedPermRequired": { "totalCount": 0, "results": [], } } user = UserFactory.create() # User logged in without permissions with gql_client.login(user): with assert_num_queries(4): res = gql_client.query(query) assert res.data == { "issuesPaginatedPermRequired": { "totalCount": 0, "results": [], } } # User logged in with permissions user.user_permissions.add(Permission.objects.get(codename="view_issue")) with gql_client.login(user): with assert_num_queries(6 if DjangoOptimizerExtension.enabled.get() else 11): res = gql_client.query(query) assert res.data == { "issuesPaginatedPermRequired": { "totalCount": 5, "results": [ {"name": issue1.name, "milestone": {"name": milestone1.name}}, {"name": issue2.name, "milestone": {"name": milestone1.name}}, {"name": issue3.name, "milestone": {"name": milestone1.name}}, {"name": issue4.name, "milestone": {"name": milestone2.name}}, {"name": issue5.name, "milestone": {"name": milestone2.name}}, ], } } with assert_num_queries(6 if DjangoOptimizerExtension.enabled.get() else 8): res = gql_client.query(query, variables={"pagination": {"limit": 2}}) assert res.data == { "issuesPaginatedPermRequired": { "totalCount": 5, "results": [ {"name": issue1.name, "milestone": {"name": milestone1.name}}, {"name": issue2.name, "milestone": {"name": milestone1.name}}, ], } } @pytest.mark.django_db(transaction=True) def test_query_paginated_with_obj_permissions(db, gql_client: GraphQLTestClient): query = """ query TestQuery ($pagination: OffsetPaginationInput) { issuesPaginatedObjPermRequired (pagination: $pagination) { totalCount results { name milestone { name } } } } """ milestone1 = MilestoneFactory.create() milestone2 = MilestoneFactory.create() IssueFactory.create(milestone=milestone1) issue2 = IssueFactory.create(milestone=milestone1) IssueFactory.create(milestone=milestone1) issue4 = IssueFactory.create(milestone=milestone2) IssueFactory.create(milestone=milestone2) # No user logged in with assert_num_queries(0): res = gql_client.query(query) assert res.data == { "issuesPaginatedObjPermRequired": { "totalCount": 0, "results": [], } } user = UserFactory.create() # User logged in without permissions with gql_client.login(user): with assert_num_queries(5): res = gql_client.query(query) assert res.data == { "issuesPaginatedObjPermRequired": { "totalCount": 0, "results": [], } } assign_perm("view_issue", user, issue2) assign_perm("view_issue", user, issue4) # User logged in with permissions with gql_client.login(user): with assert_num_queries(4 if DjangoOptimizerExtension.enabled.get() else 6): res = gql_client.query(query) assert res.data == { "issuesPaginatedObjPermRequired": { "totalCount": 2, "results": [ {"name": issue2.name, "milestone": {"name": milestone1.name}}, {"name": issue4.name, "milestone": {"name": milestone2.name}}, ], } } with assert_num_queries(4 if DjangoOptimizerExtension.enabled.get() else 5): res = gql_client.query(query, variables={"pagination": {"limit": 1}}) assert res.data == { "issuesPaginatedObjPermRequired": { "totalCount": 2, "results": [ {"name": issue2.name, "milestone": {"name": milestone1.name}}, ], } } strawberry-graphql-django-0.82.1/tests/test_pyutils.py000066400000000000000000000044311516173410200231700ustar00rootroot00000000000000from strawberry_django.utils.pyutils import ( dicttree_insersection_differs, dicttree_merge, ) def test_dicctree_merge(): assert dicttree_merge( { "foo": 1, "bar": 2, "baz": 7, "sub1": { "a": "asub1", "b": "bsub1", "c": "csub1", }, "sub2": { "a": "asub2", "b": "bsub2", "c": "csub2", }, }, { "bar": 3, "bin": 4, "sub1": { "a": "force_asub1", "d": "force_dsub1", }, "sub3": { "a": "asub3", "b": "bsub3", "c": "csub3", }, }, ) == { "foo": 1, "bar": 3, "baz": 7, "bin": 4, "sub1": { "a": "force_asub1", "b": "bsub1", "c": "csub1", "d": "force_dsub1", }, "sub2": { "a": "asub2", "b": "bsub2", "c": "csub2", }, "sub3": { "a": "asub3", "b": "bsub3", "c": "csub3", }, } def test_dicctree_intersection_differs(): assert not dicttree_insersection_differs({"a": 1}, {"b": 1}) assert not dicttree_insersection_differs({"a": 1}, {"b": 2}) assert not dicttree_insersection_differs({"a": 1}, {"a": 1}) assert not dicttree_insersection_differs({"a": 1}, {"a": 1, "b": 1}) assert not dicttree_insersection_differs( {"a": 1, "c": {"foobar": 3}}, {"a": 1, "b": 1}, ) assert not dicttree_insersection_differs( {"a": 1, "c": {"foobar": 1}}, {"a": 1, "b": 1, "c": {"yyy": "abc"}}, ) assert not dicttree_insersection_differs( {"a": 1, "c": {"foobar": 1}}, {"a": 1, "b": 1, "c": {"foobar": 1}}, ) assert dicttree_insersection_differs({"a": 1}, {"a": 2}) assert dicttree_insersection_differs({"a": 1}, {"a": 2, "b": 1}) assert dicttree_insersection_differs( {"a": 1, "c": {"foobar": 1}}, {"a": 2, "c": {"foobar": 1}}, ) assert dicttree_insersection_differs( {"a": 1, "c": {"foobar": 1}}, {"a": 1, "c": {"foobar": 2}}, ) strawberry-graphql-django-0.82.1/tests/test_queries.py000066400000000000000000000200731516173410200231340ustar00rootroot00000000000000import io import textwrap from typing import Optional, cast from unittest import mock import pytest import strawberry from asgiref.sync import sync_to_async from django.core.files.uploadedfile import SimpleUploadedFile from django.test import override_settings from graphql import GraphQLError from PIL import Image from strawberry import auto from strawberry.types.base import WithStrawberryObjectDefinition import strawberry_django from strawberry_django.fields.field import StrawberryDjangoField from strawberry_django.settings import StrawberryDjangoSettings from . import models, utils @pytest.fixture def user_group(users, groups): users[0].group = groups[0] users[0].save() @strawberry_django.type(models.User) class User: id: auto name: auto group: Optional["Group"] @strawberry_django.type(models.Group) class Group: id: auto name: auto users: list[User] @strawberry_django.type(models.Fruit) class Fruit: id: auto name: auto picture: auto @strawberry_django.type(models.Fruit) class BerryFruit: id: auto name: auto name_upper: str name_lower: str @classmethod def get_queryset(cls, queryset, info, **kwargs): return queryset.filter(name__contains="berry") @strawberry_django.type(models.Fruit, is_interface=True) class FruitInterface: id: auto name: auto @strawberry_django.type(models.Fruit) class BananaFruit(FruitInterface): @classmethod def get_queryset(cls, queryset, info, **kwargs): return queryset.filter(name__contains="banana") @strawberry.type class Query: user: User = strawberry_django.field() users: list[User] = strawberry_django.field() group: Group = strawberry_django.field() groups: list[Group] = strawberry_django.field() fruit: Fruit = strawberry_django.field() berries: list[BerryFruit] = strawberry_django.field() bananas: list[BananaFruit] = strawberry_django.field() @pytest.fixture def query(db): return utils.generate_query(Query) @pytest.fixture def query_id_as_pk(db): def _clear_cached_arguments(): for field in cast( "WithStrawberryObjectDefinition", Query ).__strawberry_definition__.fields: if isinstance(field, StrawberryDjangoField): field._cached_arguments = None _clear_cached_arguments() with override_settings( STRAWBERRY_DJANGO=StrawberryDjangoSettings( # type: ignore DEFAULT_PK_FIELD_NAME="id", ), ): yield utils.generate_query(Query) _clear_cached_arguments() pytestmark = [ pytest.mark.django_db(transaction=True), ] async def test_single(query, users): result = await query( """ query GetUser($pk: ID!) { user(pk: $pk) { name } } """, {"pk": users[0].pk}, ) assert not result.errors assert result.data["user"] == {"name": users[0].name} async def test_required_pk_single(query, users): result = await query("{ user { name } }") assert bool(result.errors) assert len(result.errors) == 1 assert isinstance(result.errors[0], GraphQLError) assert ( result.errors[0].message == "Field 'user' argument 'pk' of type 'ID!' is " "required, but it was not provided." ) async def test_id_as_pk_single(query_id_as_pk, users): # Users are created for each test, it's impossible to know what will be the id of users in the database. user_id = users[0].id result = await query_id_as_pk(f"{{ user(id: {user_id}) {{ name }} }}") assert not result.errors assert result.data["user"] == {"name": users[0].name} async def test_required_id_as_pk_single(query_id_as_pk, users): result = await query_id_as_pk("{ user { name } }") assert bool(result.errors) assert len(result.errors) == 1 assert isinstance(result.errors[0], GraphQLError) assert ( result.errors[0].message == "Field 'user' argument 'id' of type 'ID!' is " "required, but it was not provided." ) async def test_many(query, users): result = await query("{ users { name } }") assert not result.errors assert result.data["users"] == [ {"name": users[0].name}, {"name": users[1].name}, {"name": users[2].name}, ] async def test_relation(query, users, groups, user_group): result = await query("{ users { name group { name } } }") assert not result.errors assert result.data["users"] == [ {"name": users[0].name, "group": {"name": groups[0].name}}, {"name": users[1].name, "group": None}, {"name": users[2].name, "group": None}, ] async def test_reverse_relation(query, users, groups, user_group): result = await query("{ groups { name users { name } } }") assert not result.errors assert result.data["groups"] == [ {"name": groups[0].name, "users": [{"name": users[0].name}]}, {"name": groups[1].name, "users": []}, {"name": groups[2].name, "users": []}, ] async def test_type_queryset(query, fruits): result = await query("{ berries { name } }") assert not result.errors assert result.data["berries"] == [ {"name": "strawberry"}, {"name": "raspberry"}, ] async def test_querying_type_implementing_interface(query, fruits): result = await query("{ bananas { name } }") assert not result.errors assert result.data["bananas"] == [{"name": "banana"}] async def test_model_properties(query, fruits): result = await query("{ berries { nameUpper nameLower } }") assert not result.errors assert result.data["berries"] == [ {"nameUpper": "STRAWBERRY", "nameLower": "strawberry"}, {"nameUpper": "RASPBERRY", "nameLower": "raspberry"}, ] async def test_query_file_field(query): img_f = io.BytesIO() img = Image.new(mode="RGB", size=(1, 1), color="red") img.save(img_f, format="jpeg") upload = SimpleUploadedFile("strawberry-picture.png", img_f.getvalue()) fruit = await sync_to_async(models.Fruit.objects.create)( name="Strawberry", picture=upload, ) result = await query( """\ query Fruit ($pk: ID!) { fruit (pk: $pk) { id name picture { name } } } """, {"pk": fruit.pk}, ) assert not result.errors assert result.data is not None assert result.data["fruit"] == { "id": str(fruit.pk), "name": "Strawberry", "picture": {"name": ".tmp_upload/strawberry-picture.png"}, } async def test_query_file_field_when_null(query): fruit = await sync_to_async(models.Fruit.objects.create)(name="Strawberry") result = await query( """\ query Fruit ($pk: ID!) { fruit (pk: $pk) { id name picture { name } } } """, {"pk": fruit.pk}, ) assert not result.errors assert result.data is not None assert result.data["fruit"] == { "id": str(fruit.pk), "name": "Strawberry", "picture": None, } def test_field_name(): """Make sure that field_name overriding is not ignored.""" @strawberry_django.type(models.Fruit) class Fruit: name: auto color_id: int = strawberry_django.field(field_name="color_id") @strawberry.type class Query: @strawberry_django.field def fruit(self) -> Fruit: color = models.Color.objects.create(name="Yellow") return models.Fruit.objects.create( # type: ignore name="Banana", color=color, ) schema = strawberry.Schema(query=Query) expected = """\ type Fruit { name: String! colorId: Int! } type Query { fruit: Fruit! } """ assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() result = schema.execute_sync("""\ query TestQuery { fruit { name colorId } } """) assert result.data == {"fruit": {"colorId": mock.ANY, "name": "Banana"}} strawberry-graphql-django-0.82.1/tests/test_queryset_config.py000066400000000000000000000025001516173410200246600ustar00rootroot00000000000000import pytest from django.db.models import Prefetch from strawberry_django.queryset import get_queryset_config from tests.projects.models import Milestone, Project def test_queryset_config_survives_filter(): qs = Project.objects.all() config = get_queryset_config(qs) config.optimized = True new_qs = qs.filter(pk=1) assert get_queryset_config(new_qs).optimized is True def test_queryset_config_survives_prefetch_related(): qs = Project.objects.all() config = get_queryset_config(qs) config.optimized = True new_qs = qs.prefetch_related("milestones") assert get_queryset_config(new_qs).optimized is True def test_queryset_config_survives_select_related(): qs = Milestone.objects.all() config = get_queryset_config(qs) config.optimized = True new_qs = qs.select_related("project") assert get_queryset_config(new_qs).optimized is True @pytest.mark.django_db(transaction=True) def test_queryset_config_survives_in_prefetch_queryset(): Project.objects.create() qs = Milestone.objects.all() config = get_queryset_config(qs) config.optimized = True project = ( Project.objects .all() .prefetch_related(Prefetch("milestones", queryset=qs)) .get() ) assert get_queryset_config(project.milestones.all()).optimized is True strawberry-graphql-django-0.82.1/tests/test_relay.py000066400000000000000000000035361516173410200226000ustar00rootroot00000000000000import strawberry from strawberry import auto, relay from typing_extensions import Self import strawberry_django from .models import User # textdedent not possible because of comments expected_schema = ''' """An object with a Globally Unique ID""" interface Node { """The Globally Unique ID of this object""" id: ID! } type Query { user: UserType! } type UserType implements Node { """The Globally Unique ID of this object""" id: ID! name: String! } ''' def test_relay_with_nodeid(): @strawberry_django.type(User) class UserType(relay.Node): id: relay.NodeID[int] name: auto @strawberry.type class Query: user: UserType schema = strawberry.Schema(query=Query) # test print_schema assert str(schema) == expected_schema.strip() # check that resolve_id_attr resolves correctly assert UserType.resolve_id_attr() == "id" def test_relay_with_resolve_id_attr(): @strawberry_django.type(User) class UserType(relay.Node): name: auto @classmethod def resolve_id_attr(cls): return "foobar" @strawberry.type class Query: user: UserType # Crash because of early check schema = strawberry.Schema(query=Query) # test print_schema assert str(schema) == expected_schema.strip() def test_relay_with_resolve_id_and_node_id(): @strawberry_django.type(User) class UserType(relay.Node): id: relay.NodeID[int] name: auto @classmethod def resolve_id(cls, root: Self, *, info): # type: ignore return str(root.id) @strawberry.type class Query: user: UserType schema = strawberry.Schema(query=Query) # test print_schema assert str(schema) == expected_schema.strip() # check that resolve_id_attr resolves correctly assert UserType.resolve_id_attr() == "id" strawberry-graphql-django-0.82.1/tests/test_settings.py000066400000000000000000000035521516173410200233220ustar00rootroot00000000000000"""Tests for `strawberry_django/settings.py`.""" from django.test import override_settings from strawberry_django import settings def test_defaults(): """Test defaults. Test that `strawberry_django_settings()` provides the default settings if they don't exist in the Django settings file. """ assert settings.strawberry_django_settings() == settings.DEFAULT_DJANGO_SETTINGS def test_non_defaults(): """Test non defaults. Test that `strawberry_django_settings()` provides the user's settings if they are defined in the Django settings file. """ with override_settings( STRAWBERRY_DJANGO=settings.StrawberryDjangoSettings( FIELD_DESCRIPTION_FROM_HELP_TEXT=True, TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING=True, GENERATE_ENUMS_FROM_CHOICES=True, MUTATIONS_DEFAULT_ARGUMENT_NAME="id", MUTATIONS_DEFAULT_HANDLE_ERRORS=True, MAP_AUTO_ID_AS_GLOBAL_ID=True, DEFAULT_PK_FIELD_NAME="id", USE_DEPRECATED_FILTERS=True, PAGINATION_DEFAULT_LIMIT=250, PAGINATION_MAX_LIMIT=1_000, ALLOW_MUTATIONS_WITHOUT_FILTERS=True, ), ): assert ( settings.strawberry_django_settings() == settings.StrawberryDjangoSettings( FIELD_DESCRIPTION_FROM_HELP_TEXT=True, TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING=True, GENERATE_ENUMS_FROM_CHOICES=True, MUTATIONS_DEFAULT_ARGUMENT_NAME="id", MUTATIONS_DEFAULT_HANDLE_ERRORS=True, MAP_AUTO_ID_AS_GLOBAL_ID=True, DEFAULT_PK_FIELD_NAME="id", USE_DEPRECATED_FILTERS=True, PAGINATION_DEFAULT_LIMIT=250, PAGINATION_MAX_LIMIT=1_000, ALLOW_MUTATIONS_WITHOUT_FILTERS=True, ) ) strawberry-graphql-django-0.82.1/tests/test_type.py000066400000000000000000000070501516173410200224400ustar00rootroot00000000000000import dataclasses import textwrap import strawberry from django.db import models from strawberry.types import get_object_definition import strawberry_django from strawberry_django.fields.field import StrawberryDjangoField from strawberry_django.utils.typing import get_django_definition def test_non_dataclass_annotations_are_ignored_on_type(): class SomeModel(models.Model): name = models.CharField(max_length=255) class NonDataclass: non_dataclass_attr: str @dataclasses.dataclass class SomeDataclass: some_dataclass_attr: str @strawberry.type class SomeStrawberryType: some_strawberry_attr: str @strawberry_django.type(SomeModel) class SomeModelType(SomeStrawberryType, SomeDataclass, NonDataclass): name: str @strawberry.type class Query: my_type: SomeModelType schema = strawberry.Schema(query=Query) expected = """\ type Query { myType: SomeModelType! } type SomeModelType { someStrawberryAttr: String! someDataclassAttr: String! name: String! } """ assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() def test_non_dataclass_annotations_are_ignored_on_input(): class SomeModel2(models.Model): name = models.CharField(max_length=255) class NonDataclass: non_dataclass_attr: str @dataclasses.dataclass class SomeDataclass: some_dataclass_attr: str @strawberry.input class SomeStrawberryInput: some_strawberry_attr: str @strawberry_django.input(SomeModel2) class SomeModelInput(SomeStrawberryInput, SomeDataclass, NonDataclass): name: str @strawberry.type class Query: @strawberry.field def some_field(self, my_input: SomeModelInput) -> str: ... schema = strawberry.Schema(query=Query) expected = """\ type Query { someField(myInput: SomeModelInput!): String! } input SomeModelInput { someStrawberryAttr: String! someDataclassAttr: String! name: String! } """ assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() def test_optimizer_hints_on_type(): class OtherModel(models.Model): name = models.CharField(max_length=255) class SomeModel3(models.Model): name = models.CharField(max_length=255) other = models.ForeignKey(OtherModel, on_delete=models.CASCADE) @strawberry_django.type( SomeModel3, only=["name", "other", "other_name"], select_related=["other"], prefetch_related=["other"], annotate={"other_name": models.F("other__name")}, ) class SomeModelType: name: str store = get_django_definition(SomeModelType, strict=True).store assert store.only == ["name", "other", "other_name"] assert store.select_related == ["other"] assert store.prefetch_related == ["other"] assert store.annotate == {"other_name": models.F("other__name")} def test_custom_field_kept_on_inheritance(): class SomeModel4(models.Model): foo = models.CharField(max_length=255) class CustomField(StrawberryDjangoField): ... @strawberry_django.type(SomeModel4) class SomeModelType: foo: strawberry.auto = CustomField() @strawberry_django.type(SomeModel4) class SomeModelSubclassType(SomeModelType): ... for type_ in [SomeModelType, SomeModelSubclassType]: object_definition = get_object_definition(type_, strict=True) field = object_definition.get_field("foo") assert isinstance(field, CustomField) strawberry-graphql-django-0.82.1/tests/test_types.py000066400000000000000000000307121516173410200226240ustar00rootroot00000000000000import textwrap import pytest import strawberry from django.test import override_settings from strawberry import auto from strawberry.types import Info, get_object_definition from strawberry.types.object_type import StrawberryObjectDefinition import strawberry_django from strawberry_django.fields.field import StrawberryDjangoField from strawberry_django.settings import StrawberryDjangoSettings from .models import Book as BookModel from .models import Color, Fruit, User def test_type_instance(): @strawberry_django.type(User) class UserType: id: auto name: auto user = UserType(id=1, name="user") assert user.id == 1 assert user.name == "user" def test_type_instance_auto_as_str(): @strawberry_django.type(User) class UserType: id: "auto" name: "auto" user = UserType(id=1, name="user") assert user.id == 1 assert user.name == "user" def test_input_instance(): @strawberry_django.input(User) class InputType: id: auto name: auto user = InputType(id=1, name="user") assert user.id == 1 assert user.name == "user" def test_custom_field_cls(): """Custom field_cls is applied to all fields.""" class CustomStrawberryDjangoField(StrawberryDjangoField): pass @strawberry_django.type(User, field_cls=CustomStrawberryDjangoField) class UserType: id: int name: auto assert all( isinstance(field, CustomStrawberryDjangoField) for field in get_object_definition(UserType, strict=True).fields ) def test_custom_field_cls__explicit_field_type(): """Custom field_cls is applied to all fields.""" class CustomStrawberryDjangoField(StrawberryDjangoField): pass @strawberry_django.type(User, field_cls=CustomStrawberryDjangoField) class UserType: id: int name: auto = strawberry_django.field() assert isinstance( get_object_definition(UserType, strict=True).get_field("id"), CustomStrawberryDjangoField, ) assert isinstance( get_object_definition(UserType, strict=True).get_field("name"), StrawberryDjangoField, ) assert not isinstance( get_object_definition(UserType, strict=True).get_field("name"), CustomStrawberryDjangoField, ) def test_field_metadata_default(): """Test metadata default. Test that textual metadata from the Django model isn't reflected in the Strawberry type by default. """ @strawberry_django.type(BookModel) class Book: title: auto type_def = get_object_definition(Book, strict=True) assert type_def.description is None title_field = type_def.get_field("title") assert title_field is not None assert title_field.description is None @override_settings( STRAWBERRY_DJANGO=StrawberryDjangoSettings( # type: ignore FIELD_DESCRIPTION_FROM_HELP_TEXT=True, TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING=True, ), ) def test_field_metadata_preserved(): """Test metadata preserved. Test that textual metadata from the Django model is reflected in the Strawberry type if the settings are enabled. """ @strawberry_django.type(BookModel) class Book: title: auto type_def = get_object_definition(Book, strict=True) assert type_def.description == BookModel.__doc__ title_field = type_def.get_field("title") assert title_field is not None assert title_field.description == BookModel._meta.get_field("title").help_text assert get_object_definition(Book, strict=True).description == BookModel.__doc__ @override_settings( STRAWBERRY_DJANGO=StrawberryDjangoSettings( # type: ignore FIELD_DESCRIPTION_FROM_HELP_TEXT=True, TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING=True, ), ) def test_field_metadata_overridden(): """Test field metadata overriden. Test that the textual metadata from the Django model can be ignored in favor of custom metadata. """ @strawberry_django.type(BookModel, description="A story with pages") class Book: title: auto = strawberry_django.field(description="The name of the story") type_def = get_object_definition(Book, strict=True) assert type_def.description == "A story with pages" title_field = type_def.get_field("title") assert title_field is not None assert title_field.description == "The name of the story" @override_settings( STRAWBERRY_DJANGO=StrawberryDjangoSettings( # type: ignore FIELD_DESCRIPTION_FROM_HELP_TEXT=True, TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING=True, ), ) def test_field_no_empty_strings(monkeypatch: pytest.MonkeyPatch): """Test no empty strings on fields. Test that an empty Django model docstring doesn't get used for the description. """ monkeypatch.setattr(BookModel, "__doc__", "") @strawberry_django.type(BookModel) class Book: title: auto assert get_object_definition(Book, strict=True).description is None @strawberry_django.type(Color) class ColorType: id: auto name: auto @strawberry_django.type(Fruit) class FruitType: id: auto name: auto @strawberry.field def color(self, info: Info, root) -> "ColorType": return root.color def test_type_resolution_with_resolvers(): @strawberry.type class Query: fruit: FruitType = strawberry_django.field() schema = strawberry.Schema(query=Query) type_def = schema.get_type_by_name("FruitType") assert isinstance(type_def, StrawberryObjectDefinition) field = type_def.get_field("color") assert field assert field.type is ColorType @override_settings( STRAWBERRY_DJANGO=StrawberryDjangoSettings( # type: ignore FIELD_DESCRIPTION_FROM_HELP_TEXT=True, TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING=True, ), ) def test_all_fields_works(): @strawberry_django.type(Fruit, fields="__all__") class FruitType: pass @strawberry.type class Query: fruit: FruitType schema = strawberry.Schema(query=Query) expected = '''\ type DjangoImageType { name: String! path: String! size: Int! url: String! width: Int! height: Int! } type DjangoModelType { pk: ID! } """Fruit(id, name, color, sweetness, picture)""" type FruitType { id: ID! name: String! color: DjangoModelType """Level of sweetness, from 1 to 10""" sweetness: Int! picture: DjangoImageType } type Query { fruit: FruitType! } ''' assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() def test_can_override_type_when_fields_all(): @strawberry_django.type(Fruit, fields="__all__") class FruitType: name: int @strawberry.type class Query: fruit: FruitType schema = strawberry.Schema(query=Query) expected = """\ type DjangoImageType { name: String! path: String! size: Int! url: String! width: Int! height: Int! } type DjangoModelType { pk: ID! } type FruitType { name: Int! id: ID! color: DjangoModelType sweetness: Int! picture: DjangoImageType } type Query { fruit: FruitType! } """ assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() def test_fields_can_be_enumerated(): @strawberry_django.type(Fruit, fields=["name", "sweetness"]) class FruitType: pass @strawberry.type class Query: fruit: FruitType schema = strawberry.Schema(query=Query) expected = """\ type FruitType { name: String! sweetness: Int! } type Query { fruit: FruitType! } """ assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() def test_non_existent_fields_ignored(): @strawberry_django.type(Fruit, fields=["name", "sourness"]) class FruitType: pass @strawberry.type class Query: fruit: FruitType schema = strawberry.Schema(query=Query) expected = """\ type FruitType { name: String! } type Query { fruit: FruitType! } """ assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() def test_resolvers_with_fields(): @strawberry_django.type(Fruit, fields=["name"]) class FruitType: @strawberry.field def color(self, info: Info, root) -> "ColorType": return root.color @strawberry.type class Query: fruit: FruitType = strawberry_django.field() schema = strawberry.Schema(query=Query) expected = """\ type ColorType { id: ID! name: String! } type FruitType { name: String! color: ColorType! } type Query { fruit(pk: ID!): FruitType! } """ assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() def test_exclude_with_fields_is_ignored(): @strawberry_django.type(Fruit, fields=["name", "sweetness"], exclude=["name"]) class FruitType: pass @strawberry.type class Query: fruit: FruitType schema = strawberry.Schema(query=Query) expected = """\ type FruitType { name: String! sweetness: Int! } type Query { fruit: FruitType! } """ assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() def test_exclude_includes_non_enumerated_fields(): @strawberry_django.type(Fruit, exclude=["name"]) class FruitType: pass @strawberry.type class Query: fruit: FruitType schema = strawberry.Schema(query=Query) expected = """\ type DjangoImageType { name: String! path: String! size: Int! url: String! width: Int! height: Int! } type DjangoModelType { pk: ID! } type FruitType { id: ID! color: DjangoModelType sweetness: Int! picture: DjangoImageType } type Query { fruit: FruitType! } """ assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() def test_non_existent_fields_exclude_ignored(): @strawberry_django.type(Fruit, exclude=["sourness"]) class FruitType: pass @strawberry.type class Query: fruit: FruitType schema = strawberry.Schema(query=Query) expected = """\ type DjangoImageType { name: String! path: String! size: Int! url: String! width: Int! height: Int! } type DjangoModelType { pk: ID! } type FruitType { id: ID! name: String! color: DjangoModelType sweetness: Int! picture: DjangoImageType } type Query { fruit: FruitType! } """ assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() def test_can_override_type_with_exclude(): @strawberry_django.type(Fruit, exclude=["name"]) class FruitType: sweetness: str @strawberry.type class Query: fruit: FruitType schema = strawberry.Schema(query=Query) expected = """\ type DjangoImageType { name: String! path: String! size: Int! url: String! width: Int! height: Int! } type DjangoModelType { pk: ID! } type FruitType { sweetness: String! id: ID! color: DjangoModelType picture: DjangoImageType } type Query { fruit: FruitType! } """ assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() def test_can_override_fields_with_exclude(): @strawberry_django.type(Fruit, exclude=["name"]) class FruitType: name: auto @strawberry.type class Query: fruit: FruitType schema = strawberry.Schema(query=Query) expected = """\ type DjangoImageType { name: String! path: String! size: Int! url: String! width: Int! height: Int! } type DjangoModelType { pk: ID! } type FruitType { name: String! id: ID! color: DjangoModelType sweetness: Int! picture: DjangoImageType } type Query { fruit: FruitType! } """ assert textwrap.dedent(str(schema)) == textwrap.dedent(expected).strip() strawberry-graphql-django-0.82.1/tests/types.py000066400000000000000000000047501516173410200215700ustar00rootroot00000000000000from __future__ import annotations from django.conf import settings from strawberry import auto import strawberry_django from . import models @strawberry_django.type(models.Fruit) class Fruit: id: auto name: auto color: Color | None types: list[FruitType] picture: auto sweetness: auto @strawberry_django.type(models.Color) class Color: id: auto name: auto fruits: list[Fruit] @strawberry_django.type(models.FruitType) class FruitType: id: auto name: auto fruits: list[Fruit] @strawberry_django.type(models.Vegetable) class Vegetable: id: auto name: auto @strawberry_django.type(models.TomatoWithRequiredPicture, fields="__all__") class TomatoWithRequiredPictureType: pass if settings.GEOS_IMPORTED: @strawberry_django.type(models.GeosFieldsModel) class GeoField: id: auto point: auto line_string: auto polygon: auto multi_point: auto multi_line_string: auto multi_polygon: auto @strawberry_django.input(models.GeosFieldsModel) class GeoFieldInput(GeoField): pass @strawberry_django.input(models.GeosFieldsModel, partial=True) class GeoFieldPartialInput(GeoField): pass @strawberry_django.input(models.Fruit) class FruitInput(Fruit): types: list[FruitTypeInput] | None # type: ignore @strawberry_django.input(models.TomatoWithRequiredPicture) class TomatoWithRequiredPictureInput: id: auto name: auto @strawberry_django.input(models.Color) class ColorInput(Color): pass @strawberry_django.input(models.FruitType) class FruitTypeInput(FruitType): pass @strawberry_django.input(models.Fruit, partial=True) class FruitPartialInput(FruitInput): types: list[FruitTypePartialInput] | None # type: ignore @strawberry_django.partial(models.TomatoWithRequiredPicture, fields="__all__") class TomatoWithRequiredPicturePartialInput(TomatoWithRequiredPictureType): pass @strawberry_django.input(models.Color, partial=True) class ColorPartialInput(ColorInput): pass @strawberry_django.input(models.FruitType, partial=True) class FruitTypePartialInput(FruitTypeInput): pass @strawberry_django.type(models.User) class User: id: auto name: auto group: Group tag: Tag @strawberry_django.type(models.Group) class Group: id: auto name: auto tags: list[Tag] users: list[User] @strawberry_django.type(models.Tag) class Tag: id: auto name: auto groups: list[Group] user: User strawberry-graphql-django-0.82.1/tests/types2/000077500000000000000000000000001516173410200212725ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/types2/__init__.py000066400000000000000000000000001516173410200233710ustar00rootroot00000000000000strawberry-graphql-django-0.82.1/tests/types2/test_input.py000066400000000000000000000101501516173410200240370ustar00rootroot00000000000000import dataclasses from typing import cast import strawberry from strawberry import auto from strawberry.types import get_object_definition from strawberry.types.base import StrawberryOptional from strawberry.types.maybe import Maybe import strawberry_django from .test_type import TypeModel def test_input(): @strawberry_django.input(TypeModel) class Input: id: auto boolean: auto string: auto object_definition = get_object_definition(Input, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("id", StrawberryOptional(cast("type", strawberry.ID))), ("boolean", bool), ("string", str), ] def test_inherit(testtype): @testtype(TypeModel) class Base: id: auto boolean: auto @strawberry_django.input(TypeModel) class Input(Base): string: auto object_definition = get_object_definition(Input, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("id", StrawberryOptional(cast("type", strawberry.ID))), ("boolean", bool), ("string", str), ] def test_relationship(testtype): @strawberry_django.input(TypeModel) class Input: foreign_key: auto related_foreign_key: auto one_to_one: auto related_one_to_one: auto many_to_many: auto related_many_to_many: auto object_definition = get_object_definition(Input, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("foreign_key", StrawberryOptional(strawberry_django.OneToManyInput)), ( "related_foreign_key", StrawberryOptional(strawberry_django.ManyToOneInput), ), ("one_to_one", StrawberryOptional(strawberry_django.OneToOneInput)), ( "related_one_to_one", StrawberryOptional(strawberry_django.OneToOneInput), ), ( "many_to_many", StrawberryOptional(strawberry_django.ManyToManyInput), ), ( "related_many_to_many", StrawberryOptional(strawberry_django.ManyToManyInput), ), ] def test_relationship_inherit(testtype): @testtype(TypeModel) class Base: foreign_key: auto related_foreign_key: auto one_to_one: auto related_one_to_one: auto many_to_many: auto related_many_to_many: auto another_name: auto = strawberry_django.field(field_name="foreign_key") @strawberry_django.input(TypeModel) class Input(Base): pass object_definition = get_object_definition(Input, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("foreign_key", StrawberryOptional(strawberry_django.OneToManyInput)), ( "related_foreign_key", StrawberryOptional(strawberry_django.ManyToOneInput), ), ("one_to_one", StrawberryOptional(strawberry_django.OneToOneInput)), ( "related_one_to_one", StrawberryOptional(strawberry_django.OneToOneInput), ), ( "many_to_many", StrawberryOptional(strawberry_django.ManyToManyInput), ), ( "related_many_to_many", StrawberryOptional(strawberry_django.ManyToManyInput), ), ("another_name", StrawberryOptional(strawberry_django.OneToManyInput)), ] def test_maybe_field_default_value(): @strawberry_django.input(TypeModel) class InputWithMaybe: my_maybe_field: Maybe[bool] regular_optional_field: str | None @strawberry.input class StrawberryInputWithMaybe: my_maybe_field: Maybe[bool] django_fields = {f.name: f for f in dataclasses.fields(InputWithMaybe)} strawberry_fields = { f.name: f for f in dataclasses.fields(StrawberryInputWithMaybe) } assert django_fields["my_maybe_field"].default is None assert ( django_fields["my_maybe_field"].default == strawberry_fields["my_maybe_field"].default ) assert django_fields["regular_optional_field"].default is strawberry.UNSET strawberry-graphql-django-0.82.1/tests/types2/test_type.py000066400000000000000000000073131516173410200236700ustar00rootroot00000000000000import strawberry from django.db import models from strawberry import auto from strawberry.types import get_object_definition from strawberry.types.base import ( StrawberryContainer, StrawberryList, StrawberryOptional, ) import strawberry_django from strawberry_django.fields.field import StrawberryDjangoField class TypeModel(models.Model): boolean = models.BooleanField() string = models.CharField(max_length=50) foreign_key = models.ForeignKey( "TypeModel", blank=True, related_name="related_foreign_key", on_delete=models.CASCADE, ) one_to_one = models.OneToOneField( "TypeModel", blank=True, related_name="related_one_to_one", on_delete=models.CASCADE, ) many_to_many = models.ManyToManyField( "TypeModel", related_name="related_many_to_many", ) def test_type(): @strawberry_django.type(TypeModel) class Type: id: auto boolean: auto string: auto object_definition = get_object_definition(Type, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("id", strawberry.ID), ("boolean", bool), ("string", str), ] def test_inherit(testtype): @testtype(TypeModel) class Base: id: auto boolean: auto @strawberry_django.type(TypeModel) class Type(Base): string: auto object_definition = get_object_definition(Type, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("id", strawberry.ID), ("boolean", bool), ("string", str), ] def test_default_value(): @strawberry_django.type(TypeModel) class Type: string: auto = "data" string2: str = strawberry.field(default="data2") string3: str = strawberry_django.field(default="data3") object_definition = get_object_definition(Type, strict=True) assert [(f.name, f.type) for f in object_definition.fields] == [ ("string", str), ("string2", str), ("string3", str), ] assert Type().string == "data" assert Type().string2 == "data2" assert Type().string3 == "data3" def test_relationship_inherit(testtype): @testtype(TypeModel) class Base: foreign_key: auto related_foreign_key: auto one_to_one: auto related_one_to_one: auto many_to_many: auto related_many_to_many: auto another_name: auto = strawberry_django.field(field_name="foreign_key") @strawberry_django.type(TypeModel) class Type(Base): pass expected_fields: dict[str, tuple[type | StrawberryContainer, bool]] = { "foreign_key": (strawberry_django.DjangoModelType, False), "related_foreign_key": ( StrawberryList(strawberry_django.DjangoModelType), True, ), "one_to_one": (strawberry_django.DjangoModelType, False), "related_one_to_one": ( StrawberryOptional(strawberry_django.DjangoModelType), False, ), "many_to_many": ( StrawberryList(strawberry_django.DjangoModelType), True, ), "related_many_to_many": ( StrawberryList(strawberry_django.DjangoModelType), True, ), "another_name": (strawberry_django.DjangoModelType, False), } object_definition = get_object_definition(Type, strict=True) assert len(object_definition.fields) == len(expected_fields) for f in object_definition.fields: expected_type, expected_is_list = expected_fields[f.name] assert isinstance(f, StrawberryDjangoField) assert f.is_list == expected_is_list assert f.type == expected_type strawberry-graphql-django-0.82.1/tests/urls.py000066400000000000000000000006641516173410200214110ustar00rootroot00000000000000from django.conf.urls.static import static from django.urls import path from django.urls.conf import include from strawberry.django.views import AsyncGraphQLView, GraphQLView from .projects.schema import schema urlpatterns = [ path("graphql/", GraphQLView.as_view(schema=schema)), path("graphql_async/", AsyncGraphQLView.as_view(schema=schema)), path("__debug__/", include("debug_toolbar.urls")), *static("/media"), ] strawberry-graphql-django-0.82.1/tests/utils.py000066400000000000000000000125101516173410200215550ustar00rootroot00000000000000import asyncio import contextlib import contextvars import dataclasses import inspect from typing import ( Any, cast, ) import strawberry from asgiref.sync import sync_to_async from django.db import DEFAULT_DB_ALIAS, connections from django.test.client import AsyncClient, Client from django.test.utils import CaptureQueriesContext from strawberry.test.client import Response from strawberry.utils.inspect import in_async_context from typing_extensions import override from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry_django.test.client import TestClient _client: contextvars.ContextVar["GraphQLTestClient"] = contextvars.ContextVar( "_client_ctx", ) def generate_query(query=None, mutation=None, enable_optimizer=False): append_mutation = mutation and not query if query is None: @strawberry.type class Query: x: int query = Query extensions = [] if enable_optimizer: extensions = [DjangoOptimizerExtension()] schema = strawberry.Schema(query=query, mutation=mutation, extensions=extensions) def process_result(result): return result async def query_async(query, variable_values, context_value): result = await schema.execute( query, variable_values=variable_values, context_value=context_value, ) return process_result(result) def query_sync(query, variable_values=None, context_value=None): if append_mutation and not query.startswith("mutation"): query = f"mutation {query}" if in_async_context(): return query_async( query, variable_values=variable_values, context_value=context_value, ) result = schema.execute_sync( query, variable_values=variable_values, context_value=context_value, ) return process_result(result) return query_sync def dataclass(model): def wrapper(cls): return dataclasses.dataclass(cls) return wrapper def deep_tuple_to_list(data: tuple) -> list: return_list = [] for elem in data: if isinstance(elem, tuple): return_list.append(deep_tuple_to_list(elem)) else: return_list.append(elem) return return_list class AsyncCaptureQueriesContext: wrapped: CaptureQueriesContext def __init__(self, using: str): super().__init__() self.using = using @sync_to_async def wrapped_enter(self): self.wrapped = CaptureQueriesContext(connection=connections[self.using]) return self.wrapped.__enter__() # noqa: PLC2801 def __enter__(self): return asyncio.run(self.wrapped_enter()) def __exit__(self, exc_type, exc_value, traceback, /): return asyncio.run( sync_to_async(self.wrapped.__exit__)(exc_type, exc_value, traceback) ) @contextlib.contextmanager def assert_num_queries(n: int, *, using=DEFAULT_DB_ALIAS): is_async = (gql_client := _client.get(None)) is not None and gql_client.is_async if is_async: ctx_manager = AsyncCaptureQueriesContext(using) else: ctx_manager = CaptureQueriesContext(connection=connections[using]) with ctx_manager as ctx: yield ctx executed = len(ctx) assert executed == n, ( "{} queries executed, {} expected\nCaptured queries were:\n{}".format( executed, n, "\n".join( f"{i}. {q['sql']}" for i, q in enumerate(ctx.captured_queries, start=1) ), ) ) class GraphQLTestClient(TestClient): def __init__( self, path: str, client: Client | AsyncClient, ): super().__init__(path, client=cast("Client", client)) self._token: contextvars.Token | None = None self.is_async = isinstance(client, AsyncClient) def __enter__(self): self._token = _client.set(self) return self def __exit__(self, *args, **kwargs): assert self._token _client.reset(self._token) def request( self, body: dict[str, object], headers: dict[str, object] | None = None, files: dict[str, object] | None = None, ): kwargs: dict[str, object] = {"data": body} if files: # pragma:nocover kwargs["format"] = "multipart" else: kwargs["content_type"] = "application/json" return self.client.post( self.path, **kwargs, # type: ignore ) @override def query( self, query: str, variables: dict[str, Any] | None = None, headers: dict[str, object] | None = None, files: dict[str, object] | None = None, assert_no_errors: bool | None = True, ) -> Response: body = self._build_body(query, variables, files) resp = self.request(body, headers, files) if inspect.iscoroutine(resp): resp = asyncio.run(resp) data = self._decode(resp, type="multipart" if files else "json") response = Response( errors=data.get("errors"), data=data.get("data"), extensions=data.get("extensions"), ) if assert_no_errors: assert response.errors is None, response.errors return response strawberry-graphql-django-0.82.1/uv.lock000066400000000000000000004232611516173410200202160ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.10, <4.0" resolution-markers = [ "python_full_version >= '3.12'", "python_full_version < '3.12'", ] [[package]] name = "asgiref" version = "3.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, ] [[package]] name = "backports-asyncio-runner" version = "1.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] [[package]] name = "channels" version = "4.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/92/b18d4bb54d14986a8b35215a1c9e6a7f9f4d57ca63ac9aee8290ebb4957d/channels-4.3.2.tar.gz", hash = "sha256:f2bb6bfb73ad7fb4705041d07613c7b4e69528f01ef8cb9fb6c21d9295f15667", size = 27023, upload-time = "2025-11-20T15:13:05.102Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/16/34/c32915288b7ef482377b6adc401192f98c6a99b3a145423d3b8aed807898/channels-4.3.2-py3-none-any.whl", hash = "sha256:fef47e9055a603900cf16cef85f050d522d9ac4b3daccf24835bd9580705c176", size = 31313, upload-time = "2025-11-20T15:13:02.357Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "coverage" version = "7.13.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" }, { url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" }, { url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" }, { url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" }, { url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" }, { url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" }, { url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" }, { url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" }, { url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" }, { url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" }, { url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" }, { url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" }, { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, ] [package.optional-dependencies] toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] name = "cross-web" version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/93/4f/bdb62e969649ee76d4741ef8eee34384ec2bc21cc66eb7fd244e6ad62be8/cross_web-0.4.0.tar.gz", hash = "sha256:4ae65619ddfcd06d6803432c0366342d7e8aeba10194b4e144d73a662e75370c", size = 157111, upload-time = "2025-12-25T20:45:21.989Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6a/d6/6c6a036655e5091b26b9f350dcf43821895325aa4727396b14c67679a957/cross_web-0.4.0-py3-none-any.whl", hash = "sha256:0c675bd26e91428cab31e3e927929b42da94aa96da92974e57c78f9a732d0e9b", size = 14200, upload-time = "2025-12-25T20:45:23.075Z" }, ] [[package]] name = "django" version = "5.2.9" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ { name = "asgiref", marker = "python_full_version < '3.12'" }, { name = "sqlparse", marker = "python_full_version < '3.12'" }, { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/1c/188ce85ee380f714b704283013434976df8d3a2df8e735221a02605b6794/django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495", size = 10848762, upload-time = "2025-12-02T14:01:08.418Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" }, ] [[package]] name = "django" version = "6.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.12'", ] dependencies = [ { name = "asgiref", marker = "python_full_version >= '3.12'" }, { name = "sqlparse", marker = "python_full_version >= '3.12'" }, { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/15/75/19762bfc4ea556c303d9af8e36f0cd910ab17dff6c8774644314427a2120/django-6.0.tar.gz", hash = "sha256:7b0c1f50c0759bbe6331c6a39c89ae022a84672674aeda908784617ef47d8e26", size = 10932418, upload-time = "2025-12-03T16:26:21.878Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d7/ae/f19e24789a5ad852670d6885f5480f5e5895576945fcc01817dfd9bc002a/django-6.0-py3-none-any.whl", hash = "sha256:1cc2c7344303bbfb7ba5070487c17f7fc0b7174bbb0a38cebf03c675f5f19b6d", size = 8339181, upload-time = "2025-12-03T16:26:16.231Z" }, ] [[package]] name = "django-choices-field" version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3e/23/54749b86f605aafeaab81dc8e023968d95a7cc44434eac4df330e8e7f0d8/django_choices_field-4.0.0.tar.gz", hash = "sha256:7b07aacb2ad2b3ee3c8be78be98302ebbcb606285d20547a4163287819364954", size = 6346, upload-time = "2025-12-27T11:53:50.986Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3d/fd/2e0bb952961ba108bdb82efbb10eaeae5bbb5a687b6bdaf8427af181ac5d/django_choices_field-4.0.0-py3-none-any.whl", hash = "sha256:dfa59033685af989f5bc49d95a586e8de692999d3179bf1cb14ef99879e6ff45", size = 7035, upload-time = "2025-12-27T11:53:50.229Z" }, ] [[package]] name = "django-debug-toolbar" version = "6.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sqlparse" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/50/acae2dd379164f6f4c6b6b36fd48a4d21b02095a03f4df7c30a8d1f1a62c/django_debug_toolbar-6.1.0.tar.gz", hash = "sha256:e962ec350c9be8bdba918138e975a9cdb193f60ec396af2bb71b769e8e165519", size = 309141, upload-time = "2025-10-30T19:50:39.458Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6d/72/685c978af45ad08257e2c69687a873eda6b6531c79b6e6091794c41c5ff6/django_debug_toolbar-6.1.0-py3-none-any.whl", hash = "sha256:e214dea4494087e7cebdcea84223819c5eb97f9de3110a3665ad673f0ba98413", size = 269069, upload-time = "2025-10-30T19:50:37.71Z" }, ] [[package]] name = "django-guardian" version = "3.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/f9/bcff6a931298b9eb55e1550b55ab964fab747f594ba6d2d81cbe19736c5f/django_guardian-3.2.0.tar.gz", hash = "sha256:9e18ecd2e211b665972690c2d03d27bce0ea4932b5efac24a4bb9d526950a69e", size = 99940, upload-time = "2025-09-16T10:35:53.609Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2f/23/63a7d868373a73d25c4a5c2dd3cce3aaeb22fbee82560d42b6e93ba01403/django_guardian-3.2.0-py3-none-any.whl", hash = "sha256:0768565a057988a93fc4a1d93649c4a794abfd7473a8408a079cfbf83c559d77", size = 134674, upload-time = "2025-09-16T10:35:51.69Z" }, ] [[package]] name = "django-model-utils" version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/60/5e232c32a2c977cc1af8c70a38ef436598bc649ad89c2c4568454edde2c9/django_model_utils-5.0.0.tar.gz", hash = "sha256:041cdd6230d2fbf6cd943e1969318bce762272077f4ecd333ab2263924b4e5eb", size = 80559, upload-time = "2024-09-04T11:35:22.858Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fd/13/87a42048700c54bfce35900a34e2031245132775fb24363fc0e33664aa9c/django_model_utils-5.0.0-py3-none-any.whl", hash = "sha256:fec78e6c323d565a221f7c4edc703f4567d7bb1caeafe1acd16a80c5ff82056b", size = 42630, upload-time = "2024-09-04T11:36:23.166Z" }, ] [[package]] name = "django-polymorphic" version = "4.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/a8/1e3355d85b14fe192c802377bbd6bb3e60410f35aefd0f6b669b7264cf24/django_polymorphic-4.11.2.tar.gz", hash = "sha256:0b16060f50f0f196a8c5141a2f8f7d7d5289e7c78cf81aef2d63ff55762011ca", size = 61767, upload-time = "2026-03-07T16:02:14.2Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3e/17/c56283bead21d36e401d200d04321dd1f788766d330e7e88ae83a52e48e4/django_polymorphic-4.11.2-py3-none-any.whl", hash = "sha256:4bf61d6faec4870aee91b997292be45db3ebbd644dea072607efcee8ca27301a", size = 79070, upload-time = "2026-03-07T16:02:12.94Z" }, ] [[package]] name = "django-tree-queries" version = "0.23.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9e/9a/59ee71608d991a02f25cd6ca88bb3f87afa2ea6e0282c21f69637f8907cf/django_tree_queries-0.23.0.tar.gz", hash = "sha256:11845b53f173601b98555a393cdc4924dcd67a4946e7b464aa293bb22a9bf1f6", size = 28494, upload-time = "2025-11-27T19:54:36.321Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c6/de/92c49ce9343ade0b485533ef44ffdf5dce40e5dbeb9a47a33c12c17d7985/django_tree_queries-0.23.0-py3-none-any.whl", hash = "sha256:7095a3b6f1c9d57485213ab7e0b2fb76b5962590300f47a9ece8deb9705ae990", size = 32500, upload-time = "2025-11-27T19:54:34.37Z" }, ] [[package]] name = "django-types" version = "0.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-psycopg2" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/26/5f2873f7208dee0791710fda494a8d3ef7fb1d34785069b55168a23e2ac2/django_types-0.22.0.tar.gz", hash = "sha256:4cecc9eee846e7ff2a398bec9dfe6543e76efb922a7a58c5d6064bcb0e6a3dc5", size = 187214, upload-time = "2025-07-15T01:05:48.039Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/82/3a/0ecbaab07cfe7b0a4fd72dfde4be8e7c11aad2b88c816cfcbb800c14cbda/django_types-0.22.0-py3-none-any.whl", hash = "sha256:ba15c756c7a732e58afd0737e54489f1c5e6f1bd24132e9199c637b1f88b057c", size = 376869, upload-time = "2025-07-15T01:05:46.621Z" }, ] [[package]] name = "docopt" version = "0.6.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901, upload-time = "2014-06-16T11:18:57.406Z" } [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "execnet" version = "2.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] [[package]] name = "factory-boy" version = "3.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "faker" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146, upload-time = "2025-02-03T09:49:04.433Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" }, ] [[package]] name = "faker" version = "40.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d7/1d/aa43ef59589ddf3647df918143f1bac9eb004cce1c43124ee3347061797d/faker-40.1.0.tar.gz", hash = "sha256:c402212a981a8a28615fea9120d789e3f6062c0c259a82bfb8dff5d273e539d2", size = 1948784, upload-time = "2025-12-29T18:06:00.659Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fc/23/e22da510e1ec1488966330bf76d8ff4bd535cbfc93660eeb7657761a1bb2/faker-40.1.0-py3-none-any.whl", hash = "sha256:a616d35818e2a2387c297de80e2288083bc915e24b7e39d2fb5bc66cce3a929f", size = 1985317, upload-time = "2025-12-29T18:05:58.831Z" }, ] [[package]] name = "graphql-core" version = "3.2.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ac/9b/037a640a2983b09aed4a823f9cf1729e6d780b0671f854efa4727a7affbe/graphql_core-3.2.7.tar.gz", hash = "sha256:27b6904bdd3b43f2a0556dad5d579bdfdeab1f38e8e8788e555bdcb586a6f62c", size = 513484, upload-time = "2025-11-01T22:30:40.436Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0a/14/933037032608787fb92e365883ad6a741c235e0ff992865ec5d904a38f1e/graphql_core-3.2.7-py3-none-any.whl", hash = "sha256:17fc8f3ca4a42913d8e24d9ac9f08deddf0a0b2483076575757f6c412ead2ec0", size = 207262, upload-time = "2025-11-01T22:30:38.912Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "pillow" version = "12.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" }, { url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" }, { url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" }, { url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" }, { url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" }, { url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" }, { url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" }, { url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" }, { url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" }, { url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" }, { url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" }, { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" }, { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" }, { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" }, { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" }, { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" }, { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" }, { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" }, { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" }, { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" }, { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" }, { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" }, { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" }, { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" }, { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" }, { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" }, { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" }, { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" }, { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "psycopg" version = "3.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630, upload-time = "2025-12-06T17:34:53.899Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774, upload-time = "2025-12-06T17:31:41.414Z" }, ] [[package]] name = "psycopg-binary" version = "3.3.2" source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/25/d7/edfb0d9e56081246fd88490f99b1bafebd3588480cca601a4de0c41a3e08/psycopg_binary-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0768c5f32934bb52a5df098317eca9bdcf411de627c5dca2ee57662b64b54b41", size = 4597785, upload-time = "2025-12-06T17:31:44.867Z" }, { url = "https://files.pythonhosted.org/packages/71/45/8458201d9573dd851263a05cefddd4bfd31e8b3c6434b3e38d62aea9f15a/psycopg_binary-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:09b3014013f05cd89828640d3a1db5f829cc24ad8fa81b6e42b2c04685a0c9d4", size = 4664440, upload-time = "2025-12-06T17:31:49.1Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/484260d87456cfe88dc219c1919026f11949b9d1de8a6371ddbe027d4d60/psycopg_binary-3.3.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3789d452a9d17a841c7f4f97bbcba51a21f957ea35641a4c98507520e6b6a068", size = 5478355, upload-time = "2025-12-06T17:31:52.657Z" }, { url = "https://files.pythonhosted.org/packages/34/b2/18c91630c30c83f534c2bfa75fb533293fc9c3ab31bb7f2bf1cd9579c53b/psycopg_binary-3.3.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44e89938d36acc4495735af70a886d206a5bfdc80258f95b69b52f68b2968d9e", size = 5152398, upload-time = "2025-12-06T17:31:56.092Z" }, { url = "https://files.pythonhosted.org/packages/c0/14/7c705e1934107196d9dca2040cf34bce2ca26de62520e43073d2673052d4/psycopg_binary-3.3.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ed9da805e52985b0202aed4f352842c907c6b4fc6c7c109c6e646c32e2f43b", size = 6748982, upload-time = "2025-12-06T17:32:00.611Z" }, { url = "https://files.pythonhosted.org/packages/56/18/80197c47798926f79e563af02a71d1abecab88cf45ddf8dc960700598da7/psycopg_binary-3.3.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3a9ccdfee4ae59cf9bf1822777e763bc097ed208f4901e21537fca1070e1391", size = 4991214, upload-time = "2025-12-06T17:32:03.897Z" }, { url = "https://files.pythonhosted.org/packages/7e/2e/e88e2f678f5d1a968d87e57b30915061c1157e916b8aaa9b0b78bca95e25/psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de9173f8cc0efd88ac2a89b3b6c287a9a0011cdc2f53b2a12c28d6fd55f9f81c", size = 4517421, upload-time = "2025-12-06T17:32:07.287Z" }, { url = "https://files.pythonhosted.org/packages/80/9e/d56813b24370723bcd62bf73871aee4d5fca0536f3476c4c4d5b037e3c7f/psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0611f4822674f3269e507a307236efb62ae5a828fcfc923ac85fe22ca19fd7c8", size = 4206124, upload-time = "2025-12-06T17:32:10.374Z" }, { url = "https://files.pythonhosted.org/packages/91/81/5a11a898969edf0ee43d0613a6dfd689a0aa12d418c69e148a8ff153fbc7/psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:522b79c7db547767ca923e441c19b97a2157f2f494272a119c854bba4804e186", size = 3937067, upload-time = "2025-12-06T17:32:13.852Z" }, { url = "https://files.pythonhosted.org/packages/a1/33/a6180ff1e747a0395876d985e8e295c9d7cbe956a2d66f165e7c67cffe55/psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1ea41c0229f3f5a3844ad0857a83a9f869aa7b840448fa0c200e6bcf85d33d19", size = 4243731, upload-time = "2025-12-06T17:32:16.803Z" }, { url = "https://files.pythonhosted.org/packages/e9/5b/9c1b6fbc900d5b525946ed9a477865c5016a5306080c0557248bb04f1a5b/psycopg_binary-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:8ea05b499278790a8fa0ff9854ab0de2542aca02d661ddff94e830df971ff640", size = 3546403, upload-time = "2025-12-06T17:32:19.621Z" }, { url = "https://files.pythonhosted.org/packages/57/d9/49640360fc090d27afc4655021544aa71d5393ebae124ffa53a04474b493/psycopg_binary-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:94503b79f7da0b65c80d0dbb2f81dd78b300319ec2435d5e6dcf9622160bc2fa", size = 4597890, upload-time = "2025-12-06T17:32:23.087Z" }, { url = "https://files.pythonhosted.org/packages/85/cf/99634bbccc8af0dd86df4bce705eea5540d06bb7f5ab3067446ae9ffdae4/psycopg_binary-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07a5f030e0902ec3e27d0506ceb01238c0aecbc73ecd7fa0ee55f86134600b5b", size = 4664396, upload-time = "2025-12-06T17:32:26.421Z" }, { url = "https://files.pythonhosted.org/packages/40/db/6035dff6d5c6dfca3a4ab0d2ac62ede623646e327e9f99e21e0cf08976c6/psycopg_binary-3.3.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e09d0d93d35c134704a2cb2b15f81ffc8174fd602f3e08f7b1a3d8896156cf0", size = 5478743, upload-time = "2025-12-06T17:32:29.901Z" }, { url = "https://files.pythonhosted.org/packages/03/0f/fc06bbc8e87f09458d2ce04a59cd90565e54e8efca33e0802daee6d2b0e6/psycopg_binary-3.3.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:649c1d33bedda431e0c1df646985fbbeb9274afa964e1aef4be053c0f23a2924", size = 5151820, upload-time = "2025-12-06T17:32:33.562Z" }, { url = "https://files.pythonhosted.org/packages/86/ab/bcc0397c96a0ad29463e33ed03285826e0fabc43595c195f419d9291ee70/psycopg_binary-3.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5774272f754605059521ff037a86e680342e3847498b0aa86b0f3560c70963c", size = 6747711, upload-time = "2025-12-06T17:32:38.074Z" }, { url = "https://files.pythonhosted.org/packages/96/eb/7450bc75c31d5be5f7a6d02d26beef6989a4ca6f5efdec65eea6cf612d0e/psycopg_binary-3.3.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d391b70c9cc23f6e1142729772a011f364199d2c5ddc0d596f5f43316fbf982d", size = 4991626, upload-time = "2025-12-06T17:32:41.373Z" }, { url = "https://files.pythonhosted.org/packages/dc/85/65f14453804c82a7fba31cd1a984b90349c0f327b809102c4b99115c0930/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f3f601f32244a677c7b029ec39412db2772ad04a28bc2cbb4b1f0931ed0ffad7", size = 4516760, upload-time = "2025-12-06T17:32:44.921Z" }, { url = "https://files.pythonhosted.org/packages/24/8c/3105f00a91d73d9a443932f95156eae8159d5d9cb68a9d2cf512710d484f/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0ae60e910531cfcc364a8f615a7941cac89efeb3f0fffe0c4824a6d11461eef7", size = 4204028, upload-time = "2025-12-06T17:32:48.355Z" }, { url = "https://files.pythonhosted.org/packages/1e/dd/74f64a383342ef7c22d1eb2768ed86411c7f877ed2580cd33c17f436fe3c/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c43a773dd1a481dbb2fe64576aa303d80f328cce0eae5e3e4894947c41d1da7", size = 3935780, upload-time = "2025-12-06T17:32:51.347Z" }, { url = "https://files.pythonhosted.org/packages/85/30/f3f207d1c292949a26cdea6727c9c325b4ee41e04bf2736a4afbe45eb61f/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5a327327f1188b3fbecac41bf1973a60b86b2eb237db10dc945bd3dc97ec39e4", size = 4243239, upload-time = "2025-12-06T17:32:54.924Z" }, { url = "https://files.pythonhosted.org/packages/b3/08/8f1b5d6231338bf7bc46f635c4d4965facec52e1c9a7952ca8a70cb57dc0/psycopg_binary-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:136c43f185244893a527540307167f5d3ef4e08786508afe45d6f146228f5aa9", size = 3548102, upload-time = "2025-12-06T17:32:57.944Z" }, { url = "https://files.pythonhosted.org/packages/4e/1e/8614b01c549dd7e385dacdcd83fe194f6b3acb255a53cc67154ee6bf00e7/psycopg_binary-3.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9387ab615f929e71ef0f4a8a51e986fa06236ccfa9f3ec98a88f60fbf230634", size = 4579832, upload-time = "2025-12-06T17:33:01.388Z" }, { url = "https://files.pythonhosted.org/packages/26/97/0bb093570fae2f4454d42c1ae6000f15934391867402f680254e4a7def54/psycopg_binary-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3ff7489df5e06c12d1829544eaec64970fe27fe300f7cf04c8495fe682064688", size = 4658786, upload-time = "2025-12-06T17:33:05.022Z" }, { url = "https://files.pythonhosted.org/packages/61/20/1d9383e3f2038826900a14137b0647d755f67551aab316e1021443105ed5/psycopg_binary-3.3.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9742580ecc8e1ac45164e98d32ca6df90da509c2d3ff26be245d94c430f92db4", size = 5454896, upload-time = "2025-12-06T17:33:09.023Z" }, { url = "https://files.pythonhosted.org/packages/a6/62/513c80ad8bbb545e364f7737bf2492d34a4c05eef4f7b5c16428dc42260d/psycopg_binary-3.3.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d45acedcaa58619355f18e0f42af542fcad3fd84ace4b8355d3a5dea23318578", size = 5132731, upload-time = "2025-12-06T17:33:12.519Z" }, { url = "https://files.pythonhosted.org/packages/f3/28/ddf5f5905f088024bccb19857949467407c693389a14feb527d6171d8215/psycopg_binary-3.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d88f32ff8c47cb7f4e7e7a9d1747dcee6f3baa19ed9afa9e5694fd2fb32b61ed", size = 6724495, upload-time = "2025-12-06T17:33:16.624Z" }, { url = "https://files.pythonhosted.org/packages/6e/93/a1157ebcc650960b264542b547f7914d87a42ff0cc15a7584b29d5807e6b/psycopg_binary-3.3.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59d0163c4617a2c577cb34afbed93d7a45b8c8364e54b2bd2020ff25d5f5f860", size = 4964979, upload-time = "2025-12-06T17:33:20.179Z" }, { url = "https://files.pythonhosted.org/packages/0e/27/65939ba6798f9c5be4a5d9cd2061ebaf0851798525c6811d347821c8132d/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e750afe74e6c17b2c7046d2c3e3173b5a3f6080084671c8aa327215323df155b", size = 4493648, upload-time = "2025-12-06T17:33:23.464Z" }, { url = "https://files.pythonhosted.org/packages/8a/c4/5e9e4b9b1c1e27026e43387b0ba4aaf3537c7806465dd3f1d5bde631752a/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f26f113013c4dcfbfe9ced57b5bad2035dda1a7349f64bf726021968f9bccad3", size = 4173392, upload-time = "2025-12-06T17:33:26.88Z" }, { url = "https://files.pythonhosted.org/packages/c6/81/cf43fb76993190cee9af1cbcfe28afb47b1928bdf45a252001017e5af26e/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8309ee4569dced5e81df5aa2dcd48c7340c8dee603a66430f042dfbd2878edca", size = 3909241, upload-time = "2025-12-06T17:33:30.092Z" }, { url = "https://files.pythonhosted.org/packages/9d/20/c6377a0d17434674351627489deca493ea0b137c522b99c81d3a106372c8/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6464150e25b68ae3cb04c4e57496ea11ebfaae4d98126aea2f4702dd43e3c12", size = 4219746, upload-time = "2025-12-06T17:33:33.097Z" }, { url = "https://files.pythonhosted.org/packages/25/32/716c57b28eefe02a57a4c9d5bf956849597f5ea476c7010397199e56cfde/psycopg_binary-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:716a586f99bbe4f710dc58b40069fcb33c7627e95cc6fc936f73c9235e07f9cf", size = 3537494, upload-time = "2025-12-06T17:33:35.82Z" }, { url = "https://files.pythonhosted.org/packages/14/73/7ca7cb22b9ac7393fb5de7d28ca97e8347c375c8498b3bff2c99c1f38038/psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b", size = 4579068, upload-time = "2025-12-06T17:33:39.303Z" }, { url = "https://files.pythonhosted.org/packages/f5/42/0cf38ff6c62c792fc5b55398a853a77663210ebd51ed6f0c4a05b06f95a6/psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2", size = 4657520, upload-time = "2025-12-06T17:33:42.536Z" }, { url = "https://files.pythonhosted.org/packages/3b/60/df846bc84cbf2231e01b0fff48b09841fe486fa177665e50f4995b1bfa44/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f", size = 5452086, upload-time = "2025-12-06T17:33:46.54Z" }, { url = "https://files.pythonhosted.org/packages/ab/85/30c846a00db86b1b53fd5bfd4b4edfbd0c00de8f2c75dd105610bd7568fc/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d", size = 5131125, upload-time = "2025-12-06T17:33:50.413Z" }, { url = "https://files.pythonhosted.org/packages/6d/15/9968732013373f36f8a2a3fb76104dffc8efd9db78709caa5ae1a87b1f80/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e", size = 6722914, upload-time = "2025-12-06T17:33:54.544Z" }, { url = "https://files.pythonhosted.org/packages/b2/ba/29e361fe02143ac5ff5a1ca3e45697344cfbebe2eaf8c4e7eec164bff9a0/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed", size = 4966081, upload-time = "2025-12-06T17:33:58.477Z" }, { url = "https://files.pythonhosted.org/packages/99/45/1be90c8f1a1a237046903e91202fb06708745c179f220b361d6333ed7641/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30", size = 4493332, upload-time = "2025-12-06T17:34:02.011Z" }, { url = "https://files.pythonhosted.org/packages/2e/b5/bbdc07d5f0a5e90c617abd624368182aa131485e18038b2c6c85fc054aed/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d", size = 4170781, upload-time = "2025-12-06T17:34:05.298Z" }, { url = "https://files.pythonhosted.org/packages/d1/2a/0d45e4f4da2bd78c3237ffa03475ef3751f69a81919c54a6e610eb1a7c96/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da", size = 3910544, upload-time = "2025-12-06T17:34:08.251Z" }, { url = "https://files.pythonhosted.org/packages/3a/62/a8e0f092f4dbef9a94b032fb71e214cf0a375010692fbe7493a766339e47/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121", size = 4220070, upload-time = "2025-12-06T17:34:11.392Z" }, { url = "https://files.pythonhosted.org/packages/09/e6/5fc8d8aff8afa114bb4a94a0341b9309311e8bf3ab32d816032f8b984d4e/psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc", size = 3540922, upload-time = "2025-12-06T17:34:14.88Z" }, { url = "https://files.pythonhosted.org/packages/bd/75/ad18c0b97b852aba286d06befb398cc6d383e9dfd0a518369af275a5a526/psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390", size = 4596371, upload-time = "2025-12-06T17:34:18.007Z" }, { url = "https://files.pythonhosted.org/packages/5a/79/91649d94c8d89f84af5da7c9d474bfba35b08eb8f492ca3422b08f0a6427/psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443", size = 4675139, upload-time = "2025-12-06T17:34:21.374Z" }, { url = "https://files.pythonhosted.org/packages/56/ac/b26e004880f054549ec9396594e1ffe435810b0673e428e619ed722e4244/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9", size = 5456120, upload-time = "2025-12-06T17:34:25.102Z" }, { url = "https://files.pythonhosted.org/packages/4b/8d/410681dccd6f2999fb115cc248521ec50dd2b0aba66ae8de7e81efdebbee/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c", size = 5133484, upload-time = "2025-12-06T17:34:28.933Z" }, { url = "https://files.pythonhosted.org/packages/66/30/ebbab99ea2cfa099d7b11b742ce13415d44f800555bfa4ad2911dc645b71/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a", size = 6731818, upload-time = "2025-12-06T17:34:33.094Z" }, { url = "https://files.pythonhosted.org/packages/70/02/d260646253b7ad805d60e0de47f9b811d6544078452579466a098598b6f4/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d", size = 4983859, upload-time = "2025-12-06T17:34:36.457Z" }, { url = "https://files.pythonhosted.org/packages/72/8d/e778d7bad1a7910aa36281f092bd85c5702f508fd9bb0ea2020ffbb6585c/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0", size = 4516388, upload-time = "2025-12-06T17:34:40.129Z" }, { url = "https://files.pythonhosted.org/packages/bd/f1/64e82098722e2ab3521797584caf515284be09c1e08a872551b6edbb0074/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14", size = 4192382, upload-time = "2025-12-06T17:34:43.279Z" }, { url = "https://files.pythonhosted.org/packages/fa/d0/c20f4e668e89494972e551c31be2a0016e3f50d552d7ae9ac07086407599/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678", size = 3928660, upload-time = "2025-12-06T17:34:46.757Z" }, { url = "https://files.pythonhosted.org/packages/0f/e1/99746c171de22539fd5eb1c9ca21dc805b54cfae502d7451d237d1dbc349/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e", size = 4239169, upload-time = "2025-12-06T17:34:49.751Z" }, { url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pytest" version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] name = "pytest-asyncio" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] [[package]] name = "pytest-cov" version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] [[package]] name = "pytest-django" version = "4.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" }, ] [[package]] name = "pytest-mock" version = "3.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] [[package]] name = "pytest-snapshot" version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9b/7b/ab8f1fc1e687218aa66acec1c3674d9c443f6a2dc8cb6a50f464548ffa34/pytest-snapshot-0.9.0.tar.gz", hash = "sha256:c7013c3abc3e860f9feff899f8b4debe3708650d8d8242a61bf2625ff64db7f3", size = 19877, upload-time = "2022-04-23T17:35:31.751Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/29/518f32faf6edad9f56d6e0107217f7de6b79f297a47170414a2bd4be7f01/pytest_snapshot-0.9.0-py3-none-any.whl", hash = "sha256:4b9fe1c21c868fe53a545e4e3184d36bc1c88946e3f5c1d9dd676962a9b3d4ab", size = 10715, upload-time = "2022-04-23T17:35:30.288Z" }, ] [[package]] name = "pytest-watch" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, { name = "docopt" }, { name = "pytest" }, { name = "watchdog" }, ] sdist = { url = "https://files.pythonhosted.org/packages/36/47/ab65fc1d682befc318c439940f81a0de1026048479f732e84fe714cd69c0/pytest-watch-4.2.0.tar.gz", hash = "sha256:06136f03d5b361718b8d0d234042f7b2f203910d8568f63df2f866b547b3d4b9", size = 16340, upload-time = "2018-05-20T19:52:16.194Z" } [[package]] name = "pytest-xdist" version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "execnet" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] [[package]] name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "ruff" version = "0.14.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] [[package]] name = "setuptools" version = "80.9.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "sqlparse" version = "0.5.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] [[package]] name = "strawberry-graphql" version = "0.310.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cross-web" }, { name = "graphql-core" }, { name = "packaging" }, { name = "python-dateutil" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/65/8fc31347cd1a2809ea72ec4c354fc6996c79f64f0cd84df9707117045d2e/strawberry_graphql-0.310.1.tar.gz", hash = "sha256:bd01edc3016d8caea543dc8a1d2394d5257a87a36e9fbe8c3aedeeda7c2c8ec1", size = 211966, upload-time = "2026-03-08T14:00:46.91Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/42/f7/90f885565fb3fbc44c54bda17dd4c36a5c6d9b84ecfa7a6d49d30084cab3/strawberry_graphql-0.310.1-py3-none-any.whl", hash = "sha256:d6da1f16cab2b9cb4a9d5e0c196a33f98f76157d559e5afbb48e81348db163d0", size = 309316, upload-time = "2026-03-08T14:00:49.099Z" }, ] [[package]] name = "strawberry-graphql-django" version = "0.82.0" source = { editable = "." } dependencies = [ { name = "asgiref" }, { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "strawberry-graphql" }, ] [package.optional-dependencies] debug-toolbar = [ { name = "django-debug-toolbar" }, ] enum = [ { name = "django-choices-field" }, ] polymorphic = [ { name = "django-polymorphic" }, ] [package.dev-dependencies] dev = [ { name = "channels" }, { name = "django-choices-field" }, { name = "django-debug-toolbar" }, { name = "django-guardian" }, { name = "django-model-utils" }, { name = "django-polymorphic" }, { name = "django-tree-queries" }, { name = "django-types" }, { name = "factory-boy" }, { name = "pillow" }, { name = "psycopg" }, { name = "psycopg-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-django" }, { name = "pytest-mock" }, { name = "pytest-snapshot" }, { name = "pytest-watch" }, { name = "pytest-xdist" }, { name = "ruff" }, { name = "setuptools" }, ] [package.metadata] requires-dist = [ { name = "asgiref", specifier = ">=3.8" }, { name = "django", specifier = ">=4.2" }, { name = "django-choices-field", marker = "extra == 'enum'", specifier = ">=2.2.2" }, { name = "django-debug-toolbar", marker = "extra == 'debug-toolbar'", specifier = ">=6.0.0" }, { name = "django-polymorphic", marker = "extra == 'polymorphic'", specifier = ">=4.0.0" }, { name = "strawberry-graphql", specifier = ">=0.310.1" }, ] provides-extras = ["debug-toolbar", "enum", "polymorphic"] [package.metadata.requires-dev] dev = [ { name = "channels", specifier = ">=3.0.5" }, { name = "django-choices-field", specifier = ">=3.1.0" }, { name = "django-debug-toolbar", specifier = ">=6.0.0" }, { name = "django-guardian", specifier = ">=3.2.0" }, { name = "django-model-utils", specifier = ">=5.0.0" }, { name = "django-polymorphic", specifier = ">=4.0.0" }, { name = "django-tree-queries", specifier = ">=0.23.0" }, { name = "django-types", specifier = ">=0.22.0" }, { name = "factory-boy", specifier = ">=3.2.1" }, { name = "pillow", specifier = ">=12.0.0" }, { name = "psycopg", specifier = ">=3.2.10" }, { name = "psycopg-binary", specifier = ">=3.2.10" }, { name = "pytest", specifier = ">=9.0.1" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-django", specifier = ">=4.1.0" }, { name = "pytest-mock", specifier = ">=3.5.1" }, { name = "pytest-snapshot", specifier = ">=0.9.0" }, { name = "pytest-watch", specifier = ">=4.2.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = ">=0.14.0" }, { name = "setuptools", specifier = ">=80.1.0" }, ] [[package]] name = "tomli" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] [[package]] name = "types-psycopg2" version = "2.9.21.20251012" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9b/b3/2d09eaf35a084cffd329c584970a3fa07101ca465c13cad1576d7c392587/types_psycopg2-2.9.21.20251012.tar.gz", hash = "sha256:4cdafd38927da0cfde49804f39ab85afd9c6e9c492800e42f1f0c1a1b0312935", size = 26710, upload-time = "2025-10-12T02:55:39.5Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ec/0c/05feaf8cb51159f2c0af04b871dab7e98a2f83a3622f5f216331d2dd924c/types_psycopg2-2.9.21.20251012-py3-none-any.whl", hash = "sha256:712bad5c423fe979e357edbf40a07ca40ef775d74043de72bd4544ca328cc57e", size = 24883, upload-time = "2025-10-12T02:55:38.439Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "tzdata" version = "2025.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] [[package]] name = "watchdog" version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ]