├── .coveragerc ├── .github ├── issue_template.md └── workflows │ ├── deploy.yml │ ├── lint.yml │ └── tests.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── dev-env-requirements.txt ├── dev-requirements.txt ├── graphene_django_optimizer ├── __init__.py ├── field.py ├── hints.py ├── query.py ├── resolver.py ├── types.py └── utils.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── graphql_utils.py ├── models.py ├── schema.py ├── settings.py ├── test_field.py ├── test_query.py ├── test_relay.py ├── test_resolver.py ├── test_types.py └── test_utils.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source=graphene_django_optimizer 3 | 4 | [report] 5 | precision = 2 6 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Deploy to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Python 3.8 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: "3.8" 18 | - name: Build wheel and source tarball 19 | run: | 20 | pip install wheel 21 | python setup.py sdist bdist_wheel 22 | - name: Publish a Python distribution to PyPI 23 | uses: pypa/gh-action-pypi-publish@release/v1 24 | with: 25 | user: __token__ 26 | password: ${{ secrets.pypi_password }} 27 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Set up Python 3.8 12 | uses: actions/setup-python@v4 13 | with: 14 | python-version: "3.8" 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install --upgrade pip 18 | pip install flake8 19 | - name: Run pre-commit 💅 20 | run: flake8 21 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 4 10 | matrix: 11 | django: [">=3.1.0,<3.2", ">=2.2.0,<2.3"] 12 | python-version: ["3.7", "3.8"] 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install -r dev-env-requirements.txt 23 | pip install "django${{ matrix.django }}" 24 | - name: Test 25 | run: ./setup.py test 26 | - name: Upload Coverage to Codecov 27 | uses: codecov/codecov-action@v2 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv*/ 2 | *.pyc 3 | .vscode/ 4 | .idea/ 5 | *.egg-info/ 6 | .eggs/ 7 | .pytest_cache/ 8 | .tool-versions 9 | dist/ 10 | build/ 11 | .coverage 12 | htmlcov/ 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Environment 4 | 5 | The system must have installed: 6 | 7 | - python 3 8 | 9 | ```sh 10 | python -m venv venv 11 | . venv/bin/activate 12 | pip install -r dev-requirements.txt 13 | # run tests: 14 | python setup.py test 15 | ``` 16 | 17 | ## Publish 18 | 19 | ```sh 20 | # update version in setup.py 21 | # then: 22 | rm -r dist 23 | python setup.py sdist bdist_wheel 24 | twine upload dist/* 25 | ``` 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Tomás Fox 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-optimizer 2 | 3 | [![build status](https://img.shields.io/travis/tfoxy/graphene-django-optimizer.svg)](https://travis-ci.com/github/tfoxy/graphene-django-optimizer) 4 | [![coverage](https://img.shields.io/codecov/c/github/tfoxy/graphene-django-optimizer.svg)](https://codecov.io/gh/tfoxy/graphene-django-optimizer) 5 | [![PyPI version](https://img.shields.io/pypi/v/graphene-django-optimizer.svg)](https://pypi.org/project/graphene-django-optimizer/) 6 | ![python version](https://img.shields.io/pypi/pyversions/graphene-django-optimizer.svg) 7 | ![django version](https://img.shields.io/pypi/djversions/graphene-django-optimizer.svg) 8 | 9 | Optimize queries executed by [graphene-django](https://github.com/graphql-python/graphene-django) automatically, using [`select_related`](https://docs.djangoproject.com/en/2.0/ref/models/querysets/#select-related), [`prefetch_related`](https://docs.djangoproject.com/en/2.0/ref/models/querysets/#prefetch-related) and [`only`](https://docs.djangoproject.com/en/2.0/ref/models/querysets/#only) methods of Django QuerySet. 10 | 11 | ## Install 12 | 13 | ```bash 14 | pip install graphene-django-optimizer 15 | ``` 16 | 17 | _Note: If you are using Graphene V2, please install version `0.8`. v0.9 and forward will support only Graphene V3_ 18 | 19 | ## Usage 20 | 21 | Having the following schema based on [the tutorial of graphene-django](http://docs.graphene-python.org/projects/django/en/latest/tutorial-plain/#hello-graphql-schema-and-object-types) (notice the use of `gql_optimizer`) 22 | 23 | ```py 24 | # cookbook/ingredients/schema.py 25 | import graphene 26 | 27 | from graphene_django.types import DjangoObjectType 28 | import graphene_django_optimizer as gql_optimizer 29 | 30 | from cookbook.ingredients.models import Category, Ingredient 31 | 32 | 33 | class CategoryType(DjangoObjectType): 34 | class Meta: 35 | model = Category 36 | 37 | 38 | class IngredientType(DjangoObjectType): 39 | class Meta: 40 | model = Ingredient 41 | 42 | 43 | class Query(graphene.ObjectType): 44 | all_categories = graphene.List(CategoryType) 45 | all_ingredients = graphene.List(IngredientType) 46 | 47 | def resolve_all_categories(root, info): 48 | return gql_optimizer.query(Category.objects.all(), info) 49 | 50 | def resolve_all_ingredients(root, info): 51 | return gql_optimizer.query(Ingredient.objects.all(), info) 52 | ``` 53 | 54 | We will show some graphql queries and the queryset that will be executed. 55 | 56 | Fetching all the ingredients with the related category: 57 | 58 | ```graphql 59 | { 60 | allIngredients { 61 | id 62 | name 63 | category { 64 | id 65 | name 66 | } 67 | } 68 | } 69 | ``` 70 | 71 | ```py 72 | # optimized queryset: 73 | ingredients = ( 74 | Ingredient.objects 75 | .select_related('category') 76 | .only('id', 'name', 'category__id', 'category__name') 77 | ) 78 | ``` 79 | 80 | Fetching all the categories with the related ingredients: 81 | 82 | ```graphql 83 | { 84 | allCategories { 85 | id 86 | name 87 | ingredients { 88 | id 89 | name 90 | } 91 | } 92 | } 93 | ``` 94 | 95 | ```py 96 | # optimized queryset: 97 | categories = ( 98 | Category.objects 99 | .only('id', 'name') 100 | .prefetch_related(Prefetch( 101 | 'ingredients', 102 | queryset=Ingredient.objects.only('id', 'name'), 103 | )) 104 | ) 105 | ``` 106 | 107 | ## Advanced usage 108 | 109 | Sometimes we need to have a custom resolver function. In those cases, the field can't be auto optimized. 110 | So we need to use `gql_optimizer.resolver_hints` decorator to indicate the optimizations. 111 | 112 | If the resolver returns a model field, we can use the `model_field` argument: 113 | 114 | ```py 115 | import graphene 116 | import graphene_django_optimizer as gql_optimizer 117 | 118 | 119 | class ItemType(gql_optimizer.OptimizedDjangoObjectType): 120 | product = graphene.Field('ProductType') 121 | 122 | @gql_optimizer.resolver_hints( 123 | model_field='product', 124 | ) 125 | def resolve_product(root, info): 126 | # check if user have permission for seeing the product 127 | if info.context.user.is_anonymous(): 128 | return None 129 | return root.product 130 | ``` 131 | 132 | This will automatically optimize any subfield of `product`. 133 | 134 | Now, if the resolver uses related fields, you can use the `select_related` argument: 135 | 136 | ```py 137 | import graphene 138 | import graphene_django_optimizer as gql_optimizer 139 | 140 | 141 | class ItemType(gql_optimizer.OptimizedDjangoObjectType): 142 | name = graphene.String() 143 | 144 | @gql_optimizer.resolver_hints( 145 | select_related=('product', 'shipping'), 146 | only=('product__name', 'shipping__name'), 147 | ) 148 | def resolve_name(root, info): 149 | return '{} {}'.format(root.product.name, root.shipping.name) 150 | ``` 151 | 152 | Notice the usage of the type `OptimizedDjangoObjectType`, which enables 153 | optimization of any single node queries. 154 | 155 | Finally, if your field has an argument for filtering results, 156 | you can use the `prefetch_related` argument with a function 157 | that returns a `Prefetch` instance as the value. 158 | 159 | ```py 160 | from django.db.models import Prefetch 161 | import graphene 162 | import graphene_django_optimizer as gql_optimizer 163 | 164 | 165 | class CartType(gql_optimizer.OptimizedDjangoObjectType): 166 | items = graphene.List( 167 | 'ItemType', 168 | product_id=graphene.ID(), 169 | ) 170 | 171 | @gql_optimizer.resolver_hints( 172 | prefetch_related=lambda info, product_id: Prefetch( 173 | 'items', 174 | queryset=gql_optimizer.query(Item.objects.filter(product_id=product_id), info), 175 | to_attr='gql_product_id_' + product_id, 176 | ), 177 | ) 178 | def resolve_items(root, info, product_id): 179 | return getattr(root, 'gql_product_id_' + product_id) 180 | ``` 181 | 182 | With these hints, any field can be optimized. 183 | 184 | ### Optimize with non model fields 185 | 186 | Sometimes we need to have a custom non model fields. In those cases, the optimizer would not optimize with the Django `.only()` method. 187 | So if we still want to optimize with the `.only()` method, we need to use `disable_abort_only` option: 188 | 189 | ```py 190 | 191 | class IngredientType(gql_optimizer.OptimizedDjangoObjectType): 192 | calculated_calories = graphene.String() 193 | 194 | class Meta: 195 | model = Ingredient 196 | 197 | def resolve_calculated_calories(root, info): 198 | return get_calories_for_ingredient(root.id) 199 | 200 | 201 | class Query(object): 202 | all_ingredients = graphene.List(IngredientType) 203 | 204 | def resolve_all_ingredients(root, info): 205 | return gql_optimizer.query(Ingredient.objects.all(), info, disable_abort_only=True) 206 | ``` 207 | 208 | ## Contributing 209 | 210 | See [CONTRIBUTING.md](./CONTRIBUTING.md) 211 | -------------------------------------------------------------------------------- /dev-env-requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | graphene==3.0b7 3 | graphene-django==3.0.0b7 4 | pytest==4.6.3 5 | pytest-django==3.5.0 6 | pytest-cov==2.7.1 7 | flake8==3.7.7 8 | mock==2.0.0 9 | black==21.6b0 10 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | -r dev-env-requirements.txt 2 | django==3.1.14 3 | -------------------------------------------------------------------------------- /graphene_django_optimizer/__init__.py: -------------------------------------------------------------------------------- 1 | from .field import field # noqa: F401 2 | from .query import query # noqa: F401 3 | from .resolver import resolver_hints # noqa: F401 4 | from .types import OptimizedDjangoObjectType # noqa: F401 5 | -------------------------------------------------------------------------------- /graphene_django_optimizer/field.py: -------------------------------------------------------------------------------- 1 | import types 2 | from graphene.types.field import Field 3 | from graphene.types.unmountedtype import UnmountedType 4 | 5 | from .hints import OptimizationHints 6 | 7 | 8 | def field(field_type, *args, **kwargs): 9 | if isinstance(field_type, UnmountedType): 10 | field_type = Field.mounted(field_type) 11 | 12 | optimization_hints = OptimizationHints(*args, **kwargs) 13 | wrap_resolve = field_type.wrap_resolve 14 | 15 | def get_optimized_resolver(self, parent_resolver): 16 | resolver = wrap_resolve(parent_resolver) 17 | resolver.optimization_hints = optimization_hints 18 | return resolver 19 | 20 | field_type.wrap_resolve = types.MethodType(get_optimized_resolver, field_type) 21 | return field_type 22 | -------------------------------------------------------------------------------- /graphene_django_optimizer/hints.py: -------------------------------------------------------------------------------- 1 | from .utils import is_iterable, noop 2 | 3 | 4 | def _normalize_model_field(value): 5 | if not callable(value): 6 | return_value = value 7 | value = lambda *args, **kwargs: return_value 8 | return value 9 | 10 | 11 | def _normalize_hint_value(value): 12 | if not callable(value): 13 | if not is_iterable(value): 14 | value = (value,) 15 | return_value = value 16 | value = lambda *args, **kwargs: return_value 17 | return value 18 | 19 | 20 | class OptimizationHints(object): 21 | def __init__( 22 | self, 23 | model_field=None, 24 | select_related=noop, 25 | prefetch_related=noop, 26 | only=noop, 27 | ): 28 | self.model_field = _normalize_model_field(model_field) 29 | self.prefetch_related = _normalize_hint_value(prefetch_related) 30 | self.select_related = _normalize_hint_value(select_related) 31 | self.only = _normalize_hint_value(only) 32 | -------------------------------------------------------------------------------- /graphene_django_optimizer/query.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from django.core.exceptions import FieldDoesNotExist 4 | from django.db.models import ForeignKey, Prefetch 5 | from django.db.models.constants import LOOKUP_SEP 6 | from django.db.models.fields.reverse_related import ManyToOneRel 7 | from graphene import InputObjectType 8 | from graphene.types.generic import GenericScalar 9 | from graphene.types.resolver import default_resolver 10 | from graphene_django import DjangoObjectType 11 | from graphql import GraphQLResolveInfo, GraphQLSchema 12 | from graphql.language.ast import ( 13 | FragmentSpreadNode, 14 | InlineFragmentNode, 15 | VariableNode, 16 | ) 17 | from graphql.type.definition import ( 18 | GraphQLInterfaceType, 19 | GraphQLUnionType, 20 | ) 21 | 22 | from graphql.pyutils import Path 23 | 24 | from .utils import is_iterable, get_field_def_compat 25 | 26 | 27 | def query(queryset, info, **options): 28 | """ 29 | Automatically optimize queries. 30 | 31 | Arguments: 32 | - queryset (Django QuerySet object) - The queryset to be optimized 33 | - info (GraphQL GraphQLResolveInfo object) - This is passed by the graphene-django resolve methods 34 | - **options - optimization options/settings 35 | - disable_abort_only (boolean) - in case the objecttype contains any extra fields, 36 | then this will keep the "only" optimization enabled. 37 | """ 38 | 39 | return QueryOptimizer(info, **options).optimize(queryset) 40 | 41 | 42 | class QueryOptimizer(object): 43 | """ 44 | Automatically optimize queries. 45 | """ 46 | 47 | def __init__(self, info, **options): 48 | self.root_info = info 49 | self.disable_abort_only = options.pop("disable_abort_only", False) 50 | 51 | def optimize(self, queryset): 52 | info = self.root_info 53 | field_def = get_field_def_compat( 54 | info.schema, info.parent_type, info.field_nodes[0] 55 | ) 56 | store = self._optimize_gql_selections( 57 | self._get_type(field_def), 58 | info.field_nodes[0], 59 | # info.parent_type, 60 | ) 61 | return store.optimize_queryset(queryset) 62 | 63 | def _get_type(self, field_def): 64 | a_type = field_def.type 65 | while hasattr(a_type, "of_type"): 66 | a_type = a_type.of_type 67 | return a_type 68 | 69 | def _get_graphql_schema(self, schema): 70 | if isinstance(schema, GraphQLSchema): 71 | return schema 72 | else: 73 | return schema.graphql_schema 74 | 75 | def _get_possible_types(self, graphql_type): 76 | if isinstance(graphql_type, (GraphQLInterfaceType, GraphQLUnionType)): 77 | graphql_schema = self._get_graphql_schema(self.root_info.schema) 78 | return graphql_schema.get_possible_types(graphql_type) 79 | else: 80 | return (graphql_type,) 81 | 82 | def _get_base_model(self, graphql_types): 83 | models = tuple(t.graphene_type._meta.model for t in graphql_types) 84 | for model in models: 85 | if all(issubclass(m, model) for m in models): 86 | return model 87 | return None 88 | 89 | def handle_inline_fragment(self, selection, schema, possible_types, store): 90 | fragment_type_name = selection.type_condition.name.value 91 | graphql_schema = self._get_graphql_schema(schema) 92 | fragment_type = graphql_schema.get_type(fragment_type_name) 93 | fragment_possible_types = self._get_possible_types(fragment_type) 94 | for fragment_possible_type in fragment_possible_types: 95 | fragment_model = fragment_possible_type.graphene_type._meta.model 96 | parent_model = self._get_base_model(possible_types) 97 | if not parent_model: 98 | continue 99 | path_from_parent = _get_path_from_parent(fragment_model._meta, parent_model) 100 | select_related_name = LOOKUP_SEP.join( 101 | p.join_field.name for p in path_from_parent 102 | ) 103 | if not select_related_name: 104 | continue 105 | fragment_store = self._optimize_gql_selections( 106 | fragment_possible_type, 107 | selection, 108 | # parent_type, 109 | ) 110 | store.select_related(select_related_name, fragment_store) 111 | return store 112 | 113 | def handle_fragment_spread(self, store, name, field_type): 114 | fragment = self.root_info.fragments[name] 115 | fragment_store = self._optimize_gql_selections( 116 | field_type, 117 | fragment, 118 | # parent_type, 119 | ) 120 | store.append(fragment_store) 121 | 122 | def _optimize_gql_selections(self, field_type, field_ast): 123 | store = QueryOptimizerStore( 124 | disable_abort_only=self.disable_abort_only, 125 | ) 126 | 127 | selection_set = field_ast.selection_set 128 | if not selection_set: 129 | return store 130 | optimized_fields_by_model = {} 131 | schema = self.root_info.schema 132 | graphql_schema = self._get_graphql_schema(schema) 133 | graphql_type = graphql_schema.get_type(field_type.name) 134 | 135 | possible_types = self._get_possible_types(graphql_type) 136 | for selection in selection_set.selections: 137 | if isinstance(selection, InlineFragmentNode): 138 | self.handle_inline_fragment(selection, schema, possible_types, store) 139 | else: 140 | name = selection.name.value 141 | if isinstance(selection, FragmentSpreadNode): 142 | self.handle_fragment_spread(store, name, field_type) 143 | else: 144 | for possible_type in possible_types: 145 | selection_field_def = possible_type.fields.get(name) 146 | if not selection_field_def: 147 | continue 148 | 149 | graphene_type = possible_type.graphene_type 150 | # Check if graphene type is a relay connection or a relay edge 151 | if hasattr(graphene_type._meta, "node") or ( 152 | hasattr(graphene_type, "cursor") 153 | and hasattr(graphene_type, "node") 154 | ): 155 | relay_store = self._optimize_gql_selections( 156 | self._get_type(selection_field_def), 157 | selection, 158 | ) 159 | store.append(relay_store) 160 | try: 161 | from django.db.models import DEFERRED # noqa: F401 162 | except ImportError: 163 | store.abort_only_optimization() 164 | else: 165 | model = getattr(graphene_type._meta, "model", None) 166 | if model and name not in optimized_fields_by_model: 167 | field_model = optimized_fields_by_model[name] = model 168 | if field_model == model: 169 | self._optimize_field( 170 | store, 171 | model, 172 | selection, 173 | selection_field_def, 174 | possible_type, 175 | ) 176 | return store 177 | 178 | def _optimize_field(self, store, model, selection, field_def, parent_type): 179 | optimized_by_name = self._optimize_field_by_name( 180 | store, model, selection, field_def 181 | ) 182 | optimized_by_hints = self._optimize_field_by_hints( 183 | store, selection, field_def, parent_type 184 | ) 185 | optimized = optimized_by_name or optimized_by_hints 186 | if not optimized: 187 | store.abort_only_optimization() 188 | 189 | def _optimize_field_by_name(self, store, model, selection, field_def): 190 | name = self._get_name_from_resolver(field_def.resolve) 191 | if not name: 192 | return False 193 | model_field = self._get_model_field_from_name(model, name) 194 | if not model_field: 195 | return False 196 | if self._is_foreign_key_id(model_field, name): 197 | store.only(name) 198 | return True 199 | if model_field.many_to_one or model_field.one_to_one: 200 | field_store = self._optimize_gql_selections( 201 | self._get_type(field_def), 202 | selection, 203 | # parent_type, 204 | ) 205 | store.select_related(name, field_store) 206 | return True 207 | if model_field.one_to_many or model_field.many_to_many: 208 | field_store = self._optimize_gql_selections( 209 | self._get_type(field_def), 210 | selection, 211 | # parent_type, 212 | ) 213 | 214 | if isinstance(model_field, ManyToOneRel): 215 | field_store.only(model_field.field.name) 216 | 217 | related_queryset = model_field.related_model.objects.all() 218 | store.prefetch_related(name, field_store, related_queryset) 219 | return True 220 | if not model_field.is_relation: 221 | store.only(name) 222 | return True 223 | return False 224 | 225 | def _get_optimization_hints(self, resolver): 226 | return getattr(resolver, "optimization_hints", None) 227 | 228 | def _get_value(self, info, value): 229 | if isinstance(value, VariableNode): 230 | var_name = value.name.value 231 | value = info.variable_values.get(var_name) 232 | return value 233 | elif isinstance(value, InputObjectType): 234 | return value.__dict__ 235 | else: 236 | return GenericScalar.parse_literal(value) 237 | 238 | def _optimize_field_by_hints(self, store, selection, field_def, parent_type): 239 | optimization_hints = self._get_optimization_hints(field_def.resolve) 240 | if not optimization_hints: 241 | return False 242 | info = self._create_resolve_info( 243 | selection.name.value, 244 | (selection,), 245 | self._get_type(field_def), 246 | parent_type, 247 | ) 248 | 249 | args = [] 250 | for arg in selection.arguments: 251 | args.append(self._get_value(info, arg.value)) 252 | args = tuple(args) 253 | 254 | self._add_optimization_hints( 255 | optimization_hints.select_related(info, *args), 256 | store.select_list, 257 | ) 258 | self._add_optimization_hints( 259 | optimization_hints.prefetch_related(info, *args), 260 | store.prefetch_list, 261 | ) 262 | if store.only_list is not None: 263 | self._add_optimization_hints( 264 | optimization_hints.only(info, *args), 265 | store.only_list, 266 | ) 267 | return True 268 | 269 | def _add_optimization_hints(self, source, target): 270 | if source: 271 | if not is_iterable(source): 272 | source = (source,) 273 | target += [ 274 | source_item for source_item in source if source_item not in target 275 | ] 276 | 277 | def _get_name_from_resolver(self, resolver): 278 | optimization_hints = self._get_optimization_hints(resolver) 279 | if optimization_hints: 280 | name_fn = optimization_hints.model_field 281 | if name_fn: 282 | return name_fn() 283 | if self._is_resolver_for_id_field(resolver): 284 | return "id" 285 | elif isinstance(resolver, functools.partial): 286 | resolver_fn = resolver 287 | if resolver_fn.func != default_resolver: 288 | # Some resolvers have the partial function as the second 289 | # argument. 290 | for arg in resolver_fn.args: 291 | if isinstance(arg, (str, functools.partial)): 292 | break 293 | else: 294 | # No suitable instances found, default to first arg 295 | arg = resolver_fn.args[0] 296 | resolver_fn = arg 297 | if ( 298 | isinstance(resolver_fn, functools.partial) 299 | and resolver_fn.func == default_resolver 300 | ): 301 | return resolver_fn.args[0] 302 | if self._is_resolver_for_id_field(resolver_fn): 303 | return "id" 304 | return resolver_fn 305 | 306 | def _is_resolver_for_id_field(self, resolver): 307 | resolve_id = DjangoObjectType.resolve_id 308 | # For python 2 unbound method: 309 | if hasattr(resolve_id, "im_func"): 310 | resolve_id = resolve_id.im_func 311 | return resolver == resolve_id 312 | 313 | def _get_model_field_from_name(self, model, name): 314 | try: 315 | return model._meta.get_field(name) 316 | except FieldDoesNotExist: 317 | descriptor = model.__dict__.get(name) 318 | if not descriptor: 319 | return None 320 | return getattr(descriptor, "rel", None) or getattr( 321 | descriptor, "related", None 322 | ) # Django < 1.9 323 | 324 | def _is_foreign_key_id(self, model_field, name): 325 | return ( 326 | isinstance(model_field, ForeignKey) 327 | and model_field.name != name 328 | and model_field.get_attname() == name 329 | ) 330 | 331 | def _create_resolve_info(self, field_name, field_asts, return_type, parent_type): 332 | return GraphQLResolveInfo( 333 | field_name, 334 | field_asts, 335 | return_type, 336 | parent_type, 337 | Path(None, 0, None), 338 | schema=self.root_info.schema, 339 | fragments=self.root_info.fragments, 340 | root_value=self.root_info.root_value, 341 | operation=self.root_info.operation, 342 | variable_values=self.root_info.variable_values, 343 | context=self.root_info.context, 344 | is_awaitable=self.root_info.is_awaitable, 345 | ) 346 | 347 | 348 | class QueryOptimizerStore: 349 | def __init__(self, disable_abort_only=False): 350 | self.select_list = [] 351 | self.prefetch_list = [] 352 | self.only_list = [] 353 | self.disable_abort_only = disable_abort_only 354 | 355 | def select_related(self, name, store): 356 | if store.select_list: 357 | for select in store.select_list: 358 | self.select_list.append(name + LOOKUP_SEP + select) 359 | else: 360 | self.select_list.append(name) 361 | for prefetch in store.prefetch_list: 362 | if isinstance(prefetch, Prefetch): 363 | prefetch.add_prefix(name) 364 | else: 365 | prefetch = name + LOOKUP_SEP + prefetch 366 | self.prefetch_list.append(prefetch) 367 | if self.only_list is not None: 368 | if store.only_list is None: 369 | self.abort_only_optimization() 370 | else: 371 | for only in store.only_list: 372 | self.only_list.append(name + LOOKUP_SEP + only) 373 | 374 | def prefetch_related(self, name, store, queryset): 375 | if store.select_list or store.only_list: 376 | queryset = store.optimize_queryset(queryset) 377 | self.prefetch_list.append(Prefetch(name, queryset=queryset)) 378 | elif store.prefetch_list: 379 | for prefetch in store.prefetch_list: 380 | if isinstance(prefetch, Prefetch): 381 | prefetch.add_prefix(name) 382 | else: 383 | prefetch = name + LOOKUP_SEP + prefetch 384 | self.prefetch_list.append(prefetch) 385 | else: 386 | self.prefetch_list.append(name) 387 | 388 | def only(self, field): 389 | if self.only_list is not None: 390 | self.only_list.append(field) 391 | 392 | def abort_only_optimization(self): 393 | if not self.disable_abort_only: 394 | self.only_list = None 395 | 396 | def optimize_queryset(self, queryset): 397 | if self.select_list: 398 | queryset = queryset.select_related(*self.select_list) 399 | 400 | if self.prefetch_list: 401 | queryset = queryset.prefetch_related(*self.prefetch_list) 402 | 403 | if self.only_list: 404 | queryset = queryset.only(*self.only_list) 405 | 406 | return queryset 407 | 408 | def append(self, store): 409 | self.select_list += store.select_list 410 | self.prefetch_list += store.prefetch_list 411 | if self.only_list is not None: 412 | if store.only_list is None: 413 | self.only_list = None 414 | else: 415 | self.only_list += store.only_list 416 | 417 | 418 | # For legacy Django versions: 419 | def _get_path_from_parent(self, parent): 420 | """ 421 | Return a list of PathInfos containing the path from the parent 422 | model to the current model, or an empty list if parent is not a 423 | parent of the current model. 424 | """ 425 | if hasattr(self, "get_path_from_parent"): 426 | return self.get_path_from_parent(parent) 427 | if self.model is parent: 428 | return [] 429 | model = self.concrete_model 430 | # Get a reversed base chain including both the current and parent 431 | # models. 432 | chain = model._meta.get_base_chain(parent) or [] 433 | chain.reverse() 434 | chain.append(model) 435 | # Construct a list of the PathInfos between models in chain. 436 | path = [] 437 | for i, ancestor in enumerate(chain[:-1]): 438 | child = chain[i + 1] 439 | link = child._meta.get_ancestor_link(ancestor) 440 | path.extend(link.get_reverse_path_info()) 441 | return path 442 | -------------------------------------------------------------------------------- /graphene_django_optimizer/resolver.py: -------------------------------------------------------------------------------- 1 | from .hints import OptimizationHints 2 | 3 | 4 | def resolver_hints(*args, **kwargs): 5 | optimization_hints = OptimizationHints(*args, **kwargs) 6 | 7 | def apply_resolver_hints(resolver): 8 | resolver.optimization_hints = optimization_hints 9 | return resolver 10 | 11 | return apply_resolver_hints 12 | -------------------------------------------------------------------------------- /graphene_django_optimizer/types.py: -------------------------------------------------------------------------------- 1 | from graphene.types.definitions import GrapheneObjectType 2 | from graphene_django.types import DjangoObjectType 3 | 4 | from .query import query 5 | 6 | 7 | class OptimizedDjangoObjectType(DjangoObjectType): 8 | class Meta: 9 | abstract = True 10 | 11 | @classmethod 12 | def can_optimize_resolver(cls, resolver_info): 13 | return ( 14 | isinstance(resolver_info.return_type, GrapheneObjectType) 15 | and resolver_info.return_type.graphene_type is cls 16 | ) 17 | 18 | @classmethod 19 | def get_queryset(cls, queryset, info): 20 | queryset = super(OptimizedDjangoObjectType, cls).get_queryset(queryset, info) 21 | if cls.can_optimize_resolver(info): 22 | queryset = query(queryset, info) 23 | return queryset 24 | -------------------------------------------------------------------------------- /graphene_django_optimizer/utils.py: -------------------------------------------------------------------------------- 1 | import graphql 2 | from graphql import GraphQLSchema, GraphQLObjectType, FieldNode 3 | from graphql.execution.execute import get_field_def 4 | 5 | noop = lambda *args, **kwargs: None 6 | 7 | 8 | def is_iterable(obj): 9 | return hasattr(obj, "__iter__") and not isinstance(obj, str) 10 | 11 | 12 | def get_field_def_compat( 13 | schema: GraphQLSchema, parent_type: GraphQLObjectType, field_node: FieldNode 14 | ): 15 | return get_field_def( 16 | schema, 17 | parent_type, 18 | field_node.name.value if graphql.version_info < (3, 2) else field_node, 19 | ) 20 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tfoxy/graphene-django-optimizer/ebd840225a50f5679888ace9c1e6e062c119af4a/requirements.txt -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | 4 | [tool:pytest] 5 | addopts = --cov -vv 6 | python_files = tests/test_*.py 7 | django_find_project = false 8 | DJANGO_SETTINGS_MODULE = tests.settings 9 | 10 | [bdist_wheel] 11 | universal=1 12 | 13 | [flake8] 14 | # D100 missing docstring in public module 15 | # E501 line too long 16 | # E731 do not assign a lambda expression, use a def 17 | ignore = D100,E501,W503,E731 18 | exclude = .git,__pycache__,venv,venv2,venv3,build,.eggs 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import sys 5 | from setuptools import setup 6 | 7 | needs_pytest = {"pytest", "test", "ptr"}.intersection(sys.argv) 8 | pytest_runner = ["pytest-runner >=4.0,<5dev"] if needs_pytest else [] 9 | 10 | 11 | def read(fname): 12 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 13 | 14 | 15 | setup( 16 | name="graphene-django-optimizer", 17 | version="0.10.0", 18 | author="Tomás Fox", 19 | author_email="tomas.c.fox@gmail.com", 20 | description="Optimize database access inside graphene queries.", 21 | license="MIT", 22 | keywords="graphene django optimizer optimize graphql query prefetch select related", 23 | url="https://github.com/tfoxy/graphene-django-optimizer", 24 | packages=["graphene_django_optimizer"], 25 | setup_requires=pytest_runner, 26 | long_description=read("README.md"), 27 | long_description_content_type="text/markdown", 28 | classifiers=[ 29 | "Development Status :: 4 - Beta", 30 | "Environment :: Web Environment", 31 | "Intended Audience :: Developers", 32 | "License :: OSI Approved :: MIT License", 33 | "Operating System :: OS Independent", 34 | "Programming Language :: Python", 35 | "Programming Language :: Python :: 3", 36 | "Programming Language :: Python :: 3.6", 37 | "Programming Language :: Python :: 3.7", 38 | "Programming Language :: Python :: 3.8", 39 | "Framework :: Django", 40 | "Framework :: Django :: 2.2", 41 | "Framework :: Django :: 3.1", 42 | ], 43 | ) 44 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tfoxy/graphene-django-optimizer/ebd840225a50f5679888ace9c1e6e062c119af4a/tests/__init__.py -------------------------------------------------------------------------------- /tests/graphql_utils.py: -------------------------------------------------------------------------------- 1 | import graphql.version 2 | from graphql import ( 3 | GraphQLResolveInfo, 4 | Source, 5 | Undefined, 6 | parse, 7 | ) 8 | from graphql.execution.collect_fields import collect_fields 9 | from graphql.execution.execute import ExecutionContext 10 | from graphql.utilities import get_operation_root_type 11 | from collections import defaultdict 12 | 13 | from graphql.pyutils import Path 14 | 15 | from graphene_django_optimizer.utils import get_field_def_compat 16 | 17 | 18 | def create_execution_context(schema, request_string, variables=None): 19 | source = Source(request_string, "GraphQL request") 20 | document_ast = parse(source) 21 | return ExecutionContext.build( 22 | schema, 23 | document_ast, 24 | root_value=None, 25 | context_value=None, 26 | raw_variable_values=variables, 27 | operation_name=None, 28 | middleware=None, 29 | ) 30 | 31 | 32 | def get_field_asts_from_execution_context(exe_context): 33 | if graphql.version_info < (3, 2): 34 | fields = exe_context.collect_fields( 35 | type, 36 | exe_context.operation.selection_set, 37 | defaultdict(list), 38 | set(), 39 | ) 40 | else: 41 | fields = collect_fields( 42 | exe_context.schema, 43 | exe_context.fragments, 44 | exe_context.variable_values, 45 | type, 46 | exe_context.operation.selection_set, 47 | ) 48 | # field_asts = next(iter(fields.values())) 49 | field_asts = tuple(fields.values())[0] 50 | return field_asts 51 | 52 | 53 | def create_resolve_info(schema, request_string, variables=None, return_type=None): 54 | exe_context = create_execution_context(schema, request_string, variables) 55 | parent_type = get_operation_root_type(schema, exe_context.operation) 56 | field_asts = get_field_asts_from_execution_context(exe_context) 57 | 58 | if return_type is None: 59 | field_def = get_field_def_compat(schema, parent_type, field_asts[0]) 60 | if not field_def: 61 | return Undefined 62 | return_type = field_def.type 63 | 64 | # The resolve function's optional third argument is a context value that 65 | # is provided to every resolve function within an execution. It is commonly 66 | # used to represent an authenticated user, or request-specific caches. 67 | return GraphQLResolveInfo( 68 | field_asts[0].name.value, 69 | field_asts, 70 | return_type, 71 | parent_type, 72 | Path(None, 0, None), 73 | schema, 74 | exe_context.fragments, 75 | exe_context.root_value, 76 | exe_context.operation, 77 | exe_context.variable_values, 78 | exe_context.context_value, 79 | exe_context.is_awaitable, 80 | ) 81 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Item(models.Model): 5 | name = models.CharField(max_length=100, blank=True) 6 | parent = models.ForeignKey( 7 | "Item", on_delete=models.SET_NULL, null=True, related_name="children" 8 | ) 9 | item = models.ForeignKey("Item", on_delete=models.SET_NULL, null=True) 10 | value = models.IntegerField(default=10) 11 | 12 | item_type = "simple" 13 | 14 | @property 15 | def title(self): 16 | return self.name 17 | 18 | @property 19 | def unoptimized_title(self): 20 | return self.title 21 | 22 | def all_children(self): 23 | return self.children.all() 24 | 25 | 26 | class DetailedItem(Item): 27 | detail = models.TextField(null=True) 28 | item_type = models.CharField(max_length=100, null=True) 29 | 30 | 31 | class RelatedItem(Item): 32 | related_items = models.ManyToManyField(Item) 33 | 34 | 35 | class RelatedOneToManyItem(models.Model): 36 | name = models.CharField(max_length=100, blank=True) 37 | item = models.ForeignKey(Item, on_delete=models.PROTECT, related_name="otm_items") 38 | 39 | 40 | class ExtraDetailedItem(DetailedItem): 41 | extra_detail = models.TextField() 42 | 43 | 44 | class UnrelatedModel(models.Model): 45 | detail = models.TextField(null=True) 46 | 47 | 48 | class SomeOtherItem(models.Model): 49 | name = models.CharField(max_length=100, blank=True) 50 | 51 | 52 | class OtherItem(models.Model): 53 | name = models.CharField(max_length=100, blank=True) 54 | some_other_item = models.ForeignKey( 55 | "SomeOtherItem", on_delete=models.PROTECT, null=False 56 | ) 57 | -------------------------------------------------------------------------------- /tests/schema.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Prefetch 2 | import graphene 3 | from graphene import ConnectionField, relay 4 | from graphene_django.fields import DjangoConnectionField 5 | import graphene_django_optimizer as gql_optimizer 6 | from graphene_django_optimizer import OptimizedDjangoObjectType 7 | 8 | from .models import ( 9 | DetailedItem, 10 | ExtraDetailedItem, 11 | Item, 12 | RelatedItem, 13 | UnrelatedModel, 14 | SomeOtherItem, 15 | OtherItem, 16 | RelatedOneToManyItem, 17 | ) 18 | 19 | 20 | def _prefetch_children(info, filter_input): 21 | if filter_input is None: 22 | filter_input = {} 23 | 24 | gte = filter_input.get("value", {}).get("gte", 0) 25 | return Prefetch( 26 | "children", 27 | queryset=gql_optimizer.query(Item.objects.filter(value__gte=int(gte)), info), 28 | to_attr="gql_custom_filtered_children", 29 | ) 30 | 31 | 32 | class RangeInput(graphene.InputObjectType): 33 | gte = graphene.Field(graphene.Int) 34 | 35 | 36 | class ItemFilterInput(graphene.InputObjectType): 37 | value = graphene.Field(RangeInput) 38 | 39 | 40 | class ItemInterface(graphene.Interface): 41 | id = relay.GlobalID() 42 | parent_id = relay.GlobalID() 43 | foo = graphene.String() 44 | title = graphene.String() 45 | unoptimized_title = graphene.String() 46 | item_type = graphene.String() 47 | father = graphene.Field("tests.schema.ItemType") 48 | all_children = graphene.List("tests.schema.ItemType") 49 | children_names = graphene.String() 50 | aux_children_names = graphene.String() 51 | filtered_children = graphene.List( 52 | "tests.schema.ItemType", 53 | name=graphene.String(required=True), 54 | ) 55 | aux_filtered_children = graphene.List( 56 | "tests.schema.ItemType", 57 | name=graphene.String(required=True), 58 | ) 59 | children_custom_filtered = gql_optimizer.field( 60 | ConnectionField("tests.schema.ItemConnection", filter_input=ItemFilterInput()), 61 | prefetch_related=_prefetch_children, 62 | ) 63 | 64 | def resolve_foo(root, info): 65 | return "bar" 66 | 67 | @gql_optimizer.resolver_hints( 68 | model_field=lambda: "children", 69 | ) 70 | def resolve_children_names(root, info): 71 | return " ".join(item.name for item in root.children.all()) 72 | 73 | @gql_optimizer.resolver_hints( 74 | prefetch_related="children", 75 | ) 76 | def resolve_aux_children_names(root, info): 77 | return " ".join(item.name for item in root.children.all()) 78 | 79 | @gql_optimizer.resolver_hints( 80 | prefetch_related=lambda info, name: Prefetch( 81 | "children", 82 | queryset=gql_optimizer.query(Item.objects.filter(name=name), info), 83 | to_attr="gql_filtered_children_" + name, 84 | ), 85 | ) 86 | def resolve_filtered_children(root, info, name): 87 | return getattr(root, "gql_filtered_children_" + name) 88 | 89 | @gql_optimizer.resolver_hints( 90 | prefetch_related=lambda info, name: Prefetch( 91 | "children", 92 | queryset=gql_optimizer.query( 93 | Item.objects.filter(name=f"some_prefix {name}"), info 94 | ), 95 | # Different queryset than resolve_filtered_children but same to_attr, on purpose 96 | # to check equality of Prefetch is based only on to_attr attribute, as it is implemented in Django. 97 | to_attr="gql_filtered_children_" + name, 98 | ), 99 | ) 100 | def resolve_aux_filtered_children(root, info, name): 101 | return getattr(root, "gql_filtered_children_" + name) 102 | 103 | def resolve_children_custom_filtered(root, info, *_args): 104 | return getattr(root, "gql_custom_filtered_children") 105 | 106 | 107 | class BaseItemType(OptimizedDjangoObjectType): 108 | title = gql_optimizer.field( 109 | graphene.String(), 110 | only="name", 111 | ) 112 | father = gql_optimizer.field( 113 | graphene.Field("tests.schema.ItemType"), 114 | model_field="parent", 115 | ) 116 | relay_all_children = DjangoConnectionField("tests.schema.ItemNode") 117 | 118 | class Meta: 119 | model = Item 120 | fields = "__all__" 121 | 122 | @gql_optimizer.resolver_hints( 123 | model_field="children", 124 | ) 125 | def resolve_relay_all_children(root, info, **kwargs): 126 | return root.children.all() 127 | 128 | 129 | class ItemNode(BaseItemType): 130 | class Meta: 131 | model = Item 132 | fields = "__all__" 133 | 134 | interfaces = ( 135 | graphene.relay.Node, 136 | ItemInterface, 137 | ) 138 | 139 | 140 | class SomeOtherItemType(OptimizedDjangoObjectType): 141 | class Meta: 142 | model = SomeOtherItem 143 | fields = "__all__" 144 | 145 | 146 | class OtherItemType(OptimizedDjangoObjectType): 147 | class Meta: 148 | model = OtherItem 149 | fields = "__all__" 150 | 151 | 152 | class ItemType(BaseItemType): 153 | class Meta: 154 | model = Item 155 | fields = "__all__" 156 | interfaces = (ItemInterface,) 157 | 158 | 159 | class ItemConnection(graphene.relay.Connection): 160 | class Meta: 161 | node = ItemType 162 | 163 | 164 | class DetailedInterface(graphene.Interface): 165 | detail = graphene.String() 166 | 167 | 168 | class DetailedItemType(ItemType): 169 | class Meta: 170 | model = DetailedItem 171 | fields = "__all__" 172 | interfaces = (ItemInterface, DetailedInterface) 173 | 174 | 175 | class RelatedItemType(ItemType): 176 | class Meta: 177 | model = RelatedItem 178 | fields = "__all__" 179 | interfaces = (ItemInterface,) 180 | 181 | 182 | class ExtraDetailedItemType(DetailedItemType): 183 | class Meta: 184 | model = ExtraDetailedItem 185 | fields = "__all__" 186 | interfaces = (ItemInterface,) 187 | 188 | 189 | class RelatedOneToManyItemType(OptimizedDjangoObjectType): 190 | class Meta: 191 | model = RelatedOneToManyItem 192 | fields = "__all__" 193 | 194 | 195 | class UnrelatedModelType(OptimizedDjangoObjectType): 196 | class Meta: 197 | model = UnrelatedModel 198 | fields = "__all__" 199 | interfaces = (DetailedInterface,) 200 | 201 | 202 | class DummyItemMutation(graphene.Mutation): 203 | item = graphene.Field(ItemNode, description="The retrieved item.", required=False) 204 | 205 | class Arguments: 206 | item_id = graphene.ID(description="The ID of the item.") 207 | 208 | class Meta: 209 | description = "A dummy mutation that retrieves a given item node." 210 | 211 | @staticmethod 212 | def mutate(info, item_id): 213 | return graphene.Node.get_node_from_global_id(info, item_id, only_type=ItemNode) 214 | 215 | 216 | class Query(graphene.ObjectType): 217 | items = graphene.List(ItemInterface, name=graphene.String(required=True)) 218 | relay_items = DjangoConnectionField(ItemNode) 219 | other_items = graphene.List(OtherItemType) 220 | some_other_items = graphene.List(SomeOtherItemType) 221 | 222 | def resolve_items(root, info, name): 223 | return gql_optimizer.query(Item.objects.filter(name=name), info) 224 | 225 | def resolve_relay_items(root, info, **kwargs): 226 | return gql_optimizer.query(Item.objects.all(), info) 227 | 228 | def resolve_other_items(root, info): 229 | return gql_optimizer.query(OtherItemType.objects.all(), info) 230 | 231 | 232 | class Schema(graphene.Schema): 233 | @property 234 | def query_type(self): 235 | return self.graphql_schema.get_type("Query") 236 | 237 | @property 238 | def mutation_type(self): 239 | return self.graphql_schema.get_type("Mutation") 240 | 241 | @property 242 | def subscription_type(self): 243 | return self.graphql_schema.get_type("Subscription") 244 | 245 | def get_type(self, _type): 246 | return self.graphql_schema.get_type(_type) 247 | 248 | 249 | schema = Schema(query=Query, types=(UnrelatedModelType,), mutation=DummyItemMutation) 250 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | INSTALLED_APPS = ("tests",) 2 | DATABASES = { 3 | "default": { 4 | "ENGINE": "django.db.backends.sqlite3", 5 | }, 6 | } 7 | SECRET_KEY = "dummy" 8 | -------------------------------------------------------------------------------- /tests/test_field.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import graphene_django_optimizer as gql_optimizer 3 | 4 | from .graphql_utils import create_resolve_info 5 | from .models import Item 6 | from .schema import schema 7 | from .test_utils import assert_query_equality 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_should_optimize_non_django_field_if_it_has_an_optimization_hint_in_the_field(): 12 | info = create_resolve_info( 13 | schema, 14 | """ 15 | query { 16 | items(name: "bar") { 17 | id 18 | foo 19 | father { 20 | id 21 | } 22 | } 23 | } 24 | """, 25 | ) 26 | qs = Item.objects.filter(name="bar") 27 | items = gql_optimizer.query(qs, info) 28 | optimized_items = qs.select_related("parent") 29 | assert_query_equality(items, optimized_items) 30 | 31 | 32 | @pytest.mark.django_db 33 | def test_should_optimize_with_only_hint(): 34 | info = create_resolve_info( 35 | schema, 36 | """ 37 | query { 38 | items(name: "foo") { 39 | id 40 | title 41 | } 42 | } 43 | """, 44 | ) 45 | qs = Item.objects.filter(name="foo") 46 | items = gql_optimizer.query(qs, info) 47 | optimized_items = qs.only("id", "name") 48 | assert_query_equality(items, optimized_items) 49 | -------------------------------------------------------------------------------- /tests/test_query.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django.test.utils import CaptureQueriesContext 4 | from django.db import connection 5 | from django.db.models import Prefetch 6 | import graphene_django_optimizer as gql_optimizer 7 | 8 | from .graphql_utils import create_resolve_info 9 | from .models import ( 10 | Item, 11 | OtherItem, 12 | RelatedOneToManyItem, 13 | ) 14 | from .schema import schema 15 | from .test_utils import assert_query_equality 16 | 17 | 18 | @pytest.mark.django_db 19 | def test_should_reduce_number_of_queries_by_using_select_related(): 20 | # parent = Item.objects.create(name='foo') 21 | # Item.objects.create(name='bar', parent=parent) 22 | info = create_resolve_info( 23 | schema, 24 | """ 25 | query { 26 | items(name: "bar") { 27 | id 28 | foo 29 | parent { 30 | id 31 | } 32 | } 33 | } 34 | """, 35 | ) 36 | qs = Item.objects.filter(name="bar") 37 | items = gql_optimizer.query(qs, info) 38 | optimized_items = qs.select_related("parent") 39 | assert_query_equality(items, optimized_items) 40 | 41 | 42 | @pytest.mark.django_db 43 | def test_should_reduce_number_of_queries_by_using_prefetch_related(): 44 | # parent = Item.objects.create(name='foo') 45 | # Item.objects.create(name='bar', parent=parent) 46 | info = create_resolve_info( 47 | schema, 48 | """ 49 | query { 50 | items(name: "foo") { 51 | id 52 | foo 53 | children { 54 | id 55 | foo 56 | } 57 | } 58 | } 59 | """, 60 | ) 61 | qs = Item.objects.filter(name="foo") 62 | items = gql_optimizer.query(qs, info) 63 | optimized_items = qs.prefetch_related("children") 64 | assert_query_equality(items, optimized_items) 65 | 66 | 67 | @pytest.mark.django_db 68 | def test_should_optimize_scalar_model_fields(): 69 | # Item.objects.create(name='foo') 70 | info = create_resolve_info( 71 | schema, 72 | """ 73 | query { 74 | items(name: "foo") { 75 | id 76 | name 77 | } 78 | } 79 | """, 80 | ) 81 | qs = Item.objects.filter(name="foo") 82 | items = gql_optimizer.query(qs, info) 83 | optimized_items = qs.only("id", "name") 84 | assert_query_equality(items, optimized_items) 85 | 86 | 87 | @pytest.mark.django_db 88 | def test_should_optimize_scalar_foreign_key_model_fields(): 89 | # parent = Item.objects.create(name='foo') 90 | # Item.objects.create(name='bar', parent=parent) 91 | info = create_resolve_info( 92 | schema, 93 | """ 94 | query { 95 | items(name: "bar") { 96 | id 97 | parentId 98 | } 99 | } 100 | """, 101 | ) 102 | qs = Item.objects.filter(name="bar") 103 | items = gql_optimizer.query(qs, info) 104 | optimized_items = qs.only("id", "parent_id") 105 | assert_query_equality(items, optimized_items) 106 | 107 | 108 | @pytest.mark.django_db 109 | def test_should_not_try_to_optimize_non_model_fields(): 110 | # Item.objects.create(name='foo') 111 | info = create_resolve_info( 112 | schema, 113 | """ 114 | query { 115 | items(name: "foo") { 116 | id 117 | foo 118 | } 119 | } 120 | """, 121 | ) 122 | qs = Item.objects.filter(name="foo") 123 | items = gql_optimizer.query(qs, info) 124 | optimized_items = qs 125 | assert_query_equality(items, optimized_items) 126 | 127 | 128 | @pytest.mark.django_db 129 | def test_should_not_try_to_optimize_non_field_model_fields(): 130 | # Item.objects.create(name='foo') 131 | info = create_resolve_info( 132 | schema, 133 | """ 134 | query { 135 | items(name: "foo") { 136 | id 137 | unoptimizedTitle 138 | } 139 | } 140 | """, 141 | ) 142 | qs = Item.objects.filter(name="foo") 143 | items = gql_optimizer.query(qs, info) 144 | optimized_items = qs 145 | assert_query_equality(items, optimized_items) 146 | 147 | 148 | @pytest.mark.django_db 149 | def test_should_try_to_optimize_non_field_model_fields_when_disabling_abort_only(): 150 | # Item.objects.create(name='foo') 151 | info = create_resolve_info( 152 | schema, 153 | """ 154 | query { 155 | items(name: "foo") { 156 | id 157 | unoptimizedTitle 158 | } 159 | } 160 | """, 161 | ) 162 | qs = Item.objects.filter(name="foo") 163 | items = gql_optimizer.query(qs, info, disable_abort_only=True) 164 | optimized_items = qs.only("id") 165 | assert_query_equality(items, optimized_items) 166 | 167 | 168 | @pytest.mark.django_db 169 | def test_should_optimize_when_using_fragments(): 170 | # parent = Item.objects.create(name='foo') 171 | # Item.objects.create(name='bar', parent=parent) 172 | info = create_resolve_info( 173 | schema, 174 | """ 175 | query { 176 | items(name: "bar") { 177 | ...ItemFragment 178 | } 179 | } 180 | fragment ItemFragment on ItemType { 181 | id 182 | parent { 183 | id 184 | } 185 | } 186 | """, 187 | ) 188 | qs = Item.objects.filter(name="bar") 189 | items = gql_optimizer.query(qs, info) 190 | optimized_items = qs.select_related("parent").only("id", "parent__id") 191 | assert_query_equality(items, optimized_items) 192 | 193 | 194 | @pytest.mark.django_db 195 | def test_should_prefetch_field_with_camel_case_name(): 196 | # item = Item.objects.create(name='foo') 197 | # Item.objects.create(name='bar', item=item) 198 | info = create_resolve_info( 199 | schema, 200 | """ 201 | query { 202 | items(name: "foo") { 203 | id 204 | foo 205 | itemSet { 206 | id 207 | foo 208 | } 209 | } 210 | } 211 | """, 212 | ) 213 | qs = Item.objects.filter(name="foo") 214 | items = gql_optimizer.query(qs, info) 215 | optimized_items = qs.prefetch_related("item_set") 216 | assert_query_equality(items, optimized_items) 217 | 218 | 219 | @pytest.mark.django_db 220 | def test_should_select_nested_related_fields(): 221 | # parent = Item.objects.create(name='foo') 222 | # parent = Item.objects.create(name='bar', parent=parent) 223 | # Item.objects.create(name='foobar', parent=parent) 224 | info = create_resolve_info( 225 | schema, 226 | """ 227 | query { 228 | items(name: "foobar") { 229 | id 230 | foo 231 | parent { 232 | id 233 | parent { 234 | id 235 | } 236 | } 237 | } 238 | } 239 | """, 240 | ) 241 | qs = Item.objects.filter(name="foobar") 242 | items = gql_optimizer.query(qs, info) 243 | optimized_items = qs.select_related("parent__parent") 244 | assert_query_equality(items, optimized_items) 245 | 246 | 247 | @pytest.mark.django_db 248 | def test_should_prefetch_nested_related_fields(): 249 | # parent = Item.objects.create(name='foo') 250 | # parent = Item.objects.create(name='bar', parent=parent) 251 | # Item.objects.create(name='foobar', parent=parent) 252 | info = create_resolve_info( 253 | schema, 254 | """ 255 | query { 256 | items(name: "foo") { 257 | id 258 | foo 259 | children { 260 | id 261 | foo 262 | children { 263 | id 264 | foo 265 | } 266 | } 267 | } 268 | } 269 | """, 270 | ) 271 | qs = Item.objects.filter(name="foo") 272 | items = gql_optimizer.query(qs, info) 273 | optimized_items = qs.prefetch_related("children__children") 274 | assert_query_equality(items, optimized_items) 275 | 276 | 277 | @pytest.mark.django_db 278 | def test_should_prefetch_nested_select_related_field(): 279 | # parent = Item.objects.create(name='foo') 280 | # item = Item.objects.create(name='foobar') 281 | # Item.objects.create(name='bar', parent=parent, item=item) 282 | info = create_resolve_info( 283 | schema, 284 | """ 285 | query { 286 | items(name: "foo") { 287 | id 288 | foo 289 | children { 290 | id 291 | foo 292 | item { 293 | id 294 | } 295 | } 296 | } 297 | } 298 | """, 299 | ) 300 | qs = Item.objects.filter(name="foo") 301 | items = gql_optimizer.query(qs, info) 302 | optimized_items = qs.prefetch_related( 303 | Prefetch("children", queryset=Item.objects.select_related("item")), 304 | ) 305 | assert_query_equality(items, optimized_items) 306 | 307 | 308 | @pytest.mark.django_db 309 | def test_should_select_nested_prefetch_related_field(): 310 | # parent = Item.objects.create(name='foo') 311 | # Item.objects.create(name='bar', parent=parent) 312 | # Item.objects.create(name='foobar', item=parent) 313 | info = create_resolve_info( 314 | schema, 315 | """ 316 | query { 317 | items(name: "foobar") { 318 | id 319 | foo 320 | item { 321 | id 322 | children { 323 | id 324 | foo 325 | } 326 | } 327 | } 328 | } 329 | """, 330 | ) 331 | qs = Item.objects.filter(name="foobar") 332 | items = gql_optimizer.query(qs, info) 333 | optimized_items = qs.select_related("item").prefetch_related("item__children") 334 | assert_query_equality(items, optimized_items) 335 | 336 | 337 | @pytest.mark.django_db 338 | def test_should_select_nested_prefetch_and_select_related_fields(): 339 | # parent = Item.objects.create(name='foo') 340 | # item = Item.objects.create(name='bar_item') 341 | # Item.objects.create(name='bar', parent=parent, item=item) 342 | # Item.objects.create(name='foobar', item=parent) 343 | info = create_resolve_info( 344 | schema, 345 | """ 346 | query { 347 | items(name: "foobar") { 348 | id 349 | foo 350 | item { 351 | id 352 | children { 353 | id 354 | foo 355 | item { 356 | id 357 | } 358 | } 359 | } 360 | } 361 | } 362 | """, 363 | ) 364 | qs = Item.objects.filter(name="foobar") 365 | items = gql_optimizer.query(qs, info) 366 | optimized_items = qs.select_related("item").prefetch_related( 367 | Prefetch("item__children", queryset=Item.objects.select_related("item")), 368 | ) 369 | assert_query_equality(items, optimized_items) 370 | 371 | 372 | @pytest.mark.django_db 373 | def test_should_fetch_fields_of_related_field(): 374 | # parent = Item.objects.create(name='foo') 375 | # Item.objects.create(name='bar', parent=parent) 376 | info = create_resolve_info( 377 | schema, 378 | """ 379 | query { 380 | items(name: "bar") { 381 | id 382 | parent { 383 | id 384 | } 385 | } 386 | } 387 | """, 388 | ) 389 | qs = Item.objects.filter(name="bar") 390 | items = gql_optimizer.query(qs, info) 391 | optimized_items = qs.select_related("parent").only("id", "parent__id") 392 | assert_query_equality(items, optimized_items) 393 | 394 | 395 | @pytest.mark.django_db 396 | def test_should_fetch_fields_of_prefetched_field(): 397 | # parent = Item.objects.create(name='foo') 398 | # Item.objects.create(name='bar', parent=parent) 399 | info = create_resolve_info( 400 | schema, 401 | """ 402 | query { 403 | items(name: "foo") { 404 | id 405 | foo 406 | children { 407 | id 408 | } 409 | } 410 | } 411 | """, 412 | ) 413 | qs = Item.objects.filter(name="foo") 414 | items = gql_optimizer.query(qs, info) 415 | optimized_items = qs.prefetch_related( 416 | Prefetch("children", queryset=Item.objects.only("id", "parent__id")), 417 | ) 418 | assert_query_equality(items, optimized_items) 419 | 420 | 421 | @pytest.mark.django_db 422 | def test_should_fetch_child_model_field_for_interface_field(): 423 | # Item.objects.create(name='foo') 424 | # ExtraDetailedItem.objects.create(name='foo', extra_detail='test') 425 | info = create_resolve_info( 426 | schema, 427 | """ 428 | query { 429 | items(name: "foo") { 430 | id 431 | ... on ExtraDetailedItemType { 432 | extraDetail 433 | } 434 | } 435 | } 436 | """, 437 | ) 438 | qs = Item.objects.filter(name="foo") 439 | items = gql_optimizer.query(qs, info) 440 | optimized_items = qs.select_related("detaileditem__extradetaileditem").only( 441 | "id", "detaileditem__extradetaileditem__extra_detail" 442 | ) 443 | assert_query_equality(items, optimized_items) 444 | 445 | 446 | @pytest.mark.skip(reason="will be tested in the future") 447 | @pytest.mark.django_db 448 | def test_should_fetch_field_of_child_model_when_parent_has_no_optimized_field(): 449 | # Item.objects.create(name='foo') 450 | # DetailedItem.objects.create(name='foo', item_type='test') 451 | info = create_resolve_info( 452 | schema, 453 | """ 454 | query { 455 | items(name: "foo") { 456 | id 457 | item_type 458 | } 459 | } 460 | """, 461 | ) 462 | qs = Item.objects.filter(name="foo") 463 | items = gql_optimizer.query(qs, info) 464 | optimized_items = qs.select_related("detaileditem").only( 465 | "id", "detaileditem__item_type" 466 | ) 467 | assert_query_equality(items, optimized_items) 468 | 469 | 470 | @pytest.mark.django_db 471 | def test_should_fetch_field_inside_interface_fragment(): 472 | info = create_resolve_info( 473 | schema, 474 | """ 475 | query { 476 | items(name: "foo") { 477 | id 478 | ... on DetailedInterface { 479 | detail 480 | } 481 | } 482 | } 483 | """, 484 | ) 485 | qs = Item.objects.filter(name="foo") 486 | items = gql_optimizer.query(qs, info) 487 | optimized_items = qs.select_related("detaileditem").only( 488 | "id", "detaileditem__detail" 489 | ) 490 | assert_query_equality(items, optimized_items) 491 | 492 | 493 | @pytest.mark.django_db 494 | def test_should_use_nested_prefetch_related_while_also_selecting_only_required_fields(): 495 | info = create_resolve_info( 496 | schema, 497 | """ 498 | query { 499 | items(name: "foo") { 500 | children { 501 | children { 502 | id 503 | } 504 | } 505 | } 506 | } 507 | """, 508 | ) 509 | qs = Item.objects.filter(name="foo") 510 | items = gql_optimizer.query(qs, info) 511 | optimized_items = qs.prefetch_related( 512 | Prefetch( 513 | "children", 514 | queryset=Item.objects.only("id", "parent_id").prefetch_related( 515 | Prefetch( 516 | "children", 517 | queryset=Item.objects.only("id", "parent_id"), 518 | ) 519 | ), 520 | ), 521 | ) 522 | assert_query_equality(items, optimized_items) 523 | 524 | 525 | @pytest.mark.django_db 526 | def test_should_check_reverse_relations_add_foreign_key(): 527 | info = create_resolve_info( 528 | schema, 529 | """ 530 | query { 531 | items { 532 | otmItems { 533 | id 534 | } 535 | } 536 | } 537 | """, 538 | ) 539 | qs = Item.objects.all() 540 | items = gql_optimizer.query(qs, info) 541 | optimized_items = qs.prefetch_related( 542 | Prefetch( 543 | "otm_items", 544 | queryset=RelatedOneToManyItem.objects.only("id", "item_id"), 545 | ), 546 | ) 547 | assert_query_equality(items, optimized_items) 548 | 549 | # When the query is not optimized, there will be an additional queries 550 | for i in range(3): 551 | the_item = Item.objects.create(name="foo") 552 | for k in range(3): 553 | RelatedOneToManyItem.objects.create( 554 | name="bar{}{}".format(i, k), item=the_item 555 | ) 556 | 557 | with CaptureQueriesContext(connection) as expected_query_capture: 558 | for i in items: 559 | for k in i.otm_items.all(): 560 | pass 561 | 562 | with CaptureQueriesContext(connection) as optimized_query_capture: 563 | for i in optimized_items: 564 | for k in i.otm_items.all(): 565 | pass 566 | 567 | assert len(optimized_query_capture.captured_queries) == 2 568 | assert len(expected_query_capture) == len(optimized_query_capture) 569 | 570 | 571 | @pytest.mark.django_db 572 | def test_should_only_use_the_only_and_not_select_related(): 573 | info = create_resolve_info( 574 | schema, 575 | """ 576 | query { 577 | otherItems { 578 | id 579 | name 580 | } 581 | } 582 | """, 583 | ) 584 | qs = OtherItem.objects.all() 585 | items = gql_optimizer.query(qs, info) 586 | optimized_items = qs.only("id", "name") 587 | assert_query_equality(items, optimized_items) 588 | 589 | 590 | @pytest.mark.django_db 591 | def test_should_accept_two_hints_with_same_prefetch_to_attr_and_keep_one_of_them(): 592 | info = create_resolve_info( 593 | schema, 594 | """ 595 | query { 596 | items(name: "foo") { 597 | filteredChildren(name: "bar") { 598 | id 599 | name 600 | } 601 | auxFilteredChildren(name: "bar") { # Same name to generate Prefetch with same to_attr 602 | id 603 | name 604 | } 605 | } 606 | } 607 | """, 608 | ) 609 | qs = Item.objects.filter(name="foo") 610 | items = gql_optimizer.query(qs, info) 611 | optimized_items = qs.prefetch_related( 612 | Prefetch( 613 | "children", 614 | queryset=Item.objects.filter(name="bar").only("id", "name"), 615 | to_attr="gql_filtered_children_foo", 616 | ) 617 | ) 618 | assert_query_equality(items, optimized_items) 619 | -------------------------------------------------------------------------------- /tests/test_relay.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import graphene_django_optimizer as gql_optimizer 4 | 5 | from .graphql_utils import create_resolve_info 6 | from .models import Item 7 | from .schema import schema 8 | from .test_utils import assert_query_equality 9 | 10 | 11 | @pytest.mark.django_db 12 | def test_should_return_valid_result_in_a_relay_query(): 13 | Item.objects.create(id=7, name="foo") 14 | # FIXME: Item.parent_id can't be None anymore? 15 | result = schema.execute( 16 | """ 17 | query { 18 | relayItems { 19 | edges { 20 | node { 21 | id 22 | name 23 | } 24 | } 25 | } 26 | } 27 | """ 28 | ) 29 | assert not result.errors 30 | assert result.data["relayItems"]["edges"][0]["node"]["id"] == "SXRlbU5vZGU6Nw==" 31 | # assert ( 32 | # result.data["relayItems"]["edges"][0]["node"]["parentId"] 33 | # == "SXRlbU5vZGU6Tm9uZQ==" 34 | # ) 35 | assert result.data["relayItems"]["edges"][0]["node"]["name"] == "foo" 36 | 37 | 38 | @pytest.mark.django_db 39 | def test_should_reduce_number_of_queries_in_relay_schema_by_using_select_related(): 40 | info = create_resolve_info( 41 | schema, 42 | """ 43 | query { 44 | relayItems { 45 | edges { 46 | node { 47 | id 48 | foo 49 | parent { 50 | id 51 | } 52 | } 53 | } 54 | } 55 | } 56 | """, 57 | ) 58 | qs = Item.objects.filter(name="bar") 59 | items = gql_optimizer.query(qs, info) 60 | optimized_items = qs.select_related("parent") 61 | assert_query_equality(items, optimized_items) 62 | 63 | 64 | @pytest.mark.django_db 65 | def test_should_reduce_number_of_queries_in_relay_schema_by_using_prefetch_related(): 66 | info = create_resolve_info( 67 | schema, 68 | """ 69 | query { 70 | relayItems { 71 | edges { 72 | node { 73 | id 74 | foo 75 | children { 76 | id 77 | foo 78 | } 79 | } 80 | } 81 | } 82 | } 83 | """, 84 | ) 85 | qs = Item.objects.filter(name="foo") 86 | items = gql_optimizer.query(qs, info) 87 | optimized_items = qs.prefetch_related("children") 88 | assert_query_equality(items, optimized_items) 89 | 90 | 91 | @pytest.mark.django_db 92 | def test_should_optimize_query_by_only_requesting_id_field(): 93 | try: 94 | from django.db.models import DEFERRED # noqa: F401 95 | except ImportError: 96 | # Query cannot be optimized if DEFERRED is not present. 97 | # When the ConnectionField is used, it will throw the following error: 98 | # Expected value of type "ItemNode" but got: Item_Deferred_item_id_parent_id. 99 | return 100 | info = create_resolve_info( 101 | schema, 102 | """ 103 | query { 104 | relayItems { 105 | edges { 106 | node { 107 | id 108 | } 109 | } 110 | } 111 | } 112 | """, 113 | ) 114 | qs = Item.objects.filter(name="foo") 115 | items = gql_optimizer.query(qs, info) 116 | optimized_items = qs.only("id") 117 | assert_query_equality(items, optimized_items) 118 | 119 | 120 | @pytest.mark.django_db 121 | def test_should_work_fine_with_page_info_field(): 122 | Item.objects.create(id=7, name="foo") 123 | Item.objects.create(id=13, name="bar") 124 | Item.objects.create(id=17, name="foobar") 125 | result = schema.execute( 126 | """ 127 | query { 128 | relayItems(first: 2) { 129 | pageInfo { 130 | hasNextPage 131 | } 132 | edges { 133 | node { 134 | id 135 | } 136 | } 137 | } 138 | } 139 | """ 140 | ) 141 | assert not result.errors 142 | assert result.data["relayItems"]["pageInfo"]["hasNextPage"] is True 143 | 144 | 145 | @pytest.mark.django_db 146 | def test_should_work_fine_with_page_info_field_below_edges_field_when_only_optimization_is_aborted(): 147 | Item.objects.create(id=7, name="foo") 148 | Item.objects.create(id=13, name="bar") 149 | Item.objects.create(id=17, name="foobar") 150 | result = schema.execute( 151 | """ 152 | query { 153 | relayItems(first: 2) { 154 | edges { 155 | node { 156 | id 157 | foo 158 | } 159 | } 160 | pageInfo { 161 | hasNextPage 162 | } 163 | } 164 | } 165 | """ 166 | ) 167 | assert not result.errors 168 | assert result.data["relayItems"]["pageInfo"]["hasNextPage"] is True 169 | 170 | 171 | @pytest.mark.django_db 172 | def test_should_resolve_nested_variables(): 173 | item_1 = Item.objects.create(id=7, name="foo") 174 | item_1.children.create(id=8, name="bar") 175 | variables = {"itemsFirst": 1, "childrenFirst": 1} 176 | result = schema.execute( 177 | """ 178 | query Query($itemsFirst: Int!, $childrenFirst: Int!) { 179 | relayItems(first: $itemsFirst) { 180 | edges { 181 | node { 182 | relayAllChildren(first: $childrenFirst) { 183 | edges { 184 | node { 185 | id 186 | parentId 187 | } 188 | } 189 | } 190 | } 191 | } 192 | } 193 | } 194 | """, 195 | variables=variables, 196 | ) 197 | assert not result.errors 198 | item_edges = result.data["relayItems"]["edges"] 199 | assert len(item_edges) == 1 200 | child_edges = item_edges[0]["node"]["relayAllChildren"]["edges"][0] 201 | assert len(child_edges) == 1 202 | assert child_edges["node"]["id"] == "SXRlbU5vZGU6OA==" 203 | assert child_edges["node"]["parentId"] == "SXRlbU5vZGU6Nw==" 204 | -------------------------------------------------------------------------------- /tests/test_resolver.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django.db.models import Prefetch 4 | import graphene_django_optimizer as gql_optimizer 5 | 6 | from .graphql_utils import create_resolve_info 7 | from .models import Item 8 | from .schema import schema 9 | from .test_utils import assert_query_equality 10 | 11 | 12 | @pytest.mark.django_db 13 | def test_should_optimize_non_django_field_if_it_has_an_optimization_hint_in_the_resolver(): 14 | # parent = Item.objects.create(name='foo') 15 | # Item.objects.create(name='bar', parent=parent) 16 | # Item.objects.create(name='foobar', parent=parent) 17 | info = create_resolve_info( 18 | schema, 19 | """ 20 | query { 21 | items(name: "foo") { 22 | id 23 | foo 24 | childrenNames 25 | } 26 | } 27 | """, 28 | ) 29 | qs = Item.objects.filter(name="foo") 30 | items = gql_optimizer.query(qs, info) 31 | optimized_items = qs.prefetch_related( 32 | Prefetch( 33 | "children", 34 | queryset=Item.objects.only("id", "parent_id"), 35 | ), 36 | ) 37 | assert_query_equality(items, optimized_items) 38 | 39 | 40 | @pytest.mark.django_db 41 | def test_should_optimize_with_prefetch_related_as_a_string(): 42 | # parent = Item.objects.create(name='foo') 43 | # Item.objects.create(name='bar', parent=parent) 44 | # Item.objects.create(name='foobar', parent=parent) 45 | info = create_resolve_info( 46 | schema, 47 | """ 48 | query { 49 | items(name: "foo") { 50 | id 51 | foo 52 | auxChildrenNames 53 | } 54 | } 55 | """, 56 | ) 57 | qs = Item.objects.filter(name="foo") 58 | items = gql_optimizer.query(qs, info) 59 | optimized_items = qs.prefetch_related("children") 60 | assert_query_equality(items, optimized_items) 61 | 62 | 63 | @pytest.mark.django_db 64 | def test_should_optimize_with_prefetch_related_as_a_function(): 65 | # parent = Item.objects.create(name='foo') 66 | # Item.objects.create(name='bar', parent=parent) 67 | # Item.objects.create(name='foobar', parent=parent) 68 | info = create_resolve_info( 69 | schema, 70 | """ 71 | query { 72 | items(name: "foo") { 73 | id 74 | foo 75 | filteredChildren(name: "bar") { 76 | id 77 | foo 78 | } 79 | } 80 | } 81 | """, 82 | ) 83 | qs = Item.objects.filter(name="foo") 84 | items = gql_optimizer.query(qs, info) 85 | optimized_items = qs.prefetch_related( 86 | Prefetch( 87 | "children", 88 | queryset=Item.objects.filter(name="bar"), 89 | to_attr="gql_filtered_children_bar", 90 | ), 91 | ) 92 | assert_query_equality(items, optimized_items) 93 | 94 | 95 | QUERY_CONNECTION_NESTED_INPUT_OBJECT = """ 96 | query($filters: ItemFilterInput) { 97 | items(name: "foo") { 98 | id 99 | foo 100 | childrenCustomFiltered(filterInput: $filters) { 101 | edges { 102 | node { 103 | id 104 | value 105 | } 106 | } 107 | } 108 | } 109 | } 110 | """ 111 | 112 | 113 | @pytest.mark.parametrize( 114 | "variables, expected_gte", 115 | [ 116 | ({"filters": {"value": {"gte": 11}}}, 11), 117 | ({}, 0), 118 | ], 119 | ) 120 | @pytest.mark.django_db 121 | def test_should_optimize_with_prefetch_related_as_a_function_with_object_input( 122 | variables, expected_gte 123 | ): 124 | """This test attempt to provide a nested object as a variable and a null value 125 | as a filter. The objective is to ensure null and nested objects are properly 126 | resolved. 127 | """ 128 | 129 | query = QUERY_CONNECTION_NESTED_INPUT_OBJECT 130 | info = create_resolve_info(schema, query, variables=variables) 131 | 132 | optimized_items = Item.objects.prefetch_related( 133 | Prefetch( 134 | "children", 135 | queryset=Item.objects.only("id", "value").filter(value__gte=expected_gte), 136 | to_attr="gql_custom_filtered_children", 137 | ), 138 | ) 139 | 140 | items = gql_optimizer.query(Item.objects, info) 141 | assert_query_equality(items, optimized_items) 142 | 143 | 144 | @pytest.mark.django_db 145 | def test_should_return_valid_result_with_prefetch_related_as_a_function(): 146 | parent = Item.objects.create(id=1, name="foo") 147 | Item.objects.create(id=2, name="bar", parent=parent) 148 | Item.objects.create(id=3, name="foobar", parent=parent) 149 | result = schema.execute( 150 | """ 151 | query { 152 | items(name: "foo") { 153 | id 154 | foo 155 | filteredChildren(name: "bar") { 156 | id 157 | parentId 158 | foo 159 | } 160 | } 161 | } 162 | """ 163 | ) 164 | assert not result.errors 165 | assert result.data["items"][0]["filteredChildren"][0]["id"] == "SXRlbVR5cGU6Mg==" 166 | assert ( 167 | result.data["items"][0]["filteredChildren"][0]["parentId"] == "SXRlbVR5cGU6MQ==" 168 | ) 169 | 170 | 171 | @pytest.mark.django_db 172 | def test_should_return_valid_result_with_prefetch_related_as_a_function_using_variable(): 173 | parent = Item.objects.create(id=1, name="foo") 174 | Item.objects.create(id=2, name="bar", parent=parent) 175 | Item.objects.create(id=3, name="foobar", parent=parent) 176 | result = schema.execute( 177 | """ 178 | query Foo ($name: String!) { 179 | items(name: "foo") { 180 | id 181 | foo 182 | filteredChildren(name: $name) { 183 | id 184 | parentId 185 | foo 186 | } 187 | } 188 | } 189 | """, 190 | variables={"name": "bar"}, 191 | ) 192 | assert not result.errors 193 | assert result.data["items"][0]["filteredChildren"][0]["id"] == "SXRlbVR5cGU6Mg==" 194 | assert ( 195 | result.data["items"][0]["filteredChildren"][0]["parentId"] == "SXRlbVR5cGU6MQ==" 196 | ) 197 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from graphql_relay import to_global_id 3 | from mock import patch 4 | 5 | from .graphql_utils import create_resolve_info 6 | from .models import SomeOtherItem, Item 7 | from .schema import schema, SomeOtherItemType, DummyItemMutation 8 | 9 | 10 | @pytest.mark.django_db 11 | @patch("graphene_django_optimizer.types.query", return_value=SomeOtherItem.objects) 12 | def test_should_optimize_the_single_node(mocked_optimizer): 13 | SomeOtherItem.objects.create(pk=7, name="Hello") 14 | 15 | info = create_resolve_info( 16 | schema, 17 | """ 18 | query ItemDetails { 19 | someOtherItems(id: $id) { 20 | id 21 | foo 22 | parent { 23 | id 24 | } 25 | } 26 | } 27 | """, 28 | return_type=schema.graphql_schema.get_type("SomeOtherItemType"), 29 | ) 30 | 31 | result = SomeOtherItemType.get_node(info, 7) 32 | 33 | assert result, "Expected the item to be found and returned" 34 | assert result.pk == 7, "The item is not the correct one" 35 | 36 | mocked_optimizer.assert_called_once_with(SomeOtherItem.objects, info) 37 | 38 | 39 | @pytest.mark.django_db 40 | @patch("graphene_django_optimizer.types.query") 41 | def test_should_return_none_when_node_is_not_resolved(mocked_optimizer): 42 | SomeOtherItem.objects.create(id=7) 43 | 44 | info = create_resolve_info( 45 | schema, 46 | """ 47 | query { 48 | someOtherItems(id: $id) { 49 | id 50 | foo 51 | children { 52 | id 53 | foo 54 | } 55 | } 56 | } 57 | """, 58 | return_type=schema.graphql_schema.get_type("SomeOtherItemType"), 59 | ) 60 | 61 | qs = SomeOtherItem.objects 62 | mocked_optimizer.return_value = qs 63 | 64 | assert SomeOtherItemType.get_node(info, 8) is None 65 | mocked_optimizer.assert_called_once_with(SomeOtherItem.objects, info) 66 | 67 | 68 | @pytest.mark.django_db 69 | @patch("graphene_django_optimizer.types.query") 70 | def test_mutating_should_not_optimize(mocked_optimizer): 71 | Item.objects.create(id=7) 72 | 73 | info = create_resolve_info( 74 | schema, 75 | """ 76 | query { 77 | items(id: $id) { 78 | id 79 | foo 80 | children { 81 | id 82 | foo 83 | } 84 | } 85 | } 86 | """, 87 | return_type=schema.graphql_schema.get_type("SomeOtherItemType"), 88 | ) 89 | 90 | result = DummyItemMutation.mutate(info, to_global_id("ItemNode", 7)) 91 | assert result 92 | assert result.pk == 7 93 | assert mocked_optimizer.call_count == 0 94 | 95 | 96 | @pytest.mark.django_db 97 | @patch("graphene_django_optimizer.types.query", return_value=SomeOtherItem.objects) 98 | def test_should_optimize_the_queryset(mocked_optimizer): 99 | SomeOtherItem.objects.create(pk=7, name="Hello") 100 | 101 | info = create_resolve_info( 102 | schema, 103 | """ 104 | query ItemDetails { 105 | someOtherItems(id: $id) { 106 | id 107 | foo 108 | parent { 109 | id 110 | } 111 | } 112 | } 113 | """, 114 | return_type=schema.graphql_schema.get_type("SomeOtherItemType"), 115 | ) 116 | 117 | qs = SomeOtherItem.objects.filter(pk=7) 118 | result = SomeOtherItemType.get_queryset(qs, info).get() 119 | 120 | assert result, "Expected the item to be found and returned" 121 | assert result.pk == 7, "The item is not the correct one" 122 | 123 | mocked_optimizer.assert_called_once_with(qs, info) 124 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Prefetch 2 | 3 | 4 | def assert_query_equality(left_query, right_query): 5 | assert str(left_query.query) == str(right_query.query) 6 | assert len(left_query._prefetch_related_lookups) == len( 7 | right_query._prefetch_related_lookups 8 | ) 9 | for (i, lookup) in enumerate(left_query._prefetch_related_lookups): 10 | right_lookup = right_query._prefetch_related_lookups[i] 11 | if isinstance(lookup, Prefetch) and isinstance(right_lookup, Prefetch): 12 | assert_query_equality(lookup.queryset, right_lookup.queryset) 13 | elif isinstance(lookup, Prefetch): 14 | assert str(lookup.queryset.query) == right_lookup 15 | elif isinstance(right_lookup, Prefetch): 16 | assert lookup == str(right_lookup.queryset.query) 17 | else: 18 | assert lookup == right_lookup 19 | --------------------------------------------------------------------------------