From 9866dabaadb5f82e1398931bef9ddd6c4a44dc40 Mon Sep 17 00:00:00 2001 From: rafalp Date: Tue, 8 Oct 2024 22:23:11 +0200 Subject: [PATCH] Interfaces and unions --- .pylintrc | 2 +- README.md | 58 +++++++++- example/interfaces/__init__.py | 0 example/interfaces/search_result.py | 24 +++++ example/queries/__init__.py | 4 +- example/queries/models.py | 21 ++++ example/queries/search.py | 32 ++++++ example/schema.py | 2 + example/types/group.py | 8 +- example/types/post.py | 11 +- example/types/user.py | 8 +- example/unions/__init__.py | 0 example/unions/model.py | 32 ++++++ tests/test_query.py | 157 ++++++++++++++++++++++++++++ 14 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 example/interfaces/__init__.py create mode 100644 example/interfaces/search_result.py create mode 100644 example/queries/models.py create mode 100644 example/queries/search.py create mode 100644 example/unions/__init__.py create mode 100644 example/unions/model.py diff --git a/.pylintrc b/.pylintrc index 958b0ef..b76653e 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,2 +1,2 @@ [MESSAGES CONTROL] -disable=C0114, C0115, C0116, W0613, W0622 +disable=C0114, C0115, C0116, R0801, R0903, W0613, W0622 diff --git a/README.md b/README.md index 4f69354..0626bc6 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This API aims to use most features from GraphQL Modules v2. It can also be used Replacing `list` with `Iterable` may be enough to fix this. -## Resolvers need to be decorated with `@classmethod` or `@staticmethod` to keep linters happy. +## Resolvers need to be decorated with `@classmethod` or `@staticmethod` to keep linters happy Linters will scream that resolver method decorated with `@ObectType.resolver` is missing `self` first attribute. @@ -34,3 +34,59 @@ class ConcatMutation(GraphQLMutation): ``` This would be more intuitive way to define mutations than current approach of having `MutationType(GraphQLObject)` with multiple methods. + + +## Object type docs don't document interface usage for objects with schema + +According to interface docs, this is valid: + +```python +class PostType(GraphQLObject, SearchResultInterface): + __schema__ = gql( + """ + type Post { + id: ID! + content: String! + category: Category + poster: User + } + """ + ) +``` + +But it will fail to validate with following error: + +``` +ValueError: Class 'PostType' defines '__schema__' attribute with declaration for an invalid GraphQL type. ('ObjectTypeDefinitionNode' != 'InterfaceTypeDefinitionNode') +``` + +This points to `SearchResultInterface` validation logic overriding `GraphQLObject`. + +Docs also need to be updated to show example for `GraphQLObject` with interface. + +Interface's docs are also mentioning `subscription` in its parts which needs to be edited out. + +Interface's docs should also have example of usage with `GraphQLObject`, even if only as "See the `GraphQLObject` docs for usage example." link somewhere in it. + + +## Interfaces need to be explicitly passed to `make_executable_schema` + +Given GraphQL object definition: + +```python +class PostType(GraphQLObject, SearchResultInterface): + ... +``` + +`make_executable_schema` will fail with: + +``` +TypeError: Unknown type 'SearchResultInterface'. +``` + +`GraphQLObject`'s model creation could see if parent types include subclasses of `GraphQLInterface` and include them. + + +## `GraphQLUnion` docs should have example of union type implementing `resolve_type` + +We do this already for `GraphQLInterface`, it would help if `GraphQLUnion` also did it. diff --git a/example/interfaces/__init__.py b/example/interfaces/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example/interfaces/search_result.py b/example/interfaces/search_result.py new file mode 100644 index 0000000..baeaa77 --- /dev/null +++ b/example/interfaces/search_result.py @@ -0,0 +1,24 @@ +from typing import Any + +from ariadne_graphql_modules import GraphQLInterface + +from ..models.group import Group +from ..models.post import Post +from ..models.user import User + + +class SearchResultInterface(GraphQLInterface): + summary: str + + @staticmethod + def resolve_type(obj: Any, *_) -> str: + if isinstance(obj, Group): + return "Group" + + if isinstance(obj, Post): + return "Post" + + if isinstance(obj, User): + return "User" + + raise TypeError(f"Unsupported type: {type(obj)}") diff --git a/example/queries/__init__.py b/example/queries/__init__.py index 1366e10..ac88058 100644 --- a/example/queries/__init__.py +++ b/example/queries/__init__.py @@ -1,12 +1,14 @@ from typing import Any -from . import calendar, categories, groups, hello, posts, users +from . import calendar, categories, groups, hello, models, posts, search, users queries: Any = [ calendar.Query, categories.Query, groups.Query, hello.Query, + models.Query, posts.Query, + search.Query, users.Query, ] diff --git a/example/queries/models.py b/example/queries/models.py new file mode 100644 index 0000000..d076201 --- /dev/null +++ b/example/queries/models.py @@ -0,0 +1,21 @@ +from typing import Any + +from ariadne_graphql_modules import GraphQLObject +from graphql import GraphQLResolveInfo + +from ..database import db +from ..unions.model import Model + + +class Query(GraphQLObject): + @GraphQLObject.field(graphql_type=list[Model]) + @staticmethod + async def models(obj, info: GraphQLResolveInfo) -> list[Any]: + results: list = [] + + results += await db.get_all("categories") + results += await db.get_all("groups") + results += await db.get_all("posts") + results += await db.get_all("users") + + return results diff --git a/example/queries/search.py b/example/queries/search.py new file mode 100644 index 0000000..747f984 --- /dev/null +++ b/example/queries/search.py @@ -0,0 +1,32 @@ +from typing import Any + +from ariadne_graphql_modules import GraphQLObject +from graphql import GraphQLResolveInfo + +from ..database import db +from ..interfaces.search_result import SearchResultInterface + + +class Query(GraphQLObject): + @GraphQLObject.field(graphql_type=list[SearchResultInterface]) + @staticmethod + async def search(obj, info: GraphQLResolveInfo, *, query: str) -> list[Any]: + query = query.strip() + if not query: + return [] + + results: list = [] + + for group in await db.get_all("groups"): + if query in group.name.lower(): + results.append(group) + + for post in await db.get_all("posts"): + if query in post.message.lower(): + results.append(post) + + for user in await db.get_all("users"): + if query in user.username.lower(): + results.append(user) + + return results diff --git a/example/schema.py b/example/schema.py index 9f2b8c6..6e770bb 100644 --- a/example/schema.py +++ b/example/schema.py @@ -1,5 +1,6 @@ from ariadne_graphql_modules import make_executable_schema +from .interfaces.search_result import SearchResultInterface from .mutations import mutations from .queries import queries from .subscriptions import subscriptions @@ -9,5 +10,6 @@ queries, mutations, subscriptions, + SearchResultInterface, convert_names_case=True, ) diff --git a/example/types/group.py b/example/types/group.py index 25f6e03..4b8ca7b 100644 --- a/example/types/group.py +++ b/example/types/group.py @@ -4,13 +4,14 @@ from graphql import GraphQLResolveInfo from ..database import db +from ..interfaces.search_result import SearchResultInterface from ..models.group import Group if TYPE_CHECKING: from .user import UserType -class GroupType(GraphQLObject): +class GroupType(GraphQLObject, SearchResultInterface): id: GraphQLID name: str is_admin: bool @@ -19,3 +20,8 @@ class GroupType(GraphQLObject): @staticmethod async def users(obj: Group, info: GraphQLResolveInfo): return await db.get_all("users", group_id=obj.id) + + @GraphQLObject.resolver("summary") + @staticmethod + async def resolve_summary(obj: Group, info: GraphQLResolveInfo): + return f"#{obj.id}: {obj.name}" diff --git a/example/types/post.py b/example/types/post.py index 52ef02a..9292ee3 100644 --- a/example/types/post.py +++ b/example/types/post.py @@ -5,10 +5,10 @@ from graphql import GraphQLResolveInfo from ..database import db +from ..interfaces.search_result import SearchResultInterface from ..models.category import Category from ..models.post import Post from ..models.user import User - from .category import CategoryType if TYPE_CHECKING: @@ -18,15 +18,17 @@ class PostType(GraphQLObject): __schema__ = gql( """ - type Post { + type Post implements SearchResultInterface { id: ID! content: String! category: Category poster: User + summary: String! } """ ) __aliases__ = {"content": "message"} + __requires__ = [SearchResultInterface] @GraphQLObject.resolver("category", CategoryType) @staticmethod @@ -42,3 +44,8 @@ async def resolve_poster(obj: Post, info: GraphQLResolveInfo) -> User | None: return None return await db.get_row("users", id=obj.poster_id) + + @GraphQLObject.resolver("summary") + @staticmethod + async def resolve_summary(obj: Post, info: GraphQLResolveInfo): + return f"#{obj.id}: {obj.message}" diff --git a/example/types/user.py b/example/types/user.py index 10b5db2..30e7ea3 100644 --- a/example/types/user.py +++ b/example/types/user.py @@ -4,6 +4,7 @@ from graphql import GraphQLResolveInfo from ..database import db +from ..interfaces.search_result import SearchResultInterface from ..enums.role import RoleEnum from ..models.group import Group from ..models.post import Post @@ -14,7 +15,7 @@ from .group import GroupType -class UserType(GraphQLObject): +class UserType(GraphQLObject, SearchResultInterface): id: GraphQLID username: str group: Annotated["GroupType", deferred(".group")] @@ -30,3 +31,8 @@ async def resolve_group(user: User, info: GraphQLResolveInfo) -> Group: @staticmethod async def resolve_posts(user: User, info: GraphQLResolveInfo) -> list[Post]: return await db.get_all("posts", poster_id=user.id) + + @GraphQLObject.resolver("summary") + @staticmethod + async def resolve_summary(obj: User, info: GraphQLResolveInfo): + return f"#{obj.id}: {obj.username}" diff --git a/example/unions/__init__.py b/example/unions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example/unions/model.py b/example/unions/model.py new file mode 100644 index 0000000..ebca6af --- /dev/null +++ b/example/unions/model.py @@ -0,0 +1,32 @@ +from typing import Any + +from ariadne_graphql_modules import GraphQLUnion + +from ..models.category import Category +from ..models.group import Group +from ..models.post import Post +from ..models.user import User +from ..types.category import CategoryType +from ..types.group import GroupType +from ..types.post import PostType +from ..types.user import UserType + + +class Model(GraphQLUnion): + __types__ = (CategoryType, GroupType, PostType, UserType) + + @staticmethod + def resolve_type(obj: Any, *_) -> str: + if isinstance(obj, Category): + return "Category" + + if isinstance(obj, Group): + return "Group" + + if isinstance(obj, Post): + return "Post" + + if isinstance(obj, User): + return "User" + + raise TypeError(f"Unsupported type: {type(obj)}") diff --git a/tests/test_query.py b/tests/test_query.py index e28c2be..4cb6fb1 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -192,3 +192,160 @@ async def test_query_category_field(exec_query): ], }, } + + +@pytest.mark.asyncio +async def test_query_search(exec_query): + result = await exec_query( + """ + query Search { + search(query: "a") { + __typename + summary + ... on User { + id + username + } + ... on Group { + id + name + } + ... on Post { + id + content + } + } + } + """ + ) + assert result.data == { + "search": [ + { + "__typename": "Group", + "summary": "#1: Admins", + "id": "1", + "name": "Admins", + }, + { + "__typename": "Post", + "summary": "#3: Sit amet", + "id": "3", + "content": "Sit amet", + }, + { + "__typename": "User", + "summary": "#2: Alice", + "id": "2", + "username": "Alice", + }, + { + "__typename": "User", + "summary": "#4: Mia", + "id": "4", + "username": "Mia", + }, + ], + } + + +@pytest.mark.asyncio +async def test_query_models(exec_query): + result = await exec_query( + """ + query Models { + models { + __typename + ... on Category { + id + name + } + ... on User { + id + username + } + ... on Group { + id + name + } + ... on Post { + id + content + } + } + } + """ + ) + assert result.data == { + "models": [ + { + "__typename": "Category", + "id": "1", + "name": "First category", + }, + { + "__typename": "Category", + "id": "2", + "name": "Second category", + }, + { + "__typename": "Category", + "id": "3", + "name": "Child category", + }, + { + "__typename": "Category", + "id": "4", + "name": "Other child category", + }, + { + "__typename": "Group", + "id": "1", + "name": "Admins", + }, + { + "__typename": "Group", + "id": "2", + "name": "Members", + }, + { + "__typename": "Post", + "id": "1", + "content": "Lorem ipsum", + }, + { + "__typename": "Post", + "id": "2", + "content": "Dolor met", + }, + { + "__typename": "Post", + "id": "3", + "content": "Sit amet", + }, + { + "__typename": "Post", + "id": "4", + "content": "Elit", + }, + { + "__typename": "User", + "id": "1", + "username": "JohnDoe", + }, + { + "__typename": "User", + "id": "2", + "username": "Alice", + }, + { + "__typename": "User", + "id": "3", + "username": "Bob", + }, + { + "__typename": "User", + "id": "4", + "username": "Mia", + }, + ], + }