├── .coveragerc ├── .flake8 ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── graphene_django_filter ├── __init__.py ├── conf.py ├── connection_field.py ├── filter_arguments_factory.py ├── filters.py ├── filterset.py ├── filterset_factories.py ├── input_data_factories.py └── input_types.py ├── manage.py ├── poetry.lock ├── pyproject.toml └── tests ├── .env.example ├── __init__.py ├── data_generation.py ├── filtersets.py ├── migrations ├── 0001_initial.py └── __init__.py ├── models.py ├── object_types.py ├── schema.py ├── settings.py ├── test_conf.py ├── test_connection_field.py ├── test_filter_arguments_factory.py ├── test_filters.py ├── test_filterset.py ├── test_input_data_factories.py └── test_queries_execution.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = 4 | graphene_django_filter 5 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | per-file-ignores = 4 | __init__.py:F401 5 | connection_field.py:A002 6 | test_filterset.py:N802 7 | filters.py:A003 8 | 0001_initial.py:D100,D101,D104 9 | ignore = ANN002,ANN003,ANN401,ANN101,ANN102,D106,D107 10 | 11 | import-order-style = pycharm 12 | dictionaries=en_US,python,technical,django 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "02:00" 8 | open-pull-requests-limit: 10 9 | 10 | - package-ecosystem: github-actions 11 | directory: "/" 12 | schedule: 13 | interval: daily 14 | time: "02:00" 15 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Controls when the workflow will run 4 | on: 5 | # Triggers the workflow on push or pull request events but only for the main branch 6 | push: 7 | branches: [main] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 13 | jobs: 14 | lint: 15 | name: Lint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | - name: Install Python 21 | uses: actions/setup-python@v3 22 | with: 23 | python-version: "3.10" 24 | - name: Setup Poetry 25 | uses: Gr1N/setup-poetry@v8 26 | - name: Install dependencies 27 | run: poetry install 28 | - name: Run linter 29 | run: poetry run flake8 30 | 31 | test: 32 | name: Test 33 | needs: [lint] 34 | runs-on: ubuntu-latest 35 | services: 36 | postgres: 37 | image: postgres 38 | env: 39 | POSTGRES_DB: graphene_django_filter 40 | POSTGRES_PASSWORD: postgres 41 | options: >- 42 | --health-cmd pg_isready 43 | --health-interval 10s 44 | --health-timeout 5s 45 | --health-retries 5 46 | ports: 47 | - 5432:5432 48 | steps: 49 | - name: Checkout 50 | uses: actions/checkout@v3 51 | - name: Install Python 52 | uses: actions/setup-python@v3 53 | with: 54 | python-version: "3.10" 55 | - name: Setup Poetry 56 | uses: Gr1N/setup-poetry@v8 57 | - name: Install dependencies 58 | run: poetry install 59 | - name: Run tests 60 | run: poetry run python manage.py test 61 | 62 | coverage: 63 | name: Coverage 64 | needs: [test] 65 | runs-on: ubuntu-latest 66 | services: 67 | postgres: 68 | image: postgres 69 | env: 70 | POSTGRES_DB: graphene_django_filter 71 | POSTGRES_PASSWORD: postgres 72 | options: >- 73 | --health-cmd pg_isready 74 | --health-interval 10s 75 | --health-timeout 5s 76 | --health-retries 5 77 | ports: 78 | - 5432:5432 79 | steps: 80 | - name: Checkout 81 | uses: actions/checkout@v3 82 | - name: Install Python 83 | uses: actions/setup-python@v3 84 | with: 85 | python-version: "3.10" 86 | - name: Setup Poetry 87 | uses: Gr1N/setup-poetry@v8 88 | - name: Install dependencies 89 | run: poetry install 90 | - name: Coverage 91 | run: poetry run coverage run manage.py test 92 | release: 93 | name: Release 94 | needs: [coverage] 95 | runs-on: ubuntu-latest 96 | permissions: 97 | contents: write 98 | id-token: write 99 | steps: 100 | - name: Checkout 101 | uses: actions/checkout@v3 102 | with: 103 | fetch-depth: 0 104 | # This action uses Python Semantic Release v8 105 | - name: Python Semantic Release 106 | id: release 107 | uses: python-semantic-release/python-semantic-release@v8.7.0 108 | with: 109 | github_token: ${{ secrets.GITHUB_TOKEN }} 110 | 111 | - name: Publish package distributions to PyPI 112 | uses: pypa/gh-action-pypi-publish@master 113 | # NOTE: DO NOT wrap the conditional in ${{ }} as it will always evaluate to true. 114 | # See https://github.com/actions/runner/issues/1173 115 | if: steps.release.outputs.released == 'true' 116 | 117 | - name: Publish package distributions to GitHub Releases 118 | uses: python-semantic-release/upload-to-gh-release@v8.7.0 119 | if: steps.release.outputs.released == 'true' 120 | with: 121 | github_token: ${{ secrets.GITHUB_TOKEN }} 122 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | tests/.env 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: check-added-large-files 8 | - id: check-toml 9 | - id: end-of-file-fixer 10 | - id: trailing-whitespace 11 | args: [--markdown-linebreak-ext=md] 12 | - repo: https://github.com/asottile/add-trailing-comma 13 | rev: v2.2.1 14 | hooks: 15 | - id: add-trailing-comma 16 | - repo: https://github.com/pycqa/flake8 17 | rev: 4.0.1 18 | hooks: 19 | - id: flake8 20 | additional_dependencies: 21 | - flake8-import-order 22 | - flake8-docstrings 23 | - flake8-builtins 24 | - flake8-quotes 25 | - flake8-comprehensions 26 | - flake8-eradicate 27 | - flake8-simplify 28 | - flake8-use-fstring 29 | - flake8-annotations 30 | - pep8-naming 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | 4 | 5 | ## v0.6.7 (2025-04-06) 6 | 7 | ### Chore 8 | 9 | * chore: Remove coveralls ([`ce009bb`](https://github.com/devind-team/graphene-django-filter/commit/ce009bbddc7eb0755ebbffe9e9cc672d460c29db)) 10 | 11 | ### Unknown 12 | 13 | * Merge pull request #83 from lablup/fix/resolve-potential-none-filter-arg 14 | 15 | fix: resolve potential `None` value for filter argument ([`52c8486`](https://github.com/devind-team/graphene-django-filter/commit/52c848694a73c03ce1959426164527035285916b)) 16 | 17 | 18 | ## v0.6.6 (2025-04-02) 19 | 20 | ### Chore 21 | 22 | * chore(deps): Bump django-filter to >=21.1 ([`898a77c`](https://github.com/devind-team/graphene-django-filter/commit/898a77c4ae1ddf3ca2fd93eedce97adb48daf422)) 23 | 24 | * chore(deps): Bump dependency on django to <5.2.0 ([`69b93db`](https://github.com/devind-team/graphene-django-filter/commit/69b93dbe063324cee1856f06e1c8b7a8017cf3ee)) 25 | 26 | ### Fix 27 | 28 | * fix: fix ci ([`8c387cd`](https://github.com/devind-team/graphene-django-filter/commit/8c387cd2a81b6d61879ba42f67e25513784da1de)) 29 | 30 | * fix: fix ci ([`d1b366a`](https://github.com/devind-team/graphene-django-filter/commit/d1b366ac681ee5e4702ec32a6b8c1ed90220c04a)) 31 | 32 | * fix: fix dependency versions ([`57de2f4`](https://github.com/devind-team/graphene-django-filter/commit/57de2f4a3d7192606d9c755ed9350f01cd0fd225)) 33 | 34 | * fix: Discard redundant changes ([`623aed7`](https://github.com/devind-team/graphene-django-filter/commit/623aed7b39c00d3ab7970f4d545802d2a5ff78f1)) 35 | 36 | ### Unknown 37 | 38 | * Merge pull request #95 from devind-team/develop 39 | 40 | fix: fix ci ([`152e767`](https://github.com/devind-team/graphene-django-filter/commit/152e7672e000fb212e7e0cba9fad9aa98bde5270)) 41 | 42 | * Merge pull request #94 from devind-team/develop 43 | 44 | Develop ([`ec6f3e2`](https://github.com/devind-team/graphene-django-filter/commit/ec6f3e2ba34ac245d85b98a4d53ee52a028fc630)) 45 | 46 | 47 | ## v0.6.5 (2023-06-10) 48 | 49 | ### Chore 50 | 51 | * chore(deps-dev): bump pre-commit from 2.18.1 to 2.19.0 52 | 53 | Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.18.1 to 2.19.0. 54 | - [Release notes](https://github.com/pre-commit/pre-commit/releases) 55 | - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) 56 | - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.18.1...v2.19.0) 57 | 58 | --- 59 | updated-dependencies: 60 | - dependency-name: pre-commit 61 | dependency-type: direct:development 62 | update-type: version-update:semver-minor 63 | ... 64 | 65 | Signed-off-by: dependabot[bot] <support@github.com> ([`ef974f7`](https://github.com/devind-team/graphene-django-filter/commit/ef974f7fb320672b02f1a4d17e4abed6d25ac3c6)) 66 | 67 | * chore(deps): bump wrapt from 1.14.0 to 1.14.1 68 | 69 | Bumps [wrapt](https://github.com/GrahamDumpleton/wrapt) from 1.14.0 to 1.14.1. 70 | - [Release notes](https://github.com/GrahamDumpleton/wrapt/releases) 71 | - [Changelog](https://github.com/GrahamDumpleton/wrapt/blob/develop/docs/changes.rst) 72 | - [Commits](https://github.com/GrahamDumpleton/wrapt/compare/1.14.0...1.14.1) 73 | 74 | --- 75 | updated-dependencies: 76 | - dependency-name: wrapt 77 | dependency-type: direct:production 78 | update-type: version-update:semver-patch 79 | ... 80 | 81 | Signed-off-by: dependabot[bot] <support@github.com> ([`aa6b28a`](https://github.com/devind-team/graphene-django-filter/commit/aa6b28af8cb50d8f7320eec18feaf6817b318eb1)) 82 | 83 | * chore(deps-dev): bump flake8-eradicate from 1.2.0 to 1.2.1 84 | 85 | Bumps [flake8-eradicate](https://github.com/wemake-services/flake8-eradicate) from 1.2.0 to 1.2.1. 86 | - [Release notes](https://github.com/wemake-services/flake8-eradicate/releases) 87 | - [Changelog](https://github.com/wemake-services/flake8-eradicate/blob/master/CHANGELOG.md) 88 | - [Commits](https://github.com/wemake-services/flake8-eradicate/compare/1.2.0...1.2.1) 89 | 90 | --- 91 | updated-dependencies: 92 | - dependency-name: flake8-eradicate 93 | dependency-type: direct:development 94 | update-type: version-update:semver-patch 95 | ... 96 | 97 | Signed-off-by: dependabot[bot] <support@github.com> ([`8e02506`](https://github.com/devind-team/graphene-django-filter/commit/8e02506e40ae949774496bb5825fca23b3526ee6)) 98 | 99 | * chore(deps-dev): bump flake8-annotations from 2.8.0 to 2.9.0 100 | 101 | Bumps [flake8-annotations](https://github.com/sco1/flake8-annotations) from 2.8.0 to 2.9.0. 102 | - [Release notes](https://github.com/sco1/flake8-annotations/releases) 103 | - [Changelog](https://github.com/sco1/flake8-annotations/blob/main/CHANGELOG.md) 104 | - [Commits](https://github.com/sco1/flake8-annotations/compare/v2.8.0...v2.9.0) 105 | 106 | --- 107 | updated-dependencies: 108 | - dependency-name: flake8-annotations 109 | dependency-type: direct:development 110 | update-type: version-update:semver-minor 111 | ... 112 | 113 | Signed-off-by: dependabot[bot] <support@github.com> ([`9b25372`](https://github.com/devind-team/graphene-django-filter/commit/9b25372dabe19318a457c16f5f0f1fdad1565955)) 114 | 115 | * chore(deps): bump django from 3.2.12 to 3.2.13 116 | 117 | Bumps [django](https://github.com/django/django) from 3.2.12 to 3.2.13. 118 | - [Release notes](https://github.com/django/django/releases) 119 | - [Commits](https://github.com/django/django/compare/3.2.12...3.2.13) 120 | 121 | --- 122 | updated-dependencies: 123 | - dependency-name: django 124 | dependency-type: direct:production 125 | update-type: version-update:semver-patch 126 | ... 127 | 128 | Signed-off-by: dependabot[bot] <support@github.com> ([`24ad5d8`](https://github.com/devind-team/graphene-django-filter/commit/24ad5d848d2aaef7835e2124e8617f25af7ab656)) 129 | 130 | ### Fix 131 | 132 | * fix: fix project version back to original ([`ae6d34a`](https://github.com/devind-team/graphene-django-filter/commit/ae6d34a823bed0c5617c96349ecbd72f8de3a87b)) 133 | 134 | * fix: Empty dict rather than `None` when filter is missing ([`d94bc5c`](https://github.com/devind-team/graphene-django-filter/commit/d94bc5c35031d5f5f07b511fcf2c0f27ec44f4f6)) 135 | 136 | ### Unknown 137 | 138 | * Merge pull request #77 from devind-team/develop 139 | 140 | fix: fix project version back to original ([`e8243cb`](https://github.com/devind-team/graphene-django-filter/commit/e8243cb44a005e8867cf3f13b35ba3eef6694e98)) 141 | 142 | * Merge pull request #74 from devind-team/develop 143 | 144 | Fix CI GitHub action ([`56de0ad`](https://github.com/devind-team/graphene-django-filter/commit/56de0ad68d40f515b8816d881751089ba747ce4e)) 145 | 146 | * Fix C417 error during Lint GitHub action ([`949271f`](https://github.com/devind-team/graphene-django-filter/commit/949271fe7f83b6f4d9e6fb911d578100f8657a53)) 147 | 148 | * Fix python version for ci action ([`0e772df`](https://github.com/devind-team/graphene-django-filter/commit/0e772df21c47bc3ce5cbae7a193b50f8dddb8042)) 149 | 150 | * Fix C419 flake8 error ([`da1aa58`](https://github.com/devind-team/graphene-django-filter/commit/da1aa5808590a03530b38abe7d7aec70dbb310f6)) 151 | 152 | * Merge pull request #73 from devind-team/develop 153 | 154 | Upgrade version of python, Django, graphene, graphene-django ([`78f9c6a`](https://github.com/devind-team/graphene-django-filter/commit/78f9c6aee76c0e6632b28caafa83711c68ad9d17)) 155 | 156 | * Update Django version to <4.3 ([`123acaf`](https://github.com/devind-team/graphene-django-filter/commit/123acaf41e0aea724c26ef1522ad60c7e6f3c4c3)) 157 | 158 | * Bump up main version ([`1b4442b`](https://github.com/devind-team/graphene-django-filter/commit/1b4442bdd9669a26ce521ce09f5c7800c40f716a)) 159 | 160 | * Upgrade version range for Python, Django, graphene, graphene-django ([`d51ef74`](https://github.com/devind-team/graphene-django-filter/commit/d51ef74503a12c1bdd9ac95d4e4daea48cf5b4c7)) 161 | 162 | * Bump to 0.6.5 ([`5b67df9`](https://github.com/devind-team/graphene-django-filter/commit/5b67df9db7630b6d242672cf67f21af5765b6eba)) 163 | 164 | * deps: Bump django<=5.0.0 graphene>=2.1.9 graphene-django>=2.15.0 ([`b914d0d`](https://github.com/devind-team/graphene-django-filter/commit/b914d0d2301522733262e15a8127b78f6dc5ade7)) 165 | 166 | * Merge pull request #42 from devind-team/dependabot/pip/pre-commit-2.19.0 167 | 168 | chore(deps-dev): bump pre-commit from 2.18.1 to 2.19.0 ([`7e6fe72`](https://github.com/devind-team/graphene-django-filter/commit/7e6fe720f3e28be30828ef56aaeeb0a94be8b92c)) 169 | 170 | * Merge pull request #40 from devind-team/dependabot/pip/flake8-eradicate-1.2.1 171 | 172 | chore(deps-dev): bump flake8-eradicate from 1.2.0 to 1.2.1 ([`4f4ddf6`](https://github.com/devind-team/graphene-django-filter/commit/4f4ddf6b3510e60ade9c51c2980e1fabbe0de50a)) 173 | 174 | * Merge pull request #41 from devind-team/dependabot/pip/wrapt-1.14.1 175 | 176 | chore(deps): bump wrapt from 1.14.0 to 1.14.1 ([`352f596`](https://github.com/devind-team/graphene-django-filter/commit/352f5965dac6ad4c9d09fe3ac8c1700ecb495289)) 177 | 178 | * Merge pull request #38 from devind-team/dependabot/pip/flake8-annotations-2.9.0 179 | 180 | chore(deps-dev): bump flake8-annotations from 2.8.0 to 2.9.0 ([`05f4ef6`](https://github.com/devind-team/graphene-django-filter/commit/05f4ef625fad198f8b8c7e05fdb2fda2e52b55b2)) 181 | 182 | * Merge pull request #37 from devind-team/dependabot/pip/django-3.2.13 183 | 184 | chore(deps): bump django from 3.2.12 to 3.2.13 ([`9368227`](https://github.com/devind-team/graphene-django-filter/commit/936822743e67dfd59dfac44a34659042d076b1e9)) 185 | 186 | * Merge pull request #34 from devind-team/dependabot/pip/pre-commit-2.18.1 187 | 188 | chore(deps-dev): bump pre-commit from 2.17.0 to 2.18.1 ([`cb89305`](https://github.com/devind-team/graphene-django-filter/commit/cb8930579ddcb01027fae00aaedbac6e96b8ca1d)) 189 | 190 | 191 | ## v0.6.4 (2022-04-04) 192 | 193 | ### Chore 194 | 195 | * chore(deps-dev): bump pre-commit from 2.17.0 to 2.18.1 196 | 197 | Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.17.0 to 2.18.1. 198 | - [Release notes](https://github.com/pre-commit/pre-commit/releases) 199 | - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) 200 | - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.17.0...v2.18.1) 201 | 202 | --- 203 | updated-dependencies: 204 | - dependency-name: pre-commit 205 | dependency-type: direct:development 206 | update-type: version-update:semver-minor 207 | ... 208 | 209 | Signed-off-by: dependabot[bot] <support@github.com> ([`9172dae`](https://github.com/devind-team/graphene-django-filter/commit/9172dae3233895d8278f1dd08743456e9c3e6f0f)) 210 | 211 | ### Fix 212 | 213 | * fix: fix using newer python features ([`06891bb`](https://github.com/devind-team/graphene-django-filter/commit/06891bb8eaa2967af3582976cc8a0c187890088e)) 214 | 215 | * fix: fix different types with the same name in the schema ([`5bdb9df`](https://github.com/devind-team/graphene-django-filter/commit/5bdb9dfaf4361fad7ddf5ac87bf4352ec9365f51)) 216 | 217 | ### Unknown 218 | 219 | * Merge pull request #36 from devind-team/32-different-types-with-the-same-name-in-the-schema 220 | 221 | fix: fix using newer python features ([`22826b5`](https://github.com/devind-team/graphene-django-filter/commit/22826b5f7446de7c59d9d5d04f2386cb743b8581)) 222 | 223 | * Merge pull request #35 from devind-team/32-different-types-with-the-same-name-in-the-schema 224 | 225 | fix: fix different types with the same name in the schema ([`8af2c10`](https://github.com/devind-team/graphene-django-filter/commit/8af2c10e98bdc3ee2164eea40257c26d7cd5337e)) 226 | 227 | 228 | ## v0.6.3 (2022-03-31) 229 | 230 | ### Chore 231 | 232 | * chore: ignore dynamically typed expressions ([`a4ee623`](https://github.com/devind-team/graphene-django-filter/commit/a4ee6231fd8e90e2c243c32d9eeba8ebcf4e5018)) 233 | 234 | * chore: fix and update dependencies ([`7f9e0a6`](https://github.com/devind-team/graphene-django-filter/commit/7f9e0a636cd4b10870ca337df1906f8cea8113f8)) 235 | 236 | * chore(deps-dev): bump python-dotenv from 0.19.2 to 0.20.0 237 | 238 | Bumps [python-dotenv](https://github.com/theskumar/python-dotenv) from 0.19.2 to 0.20.0. 239 | - [Release notes](https://github.com/theskumar/python-dotenv/releases) 240 | - [Changelog](https://github.com/theskumar/python-dotenv/blob/master/CHANGELOG.md) 241 | - [Commits](https://github.com/theskumar/python-dotenv/compare/v0.19.2...v0.20.0) 242 | 243 | --- 244 | updated-dependencies: 245 | - dependency-name: python-dotenv 246 | dependency-type: direct:development 247 | update-type: version-update:semver-minor 248 | ... 249 | 250 | Signed-off-by: dependabot[bot] <support@github.com> ([`2d418ca`](https://github.com/devind-team/graphene-django-filter/commit/2d418ca9266cc2ddc621aced9cd85d3067573f23)) 251 | 252 | * chore(deps): bump wrapt from 1.13.3 to 1.14.0 253 | 254 | Bumps [wrapt](https://github.com/GrahamDumpleton/wrapt) from 1.13.3 to 1.14.0. 255 | - [Release notes](https://github.com/GrahamDumpleton/wrapt/releases) 256 | - [Changelog](https://github.com/GrahamDumpleton/wrapt/blob/develop/docs/changes.rst) 257 | - [Commits](https://github.com/GrahamDumpleton/wrapt/compare/1.13.3...1.14.0) 258 | 259 | --- 260 | updated-dependencies: 261 | - dependency-name: wrapt 262 | dependency-type: direct:production 263 | update-type: version-update:semver-minor 264 | ... 265 | 266 | Signed-off-by: dependabot[bot] <support@github.com> ([`18024c4`](https://github.com/devind-team/graphene-django-filter/commit/18024c43c4f740aec6c506e80106c16c3b218638)) 267 | 268 | ### Fix 269 | 270 | * fix: fix wrong typing for the AdvancedDjangoFilterConnectionField constructor ([`2d2143d`](https://github.com/devind-team/graphene-django-filter/commit/2d2143d195d4b0daf89fffa01a8c495e8051187d)) 271 | 272 | ### Unknown 273 | 274 | * Merge pull request #31 from devind-team/28-wrong-typing-for-the-advanceddjangofilterconnectionfield-constructor 275 | 276 | 28 wrong typing for the advanceddjangofilterconnectionfield constructor ([`91773f0`](https://github.com/devind-team/graphene-django-filter/commit/91773f065d0a45a7c316339befb22661cea40229)) 277 | 278 | * Merge pull request #30 from devind-team/27-unnecessary-dependency 279 | 280 | Fix and update dependencies ([`8bdac29`](https://github.com/devind-team/graphene-django-filter/commit/8bdac290f0b5de32ceb66684a2fb64a50e985953)) 281 | 282 | * Merge pull request #22 from devind-team/dependabot/pip/wrapt-1.14.0 283 | 284 | chore(deps): bump wrapt from 1.13.3 to 1.14.0 ([`e9f023a`](https://github.com/devind-team/graphene-django-filter/commit/e9f023ac64cf9769b168ecf5eaea1b115f64f9ce)) 285 | 286 | * Merge pull request #23 from devind-team/dependabot/pip/python-dotenv-0.20.0 287 | 288 | chore(deps-dev): bump python-dotenv from 0.19.2 to 0.20.0 ([`140f293`](https://github.com/devind-team/graphene-django-filter/commit/140f293e7f2d13341412e1ae4158c30fe827c8ed)) 289 | 290 | 291 | ## v0.6.2 (2022-03-08) 292 | 293 | ### Chore 294 | 295 | * chore(deps-dev): bump flake8-simplify from 0.14.5 to 0.18.1 296 | 297 | Bumps [flake8-simplify](https://github.com/MartinThoma/flake8-simplify) from 0.14.5 to 0.18.1. 298 | - [Release notes](https://github.com/MartinThoma/flake8-simplify/releases) 299 | - [Changelog](https://github.com/MartinThoma/flake8-simplify/blob/master/CHANGELOG.md) 300 | - [Commits](https://github.com/MartinThoma/flake8-simplify/commits) 301 | 302 | --- 303 | updated-dependencies: 304 | - dependency-name: flake8-simplify 305 | dependency-type: direct:development 306 | update-type: version-update:semver-minor 307 | ... 308 | 309 | Signed-off-by: dependabot[bot] <support@github.com> ([`05a0808`](https://github.com/devind-team/graphene-django-filter/commit/05a080876e71d2f468afed68b30912e3146c7575)) 310 | 311 | * chore(deps): bump actions/setup-python from 2 to 3 312 | 313 | Bumps [actions/setup-python](https://github.com/actions/setup-python) from 2 to 3. 314 | - [Release notes](https://github.com/actions/setup-python/releases) 315 | - [Commits](https://github.com/actions/setup-python/compare/v2...v3) 316 | 317 | --- 318 | updated-dependencies: 319 | - dependency-name: actions/setup-python 320 | dependency-type: direct:production 321 | update-type: version-update:semver-major 322 | ... 323 | 324 | Signed-off-by: dependabot[bot] <support@github.com> ([`e63609d`](https://github.com/devind-team/graphene-django-filter/commit/e63609df3ac282045bdf91b0dbd71969cfc2e669)) 325 | 326 | * chore(deps-dev): bump flake8-comprehensions from 3.7.0 to 3.8.0 327 | 328 | Bumps [flake8-comprehensions](https://github.com/adamchainz/flake8-comprehensions) from 3.7.0 to 3.8.0. 329 | - [Release notes](https://github.com/adamchainz/flake8-comprehensions/releases) 330 | - [Changelog](https://github.com/adamchainz/flake8-comprehensions/blob/main/HISTORY.rst) 331 | - [Commits](https://github.com/adamchainz/flake8-comprehensions/compare/3.7.0...3.8.0) 332 | 333 | --- 334 | updated-dependencies: 335 | - dependency-name: flake8-comprehensions 336 | dependency-type: direct:development 337 | update-type: version-update:semver-minor 338 | ... 339 | 340 | Signed-off-by: dependabot[bot] <support@github.com> ([`1332389`](https://github.com/devind-team/graphene-django-filter/commit/13323894b4e5b800f517a4767c9e308d47196d3e)) 341 | 342 | ### Fix 343 | 344 | * fix: fix bug when filter is missing ([`2c7485f`](https://github.com/devind-team/graphene-django-filter/commit/2c7485fb52baef60b3056e92894ec04e1d5562b5)) 345 | 346 | * fix: fix wrong env loading ([`1c3fe7b`](https://github.com/devind-team/graphene-django-filter/commit/1c3fe7ba1b45ec318a47db4ea8a6e36f1a241e6a)) 347 | 348 | ### Refactor 349 | 350 | * refactor: simplify queries execution tests ([`47da618`](https://github.com/devind-team/graphene-django-filter/commit/47da6180b639d42e3a669552ce3b7b8a152b42a6)) 351 | 352 | ### Unknown 353 | 354 | * Merge pull request #21 from devind-team/develop 355 | 356 | Bug fixing and refactoring ([`24dd752`](https://github.com/devind-team/graphene-django-filter/commit/24dd752d6b7bc5a26e51cd0c0580194f22ca933f)) 357 | 358 | * Merge pull request #15 from devind-team/dependabot/pip/flake8-simplify-0.18.1 359 | 360 | chore(deps-dev): bump flake8-simplify from 0.14.5 to 0.18.1 ([`c89a0b9`](https://github.com/devind-team/graphene-django-filter/commit/c89a0b9ec35756128eb471f25a4f853658cfc0d5)) 361 | 362 | * Merge pull request #16 from devind-team/dependabot/github_actions/actions/setup-python-3 363 | 364 | chore(deps): bump actions/setup-python from 2 to 3 ([`198ea8f`](https://github.com/devind-team/graphene-django-filter/commit/198ea8f28e8f0db180fe37ea4bb49261070f777f)) 365 | 366 | * Merge pull request #20 from devind-team/dependabot/pip/flake8-comprehensions-3.8.0 367 | 368 | chore(deps-dev): bump flake8-comprehensions from 3.7.0 to 3.8.0 ([`d9c10f4`](https://github.com/devind-team/graphene-django-filter/commit/d9c10f4c8b898ab3f6b72e2901f8134966a06de7)) 369 | 370 | * Merge pull request #17 from devind-team/dependabot/github_actions/actions/checkout-3 371 | 372 | chore(deps): bump actions/checkout from 2 to 3 ([`0af68a7`](https://github.com/devind-team/graphene-django-filter/commit/0af68a7c4ff7df8ba0ed56a0f6e48e351bb2b400)) 373 | 374 | 375 | ## v0.6.1 (2022-03-03) 376 | 377 | ### Fix 378 | 379 | * fix: update README.md ([`6bcb8de`](https://github.com/devind-team/graphene-django-filter/commit/6bcb8de25a11dd75c7f026e898a853599e7c0425)) 380 | 381 | * fix: update README.md ([`43dbfb9`](https://github.com/devind-team/graphene-django-filter/commit/43dbfb934ae755ef104c8c9ece9883f0f25f23ff)) 382 | 383 | ### Unknown 384 | 385 | * Merge branch 'develop' into main ([`ddbb24b`](https://github.com/devind-team/graphene-django-filter/commit/ddbb24bfd1ce6043f10253145c121fdd343fd33e)) 386 | 387 | * Merge branch 'develop' into main ([`5c43140`](https://github.com/devind-team/graphene-django-filter/commit/5c43140b9d015b3f3dcde2b19a71ed9f9f5cbcb5)) 388 | 389 | 390 | ## v0.6.0 (2022-03-03) 391 | 392 | ### Chore 393 | 394 | * chore(deps): bump actions/checkout from 2 to 3 395 | 396 | Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3. 397 | - [Release notes](https://github.com/actions/checkout/releases) 398 | - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) 399 | - [Commits](https://github.com/actions/checkout/compare/v2...v3) 400 | 401 | --- 402 | updated-dependencies: 403 | - dependency-name: actions/checkout 404 | dependency-type: direct:production 405 | update-type: version-update:semver-major 406 | ... 407 | 408 | Signed-off-by: dependabot[bot] <support@github.com> ([`c69045f`](https://github.com/devind-team/graphene-django-filter/commit/c69045f76af7434e88c8a523908cc778655e1296)) 409 | 410 | ### Documentation 411 | 412 | * docs: update README.md ([`d8507c4`](https://github.com/devind-team/graphene-django-filter/commit/d8507c4db98f63e2e90a0831465e26230f72455b)) 413 | 414 | ### Feature 415 | 416 | * feat: add tests coverage ([`1463fc8`](https://github.com/devind-team/graphene-django-filter/commit/1463fc8705e26efb5745bded2754b22e38193429)) 417 | 418 | * feat: add tests for executing queries with full text search ([`e63825b`](https://github.com/devind-team/graphene-django-filter/commit/e63825b4ecb6b01ca5d9768d0f5ba7b75cafecda)) 419 | 420 | * feat: make the search query value optional ([`8583f8e`](https://github.com/devind-team/graphene-django-filter/commit/8583f8e065eafec8346e71b4c9b2c962222b1440)) 421 | 422 | * feat: add the pre-commit dependency. ([`9a0e579`](https://github.com/devind-team/graphene-django-filter/commit/9a0e57909e4061750c2c760d2f5b85f2df9b46a0)) 423 | 424 | * feat: add a filter counter to generate a unique annotation name. ([`5d7dd23`](https://github.com/devind-team/graphene-django-filter/commit/5d7dd23e2b365f7f6cd5d77bcf72db67dc7a6845)) 425 | 426 | ### Fix 427 | 428 | * fix: create PostgreSQL service container ([`71d9b13`](https://github.com/devind-team/graphene-django-filter/commit/71d9b13155d6dd270e1c0964ad0cddcaa7e4d43d)) 429 | 430 | * fix: fix InputObjectType classes for special lookups ([`2683d00`](https://github.com/devind-team/graphene-django-filter/commit/2683d00529dbb897464b60e3da4bbfdc4e74e123)) 431 | 432 | * fix: remove default lookup from full text search keys ([`ca26f0f`](https://github.com/devind-team/graphene-django-filter/commit/ca26f0f12dc7b52ee4cfe34cbc8b16dc6342daee)) 433 | 434 | * fix: change QuerySetProxy to be able to use other QuerySet methods ([`5823820`](https://github.com/devind-team/graphene-django-filter/commit/582382032f2cafb93a5e6c28b5ddaf6348a3f7ec)) 435 | 436 | ### Unknown 437 | 438 | * Merge branch 'develop' into main ([`ba55abc`](https://github.com/devind-team/graphene-django-filter/commit/ba55abc3e28f116532cbb36f28ab44da63258af0)) 439 | 440 | * Merge pull request #19 from devind-team/develop 441 | 442 | Full text search ([`b31d3f2`](https://github.com/devind-team/graphene-django-filter/commit/b31d3f242ceb88afff4c3f62340770f374caa5bd)) 443 | 444 | * Merge branch 'main' into develop ([`84b7802`](https://github.com/devind-team/graphene-django-filter/commit/84b78022f6bf3d08db1547b55f3abad153c3f4a9)) 445 | 446 | 447 | ## v0.5.1 (2022-02-22) 448 | 449 | ### Chore 450 | 451 | * chore: Added description file for dependabot ([`27afdd2`](https://github.com/devind-team/graphene-django-filter/commit/27afdd2fba82d3494d02a33ecde213a17fec2986)) 452 | 453 | ### Feature 454 | 455 | * feat: add input data factories 456 | 457 | These factories are designed for converting tree data into data suitable for the FilterSet. ([`8459a75`](https://github.com/devind-team/graphene-django-filter/commit/8459a7510d36b6c29b7dc8c79d1b72736e10668f)) 458 | 459 | * feat: extend InputObjectType classes for special lookups ([`59efd43`](https://github.com/devind-team/graphene-django-filter/commit/59efd43448c2a40d1d299b0fb77b81b7b9abc1c9)) 460 | 461 | * feat: extend InputObjectType classes for special lookups ([`1ba8169`](https://github.com/devind-team/graphene-django-filter/commit/1ba8169af317c34c39f1abb9a7385a50c5fc12d3)) 462 | 463 | * feat: add full text search filters to the input types generation ([`dde0799`](https://github.com/devind-team/graphene-django-filter/commit/dde0799315f4b1b264253daa0920825cd6bc0068)) 464 | 465 | * feat: change generation of full text search filters ([`e32301f`](https://github.com/devind-team/graphene-django-filter/commit/e32301fc52787e5f10917a4f6a442e29380c760e)) 466 | 467 | * feat: set fixed descriptions ([`f4effe7`](https://github.com/devind-team/graphene-django-filter/commit/f4effe75a749f351b238c06ee9f6e5cd18944d3e)) 468 | 469 | * feat: add InputObjectType classes for special lookups ([`3cd55ac`](https://github.com/devind-team/graphene-django-filter/commit/3cd55ac1743e6db2b62fe01dba5a9ad5174ccd57)) 470 | 471 | * feat: override the `get_filters` method of the `AdvancedFilterSet` class 472 | 473 | This method returns regular and special filters. ([`f337349`](https://github.com/devind-team/graphene-django-filter/commit/f337349299d5cdba4320c8b00b6364d7afe4ad8d)) 474 | 475 | * feat: add the `available_lookups` field to full text search filter classes ([`abf10ae`](https://github.com/devind-team/graphene-django-filter/commit/abf10aea2987145f1a142c4b3100b2f50869f7b6)) 476 | 477 | * feat: extend fixed settings ([`b03c09e`](https://github.com/devind-team/graphene-django-filter/commit/b03c09e79ec3ca81c82210e6508bdf1d7d1d1b43)) 478 | 479 | * feat: add filters for the full text search ([`adb87b8`](https://github.com/devind-team/graphene-django-filter/commit/adb87b8cfea79d79768f691090a2221d551846e9)) 480 | 481 | * feat: replace sqlite database with postgresql for testing ([`56c764e`](https://github.com/devind-team/graphene-django-filter/commit/56c764e9036afb4554c2e3f980510fdb4d908e1f)) 482 | 483 | * feat: add library settings ([`9b491d2`](https://github.com/devind-team/graphene-django-filter/commit/9b491d24e957d6e1c944e7b1e3c72cc522fe2c6a)) 484 | 485 | * feat: add the separation of lookups into regular and special ([`d04386d`](https://github.com/devind-team/graphene-django-filter/commit/d04386d65d0feaf14417158f69d8be79a73922d2)) 486 | 487 | ### Fix 488 | 489 | * fix: fix Django version ([`2a3a977`](https://github.com/devind-team/graphene-django-filter/commit/2a3a9772a4e458a435d5eca08c511dbb83cd2b65)) 490 | 491 | * fix: Update django dependency and add how to install information #13 ([`9e15745`](https://github.com/devind-team/graphene-django-filter/commit/9e15745836dc5e4bf532cde918b566dbfca3f9e9)) 492 | 493 | * fix: fix InputObjectType classes for special lookups ([`5909798`](https://github.com/devind-team/graphene-django-filter/commit/5909798d9ec7279d321ee215ab0a8802be6525c3)) 494 | 495 | * fix: fix the logic for getting settings ([`80e088c`](https://github.com/devind-team/graphene-django-filter/commit/80e088c309cb049aedf5967deac16a81ed67c138)) 496 | 497 | ### Refactor 498 | 499 | * refactor: change names for filter classes ([`1fdd6a2`](https://github.com/devind-team/graphene-django-filter/commit/1fdd6a233b1726c0e50523ccc36d107af9534b12)) 500 | 501 | ### Unknown 502 | 503 | * Merge pull request #14 from devind-team/luferov/dependency 504 | 505 | fix: Update django dependency and add how to install information #13 ([`954277f`](https://github.com/devind-team/graphene-django-filter/commit/954277f265ec9ebe9e2f73eb76575c010cc5f924)) 506 | 507 | * Merge pull request #8 from devind-team/luferov/dependabot 508 | 509 | chore: Added description file for dependabot ([`a925ae9`](https://github.com/devind-team/graphene-django-filter/commit/a925ae96fad5512e5430cbfb2c1cefd468f20cd8)) 510 | 511 | 512 | ## v0.5.0 (2022-02-11) 513 | 514 | ### Documentation 515 | 516 | * docs: update README.md ([`b8d2271`](https://github.com/devind-team/graphene-django-filter/commit/b8d2271f87b398640a7711c944d6bd6621cb5a12)) 517 | 518 | * docs: fix README.md ([`6cf4724`](https://github.com/devind-team/graphene-django-filter/commit/6cf4724c3fd4ee4fd67a6ba605852a1eacbca50a)) 519 | 520 | ### Feature 521 | 522 | * feat: add the implementation of the `not` operator ([`00dae57`](https://github.com/devind-team/graphene-django-filter/commit/00dae57256c7bf37d4834e03d6cc9bd2d787f8f7)) 523 | 524 | * feat: start the implementation of the expression `not` ([`ef0ec97`](https://github.com/devind-team/graphene-django-filter/commit/ef0ec97813e5a543b1311d8aa2cd5dfd70f6bfa2)) 525 | 526 | * feat: improve input type factories ([`353b4a3`](https://github.com/devind-team/graphene-django-filter/commit/353b4a33d25fef7154d15008976b1ad408474f97)) 527 | 528 | ### Test 529 | 530 | * test: add the `not` operator to schema tests ([`7091fa0`](https://github.com/devind-team/graphene-django-filter/commit/7091fa0d8ac1e3cfcb8cc6ffa8d7d8a613f98a94)) 531 | 532 | * test: fix bounded test of the `filter_queryset` method ([`52a1c1c`](https://github.com/devind-team/graphene-django-filter/commit/52a1c1cb1356a692b54203452116d6f80dc8c7b6)) 533 | 534 | * test: add tests for utility functions and classes of the `filterset` module ([`c268d3f`](https://github.com/devind-team/graphene-django-filter/commit/c268d3fd57c3cc29c6c2adf6cc2feef9f1b04cb4)) 535 | 536 | ### Unknown 537 | 538 | * Merge pull request #6 from devind-team/develop 539 | 540 | Added the `not` operator in possible filtration. ([`9cbb1d0`](https://github.com/devind-team/graphene-django-filter/commit/9cbb1d0d35b345f2a40fe55358279eb5b59df09c)) 541 | 542 | * Merge branch 'main' into develop ([`0497e0d`](https://github.com/devind-team/graphene-django-filter/commit/0497e0d5fd2656ad0927f2acc7c39d1e953c9126)) 543 | 544 | 545 | ## v0.4.1 (2022-02-08) 546 | 547 | ### Unknown 548 | 549 | * Merge pull request #2 from devind-team/develop 550 | 551 | Hot fix ([`9589554`](https://github.com/devind-team/graphene-django-filter/commit/9589554e7e8aec04be44e2f1ee335454baf0c557)) 552 | 553 | 554 | ## v0.4.0 (2022-02-08) 555 | 556 | ### Fix 557 | 558 | * fix: remove pull request release from CI ([`5ae2757`](https://github.com/devind-team/graphene-django-filter/commit/5ae2757ca98cd82e9eaf2797ab8955f5103a1cd9)) 559 | 560 | ### Unknown 561 | 562 | * Merge remote-tracking branch 'origin/develop' into develop ([`8faa52b`](https://github.com/devind-team/graphene-django-filter/commit/8faa52bdfc2a66fc74a8aecb798b8358f7f7ea7c)) 563 | 564 | * Merge pull request #1 from devind-team/develop 565 | 566 | Basic functionality ([`20b0e53`](https://github.com/devind-team/graphene-django-filter/commit/20b0e530605045cfd80730a17c4fcd321a8bdc9e)) 567 | 568 | 569 | ## v0.3.0 (2022-02-08) 570 | 571 | ### Documentation 572 | 573 | * docs: update README.md ([`9eba1de`](https://github.com/devind-team/graphene-django-filter/commit/9eba1dea72b86d214333fe483876811b989d37bf)) 574 | 575 | ### Feature 576 | 577 | * feat: add the implementation of the `resolve_queryset` method of the `AdvancedDjangoFilterConnectionField` class ([`f657ce8`](https://github.com/devind-team/graphene-django-filter/commit/f657ce86f651c4c39297296c8834f48e8e0beaa9)) 578 | 579 | * feat: change the data generation ([`be87ca6`](https://github.com/devind-team/graphene-django-filter/commit/be87ca66629da06e903a7b9113f29a75ea92b40e)) 580 | 581 | * feat: add filtering to the `AdvancedFilterSet` class ([`7e77202`](https://github.com/devind-team/graphene-django-filter/commit/7e7720201b0d74e470390300646c918417c1e3f7)) 582 | 583 | * feat: add the `form` property override for the `AdvancedFilterSet` class ([`25d61ab`](https://github.com/devind-team/graphene-django-filter/commit/25d61abe0c8add1818b8c75ab32be28fa3d8450c)) 584 | 585 | * feat: simplify and expand FilterSet classes ([`887896c`](https://github.com/devind-team/graphene-django-filter/commit/887896c40aa221e600ef98ab088b387f476537c4)) 586 | 587 | * feat: add data generation for testing ([`fa3b08f`](https://github.com/devind-team/graphene-django-filter/commit/fa3b08fd6a15a37ad21af471ddecf4ca096c07a5)) 588 | 589 | * feat: add the `find_filter` method to the `AdvancedFilterSet` class ([`d53fb21`](https://github.com/devind-team/graphene-django-filter/commit/d53fb2106c04bfeaf57508c1b89dbf16168a98dd)) 590 | 591 | * feat: add form generation for the `AdvancedFilterSet` class ([`ca14c3b`](https://github.com/devind-team/graphene-django-filter/commit/ca14c3b7d1959b4b1cb7a2bff26c2f1465fb309f)) 592 | 593 | * feat: add the `tree_input_type_to_data` function 594 | 595 | This function convert a tree_input_type to a FilterSet data. ([`8964686`](https://github.com/devind-team/graphene-django-filter/commit/896468638aac29ac8188ad980da898afa78dd3c9)) 596 | 597 | * feat: add the `get_filter_set_class` function that return a AdvancedFilterSet instead of a FilterSet ([`86a2b14`](https://github.com/devind-team/graphene-django-filter/commit/86a2b148f16ceb88f3e786b48e7b562d19d5a1cc)) 598 | 599 | * feat: add the `AdvancedFilterSet` class ([`a187ae5`](https://github.com/devind-team/graphene-django-filter/commit/a187ae5244b81ef7b07de587110954b3cb046d8a)) 600 | 601 | * feat: add GraphQL schema for testing ([`53a8532`](https://github.com/devind-team/graphene-django-filter/commit/53a85323194d38cd8625d9fd7d54859373155178)) 602 | 603 | * feat: extend models for testing ([`a59deb6`](https://github.com/devind-team/graphene-django-filter/commit/a59deb6ca00a70d3a41541a34dd594ef128c7dd5)) 604 | 605 | * feat: add the stub to override the `resolve_queryset` method of the AdvancedDjangoFilterConnectionField class ([`8efe694`](https://github.com/devind-team/graphene-django-filter/commit/8efe6946880ababbf6ad326d4ccd6deb67c1c085)) 606 | 607 | * feat: add the `filtering_args` property override to the AdvancedDjangoFilterConnectionField class ([`be51546`](https://github.com/devind-team/graphene-django-filter/commit/be51546657c40f8d755189fc93dcaefdc2ab2690)) 608 | 609 | * feat: add connection to object type classes ([`67033b4`](https://github.com/devind-team/graphene-django-filter/commit/67033b4c9ba0184d6198b669c65a548982342cd5)) 610 | 611 | * feat: add function `get_filtering_args_from_filterset` that produce the arguments to pass to a Graphene Field ([`81d42d0`](https://github.com/devind-team/graphene-django-filter/commit/81d42d03c8ea9c62899e9f1f9ce5c7d3780c83de)) 612 | 613 | * feat: add types classes ([`b762b40`](https://github.com/devind-team/graphene-django-filter/commit/b762b40d5059978e0dc8acec1917b57c4ead8028)) 614 | 615 | * feat: complete all functions to create an input type ([`eb1099a`](https://github.com/devind-team/graphene-django-filter/commit/eb1099aca60d20c4fccbab932507508b0f930b30)) 616 | 617 | * feat: add method `create_field_filter_input_type` that create field filter input types from filter set tree leaves ([`9f76dec`](https://github.com/devind-team/graphene-django-filter/commit/9f76dec0928af16516d02707c7d6a4f09ed42e1c)) 618 | 619 | * feat: add method `get_input_type_name` that return input type name from a type name and node path ([`8607c69`](https://github.com/devind-team/graphene-django-filter/commit/8607c695929a892aae1a8465201931d9494b271f)) 620 | 621 | * feat: add method get_field that return Graphene type from filter field ([`f76a3ce`](https://github.com/devind-team/graphene-django-filter/commit/f76a3ce74f195afa94bca2199a5b1dc46dd9ac54)) 622 | 623 | * feat: add functions for converting a FilterSet class to a tree ([`c172f06`](https://github.com/devind-team/graphene-django-filter/commit/c172f0669cd9994adf6ba85b89e019505a8f078d)) 624 | 625 | * feat: add models and filtersets for testing ([`aadab08`](https://github.com/devind-team/graphene-django-filter/commit/aadab08113dd06380bb8457ef7f6807ce2bce82f)) 626 | 627 | * feat: add method that adds a subfield to LookupInputTypeBuilder class ([`1ed5706`](https://github.com/devind-team/graphene-django-filter/commit/1ed5706c63047de9994ea05ce2c5899c4ae30f21)) 628 | 629 | * feat: add FilterInputTypeBuilderTest for creation filter input type ([`52ef851`](https://github.com/devind-team/graphene-django-filter/commit/52ef8517c3b44031f12893e050b8016d0761b419)) 630 | 631 | * feat: add LookupInputTypeBuilder for creation lookups input type for field ([`4a7c7c4`](https://github.com/devind-team/graphene-django-filter/commit/4a7c7c45d66d0f8bc7470cda1b80d39efe2ba31d)) 632 | 633 | ### Fix 634 | 635 | * fix: suboptimal design on objects replaced with design on arrays ([`c857efc`](https://github.com/devind-team/graphene-django-filter/commit/c857efc90b0ea9d7688f6599ab9d4ffd3156419f)) 636 | 637 | * fix: change `UserFilterFieldsType` class `filter_fields` according to the `UserFilter` class ([`bb923e9`](https://github.com/devind-team/graphene-django-filter/commit/bb923e9c05e8f2bfd074b4cbe981677aff9b188c)) 638 | 639 | * fix: change the wrong filtering logic ([`cfe9e5a`](https://github.com/devind-team/graphene-django-filter/commit/cfe9e5ad571efc7ff69f645903b07e2c58074414)) 640 | 641 | * fix: fix getting form errors ([`eecb07f`](https://github.com/devind-team/graphene-django-filter/commit/eecb07f238c53e1c55c495313e35aef815a31774)) 642 | 643 | * fix: remove the DEFAULT_LOOKUP_EXPR from the FilterSet data ([`f2a69b2`](https://github.com/devind-team/graphene-django-filter/commit/f2a69b24c062b0af1070671a7d75d77e7afb3104)) 644 | 645 | * fix: fix the typo in the `get_filterset_class` function ([`74d4934`](https://github.com/devind-team/graphene-django-filter/commit/74d4934254e0aa9cfa5d25874763b4b7a040d86f)) 646 | 647 | * fix: change the wrong return type of the `get_filtering_args_from_filterset` function ([`0087dcf`](https://github.com/devind-team/graphene-django-filter/commit/0087dcf1896caa19d882156b7cac1a15263fa825)) 648 | 649 | * fix: change the wrong filtering logic ([`dd18cd3`](https://github.com/devind-team/graphene-django-filter/commit/dd18cd3f6c2cf019b647012f0835cfcd457133d2)) 650 | 651 | * fix: add search filter without lookup_expr ([`c55d3ba`](https://github.com/devind-team/graphene-django-filter/commit/c55d3ba2ee4a4ee358aca148c6cd0cdf3314bb01)) 652 | 653 | * fix: fix input type creation ([`ddca6fc`](https://github.com/devind-team/graphene-django-filter/commit/ddca6fc742e51df0f344489f121303de055aaddc)) 654 | 655 | * fix: fix expected/actual wrong order ([`3977969`](https://github.com/devind-team/graphene-django-filter/commit/39779698b92dd97b5ffcc5527d1f6137bd8c9b29)) 656 | 657 | * fix: remove flake8-spellcheck 658 | 659 | This plugin does not work correctly in PyCharm. ([`e1ddbbe`](https://github.com/devind-team/graphene-django-filter/commit/e1ddbbe5d17f448882641383bc96ff4c94e35be0)) 660 | 661 | ### Refactor 662 | 663 | * refactor: change the location of data required for tests ([`7becb86`](https://github.com/devind-team/graphene-django-filter/commit/7becb86195a2d7e8102398abab0a70ace8342ff6)) 664 | 665 | * refactor: change query names ([`9ae4a59`](https://github.com/devind-team/graphene-django-filter/commit/9ae4a59d6f6044fcaad4a1bc51ba5c5ac016e0ee)) 666 | 667 | * refactor: change the names `filter_set` to `filterset` ([`a2ba2bc`](https://github.com/devind-team/graphene-django-filter/commit/a2ba2bcbb7ac218fe8722da4852565a58a088b3a)) 668 | 669 | * refactor: simplify the `create_filter_input_type` function ([`8a59a50`](https://github.com/devind-team/graphene-django-filter/commit/8a59a506b03c1ff684d77d2a696290d6b45a9453)) 670 | 671 | * refactor: simplify the creation of input types ([`d5b5155`](https://github.com/devind-team/graphene-django-filter/commit/d5b5155afc7ef8be89a782f82af464565f2db862)) 672 | 673 | * refactor: remove unnecessary input type builders ([`4ce26c8`](https://github.com/devind-team/graphene-django-filter/commit/4ce26c8529d4e4fd24b6f7eb56d3102fca36c762)) 674 | 675 | * refactor: simplify LookupInputTypeBuilder with method fusion ([`7f62db0`](https://github.com/devind-team/graphene-django-filter/commit/7f62db031961fbaa2d2e587f713d8c926accbef2)) 676 | 677 | 678 | ## v0.2.0 (2022-01-11) 679 | 680 | ### Feature 681 | 682 | * feat: add semantic release settings ([`07f5d5c`](https://github.com/devind-team/graphene-django-filter/commit/07f5d5c5e414a3b3b9289bbf06f03956852842a0)) 683 | 684 | * feat: add settings for publishing ([`01970bd`](https://github.com/devind-team/graphene-django-filter/commit/01970bd722e2d11ab243f1e13f4883414a9f7241)) 685 | 686 | * feat: add lint step to ci ([`62f1014`](https://github.com/devind-team/graphene-django-filter/commit/62f1014867304df22d3d0e50c33632988b3051c8)) 687 | 688 | * feat: add git pre-commit ([`3cafc36`](https://github.com/devind-team/graphene-django-filter/commit/3cafc369ec7744e2d6e98752726984021cdb8505)) 689 | 690 | * feat: add flake8 linter ([`9add44b`](https://github.com/devind-team/graphene-django-filter/commit/9add44b9ff5654b9e02f929deb11f15a73aa1e8e)) 691 | 692 | * feat: add required libraries ([`393ad9a`](https://github.com/devind-team/graphene-django-filter/commit/393ad9a3dc9a4676c0fe1a722b526c29f8b91674)) 693 | 694 | * feat: add example test ([`5e7dd1b`](https://github.com/devind-team/graphene-django-filter/commit/5e7dd1b7ebca3af985ebf3c4385dc6cba2e4b03b)) 695 | 696 | * feat: add init files ([`a30097f`](https://github.com/devind-team/graphene-django-filter/commit/a30097f545ea26395fddf452d962bec9d1447240)) 697 | 698 | * feat: add ci.yml file ([`e225c84`](https://github.com/devind-team/graphene-django-filter/commit/e225c845528233ef84ca9d9acb48f58fd49b95d4)) 699 | 700 | ### Fix 701 | 702 | * fix: change python version ([`5ef78df`](https://github.com/devind-team/graphene-django-filter/commit/5ef78df74df5a64cdf4a5ca05f901dd99486cc90)) 703 | 704 | * fix: change example test ci ([`1132f8c`](https://github.com/devind-team/graphene-django-filter/commit/1132f8c7b05a3391626e03e1a7a33217ece9c889)) 705 | 706 | * fix: change example test ci ([`1d5fe03`](https://github.com/devind-team/graphene-django-filter/commit/1d5fe033cae551cad7dbd037820d7e67d6d94c69)) 707 | 708 | * fix: change example test ci ([`0256c44`](https://github.com/devind-team/graphene-django-filter/commit/0256c44859ab625ee0d54a313436f68f3a7dab67)) 709 | 710 | * fix: change example test ci ([`898ba7c`](https://github.com/devind-team/graphene-django-filter/commit/898ba7c9b115a270d7a81028b8b8fefc3f4ab8e0)) 711 | 712 | ### Unknown 713 | 714 | * Merge remote-tracking branch 'origin/main' into main ([`2a36cbc`](https://github.com/devind-team/graphene-django-filter/commit/2a36cbca9a45cb412e30587e3c45b12b89226bf9)) 715 | 716 | * Initial commit ([`f2492ba`](https://github.com/devind-team/graphene-django-filter/commit/f2492bad4bd1af3a2e6550faf4288047041c35ea)) 717 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 devind-team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Graphene-Django-Filter 2 | [![CI](https://github.com/devind-team/graphene-django-filter/workflows/CI/badge.svg)](https://github.com/devind-team/graphene-django-filter/actions) 3 | [![Coverage Status](https://coveralls.io/repos/github/devind-team/graphene-django-filter/badge.svg?branch=main)](https://coveralls.io/github/devind-team/graphene-django-filter?branch=main) 4 | [![PyPI version](https://badge.fury.io/py/graphene-django-filter.svg)](https://badge.fury.io/py/graphene-django-filter) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-success.svg)](https://opensource.org/licenses/MIT) 6 | 7 | This package contains advanced filters for [graphene-django](https://github.com/graphql-python/graphene-django). 8 | The standard filtering feature in graphene-django relies on the [django-filter](https://github.com/carltongibson/django-filter) 9 | library and therefore provides the flat API without the ability to use logical operators such as 10 | `and`, `or` and `not`. This library makes the API nested and adds logical expressions by extension 11 | of the `DjangoFilterConnectionField` field and the `FilterSet` class. 12 | Also, the library provides some other convenient filtering features. 13 | 14 | # Installation 15 | ```shell 16 | # pip 17 | pip install graphene-django-filter 18 | # poetry 19 | poetry add graphene-django-filter 20 | ``` 21 | 22 | # Requirements 23 | * Python (3.7, 3.8, 3.9, 3.10) 24 | * Graphene-Django (2.15) 25 | 26 | # Features 27 | 28 | ## Nested API with the ability to use logical operators 29 | To use, simply replace all `DjangoFilterConnectionField` fields with 30 | `AdvancedDjangoFilterConnectionField` fields in your queries. 31 | Also,if you create custom FilterSets, replace the inheritance from the `FilterSet` class 32 | with the inheritance from the `AdvancedFilterSet` class. 33 | For example, the following task query exposes the old flat API. 34 | ```python 35 | import graphene 36 | from django_filters import FilterSet 37 | from graphene_django import DjangoObjectType 38 | from graphene_django.filter import DjangoFilterConnectionField 39 | 40 | class TaskFilter(FilterSet): 41 | class Meta: 42 | model = Task 43 | fields = { 44 | 'name': ('exact', 'contains'), 45 | 'user__email': ('exact', 'contains'), 46 | 'user__first_name': ('exact', 'contains'), 47 | 'user__last_name': ('exact', 'contains'), 48 | } 49 | 50 | class UserType(DjangoObjectType): 51 | class Meta: 52 | model = User 53 | interfaces = (graphene.relay.Node,) 54 | fields = '__all__' 55 | 56 | class TaskType(DjangoObjectType): 57 | user = graphene.Field(UserType) 58 | 59 | class Meta: 60 | model = Task 61 | interfaces = (graphene.relay.Node,) 62 | fields = '__all__' 63 | filterset_class = TaskFilter 64 | 65 | class Query(graphene.ObjectType): 66 | tasks = DjangoFilterConnectionField(TaskType) 67 | ``` 68 | The flat API in which all filters are applied using the `and` operator looks like this. 69 | ```graphql 70 | { 71 | tasks( 72 | name_Contains: "important" 73 | user_Email_Contains: "john" 74 | user_FirstName: "John" 75 | user_LastName: "Dou" 76 | ){ 77 | edges { 78 | node { 79 | id 80 | name 81 | } 82 | } 83 | } 84 | } 85 | ``` 86 | After replacing the field class with the `AdvancedDjangoFilterConnectionField` 87 | and the `FilterSet` class with the `AdvancedFilterSet` 88 | the API becomes nested with support for logical expressions. 89 | ```python 90 | import graphene 91 | from graphene_django_filter import AdvancedDjangoFilterConnectionField, AdvancedFilterSet 92 | 93 | class TaskFilter(AdvancedFilterSet): 94 | class Meta: 95 | model = Task 96 | fields = { 97 | 'name': ('exact', 'contains'), 98 | 'user__email': ('exact', 'contains'), 99 | 'user__first_name': ('exact', 'contains'), 100 | 'user__last_name': ('exact', 'contains'), 101 | } 102 | 103 | class Query(graphene.ObjectType): 104 | tasks = AdvancedDjangoFilterConnectionField(TaskType) 105 | ``` 106 | For example, the following query returns tasks which names contain the word "important" 107 | or the user's email address contains the word "john" and the user's last name is "Dou" 108 | and the first name is not "John". 109 | Note that the operators are applied to lookups 110 | such as `contains`, `exact`, etc. at the last level of nesting. 111 | ```graphql 112 | { 113 | tasks( 114 | filter: { 115 | or: [ 116 | {name: {contains: "important"}} 117 | { 118 | and: [ 119 | {user: {email: {contains: "john"}}} 120 | {user: {lastName: {exact: "Dou"}}} 121 | ] 122 | } 123 | ] 124 | not: { 125 | user: {firstName: {exact: "John"}} 126 | } 127 | } 128 | ) { 129 | edges { 130 | node { 131 | id 132 | name 133 | } 134 | } 135 | } 136 | } 137 | ``` 138 | The same result can be achieved with an alternative query structure 139 | because within the same object the `and` operator is always used. 140 | ```graphql 141 | { 142 | tasks( 143 | filter: { 144 | or: [ 145 | {name: {contains: "important"}} 146 | { 147 | user: { 148 | email: {contains: "john"} 149 | lastName: {exact: "Dou"} 150 | } 151 | } 152 | ] 153 | not: { 154 | user: {firstName: {exact: "John"}} 155 | } 156 | } 157 | ){ 158 | edges { 159 | node { 160 | id 161 | name 162 | } 163 | } 164 | } 165 | } 166 | ``` 167 | The filter input type has the following structure. 168 | ```graphql 169 | input FilterInputType { 170 | and: [FilterInputType] 171 | or: [FilterInputType] 172 | not: FilterInputType 173 | ...FieldLookups 174 | } 175 | ``` 176 | For more examples, see [tests](https://github.com/devind-team/graphene-django-filter/blob/06ed0af8def8a4378b4c65a5d137ef17b6176cab/tests/test_queries_execution.py#L23). 177 | 178 | ## Full text search 179 | Django provides the [API](https://docs.djangoproject.com/en/3.2/ref/contrib/postgres/search/) 180 | for PostgreSQL full text search. Graphene-Django-Filter inject this API into the GraphQL filter API. 181 | To use, add `full_text_search` lookup to fields for which you want to enable full text search. 182 | For example, the following type has full text search for 183 | `first_name` and `last_name` fields. 184 | ```python 185 | import graphene 186 | from graphene_django import DjangoObjectType 187 | from graphene_django_filter import AdvancedDjangoFilterConnectionField 188 | 189 | class UserType(DjangoObjectType): 190 | class Meta: 191 | model = User 192 | interfaces = (graphene.relay.Node,) 193 | fields = '__all__' 194 | filter_fields = { 195 | 'email': ('exact', 'startswith', 'contains'), 196 | 'first_name': ('exact', 'contains', 'full_text_search'), 197 | 'last_name': ('exact', 'contains', 'full_text_search'), 198 | } 199 | 200 | class Query(graphene.ObjectType): 201 | users = AdvancedDjangoFilterConnectionField(UserType) 202 | ``` 203 | Since this feature belongs to the AdvancedFilterSet, 204 | it can be used in a custom FilterSet. 205 | The following example will work exactly like the previous one. 206 | ```python 207 | import graphene 208 | from graphene_django import DjangoObjectType 209 | from graphene_django_filter import AdvancedDjangoFilterConnectionField, AdvancedFilterSet 210 | 211 | class UserFilter(AdvancedFilterSet): 212 | class Meta: 213 | model = User 214 | fields = { 215 | 'email': ('exact', 'startswith', 'contains'), 216 | 'first_name': ('exact', 'contains', 'full_text_search'), 217 | 'last_name': ('exact', 'contains', 'full_text_search'), 218 | } 219 | 220 | class UserType(DjangoObjectType): 221 | class Meta: 222 | model = User 223 | interfaces = (graphene.relay.Node,) 224 | fields = '__all__' 225 | filterset_class = UserFilter 226 | 227 | class Query(graphene.ObjectType): 228 | users = AdvancedDjangoFilterConnectionField(UserType) 229 | ``` 230 | Full text search API includes SearchQuery, SearchRank, and Trigram filters. 231 | SearchQuery and SearchRank filters are at the top level. 232 | If some field has been enabled for full text search then it can be included in the field array. 233 | The following queries show an example of using the SearchQuery and SearchRank filters. 234 | ```graphql 235 | { 236 | users( 237 | filter: { 238 | searchQuery: { 239 | vector: { 240 | fields: ["first_name"] 241 | } 242 | query: { 243 | or: [ 244 | {value: "Bob"} 245 | {value: "Alice"} 246 | ] 247 | } 248 | } 249 | } 250 | ){ 251 | edges { 252 | node { 253 | id 254 | firstName 255 | lastName 256 | } 257 | } 258 | } 259 | } 260 | ``` 261 | ```graphql 262 | { 263 | users( 264 | filter: { 265 | searchRank: { 266 | vector: {fields: ["first_name", "last_name"]} 267 | query: {value: "John Dou"} 268 | lookups: {gte: 0.5} 269 | } 270 | } 271 | ){ 272 | edges { 273 | node { 274 | id 275 | firstName 276 | lastName 277 | } 278 | } 279 | } 280 | } 281 | ``` 282 | Trigram filter belongs to the corresponding field. 283 | The following query shows an example of using the Trigram filter. 284 | ```graphql 285 | { 286 | users( 287 | filter: { 288 | firstName: { 289 | trigram: { 290 | value: "john" 291 | lookups: {gte: 0.85} 292 | } 293 | } 294 | } 295 | ){ 296 | edges { 297 | node { 298 | id 299 | firstName 300 | lastName 301 | } 302 | } 303 | } 304 | } 305 | ``` 306 | Input types have the following structure. 307 | ```graphql 308 | input SearchConfigInputType { 309 | value: String! 310 | isField: Boolean 311 | } 312 | enum SearchVectorWeight { 313 | A 314 | B 315 | C 316 | D 317 | } 318 | input SearchVectorInputType { 319 | fields: [String!]! 320 | config: SearchConfigInputType 321 | weight: SearchVectorWeight 322 | } 323 | enum SearchQueryType { 324 | PLAIN 325 | PHRASE 326 | RAW 327 | WEBSEARCH 328 | } 329 | input SearchQueryInputType { 330 | value: String 331 | config: SearchConfigInputType 332 | and: [SearchQueryInputType] 333 | or: [SearchQueryInputType] 334 | not: SearchQueryInputType 335 | } 336 | input SearchQueryFilterInputType { 337 | vector: SearchVectorInputType! 338 | query: SearchQueryInputType! 339 | } 340 | input FloatLookupsInputType { 341 | exact: Float 342 | gt: Float 343 | gte: Float 344 | lt: Float 345 | lte: Float 346 | } 347 | input SearchRankWeightsInputType { 348 | D: Float 349 | C: Float 350 | B: Float 351 | A: Float 352 | } 353 | input SearchRankFilterInputType { 354 | vector: SearchVectorInputType! 355 | query: SearchQueryInputType! 356 | lookups: FloatLookupsInputType! 357 | weights: SearchRankWeightsInputType 358 | coverDensity: Boolean 359 | normalization: Int 360 | } 361 | enum TrigramSearchKind { 362 | SIMILARITY 363 | DISTANCE 364 | } 365 | input TrigramFilterInputType { 366 | kind: TrigramSearchKind 367 | lookups: FloatLookupsInputType! 368 | value: String! 369 | } 370 | ``` 371 | For more examples, see [tests](https://github.com/devind-team/graphene-django-filter/blob/06ed0af8def8a4378b4c65a5d137ef17b6176cab/tests/test_queries_execution.py#L134). 372 | 373 | ## Settings 374 | The library can be customised using settings. 375 | To add settings, create a dictionary 376 | with name `GRAPHENE_DJANGO_FILTER` in the project’s `settings.py`. 377 | The default settings are as follows. 378 | ```python 379 | GRAPHENE_DJANGO_FILTER = { 380 | 'FILTER_KEY': 'filter', 381 | 'AND_KEY': 'and', 382 | 'OR_KEY': 'or', 383 | 'NOT_KEY': 'not', 384 | } 385 | ``` 386 | To read the settings, import them from the `conf` module. 387 | ```python 388 | from graphene_django_filter.conf import settings 389 | 390 | print(settings.FILTER_KEY) 391 | ``` 392 | The `settings` object also includes fixed settings, which depend on the user's environment. 393 | `IS_POSTGRESQL` determinate that current database is PostgreSQL 394 | and `HAS_TRIGRAM_EXTENSION` that `pg_trgm` extension is installed. 395 | -------------------------------------------------------------------------------- /graphene_django_filter/__init__.py: -------------------------------------------------------------------------------- 1 | """Graphene-django-filter source.""" 2 | 3 | __version__ = '0.6.5' 4 | 5 | from .connection_field import AdvancedDjangoFilterConnectionField 6 | from .filterset import AdvancedFilterSet 7 | -------------------------------------------------------------------------------- /graphene_django_filter/conf.py: -------------------------------------------------------------------------------- 1 | """Library settings.""" 2 | 3 | from typing import Any, Dict, Optional, Union 4 | 5 | from django.conf import settings as django_settings 6 | from django.db import connection 7 | from django.test.signals import setting_changed 8 | 9 | 10 | def get_fixed_settings() -> Dict[str, bool]: 11 | """Return fixed settings.""" 12 | is_postgresql = connection.vendor == 'postgresql' 13 | has_trigram_extension = False 14 | if is_postgresql: 15 | with connection.cursor() as cursor: 16 | cursor.execute("SELECT COUNT(*) FROM pg_available_extensions WHERE name='pg_trgm'") 17 | has_trigram_extension = cursor.fetchone()[0] == 1 18 | return { 19 | 'IS_POSTGRESQL': is_postgresql, 20 | 'HAS_TRIGRAM_EXTENSION': has_trigram_extension, 21 | } 22 | 23 | 24 | FIXED_SETTINGS = get_fixed_settings() 25 | DEFAULT_SETTINGS = { 26 | 'FILTER_KEY': 'filter', 27 | 'AND_KEY': 'and', 28 | 'OR_KEY': 'or', 29 | 'NOT_KEY': 'not', 30 | } 31 | DJANGO_SETTINGS_KEY = 'GRAPHENE_DJANGO_FILTER' 32 | 33 | 34 | class Settings: 35 | """Library settings. 36 | 37 | Settings consist of fixed ones that depend on the user environment 38 | and others that can be set with Django settings.py module. 39 | """ 40 | 41 | def __init__(self, user_settings: Optional[dict] = None) -> None: 42 | self._user_settings = user_settings 43 | 44 | @property 45 | def user_settings(self) -> dict: 46 | """Return user-defined settings.""" 47 | if self._user_settings is None: 48 | self._user_settings = getattr(django_settings, DJANGO_SETTINGS_KEY, {}) 49 | return self._user_settings 50 | 51 | def __getattr__(self, name: str) -> Union[str, bool]: 52 | """Return a setting value.""" 53 | if name not in FIXED_SETTINGS and name not in DEFAULT_SETTINGS: 54 | raise AttributeError(f'Invalid Graphene setting: `{name}`') 55 | if name in FIXED_SETTINGS: 56 | return FIXED_SETTINGS[name] 57 | elif name in self.user_settings: 58 | return self.user_settings[name] 59 | else: 60 | return DEFAULT_SETTINGS[name] 61 | 62 | 63 | settings = Settings(None) 64 | 65 | 66 | def reload_settings(setting: str, value: Any, **kwargs) -> None: 67 | """Reload settings in response to the `setting_changed` signal.""" 68 | global settings 69 | if setting == DJANGO_SETTINGS_KEY: 70 | settings = Settings(value) 71 | 72 | 73 | setting_changed.connect(reload_settings) 74 | -------------------------------------------------------------------------------- /graphene_django_filter/connection_field.py: -------------------------------------------------------------------------------- 1 | """`AdvancedDjangoFilterConnectionField` class module. 2 | 3 | Use the `AdvancedDjangoFilterConnectionField` class from this 4 | module instead of the `DjangoFilterConnectionField` from graphene-django. 5 | """ 6 | 7 | import warnings 8 | from typing import Any, Callable, Dict, Iterable, Optional, Type, Union 9 | 10 | import graphene 11 | from django.core.exceptions import ValidationError 12 | from django.db import models 13 | from graphene_django import DjangoObjectType 14 | from graphene_django.filter import DjangoFilterConnectionField 15 | 16 | from .conf import settings 17 | from .filter_arguments_factory import FilterArgumentsFactory 18 | from .filterset import AdvancedFilterSet 19 | from .filterset_factories import get_filterset_class 20 | from .input_data_factories import tree_input_type_to_data 21 | 22 | 23 | class AdvancedDjangoFilterConnectionField(DjangoFilterConnectionField): 24 | """Allow you to use advanced filters provided by this library.""" 25 | 26 | def __init__( 27 | self, 28 | type: Union[Type[DjangoObjectType], Callable[[], Type[DjangoObjectType]], str], 29 | fields: Optional[Dict[str, list]] = None, 30 | order_by: Any = None, 31 | extra_filter_meta: Optional[dict] = None, 32 | filterset_class: Optional[Type[AdvancedFilterSet]] = None, 33 | filter_input_type_prefix: Optional[str] = None, 34 | *args, 35 | **kwargs 36 | ) -> None: 37 | super().__init__( 38 | type, 39 | fields, 40 | order_by, 41 | extra_filter_meta, 42 | filterset_class, 43 | *args, 44 | **kwargs 45 | ) 46 | assert self.provided_filterset_class is None or issubclass( 47 | self.provided_filterset_class, AdvancedFilterSet, 48 | ), 'Use the `AdvancedFilterSet` class with the `AdvancedDjangoFilterConnectionField`' 49 | self._filter_input_type_prefix = filter_input_type_prefix 50 | if self._filter_input_type_prefix is None and self._provided_filterset_class: 51 | warnings.warn( 52 | 'The `filterset_class` argument without `filter_input_type_prefix` ' 53 | 'can result in different types with the same name in the schema.', 54 | ) 55 | if self._filter_input_type_prefix is None and self.node_type._meta.filterset_class: 56 | warnings.warn( 57 | f'The `filterset_class` field of `{self.node_type.__name__}` Meta ' 58 | 'without the `filter_input_type_prefix` argument ' 59 | 'can result in different types with the same name in the schema.', 60 | ) 61 | 62 | @property 63 | def provided_filterset_class(self) -> Optional[Type[AdvancedFilterSet]]: 64 | """Return a provided AdvancedFilterSet class.""" 65 | return self._provided_filterset_class or self.node_type._meta.filterset_class 66 | 67 | @property 68 | def filter_input_type_prefix(self) -> str: 69 | """Return a prefix for a filter input type name.""" 70 | if self._filter_input_type_prefix: 71 | return self._filter_input_type_prefix 72 | node_type_name = self.node_type.__name__.replace('Type', '') 73 | if self.provided_filterset_class: 74 | return f'{node_type_name}{self.provided_filterset_class.__name__}' 75 | else: 76 | return node_type_name 77 | 78 | @property 79 | def filterset_class(self) -> Type[AdvancedFilterSet]: 80 | """Return a AdvancedFilterSet instead of a FilterSet.""" 81 | if not self._filterset_class: 82 | fields = self._fields or self.node_type._meta.filter_fields 83 | meta = {'model': self.model, 'fields': fields} 84 | if self._extra_filter_meta: 85 | meta.update(self._extra_filter_meta) 86 | self._filterset_class = get_filterset_class(self.provided_filterset_class, **meta) 87 | return self._filterset_class 88 | 89 | @property 90 | def filtering_args(self) -> dict: 91 | """Return filtering args from the filterset.""" 92 | if not self._filtering_args: 93 | self._filtering_args = FilterArgumentsFactory( 94 | self.filterset_class, 95 | self.filter_input_type_prefix, 96 | ).arguments 97 | return self._filtering_args 98 | 99 | @classmethod 100 | def resolve_queryset( 101 | cls, 102 | connection: object, 103 | iterable: Iterable, 104 | info: graphene.ResolveInfo, 105 | args: Dict[str, Any], 106 | filtering_args: Dict[str, graphene.InputField], 107 | filterset_class: Type[AdvancedFilterSet], 108 | ) -> models.QuerySet: 109 | """Return a filtered QuerySet.""" 110 | qs = super(DjangoFilterConnectionField, cls).resolve_queryset( 111 | connection, iterable, info, args, 112 | ) 113 | filter_arg = args.get(settings.FILTER_KEY) or {} 114 | filterset = filterset_class( 115 | data=tree_input_type_to_data(filterset_class, filter_arg), 116 | queryset=qs, 117 | request=info.context, 118 | ) 119 | if filterset.form.is_valid(): 120 | return filterset.qs 121 | raise ValidationError(filterset.form.errors.as_json()) 122 | -------------------------------------------------------------------------------- /graphene_django_filter/filter_arguments_factory.py: -------------------------------------------------------------------------------- 1 | """Module for converting a AdvancedFilterSet class to filter arguments.""" 2 | 3 | from typing import Any, Dict, List, Optional, Sequence, Type, cast 4 | 5 | import graphene 6 | from anytree import Node 7 | from django.db import models 8 | from django.db.models.constants import LOOKUP_SEP 9 | from django_filters import Filter 10 | from django_filters.conf import settings as django_settings 11 | from graphene_django.filter.utils import get_model_field 12 | from graphene_django.forms.converter import convert_form_field 13 | from stringcase import pascalcase 14 | 15 | from .conf import settings 16 | from .filters import SearchQueryFilter, SearchRankFilter, TrigramFilter 17 | from .filterset import AdvancedFilterSet 18 | from .input_types import ( 19 | SearchQueryFilterInputType, 20 | SearchRankFilterInputType, 21 | TrigramFilterInputType, 22 | ) 23 | 24 | 25 | class FilterArgumentsFactory: 26 | """Factory for creating filter arguments.""" 27 | 28 | SPECIAL_FILTER_INPUT_TYPES_FACTORIES = { 29 | SearchQueryFilter.postfix: lambda: graphene.InputField( 30 | SearchQueryFilterInputType, 31 | description='Field for the full text search using ' 32 | 'the `SearchVector` and `SearchQuery` object', 33 | ), 34 | SearchRankFilter.postfix: lambda: graphene.InputField( 35 | SearchRankFilterInputType, 36 | description='Field for the full text search using the `SearchRank` object', 37 | ), 38 | TrigramFilter.postfix: lambda: graphene.InputField( 39 | TrigramFilterInputType, 40 | description='Field for the full text search using similarity or distance of trigram', 41 | ), 42 | } 43 | 44 | input_object_types: Dict[str, Type[graphene.InputObjectType]] = {} 45 | 46 | def __init__(self, filterset_class: Type[AdvancedFilterSet], input_type_prefix: str) -> None: 47 | self.filterset_class = filterset_class 48 | self.input_type_prefix = input_type_prefix 49 | self.filter_input_type_name = f'{self.input_type_prefix}FilterInputType' 50 | 51 | @property 52 | def arguments(self) -> Dict[str, graphene.Argument]: 53 | """Inspect a FilterSet and produce the arguments to pass to a Graphene Field. 54 | 55 | These arguments will be available to filter against in the GraphQL. 56 | """ 57 | input_object_type = self.input_object_types.get( 58 | self.filter_input_type_name, 59 | self.create_filter_input_type( 60 | self.filterset_to_trees(self.filterset_class), 61 | ), 62 | ) 63 | return { 64 | settings.FILTER_KEY: graphene.Argument( 65 | input_object_type, 66 | description='Advanced filter field', 67 | ), 68 | } 69 | 70 | def create_filter_input_type(self, roots: List[Node]) -> Type[graphene.InputObjectType]: 71 | """Create a filter input type from filter set trees.""" 72 | self.input_object_types[self.filter_input_type_name] = cast( 73 | Type[graphene.InputObjectType], 74 | type( 75 | self.filter_input_type_name, 76 | (graphene.InputObjectType,), 77 | { 78 | **{ 79 | root.name: self.create_filter_input_subfield( 80 | root, 81 | self.input_type_prefix, 82 | f'`{pascalcase(root.name)}` field', 83 | ) 84 | for root in roots 85 | }, 86 | settings.AND_KEY: graphene.InputField( 87 | graphene.List(lambda: self.input_object_types[self.filter_input_type_name]), 88 | description='`And` field', 89 | ), 90 | settings.OR_KEY: graphene.InputField( 91 | graphene.List(lambda: self.input_object_types[self.filter_input_type_name]), 92 | description='`Or` field', 93 | ), 94 | settings.NOT_KEY: graphene.InputField( 95 | lambda: self.input_object_types[self.filter_input_type_name], 96 | description='`Not` field', 97 | ), 98 | }, 99 | ), 100 | ) 101 | return self.input_object_types[self.filter_input_type_name] 102 | 103 | def create_filter_input_subfield( 104 | self, 105 | root: Node, 106 | prefix: str, 107 | description: str, 108 | ) -> graphene.InputField: 109 | """Create a filter input subfield from a filter set subtree.""" 110 | fields: Dict[str, graphene.InputField] = {} 111 | if root.name in self.SPECIAL_FILTER_INPUT_TYPES_FACTORIES: 112 | return self.SPECIAL_FILTER_INPUT_TYPES_FACTORIES[root.name]() 113 | else: 114 | for child in root.children: 115 | if child.height == 0: 116 | filter_name = f'{LOOKUP_SEP}'.join( 117 | node.name for node in child.path 118 | if node.name != django_settings.DEFAULT_LOOKUP_EXPR 119 | ) 120 | fields[child.name] = self.get_field( 121 | filter_name, 122 | self.filterset_class.base_filters[filter_name], 123 | ) 124 | else: 125 | fields[child.name] = self.create_filter_input_subfield( 126 | child, 127 | prefix + pascalcase(root.name), 128 | f'`{pascalcase(child.name)}` subfield', 129 | ) 130 | return graphene.InputField( 131 | self.create_input_object_type( 132 | f'{prefix}{pascalcase(root.name)}FilterInputType', 133 | fields, 134 | ), 135 | description=description, 136 | ) 137 | 138 | @classmethod 139 | def create_input_object_type( 140 | cls, 141 | name: str, 142 | fields: Dict[str, Any], 143 | ) -> Type[graphene.InputObjectType]: 144 | """Create an inheritor for the `InputObjectType` class.""" 145 | if name in cls.input_object_types: 146 | return cls.input_object_types[name] 147 | cls.input_object_types[name] = cast( 148 | Type[graphene.InputObjectType], 149 | type( 150 | name, 151 | (graphene.InputObjectType,), 152 | fields, 153 | ), 154 | ) 155 | return cls.input_object_types[name] 156 | 157 | def get_field(self, name: str, filter_field: Filter) -> graphene.InputField: 158 | """Return Graphene input field from a filter field. 159 | 160 | It is a partial copy of the `get_filtering_args_from_filterset` function 161 | from graphene-django. 162 | https://github.com/graphql-python/graphene-django/blob/caf954861025b9f3d9d3f9c204a7cbbc87352265/graphene_django/filter/utils.py#L11 163 | """ 164 | model = self.filterset_class._meta.model 165 | form_field: Optional[models.Field] = None 166 | filter_type: str = filter_field.lookup_expr 167 | if name in getattr(self.filterset_class, 'declared_filters'): 168 | form_field = filter_field.field 169 | field = convert_form_field(form_field) 170 | else: 171 | model_field = get_model_field(model, filter_field.field_name) 172 | if filter_type != 'isnull' and hasattr(model_field, 'formfield'): 173 | form_field = model_field.formfield( 174 | required=filter_field.extra.get('required', False), 175 | ) 176 | if not form_field: 177 | form_field = filter_field.field 178 | field = convert_form_field(form_field) 179 | if filter_type in ('in', 'range'): 180 | field = graphene.List(field.get_type()) 181 | field_type = field.InputField() 182 | field_type.description = getattr(filter_field, 'label') or \ 183 | f'`{pascalcase(filter_field.lookup_expr)}` lookup' 184 | return field_type 185 | 186 | @classmethod 187 | def filterset_to_trees(cls, filterset_class: Type[AdvancedFilterSet]) -> List[Node]: 188 | """Convert a FilterSet class to trees.""" 189 | trees: List[Node] = [] 190 | for filter_value in filterset_class.base_filters.values(): 191 | values = (*filter_value.field_name.split(LOOKUP_SEP), filter_value.lookup_expr) 192 | if len(trees) == 0 or not any(cls.try_add_sequence(tree, values) for tree in trees): 193 | trees.append(cls.sequence_to_tree(values)) 194 | return trees 195 | 196 | @classmethod 197 | def try_add_sequence(cls, root: Node, values: Sequence[str]) -> bool: 198 | """Try to add a sequence to a tree. 199 | 200 | Return a flag indicating whether the mutation was made. 201 | """ 202 | if root.name == values[0]: 203 | for child in root.children: 204 | is_mutated = cls.try_add_sequence(child, values[1:]) 205 | if is_mutated: 206 | return True 207 | root.children = (*root.children, cls.sequence_to_tree(values[1:])) 208 | return True 209 | else: 210 | return False 211 | 212 | @staticmethod 213 | def sequence_to_tree(values: Sequence[str]) -> Node: 214 | """Convert a sequence to a tree.""" 215 | node: Optional[Node] = None 216 | for value in values: 217 | node = Node(name=value, parent=node) 218 | return node.root 219 | -------------------------------------------------------------------------------- /graphene_django_filter/filters.py: -------------------------------------------------------------------------------- 1 | """Additional filters for special lookups.""" 2 | 3 | from typing import Any, Callable, NamedTuple, Optional, Union 4 | 5 | from django.contrib.postgres.search import ( 6 | SearchQuery, 7 | SearchRank, 8 | SearchVector, 9 | TrigramDistance, 10 | TrigramSimilarity, 11 | ) 12 | from django.db import models 13 | from django.db.models.constants import LOOKUP_SEP 14 | from django_filters import Filter 15 | from django_filters.constants import EMPTY_VALUES 16 | 17 | 18 | class AnnotatedFilter(Filter): 19 | """Filter with a QuerySet object annotation.""" 20 | 21 | class Value(NamedTuple): 22 | annotation_value: Any 23 | search_value: Any 24 | 25 | postfix = 'annotated' 26 | 27 | def __init__( 28 | self, 29 | field_name: Optional[str] = None, 30 | lookup_expr: Optional[str] = None, 31 | *, 32 | label: Optional[str] = None, 33 | method: Optional[Union[str, Callable]] = None, 34 | distinct: bool = False, 35 | exclude: bool = False, 36 | **kwargs 37 | ) -> None: 38 | super().__init__( 39 | field_name, 40 | lookup_expr, 41 | label=label, 42 | method=method, 43 | distinct=distinct, 44 | exclude=exclude, 45 | **kwargs 46 | ) 47 | self.filter_counter = 0 48 | 49 | @property 50 | def annotation_name(self) -> str: 51 | """Return the name used for the annotation.""" 52 | return f'{self.field_name}_{self.postfix}_{self.creation_counter}_{self.filter_counter}' 53 | 54 | def filter(self, qs: models.QuerySet, value: Value) -> models.QuerySet: 55 | """Filter a QuerySet using annotation.""" 56 | if value in EMPTY_VALUES: 57 | return qs 58 | if self.distinct: 59 | qs = qs.distinct() 60 | annotation_name = self.annotation_name 61 | self.filter_counter += 1 62 | qs = qs.annotate(**{annotation_name: value.annotation_value}) 63 | lookup = f'{annotation_name}{LOOKUP_SEP}{self.lookup_expr}' 64 | return self.get_method(qs)(**{lookup: value.search_value}) 65 | 66 | 67 | class SearchQueryFilter(AnnotatedFilter): 68 | """Full text search filter using the `SearchVector` and `SearchQuery` object.""" 69 | 70 | class Value(NamedTuple): 71 | annotation_value: SearchVector 72 | search_value: SearchQuery 73 | 74 | postfix = 'search_query' 75 | available_lookups = ('exact',) 76 | 77 | def filter(self, qs: models.QuerySet, value: Value) -> models.QuerySet: 78 | """Filter a QuerySet using the `SearchVector` and `SearchQuery` object.""" 79 | return super().filter(qs, value) 80 | 81 | 82 | class SearchRankFilter(AnnotatedFilter): 83 | """Full text search filter using the `SearchRank` object.""" 84 | 85 | class Value(NamedTuple): 86 | annotation_value: SearchRank 87 | search_value: float 88 | 89 | postfix = 'search_rank' 90 | available_lookups = ('exact', 'gt', 'gte', 'lt', 'lte') 91 | 92 | def filter(self, qs: models.QuerySet, value: Value) -> models.QuerySet: 93 | """Filter a QuerySet using the `SearchRank` object.""" 94 | return super().filter(qs, value) 95 | 96 | 97 | class TrigramFilter(AnnotatedFilter): 98 | """Full text search filter using similarity or distance of trigram.""" 99 | 100 | class Value(NamedTuple): 101 | annotation_value: Union[TrigramSimilarity, TrigramDistance] 102 | search_value: float 103 | 104 | postfix = 'trigram' 105 | available_lookups = ('exact', 'gt', 'gte', 'lt', 'lte') 106 | 107 | def filter(self, qs: models.QuerySet, value: Value) -> models.QuerySet: 108 | """Filter a QuerySet using similarity or distance of trigram.""" 109 | return super().filter(qs, value) 110 | -------------------------------------------------------------------------------- /graphene_django_filter/filterset.py: -------------------------------------------------------------------------------- 1 | """`AdvancedFilterSet` class module. 2 | 3 | Use the `AdvancedFilterSet` class from this module instead of the `FilterSet` from django-filter. 4 | """ 5 | 6 | import warnings 7 | from collections import OrderedDict 8 | from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Type, Union, cast 9 | 10 | from django.db import connection, models 11 | from django.db.models.constants import LOOKUP_SEP 12 | from django.forms import Form 13 | from django.forms.utils import ErrorDict 14 | from django_filters import Filter 15 | from django_filters.conf import settings as django_settings 16 | from django_filters.filterset import BaseFilterSet, FilterSetMetaclass 17 | from wrapt import ObjectProxy 18 | 19 | from .conf import settings 20 | 21 | 22 | class QuerySetProxy(ObjectProxy): 23 | """Proxy for a QuerySet object. 24 | 25 | The Django-filter library works with QuerySet objects, 26 | but such objects do not provide the ability to apply the negation operator to the entire object. 27 | Therefore, it is convenient to work with the Q object instead of the QuerySet. 28 | This class replaces the original QuerySet object, 29 | and creates a Q object when calling the `filter` and `exclude` methods. 30 | """ 31 | 32 | __slots__ = 'q' 33 | 34 | def __init__(self, wrapped: models.QuerySet, q: Optional[models.Q] = None) -> None: 35 | super().__init__(wrapped) 36 | self.q = q or models.Q() 37 | 38 | def __getattr__(self, name: str) -> Any: 39 | """Return QuerySet attributes for all cases except `filter` and `exclude`.""" 40 | if name == 'filter': 41 | return self.filter_ 42 | elif name == 'exclude': 43 | return self.exclude_ 44 | attr = super().__getattr__(name) 45 | if callable(attr): 46 | def func(*args, **kwargs) -> Any: 47 | result = attr(*args, **kwargs) 48 | if isinstance(result, models.QuerySet): 49 | return QuerySetProxy(result, self.q) 50 | return result 51 | return func 52 | return attr 53 | 54 | def __iter__(self) -> Iterator[Any]: 55 | """Return QuerySet and Q objects.""" 56 | return iter([self.__wrapped__, self.q]) 57 | 58 | def filter_(self, *args, **kwargs) -> 'QuerySetProxy': 59 | """Replace the `filter` method of the QuerySet class.""" 60 | if len(kwargs) == 0 and len(args) == 1 and isinstance(args[0], models.Q): 61 | q = args[0] 62 | else: 63 | q = models.Q(*args, **kwargs) 64 | self.q = self.q & q 65 | return self 66 | 67 | def exclude_(self, *args, **kwargs) -> 'QuerySetProxy': 68 | """Replace the `exclude` method of the QuerySet class.""" 69 | if len(kwargs) == 0 and len(args) == 1 and isinstance(args[0], models.Q): 70 | q = args[0] 71 | else: 72 | q = models.Q(*args, **kwargs) 73 | self.q = self.q & ~q 74 | return self 75 | 76 | 77 | def is_full_text_search_lookup_expr(lookup_expr: str) -> bool: 78 | """Determine if a lookup_expr is a full text search expression.""" 79 | return lookup_expr.split(LOOKUP_SEP)[-1] == 'full_text_search' 80 | 81 | 82 | def is_regular_lookup_expr(lookup_expr: str) -> bool: 83 | """Determine whether the lookup_expr must be processed in a regular way.""" 84 | return not any([ 85 | is_full_text_search_lookup_expr(lookup_expr), 86 | ]) 87 | 88 | 89 | class AdvancedFilterSet(BaseFilterSet, metaclass=FilterSetMetaclass): 90 | """Allow you to use advanced filters.""" 91 | 92 | class TreeFormMixin(Form): 93 | """Tree-like form mixin.""" 94 | 95 | def __init__( 96 | self, 97 | and_forms: Optional[List['AdvancedFilterSet.TreeFormMixin']] = None, 98 | or_forms: Optional[List['AdvancedFilterSet.TreeFormMixin']] = None, 99 | not_form: Optional['AdvancedFilterSet.TreeFormMixin'] = None, 100 | *args, 101 | **kwargs 102 | ) -> None: 103 | super().__init__(*args, **kwargs) 104 | self.and_forms = and_forms or [] 105 | self.or_forms = or_forms or [] 106 | self.not_form = not_form 107 | 108 | @property 109 | def errors(self) -> ErrorDict: 110 | """Return an ErrorDict for the data provided for the form.""" 111 | self_errors: ErrorDict = super().errors 112 | for key in ('and', 'or'): 113 | errors: ErrorDict = ErrorDict() 114 | for i, form in enumerate(getattr(self, f'{key}_forms')): 115 | if form.errors: 116 | errors[f'{key}_{i}'] = form.errors 117 | if len(errors): 118 | self_errors.update({key: errors}) 119 | if self.not_form and self.not_form.errors: 120 | self_errors.update({'not': self.not_form.errors}) 121 | return self_errors 122 | 123 | def get_form_class(self) -> Type[Union[Form, TreeFormMixin]]: 124 | """Return a django Form class suitable of validating the filterset data. 125 | 126 | The form must be tree-like because the data is tree-like. 127 | """ 128 | form_class = super(AdvancedFilterSet, self).get_form_class() 129 | tree_form = cast( 130 | Type[Union[Form, AdvancedFilterSet.TreeFormMixin]], 131 | type( 132 | f'{form_class.__name__.replace("Form", "")}TreeForm', 133 | (form_class, AdvancedFilterSet.TreeFormMixin), 134 | {}, 135 | ), 136 | 137 | ) 138 | return tree_form 139 | 140 | @property 141 | def form(self) -> Union[Form, TreeFormMixin]: 142 | """Return a django Form suitable of validating the filterset data.""" 143 | if not hasattr(self, '_form'): 144 | form_class = self.get_form_class() 145 | if self.is_bound: 146 | self._form = self.create_form(form_class, self.data) 147 | else: 148 | self._form = form_class(prefix=self.form_prefix) 149 | return self._form 150 | 151 | def create_form( 152 | self, 153 | form_class: Type[Union[Form, TreeFormMixin]], 154 | data: Dict[str, Any], 155 | ) -> Union[Form, TreeFormMixin]: 156 | """Create a form from a form class and data.""" 157 | return form_class( 158 | data={k: v for k, v in data.items() if k not in ('and', 'or', 'not')}, 159 | and_forms=[self.create_form(form_class, and_data) for and_data in data.get('and', [])], 160 | or_forms=[self.create_form(form_class, or_data) for or_data in data.get('or', [])], 161 | not_form=self.create_form(form_class, data['not']) if data.get('not', None) else None, 162 | ) 163 | 164 | def find_filter(self, data_key: str) -> Filter: 165 | """Find a filter using a data key. 166 | 167 | The data key may differ from a filter name, because 168 | the data keys may contain DEFAULT_LOOKUP_EXPR and user can create 169 | a AdvancedFilterSet class without following the naming convention. 170 | """ 171 | if LOOKUP_SEP in data_key: 172 | field_name, lookup_expr = data_key.rsplit(LOOKUP_SEP, 1) 173 | else: 174 | field_name, lookup_expr = data_key, django_settings.DEFAULT_LOOKUP_EXPR 175 | key = field_name if lookup_expr == django_settings.DEFAULT_LOOKUP_EXPR else data_key 176 | if key in self.filters: 177 | return self.filters[key] 178 | for filter_value in self.filters.values(): 179 | if filter_value.field_name == field_name and filter_value.lookup_expr == lookup_expr: 180 | return filter_value 181 | 182 | def filter_queryset(self, queryset: models.QuerySet) -> models.QuerySet: 183 | """Filter a queryset with a top level form's `cleaned_data`.""" 184 | qs, q = self.get_queryset_proxy_for_form(queryset, self.form) 185 | return qs.filter(q) 186 | 187 | def get_queryset_proxy_for_form( 188 | self, 189 | queryset: models.QuerySet, 190 | form: Union[Form, TreeFormMixin], 191 | ) -> QuerySetProxy: 192 | """Return a `QuerySetProxy` object for a form's `cleaned_data`.""" 193 | qs = queryset 194 | q = models.Q() 195 | for name, value in form.cleaned_data.items(): 196 | qs, q = self.find_filter(name).filter(QuerySetProxy(qs, q), value) 197 | and_q = models.Q() 198 | for and_form in form.and_forms: 199 | qs, new_q = self.get_queryset_proxy_for_form(qs, and_form) 200 | and_q = and_q & new_q 201 | or_q = models.Q() 202 | for or_form in form.or_forms: 203 | qs, new_q = self.get_queryset_proxy_for_form(qs, or_form) 204 | or_q = or_q | new_q 205 | if form.not_form: 206 | qs, new_q = self.get_queryset_proxy_for_form(queryset, form.not_form) 207 | not_q = ~new_q 208 | else: 209 | not_q = models.Q() 210 | return QuerySetProxy(qs, q & and_q & or_q & not_q) 211 | 212 | @classmethod 213 | def get_filters(cls) -> OrderedDict: 214 | """Get all filters for the filterset. 215 | 216 | This is the combination of declared and generated filters. 217 | """ 218 | filters = super().get_filters() 219 | if not cls._meta.model: 220 | return filters 221 | return OrderedDict([ 222 | *filters.items(), 223 | *cls.create_full_text_search_filters(filters).items(), 224 | ]) 225 | 226 | @classmethod 227 | def create_full_text_search_filters( 228 | cls, 229 | base_filters: OrderedDict, 230 | ) -> OrderedDict: 231 | """Create available full text search filters.""" 232 | new_filters = OrderedDict() 233 | full_text_search_fields = cls.get_full_text_search_fields() 234 | if not len(full_text_search_fields): 235 | return new_filters 236 | if not settings.IS_POSTGRESQL: 237 | warnings.warn( 238 | f'Full text search is not available because the {connection.vendor} vendor is ' 239 | 'used instead of the postgresql vendor.', 240 | ) 241 | return new_filters 242 | from .filters import SearchQueryFilter, SearchRankFilter, TrigramFilter 243 | new_filters = OrderedDict([ 244 | *new_filters.items(), 245 | *cls.create_special_filters(base_filters, SearchQueryFilter).items(), 246 | *cls.create_special_filters(base_filters, SearchRankFilter).items(), 247 | ]) 248 | if not settings.HAS_TRIGRAM_EXTENSION: 249 | warnings.warn( 250 | 'Trigram search is not available because the `pg_trgm` extension is not installed.', 251 | ) 252 | return new_filters 253 | for field_name in full_text_search_fields: 254 | new_filters = OrderedDict([ 255 | *new_filters.items(), 256 | *cls.create_special_filters(base_filters, TrigramFilter, field_name).items(), 257 | ]) 258 | return new_filters 259 | 260 | @classmethod 261 | def create_special_filters( 262 | cls, 263 | base_filters: OrderedDict, 264 | filter_class: Union[Type[Filter], Any], 265 | field_name: Optional[str] = None, 266 | ) -> OrderedDict: 267 | """Create special filters using a filter class and a field name.""" 268 | new_filters = OrderedDict() 269 | for lookup_expr in filter_class.available_lookups: 270 | if field_name: 271 | postfix_field_name = f'{field_name}{LOOKUP_SEP}{filter_class.postfix}' 272 | else: 273 | postfix_field_name = filter_class.postfix 274 | filter_name = cls.get_filter_name(postfix_field_name, lookup_expr) 275 | if filter_name not in base_filters: 276 | new_filters[filter_name] = filter_class( 277 | field_name=postfix_field_name, 278 | lookup_expr=lookup_expr, 279 | ) 280 | return new_filters 281 | 282 | @classmethod 283 | def get_fields(cls) -> OrderedDict: 284 | """Resolve the `Meta.fields` argument including only regular lookups.""" 285 | return cls._get_fields(is_regular_lookup_expr) 286 | 287 | @classmethod 288 | def get_full_text_search_fields(cls) -> OrderedDict: 289 | """Resolve the `Meta.fields` argument including only full text search lookups.""" 290 | return cls._get_fields(is_full_text_search_lookup_expr) 291 | 292 | @classmethod 293 | def _get_fields(cls, predicate: Callable[[str], bool]) -> OrderedDict: 294 | """Resolve the `Meta.fields` argument including lookups that match the predicate.""" 295 | fields: List[Tuple[str, List[str]]] = [] 296 | for k, v in super().get_fields().items(): 297 | regular_field = [lookup_expr for lookup_expr in v if predicate(lookup_expr)] 298 | if len(regular_field): 299 | fields.append((k, regular_field)) 300 | return OrderedDict(fields) 301 | -------------------------------------------------------------------------------- /graphene_django_filter/filterset_factories.py: -------------------------------------------------------------------------------- 1 | """Functions for creating a FilterSet class.""" 2 | 3 | from typing import Optional, Type 4 | 5 | from graphene_django.filter.filterset import custom_filterset_factory, setup_filterset 6 | from graphene_django.filter.utils import replace_csv_filters 7 | 8 | from .filterset import AdvancedFilterSet 9 | 10 | 11 | def get_filterset_class( 12 | filterset_class: Optional[Type[AdvancedFilterSet]], 13 | **meta 14 | ) -> Type[AdvancedFilterSet]: 15 | """Get a class to be used as a FilterSet. 16 | 17 | It is a partial copy of the `get_filterset_class` function from graphene-django. 18 | https://github.com/graphql-python/graphene-django/blob/caf954861025b9f3d9d3f9c204a7cbbc87352265/graphene_django/filter/utils.py#L56 19 | """ 20 | if filterset_class: 21 | graphene_filterset_class = setup_filterset(filterset_class) 22 | else: 23 | graphene_filterset_class = custom_filterset_factory( 24 | filterset_base_class=AdvancedFilterSet, 25 | **meta 26 | ) 27 | replace_csv_filters(graphene_filterset_class) 28 | return graphene_filterset_class 29 | -------------------------------------------------------------------------------- /graphene_django_filter/input_data_factories.py: -------------------------------------------------------------------------------- 1 | """Functions for converting tree data into data suitable for the FilterSet.""" 2 | 3 | from typing import Any, Dict, List, Type, Union 4 | 5 | from django.contrib.postgres.search import ( 6 | SearchQuery, 7 | SearchRank, 8 | SearchVector, 9 | TrigramDistance, 10 | TrigramSimilarity, 11 | ) 12 | from django.core.exceptions import ValidationError 13 | from django.db import models 14 | from django.db.models.constants import LOOKUP_SEP 15 | from django_filters.conf import settings as django_settings 16 | from graphene.types.inputobjecttype import InputObjectTypeContainer 17 | from graphene_django_filter.filters import SearchQueryFilter, SearchRankFilter, TrigramFilter 18 | from graphene_django_filter.input_types import ( 19 | SearchConfigInputType, 20 | SearchQueryFilterInputType, 21 | SearchQueryInputType, 22 | SearchRankFilterInputType, 23 | SearchRankWeightsInputType, 24 | SearchVectorInputType, 25 | TrigramFilterInputType, 26 | TrigramSearchKind, 27 | ) 28 | 29 | from .conf import settings 30 | from .filterset import AdvancedFilterSet 31 | 32 | 33 | def tree_input_type_to_data( 34 | filterset_class: Type[AdvancedFilterSet], 35 | tree_input_type: InputObjectTypeContainer, 36 | prefix: str = '', 37 | ) -> Dict[str, Any]: 38 | """Convert a tree_input_type to a FilterSet data.""" 39 | result: Dict[str, Any] = {} 40 | for key, value in tree_input_type.items(): 41 | if key in ('and', 'or'): 42 | result[key] = [tree_input_type_to_data(filterset_class, subtree) for subtree in value] 43 | elif key == 'not': 44 | result[key] = tree_input_type_to_data(filterset_class, value) 45 | else: 46 | result.update( 47 | create_data( 48 | (prefix + LOOKUP_SEP + key if prefix else key).replace( 49 | LOOKUP_SEP + django_settings.DEFAULT_LOOKUP_EXPR, '', 50 | ), 51 | value, 52 | filterset_class, 53 | ), 54 | ) 55 | return result 56 | 57 | 58 | def create_data(key: str, value: Any, filterset_class: Type[AdvancedFilterSet]) -> Dict[str, Any]: 59 | """Create data from a key and a value.""" 60 | for factory_key, factory in DATA_FACTORIES.items(): 61 | if factory_key in key: 62 | return factory(value, key, filterset_class) 63 | if isinstance(value, InputObjectTypeContainer): 64 | return tree_input_type_to_data(filterset_class, value, key) 65 | else: 66 | return {key: value} 67 | 68 | 69 | def create_search_query_data( 70 | input_type: SearchQueryFilterInputType, 71 | key: str, 72 | filterset_class: Type[AdvancedFilterSet], 73 | ) -> Dict[str, SearchQueryFilter.Value]: 74 | """Create a data for the `SearchQueryFilter` class.""" 75 | return { 76 | key: SearchQueryFilter.Value( 77 | annotation_value=create_search_vector(input_type.vector, filterset_class), 78 | search_value=create_search_query(input_type.query), 79 | ), 80 | } 81 | 82 | 83 | def create_search_rank_data( 84 | input_type: Union[SearchRankFilterInputType, InputObjectTypeContainer], 85 | key: str, 86 | filterset_class: Type[AdvancedFilterSet], 87 | ) -> Dict[str, SearchRankFilter.Value]: 88 | """Create a data for the `SearchRankFilter` class.""" 89 | rank_data = {} 90 | for lookup, value in input_type.lookups.items(): 91 | search_rank_data = { 92 | 'vector': create_search_vector(input_type.vector, filterset_class), 93 | 'query': create_search_query(input_type.query), 94 | 'cover_density': input_type.cover_density, 95 | } 96 | weights = input_type.get('weights', None) 97 | if weights: 98 | search_rank_data['weights'] = create_search_rank_weights(weights) 99 | normalization = input_type.get('normalization', None) 100 | if normalization: 101 | search_rank_data['normalization'] = normalization 102 | k = (key + LOOKUP_SEP + lookup).replace( 103 | LOOKUP_SEP + django_settings.DEFAULT_LOOKUP_EXPR, '', 104 | ) 105 | rank_data[k] = SearchRankFilter.Value( 106 | annotation_value=SearchRank(**search_rank_data), 107 | search_value=value, 108 | ) 109 | return rank_data 110 | 111 | 112 | def create_trigram_data( 113 | input_type: TrigramFilterInputType, 114 | key: str, 115 | *args 116 | ) -> Dict[str, TrigramFilter.Value]: 117 | """Create a data for the `TrigramFilter` class.""" 118 | trigram_data = {} 119 | if input_type.kind == TrigramSearchKind.SIMILARITY: 120 | trigram_class = TrigramSimilarity 121 | else: 122 | trigram_class = TrigramDistance 123 | for lookup, value in input_type.lookups.items(): 124 | k = (key + LOOKUP_SEP + lookup).replace( 125 | LOOKUP_SEP + django_settings.DEFAULT_LOOKUP_EXPR, '', 126 | ) 127 | trigram_data[k] = TrigramFilter.Value( 128 | annotation_value=trigram_class( 129 | LOOKUP_SEP.join(key.split(LOOKUP_SEP)[:-1]), 130 | input_type.value, 131 | ), 132 | search_value=value, 133 | ) 134 | return trigram_data 135 | 136 | 137 | def create_search_vector( 138 | input_type: Union[SearchVectorInputType, InputObjectTypeContainer], 139 | filterset_class: Type[AdvancedFilterSet], 140 | ) -> SearchVector: 141 | """Create an object of the `SearchVector` class.""" 142 | validate_search_vector_fields(filterset_class, input_type.fields) 143 | search_vector_data = {} 144 | config = input_type.get('config', None) 145 | if config: 146 | search_vector_data['config'] = create_search_config(config) 147 | weight = input_type.get('weight', None) 148 | if weight: 149 | search_vector_data['weight'] = weight.value 150 | return SearchVector(*input_type.fields, **search_vector_data) 151 | 152 | 153 | def create_search_query( 154 | input_type: Union[SearchQueryInputType, InputObjectTypeContainer], 155 | ) -> SearchQuery: 156 | """Create an object of the `SearchQuery` class.""" 157 | validate_search_query(input_type) 158 | value = input_type.get('value', None) 159 | if value: 160 | config = input_type.get('config', None) 161 | if config: 162 | search_query = SearchQuery(input_type.value, config=create_search_config(config)) 163 | else: 164 | search_query = SearchQuery(input_type.value) 165 | else: 166 | search_query = None 167 | and_search_query = None 168 | for and_input_type in input_type.get(settings.AND_KEY, []): 169 | if and_search_query is None: 170 | and_search_query = create_search_query(and_input_type) 171 | else: 172 | and_search_query = and_search_query & create_search_query(and_input_type) 173 | or_search_query = None 174 | for or_input_type in input_type.get(settings.OR_KEY, []): 175 | if or_search_query is None: 176 | or_search_query = create_search_query(or_input_type) 177 | else: 178 | or_search_query = or_search_query | create_search_query(or_input_type) 179 | not_input_type = input_type.get(settings.NOT_KEY, None) 180 | not_search_query = create_search_query(not_input_type) if not_input_type else None 181 | valid_queries = ( 182 | q for q in (and_search_query, or_search_query, not_search_query) if q is not None 183 | ) 184 | for valid_query in valid_queries: 185 | search_query = search_query & valid_query if search_query else valid_query 186 | return search_query 187 | 188 | 189 | def create_search_config(input_type: SearchConfigInputType) -> Union[str, models.F]: 190 | """Create a `SearchVector` or `SearchQuery` object config.""" 191 | return models.F(input_type.value) if input_type.is_field else input_type.value 192 | 193 | 194 | def create_search_rank_weights(input_type: SearchRankWeightsInputType) -> List[float]: 195 | """Create a search rank weights list.""" 196 | return [input_type.D, input_type.C, input_type.B, input_type.A] 197 | 198 | 199 | def validate_search_vector_fields( 200 | filterset_class: Type[AdvancedFilterSet], 201 | fields: List[str], 202 | ) -> None: 203 | """Validate that fields is included in full text search fields.""" 204 | full_text_search_fields = filterset_class.get_full_text_search_fields() 205 | for field in fields: 206 | if field not in full_text_search_fields: 207 | raise ValidationError(f'The `{field}` field is not included in full text search fields') 208 | 209 | 210 | def validate_search_query( 211 | input_type: Union[SearchQueryInputType, InputObjectTypeContainer], 212 | ) -> None: 213 | """Validate that search query contains at least one required field.""" 214 | if all([ 215 | 'value' not in input_type, 216 | settings.AND_KEY not in input_type, 217 | settings.OR_KEY not in input_type, 218 | settings.NOT_KEY not in input_type, 219 | ]): 220 | raise ValidationError( 221 | 'The search query must contains at least one required field ' 222 | f'such as `value`, `{settings.AND_KEY}`, `{settings.OR_KEY}`, `{settings.NOT_KEY}`.', 223 | ) 224 | 225 | 226 | DATA_FACTORIES = { 227 | SearchQueryFilter.postfix: create_search_query_data, 228 | SearchRankFilter.postfix: create_search_rank_data, 229 | TrigramFilter.postfix: create_trigram_data, 230 | } 231 | -------------------------------------------------------------------------------- /graphene_django_filter/input_types.py: -------------------------------------------------------------------------------- 1 | """InputObjectType classes for special lookups.""" 2 | 3 | from typing import Type, cast 4 | 5 | import graphene 6 | 7 | from .conf import settings 8 | 9 | 10 | class SearchConfigInputType(graphene.InputObjectType): 11 | """Input type for the `SearchVector` or `SearchQuery` object config.""" 12 | 13 | value = graphene.String( 14 | required=True, 15 | description='`SearchVector` or `SearchQuery` object config value', 16 | ) 17 | is_field = graphene.Boolean( 18 | default_value=False, 19 | description='Whether to wrap the value with the F object', 20 | ) 21 | 22 | 23 | class SearchVectorWeight(graphene.Enum): 24 | """Weight of the `SearchVector` object.""" 25 | 26 | A = 'A' 27 | B = 'B' 28 | C = 'C' 29 | D = 'D' 30 | 31 | 32 | class SearchVectorInputType(graphene.InputObjectType): 33 | """Input type for creating the `SearchVector` object.""" 34 | 35 | fields = graphene.InputField( 36 | graphene.List(graphene.NonNull(graphene.String)), 37 | required=True, 38 | description='Field names of vector', 39 | ) 40 | config = graphene.InputField(SearchConfigInputType, description='Vector config'), 41 | weight = graphene.InputField(SearchVectorWeight, description='Vector weight') 42 | 43 | 44 | class SearchQueryType(graphene.Enum): 45 | """Search type of the `SearchQuery` object.""" 46 | 47 | PLAIN = 'plain' 48 | PHRASE = 'phrase' 49 | RAW = 'raw' 50 | WEBSEARCH = 'websearch' 51 | 52 | 53 | def create_search_query_input_type() -> Type[graphene.InputObjectType]: 54 | """Return input type for creating the `SearchQuery` object.""" 55 | search_query_input_type = cast( 56 | Type[graphene.InputObjectType], 57 | type( 58 | 'SearchQueryInputType', 59 | (graphene.InputObjectType,), 60 | { 61 | '__doc__': 'Input type for creating the `SearchQuery` object.', 62 | 'value': graphene.String(description='Query value'), 63 | 'config': graphene.InputField(SearchConfigInputType, description='Query config'), 64 | settings.AND_KEY: graphene.InputField( 65 | graphene.List(graphene.NonNull(lambda: search_query_input_type)), 66 | description='`And` field', 67 | ), 68 | settings.OR_KEY: graphene.InputField( 69 | graphene.List(graphene.NonNull(lambda: search_query_input_type)), 70 | description='`Or` field', 71 | ), 72 | settings.NOT_KEY: graphene.InputField( 73 | graphene.List(graphene.NonNull(lambda: search_query_input_type)), 74 | description='`Not` field', 75 | ), 76 | }, 77 | ), 78 | ) 79 | return search_query_input_type 80 | 81 | 82 | SearchQueryInputType = create_search_query_input_type() 83 | 84 | 85 | class SearchQueryFilterInputType(graphene.InputObjectType): 86 | """Input type for the full text search using the `SearchVector` and `SearchQuery` object.""" 87 | 88 | vector = graphene.InputField(SearchVectorInputType, required=True, description='Search vector') 89 | query = graphene.InputField(SearchQueryInputType, required=True, description='Search query') 90 | 91 | 92 | class FloatLookupsInputType(graphene.InputObjectType): 93 | """Input type for float lookups.""" 94 | 95 | exact = graphene.Float(description='Is exact') 96 | gt = graphene.Float(description='Is greater than') 97 | gte = graphene.Float(description='Is greater than or equal to') 98 | lt = graphene.Float(description='Is less than') 99 | lte = graphene.Float(description='Is less than or equal to') 100 | 101 | 102 | class SearchRankWeightsInputType(graphene.InputObjectType): 103 | """`SearchRank` object weights. 104 | 105 | Default values are set according to the documentation. 106 | https://docs.djangoproject.com/en/3.2/ref/contrib/postgres/search/#weighting-queries 107 | """ 108 | 109 | D = graphene.Float(default_value=0.1, description='D letter') 110 | C = graphene.Float(default_value=0.2, description='C letter') 111 | B = graphene.Float(default_value=0.4, description='B letter') 112 | A = graphene.Float(default_value=1.0, description='A letter') 113 | 114 | 115 | class SearchRankFilterInputType(graphene.InputObjectType): 116 | """Input type for the full text search using the `SearchRank` object.""" 117 | 118 | vector = graphene.InputField(SearchVectorInputType, required=True, description='Search vector') 119 | query = graphene.InputField(SearchQueryInputType, required=True, description='Search query') 120 | lookups = graphene.InputField( 121 | FloatLookupsInputType, 122 | required=True, 123 | description='Available lookups', 124 | ) 125 | weights = graphene.InputField(SearchRankWeightsInputType, description='Search rank weights') 126 | cover_density = graphene.Boolean( 127 | default_value=False, 128 | description='Whether to include coverage density ranking', 129 | ) 130 | normalization = graphene.Int(description='Search rank normalization') 131 | 132 | 133 | class TrigramSearchKind(graphene.Enum): 134 | """Type of the search using trigrams.""" 135 | 136 | SIMILARITY = 'similarity' 137 | DISTANCE = 'distance' 138 | 139 | 140 | class TrigramFilterInputType(graphene.InputObjectType): 141 | """Input type for the full text search using similarity or distance of trigram.""" 142 | 143 | kind = graphene.InputField( 144 | TrigramSearchKind, 145 | default_value=TrigramSearchKind.SIMILARITY, 146 | description='Type of the search using trigrams', 147 | ) 148 | lookups = graphene.InputField( 149 | FloatLookupsInputType, 150 | required=True, 151 | description='Available lookups', 152 | ) 153 | value = graphene.String(required=True, description='Search value') 154 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | """Django's command-line utility for administrative tasks.""" 2 | 3 | import os 4 | import sys 5 | 6 | from django.core.management import execute_from_command_line 7 | 8 | 9 | def main() -> None: 10 | """Run administrative tasks.""" 11 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') 12 | execute_from_command_line(sys.argv) 13 | 14 | 15 | if __name__ == '__main__': 16 | main() 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "graphene-django-filter" 3 | version = "0.6.5" 4 | description = "Advanced filters for Graphene" 5 | authors = ["devind-team "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/devind-team/graphene-django-filter" 9 | repository = "https://github.com/devind-team/graphene-django-filter" 10 | keywords = ["django", "graphene", "filter"] 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | "Framework :: Django" 16 | ] 17 | 18 | [tool.poetry.dependencies] 19 | python = ">=3.8,<4.0" 20 | Django = ">=3.2,<6" 21 | graphene = ">=2.1.9,<4" 22 | graphene-django = "^3.0.0" 23 | django-filter = ">=21.1" 24 | psycopg2-binary = ">=2.9.3,!=2.9.6" 25 | stringcase = "^1.2.0" 26 | anytree = "^2.8.0" 27 | wrapt = "^1.14.0" 28 | 29 | [tool.poetry.dev-dependencies] 30 | flake8 = "^4.0.1" 31 | flake8-import-order = "^0.18.1" 32 | flake8-docstrings = "^1.6.0" 33 | flake8-builtins = "^1.5.3" 34 | flake8-quotes = "^3.3.1" 35 | flake8-comprehensions = "^3.8.0" 36 | flake8-eradicate = "^1.2.1" 37 | flake8-simplify = "^0.19.2" 38 | flake8-use-fstring = "^1.3" 39 | flake8-annotations = "^2.9.0" 40 | pep8-naming = "^0.12.1" 41 | django-seed = "^0.3.1" 42 | python-dotenv = "^0.20.0" 43 | pre-commit = "^2.19.0" 44 | coveralls = ">=3.3.1" 45 | 46 | [build-system] 47 | requires = ["poetry-core>=1.0.0"] 48 | build-backend = "poetry.core.masonry.api" 49 | 50 | [tool.semantic_release] 51 | version_variable = [ 52 | "graphene_django_filter/__init__.py:__version__", 53 | "pyproject.toml:version" 54 | ] 55 | branch = "main" 56 | upload_to_pypi = true 57 | upload_to_release = true 58 | build_command = "pip install poetry && poetry build" 59 | -------------------------------------------------------------------------------- /tests/.env.example: -------------------------------------------------------------------------------- 1 | DB_HOST=localhost 2 | DB_NAME=graphene_django_filter 3 | DB_USER=postgres 4 | DB_PASSWORD=postgres 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for graphene-django-filter project.""" 2 | -------------------------------------------------------------------------------- /tests/data_generation.py: -------------------------------------------------------------------------------- 1 | """Data generation for testing.""" 2 | 3 | from datetime import datetime 4 | from itertools import count 5 | 6 | from django.db import connection 7 | from django.utils.timezone import make_aware 8 | from django_seed import Seed 9 | from django_seed.seeder import Seeder 10 | 11 | from .models import Task, TaskGroup, User 12 | 13 | 14 | def generate_data() -> None: 15 | """Generate data for testing.""" 16 | reset_sequences() 17 | seeder: Seeder = Seed.seeder() 18 | generate_users(seeder) 19 | generate_tasks(seeder) 20 | generate_task_groups(seeder) 21 | seeder.execute() 22 | set_task_groups_tasks() 23 | 24 | 25 | def reset_sequences() -> None: 26 | """Reset database sequences.""" 27 | with connection.cursor() as cursor: 28 | for model in (User, Task, TaskGroup): 29 | cursor.execute(f'ALTER SEQUENCE {model._meta.db_table}_id_seq RESTART WITH 1;') 30 | 31 | 32 | def generate_users(seeder: Seeder) -> None: 33 | """Generate user data for testing.""" 34 | number_generator = iter(count(1)) 35 | seeder.add_entity( 36 | User, 5, { 37 | 'birthday': datetime.strptime('01/01/2000', '%m/%d/%Y'), 38 | 'first_name': 'Bob', 39 | 'last_name': 'Smith', 40 | 'is_active': False, 41 | }, 42 | ) 43 | seeder.add_entity( 44 | User, 10, { 45 | 'email': lambda ie: f'alice{next(number_generator)}@domain.com', 46 | 'first_name': 'Alice', 47 | 'last_name': 'Stone', 48 | 'is_active': False, 49 | }, 50 | ) 51 | seeder.add_entity( 52 | User, 15, { 53 | 'email': lambda ie: f'alice{next(number_generator)}@domain.com', 54 | 'first_name': 'Alice', 55 | 'last_name': 'Stone', 56 | 'is_active': True, 57 | }, 58 | ) 59 | number_generator = iter(count(1)) 60 | seeder.add_entity( 61 | User, 20, { 62 | 'email': lambda ie: f'jane_doe{next(number_generator)}@domain.com', 63 | 'first_name': 'Jane', 64 | 'last_name': 'Dou', 65 | 'is_active': True, 66 | }, 67 | ) 68 | number_generator = iter(count(1)) 69 | seeder.add_entity( 70 | User, 25, { 71 | 'email': lambda ie: f'john_doe{next(number_generator)}@domain.com', 72 | 'first_name': 'John', 73 | 'last_name': 'Dou', 74 | 'is_active': True, 75 | }, 76 | ) 77 | 78 | 79 | def generate_tasks(seeder: Seeder) -> None: 80 | """Generate task data for testing.""" 81 | seeder.add_entity( 82 | Task, 15, { 83 | 'user_id': 1, 84 | 'created_at': make_aware(datetime.strptime('01/01/2019', '%m/%d/%Y')), 85 | 'completed_at': make_aware(datetime.strptime('02/01/2019', '%m/%d/%Y')), 86 | }, 87 | ) 88 | seeder.add_entity( 89 | Task, 15, { 90 | 'user_id': 2, 91 | 'description': 'This task is very important', 92 | 'created_at': make_aware(datetime.strptime('01/01/2020', '%m/%d/%Y')), 93 | 'completed_at': make_aware(datetime.strptime('02/01/2020', '%m/%d/%Y')), 94 | }, 95 | ) 96 | number_generator = iter(count(1)) 97 | seeder.add_entity( 98 | Task, 45, { 99 | 'user_id': 3, 100 | 'name': lambda ie: f'Important task №{next(number_generator)}', 101 | 'created_at': make_aware(datetime.strptime('01/01/2021', '%m/%d/%Y')), 102 | 'completed_at': make_aware(datetime.strptime('02/01/2021', '%m/%d/%Y')), 103 | }, 104 | ) 105 | 106 | 107 | def generate_task_groups(seeder: Seeder) -> None: 108 | """Generate task groups data for testing.""" 109 | number_generator = iter(count(1)) 110 | priority_generator = iter(count(1)) 111 | seeder.add_entity( 112 | TaskGroup, 15, { 113 | 'name': lambda ie: f'Task group №{next(number_generator)}', 114 | 'priority': lambda ie: next(priority_generator), 115 | }, 116 | ) 117 | 118 | 119 | def set_task_groups_tasks() -> None: 120 | """Set tasks for task groups after data generation.""" 121 | for i, task_group in enumerate(TaskGroup.objects.all()): 122 | task_group.tasks.set(range(i * 5 + 1, i * 5 + 5 + 1)) 123 | -------------------------------------------------------------------------------- /tests/filtersets.py: -------------------------------------------------------------------------------- 1 | """FilterSet classes.""" 2 | 3 | from graphene_django_filter import AdvancedFilterSet 4 | 5 | from .models import Task, TaskGroup, User 6 | 7 | 8 | class UserFilter(AdvancedFilterSet): 9 | """User FilterSet class for testing.""" 10 | 11 | class Meta: 12 | model = User 13 | fields = { 14 | 'email': ('exact', 'startswith', 'contains'), 15 | 'first_name': ('exact', 'contains', 'full_text_search'), 16 | 'last_name': ('exact', 'contains', 'full_text_search'), 17 | 'is_active': ('exact',), 18 | 'birthday': ('exact',), 19 | } 20 | 21 | 22 | class TaskFilter(AdvancedFilterSet): 23 | """Task FilterSet class for testing.""" 24 | 25 | class Meta: 26 | model = Task 27 | fields = { 28 | 'name': ('exact', 'contains', 'full_text_search'), 29 | 'created_at': ('gt',), 30 | 'completed_at': ('lt',), 31 | 'description': ('exact', 'contains'), 32 | 'user': ('exact', 'in'), 33 | 'user__email': ('exact', 'iexact', 'contains', 'icontains'), 34 | 'user__last_name': ('exact', 'contains'), 35 | } 36 | 37 | 38 | class TaskGroupFilter(AdvancedFilterSet): 39 | """TaskGroup FilterSet class for testing.""" 40 | 41 | class Meta: 42 | model = TaskGroup 43 | fields = { 44 | 'name': ('exact', 'contains', 'full_text_search'), 45 | 'priority': ('exact', 'gte', 'lte'), 46 | 'tasks': ('exact',), 47 | } 48 | -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-02-15 19:31 2 | 3 | import django.db.models.deletion 4 | from django.contrib.postgres.operations import TrigramExtension 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | TrigramExtension(), 17 | migrations.CreateModel( 18 | name='Task', 19 | fields=[ 20 | ( 21 | 'id', 22 | models.AutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name='ID', 27 | ), 28 | ), 29 | ('name', models.CharField(max_length=256)), 30 | ('created_at', models.DateTimeField(auto_now_add=True)), 31 | ('completed_at', models.DateTimeField(null=True)), 32 | ('description', models.TextField()), 33 | ], 34 | ), 35 | migrations.CreateModel( 36 | name='User', 37 | fields=[ 38 | ( 39 | 'id', 40 | models.AutoField( 41 | auto_created=True, 42 | primary_key=True, 43 | serialize=False, 44 | verbose_name='ID', 45 | ), 46 | ), 47 | ('email', models.EmailField(max_length=254, unique=True)), 48 | ('first_name', models.CharField(max_length=128)), 49 | ('last_name', models.CharField(max_length=128)), 50 | ('is_active', models.BooleanField(default=True)), 51 | ('birthday', models.DateField(null=True)), 52 | ], 53 | ), 54 | migrations.CreateModel( 55 | name='TaskGroup', 56 | fields=[ 57 | ( 58 | 'id', 59 | models.AutoField( 60 | auto_created=True, 61 | primary_key=True, 62 | serialize=False, 63 | verbose_name='ID', 64 | ), 65 | ), 66 | ('name', models.CharField(max_length=256)), 67 | ('priority', models.PositiveSmallIntegerField(default=0)), 68 | ('tasks', models.ManyToManyField(to='tests.Task')), 69 | ], 70 | ), 71 | migrations.AddField( 72 | model_name='task', 73 | name='user', 74 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.user'), 75 | ), 76 | ] 77 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | """Migrations for graphene-django-filter tests.""" 2 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | """Django models for testing.""" 2 | 3 | from django.db import models 4 | 5 | 6 | class User(models.Model): 7 | """User model.""" 8 | 9 | email = models.EmailField(unique=True) 10 | first_name = models.CharField(max_length=128) 11 | last_name = models.CharField(max_length=128) 12 | is_active = models.BooleanField(default=True) 13 | birthday = models.DateField(null=True) 14 | 15 | 16 | class Task(models.Model): 17 | """Task model.""" 18 | 19 | name = models.CharField(max_length=256) 20 | created_at = models.DateTimeField(auto_now_add=True) 21 | completed_at = models.DateTimeField(null=True) 22 | description = models.TextField() 23 | 24 | user = models.ForeignKey(User, on_delete=models.CASCADE) 25 | 26 | 27 | class TaskGroup(models.Model): 28 | """Task group model.""" 29 | 30 | name = models.CharField(max_length=256) 31 | priority = models.PositiveSmallIntegerField(default=0) 32 | tasks = models.ManyToManyField(Task) 33 | -------------------------------------------------------------------------------- /tests/object_types.py: -------------------------------------------------------------------------------- 1 | """DjangoObjectType classes.""" 2 | 3 | import graphene 4 | from graphene_django import DjangoObjectType 5 | 6 | from .filtersets import TaskFilter, TaskGroupFilter, UserFilter 7 | from .models import Task, TaskGroup, User 8 | 9 | 10 | class UserFilterFieldsType(DjangoObjectType): 11 | """UserType with the `filter_fields` field in the Meta class.""" 12 | 13 | class Meta: 14 | model = User 15 | interfaces = (graphene.relay.Node,) 16 | fields = '__all__' 17 | filter_fields = { 18 | 'email': ('exact', 'startswith', 'contains'), 19 | 'first_name': ('exact', 'contains', 'full_text_search'), 20 | 'last_name': ('exact', 'contains', 'full_text_search'), 21 | 'is_active': ('exact',), 22 | 'birthday': ('exact',), 23 | } 24 | 25 | 26 | class UserFilterSetClassType(DjangoObjectType): 27 | """UserType with the `filterset_class` field in the Meta class.""" 28 | 29 | class Meta: 30 | model = User 31 | interfaces = (graphene.relay.Node,) 32 | fields = '__all__' 33 | filterset_class = UserFilter 34 | 35 | 36 | class TaskFilterFieldsType(DjangoObjectType): 37 | """TaskType with the `filter_fields` field in the Meta class.""" 38 | 39 | user = graphene.Field(UserFilterFieldsType, description='User field') 40 | 41 | class Meta: 42 | model = Task 43 | interfaces = (graphene.relay.Node,) 44 | fields = '__all__' 45 | filter_fields = { 46 | 'name': ('exact', 'contains', 'full_text_search'), 47 | 'created_at': ('gt',), 48 | 'completed_at': ('lt',), 49 | 'description': ('exact', 'contains'), 50 | 'user': ('exact', 'in'), 51 | 'user__email': ('exact', 'iexact', 'contains', 'icontains'), 52 | 'user__last_name': ('exact', 'contains'), 53 | } 54 | 55 | 56 | class TaskFilterSetClassType(DjangoObjectType): 57 | """TaskType with the `filterset_class` field in the Meta class.""" 58 | 59 | user = graphene.Field(UserFilterSetClassType, description='User field') 60 | 61 | class Meta: 62 | model = Task 63 | interfaces = (graphene.relay.Node,) 64 | fields = ('name', 'user') 65 | filterset_class = TaskFilter 66 | 67 | 68 | class TaskGroupFilterFieldsType(DjangoObjectType): 69 | """TaskGroupType with the `filter_fields` field in the Meta class.""" 70 | 71 | tasks = graphene.List(graphene.NonNull(TaskFilterFieldsType), description='Tasks field') 72 | 73 | class Meta: 74 | model = TaskGroup 75 | interfaces = (graphene.relay.Node,) 76 | fields = '__all__' 77 | filter_fields = { 78 | 'name': ('exact', 'contains', 'full_text_search'), 79 | 'priority': ('exact', 'gte', 'lte'), 80 | 'tasks': ('exact',), 81 | } 82 | 83 | 84 | class TaskGroupFilterSetClassType(DjangoObjectType): 85 | """TaskGroupType with the `filterset_class` field in the Meta class.""" 86 | 87 | tasks = graphene.List(graphene.NonNull(TaskFilterSetClassType), description='Tasks field') 88 | 89 | class Meta: 90 | model = TaskGroup 91 | interfaces = (graphene.relay.Node,) 92 | fields = '__all__' 93 | filterset_class = TaskGroupFilter 94 | -------------------------------------------------------------------------------- /tests/schema.py: -------------------------------------------------------------------------------- 1 | """GraphQL schema.""" 2 | 3 | import graphene 4 | from graphene_django_filter import AdvancedDjangoFilterConnectionField 5 | 6 | from .object_types import ( 7 | TaskFilterFieldsType, 8 | TaskFilterSetClassType, 9 | TaskGroupFilterFieldsType, 10 | TaskGroupFilterSetClassType, 11 | UserFilterFieldsType, 12 | UserFilterSetClassType, 13 | ) 14 | 15 | 16 | class Query(graphene.ObjectType): 17 | """Schema queries.""" 18 | 19 | users_fields = AdvancedDjangoFilterConnectionField( 20 | UserFilterFieldsType, 21 | description='Advanced filter fields with the `UserFilterFieldsType` type', 22 | ) 23 | users_filterset = AdvancedDjangoFilterConnectionField( 24 | UserFilterSetClassType, 25 | filter_input_type_prefix='UserFilterSetClass', 26 | description='Advanced filter fields with the `UserFilterSetClassType` type', 27 | ) 28 | tasks_fields = AdvancedDjangoFilterConnectionField( 29 | TaskFilterFieldsType, 30 | description='Advanced filter field with the `TaskFilterFieldsType` type', 31 | ) 32 | tasks_filterset = AdvancedDjangoFilterConnectionField( 33 | TaskFilterSetClassType, 34 | filter_input_type_prefix='TaskFilterSetClass', 35 | description='Advanced filter field with the `TaskFilterSetClassType` type', 36 | ) 37 | task_groups_fields = AdvancedDjangoFilterConnectionField( 38 | TaskGroupFilterFieldsType, 39 | description='Advanced filter field with the `TaskGroupFilterFieldsType` type', 40 | ) 41 | task_groups_filterset = AdvancedDjangoFilterConnectionField( 42 | TaskGroupFilterSetClassType, 43 | filter_input_type_prefix='TaskGroupFilterSetClass', 44 | description='Advanced filter field with the `TaskGroupFilterSetClassType` type', 45 | ) 46 | 47 | 48 | schema = graphene.Schema(query=Query) 49 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | """Django settings for graphene-django-filter project.""" 2 | 3 | import os 4 | from os.path import join 5 | from pathlib import Path 6 | 7 | from dotenv import load_dotenv 8 | 9 | BASE_DIR = Path(__file__).resolve(strict=True).parent 10 | 11 | ENV_PATH = join(BASE_DIR, '.env') 12 | 13 | load_dotenv(dotenv_path=ENV_PATH) 14 | 15 | DATABASES = { 16 | 'default': { 17 | 'ENGINE': 'django.db.backends.postgresql', 18 | 'HOST': os.getenv('DB_HOST', 'localhost'), 19 | 'NAME': os.getenv('DB_NAME', 'graphene_django_filter'), 20 | 'USER': os.getenv('DB_USER', 'postgres'), 21 | 'PASSWORD': os.getenv('DB_PASSWORD', 'postgres'), 22 | }, 23 | } 24 | 25 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 26 | 27 | INSTALLED_APPS = ( 28 | 'django.contrib.contenttypes', 29 | 'django.contrib.auth', 30 | 'django_filters', 31 | 'django_seed', 32 | 'graphene_django_filter', 33 | 'tests', 34 | ) 35 | 36 | MIDDLEWARE = [] 37 | 38 | USE_TZ = True 39 | 40 | TIME_ZONE = 'UTC' 41 | -------------------------------------------------------------------------------- /tests/test_conf.py: -------------------------------------------------------------------------------- 1 | """Library settings tests.""" 2 | 3 | from django.test import TestCase, override_settings 4 | from graphene_django_filter import conf 5 | 6 | 7 | class SettingsTests(TestCase): 8 | """Library settings tests.""" 9 | 10 | def test_initial(self) -> None: 11 | """Test initial settings.""" 12 | self.assertTrue(conf.settings.IS_POSTGRESQL) 13 | self.assertTrue(conf.settings.HAS_TRIGRAM_EXTENSION) 14 | self.assertEqual('filter', conf.settings.FILTER_KEY) 15 | self.assertEqual('and', conf.settings.AND_KEY) 16 | self.assertEqual('or', conf.settings.OR_KEY) 17 | self.assertEqual('not', conf.settings.NOT_KEY) 18 | 19 | def test_overridden(self) -> None: 20 | """Test overridden settings.""" 21 | self.assertEqual('filter', conf.settings.FILTER_KEY) 22 | with override_settings(GRAPHENE_DJANGO_FILTER={'FILTER_KEY': 'where'}): 23 | self.assertEqual('where', conf.settings.FILTER_KEY) 24 | self.assertEqual('filter', conf.settings.FILTER_KEY) 25 | -------------------------------------------------------------------------------- /tests/test_connection_field.py: -------------------------------------------------------------------------------- 1 | """`connection_field` module tests.""" 2 | 3 | from django.test import TestCase 4 | from django_filters import FilterSet 5 | from graphene_django_filter import AdvancedDjangoFilterConnectionField 6 | 7 | from .filtersets import TaskFilter 8 | from .object_types import TaskFilterFieldsType, TaskFilterSetClassType 9 | 10 | 11 | class AdvancedDjangoFilterConnectionFieldTests(TestCase): 12 | """The `AdvancedDjangoFilterConnectionField` class tests.""" 13 | 14 | def test_init(self) -> None: 15 | """Test the `__init__` method.""" 16 | AdvancedDjangoFilterConnectionField(TaskFilterFieldsType) 17 | advanced_django_filter_connection_field = AdvancedDjangoFilterConnectionField( 18 | TaskFilterFieldsType, 19 | filter_input_type_prefix='Task', 20 | ) 21 | self.assertEqual('Task', advanced_django_filter_connection_field.filter_input_type_prefix) 22 | with self.assertRaisesMessage( 23 | AssertionError, 24 | 'Use the `AdvancedFilterSet` class with the `AdvancedDjangoFilterConnectionField`', 25 | ): 26 | AdvancedDjangoFilterConnectionField( 27 | TaskFilterFieldsType, 28 | filterset_class=FilterSet, 29 | filter_input_type_prefix='Task', 30 | ) 31 | with self.assertWarnsRegex( 32 | UserWarning, 33 | r'The `filterset_class` argument without `filter_input_type_prefix`.+', 34 | ): 35 | AdvancedDjangoFilterConnectionField(TaskFilterFieldsType, filterset_class=TaskFilter) 36 | with self.assertWarnsRegex( 37 | UserWarning, 38 | r'The `filterset_class` field of `TaskFilterSetClassType` Meta.+', 39 | ): 40 | AdvancedDjangoFilterConnectionField(TaskFilterSetClassType) 41 | 42 | def test_provided_filterset_class(self) -> None: 43 | """Test the `provided_filterset_class` property.""" 44 | self.assertEqual( 45 | TaskFilter, 46 | AdvancedDjangoFilterConnectionField( 47 | TaskFilterFieldsType, 48 | filterset_class=TaskFilter, 49 | filter_input_type_prefix='Task', 50 | ).provided_filterset_class, 51 | ) 52 | self.assertEqual( 53 | TaskFilter, 54 | AdvancedDjangoFilterConnectionField( 55 | TaskFilterSetClassType, 56 | filter_input_type_prefix='Task', 57 | ).provided_filterset_class, 58 | ) 59 | 60 | def test_filter_input_type_prefix(self) -> None: 61 | """Test the `filter_input_type_prefix` property.""" 62 | self.assertEqual( 63 | 'Task', 64 | AdvancedDjangoFilterConnectionField( 65 | TaskFilterSetClassType, 66 | filter_input_type_prefix='Task', 67 | ).filter_input_type_prefix, 68 | ) 69 | with self.assertWarns(UserWarning): 70 | self.assertEqual( 71 | 'TaskFilterFieldsTaskFilter', 72 | AdvancedDjangoFilterConnectionField( 73 | TaskFilterFieldsType, 74 | filterset_class=TaskFilter, 75 | ).filter_input_type_prefix, 76 | ) 77 | self.assertEqual( 78 | 'TaskFilterFields', 79 | AdvancedDjangoFilterConnectionField( 80 | TaskFilterFieldsType, 81 | ).filter_input_type_prefix, 82 | ) 83 | 84 | def test_filtering_args(self) -> None: 85 | """Test the `filtering_args` property.""" 86 | tasks = AdvancedDjangoFilterConnectionField( 87 | TaskFilterFieldsType, 88 | description='Advanced filter field', 89 | ) 90 | filtering_args = tasks.filtering_args 91 | self.assertEqual(('filter',), tuple(filtering_args.keys())) 92 | self.assertEqual( 93 | 'TaskFilterFieldsFilterInputType', 94 | filtering_args['filter'].type.__name__, 95 | ) 96 | -------------------------------------------------------------------------------- /tests/test_filter_arguments_factory.py: -------------------------------------------------------------------------------- 1 | """Tests for converting a AdvancedFilterSet class to filter arguments.""" 2 | 3 | from unittest.mock import patch 4 | 5 | import graphene 6 | from anytree import Node 7 | from anytree.exporter import DictExporter 8 | from django.test import TestCase 9 | from graphene_django_filter.filter_arguments_factory import FilterArgumentsFactory 10 | from graphene_django_filter.input_types import ( 11 | SearchQueryFilterInputType, 12 | SearchRankFilterInputType, 13 | TrigramFilterInputType, 14 | ) 15 | from stringcase import pascalcase 16 | 17 | from .filtersets import TaskFilter 18 | 19 | 20 | class FilterArgumentsFactoryTests(TestCase): 21 | """The `FilterArgumentsFactory` class tests.""" 22 | 23 | abstract_tree_root = Node( 24 | 'field1', children=( 25 | Node( 26 | 'field2', children=( 27 | Node( 28 | 'field3', children=( 29 | Node('field4'), 30 | ), 31 | ), 32 | ), 33 | ), 34 | ), 35 | ) 36 | task_filter_trees_roots = [ 37 | Node( 38 | name='name', children=[ 39 | Node(name='exact'), 40 | Node(name='contains'), 41 | Node( 42 | name='trigram', children=[ 43 | Node(name='exact'), 44 | Node(name='gt'), 45 | Node(name='gte'), 46 | Node(name='lt'), 47 | Node(name='lte'), 48 | ], 49 | ), 50 | ], 51 | ), 52 | Node(name='created_at', children=[Node(name='gt')]), 53 | Node(name='completed_at', children=[Node(name='lt')]), 54 | Node(name='description', children=[Node(name='exact'), Node(name='contains')]), 55 | Node( 56 | name='user', children=[ 57 | Node(name='exact'), 58 | Node(name='in'), 59 | Node( 60 | name='email', children=[ 61 | Node(name='exact'), 62 | Node(name='iexact'), 63 | Node(name='contains'), 64 | Node(name='icontains'), 65 | ], 66 | ), 67 | Node( 68 | name='last_name', children=[ 69 | Node(name='exact'), 70 | Node(name='contains'), 71 | ], 72 | ), 73 | ], 74 | ), 75 | Node(name='search_query', children=[Node(name='exact')]), 76 | Node( 77 | name='search_rank', children=[ 78 | Node(name='exact'), 79 | Node(name='gt'), 80 | Node(name='gte'), 81 | Node(name='lt'), 82 | Node(name='lte'), 83 | ], 84 | ), 85 | ] 86 | 87 | def test_sequence_to_tree(self) -> None: 88 | """Test the `sequence_to_tree` method.""" 89 | self.assertEqual( 90 | { 91 | 'name': 'field1', 92 | 'children': [{'name': 'field2'}], 93 | }, 94 | DictExporter().export(FilterArgumentsFactory.sequence_to_tree(('field1', 'field2'))), 95 | ) 96 | 97 | def test_possible_try_add_sequence(self) -> None: 98 | """Test the `try_add_sequence` method when adding a sequence is possible.""" 99 | is_mutated = FilterArgumentsFactory.try_add_sequence( 100 | self.abstract_tree_root, ('field1', 'field5', 'field6'), 101 | ) 102 | self.assertTrue(is_mutated) 103 | self.assertEqual( 104 | { 105 | 'name': 'field1', 106 | 'children': [ 107 | { 108 | 'name': 'field2', 109 | 'children': [ 110 | { 111 | 'name': 'field3', 112 | 'children': [{'name': 'field4'}], 113 | }, 114 | ], 115 | }, 116 | { 117 | 'name': 'field5', 118 | 'children': [{'name': 'field6'}], 119 | }, 120 | ], 121 | }, 122 | DictExporter().export(self.abstract_tree_root), 123 | ) 124 | 125 | def test_impossible_try_add_sequence(self) -> None: 126 | """Test the `try_add_sequence` method when adding a sequence is impossible.""" 127 | is_mutated = FilterArgumentsFactory.try_add_sequence( 128 | self.abstract_tree_root, ('field5', 'field6'), 129 | ) 130 | self.assertFalse(is_mutated) 131 | 132 | def test_init(self) -> None: 133 | """The the `__init__` method.""" 134 | filter_arguments_factory = FilterArgumentsFactory(TaskFilter, 'Task') 135 | self.assertEqual(TaskFilter, filter_arguments_factory.filterset_class) 136 | self.assertEqual('Task', filter_arguments_factory.input_type_prefix) 137 | self.assertEqual('TaskFilterInputType', filter_arguments_factory.filter_input_type_name) 138 | 139 | def test_filterset_to_trees(self) -> None: 140 | """Test the `filterset_to_trees` method.""" 141 | roots = FilterArgumentsFactory.filterset_to_trees(TaskFilter) 142 | exporter = DictExporter() 143 | self.assertEqual( 144 | [exporter.export(root) for root in self.task_filter_trees_roots], 145 | [exporter.export(root) for root in roots], 146 | ) 147 | 148 | def test_create_input_object_type(self) -> None: 149 | """Test the `create_input_object_type` method.""" 150 | input_object_type = FilterArgumentsFactory.create_input_object_type( 151 | 'CustomInputObjectType', 152 | {'field': graphene.String()}, 153 | ) 154 | self.assertEqual('CustomInputObjectType', input_object_type.__name__) 155 | self.assertTrue(issubclass(input_object_type, graphene.InputObjectType)) 156 | self.assertTrue(hasattr(input_object_type, 'field')) 157 | 158 | @patch( 159 | 'graphene_django_filter.filter_arguments_factory.FilterArgumentsFactory.input_object_types', 160 | new={}, 161 | ) 162 | def test_create_input_object_type_cache(self) -> None: 163 | """Test the `create_input_object_type` method's cache usage.""" 164 | self.assertEqual({}, FilterArgumentsFactory.input_object_types) 165 | key = 'CustomInputObjectType' 166 | input_object_type = FilterArgumentsFactory.create_input_object_type(key, {}) 167 | self.assertTrue( 168 | input_object_type == FilterArgumentsFactory.create_input_object_type(key, {}), 169 | ) 170 | self.assertEqual({key: input_object_type}, FilterArgumentsFactory.input_object_types) 171 | 172 | def test_create_filter_input_subfield_without_special(self) -> None: 173 | """Test the `create_filter_input_subfield` method without any special filters.""" 174 | filter_arguments_factory = FilterArgumentsFactory(TaskFilter, 'Task') 175 | input_field = filter_arguments_factory.create_filter_input_subfield( 176 | self.task_filter_trees_roots[4], 177 | 'Task', 178 | 'User field', 179 | ) 180 | self.assertEqual('User field', input_field.description) 181 | input_object_type = input_field.type 182 | self.assertEqual('TaskUserFilterInputType', input_object_type.__name__) 183 | self.assertEqual('`Exact` lookup', getattr(input_object_type, 'exact').description) 184 | self.assertEqual('`LastName` subfield', getattr(input_object_type, 'last_name').description) 185 | self.assertEqual('`Email` subfield', getattr(input_object_type, 'email').description) 186 | email_type = getattr(input_object_type, 'email').type 187 | self.assertEqual('TaskUserEmailFilterInputType', email_type.__name__) 188 | for attr in ('iexact', 'contains', 'icontains'): 189 | self.assertEqual(f'`{pascalcase(attr)}` lookup', getattr(email_type, attr).description) 190 | 191 | def test_create_filter_input_subfield_with_search_query(self) -> None: 192 | """Test the `create_filter_input_subfield` method with the search query filter.""" 193 | filter_arguments_factory = FilterArgumentsFactory(TaskFilter, 'Task') 194 | search_query_type = filter_arguments_factory.create_filter_input_subfield( 195 | self.task_filter_trees_roots[5], 196 | 'Task', 197 | 'SearchQuery', 198 | ).type 199 | self.assertEqual(search_query_type, SearchQueryFilterInputType) 200 | 201 | def test_create_filter_input_subtype_with_search_rank(self) -> None: 202 | """Test the `create_filter_input_subtype` method with the search rank filter.""" 203 | filter_arguments_factory = FilterArgumentsFactory(TaskFilter, 'Task') 204 | search_rank_type = filter_arguments_factory.create_filter_input_subfield( 205 | self.task_filter_trees_roots[6], 206 | 'Task', 207 | 'SearchRank', 208 | ).type 209 | self.assertEqual(search_rank_type, SearchRankFilterInputType) 210 | 211 | def test_create_filter_input_subtype_with_trigram(self) -> None: 212 | """Test the `create_filter_input_subtype` method with the trigram filter.""" 213 | filter_arguments_factory = FilterArgumentsFactory(TaskFilter, 'Task') 214 | input_object_type = filter_arguments_factory.create_filter_input_subfield( 215 | self.task_filter_trees_roots[0], 216 | 'Task', 217 | 'Name field', 218 | ).type 219 | trigram_type = getattr(input_object_type, 'trigram').type 220 | self.assertEqual(trigram_type, TrigramFilterInputType) 221 | 222 | def test_create_filter_input_type(self) -> None: 223 | """Test the `create_filter_input_type` method.""" 224 | filter_arguments_factory = FilterArgumentsFactory(TaskFilter, 'Task') 225 | input_object_type = filter_arguments_factory.create_filter_input_type( 226 | self.task_filter_trees_roots, 227 | ) 228 | self.assertEqual('TaskFilterInputType', input_object_type.__name__) 229 | for attr in ('name', 'description', 'user', 'created_at', 'completed_at'): 230 | self.assertEqual( 231 | f'`{pascalcase(attr)}` field', 232 | getattr(input_object_type, attr).description, 233 | ) 234 | for operator in ('and', 'or'): 235 | operator_input_type = getattr(input_object_type, operator) 236 | self.assertEqual(f'`{operator.capitalize()}` field', operator_input_type.description) 237 | self.assertIsInstance(operator_input_type.type, graphene.List) 238 | self.assertEqual(input_object_type, operator_input_type.type.of_type) 239 | not_input_type = getattr(input_object_type, 'not') 240 | self.assertEqual('`Not` field', not_input_type.description) 241 | self.assertEqual(input_object_type, not_input_type.type) 242 | 243 | def test_arguments(self) -> None: 244 | """Test the `arguments` property.""" 245 | filter_arguments_factory = FilterArgumentsFactory(TaskFilter, 'Task') 246 | arguments = filter_arguments_factory.arguments 247 | self.assertEqual(('filter',), tuple(arguments.keys())) 248 | self.assertEqual('TaskFilterInputType', arguments['filter'].type.__name__) 249 | -------------------------------------------------------------------------------- /tests/test_filters.py: -------------------------------------------------------------------------------- 1 | """Tests for additional filters for special lookups.""" 2 | 3 | from unittest.mock import patch 4 | 5 | from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity 6 | from django.db import models 7 | from django.db.models import functions 8 | from django.test import TestCase 9 | from django_filters import Filter 10 | from graphene_django_filter.filters import ( 11 | AnnotatedFilter, 12 | SearchQueryFilter, 13 | SearchRankFilter, 14 | TrigramFilter, 15 | ) 16 | 17 | 18 | from .data_generation import generate_data 19 | from .models import User 20 | 21 | 22 | class FiltersTests(TestCase): 23 | """Tests for additional filters for special lookups.""" 24 | 25 | @classmethod 26 | def setUpClass(cls) -> None: 27 | """Set up tests for additional filters for special lookups.""" 28 | super().setUpClass() 29 | generate_data() 30 | 31 | @patch.object(Filter, 'creation_counter', new=0) 32 | def test_annotate_name(self) -> None: 33 | """Test the `annotation_name` property of the `AnnotatedFilter` class.""" 34 | annotated_filter = AnnotatedFilter(field_name='id', lookup_expr='exact') 35 | self.assertEqual('id_annotated_0_0', annotated_filter.annotation_name) 36 | 37 | def test_annotated_filter(self) -> None: 38 | """Test the `filter` method of the `AnnotatedFilter` class.""" 39 | annotated_filter = AnnotatedFilter(field_name='id', lookup_expr='exact') 40 | self.assertEqual(0, annotated_filter.filter_counter) 41 | users = annotated_filter.filter( 42 | User.objects.all(), 43 | AnnotatedFilter.Value( 44 | annotation_value=functions.Concat( 45 | models.Value('#'), 46 | functions.Cast(models.F('id'), output_field=models.CharField()), 47 | ), 48 | search_value='#5', 49 | ), 50 | ).all() 51 | self.assertEqual(1, annotated_filter.filter_counter) 52 | self.assertEqual([5], [user.id for user in users]) 53 | 54 | def test_search_query_filter(self) -> None: 55 | """Test the `SearchQueryFilter` class.""" 56 | search_query_filter = SearchQueryFilter(field_name='first_name', lookup_expr='exact') 57 | users = search_query_filter.filter( 58 | User.objects.all(), 59 | SearchQueryFilter.Value( 60 | annotation_value=SearchVector('first_name'), 61 | search_value=SearchQuery('Jane'), 62 | ), 63 | ).all() 64 | self.assertTrue(all('Jane' in user.first_name for user in users)) 65 | 66 | @patch.object(Filter, 'creation_counter', new=0) 67 | def test_search_rank_filter(self) -> None: 68 | """Test the `SearchQueryFilter` class.""" 69 | search_rank_filter = SearchRankFilter(field_name='first_name', lookup_expr='lte') 70 | users = search_rank_filter.filter( 71 | User.objects.all(), 72 | SearchRankFilter.Value( 73 | annotation_value=SearchRank( 74 | vector=SearchVector('first_name'), 75 | query=SearchQuery('Jane'), 76 | ), 77 | search_value=1, 78 | ), 79 | ).all() 80 | self.assertTrue(all(hasattr(user, 'first_name_search_rank_0_0') for user in users)) 81 | 82 | def test_trigram_filter(self) -> None: 83 | """Test the `TrigramFilter` class.""" 84 | trigram_filter = TrigramFilter(field_name='field_name', lookup_expr='exact') 85 | users = trigram_filter.filter( 86 | User.objects.all(), 87 | TrigramFilter.Value( 88 | annotation_value=TrigramSimilarity('first_name', 'Jane'), 89 | search_value=1, 90 | ), 91 | ).all() 92 | self.assertTrue(all('Jane' in user.first_name for user in users)) 93 | -------------------------------------------------------------------------------- /tests/test_filterset.py: -------------------------------------------------------------------------------- 1 | """`filterset` module tests.""" 2 | 3 | from collections import OrderedDict 4 | from contextlib import ExitStack 5 | from datetime import datetime 6 | from typing import List 7 | from unittest.mock import MagicMock, patch 8 | 9 | from django.db import models 10 | from django.test import TestCase 11 | from django.utils.timezone import make_aware 12 | from django_filters import CharFilter 13 | from graphene_django_filter.filters import SearchQueryFilter, SearchRankFilter, TrigramFilter 14 | from graphene_django_filter.filterset import ( 15 | AdvancedFilterSet, 16 | QuerySetProxy, 17 | is_full_text_search_lookup_expr, 18 | is_regular_lookup_expr, 19 | ) 20 | 21 | from .data_generation import generate_data 22 | from .filtersets import TaskFilter 23 | from .models import Task, User 24 | 25 | 26 | class UtilsTests(TestCase): 27 | """Tests for utility functions and classes of the `filterset` module.""" 28 | 29 | def test_queryset_proxy(self) -> None: 30 | """Test the `QuerySetProxy` class.""" 31 | queryset = User.objects.all() 32 | queryset_proxy = QuerySetProxy(queryset) 33 | self.assertIsInstance( 34 | queryset_proxy.annotate(number=models.F('id') + models.Value('#')), 35 | QuerySetProxy, 36 | ) 37 | self.assertNotEqual(queryset.filter, queryset_proxy.filter) 38 | self.assertNotEqual(queryset.exclude, queryset_proxy.exclude) 39 | queryset_proxy.filter(email__contains='kate').exclude( 40 | models.Q(first_name='John') & models.Q(last_name='Dou'), 41 | ) 42 | self.assertEqual( 43 | models.Q(email__contains='kate') & ~( 44 | models.Q(first_name='John') & models.Q(last_name='Dou') 45 | ), 46 | queryset_proxy.q, 47 | ) 48 | self.assertEqual([queryset_proxy.__wrapped__, queryset_proxy.q], list(queryset_proxy)) 49 | 50 | def test_is_full_text_search_lookup(self) -> None: 51 | """Test the `is_full_text_search_lookup` function.""" 52 | self.assertFalse(is_full_text_search_lookup_expr('name__exact')) 53 | self.assertTrue(is_full_text_search_lookup_expr('name__full_text_search')) 54 | 55 | def test_is_regular_lookup(self) -> None: 56 | """Test the `is_regular_lookup` function.""" 57 | self.assertTrue(is_regular_lookup_expr('name__exact')) 58 | self.assertFalse(is_regular_lookup_expr('name__full_text_search')) 59 | 60 | 61 | class AdvancedFilterSetTests(TestCase): 62 | """`AdvancedFilterSetTest` class tests.""" 63 | 64 | class FindFilterFilterSet(AdvancedFilterSet): 65 | in_last_name = CharFilter(field_name='last_name', lookup_expr='contains') 66 | 67 | class Meta: 68 | model = User 69 | fields = { 70 | 'email': ('exact',), 71 | 'first_name': ('iexact',), 72 | } 73 | 74 | gt_datetime = make_aware(datetime.strptime('12/31/2019', '%m/%d/%Y')) 75 | lt_datetime = make_aware(datetime.strptime('02/02/2021', '%m/%d/%Y')) 76 | task_filter_data = { 77 | 'user__in': '2,3', 78 | 'and': [ 79 | {'created_at__gt': gt_datetime}, 80 | {'completed_at__lt': lt_datetime}, 81 | ], 82 | 'or': [ 83 | {'name__contains': 'Important'}, 84 | {'description__contains': 'important'}, 85 | ], 86 | 'not': { 87 | 'user': 2, 88 | }, 89 | } 90 | 91 | class FullTextSearchFilterSet(AdvancedFilterSet): 92 | class Meta: 93 | model = Task 94 | fields = { 95 | 'user__email': ('exact', 'contains'), 96 | 'user__first_name': ('exact', 'contains', 'full_text_search'), 97 | 'user__last_name': ('full_text_search',), 98 | } 99 | 100 | expected_regular_filters = [ 101 | ('user__email', CharFilter(field_name='user__email', lookup_expr='exact')), 102 | ('user__email__contains', CharFilter(field_name='user__email', lookup_expr='contains')), 103 | ('user__first_name', CharFilter(field_name='user__first_name', lookup_expr='exact')), 104 | ( 105 | 'user__first_name__contains', 106 | CharFilter(field_name='user__first_name', lookup_expr='contains'), 107 | ), 108 | ] 109 | expected_search_query_filters = [ 110 | ('search_query', SearchQueryFilter(field_name='search_query', lookup_expr='exact')), 111 | ] 112 | expected_search_rank_filters = [ 113 | ('search_rank', SearchRankFilter(field_name='search_rank', lookup_expr='exact')), 114 | ('search_rank__gt', SearchRankFilter(field_name='search_rank', lookup_expr='gt')), 115 | ('search_rank__gte', SearchRankFilter(field_name='search_rank', lookup_expr='gte')), 116 | ('search_rank__lt', SearchRankFilter(field_name='search_rank', lookup_expr='lt')), 117 | ('search_rank__lte', SearchRankFilter(field_name='search_rank', lookup_expr='lte')), 118 | ] 119 | expected_trigram_filters = [ 120 | ( 121 | 'user__first_name__trigram', 122 | TrigramFilter(field_name='user__first_name__trigram', lookup_expr='exact'), 123 | ), 124 | ( 125 | 'user__first_name__trigram__gt', 126 | TrigramFilter(field_name='user__first_name__trigram', lookup_expr='gt'), 127 | ), 128 | ( 129 | 'user__first_name__trigram__gte', 130 | TrigramFilter(field_name='user__first_name__trigram', lookup_expr='gte'), 131 | ), 132 | ( 133 | 'user__first_name__trigram__lt', 134 | TrigramFilter(field_name='user__first_name__trigram', lookup_expr='lt'), 135 | ), 136 | ( 137 | 'user__first_name__trigram__lte', 138 | TrigramFilter(field_name='user__first_name__trigram', lookup_expr='lte'), 139 | ), 140 | ( 141 | 'user__last_name__trigram', 142 | TrigramFilter(field_name='user__last_name__trigram', lookup_expr='exact'), 143 | ), 144 | ( 145 | 'user__last_name__trigram__gt', 146 | TrigramFilter(field_name='user__last_name__trigram', lookup_expr='gt'), 147 | ), 148 | ( 149 | 'user__last_name__trigram__gte', 150 | TrigramFilter(field_name='user__last_name__trigram', lookup_expr='gte'), 151 | ), 152 | ( 153 | 'user__last_name__trigram__lt', 154 | TrigramFilter(field_name='user__last_name__trigram', lookup_expr='lt'), 155 | ), 156 | ( 157 | 'user__last_name__trigram__lte', 158 | TrigramFilter(field_name='user__last_name__trigram', lookup_expr='lte'), 159 | ), 160 | ] 161 | 162 | @classmethod 163 | def setUpClass(cls) -> None: 164 | """Set up `AdvancedFilterSetTest` class.""" 165 | super().setUpClass() 166 | generate_data() 167 | 168 | def assertFiltersEqual(self, first: List[tuple], second: List[tuple]) -> None: 169 | """Fail if the two filter lists unequal.""" 170 | self.assertEqual(len(first), len(second)) 171 | for expected, actual in zip(first, second): 172 | self.assertEqual(expected[0], actual[0]) 173 | self.assertEqual(type(expected[1]), type(actual[1])) 174 | self.assertEqual(expected[1].field_name, actual[1].field_name) 175 | self.assertEqual(expected[1].lookup_expr, actual[1].lookup_expr) 176 | 177 | def test_get_form_class(self) -> None: 178 | """Test getting a tree form class with the `get_form_class` method.""" 179 | form_class = TaskFilter().get_form_class() 180 | self.assertEqual('TaskFilterTreeForm', form_class.__name__) 181 | form = form_class(or_forms=[form_class()], not_form=form_class()) 182 | self.assertEqual(0, len(form.and_forms)) 183 | self.assertEqual(1, len(form.or_forms)) 184 | self.assertIsInstance(form.or_forms[0], form_class) 185 | self.assertIsInstance(form.not_form, form_class) 186 | 187 | def test_tree_form_errors(self) -> None: 188 | """Test getting a tree form class errors.""" 189 | form_class = TaskFilter().get_form_class() 190 | form = form_class(or_forms=[form_class()], not_form=form_class()) 191 | with ExitStack() as stack: 192 | for f in (form, form.or_forms[0], form.not_form): 193 | stack.enter_context( 194 | patch.object(f, 'cleaned_data', new={'name': 'parent_name_data'}, create=True), 195 | ) 196 | all_errors = { 197 | 'name': ['root_form_error'], 198 | 'or': { 199 | 'or_0': { 200 | 'name': ['or_form_error'], 201 | }, 202 | }, 203 | 'not': { 204 | 'name': ['not_form_error'], 205 | }, 206 | } 207 | self.assertEqual({}, form.errors) 208 | form.add_error('name', 'root_form_error') 209 | self.assertEqual( 210 | {k: v for k, v in all_errors.items() if k == 'name'}, 211 | form.errors, 212 | ) 213 | form.or_forms[0].add_error('name', 'or_form_error') 214 | self.assertEqual( 215 | {k: v for k, v in all_errors.items() if k in ('name', 'or')}, 216 | form.errors, 217 | ) 218 | form.not_form.add_error('name', 'not_form_error') 219 | self.assertEqual(all_errors, form.errors) 220 | 221 | def test_form(self) -> None: 222 | """Test the `form` property.""" 223 | empty_filter = TaskFilter() 224 | self.assertFalse(empty_filter.form.is_bound) 225 | task_filter = TaskFilter(data=self.task_filter_data) 226 | self.assertTrue(task_filter.form.is_bound) 227 | self.assertEqual( 228 | {k: v for k, v in self.task_filter_data.items() if k not in ('and', 'or', 'not')}, 229 | task_filter.form.data, 230 | ) 231 | for key in ('and', 'or'): 232 | forms = getattr(task_filter.form, f'{key}_forms') 233 | for data, form in zip(self.task_filter_data[key], forms): 234 | self.assertEqual(data, form.data) 235 | for form in forms: 236 | self.assertEqual(0, len(form.and_forms)) 237 | self.assertEqual(0, len(form.or_forms)) 238 | self.assertEqual(self.task_filter_data['not'], task_filter.form.not_form.data) 239 | self.assertEqual(0, len(task_filter.form.not_form.and_forms)) 240 | self.assertEqual(0, len(task_filter.form.not_form.or_forms)) 241 | 242 | def test_find_filter(self) -> None: 243 | """Test the `find_filter` method.""" 244 | filterset = AdvancedFilterSetTests.FindFilterFilterSet() 245 | email_filter = filterset.find_filter('email') 246 | self.assertEqual(email_filter.field_name, 'email') 247 | self.assertEqual(email_filter.lookup_expr, 'exact') 248 | email_filter = filterset.find_filter('email__exact') 249 | self.assertEqual(email_filter.field_name, 'email') 250 | self.assertEqual(email_filter.lookup_expr, 'exact') 251 | first_name_filter = filterset.find_filter('first_name__iexact') 252 | self.assertEqual(first_name_filter.field_name, 'first_name') 253 | self.assertEqual(first_name_filter.lookup_expr, 'iexact') 254 | last_name_filter = filterset.find_filter('last_name__contains') 255 | self.assertEqual(last_name_filter.field_name, 'last_name') 256 | self.assertEqual(last_name_filter.lookup_expr, 'contains') 257 | 258 | def test_filter_queryset(self) -> None: 259 | """Test the `filter_queryset` method.""" 260 | task_filter = TaskFilter(data=self.task_filter_data) 261 | getattr(task_filter.form, 'errors') # Ensure form validation before filtering 262 | tasks = task_filter.filter_queryset(task_filter.queryset.all()) 263 | expected_tasks = Task.objects.filter( 264 | models.Q(user__in=(2, 3)) & models.Q(created_at__gt=self.gt_datetime) & models.Q( 265 | completed_at__lt=self.lt_datetime, 266 | ) & models.Q( 267 | models.Q(name__contains='Important') | models.Q(description__contains='important'), 268 | ) & ~models.Q(user=2), 269 | ).all() 270 | self.assertEqual(list(expected_tasks), list(tasks)) 271 | 272 | def test_get_fields(self) -> None: 273 | """Test `get_fields` and `get_full_text_search_fields` methods.""" 274 | self.assertEqual( 275 | OrderedDict([ 276 | ('user__email', ['exact', 'contains']), 277 | ('user__first_name', ['exact', 'contains']), 278 | ]), 279 | self.FullTextSearchFilterSet.get_fields(), 280 | ) 281 | self.assertEqual( 282 | OrderedDict([ 283 | ('user__first_name', ['full_text_search']), 284 | ('user__last_name', ['full_text_search']), 285 | ]), 286 | self.FullTextSearchFilterSet.get_full_text_search_fields(), 287 | ) 288 | 289 | def test_create_special_filters_without_field_name(self) -> None: 290 | """Test the `create_special_filters` method without the `field_name` parameter.""" 291 | base_filters = OrderedDict([('search_rank__gt', MagicMock())]) 292 | filters = AdvancedFilterSet.create_special_filters(base_filters, SearchRankFilter) 293 | expected_filters = [ 294 | ('search_rank', SearchRankFilter(field_name='search_rank', lookup_expr='exact')), 295 | ('search_rank__gte', SearchRankFilter(field_name='search_rank', lookup_expr='gte')), 296 | ('search_rank__lt', SearchRankFilter(field_name='search_rank', lookup_expr='lt')), 297 | ('search_rank__lte', SearchRankFilter(field_name='search_rank', lookup_expr='lte')), 298 | ] 299 | self.assertFiltersEqual(expected_filters, filters.items()) 300 | 301 | def test_create_special_filters_with_field_name(self) -> None: 302 | """Test the `create_special_filters` method with the `field_name` parameter.""" 303 | base_filters = OrderedDict([('name__trigram__gt', MagicMock())]) 304 | filters = AdvancedFilterSet.create_special_filters(base_filters, TrigramFilter, 'name') 305 | expected_filters = [ 306 | ('name__trigram', TrigramFilter(field_name='name__trigram', lookup_expr='exact')), 307 | ('name__trigram__gte', TrigramFilter(field_name='name__trigram', lookup_expr='gte')), 308 | ('name__trigram__lt', TrigramFilter(field_name='name__trigram', lookup_expr='lt')), 309 | ('name__trigram__lte', TrigramFilter(field_name='name__trigram', lookup_expr='lte')), 310 | ] 311 | self.assertFiltersEqual(expected_filters, filters.items()) 312 | 313 | @patch.object( 314 | AdvancedFilterSet, 315 | 'get_full_text_search_fields', 316 | new=MagicMock(return_value=OrderedDict()), 317 | ) 318 | def test_create_full_text_search_filters_without_fields(self) -> None: 319 | """Test the `create_full_text_search_filters` method without full text search fields.""" 320 | base_filters = OrderedDict() 321 | filters = self.FullTextSearchFilterSet.create_full_text_search_filters(base_filters) 322 | expected_filters = [] 323 | self.assertFiltersEqual(expected_filters, filters.items()) 324 | 325 | @patch( 326 | 'graphene_django_filter.conf.FIXED_SETTINGS', new={ 327 | 'IS_POSTGRESQL': False, 328 | 'HAS_TRIGRAM_EXTENSION': False, 329 | }, 330 | ) 331 | def test_create_full_text_search_filters_without_postgresql(self) -> None: 332 | """Test the `create_full_text_search_filters` method if the database is not PostgreSQL.""" 333 | base_filters = OrderedDict() 334 | with self.assertWarns(UserWarning): 335 | filters = self.FullTextSearchFilterSet.create_full_text_search_filters(base_filters) 336 | expected_filters = [] 337 | self.assertFiltersEqual(expected_filters, filters.items()) 338 | 339 | @patch( 340 | 'graphene_django_filter.conf.FIXED_SETTINGS', new={ 341 | 'IS_POSTGRESQL': True, 342 | 'HAS_TRIGRAM_EXTENSION': False, 343 | }, 344 | ) 345 | def test_create_full_text_search_filters_without_trigrams(self) -> None: 346 | """Test the `create_full_text_search_filters` method. 347 | 348 | The database has not `pg_trgm` extension. 349 | """ 350 | base_filters = OrderedDict() 351 | with self.assertWarns(UserWarning): 352 | filters = self.FullTextSearchFilterSet.create_full_text_search_filters(base_filters) 353 | expected_filters = [ 354 | *self.expected_search_query_filters, 355 | *self.expected_search_rank_filters, 356 | ] 357 | self.assertFiltersEqual(expected_filters, filters.items()) 358 | 359 | def test_create_full_text_search_filter(self) -> None: 360 | """Test the `create_full_text_search_filters` method with all filters.""" 361 | base_filters = OrderedDict( 362 | [('search_query', SearchQueryFilter(field_name='', lookup_expr='exact'))], 363 | ) 364 | filters = self.FullTextSearchFilterSet.create_full_text_search_filters(base_filters) 365 | expected_filters = [ 366 | *self.expected_search_rank_filters, 367 | *self.expected_trigram_filters, 368 | ] 369 | self.assertFiltersEqual(expected_filters, filters.items()) 370 | 371 | def test_get_filters(self) -> None: 372 | """Test the `get_filters` method.""" 373 | filters = self.FullTextSearchFilterSet.get_filters() 374 | expected_filters = [ 375 | *self.expected_regular_filters, 376 | *self.expected_search_query_filters, 377 | *self.expected_search_rank_filters, 378 | *self.expected_trigram_filters, 379 | ] 380 | self.assertFiltersEqual(expected_filters, filters.items()) 381 | -------------------------------------------------------------------------------- /tests/test_input_data_factories.py: -------------------------------------------------------------------------------- 1 | """Input data factories tests.""" 2 | 3 | from collections import OrderedDict 4 | from contextlib import contextmanager 5 | from datetime import datetime, timedelta 6 | from typing import Any, Generator, Tuple, Type, cast 7 | from unittest.mock import MagicMock, patch 8 | 9 | import graphene 10 | from django.contrib.postgres.search import ( 11 | SearchQuery, 12 | SearchRank, 13 | SearchVector, 14 | TrigramDistance, 15 | TrigramSimilarity, 16 | ) 17 | from django.core.exceptions import ValidationError 18 | from django.db import models 19 | from django.test import TestCase 20 | from graphene.types.inputobjecttype import InputObjectTypeContainer 21 | from graphene_django_filter.filters import ( 22 | SearchQueryFilter, 23 | SearchRankFilter, 24 | TrigramFilter, 25 | ) 26 | from graphene_django_filter.filterset import AdvancedFilterSet 27 | from graphene_django_filter.input_data_factories import ( 28 | create_data, 29 | create_search_config, 30 | create_search_query, 31 | create_search_query_data, 32 | create_search_rank_data, 33 | create_search_rank_weights, 34 | create_search_vector, 35 | create_trigram_data, 36 | tree_input_type_to_data, 37 | validate_search_query, 38 | validate_search_vector_fields, 39 | ) 40 | from graphene_django_filter.input_types import ( 41 | FloatLookupsInputType, 42 | SearchConfigInputType, 43 | SearchQueryFilterInputType, 44 | SearchQueryInputType, 45 | SearchRankFilterInputType, 46 | SearchRankWeightsInputType, 47 | SearchVectorInputType, 48 | SearchVectorWeight, 49 | TrigramFilterInputType, 50 | TrigramSearchKind, 51 | ) 52 | 53 | 54 | class InputDataFactoriesTests(TestCase): 55 | """Input data factories tests.""" 56 | 57 | filterset_class_mock = cast( 58 | Type[AdvancedFilterSet], 59 | MagicMock( 60 | get_full_text_search_fields=MagicMock( 61 | return_value=OrderedDict([ 62 | ('field1', MagicMock()), 63 | ('field2', MagicMock()), 64 | ]), 65 | ), 66 | ), 67 | ) 68 | 69 | rank_weights_input_type = SearchRankWeightsInputType._meta.container({ 70 | 'A': 0.9, 71 | 'B': SearchRankWeightsInputType.B.kwargs['default_value'], 72 | 'C': SearchRankWeightsInputType.C.kwargs['default_value'], 73 | 'D': SearchRankWeightsInputType.D.kwargs['default_value'], 74 | }) 75 | 76 | invalid_search_query_input_type = SearchQueryInputType._meta.container({}) 77 | config_search_query_input_type = SearchConfigInputType._meta.container({ 78 | 'config': SearchConfigInputType._meta.container({ 79 | 'value': 'russian', 80 | 'is_field': False, 81 | }), 82 | 'value': 'value', 83 | }) 84 | expressions_search_query_input_type = SearchQueryInputType._meta.container({ 85 | 'value': 'value1', 86 | 'and': [ 87 | SearchQueryInputType._meta.container({'value': 'and_value1'}), 88 | SearchQueryInputType._meta.container({'value': 'and_value2'}), 89 | ], 90 | 'or': [ 91 | SearchQueryInputType._meta.container({'value': 'or_value1'}), 92 | SearchQueryInputType._meta.container({'value': 'or_value2'}), 93 | ], 94 | 'not': SearchQueryInputType._meta.container({'value': 'not_value'}), 95 | }) 96 | 97 | search_rank_input_type = SearchRankFilterInputType._meta.container({ 98 | 'vector': MagicMock(), 99 | 'query': MagicMock(), 100 | 'lookups': FloatLookupsInputType._meta.container({'gt': 0.8, 'lt': 0.9}), 101 | 'weights': rank_weights_input_type, 102 | 'cover_density': True, 103 | 'normalization': 2, 104 | }) 105 | 106 | @contextmanager 107 | def patch_vector_and_query_factories( 108 | self, 109 | ) -> Generator[Tuple[MagicMock, MagicMock, MagicMock, MagicMock], Any, None]: 110 | """Patch `create_search_vector` and `create_search_query` functions.""" 111 | with patch( 112 | 'graphene_django_filter.input_data_factories.create_search_vector', 113 | ) as create_search_vector_mock, patch( 114 | 'graphene_django_filter.input_data_factories.create_search_query', 115 | ) as create_search_query_mock: 116 | search_vector_mock = MagicMock() 117 | create_search_vector_mock.return_value = search_vector_mock 118 | search_query_mock = MagicMock() 119 | create_search_query_mock.return_value = search_query_mock 120 | yield ( 121 | create_search_vector_mock, 122 | search_vector_mock, 123 | create_search_query_mock, 124 | search_query_mock, 125 | ) 126 | 127 | class TaskNameFilterInputType(graphene.InputObjectType): 128 | exact = graphene.String() 129 | trigram = graphene.InputField(TrigramFilterInputType) 130 | 131 | class TaskDescriptionFilterInputType(graphene.InputObjectType): 132 | exact = graphene.String() 133 | 134 | class TaskUserEmailFilterInputType(graphene.InputObjectType): 135 | exact = graphene.String() 136 | iexact = graphene.String() 137 | contains = graphene.String() 138 | icontains = graphene.String() 139 | 140 | class TaskUserLastNameFilterInputType(graphene.InputObjectType): 141 | exact = graphene.String() 142 | 143 | class TaskUserFilterInputType(graphene.InputObjectType): 144 | exact = graphene.String() 145 | email = graphene.InputField( 146 | lambda: InputDataFactoriesTests.TaskUserEmailFilterInputType, 147 | ) 148 | last_name = graphene.InputField( 149 | lambda: InputDataFactoriesTests.TaskUserLastNameFilterInputType, 150 | ) 151 | 152 | class TaskCreatedAtInputType(graphene.InputObjectType): 153 | gt = graphene.DateTime() 154 | 155 | class TaskCompletedAtInputType(graphene.InputObjectType): 156 | lg = graphene.DateTime() 157 | 158 | TaskFilterInputType = type( 159 | 'TaskFilterInputType', 160 | (graphene.InputObjectType,), 161 | { 162 | 'name': graphene.InputField( 163 | lambda: InputDataFactoriesTests.TaskNameFilterInputType, 164 | ), 165 | 'description': graphene.InputField( 166 | lambda: InputDataFactoriesTests.TaskDescriptionFilterInputType, 167 | ), 168 | 'user': graphene.InputField( 169 | lambda: InputDataFactoriesTests.TaskUserFilterInputType, 170 | ), 171 | 'created_at': graphene.InputField( 172 | lambda: InputDataFactoriesTests.TaskCreatedAtInputType, 173 | ), 174 | 'completed_at': graphene.InputField( 175 | lambda: InputDataFactoriesTests.TaskCompletedAtInputType, 176 | ), 177 | 'and': graphene.InputField( 178 | graphene.List(lambda: InputDataFactoriesTests.TaskFilterInputType), 179 | ), 180 | 'or': graphene.InputField( 181 | graphene.List(lambda: InputDataFactoriesTests.TaskFilterInputType), 182 | ), 183 | 'not': graphene.InputField( 184 | lambda: InputDataFactoriesTests.TaskFilterInputType, 185 | ), 186 | 'search_query': graphene.InputField( 187 | SearchQueryFilterInputType, 188 | ), 189 | 'search_rank': graphene.InputField( 190 | SearchRankFilterInputType, 191 | ), 192 | }, 193 | ) 194 | 195 | task_filterset_class_mock = cast( 196 | Type[AdvancedFilterSet], 197 | MagicMock( 198 | get_full_text_search_fields=MagicMock( 199 | return_value=OrderedDict([ 200 | ('name', MagicMock()), 201 | ]), 202 | ), 203 | ), 204 | ) 205 | gt_datetime = datetime.today() - timedelta(days=1) 206 | lt_datetime = datetime.today() 207 | tree_input_type = TaskFilterInputType._meta.container({ 208 | 'name': TaskNameFilterInputType._meta.container({ 209 | 'exact': 'Important task', 210 | 'trigram': TrigramFilterInputType._meta.container({ 211 | 'kind': TrigramSearchKind.SIMILARITY, 212 | 'lookups': FloatLookupsInputType._meta.container({'gt': 0.8}), 213 | 'value': 'Buy some milk', 214 | }), 215 | }), 216 | 'description': TaskDescriptionFilterInputType._meta.container( 217 | {'exact': 'This task is very important'}, 218 | ), 219 | 'user': TaskUserFilterInputType._meta.container( 220 | {'email': TaskUserEmailFilterInputType._meta.container({'contains': 'dev'})}, 221 | ), 222 | 'and': [ 223 | TaskFilterInputType._meta.container({ 224 | 'completed_at': TaskCompletedAtInputType._meta.container({'lt': lt_datetime}), 225 | }), 226 | ], 227 | 'or': [ 228 | TaskFilterInputType._meta.container({ 229 | 'created_at': TaskCreatedAtInputType._meta.container({'gt': gt_datetime}), 230 | }), 231 | ], 232 | 'not': TaskFilterInputType._meta.container({ 233 | 'user': TaskUserFilterInputType._meta.container( 234 | {'first_name': TaskUserEmailFilterInputType._meta.container({'exact': 'John'})}, 235 | ), 236 | }), 237 | 'search_query': SearchQueryFilterInputType._meta.container({ 238 | 'vector': SearchVectorInputType._meta.container({'fields': ['name']}), 239 | 'query': SearchQueryInputType._meta.container({'value': 'Fix the bug'}), 240 | }), 241 | 'search_rank': SearchRankFilterInputType._meta.container({ 242 | 'vector': SearchVectorInputType._meta.container({'fields': ['name']}), 243 | 'query': SearchQueryInputType._meta.container({'value': 'Fix the bug'}), 244 | 'lookups': FloatLookupsInputType._meta.container({'gt': 0.8}), 245 | 'cover_density': False, 246 | }), 247 | }) 248 | 249 | def test_validate_search_query(self) -> None: 250 | """Test the `validate_search_query` function.""" 251 | for key in ('value', 'and', 'or', 'not'): 252 | search_query_input_type = SearchQueryInputType._meta.container({ 253 | key: 'value', 254 | }) 255 | validate_search_query(search_query_input_type) 256 | with self.assertRaisesMessage( 257 | ValidationError, 258 | 'The search query must contains at least one required field ' 259 | 'such as `value`, `and`, `or`, `not`.', 260 | ): 261 | validate_search_query(SearchQueryInputType._meta.container({})) 262 | 263 | def test_validate_search_vector_fields(self) -> None: 264 | """Test the `validate_search_vector_fields` function.""" 265 | validate_search_vector_fields(self.filterset_class_mock, ['field1', 'field2']) 266 | with self.assertRaisesMessage( 267 | ValidationError, 268 | 'The `field3` field is not included in full text search fields', 269 | ): 270 | validate_search_vector_fields(self.filterset_class_mock, ['field1', 'field2', 'field3']) 271 | 272 | def test_create_search_rank_weights(self) -> None: 273 | """Test the `create_search_rank_weights` function.""" 274 | self.assertEqual( 275 | [0.1, 0.2, 0.4, 0.9], 276 | create_search_rank_weights(self.rank_weights_input_type), 277 | ) 278 | 279 | def test_create_search_config(self) -> None: 280 | """Test the `create_search_config` function.""" 281 | string_input_type = SearchConfigInputType._meta.container({ 282 | 'value': 'russian', 283 | 'is_field': False, 284 | }) 285 | string_config = create_search_config(string_input_type) 286 | self.assertEqual(string_input_type.value, string_config) 287 | field_input_type = SearchConfigInputType._meta.container({ 288 | 'value': 'russian', 289 | 'is_field': True, 290 | }) 291 | field_config = create_search_config(field_input_type) 292 | self.assertEqual(models.F('russian'), field_config) 293 | 294 | def test_create_search_query(self) -> None: 295 | """Test the `create_search_query` function.""" 296 | with self.assertRaises(ValidationError): 297 | create_search_query(self.invalid_search_query_input_type) 298 | config_search_query = create_search_query(self.config_search_query_input_type) 299 | self.assertEqual(SearchQuery('value', config='russian'), config_search_query) 300 | expressions_search_query = create_search_query(self.expressions_search_query_input_type) 301 | self.assertEqual( 302 | SearchQuery('value1') & ( 303 | SearchQuery('and_value1') & SearchQuery('and_value2') 304 | ) & ( 305 | SearchQuery('or_value1') | SearchQuery('or_value2') 306 | ) & ~SearchQuery('not_value'), 307 | expressions_search_query, 308 | ) 309 | 310 | def test_create_search_vector(self) -> None: 311 | """Test the `create_search_vector` function.""" 312 | invalid_input_type = SearchVectorInputType._meta.container({ 313 | 'fields': ['field1', 'field2', 'field3'], 314 | }) 315 | with self.assertRaises(ValidationError): 316 | create_search_vector(invalid_input_type, self.filterset_class_mock) 317 | config_input_type = SearchVectorInputType._meta.container({ 318 | 'fields': ['field1', 'field2'], 319 | 'config': SearchConfigInputType._meta.container({ 320 | 'value': 'russian', 321 | }), 322 | }) 323 | config_search_vector = create_search_vector(config_input_type, self.filterset_class_mock) 324 | self.assertEqual(SearchVector('field1', 'field2', config='russian'), config_search_vector) 325 | weight_input_type = SearchVectorInputType._meta.container({ 326 | 'fields': ['field1', 'field2'], 327 | 'weight': SearchVectorWeight.A, 328 | }) 329 | weight_search_vector = create_search_vector(weight_input_type, self.filterset_class_mock) 330 | self.assertEqual(SearchVector('field1', 'field2', weight='A'), weight_search_vector) 331 | 332 | def test_create_trigram_data(self) -> None: 333 | """Test the `create_trigram_data` function.""" 334 | for trigram_class in (TrigramSimilarity, TrigramDistance): 335 | with self.subTest(trigram_class=trigram_class): 336 | similarity_input_type = TrigramFilterInputType._meta.container({ 337 | 'kind': TrigramSearchKind.SIMILARITY 338 | if trigram_class == TrigramSimilarity else TrigramSearchKind.DISTANCE, 339 | 'lookups': FloatLookupsInputType._meta.container({'gt': 0.8, 'lt': 0.9}), 340 | 'value': 'value', 341 | }) 342 | trigram_data = create_trigram_data(similarity_input_type, 'field__trigram') 343 | expected_trigram_data = { 344 | 'field__trigram__gt': TrigramFilter.Value( 345 | annotation_value=trigram_class('field', 'value'), 346 | search_value=0.8, 347 | ), 348 | 'field__trigram__lt': TrigramFilter.Value( 349 | annotation_value=trigram_class('field', 'value'), 350 | search_value=0.9, 351 | ), 352 | } 353 | self.assertEqual(expected_trigram_data, trigram_data) 354 | 355 | @patch.object( 356 | SearchRank, 357 | '__eq__', 358 | new=lambda self, other: str(self) + self.function == str(other) + other.function, 359 | ) 360 | def test_create_search_rank_data(self) -> None: 361 | """Test the `create_search_rank_data` function.""" 362 | with self.patch_vector_and_query_factories() as mocks: 363 | create_sv_mock, sv_mock, create_sq_mock, sq_mock = mocks 364 | search_rank_data = create_search_rank_data( 365 | self.search_rank_input_type, 366 | 'field', 367 | self.filterset_class_mock, 368 | ) 369 | expected_search_rank = SearchRank( 370 | vector=sv_mock, 371 | query=sq_mock, 372 | weights=[0.1, 0.2, 0.4, 0.9], 373 | cover_density=True, 374 | normalization=2, 375 | ) 376 | self.assertEqual( 377 | { 378 | 'field__gt': SearchRankFilter.Value( 379 | annotation_value=expected_search_rank, 380 | search_value=0.8, 381 | ), 382 | 'field__lt': SearchRankFilter.Value( 383 | annotation_value=expected_search_rank, 384 | search_value=0.9, 385 | ), 386 | }, search_rank_data, 387 | ) 388 | create_sv_mock.assert_called_with( 389 | self.search_rank_input_type.vector, 390 | self.filterset_class_mock, 391 | ) 392 | create_sq_mock.assert_called_with(self.search_rank_input_type.query) 393 | 394 | def test_create_search_query_data(self) -> None: 395 | """Test the `create_search_query_data` function.""" 396 | with self.patch_vector_and_query_factories() as mocks: 397 | create_sv_mock, sv_mock, create_sq_mock, sq_mock = mocks 398 | vector = MagicMock() 399 | query = MagicMock() 400 | search_query_data = create_search_query_data( 401 | SearchQueryFilterInputType._meta.container({ 402 | 'vector': vector, 403 | 'query': query, 404 | }), 405 | 'field', 406 | self.filterset_class_mock, 407 | ) 408 | self.assertEqual( 409 | { 410 | 'field': SearchQueryFilter.Value( 411 | annotation_value=sv_mock, 412 | search_value=sq_mock, 413 | ), 414 | }, search_query_data, 415 | ) 416 | create_sv_mock.assert_called_once_with(vector, self.filterset_class_mock) 417 | create_sq_mock.assert_called_once_with(query) 418 | 419 | def test_create_data(self) -> None: 420 | """Test the `create_data` function.""" 421 | with patch( 422 | 'graphene_django_filter.input_data_factories.DATA_FACTORIES', 423 | new={ 424 | 'search_query': MagicMock(return_value=MagicMock()), 425 | 'search_rank': MagicMock(return_value=MagicMock()), 426 | 'trigram': MagicMock(return_value=MagicMock()), 427 | }, 428 | ): 429 | from graphene_django_filter.input_data_factories import DATA_FACTORIES 430 | for factory_key, factory in DATA_FACTORIES.items(): 431 | value = MagicMock() 432 | self.assertEqual( 433 | factory.return_value, 434 | create_data(factory_key, value, self.filterset_class_mock), 435 | ) 436 | factory.assert_called_once_with(value, factory_key, self.filterset_class_mock) 437 | with patch('graphene_django_filter.input_data_factories.tree_input_type_to_data') as mock: 438 | key = 'field' 439 | value = MagicMock(spec=InputObjectTypeContainer) 440 | mock.return_value = {key: value} 441 | self.assertEqual(mock.return_value, create_data(key, value, self.filterset_class_mock)) 442 | key = 'field' 443 | value = MagicMock() 444 | self.assertEqual({key: value}, create_data(key, value, self.filterset_class_mock)) 445 | 446 | def test_tree_input_type_to_data(self) -> None: 447 | """Test the `tree_input_type_to_data` function.""" 448 | data = tree_input_type_to_data(self.task_filterset_class_mock, self.tree_input_type) 449 | expected_data = { 450 | 'name': 'Important task', 451 | 'name__trigram__gt': TrigramFilter.Value( 452 | annotation_value=TrigramSimilarity('name', 'Buy some milk'), 453 | search_value=0.8, 454 | ), 455 | 'description': 'This task is very important', 456 | 'user__email__contains': 'dev', 457 | 'and': [{ 458 | 'completed_at__lt': self.lt_datetime, 459 | }], 460 | 'or': [{ 461 | 'created_at__gt': self.gt_datetime, 462 | }], 463 | 'not': { 464 | 'user__first_name': 'John', 465 | }, 466 | 'search_query': SearchQueryFilter.Value( 467 | annotation_value=SearchVector('name'), 468 | search_value=SearchQuery('Fix the bug'), 469 | ), 470 | 'search_rank__gt': SearchRankFilter.Value( 471 | annotation_value=SearchRank( 472 | vector=SearchVector('name'), 473 | query=SearchQuery('Fix the bug'), 474 | ), 475 | search_value=0.8, 476 | ), 477 | } 478 | self.assertEqual(expected_data, data) 479 | -------------------------------------------------------------------------------- /tests/test_queries_execution.py: -------------------------------------------------------------------------------- 1 | """Queries execution tests.""" 2 | 3 | from datetime import datetime 4 | from typing import List 5 | 6 | from django.test import TestCase 7 | from django.utils.timezone import make_aware 8 | from graphql.execution import ExecutionResult 9 | from graphql_relay import from_global_id 10 | 11 | from .data_generation import generate_data 12 | from .schema import schema 13 | 14 | 15 | class QueriesExecutionTests(TestCase): 16 | """Queries execution tests.""" 17 | 18 | @classmethod 19 | def setUpClass(cls) -> None: 20 | """Set up `QueriesExecutionTests` class.""" 21 | super().setUpClass() 22 | generate_data() 23 | 24 | def assert_query_execution(self, expected: List[int], query: str, key: str) -> None: 25 | """Fail if a query execution returns the invalid entries.""" 26 | execution_result = schema.execute(query) 27 | self.assertEqual(expected, self.get_ids(execution_result, key)) 28 | 29 | @staticmethod 30 | def get_ids(execution_result: ExecutionResult, key: str) -> List[int]: 31 | """Return identifiers from an execution result using a key.""" 32 | return sorted( 33 | int(from_global_id(edge['node']['id'])[1]) for edge 34 | in execution_result.data[key]['edges'] 35 | ) 36 | 37 | 38 | class EdgeCaseTests(QueriesExecutionTests): 39 | """Tests for executing queries in edge cases.""" 40 | 41 | without_filter_query = """ 42 | { 43 | %s { 44 | edges { 45 | node { 46 | id 47 | } 48 | } 49 | } 50 | } 51 | """ 52 | without_filter_fields_query = without_filter_query % 'usersFields' 53 | without_filter_filterset_query = without_filter_query % 'usersFilterset' 54 | with_empty_filter_query = """ 55 | { 56 | %s(filter: {}) { 57 | edges { 58 | node { 59 | id 60 | } 61 | } 62 | } 63 | } 64 | """ 65 | with_empty_filter_fields_query = with_empty_filter_query % 'usersFields' 66 | with_empty_filter_filterset_query = with_empty_filter_query % 'usersFilterset' 67 | 68 | def test_without_filter(self) -> None: 69 | """Test the schema execution without a filter.""" 70 | expected = list(range(1, 76)) 71 | self.assert_query_execution(expected, self.without_filter_fields_query, 'usersFields') 72 | self.assert_query_execution(expected, self.without_filter_filterset_query, 'usersFilterset') 73 | 74 | def test_with_empty_filter(self) -> None: 75 | """Test the schema execution with an empty filter.""" 76 | expected = list(range(1, 76)) 77 | self.assert_query_execution(expected, self.with_empty_filter_fields_query, 'usersFields') 78 | self.assert_query_execution( 79 | expected, 80 | self.with_empty_filter_filterset_query, 81 | 'usersFilterset', 82 | ) 83 | 84 | 85 | class LogicalExpressionsTests(QueriesExecutionTests): 86 | """Tests for executing queries with logical expressions.""" 87 | 88 | users_query = """ 89 | { 90 | %s( 91 | filter: { 92 | isActive: {exact: true} 93 | or: [ 94 | {email: {contains: "alice"}} 95 | {firstName: {exact: "Jane"}} 96 | ] 97 | } 98 | ) { 99 | edges { 100 | node { 101 | id 102 | } 103 | } 104 | } 105 | } 106 | """ 107 | users_fields_query = users_query % 'usersFields' 108 | users_filterset_query = users_query % 'usersFilterset' 109 | tasks_query = f""" 110 | {{ 111 | %s( 112 | filter: {{ 113 | completedAt: {{ 114 | lt: "{make_aware(datetime.strptime('02/02/2021', '%m/%d/%Y')).isoformat()}" 115 | }} 116 | createdAt: {{ 117 | gt: "{make_aware(datetime.strptime('12/31/2019', '%m/%d/%Y')).isoformat()}" 118 | }} 119 | or: [ 120 | {{name: {{contains: "Important"}}}} 121 | {{description: {{contains: "important"}}}} 122 | ] 123 | }} 124 | ) {{ 125 | edges {{ 126 | node {{ 127 | id 128 | }} 129 | }} 130 | }} 131 | }} 132 | """ 133 | tasks_fields_query = tasks_query % 'tasksFields' 134 | tasks_filterset_query = tasks_query % 'tasksFilterset' 135 | task_groups_query = """ 136 | { 137 | %s( 138 | filter: { 139 | or: [ 140 | {name: {exact: "Task group №1"}} 141 | { 142 | and: [ 143 | {priority: {gte: 5}} 144 | {priority: {lte: 10}} 145 | ] 146 | } 147 | ] 148 | and: [ 149 | {not: {priority: {exact: 7}}} 150 | {not: {priority: {exact: 9}}} 151 | ] 152 | } 153 | ) { 154 | edges { 155 | node { 156 | id 157 | } 158 | } 159 | } 160 | } 161 | """ 162 | task_groups_fields_query = task_groups_query % 'taskGroupsFields' 163 | task_groups_filterset_query = task_groups_query % 'taskGroupsFilterset' 164 | 165 | def test_users_execution(self) -> None: 166 | """Test the schema execution by querying users.""" 167 | expected = list(range(16, 51)) 168 | self.assert_query_execution(expected, self.users_fields_query, 'usersFields') 169 | self.assert_query_execution(expected, self.users_filterset_query, 'usersFilterset') 170 | 171 | def test_tasks_execution(self) -> None: 172 | """Test the schema execution by querying tasks.""" 173 | expected = list(range(16, 76)) 174 | self.assert_query_execution(expected, self.tasks_fields_query, 'tasksFields') 175 | self.assert_query_execution(expected, self.tasks_filterset_query, 'tasksFilterset') 176 | 177 | def test_task_groups_execution(self) -> None: 178 | """Test the schema execution by querying task groups.""" 179 | expected = [1, 5, 6, 8, 10] 180 | self.assert_query_execution(expected, self.task_groups_fields_query, 'taskGroupsFields') 181 | self.assert_query_execution( 182 | expected, 183 | self.task_groups_filterset_query, 184 | 'taskGroupsFilterset', 185 | ) 186 | 187 | 188 | class FullTextSearchTests(QueriesExecutionTests): 189 | """Tests for executing queries with full text search.""" 190 | 191 | search_query_query = """ 192 | { 193 | %s( 194 | filter: { 195 | searchQuery: { 196 | vector: { 197 | fields: ["first_name"] 198 | } 199 | query: { 200 | or: [ 201 | {value: "Bob"} 202 | {value: "Alice"} 203 | ] 204 | } 205 | } 206 | } 207 | ) { 208 | edges { 209 | node { 210 | id 211 | } 212 | } 213 | } 214 | } 215 | """ 216 | search_query_fields_query = search_query_query % 'usersFields' 217 | search_query_filterset_query = search_query_query % 'usersFilterset' 218 | search_rank_query = """ 219 | { 220 | %s( 221 | filter: { 222 | searchRank: { 223 | vector: {fields: ["name"]} 224 | query: {value: "Important task №"} 225 | lookups: {gte: 0.08} 226 | } 227 | } 228 | ) { 229 | edges { 230 | node { 231 | id 232 | } 233 | } 234 | } 235 | } 236 | """ 237 | search_rank_fields_query = search_rank_query % 'tasksFields' 238 | search_rank_filterset_query = search_rank_query % 'tasksFilterset' 239 | trigram_query = """ 240 | { 241 | %s( 242 | filter: { 243 | or: [ 244 | { 245 | firstName: { 246 | trigram: { 247 | value: "john" 248 | lookups: {gte: 0.85} 249 | } 250 | } 251 | } 252 | { 253 | lastName: { 254 | trigram: { 255 | value: "dou" 256 | lookups: {gte: 0.85} 257 | } 258 | } 259 | } 260 | ] 261 | } 262 | ) { 263 | edges { 264 | node { 265 | id 266 | } 267 | } 268 | } 269 | } 270 | """ 271 | trigram_fields_query = trigram_query % 'usersFields' 272 | trigram_filterset_query = trigram_query % 'usersFilterset' 273 | 274 | def test_search_query_execution(self) -> None: 275 | """Test the schema execution by a search query.""" 276 | expected = list(range(1, 31)) 277 | self.assert_query_execution(expected, self.search_query_fields_query, 'usersFields') 278 | self.assert_query_execution(expected, self.search_query_filterset_query, 'usersFilterset') 279 | 280 | def test_search_rank_execution(self) -> None: 281 | """Test the schema execution by a search rank.""" 282 | expected = list(range(31, 76)) 283 | self.assert_query_execution(expected, self.search_rank_fields_query, 'tasksFields') 284 | self.assert_query_execution(expected, self.search_rank_filterset_query, 'tasksFilterset') 285 | 286 | def test_trigram_execution(self) -> None: 287 | """Test the schema execution by a trigram.""" 288 | expected = list(range(31, 76)) 289 | self.assert_query_execution(expected, self.trigram_fields_query, 'usersFields') 290 | self.assert_query_execution(expected, self.trigram_filterset_query, 'usersFilterset') 291 | --------------------------------------------------------------------------------