Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Develop/exclude excludeds #329

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 65 additions & 29 deletions grapple/actions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import inspect
from collections.abc import Iterable
from types import MethodType
from typing import Any, Dict, Type, Union
from typing import Any, Callable, Dict, Tuple, Type, Union

import graphene
from django.apps import apps
Expand All @@ -19,13 +19,15 @@
from wagtail.snippets.models import get_snippet_models

from .helpers import field_middlewares, streamfield_types
from .models import DefaultField, GraphQLField
from .registry import registry
from .settings import grapple_settings
from .types.documents import DocumentObjectType
from .types.images import ImageObjectType
from .types.pages import Page, PageInterface
from .types.rich_text import RichText as RichTextType
from .types.streamfield import generate_streamfield_union
from .utils import resolve_not_exposed_exception

if apps.is_installed("wagtailmedia"):
from wagtailmedia.models import AbstractMedia
Expand Down Expand Up @@ -154,7 +156,20 @@ def get_fields_and_properties(cls):
return fields + properties


def get_field_type(field):
ComplicatedField = Any # QuerySetList, TagList, ...


def get_field_type(
field: Union[
GraphQLField,
Tuple[GraphQLField, ComplicatedField],
Callable[[], GraphQLField],
Tuple[Callable[[], GraphQLField], ComplicatedField],
Tuple[Callable[[], Tuple[GraphQLField, ComplicatedField]]],
]
) -> Tuple[GraphQLField, Any]:
if callable(field):
field = field()
# If a tuple is returned then obj[1] wraps obj[0]
field_wrapper = None
if isinstance(field, tuple):
Expand Down Expand Up @@ -297,42 +312,66 @@ class Meta:
type_meta = {"Meta": Meta, "id": graphene.ID(), "name": type_name}

exclude_fields = []
exclude_meta_fields = []
graphql_fields = getattr(cls, "graphql_fields", [])
default_graphql_fields = [
i.field_name for i in graphql_fields if isinstance(i, DefaultField)
]
custom_graphql_fields = [
get_field_type(i)
for i in graphql_fields
if not isinstance(i, DefaultField)
]
base_type_for_exclusion_checks = (
base_type if not issubclass(cls, WagtailPage) else WagtailPage
)
for field in get_fields_and_properties(cls):
# Filter out any fields that are defined on the interface of base type to prevent the
# 'Excluding the custom field "<field>" on DjangoObjectType "<cls>" has no effect.
# Either remove the custom field or remove the field from the "exclude" list.' warning
if (
if field in default_graphql_fields and not (
hasattr(interface, field)
or hasattr(base_type_for_exclusion_checks, field)
):
raise TypeError(
f"{field} is not part of grapple default implementation"
)

if not (
field == "id"
or hasattr(interface, field)
or hasattr(base_type_for_exclusion_checks, field)
):
continue

exclude_fields.append(field)
# see #105
# Filter out any fields that are defined on the interface of base type to prevent the
# 'Excluding the custom field "<field>" on DjangoObjectType "<cls>" has no effect.
# Either remove the custom field or remove the field from the "exclude" list.' warning
exclude_meta_fields.append(field)
else:
if (
default_graphql_fields
and field not in default_graphql_fields
):
# Filter out any fields that are not acquired by the user
exclude_fields.append(field)

# Add any custom fields to node if they are defined.
methods = {}
if hasattr(cls, "graphql_fields"):
for field in cls.graphql_fields:
if callable(field):
field = field()

# Add field to GQL type with correct field-type
field, field_type = get_field_type(field)
type_meta[field.field_name] = field_type

# Remove field from excluded list
if field.field_name in exclude_fields:
exclude_fields.remove(field.field_name)

# Add a custom resolver for each field
methods["resolve_" + field.field_name] = model_resolver(field)

for field, field_type in custom_graphql_fields:
# Add field to GQL type with correct field-type
type_meta[field.field_name] = field_type

# Remove field from excluded lists
if field.field_name in exclude_fields:
exclude_fields.remove(field.field_name)
if field.field_name in exclude_meta_fields:
exclude_meta_fields.remove(field.field_name)

# Add a custom resolver for each field
methods["resolve_" + field.field_name] = model_resolver(field)
for i in exclude_fields:
# because of #105 we can't remove them from the schema, so just raise error in case of access
methods["resolve_" + i] = resolve_not_exposed_exception

type_meta["Meta"].exclude_fields = exclude_meta_fields
# Replace stud node with real thing
type_meta["Meta"].exclude_fields = exclude_fields
node = type(type_name, (base_type,), type_meta)

# Add custom resolvers for fields
Expand Down Expand Up @@ -460,9 +499,6 @@ class Meta:
# Add any custom fields to node if they are defined.
if hasattr(cls, "graphql_fields"):
for item in cls.graphql_fields:
if callable(item):
item = item()

# Get correct types from field
field, field_type = get_field_type(item)

Expand Down
7 changes: 7 additions & 0 deletions grapple/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
from .registry import registry


class DefaultField:
field_name: str

def __init__(self, field_name: str):
self.field_name = field_name


# Classes used to define what the Django field should look like in the GQL type
class GraphQLField:
field_name: str
Expand Down
11 changes: 11 additions & 0 deletions grapple/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import graphql
from django.conf import settings
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db import connection
from graphql import GraphQLError
from wagtail.models import Site
from wagtail.search.index import class_is_indexed
from wagtail.search.models import Query
Expand Down Expand Up @@ -216,3 +218,12 @@ def get_media_item_url(cls):
if url[0] == "/":
return settings.BASE_URL + url
return url


def resolve_not_exposed_exception(cls, instance, info, **kwargs):
if info.return_type.of_type == graphql.GraphQLInt:
return 0
if info.return_type.of_type == graphql.GraphQLString:
return "this field is not exposed"
# Add other types here
raise GraphQLError("this field is not exposed")
48 changes: 47 additions & 1 deletion tests/test_grapple.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from django.db import connection
from django.test import RequestFactory, TestCase, override_settings
from graphene.test import Client
from testapp.factories import BlogPageFactory
from testapp.factories import AuthorPageFactory, BlogPageFactory
from testapp.models import GlobalSocialMediaSettings, HomePage, SocialMediaSettings
from wagtail.documents import get_document_model
from wagtail.images import get_image_model
Expand Down Expand Up @@ -1684,3 +1684,49 @@ def test_query_all_settings_with_site_filter(self):
}
},
)


class ExcludeFieldTest(BaseGrappleTest):
def setUp(self):
super().setUp()
self.factory = RequestFactory()
self.author = AuthorPageFactory(parent=self.home, nickname="the_nickname")

def test_returned_value_of_excluded_field(self):
query = """
{
pages(contentType: "testapp.AuthorPage") {
id
...on AuthorPage{
contentType
numchild
}
}
}
"""

executed = self.client.execute(query)
self.assertEqual(
executed["data"]["pages"][0]["contentType"], "this field is not exposed"
)
self.assertEqual(executed["data"]["pages"][0]["numchild"], 0)

def test_returned_value_of_meta_excluded_field(self):
query = """
{
pages(contentType: "testapp.AuthorPage") {
id
...on AuthorPage{
nickname
}
}
}
"""

executed = self.client.execute(query)
for e in executed["errors"]:
if "Cannot query field" in e["message"] and "nickname" in e["message"]:
"an assertion that indicates that the test is passed"
self.assertTrue(True)
return
self.fail("Expected error message not found in executed errors")
17 changes: 17 additions & 0 deletions tests/testapp/migrations/0003_authorpage_nickname.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.1.9 on 2023-05-15 11:39

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("testapp", "0002_create_homepage"),
]

operations = [
migrations.AddField(
model_name="authorpage",
name="nickname",
field=models.CharField(blank=True, max_length=255),
),
]
9 changes: 8 additions & 1 deletion tests/testapp/models/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from grapple.middleware import IsAnonymousMiddleware
from grapple.models import (
DefaultField,
GraphQLCollection,
GraphQLDocument,
GraphQLField,
Expand Down Expand Up @@ -76,10 +77,16 @@ class HomePage(Page):

class AuthorPage(Page):
name = models.CharField(max_length=255)
nickname = models.CharField(max_length=255, blank=True)

content_panels = Page.content_panels + [FieldPanel("name")]

graphql_fields = [GraphQLString("name")]
graphql_fields = [
DefaultField("id"),
DefaultField("title"),
DefaultField("slug"),
GraphQLString("name"),
]


class BlogPageTag(TaggedItemBase):
Expand Down