diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml new file mode 100644 index 0000000..d7623f4 --- /dev/null +++ b/.github/workflows/deploy_docs.yml @@ -0,0 +1,27 @@ +name: Deploy documentation + +on: + push: + branches: + - main + +jobs: + docs-publish: + name: publish documentation + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: pyproject.toml + + - name: Install Hatch + uses: pypa/hatch@257e27e51a6a5616ed08a39a408a21c35c9931bc + + - name: Build documentation + run: hatch run mkdocs gh-deploy --force diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 22ffa67..b5a6ad7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,10 +13,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - - name: Set up Python 3.11 + - name: Set up Python 3.12 uses: actions/setup-python@v4 with: - python-version: "3.11" + python-version: "3.12" - name: Install pypa/build run: >- python -m diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2fe4eb9..485dfbf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,28 +8,35 @@ on: jobs: build: - runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e .[test] - - name: Pytest - run: | - pytest - - name: Linters - run: | - pylint ariadne_graphql_modules tests - mypy ariadne_graphql_modules --ignore-missing-imports - black --check . + - uses: actions/checkout@master + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[test,lint] + + - name: Run Ruff + run: ruff check . + + - name: Run Black + run: black --check . + + - name: Run MyPy + run: mypy ariadne_graphql_modules --ignore-missing-imports + + - name: Run Pylint + run: pylint ariadne_graphql_modules tests + + - name: Run Pytest + run: pytest diff --git a/.pylintrc b/.pylintrc index 02b186f..cd1fe1b 100644 --- a/.pylintrc +++ b/.pylintrc @@ -3,7 +3,7 @@ ignore=snapshots load-plugins=pylint.extensions.bad_builtin, pylint.extensions.mccabe [MESSAGES CONTROL] -disable=C0103, C0111, C0209, C0412, I0011, R0101, R0801, R0901, R0902, R0903, R0912, R0913, R0914, R0915, R1260, W0231, W0621, W0703 +disable=C0103, C0111, C0209, C0412, I0011, R0101, R0801, R0901, R0902, R0903, R0911, R0912, R0913, R0914, R0915, R1260, W0231, W0621, W0703 [SIMILARITIES] ignore-imports=yes diff --git a/MOVING.md b/MOVING.md deleted file mode 100644 index 2480191..0000000 --- a/MOVING.md +++ /dev/null @@ -1,179 +0,0 @@ -Moving guide -============ - -`make_executable_schema` provided by Ariadne GraphQL Modules supports combining old and new approaches for schema definition. This allows developers using either to make a switch. - - -## Updating `make_executable_schema` - -To be able to mix old and new approaches in your implementation, replace `make_executable_schema` imported from `ariadne` with one from `ariadne_graphql_modules`: - -Old code: - -```python -from ariadne import load_schema_from_path, make_executable_schema - -type_defs = load_schema_from_path("schema.graphql") - -schema = make_executable_schema(type_defs, type_a, type_b, type_c) -``` - -New code: - -```python -from ariadne import load_schema_from_path -from ariadne_graphql_modules import make_executable_schema - -type_defs = load_schema_from_path("schema.graphql") - -schema = make_executable_schema( - type_defs, type_a, type_b, type_c, -) -``` - -If you are passing `type_defs` or types as lists (behavior supported by `ariadne.make_executable_schema`), you'll need to unpack them before passing: - -```python -from ariadne import load_schema_from_path -from ariadne_graphql_modules import make_executable_schema - -type_defs = load_schema_from_path("schema.graphql") - -schema = make_executable_schema( - type_defs, type_a, *user_types, -) -``` - - -If you are using directives, `directives` option is named `extra_directives` in new function: - -```python -from ariadne import load_schema_from_path -from ariadne_graphql_modules import make_executable_schema - -type_defs = load_schema_from_path("schema.graphql") - -schema = make_executable_schema( - type_defs, type_a, type_b, type_c, - extra_directives={"date": MyDateDirective}, -) -``` - -Your resulting schema will remain the same. But you can now pass new types defined using modular approach to add or replace existing ones. - - -## Using old types in requirements - -Use `DeferredType` to satisfy requirement on types defined old way: - -```python -from ariadne_graphql_modules import DeferredType, ObjectType, gql - - -class UserType(ObjectType): - __schema__ = gql( - """ - type User { - id: ID! - name: String! - group: UserGroup! - } - """ - ) - __requires__ = [DeferredType("UserGroup")] -``` - - -## Old types depending on new ones - -In case when old type depends on new one, you only need to make new type known to `make_executable_schema`. This can be done by passing it directly to types list or through requires of other type. - -```python -from ariadne import QueryType, gql -from ariadne_graphql_modules import ObjectType, make_executable_schema - -type_defs = gql( - """ - type Query { - user: User! - } - """ -) - -query_type = QueryType() - -@query_type.field("user") -def resolve_user(*_): - return { - "id": 1, - "name": "Alice", - } - - -class UserType(ObjectType): - __schema__ = gql( - """ - type User { - id: ID! - name: String! - } - """ - ) - - -schema = make_executable_schema( - UserType, type_defs, query_type -) -``` - - -## Combining roots - -If `combine_roots` option of `make_executable_schema` is enabled (default), `Query`, `Mutation` and `Subscription` types defined both old and new way will be combined in final schema: - -```python -from ariadne import QueryType, gql -from ariadne_graphql_modules import ObjectType, make_executable_schema - -type_defs = gql( - """ - type Query { - random: Int! - } - """ -) - -query_type = QueryType() - -@query_type.field("random") -def resolve_random(*_): - return 6 - - -class NewQueryType(ObjectType): - __schema__ = gql( - """ - type Query { - year: Int! - } - """ - ) - - @staticmethod - def resolve_year(*_): - return 2022 - - -schema = make_executable_schema( - NewQueryType, type_defs, query_type -) -``` - -Final `Query` type: - -```graphql -type Query { - random: Int! - year: Int! -} -``` \ No newline at end of file diff --git a/README.md b/README.md index bd2c3a3..c88a1a7 100644 --- a/README.md +++ b/README.md @@ -2,277 +2,49 @@ [![Build Status](https://github.com/mirumee/ariadne-graphql-modules/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/mirumee/ariadne-graphql-modules/actions) -- - - - - +## ⚠️ Important Migration Warning: Version 1.0.0 -# Ariadne GraphQL Modules - -Ariadne package for implementing Ariadne GraphQL schemas using modular approach. - -For reasoning behind this work, please see [this GitHub discussion](https://github.com/mirumee/ariadne/issues/306). - -See [API reference](./REFERENCE.md) file for documentation. - - -## Installation - -Ariadne GraphQL Modules can be installed using pip: - -```console -pip install ariadne-graphql-modules -``` - -Ariadne 0.22 or later is required for library to work. - - -## Examples - -### Basic example - -```python -from datetime import date - -from ariadne.asgi import GraphQL -from ariadne_graphql_modules import ObjectType, gql, make_executable_schema - - -class Query(ObjectType): - __schema__ = gql( - """ - type Query { - message: String! - year: Int! - } - """ - ) - - @staticmethod - def resolve_message(*_): - return "Hello world!" - - @staticmethod - def resolve_year(*_): - return date.today().year - - -schema = make_executable_schema(Query) -app = GraphQL(schema=schema, debug=True) -``` - - -### Dependency injection - -If `__schema__` string contains other type, its definition should be provided via `__requires__` attribute: - -```python -from typing import List, Optional - -from ariadne.asgi import GraphQL -from ariadne_graphql_modules import ObjectType, gql, make_executable_schema - -from my_app.users import User, get_user, get_last_users - - -class UserType(ObjectType): - __schema__ = gql( - """ - type User { - id: ID! - name: String! - email: String - } - """ - ) - - @staticmethod - def resolve_email(user: User, info): - if info.context["is_admin"]: - return user.email - - return None - - -class UsersQueries(ObjectType): - __schema__ = gql( - """ - type Query { - user(id: ID!): User - users: [User!]! - } - """ - ) - __requires__ = [UserType] - - @staticmethod - def resolve_user(*_, id: string) -> Optional[User]: - return get_user(id=id) - - @staticmethod - def resolve_users(*_, id: string) -> List[User]: - return get_last_users() - - -# UsersQueries already knows about `UserType` so it can be omitted -# in make_executable_schema arguments -schema = make_executable_schema(UsersQueries) -app = GraphQL(schema=schema, debug=True) -``` - - -#### Deferred dependencies - -Optionally dependencies can be declared as deferred so they can be provided directly to `make_executable_schema`: - -```python -from typing import List, Optional - -from ariadne.asgi import GraphQL -from ariadne_graphql_modules import DeferredType, ObjectType, gql, make_executable_schema - -from my_app.users import User, get_user, get_last_users - - -class UserType(ObjectType): - __schema__ = gql( - """ - type User { - id: ID! - name: String! - email: String - } - """ - ) - - @staticmethod - def resolve_email(user: User, info): - if info.context["is_admin"]: - return user.email - - return None - - -class UsersQueries(ObjectType): - __schema__ = gql( - """ - type Query { - user(id: ID!): User - users: [User!]! - } - """ - ) - __requires__ = [DeferredType("User")] - - @staticmethod - def resolve_user(*_, id: string) -> Optional[User]: - return get_user(id=id) - - @staticmethod - def resolve_users(*_, id: string) -> List[User]: - return get_last_users() - - -schema = make_executable_schema(UserType, UsersQueries) -app = GraphQL(schema=schema, debug=True) -``` +With the release of version 1.0.0, there have been significant changes to the `ariadne_graphql_modules` API. If you are upgrading from a previous version, **you will need to update your imports** to ensure your code continues to function correctly. +### What You Need to Do -### Automatic case convertion between `python_world` and `clientWorld` +To maintain compatibility with existing code, you must explicitly import types from the `v1` module of `ariadne_graphql_modules`. This is necessary for any code that relies on the legacy API from versions prior to 1.0.0. -#### Resolving fields values +### Example -Use `__aliases__ = convert_case` to automatically set aliases for fields that convert case +**Before upgrading:** ```python -from ariadne_graphql_modules import ObjectType, convert_case, gql - - -class UserType(ObjectType): - __schema__ = gql( - """ - type User { - id: ID! - fullName: String! - } - """ - ) - __aliases__ = convert_case +from ariadne_graphql_modules import ObjectType, EnumType ``` - -#### Converting fields arguments - -Use `__fields_args__ = convert_case` on type to automatically convert field arguments to python case in resolver kwargs: +**After upgrading to 1.0.0:** ```python -from ariadne_graphql_modules import MutationType, convert_case, gql - -from my_app import create_user - - -class UserRegisterMutation(MutationType): - __schema__ = gql( - """ - type Mutation { - registerUser(fullName: String!, email: String!): Boolean! - } - """ - ) - __fields_args__ = convert_case - - @staticmethod - async def resolve_mutation(*_, full_name: str, email: str): - user = await create_user( - full_name=full_name, - email=email, - ) - return bool(user) +from ariadne_graphql_modules.v1 import ObjectType, EnumType ``` +### Why This Change? -#### Converting inputs fields - -Use `__args__ = convert_case` on type to automatically convert input fields to python case in resolver kwargs: - -```python -from ariadne_graphql_modules import InputType, MutationType, convert_case, gql - -from my_app import create_user +The introduction of version 1.0.0 brings a more robust and streamlined API, with better support for modular GraphQL schemas. To facilitate this, legacy types and functionality have been moved to the `v1` submodule, allowing new projects to take full advantage of the updated architecture while providing a clear path for migrating existing codebases. +# Ariadne GraphQL Modules -class UserRegisterInput(InputType): - __schema__ = gql( - """ - input UserRegisterInput { - fullName: String! - email: String! - } - """ - ) - __args__ = convert_case +**Ariadne GraphQL Modules** is an extension for the [Ariadne](https://ariadnegraphql.org/) framework, designed to help developers structure and manage GraphQL schemas in a modular way. This library provides an organized approach to building GraphQL APIs by dividing your schema into self-contained, reusable modules, each responsible for its own part of the schema. +## Installation -class UserRegisterMutation(MutationType): - __schema__ = gql( - """ - type Mutation { - registerUser(input: UserRegisterInput!): Boolean! - } - """ - ) - __requires__ = [UserRegisterInput] +Ariadne GraphQL Modules can be installed using pip: - @staticmethod - async def resolve_mutation(*_, input: dict): - user = await create_user( - full_name=input["full_name"], - email=input["email"], - ) - return bool(user) +```bash +pip install ariadne-graphql-modules ``` +Ariadne 0.23 or later is required for the library to work. -### Roots merging +## Basic Usage -`Query`, `Mutation` and `Subscription` types are automatically merged into one by `make_executable_schema`: +Here is a basic example of how to use Ariadne GraphQL Modules to create a simple GraphQL API: ```python from datetime import date @@ -281,25 +53,12 @@ from ariadne.asgi import GraphQL from ariadne_graphql_modules import ObjectType, gql, make_executable_schema -class YearQuery(ObjectType): - __schema__ = gql( - """ - type Query { - year: Int! - } - """ - ) - - @staticmethod - def resolve_year(*_): - return date.today().year - - -class MessageQuery(ObjectType): +class Query(ObjectType): __schema__ = gql( """ type Query { message: String! + year: Int! } """ ) @@ -308,35 +67,14 @@ class MessageQuery(ObjectType): def resolve_message(*_): return "Hello world!" + @staticmethod + def resolve_year(*_): + return date.today().year -schema = make_executable_schema(YearQuery, MessageQuery) -app = GraphQL(schema=schema, debug=True) -``` - -Final schema will contain single `Query` type thats result of merged tupes: -```graphql -type Query { - message: String! - year: Int! -} +schema = make_executable_schema(Query) +app = GraphQL(schema=schema, debug=True) ``` -Fields on final type will be ordered alphabetically. - - -## Moving declarations from Ariadne - -Ariadne GraphQL Modules support combining old and new approaches to schema definition. - -See [moving guide](./MOVING.md) for examples and details. - - -## Contributing - -We are welcoming contributions to Ariadne GraphQL Modules! If you've found a bug or issue, feel free to use [GitHub issues](https://github.com/mirumee/ariadne/issues). If you have any questions or feedback, please let us know via [GitHub discussions](https://github.com/mirumee/ariadne/discussions/). - -Also make sure you follow [@AriadneGraphQL](https://twitter.com/AriadneGraphQL) on Twitter for latest updates, news and random musings! +In this example, a simple `Query` type is defined within a module. The `make_executable_schema` function is then used to combine the module into a complete schema, which can be used to create a GraphQL server. -**Crafted with ❤️ by [Mirumee Software](http://mirumee.com)** -hello@mirumee.com diff --git a/REFERENCE.md b/REFERENCE.md deleted file mode 100644 index e51ffc0..0000000 --- a/REFERENCE.md +++ /dev/null @@ -1,950 +0,0 @@ -# API Reference - -- [ObjectType](#ObjectType) -- [MutationType](#MutationType) -- [SubscriptionType](#SubscriptionType) -- [InputType](#InputType) -- [ScalarType](#ScalarType) -- [EnumType](#EnumType) -- [InterfaceType](#InterfaceType) -- [UnionType](#UnionType) -- [DirectiveType](#DirectiveType) -- [DeferredType](#DeferredType) -- [CollectionType](#CollectionType) -- [BaseType](#BaseType) -- [DefinitionType](#DefinitionType) -- [BindableType](#BindableType) -- [make_executable_schema](#make_executable_schema) -- [convert_case](#convert_case) - - -## `ObjectType` - -New `ObjectType` is base class for Python classes representing GraphQL types (either `type` or `extend type`). - - -### `__schema__` - -`ObjectType` key attribute is `__schema__` string that can define only one GraphQL type: - -```python -class QueryType(ObjectType): - __schema__ = """ - type Query { - year: Int! - } - """ -``` - -`ObjectType` implements validation logic for `__schema__`. It verifies that its valid SDL string defining exactly one GraphQL type. - - -### Resolvers - -Resolvers are class methods or static methods named after schema's fields: - -```python -class QueryType(ObjectType): - __schema__ = """ - type Query { - year: Int! - } - """ - - @staticmethod - def resolve_year(_, info: GraphQLResolveInfo) -> int: - return 2022 -``` - -If resolver function is not present for field, default resolver implemented by `graphql-core` will be used in its place. - -In situations when field's name should be resolved to different value, custom mappings can be defined via `__aliases__` attribute: - -```python -class UserType(ObjectType): - __schema__ = """ - type User { - id: ID! - dateJoined: String! - } - """ - __aliases__ = { - "dateJoined": "date_joined" - } -``` - -Above code will result in Ariadne generating resolver resolving `dateJoined` field to `date_joined` attribute on resolved object. - -If `date_joined` exists as `resolve_date_joined` callable on `ObjectType`, it will be used as resolver for `dateJoined`: - -```python -class UserType(ObjectType): - __schema__ = """ - type User { - id: ID! - dateJoined: String - } - """ - __aliases__ = { - "dateJoined": "date_joined" - } - - @staticmethod - def resolve_date_joined(user, info) -> Optional[str]: - if can_see_activity(info.context): - return user.date_joined - - return None -``` - - -### `__requires__` - -When GraphQL type requires on other GraphQL type (or scalar/directive etc. ect.) `ObjectType` will raise an error about missing dependency. This dependency can be provided through `__requires__` attribute: - -```python -class UserType(ObjectType): - __schema__ = """ - type User { - id: ID! - dateJoined: String! - } - """ - - -class UsersGroupType(ObjectType): - __schema__ = """ - type UsersGroup { - id: ID! - users: [User!]! - } - """ - __requires__ = [UserType] -``` - -`ObjectType` verifies that types specified in `__requires__` actually define required types. If `__schema__` in `UserType` is not defining `User`, error will be raised about missing dependency. - -In case of circular dependencies, special `DeferredType` can be used: - -```python -class UserType(ObjectType): - __schema__ = """ - type User { - id: ID! - dateJoined: String! - group: UsersGroup - } - """ - __requires__ = [DeferredType("UsersGroup")] - - -class UsersGroupType(ObjectType): - __schema__ = """ - type UsersGroup { - id: ID! - users: [User!]! - } - """ - __requires__ = [UserType] -``` - -`DeferredType` makes `UserType` happy about `UsersGroup` dependency, deferring dependency check to `make_executable_schema`. If "real" `UsersGroup` is not provided at that time, error will be raised about missing types required to create schema. - - -### `__fields_args__` - -Optional attribute that can be used to specify custom mappings between GraphQL args and Python kwargs: - -```python -from ariadne_graphql_modules import DeferredType, ObjectType, gql - -from my_app.models import Article - - -class SearchQuery(ObjectType): - __schema__ = gql( - """ - type Query { - search(query: String!, includeDrafts: Boolean): [Article!]! - } - """ - ) - __fields_args__ = { - "includeDrafts": "with_drafts", - } - __requires__ = [DeferredType("Article")] - - @staticmethod - async def resolve_search(*_, query: str, with_drafts: bool | None): - articles = Article.query.search(query) - if not with_drafts: - articles = articles.filter(is_draft=False) - return await articles.all() -``` - - -## `MutationType` - -Convenience type for defining single mutation: - -```python -from ariadne_graphql_modules import MutationType, gql - -from my_app import create_user - - -class UserRegisterMutation(MutationType): - __schema__ = gql( - """ - type Mutation { - registerUser(username: String!, email: String!): Boolean! - } - """ - ) - - @staticmethod - async def resolve_mutation(*_, username: str, email: str): - user = await create_user( - full_name=username, - email=email, - ) - return bool(user) -``` - -Recommended use for this type is to create custom base class for your GraphQL API: - -```python -from ariadne_graphql_modules import MutationType, gql - - -class BaseMutation(MutationType): - __abstract__ = True - - @classmethod - async def resolve_mutation(cls, _, *args, **kwargs): - try: - return await cls.perform_mutation(cls, *args, **kwargs) - except Exception as e: - return {"errors": e} - - @classmethod - def get_error_result(cls, error): - return {"errors": [e]} -``` - - -### `__args__` - -Optional attribute that can be used to specify custom mapping between GraphQL schema and Python: - -```python -from ariadne_graphql_modules import MutationType, gql - -from my_app import create_user - - -class UserRegisterMutation(MutationType): - __schema__ = gql( - """ - type Mutation { - registerUser( - userName: String!, - email: String!, - admin: Boolean, - ): Boolean! - } - """ - ) - __args__ = {"userName": "username", "admin": "is_admin"} - - @staticmethod - async def resolve_mutation(*_, username: str, email: str, is_admin: bool | None): - user = await create_user( - full_name=username, - email=email, - is_admin=bool(is_admin), - ) - return bool(user) -``` - - -## `SubscriptionType` - -Specialized subclass of `ObjectType` that defines GraphQL subscription: - -```python -class ChatSubscriptions(SubscriptionType): - __schema__ = """ - type Subscription { - chat: Chat - } - """ - __requires__ = [ChatType] - - @staticmethod - async def subscribe_chat(*_): - async for event in subscribe("chats"): - yield event["chat_id"] - - @staticmethod - async def resolve_chat(chat_id, *_): - # Optional - return await get_chat_from_db(chat_id) -``` - - -## `InputType` - -Defines GraphQL input: - -```python -class UserCreateInput(InputType): - __schema__ = """ - input UserInput { - name: String! - email: String! - fullName: String! - } - """ - __args__ = { - "fullName": "full_name", - } -``` - -### `__args__` - -Optional attribue `__args__` is a `Dict[str, str]` used to override key names for `dict` representing input's data. - -Following JSON: - -```json -{ - "name": "Alice", - "email:" "alice@example.com", - "fullName": "Alice Chains" -} -``` - -Will be represented as following dict: - -```python -{ - "name": "Alice", - "email": "alice@example.com", - "full_name": "Alice Chains", -} -``` - - -## `ScalarType` - -Allows you to define custom scalar in your GraphQL schema. - -```python -class DateScalar(ScalarType): - __schema__ = "scalar Datetime" - - @staticmethod - def serialize(value) -> str: - # Called by GraphQL to serialize Python value to - # JSON-serializable format - return value.strftime("%Y-%m-%d") - - @staticmethod - def parse_value(value) -> str: - # Called by GraphQL to parse JSON-serialized value to - # Python type - parsed_datetime = datetime.strptime(formatted_date, "%Y-%m-%d") - return parsed_datetime.date() -``` - -Note that those methods are only required if Python type is not JSON serializable, or you want to customize its serialization process. - -Additionally you may define third method called `parse_literal` that customizes value's deserialization from GraphQL query's AST, but this is only useful for complex types that represent objects: - -```python -from graphql import StringValueNode - - -class DateScalar(Scalar): - __schema__ = "scalar Datetime" - - @staticmethod - def def parse_literal(ast, variable_values: Optional[Dict[str, Any]] = None): - if not isinstance(ast, StringValueNode): - raise ValueError() - - parsed_datetime = datetime.strptime(ast.value, "%Y-%m-%d") - return parsed_datetime.date() -``` - -If you won't define `parse_literal`, GraphQL will use custom logic that will unpack value from AST and then call `parse_value` on it. - - -## `EnumType` - -Defines enum in GraphQL schema: - -```python -class UserRoleEnum(EnumType): - __schema__ = """ - enum UserRole { - USER - MOD - ADMIN - } - """ -``` - -`__enum__` attribute allows you to specify Python enum to represent GraphQL enum in your Python logic: - -```python -class UserRole(IntEnum): - USER = 0 - MOD = 1 - ADMIN = 1 - - -class UserRoleEnum(EnumType): - __schema__ = """ - enum UserRole { - USER - MOD - ADMIN - } - """ - __enum__ = UserRole -``` - -You can also make `__enum__` a dict to skip enum if you want: - -```python -class UserRoleEnum(EnumType): - __schema__ = """ - enum UserRole { - USER - MOD - ADMIN - } - """ - __enum__ = { - "USER": 0, - "MOD": 1, - "ADMIN": 2, - } -``` - - -## `InterfaceType` - -Defines interface in GraphQL schema: - -```python -class SearchResultInterface(InterfaceType): - __schema__ = """ - interface SearchResult { - summary: String! - score: Int! - } - """ - - @staticmethod - def resolve_type(obj, info): - # Returns string with name of GraphQL type representing Python type - # from your business logic - if isinstance(obj, UserModel): - return UserType.graphql_name - - if isinstance(obj, CommentModel): - return CommentType.graphql_name - - return None - - @staticmethod - def resolve_summary(obj, info): - # Optional default resolver for summary field, used by types implementing - # this interface when they don't implement their own -``` - - -## `UnionType` - -Defines GraphQL union: - -```python -class SearchResultUnion(UnionType): - __schema__ = "union SearchResult = User | Post | Thread" - __requires__ = [UserType, PostType, ThreadType] - - @staticmethod - def resolve_type(obj, info): - # Returns string with name of GraphQL type representing Python type - # from your business logic - if isinstance(obj, UserModel): - return UserType.graphql_name - - if isinstance(obj, PostModel): - return PostType.graphql_name - - if isinstance(obj, ThreadModel): - return ThreadType.graphql_name - - return None -``` - - -## `DirectiveType` - -Defines new GraphQL directive in your schema and specifies `SchemaDirectiveVisitor` for it: - - -```python -from ariadne import SchemaDirectiveVisitor -from graphql import default_field_resolver - - -class PrefixStringSchemaVisitor(SchemaDirectiveVisitor): - def visit_field_definition(self, field, object_type): - original_resolver = field.resolve or default_field_resolver - - def resolve_prefixed_value(obj, info, **kwargs): - result = original_resolver(obj, info, **kwargs) - if result: - return f"PREFIX: {result}" - return result - - field.resolve = resolve_prefixed_value - return field - - -class PrefixStringDirective(DirectiveType): - __schema__ = "directive @example on FIELD_DEFINITION" - __visitor__ = PrefixStringSchemaVisitor -``` - - -## `make_executable_schema` - -New `make_executable_schema` takes list of Ariadne's types and constructs executable schema from them, performing last-stage validation for types consistency: - -```python -class UserType(ObjectType): - __schema__ = """ - type User { - id: ID! - username: String! - } - """ - - -class QueryType(ObjectType): - __schema__ = """ - type Query { - user: User - } - """ - __requires__ = [UserType] - - @staticmethod - def user(*_): - return { - "id": 1, - "username": "Alice", - } - - -schema = make_executable_schema(QueryType) -``` - - -### Automatic merging of roots - -Passing multiple `Query`, `Mutation` or `Subscription` definitions to `make_executable_schema` by default results in schema defining single types containing sum of all fields defined on those types, ordered alphabetically by field name. - -```python -class UserQueriesType(ObjectType): - __schema__ = """ - type Query { - user(id: ID!): User - } - """ - ... - - -class ProductsQueriesType(ObjectType): - __schema__ = """ - type Query { - product(id: ID!): Product - } - """ - ... - -schema = make_executable_schema(UserQueriesType, ProductsQueriesType) -``` - -Above schema will have single `Query` type looking like this: - -```graphql -type Query { - product(id: ID!): Product - user(id: ID!): User -} -``` - -To opt out of this behavior use `merge_roots=False` option: - -```python -schema = make_executable_schema( - UserQueriesType, - ProductsQueriesType, - merge_roots=False, -) -``` - -## `DeferredType` - -`DeferredType` names required GraphQL type as provided at later time: - -- Via `make_executable_schema` call -- Or via other type's `__requires__` - -It's mostly used to define lazy relationships in reusable modules and to break circular relationships. - - -### Type with `DeferredType` and other type passed to `make_executable_schema`: - -```python -class QueryType(ObjectType): - __schema__ = """ - type Query { - id: ID! - users: [User!]! - } - """ - __requires__ = [DeferredType("User")] - - -class UserType(ObjectType): - __schema__ = """ - type User { - id: ID! - dateJoined: String! - } - """ - - -schema = make_excutable_schema(QueryType, UserType) -``` - - -### Type with `DeferredType` and other type passed as dependency of third type: - -```python -class UsersGroupType(ObjectType): - __schema__ = """ - type UsersGroup { - id: ID! - users: [User!]! - } - """ - __requires__ = [UserType] - - -class UserType(ObjectType): - __schema__ = """ - type User { - id: ID! - dateJoined: String! - } - """ - - -class QueryType(ObjectType): - __schema__ = """ - type Query { - id: ID! - users: [User!]! - groups: [UsersGroup!]! - } - """ - __requires__ = [DeferredType("User"), UsersGroupType] - - -schema = make_excutable_schema(QueryType) -``` - - -## `CollectionType` - -Collection is an utility type that gathers multiple types into single object: - -```python -class UserMutations(CollectionType): - __types__ = [ - BanUserMutation, - UnbanUserMutation, - CreateUserMutation, - UpdateUserMutation, - DeleteUserMutation, - ] - - -schema = make_excutable_schema(UserMutations) -``` - - -## `BaseType` - -Base type that all other types extend. You can use it to create custom types: - -```python -from typing import Dict - -from ariadne_graphql_modules import BaseType -from django.db.models import Model -from graphql import GraphQLFieldResolver - -class MyType(BaseType) - __abstract__ = True - - @classmethod - def __get_types__(cls) -> List[Type["BaseType"]]: - # Called by make_executable_schema to get list of types - # to build GraphQL schema from. - return [] -``` - - -## `DefinitionType` - -Base for types that define `__schema__`: - -```python -class MyType(DefinitionType) - __abstract__: bool = True - __schema__: str - __requires__: List[Union[Type["DefinitionType"], DeferredType]] = [] - - graphql_name: str - graphql_type: Type[DefinitionNode] -``` - -Extends `BaseType`. - - -## `BindableType` - -Base for types that define `__bind_to_schema__` class method: - -```python -class MyType(BindableType) - __abstract__: bool = True - __schema__: str - __requires__: List[Union[Type["DefinitionType"], DeferredType]] = [] - - graphql_name: str - graphql_type: Type[DefinitionNode] - - @classmethod - def __bind_to_schema__(cls, schema: GraphQLSchema): - pass # Bind python logic to GraphQL schema here -``` - -Extends `DefinitionType`. - - -## `make_executable_schema` - -```python -def make_executable_schema( - *args: Union[Type[BaseType], SchemaBindable, str], - merge_roots: bool = True, - extra_sdl: Optional[Union[str, Sequence[str]]] = None, - extra_bindables: Optional[Sequence[SchemaBindable]] = None, - extra_directives: Optional[Dict[str, Type[SchemaDirectiveVisitor]]] = None, -) -> GraphQLSchema: - ... -``` - -Utility function that takes args with types definitions and creates executable schema. - - -### `merge_roots: bool = True` - -If set to true (default), `make_executable_schema` will automatically merge multiple `Query`, `Mutation` and `Subscription` types instead of raising error. - -Final merged types fields will be ordered alphabetically: - -```python -class YearType(ObjectType): - __schema__ = """ - type Query { - year: Int! - } - """ - - @staticmethod - def resolve_year(*_): - return 2022 - - -class MonthType(ObjectType): - __schema__ = """ - type Query { - month: Int! - } - """ - - @staticmethod - def resolve_month(*_): - return 10 - - -schema = make_executable_schema(YearType, MonthType) - -assert print_schema(schema) == """ -type Query { - month: Int! - year: Int! -} -""" -``` - -When `merge_roots=False` is explicitly set, `make_executable_schema` will raise an GraphQL error that `type Query` is defined more than once. - - -### `extra_directives` - -Dict of Ariadne's directives names and implementation. Optional. - -Used when moving schema definition to Ariadne GraphQL Modules approach from existing schema definition. - -See [moving guide](./MOVING.md) for examples and details. - - -## `convert_case` - -Utility function that can be used to automatically setup case conversion rules for types. - - -#### Resolving fields values - -Use `__aliases__ = convert_case` to automatically set aliases for fields that convert case - -```python -from ariadne_graphql_modules import ObjectType, convert_case, gql - - -class UserType(ObjectType): - __schema__ = gql( - """ - type User { - id: ID! - fullName: String! - } - """ - ) - __aliases__ = convert_case -``` - - -#### Converting fields arguments - -Use `__fields_args__ = convert_case` on type to automatically convert field arguments to python case in resolver kwargs: - -```python -from ariadne_graphql_modules import DeferredType, ObjectType, convert_case, gql - -from my_app.models import Article - - -class SearchQuery(ObjectType): - __schema__ = gql( - """ - type Query { - search(query: String!, includeDrafts: Boolean): [Article!]! - } - """ - ) - __fields_args__ = convert_case - __requires__ = [DeferredType("Article")] - - @staticmethod - async def resolve_search(*_, query: str, include_drafts: bool | None): - articles = Article.query.search(query) - if not include_drafts: - articles = articles.filter(is_draft=False) - return await articles.all() -``` - - -#### Converting mutation arguments - -Use `__args__ = convert_case` on `MutationType` to automatically convert input fields to python case in resolver kwargs: - -```python -from ariadne_graphql_modules import MutationType, convert_case, gql - -from my_app import create_user - - -class UserRegisterMutation(MutationType): - __schema__ = gql( - """ - type Mutation { - registerUser(fullName: String!, email: String!): Boolean! - } - """ - ) - __args__ = convert_case - - @staticmethod - async def resolve_mutation(*_, full_name: str, email: str): - user = await create_user( - full_name=full_name, - email=email, - ) - return bool(user) -``` - - -#### Converting inputs fields - -Use `__args__ = convert_case` on `InputType` to automatically convert input fields to python case in resolver kwargs: - -```python -from ariadne_graphql_modules import InputType, MutationType, convert_case, gql - -from my_app import create_user - - -class UserRegisterInput(InputType): - __schema__ = gql( - """ - input UserRegisterInput { - fullName: String! - email: String! - } - """ - ) - __args__ = convert_case - - -class UserRegisterMutation(MutationType): - __schema__ = gql( - """ - type Mutation { - registerUser(input: UserRegisterInput!): Boolean! - } - """ - ) - __requires__ = [UserRegisterInput] - - @staticmethod - async def resolve_mutation(*_, input: dict): - user = await create_user( - full_name=input["full_name"], - email=input["email"], - ) - return bool(user) -``` \ No newline at end of file diff --git a/ariadne_graphql_modules/__init__.py b/ariadne_graphql_modules/__init__.py index 789effe..04cc176 100644 --- a/ariadne_graphql_modules/__init__.py +++ b/ariadne_graphql_modules/__init__.py @@ -1,38 +1,69 @@ -from ariadne import gql - -from .bases import BaseType, BindableType, DeferredType, DefinitionType -from .collection_type import CollectionType -from .convert_case import convert_case -from .directive_type import DirectiveType -from .enum_type import EnumType -from .executable_schema import make_executable_schema -from .input_type import InputType -from .interface_type import InterfaceType -from .mutation_type import MutationType -from .object_type import ObjectType -from .scalar_type import ScalarType -from .subscription_type import SubscriptionType -from .union_type import UnionType -from .utils import create_alias_resolver, parse_definition +from ariadne_graphql_modules.base import GraphQLMetadata, GraphQLType +from ariadne_graphql_modules.base_graphql_model import GraphQLModel +from ariadne_graphql_modules.convert_name import ( + convert_graphql_name_to_python, + convert_python_name_to_graphql, +) +from ariadne_graphql_modules.deferredtype import deferred +from ariadne_graphql_modules.description import get_description_node +from ariadne_graphql_modules.enum_type import ( + GraphQLEnum, + GraphQLEnumModel, + create_graphql_enum_model, + graphql_enum, +) +from ariadne_graphql_modules.executable_schema import make_executable_schema +from ariadne_graphql_modules.idtype import GraphQLID +from ariadne_graphql_modules.input_type import GraphQLInput, GraphQLInputModel +from ariadne_graphql_modules.interface_type import ( + GraphQLInterface, + GraphQLInterfaceModel, +) +from ariadne_graphql_modules.object_type import ( + GraphQLObject, + GraphQLObjectModel, + object_field, +) +from ariadne_graphql_modules.roots import ROOTS_NAMES, merge_root_nodes +from ariadne_graphql_modules.scalar_type import GraphQLScalar, GraphQLScalarModel +from ariadne_graphql_modules.sort import sort_schema_document +from ariadne_graphql_modules.subscription_type import ( + GraphQLSubscription, + GraphQLSubscriptionModel, +) +from ariadne_graphql_modules.union_type import GraphQLUnion, GraphQLUnionModel +from ariadne_graphql_modules.value import get_value_from_node, get_value_node __all__ = [ - "BaseType", - "BindableType", - "CollectionType", - "DeferredType", - "DefinitionType", - "DirectiveType", - "EnumType", - "InputType", - "InterfaceType", - "MutationType", - "ObjectType", - "ScalarType", - "SubscriptionType", - "UnionType", - "convert_case", - "create_alias_resolver", - "gql", + "GraphQLEnum", + "GraphQLEnumModel", + "GraphQLID", + "GraphQLInput", + "GraphQLInputModel", + "GraphQLInterface", + "GraphQLInterfaceModel", + "GraphQLSubscription", + "GraphQLSubscriptionModel", + "GraphQLMetadata", + "GraphQLModel", + "GraphQLObject", + "GraphQLObjectModel", + "GraphQLScalar", + "GraphQLScalarModel", + "GraphQLType", + "GraphQLUnion", + "GraphQLUnionModel", + "ROOTS_NAMES", + "convert_graphql_name_to_python", + "convert_python_name_to_graphql", + "create_graphql_enum_model", + "deferred", + "get_description_node", + "get_value_from_node", + "get_value_node", + "graphql_enum", "make_executable_schema", - "parse_definition", + "merge_root_nodes", + "object_field", + "sort_schema_document", ] diff --git a/ariadne_graphql_modules/base.py b/ariadne_graphql_modules/base.py new file mode 100644 index 0000000..9ef0145 --- /dev/null +++ b/ariadne_graphql_modules/base.py @@ -0,0 +1,101 @@ +from collections.abc import Iterable +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Optional, Union + +from ariadne_graphql_modules.base_graphql_model import GraphQLModel + + +class GraphQLType: + __graphql_name__: Optional[str] + __description__: Optional[str] + __abstract__: bool = True + + @classmethod + def __get_graphql_name__(cls) -> str: + name = getattr(cls, "__graphql_name__", None) + if name: + return name + + name_mappings = [ + ("GraphQLEnum", "Enum"), + ("GraphQLInput", "Input"), + ("GraphQLScalar", ""), + ("Scalar", ""), + ("GraphQL", ""), + ("Type", ""), + ("GraphQLType", ""), + ] + + name = cls.__name__ + for suffix, replacement in name_mappings: + if name.endswith(suffix): + return name[: -len(suffix)] + replacement + + return name + + @classmethod + def __get_graphql_model__(cls, metadata: "GraphQLMetadata") -> GraphQLModel: + raise NotImplementedError( + "Subclasses of 'GraphQLType' must define '__get_graphql_model__'" + ) + + @classmethod + def __get_graphql_types__( + cls, _: "GraphQLMetadata" + ) -> Iterable[Union[type["GraphQLType"], type[Enum]]]: + """Returns iterable with GraphQL types associated with this type""" + return [cls] + + +@dataclass(frozen=True) +class GraphQLMetadata: + data: dict[Union[type[GraphQLType], type[Enum]], Any] = field(default_factory=dict) + names: dict[Union[type[GraphQLType], type[Enum]], str] = field(default_factory=dict) + models: dict[Union[type[GraphQLType], type[Enum]], GraphQLModel] = field( + default_factory=dict + ) + + def get_data(self, graphql_type: Union[type[GraphQLType], type[Enum]]) -> Any: + try: + return self.data[graphql_type] + except KeyError as e: + raise KeyError(f"No data is set for '{graphql_type}'.") from e + + def set_data( + self, graphql_type: Union[type[GraphQLType], type[Enum]], data: Any + ) -> Any: + self.data[graphql_type] = data + return data + + def get_graphql_model( + self, graphql_type: Union[type[GraphQLType], type[Enum]] + ) -> GraphQLModel: + if graphql_type not in self.models: + if hasattr(graphql_type, "__get_graphql_model__"): + self.models[graphql_type] = graphql_type.__get_graphql_model__(self) + elif issubclass(graphql_type, Enum): + # pylint: disable=import-outside-toplevel + from ariadne_graphql_modules.enum_type.enum_model_utils import ( + create_graphql_enum_model, + ) + + self.models[graphql_type] = create_graphql_enum_model(graphql_type) + else: + raise ValueError(f"Can't retrieve GraphQL model for '{graphql_type}'.") + + return self.models[graphql_type] + + def set_graphql_name( + self, graphql_type: Union[type[GraphQLType], type[Enum]], name: str + ): + self.names[graphql_type] = name + + def get_graphql_name( + self, graphql_type: Union[type[GraphQLType], type[Enum]] + ) -> str: + if graphql_type not in self.names: + model = self.get_graphql_model(graphql_type) + self.set_graphql_name(graphql_type, model.name) + + return self.names[graphql_type] diff --git a/ariadne_graphql_modules/base_graphql_model.py b/ariadne_graphql_modules/base_graphql_model.py new file mode 100644 index 0000000..30ffa80 --- /dev/null +++ b/ariadne_graphql_modules/base_graphql_model.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + +from graphql import GraphQLSchema, TypeDefinitionNode + + +@dataclass(frozen=True) +class GraphQLModel: + name: str + ast: TypeDefinitionNode + ast_type: type[TypeDefinitionNode] + + def bind_to_schema(self, schema: GraphQLSchema): + pass diff --git a/ariadne_graphql_modules/base_object_type/__init__.py b/ariadne_graphql_modules/base_object_type/__init__.py new file mode 100644 index 0000000..8b58aea --- /dev/null +++ b/ariadne_graphql_modules/base_object_type/__init__.py @@ -0,0 +1,17 @@ +from ariadne_graphql_modules.base_object_type.graphql_field import ( + GraphQLFieldData, + GraphQLObjectData, +) +from ariadne_graphql_modules.base_object_type.graphql_type import GraphQLBaseObject +from ariadne_graphql_modules.base_object_type.validators import ( + validate_object_type_with_schema, + validate_object_type_without_schema, +) + +__all__ = [ + "GraphQLBaseObject", + "GraphQLObjectData", + "GraphQLFieldData", + "validate_object_type_with_schema", + "validate_object_type_without_schema", +] diff --git a/ariadne_graphql_modules/base_object_type/graphql_field.py b/ariadne_graphql_modules/base_object_type/graphql_field.py new file mode 100644 index 0000000..350ce8f --- /dev/null +++ b/ariadne_graphql_modules/base_object_type/graphql_field.py @@ -0,0 +1,168 @@ +from dataclasses import dataclass, field +from typing import Any, Optional + +from ariadne.types import Resolver, Subscriber +from graphql import FieldDefinitionNode, NamedTypeNode + + +@dataclass(frozen=True) +class GraphQLObjectFieldArg: + name: Optional[str] + out_name: Optional[str] + field_type: Optional[Any] + description: Optional[str] = None + default_value: Optional[Any] = None + + +@dataclass(frozen=True) +class GraphQLObjectData: + fields: dict[str, "GraphQLObjectField"] + interfaces: list[NamedTypeNode] + + +@dataclass +class GraphQLClassData: + type_aliases: dict[str, str] = field(default_factory=dict) + fields_ast: dict[str, FieldDefinitionNode] = field(default_factory=dict) + resolvers: dict[str, "Resolver"] = field(default_factory=dict) + aliases: dict[str, str] = field(default_factory=dict) + out_names: dict[str, dict[str, str]] = field(default_factory=dict) + + +@dataclass(frozen=True) +class GraphQLObjectResolver: + resolver: Resolver + field: str + description: Optional[str] = None + args: Optional[dict[str, GraphQLObjectFieldArg]] = None + field_type: Optional[Any] = None + + +@dataclass(frozen=True) +class GraphQLObjectSource: + subscriber: Subscriber + field: str + description: Optional[str] = None + args: Optional[dict[str, GraphQLObjectFieldArg]] = None + field_type: Optional[Any] = None + + +@dataclass +class GraphQLFieldData: + fields_types: dict[str, str] = field(default_factory=dict) + fields_names: dict[str, str] = field(default_factory=dict) + fields_descriptions: dict[str, str] = field(default_factory=dict) + fields_args: dict[str, dict[str, GraphQLObjectFieldArg]] = field( + default_factory=dict + ) + fields_resolvers: dict[str, Resolver] = field(default_factory=dict) + fields_subscribers: dict[str, Subscriber] = field(default_factory=dict) + fields_defaults: dict[str, Any] = field(default_factory=dict) + fields_order: list[str] = field(default_factory=list) + type_hints: dict[str, Any] = field(default_factory=dict) + aliases: dict[str, str] = field(default_factory=dict) + aliases_targets: list[str] = field(default_factory=list) + + +class GraphQLObjectField: + def __init__( + self, + *, + name: Optional[str] = None, + description: Optional[str] = None, + field_type: Optional[Any] = None, + args: Optional[dict[str, GraphQLObjectFieldArg]] = None, + resolver: Optional[Resolver] = None, + subscriber: Optional[Subscriber] = None, + default_value: Optional[Any] = None, + ): + self.name = name + self.description = description + self.field_type = field_type + self.args = args + self.resolver = resolver + self.subscriber = subscriber + self.default_value = default_value + + def __call__(self, resolver: Resolver): + """Makes GraphQLObjectField instances work as decorators.""" + self.resolver = resolver + if not self.field_type: + self.field_type = get_field_type_from_resolver(resolver) + return self + + +def object_field( + resolver: Optional[Resolver] = None, + *, + args: Optional[dict[str, GraphQLObjectFieldArg]] = None, + name: Optional[str] = None, + description: Optional[str] = None, + graphql_type: Optional[Any] = None, + default_value: Optional[Any] = None, +) -> GraphQLObjectField: + if isinstance(resolver, staticmethod): + resolver = resolver.__func__ + field_type: Any = graphql_type + if not graphql_type and resolver: + field_type = get_field_type_from_resolver(resolver) + return GraphQLObjectField( + name=name, + description=description, + field_type=field_type, + args=args, + resolver=resolver, + default_value=default_value, + ) + + +def get_field_type_from_resolver(resolver: Resolver) -> Any: + if isinstance(resolver, staticmethod): + resolver = resolver.__func__ + return resolver.__annotations__.get("return") + + +def get_field_type_from_subscriber(subscriber: Subscriber) -> Any: + if isinstance(subscriber, staticmethod): + subscriber = subscriber.__func__ + return subscriber.__annotations__.get("return") + + +def object_resolver( + field: str, + graphql_type: Optional[Any] = None, + args: Optional[dict[str, GraphQLObjectFieldArg]] = None, + description: Optional[str] = None, +): + def object_resolver_factory(f: Resolver) -> GraphQLObjectResolver: + if isinstance(f, staticmethod): + f = f.__func__ + return GraphQLObjectResolver( + args=args, + description=description, + resolver=f, + field=field, + field_type=graphql_type or get_field_type_from_resolver(f), + ) + + return object_resolver_factory + + +def object_subscriber( + field: str, + graphql_type: Optional[Any] = None, + args: Optional[dict[str, GraphQLObjectFieldArg]] = None, + description: Optional[str] = None, +): + def object_subscriber_factory(f: Subscriber) -> GraphQLObjectSource: + if isinstance(f, staticmethod): + f = f.__func__ + return GraphQLObjectSource( + args=args, + description=description, + subscriber=f, + field=field, + field_type=graphql_type or get_field_type_from_subscriber(f), + ) + + return object_subscriber_factory diff --git a/ariadne_graphql_modules/base_object_type/graphql_type.py b/ariadne_graphql_modules/base_object_type/graphql_type.py new file mode 100644 index 0000000..4b31f0d --- /dev/null +++ b/ariadne_graphql_modules/base_object_type/graphql_type.py @@ -0,0 +1,420 @@ +from collections.abc import Iterable +from copy import deepcopy +from enum import Enum +from typing import ( + Any, + Optional, + Union, +) + +from ariadne.types import Resolver +from graphql import ( + FieldDefinitionNode, + InputValueDefinitionNode, + StringValueNode, +) + +from ariadne_graphql_modules.base import GraphQLMetadata, GraphQLType +from ariadne_graphql_modules.base_graphql_model import GraphQLModel +from ariadne_graphql_modules.base_object_type.graphql_field import ( + GraphQLClassData, + GraphQLFieldData, + GraphQLObjectData, + GraphQLObjectField, + GraphQLObjectFieldArg, + GraphQLObjectResolver, + GraphQLObjectSource, + object_field, + object_resolver, +) +from ariadne_graphql_modules.base_object_type.utils import ( + get_field_args_from_resolver, + get_field_args_from_subscriber, + get_field_args_out_names, + get_field_node_from_obj_field, + update_field_args_options, +) +from ariadne_graphql_modules.convert_name import convert_python_name_to_graphql +from ariadne_graphql_modules.description import get_description_node +from ariadne_graphql_modules.types import GraphQLClassType +from ariadne_graphql_modules.typing import get_graphql_type +from ariadne_graphql_modules.value import get_value_node + + +class GraphQLBaseObject(GraphQLType): + __kwargs__: dict[str, Any] + __abstract__: bool = True + __schema__: Optional[str] = None + __aliases__: Optional[dict[str, str]] + __requires__: Optional[Iterable[Union[type[GraphQLType], type[Enum]]]] + __graphql_type__ = GraphQLClassType.BASE + + def __init__(self, **kwargs: Any): + default_values: dict[str, Any] = {} + + for inherited_obj in self._collect_inherited_objects(): + if hasattr(inherited_obj, "__kwargs__"): + default_values.update(inherited_obj.__kwargs__) + + default_values.update(self.__kwargs__) + + for kwarg in kwargs: + if kwarg not in default_values: + valid_kwargs = "', '".join(default_values) + raise TypeError( + f"{type(self).__name__}.__init__() got an unexpected " + f"keyword argument '{kwarg}'. " + f"Valid keyword arguments: '{valid_kwargs}'" + ) + + for kwarg, default in default_values.items(): + setattr(self, kwarg, kwargs.get(kwarg, deepcopy(default))) + + @classmethod + def __get_graphql_model__(cls, metadata: GraphQLMetadata) -> "GraphQLModel": + name = cls.__get_graphql_name__() + metadata.set_graphql_name(cls, name) + + if getattr(cls, "__schema__", None): + return cls.__get_graphql_model_with_schema__() + + return cls.__get_graphql_model_without_schema__(metadata, name) + + @classmethod + def __get_graphql_model_with_schema__(cls) -> "GraphQLModel": + raise NotImplementedError() + + @classmethod + def __get_graphql_model_without_schema__( + cls, metadata: GraphQLMetadata, name: str + ) -> "GraphQLModel": + raise NotImplementedError() + + @classmethod + def _create_fields_and_resolvers_with_schema( + cls, definition_fields: tuple["FieldDefinitionNode", ...] + ) -> tuple[tuple[FieldDefinitionNode, ...], dict[str, Resolver]]: + descriptions: dict[str, StringValueNode] = {} + args_descriptions: dict[str, dict[str, StringValueNode]] = {} + args_defaults: dict[str, dict[str, Any]] = {} + resolvers: dict[str, Resolver] = {} + + for attr_name in dir(cls): + cls_attr = getattr(cls, attr_name) + if isinstance(cls_attr, GraphQLObjectResolver): + resolvers[cls_attr.field] = cls_attr.resolver + description_node = get_description_node(cls_attr.description) + if description_node: + descriptions[cls_attr.field] = description_node + + field_args = get_field_args_from_resolver(cls_attr.resolver) + if field_args: + args_descriptions[cls_attr.field] = {} + args_defaults[cls_attr.field] = {} + + final_args = update_field_args_options(field_args, cls_attr.args) + for arg_name, arg_options in final_args.items(): + arg_description = get_description_node(arg_options.description) + if arg_description: + args_descriptions[cls_attr.field][ + arg_name + ] = arg_description + + if arg_options.default_value is not None: + args_defaults[cls_attr.field][arg_name] = get_value_node( + arg_options.default_value + ) + + fields: list[FieldDefinitionNode] = [] + for field in definition_fields: + field_args_descriptions = args_descriptions.get(field.name.value, {}) + field_args_defaults = args_defaults.get(field.name.value, {}) + + args: list[InputValueDefinitionNode] = [] + for arg in field.arguments: + arg_name = arg.name.value + args.append( + InputValueDefinitionNode( + description=( + arg.description or field_args_descriptions.get(arg_name) + ), + name=arg.name, + directives=arg.directives, + type=arg.type, + default_value=( + arg.default_value or field_args_defaults.get(arg_name) + ), + ) + ) + + fields.append( + FieldDefinitionNode( + name=field.name, + description=( + field.description or descriptions.get(field.name.value) + ), + directives=field.directives, + arguments=tuple(args), + type=field.type, + ) + ) + + return tuple(fields), resolvers + + @classmethod + def _process_graphql_fields( + cls, + metadata: GraphQLMetadata, + type_data, + type_aliases, + object_model_data: GraphQLClassData, + ): + for attr_name, field in type_data.fields.items(): + object_model_data.fields_ast[attr_name] = get_field_node_from_obj_field( + cls, metadata, field + ) + + if attr_name in type_aliases and field.name: + object_model_data.aliases[field.name] = type_aliases[attr_name] + elif field.name and attr_name != field.name and not field.resolver: + object_model_data.aliases[field.name] = attr_name + + if field.resolver and field.name: + object_model_data.resolvers[field.name] = field.resolver + + if field.args and field.name: + object_model_data.out_names[field.name] = get_field_args_out_names( + field.args + ) + + @classmethod + def __get_graphql_types__( + cls, metadata: "GraphQLMetadata" + ) -> Iterable[Union[type["GraphQLType"], type[Enum]]]: + """Returns iterable with GraphQL types associated with this type""" + if getattr(cls, "__schema__", None): + return cls.__get_graphql_types_with_schema__(metadata) + + return cls.__get_graphql_types_without_schema__(metadata) + + @classmethod + def __get_graphql_types_with_schema__( + cls, _: "GraphQLMetadata" + ) -> Iterable[type["GraphQLType"]]: + types: list[type[GraphQLType]] = [cls] + types.extend(getattr(cls, "__requires__", [])) + return types + + @classmethod + def __get_graphql_types_without_schema__( + cls, metadata: "GraphQLMetadata" + ) -> Iterable[Union[type["GraphQLType"], type[Enum]]]: + types: list[Union[type[GraphQLType], type[Enum]]] = [cls] + type_data = cls.get_graphql_object_data(metadata) + + for field in type_data.fields.values(): + field_type = get_graphql_type(field.field_type) + if field_type and field_type not in types: + types.append(field_type) + + if field.args: + for field_arg in field.args.values(): + field_arg_type = get_graphql_type(field_arg.field_type) + if field_arg_type and field_arg_type not in types: + types.append(field_arg_type) + + return types + + @staticmethod + def field( + f: Optional[Resolver] = None, + *, + name: Optional[str] = None, + graphql_type: Optional[Any] = None, + args: Optional[dict[str, GraphQLObjectFieldArg]] = None, + description: Optional[str] = None, + default_value: Optional[Any] = None, + ) -> Any: + """Shortcut for object_field()""" + return object_field( + f, + args=args, + name=name, + graphql_type=graphql_type, + description=description, + default_value=default_value, + ) + + @staticmethod + def resolver( + field: str, + graphql_type: Optional[Any] = None, + args: Optional[dict[str, GraphQLObjectFieldArg]] = None, + description: Optional[str] = None, + ): + """Shortcut for object_resolver()""" + return object_resolver( + args=args, + field=field, + graphql_type=graphql_type, + description=description, + ) + + @staticmethod + def argument( + name: Optional[str] = None, + description: Optional[str] = None, + graphql_type: Optional[Any] = None, + default_value: Optional[Any] = None, + ) -> GraphQLObjectFieldArg: + return GraphQLObjectFieldArg( + name=name, + out_name=None, + field_type=graphql_type, + description=description, + default_value=default_value, + ) + + @classmethod + def get_graphql_object_data( + cls, + metadata: GraphQLMetadata, + ) -> GraphQLObjectData: + try: + return metadata.get_data(cls) + except KeyError as exc: + if getattr(cls, "__schema__", None): + raise NotImplementedError( + "'get_graphql_object_data' is not supported for " + "objects with '__schema__'." + ) from exc + object_data = cls.create_graphql_object_data_without_schema() + + metadata.set_data(cls, object_data) + return object_data + + @classmethod + def create_graphql_object_data_without_schema(cls) -> GraphQLObjectData: + raise NotImplementedError() + + @staticmethod + def _build_fields(fields_data: GraphQLFieldData) -> dict[str, "GraphQLObjectField"]: + fields = {} + for field_name in fields_data.fields_order: + fields[field_name] = GraphQLObjectField( + name=fields_data.fields_names[field_name], + description=fields_data.fields_descriptions.get(field_name), + field_type=fields_data.fields_types.get(field_name), + args=fields_data.fields_args.get(field_name), + resolver=fields_data.fields_resolvers.get(field_name), + subscriber=fields_data.fields_subscribers.get(field_name), + default_value=fields_data.fields_defaults.get(field_name), + ) + return fields + + @classmethod + def _process_type_hints_and_aliases(cls, fields_data: GraphQLFieldData): + fields_data.type_hints.update(cls.__annotations__) # pylint: disable=no-member + fields_data.aliases.update(getattr(cls, "__aliases__", None) or {}) + fields_data.aliases_targets = list(fields_data.aliases.values()) + + for attr_name, attr_type in fields_data.type_hints.items(): + if attr_name.startswith("__"): + continue + + if attr_name in fields_data.aliases_targets: + cls_attr = getattr(cls, attr_name, None) + if not isinstance(cls_attr, GraphQLObjectField): + continue + + fields_data.fields_order.append(attr_name) + fields_data.fields_names[attr_name] = convert_python_name_to_graphql( + attr_name + ) + fields_data.fields_types[attr_name] = attr_type + + @staticmethod + def _process_class_attributes( # noqa: C901 + target_cls, fields_data: GraphQLFieldData + ): + for attr_name in dir(target_cls): + if attr_name.startswith("__"): + continue + cls_attr = getattr(target_cls, attr_name) + if isinstance(cls_attr, GraphQLObjectField): + if attr_name not in fields_data.fields_order: + fields_data.fields_order.append(attr_name) + + fields_data.fields_names[attr_name] = ( + cls_attr.name or convert_python_name_to_graphql(attr_name) + ) + + if cls_attr.field_type: + fields_data.fields_types[attr_name] = cls_attr.field_type + if cls_attr.description: + fields_data.fields_descriptions[attr_name] = cls_attr.description + if cls_attr.resolver: + resolver = cls_attr.resolver + if isinstance(resolver, staticmethod): + resolver = resolver.__func__ # type: ignore[attr-defined] + fields_data.fields_resolvers[attr_name] = resolver + field_args = get_field_args_from_resolver(resolver) + if field_args: + fields_data.fields_args[attr_name] = update_field_args_options( + field_args, cls_attr.args + ) + if cls_attr.default_value: + fields_data.fields_defaults[attr_name] = cls_attr.default_value + elif isinstance(cls_attr, GraphQLObjectResolver): + if ( + cls_attr.field_type + and cls_attr.field not in fields_data.fields_types + ): + fields_data.fields_types[cls_attr.field] = cls_attr.field_type + if ( + cls_attr.description + and cls_attr.field not in fields_data.fields_descriptions + ): + fields_data.fields_descriptions[cls_attr.field] = ( + cls_attr.description + ) + resolver = cls_attr.resolver + if isinstance(resolver, staticmethod): + resolver = resolver.__func__ # type: ignore[attr-defined] + fields_data.fields_resolvers[cls_attr.field] = resolver + field_args = get_field_args_from_resolver(resolver) + if field_args and not fields_data.fields_args.get(cls_attr.field): + fields_data.fields_args[cls_attr.field] = update_field_args_options( + field_args, cls_attr.args + ) + elif isinstance(cls_attr, GraphQLObjectSource): + if ( + cls_attr.field_type + and cls_attr.field not in fields_data.fields_types + ): + fields_data.fields_types[cls_attr.field] = cls_attr.field_type + if ( + cls_attr.description + and cls_attr.field not in fields_data.fields_descriptions + ): + fields_data.fields_descriptions[cls_attr.field] = ( + cls_attr.description + ) + subscriber = cls_attr.subscriber + if isinstance(subscriber, staticmethod): + subscriber = subscriber.__func__ # type: ignore[attr-defined] + fields_data.fields_subscribers[cls_attr.field] = subscriber + field_args = get_field_args_from_subscriber(subscriber) + if field_args: + fields_data.fields_args[cls_attr.field] = update_field_args_options( + field_args, cls_attr.args + ) + + elif attr_name not in fields_data.aliases_targets and not callable( + cls_attr + ): + fields_data.fields_defaults[attr_name] = cls_attr + + @classmethod + def _collect_inherited_objects(cls): + raise NotImplementedError diff --git a/ariadne_graphql_modules/base_object_type/utils.py b/ariadne_graphql_modules/base_object_type/utils.py new file mode 100644 index 0000000..79be529 --- /dev/null +++ b/ariadne_graphql_modules/base_object_type/utils.py @@ -0,0 +1,195 @@ +from dataclasses import replace +from inspect import signature +from typing import TYPE_CHECKING, Optional + +from ariadne.types import Resolver, Subscriber +from graphql import FieldDefinitionNode, InputValueDefinitionNode, NameNode + +from ariadne_graphql_modules.base import GraphQLMetadata +from ariadne_graphql_modules.base_object_type.graphql_field import ( + GraphQLObjectField, + GraphQLObjectFieldArg, +) +from ariadne_graphql_modules.convert_name import convert_python_name_to_graphql +from ariadne_graphql_modules.description import get_description_node +from ariadne_graphql_modules.typing import get_type_node +from ariadne_graphql_modules.value import get_value_node + +if TYPE_CHECKING: + from ariadne_graphql_modules.base_object_type.graphql_type import GraphQLBaseObject + + +def get_field_node_from_obj_field( + parent_type: type["GraphQLBaseObject"], + metadata: GraphQLMetadata, + field: GraphQLObjectField, +) -> FieldDefinitionNode: + return FieldDefinitionNode( + description=get_description_node(field.description), + name=NameNode(value=field.name), + type=get_type_node(metadata, field.field_type, parent_type), + arguments=get_field_args_nodes_from_obj_field_args(metadata, field.args), + ) + + +def get_field_args_from_resolver( + resolver: Resolver, +) -> dict[str, GraphQLObjectFieldArg]: + if isinstance(resolver, staticmethod): + resolver = resolver.__func__ + resolver_signature = signature(resolver) + type_hints = resolver.__annotations__ + type_hints.pop("return", None) + + field_args: dict[str, GraphQLObjectFieldArg] = {} + field_args_start = 0 + + # Fist pass: (arg, *_, something, something) or (arg, *, something, something): + for i, param in enumerate(resolver_signature.parameters.values()): + param_repr = str(param) + if param_repr.startswith("*") and not param_repr.startswith("**"): + field_args_start = i + 1 + break + else: + if len(resolver_signature.parameters) < 2: + raise TypeError( + f"Resolver function '{resolver_signature}' should accept at least " + "'obj' and 'info' positional arguments." + ) + + field_args_start = 2 + + args_parameters = tuple(resolver_signature.parameters.items())[field_args_start:] + if not args_parameters: + return field_args + + for param_name, param in args_parameters: + if param.default != param.empty: + param_default = param.default + else: + param_default = None + + field_args[param_name] = GraphQLObjectFieldArg( + name=convert_python_name_to_graphql(param_name), + out_name=param_name, + field_type=type_hints.get(param_name), + default_value=param_default, + ) + + return field_args + + +def get_field_args_from_subscriber( + subscriber: Subscriber, +) -> dict[str, GraphQLObjectFieldArg]: + subscriber_signature = signature(subscriber) + type_hints = subscriber.__annotations__ + type_hints.pop("return", None) + + field_args: dict[str, GraphQLObjectFieldArg] = {} + field_args_start = 0 + + # Fist pass: (arg, *_, something, something) or (arg, *, something, something): + for i, param in enumerate(subscriber_signature.parameters.values()): + param_repr = str(param) + if param_repr.startswith("*") and not param_repr.startswith("**"): + field_args_start = i + 1 + break + else: + if len(subscriber_signature.parameters) < 2: + raise TypeError( + f"Subscriber function '{subscriber_signature}' should accept at least " + "'obj' and 'info' positional arguments." + ) + + field_args_start = 2 + + args_parameters = tuple(subscriber_signature.parameters.items())[field_args_start:] + if not args_parameters: + return field_args + + for param_name, param in args_parameters: + if param.default != param.empty: + param_default = param.default + else: + param_default = None + + field_args[param_name] = GraphQLObjectFieldArg( + name=convert_python_name_to_graphql(param_name), + out_name=param_name, + field_type=type_hints.get(param_name), + default_value=param_default, + ) + + return field_args + + +def get_field_args_out_names( + field_args: dict[str, GraphQLObjectFieldArg], +) -> dict[str, str]: + out_names: dict[str, str] = {} + for field_arg in field_args.values(): + if field_arg.name and field_arg.out_name: + out_names[field_arg.name] = field_arg.out_name + return out_names + + +def get_field_arg_node_from_obj_field_arg( + metadata: GraphQLMetadata, + field_arg: GraphQLObjectFieldArg, +) -> InputValueDefinitionNode: + if field_arg.default_value is not None: + default_value = get_value_node(field_arg.default_value) + else: + default_value = None + + return InputValueDefinitionNode( + description=get_description_node(field_arg.description), + name=NameNode(value=field_arg.name), + type=get_type_node(metadata, field_arg.field_type), + default_value=default_value, + ) + + +def get_field_args_nodes_from_obj_field_args( + metadata: GraphQLMetadata, + field_args: Optional[dict[str, GraphQLObjectFieldArg]], +) -> Optional[tuple[InputValueDefinitionNode, ...]]: + if not field_args: + return None + + return tuple( + get_field_arg_node_from_obj_field_arg(metadata, field_arg) + for field_arg in field_args.values() + ) + + +def update_field_args_options( + field_args: dict[str, GraphQLObjectFieldArg], + args_options: Optional[dict[str, GraphQLObjectFieldArg]], +) -> dict[str, GraphQLObjectFieldArg]: + if not args_options: + return field_args + + updated_args: dict[str, GraphQLObjectFieldArg] = {} + for arg_name in field_args: + arg_options = args_options.get(arg_name) + if not arg_options: + updated_args[arg_name] = field_args[arg_name] + continue + args_update = {} + if arg_options.name: + args_update["name"] = arg_options.name + if arg_options.description: + args_update["description"] = arg_options.description + if arg_options.default_value is not None: + args_update["default_value"] = arg_options.default_value + if arg_options.field_type: + args_update["field_type"] = arg_options.field_type + + if args_update: + updated_args[arg_name] = replace(field_args[arg_name], **args_update) + else: + updated_args[arg_name] = field_args[arg_name] + + return updated_args diff --git a/ariadne_graphql_modules/base_object_type/validators.py b/ariadne_graphql_modules/base_object_type/validators.py new file mode 100644 index 0000000..a5a5de1 --- /dev/null +++ b/ariadne_graphql_modules/base_object_type/validators.py @@ -0,0 +1,558 @@ +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Optional, Union, cast + +from graphql import FieldDefinitionNode, ObjectTypeDefinitionNode, TypeDefinitionNode + +from ariadne_graphql_modules.base_object_type.graphql_field import ( + GraphQLObjectField, + GraphQLObjectFieldArg, + GraphQLObjectResolver, + GraphQLObjectSource, +) +from ariadne_graphql_modules.base_object_type.utils import ( + get_field_args_from_resolver, + get_field_args_from_subscriber, +) +from ariadne_graphql_modules.convert_name import convert_python_name_to_graphql +from ariadne_graphql_modules.utils import parse_definition +from ariadne_graphql_modules.validators import validate_description, validate_name +from ariadne_graphql_modules.value import get_value_node + +if TYPE_CHECKING: + from ariadne_graphql_modules.base_object_type.graphql_type import GraphQLBaseObject + + +@dataclass +class GraphQLObjectValidationData: + aliases: dict[str, str] + fields_attrs: list[str] + fields_instances: dict[str, GraphQLObjectField] + resolvers_instances: dict[str, GraphQLObjectResolver] + sources_instances: dict[str, GraphQLObjectSource] + + +def get_all_annotations(cls): + annotations = {} + for base_cls in reversed(cls.__mro__): + annotations.update(getattr(base_cls, "__annotations__", {})) + return annotations + + +def validate_object_type_with_schema( # noqa: C901 + cls: type["GraphQLBaseObject"], + valid_type: type[TypeDefinitionNode] = ObjectTypeDefinitionNode, +) -> dict[str, Any]: + definition = cast( + ObjectTypeDefinitionNode, parse_definition(valid_type, cls.__schema__) + ) + + if not isinstance(definition, valid_type): + raise ValueError( + f"Class '{cls.__name__}' defines '__schema__' attribute " + "with declaration for an invalid GraphQL type. " + f"('{definition.__class__.__name__}' != " + f"'{valid_type.__name__}')" + ) + + validate_name(cls, definition) + validate_description(cls, definition) + + if not definition.fields: + raise ValueError( + f"Class '{cls.__name__}' defines '__schema__' attribute " + "with declaration for an object type without any fields. " + ) + + field_names: list[str] = [f.name.value for f in definition.fields] + field_definitions: dict[str, FieldDefinitionNode] = { + f.name.value: f for f in definition.fields + } + + fields_resolvers: list[str] = [] + source_fields: list[str] = [] + valid_fields: str = "" + + for attr_name in dir(cls): + cls_attr = getattr(cls, attr_name) + if isinstance(cls_attr, GraphQLObjectField): + raise ValueError( + f"Class '{cls.__name__}' defines 'GraphQLObjectField' instance. " + "This is not supported for types defining '__schema__'." + ) + + if isinstance(cls_attr, GraphQLObjectResolver): + if cls_attr.field not in field_names: + valid_fields = "', '".join(sorted(field_names)) + raise ValueError( + f"Class '{cls.__name__}' defines resolver for an undefined " + f"field '{cls_attr.field}'. (Valid fields: '{valid_fields}')" + ) + + if cls_attr.field in fields_resolvers: + raise ValueError( + f"Class '{cls.__name__}' defines multiple resolvers for field " + f"'{cls_attr.field}'." + ) + + fields_resolvers.append(cls_attr.field) + + if cls_attr.description and field_definitions[cls_attr.field].description: + raise ValueError( + f"Class '{cls.__name__}' defines multiple descriptions " + f"for field '{cls_attr.field}'." + ) + + if cls_attr.args: + field_args = { + arg.name.value: arg + for arg in field_definitions[cls_attr.field].arguments + } + + for arg_name, arg_options in cls_attr.args.items(): + if arg_name not in field_args: + raise ValueError( + f"Class '{cls.__name__}' defines options for '{arg_name}' " + f"argument of the '{cls_attr.field}' field " + "that doesn't exist." + ) + + if arg_options.name: + raise ValueError( + f"Class '{cls.__name__}' defines 'name' option for " + f"'{arg_name}' argument of the '{cls_attr.field}' field. " + "This is not supported for types defining '__schema__'." + ) + + if arg_options.field_type: + raise ValueError( + f"Class '{cls.__name__}' defines 'type' option for " + f"'{arg_name}' argument of the '{cls_attr.field}' field. " + "This is not supported for types defining '__schema__'." + ) + + if arg_options.description and field_args[arg_name].description: + raise ValueError( + f"Class '{cls.__name__}' defines duplicate descriptions " + f"for '{arg_name}' argument " + f"of the '{cls_attr.field}' field." + ) + + validate_field_arg_default_value( + cls, cls_attr.field, arg_name, arg_options.default_value + ) + + resolver_args = get_field_args_from_resolver(cls_attr.resolver) + for arg_name, arg_obj in resolver_args.items(): + validate_field_arg_default_value( + cls, cls_attr.field, arg_name, arg_obj.default_value + ) + if isinstance(cls_attr, GraphQLObjectSource): + if cls_attr.field not in field_names: + valid_fields = "', '".join(sorted(field_names)) + raise ValueError( + f"Class '{cls.__name__}' defines source for an undefined " + f"field '{cls_attr.field}'. (Valid fields: '{valid_fields}')" + ) + + if cls_attr.field in source_fields: + raise ValueError( + f"Class '{cls.__name__}' defines multiple sources for field " + f"'{cls_attr.field}'." + ) + + source_fields.append(cls_attr.field) + + if cls_attr.description and field_definitions[cls_attr.field].description: + raise ValueError( + f"Class '{cls.__name__}' defines multiple descriptions " + f"for field '{cls_attr.field}'." + ) + + if cls_attr.args: + field_args = { + arg.name.value: arg + for arg in field_definitions[cls_attr.field].arguments + } + + for arg_name, arg_options in cls_attr.args.items(): + if arg_name not in field_args: + raise ValueError( + f"Class '{cls.__name__}' defines options for '{arg_name}' " + f"argument of the '{cls_attr.field}' field " + "that doesn't exist." + ) + + if arg_options.name: + raise ValueError( + f"Class '{cls.__name__}' defines 'name' option for " + f"'{arg_name}' argument of the '{cls_attr.field}' field. " + "This is not supported for types defining '__schema__'." + ) + + if arg_options.field_type: + raise ValueError( + f"Class '{cls.__name__}' defines 'type' option for " + f"'{arg_name}' argument of the '{cls_attr.field}' field. " + "This is not supported for types defining '__schema__'." + ) + + if arg_options.description and field_args[arg_name].description: + raise ValueError( + f"Class '{cls.__name__}' defines duplicate descriptions " + f"for '{arg_name}' argument " + f"of the '{cls_attr.field}' field." + ) + + validate_field_arg_default_value( + cls, cls_attr.field, arg_name, arg_options.default_value + ) + + subscriber_args = get_field_args_from_subscriber(cls_attr.subscriber) + for arg_name, arg_obj in subscriber_args.items(): + validate_field_arg_default_value( + cls, cls_attr.field, arg_name, arg_obj.default_value + ) + + aliases: dict[str, str] = getattr(cls, "__aliases__", None) or {} + validate_object_aliases(cls, aliases, field_names, fields_resolvers) + + return get_object_type_with_schema_kwargs(cls, aliases, field_names) + + +def validate_object_type_without_schema( + cls: type["GraphQLBaseObject"], +) -> dict[str, Any]: + data = get_object_type_validation_data(cls) + + # Alias target is not present in schema as a field if its not an + # explicit field (instance of GraphQLObjectField) + for alias_target in data.aliases.values(): + if ( + alias_target in data.fields_attrs + and alias_target not in data.fields_instances + ): + data.fields_attrs.remove(alias_target) + + # Validate GraphQL names for future type's fields and assert those are unique + validate_object_unique_graphql_names(cls, data.fields_attrs, data.fields_instances) + validate_object_resolvers( + cls, data.fields_attrs, data.fields_instances, data.resolvers_instances + ) + validate_object_subscribers(cls, data.fields_attrs, data.sources_instances) + validate_object_fields_args(cls) + + # Gather names of field attrs with defined resolver + fields_resolvers: list[str] = [] + for attr_name, field_instance in data.fields_instances.items(): + if field_instance.resolver: + fields_resolvers.append(attr_name) + for resolver_instance in data.resolvers_instances.values(): + fields_resolvers.append(resolver_instance.field) + + validate_object_aliases(cls, data.aliases, data.fields_attrs, fields_resolvers) + + return get_object_type_kwargs(cls, data.aliases) + + +def validate_object_unique_graphql_names( + cls: type["GraphQLBaseObject"], + fields_attrs: list[str], + fields_instances: dict[str, GraphQLObjectField], +): + graphql_names: list[str] = [] + for attr_name in fields_attrs: + if attr_name in fields_instances and fields_instances[attr_name].name: + attr_graphql_name = fields_instances[attr_name].name + else: + attr_graphql_name = convert_python_name_to_graphql(attr_name) + + if not attr_graphql_name: + raise ValueError( + f"Field '{attr_name}' in class '{cls.__name__}' has " + "an invalid or empty GraphQL name." + ) + + if attr_graphql_name in graphql_names: + raise ValueError( + f"Class '{cls.__name__}' defines multiple fields with GraphQL " + f"name '{attr_graphql_name}'." + ) + graphql_names.append(attr_graphql_name) + + +def validate_object_resolvers( + cls: type["GraphQLBaseObject"], + fields_names: list[str], + fields_instances: dict[str, GraphQLObjectField], + resolvers_instances: dict[str, GraphQLObjectResolver], +): + resolvers_fields: list[str] = [] + + for field_attr, field in fields_instances.items(): + if field.resolver: + resolvers_fields.append(field_attr) + + for resolver in resolvers_instances.values(): + if resolver.field not in fields_names: + valid_fields: str = "', '".join(sorted(fields_names)) + raise ValueError( + f"Class '{cls.__name__}' defines resolver for an undefined " + f"field '{resolver.field}'. (Valid fields: '{valid_fields}')" + ) + + if resolver.field in resolvers_fields: + raise ValueError( + f"Class '{cls.__name__}' defines multiple resolvers for field " + f"'{resolver.field}'." + ) + + resolvers_fields.append(resolver.field) + + field_instance: Optional[GraphQLObjectField] = fields_instances.get( + resolver.field + ) + if field_instance: + if field_instance.description and resolver.description: + raise ValueError( + f"Class '{cls.__name__}' defines multiple descriptions " + f"for field '{resolver.field}'." + ) + + if field_instance.args and resolver.args: + raise ValueError( + f"Class '{cls.__name__}' defines multiple arguments options " + f"('args') for field '{resolver.field}'." + ) + + +def validate_object_subscribers( + cls: type["GraphQLBaseObject"], + fields_names: list[str], + sources_instances: dict[str, GraphQLObjectSource], +): + source_fields: list[str] = [] + + for key, source in sources_instances.items(): + if not isinstance(source.field, str): + raise ValueError(f"The field name for {key} must be a string.") + if source.field not in fields_names: + valid_fields: str = "', '".join(sorted(fields_names)) + raise ValueError( + f"Class '{cls.__name__}' defines source for an undefined " + f"field '{source.field}'. (Valid fields: '{valid_fields}')" + ) + if source.field in source_fields: + raise ValueError( + f"Class '{cls.__name__}' defines multiple sources for field " + f"'{source.field}'." + ) + + source_fields.append(source.field) + + if source.description is not None and not isinstance(source.description, str): + raise ValueError(f"The description for {key} must be a string if provided.") + + if source.args is not None: + if not isinstance(source.args, dict): + raise ValueError( + f"The args for {key} must be a dictionary if provided." + ) + for arg_name, arg_info in source.args.items(): + if not isinstance(arg_info, GraphQLObjectFieldArg): + raise ValueError( + f"Argument {arg_name} for {key} must " + "have a GraphQLObjectFieldArg as its info." + ) + + +def validate_object_fields_args(cls: type["GraphQLBaseObject"]): + for field_name in dir(cls): + field_instance = getattr(cls, field_name) + if ( + isinstance(field_instance, (GraphQLObjectField, GraphQLObjectResolver)) + and field_instance.resolver + ): + validate_object_field_args(cls, field_name, field_instance) + + +def validate_object_field_args( + cls: type["GraphQLBaseObject"], + field_name: str, + field_instance: Union["GraphQLObjectField", "GraphQLObjectResolver"], +): + if field_instance.resolver: + resolver_args = get_field_args_from_resolver(field_instance.resolver) + if resolver_args: + for arg_name, arg_obj in resolver_args.items(): + validate_field_arg_default_value( + cls, field_name, arg_name, arg_obj.default_value + ) + + if not field_instance.args: + return # Skip extra logic for validating instance.args + + resolver_args_names = list(resolver_args.keys()) + if resolver_args_names: + error_help = "expected one of: '{}'".format("', '".join(resolver_args_names)) + else: + error_help = "function accepts no extra arguments" + + for arg_name, arg_options in field_instance.args.items(): + if arg_name not in resolver_args_names: + if isinstance(field_instance, GraphQLObjectField): + raise ValueError( + f"Class '{cls.__name__}' defines '{field_name}' field " + f"with extra configuration for '{arg_name}' argument " + "thats not defined on the resolver function. " + f"({error_help})" + ) + + raise ValueError( + f"Class '{cls.__name__}' defines '{field_name}' resolver " + f"with extra configuration for '{arg_name}' argument " + "thats not defined on the resolver function. " + f"({error_help})" + ) + + validate_field_arg_default_value( + cls, field_name, arg_name, arg_options.default_value + ) + + +def validate_object_aliases( + cls: type["GraphQLBaseObject"], + aliases: dict[str, str], + fields_names: list[str], + fields_resolvers: list[str], +): + for alias in aliases: + if alias not in fields_names: + valid_fields: str = "', '".join(sorted(fields_names)) + raise ValueError( + f"Class '{cls.__name__}' defines an alias for an undefined " + f"field '{alias}'. (Valid fields: '{valid_fields}')" + ) + + if alias in fields_resolvers: + raise ValueError( + f"Class '{cls.__name__}' defines an alias for a field " + f"'{alias}' that already has a custom resolver." + ) + + +def validate_field_arg_default_value( + cls: type["GraphQLBaseObject"], field_name: str, arg_name: str, default_value: Any +): + if default_value is None: + return + + try: + get_value_node(default_value) + except TypeError as e: + raise TypeError( + f"Class '{cls.__name__}' defines default value " + f"for '{arg_name}' argument " + f"of the '{field_name}' field that can't be " + "represented in GraphQL schema." + ) from e + + +def get_object_type_validation_data( # noqa: C901 + cls: type["GraphQLBaseObject"], +) -> GraphQLObjectValidationData: + fields_attrs: list[str] = [ + attr_name + for attr_name in get_all_annotations(cls) + if not attr_name.startswith("__") + ] + + fields_instances: dict[str, GraphQLObjectField] = {} + resolvers_instances: dict[str, GraphQLObjectResolver] = {} + sources_instances: dict[str, GraphQLObjectSource] = {} + + for attr_name in dir(cls): + if attr_name.startswith("__"): + continue + + cls_attr = getattr(cls, attr_name) + if isinstance(cls_attr, GraphQLObjectResolver): + resolvers_instances[attr_name] = cls_attr + if attr_name in fields_attrs: + fields_attrs.remove(attr_name) + + if isinstance(cls_attr, GraphQLObjectSource): + sources_instances[attr_name] = cls_attr + if attr_name in fields_attrs: + fields_attrs.remove(attr_name) + + elif isinstance(cls_attr, GraphQLObjectField): + fields_instances[attr_name] = cls_attr + + if attr_name not in fields_attrs: + fields_attrs.append(attr_name) + + elif callable(attr_name): + if attr_name in fields_attrs: + fields_attrs.remove(attr_name) + + return GraphQLObjectValidationData( + aliases=getattr(cls, "__aliases__", None) or {}, + fields_attrs=fields_attrs, + fields_instances=fields_instances, + resolvers_instances=resolvers_instances, + sources_instances=sources_instances, + ) + + +def get_object_type_kwargs( + cls: type["GraphQLBaseObject"], + aliases: dict[str, str], +) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + for attr_name in get_all_annotations(cls): + if attr_name.startswith("__"): + continue + + kwarg_name = aliases.get(attr_name, attr_name) + kwarg_value = getattr(cls, kwarg_name, None) + if isinstance(kwarg_value, GraphQLObjectField): + kwargs[kwarg_name] = kwarg_value.default_value + elif isinstance(kwarg_value, GraphQLObjectResolver): + continue # Skip resolver instances + elif not callable(kwarg_value): + kwargs[kwarg_name] = kwarg_value + + for attr_name in dir(cls): + if attr_name.startswith("__") or attr_name in kwargs: + continue + + kwarg_name = aliases.get(attr_name, attr_name) + kwarg_value = getattr(cls, kwarg_name) + if isinstance(kwarg_value, GraphQLObjectField): + kwargs[kwarg_name] = kwarg_value.default_value + elif not callable(kwarg_value): + kwargs[kwarg_name] = kwarg_value + + return kwargs + + +def get_object_type_with_schema_kwargs( + cls: type["GraphQLBaseObject"], + aliases: dict[str, str], + field_names: list[str], +) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + for field_name in field_names: + final_name = aliases.get(field_name, field_name) + attr_value = getattr(cls, final_name, None) + + if isinstance(attr_value, GraphQLObjectField): + kwargs[final_name] = attr_value.default_value + elif not isinstance(attr_value, GraphQLObjectResolver) and not callable( + attr_value + ): + kwargs[final_name] = attr_value + + return kwargs diff --git a/ariadne_graphql_modules/compatibility_layer.py b/ariadne_graphql_modules/compatibility_layer.py new file mode 100644 index 0000000..cf2cffa --- /dev/null +++ b/ariadne_graphql_modules/compatibility_layer.py @@ -0,0 +1,166 @@ +from enum import Enum +from inspect import isclass +from typing import Any, Union, cast + +from graphql import ( + EnumTypeDefinitionNode, + InputObjectTypeDefinitionNode, + InterfaceTypeDefinitionNode, + ObjectTypeDefinitionNode, + ScalarTypeDefinitionNode, + TypeExtensionNode, + UnionTypeDefinitionNode, +) + +from ariadne_graphql_modules import ( + GraphQLEnumModel, + GraphQLInputModel, + GraphQLInterfaceModel, + GraphQLObjectModel, + GraphQLScalarModel, + GraphQLSubscriptionModel, + GraphQLUnionModel, +) +from ariadne_graphql_modules.base import GraphQLType +from ariadne_graphql_modules.base_graphql_model import GraphQLModel +from ariadne_graphql_modules.v1.bases import BaseType, BindableType +from ariadne_graphql_modules.v1.directive_type import DirectiveType +from ariadne_graphql_modules.v1.enum_type import EnumType +from ariadne_graphql_modules.v1.executable_schema import get_all_types +from ariadne_graphql_modules.v1.input_type import InputType +from ariadne_graphql_modules.v1.interface_type import InterfaceType +from ariadne_graphql_modules.v1.mutation_type import MutationType +from ariadne_graphql_modules.v1.object_type import ObjectType +from ariadne_graphql_modules.v1.scalar_type import ScalarType +from ariadne_graphql_modules.v1.subscription_type import SubscriptionType +from ariadne_graphql_modules.v1.union_type import UnionType + + +def wrap_legacy_types( + *bindable_types: type[BaseType], +) -> list[type["LegacyGraphQLType"]]: + all_types = get_all_types(bindable_types) + + return [ + type(f"Legacy{t.__name__}", (LegacyGraphQLType,), {"__base_type__": t}) + for t in all_types + ] + + +class LegacyGraphQLType(GraphQLType): + __base_type__: type[BindableType] + __abstract__: bool = False + + @classmethod + def __get_graphql_model__(cls, *_) -> GraphQLModel: + if issubclass(cls.__base_type__.graphql_type, TypeExtensionNode): + pass + if issubclass(cls.__base_type__, ObjectType): + return cls.construct_object_model(cls.__base_type__) + if issubclass(cls.__base_type__, EnumType): + return cls.construct_enum_model(cls.__base_type__) + if issubclass(cls.__base_type__, InputType): + return cls.construct_input_model(cls.__base_type__) + if issubclass(cls.__base_type__, InterfaceType): + return cls.construct_interface_model(cls.__base_type__) + if issubclass(cls.__base_type__, MutationType): + return cls.construct_object_model(cls.__base_type__) + if issubclass(cls.__base_type__, ScalarType): + return cls.construct_scalar_model(cls.__base_type__) + if issubclass(cls.__base_type__, SubscriptionType): + return cls.construct_subscription_model(cls.__base_type__) + if issubclass(cls.__base_type__, UnionType): + return cls.construct_union_model(cls.__base_type__) + raise ValueError(f"Unsupported base_type {cls.__base_type__}") + + @classmethod + def construct_object_model( + cls, base_type: type[Union[ObjectType, MutationType]] + ) -> GraphQLObjectModel: + return GraphQLObjectModel( + name=base_type.graphql_name, + ast_type=ObjectTypeDefinitionNode, + ast=cast(ObjectTypeDefinitionNode, base_type.graphql_def), + resolvers=base_type.resolvers, # type: ignore + aliases=base_type.__aliases__ or {}, # type: ignore + out_names={}, + ) + + @classmethod + def construct_enum_model(cls, base_type: type[EnumType]) -> GraphQLEnumModel: + members = base_type.__enum__ or {} + members_values: dict[str, Any] = {} + + if isinstance(members, dict): + members_values = dict(members.items()) + elif isclass(members) and issubclass(members, Enum): + members_values = {member.name: member for member in members} + + return GraphQLEnumModel( + name=base_type.graphql_name, + members=members_values, + ast_type=EnumTypeDefinitionNode, + ast=cast(EnumTypeDefinitionNode, base_type.graphql_def), + ) + + @classmethod + def construct_directive_model(cls, base_type: type[DirectiveType]): + """TODO: https://github.com/mirumee/ariadne-graphql-modules/issues/29""" + + @classmethod + def construct_input_model(cls, base_type: type[InputType]) -> GraphQLInputModel: + return GraphQLInputModel( + name=base_type.graphql_name, + ast_type=InputObjectTypeDefinitionNode, + ast=cast(InputObjectTypeDefinitionNode, base_type.graphql_def), + out_type=base_type.graphql_type, + out_names={}, + ) + + @classmethod + def construct_interface_model( + cls, base_type: type[InterfaceType] + ) -> GraphQLInterfaceModel: + return GraphQLInterfaceModel( + name=base_type.graphql_name, + ast_type=InterfaceTypeDefinitionNode, + ast=cast(InterfaceTypeDefinitionNode, base_type.graphql_def), + resolve_type=base_type.resolve_type, + resolvers=base_type.resolvers, + out_names={}, + aliases=base_type.__aliases__ or {}, # type: ignore + ) + + @classmethod + def construct_scalar_model(cls, base_type: type[ScalarType]) -> GraphQLScalarModel: + return GraphQLScalarModel( + name=base_type.graphql_name, + ast_type=ScalarTypeDefinitionNode, + ast=cast(ScalarTypeDefinitionNode, base_type.graphql_def), + serialize=base_type.serialize, + parse_value=base_type.parse_value, + parse_literal=base_type.parse_literal, + ) + + @classmethod + def construct_subscription_model( + cls, base_type: type[SubscriptionType] + ) -> GraphQLSubscriptionModel: + return GraphQLSubscriptionModel( + name=base_type.graphql_name, + ast_type=ObjectTypeDefinitionNode, + ast=cast(ObjectTypeDefinitionNode, base_type.graphql_def), + resolvers=base_type.resolvers, + aliases=base_type.__aliases__ or {}, # type: ignore + out_names={}, + subscribers=base_type.subscribers, + ) + + @classmethod + def construct_union_model(cls, base_type: type[UnionType]) -> GraphQLUnionModel: + return GraphQLUnionModel( + name=base_type.graphql_name, + ast_type=UnionTypeDefinitionNode, + ast=cast(UnionTypeDefinitionNode, base_type.graphql_def), + resolve_type=base_type.resolve_type, + ) diff --git a/ariadne_graphql_modules/convert_name.py b/ariadne_graphql_modules/convert_name.py new file mode 100644 index 0000000..107bac4 --- /dev/null +++ b/ariadne_graphql_modules/convert_name.py @@ -0,0 +1,13 @@ +def convert_python_name_to_graphql(python_name: str) -> str: + components = python_name.split("_") + return components[0].lower() + "".join(x.capitalize() for x in components[1:]) + + +def convert_graphql_name_to_python(graphql_name: str) -> str: + python_name = "" + for c in graphql_name: + if c.isupper() or c.isdigit(): + python_name += "_" + c.lower() + else: + python_name += c + return python_name.lstrip("_") diff --git a/ariadne_graphql_modules/deferredtype.py b/ariadne_graphql_modules/deferredtype.py new file mode 100644 index 0000000..480f7b7 --- /dev/null +++ b/ariadne_graphql_modules/deferredtype.py @@ -0,0 +1,57 @@ +import sys +from dataclasses import dataclass +from typing import cast + + +@dataclass(frozen=True) +class DeferredTypeData: + """Data class representing deferred type information with a module path.""" + + path: str + + +def deferred(module_path: str) -> DeferredTypeData: + """ + Create a DeferredTypeData object from a given module path. + + If the module path is relative (starts with '.'), + resolve it based on the caller's package context. + """ + if not module_path.startswith("."): + return DeferredTypeData(module_path) + + frame = _get_caller_frame() + current_package = cast(str, frame.f_globals["__package__"]) + + module_path_suffix = _resolve_module_path_suffix(module_path, current_package) + + return DeferredTypeData(module_path_suffix) + + +def _get_caller_frame(): + """Retrieve the caller's frame and ensure it's within a valid context.""" + frame = sys._getframe(2) # pylint: disable=protected-access + if not frame: + raise RuntimeError( + "'deferred' must be called within a class attribute definition context." + ) + return frame + + +def _resolve_module_path_suffix(module_path: str, current_package: str) -> str: + """Resolve the full module path by handling relative imports.""" + module_path_suffix = module_path[1:] # Remove initial dot + packages = current_package.split(".") + + while module_path_suffix.startswith(".") and packages: + module_path_suffix = module_path_suffix[1:] # Remove dot + packages.pop() + + if not packages: + raise ValueError( + f"'{module_path}' points outside of the '{current_package}' package." + ) + + return ( + f"{'.'.join(packages)}.{module_path_suffix}" if packages else module_path_suffix + ) diff --git a/ariadne_graphql_modules/description.py b/ariadne_graphql_modules/description.py new file mode 100644 index 0000000..cda949f --- /dev/null +++ b/ariadne_graphql_modules/description.py @@ -0,0 +1,20 @@ +from textwrap import dedent +from typing import Optional + +from graphql import StringValueNode + + +def get_description_node(description: Optional[str]) -> Optional[StringValueNode]: + """Convert a description string into a GraphQL StringValueNode. + + If the description is provided, it will be dedented, stripped of surrounding + whitespace, and used to create a StringValueNode. If the description contains + newline characters, the `block` attribute of the StringValueNode + will be set to `True`. + """ + if not description: + return None + + return StringValueNode( + value=dedent(description).strip(), block="\n" in description.strip() + ) diff --git a/ariadne_graphql_modules/enum_type/__init__.py b/ariadne_graphql_modules/enum_type/__init__.py new file mode 100644 index 0000000..f0c3d80 --- /dev/null +++ b/ariadne_graphql_modules/enum_type/__init__.py @@ -0,0 +1,15 @@ +from ariadne_graphql_modules.enum_type.enum_model_utils import ( + create_graphql_enum_model, + graphql_enum, +) +from ariadne_graphql_modules.enum_type.graphql_type import ( + GraphQLEnum, +) +from ariadne_graphql_modules.enum_type.models import GraphQLEnumModel + +__all__ = [ + "GraphQLEnum", + "GraphQLEnumModel", + "create_graphql_enum_model", + "graphql_enum", +] diff --git a/ariadne_graphql_modules/enum_type/enum_model_utils.py b/ariadne_graphql_modules/enum_type/enum_model_utils.py new file mode 100644 index 0000000..23b69b2 --- /dev/null +++ b/ariadne_graphql_modules/enum_type/enum_model_utils.py @@ -0,0 +1,104 @@ +from collections.abc import Iterable +from enum import Enum +from typing import Any, Optional, cast + +from graphql import EnumTypeDefinitionNode, EnumValueDefinitionNode, NameNode + +from ariadne_graphql_modules.description import get_description_node +from ariadne_graphql_modules.enum_type.models import GraphQLEnumModel + + +def create_graphql_enum_model( # noqa: C901 + enum: type[Enum], + *, + name: Optional[str] = None, + description: Optional[str] = None, + members_descriptions: Optional[dict[str, str]] = None, + members_include: Optional[Iterable[str]] = None, + members_exclude: Optional[Iterable[str]] = None, +) -> "GraphQLEnumModel": + if members_include and members_exclude: + raise ValueError( + "'members_include' and 'members_exclude' options are mutually exclusive." + ) + + if hasattr(enum, "__get_graphql_model__"): + return cast(GraphQLEnumModel, enum.__get_graphql_model__()) + + if not name: + if hasattr(enum, "__get_graphql_name__"): + name = cast("str", enum.__get_graphql_name__()) + else: + name = enum.__name__ + + members: dict[str, Any] = {i.name: i for i in enum} + final_members: dict[str, Any] = {} + + if members_include: + for key, value in members.items(): + if key in members_include: + final_members[key] = value + elif members_exclude: + for key, value in members.items(): + if key not in members_exclude: + final_members[key] = value + else: + final_members = members + + members_descriptions = members_descriptions or {} + for member in members_descriptions: + if member not in final_members: + raise ValueError( + f"Member description was specified for a member '{member}' " + "not present in final GraphQL enum." + ) + + return GraphQLEnumModel( + name=name, + members=final_members, + ast_type=EnumTypeDefinitionNode, + ast=EnumTypeDefinitionNode( + name=NameNode(value=name), + description=get_description_node(description), + values=tuple( + EnumValueDefinitionNode( + name=NameNode(value=value_name), + description=get_description_node( + members_descriptions.get(value_name) + ), + ) + for value_name in final_members + ), + ), + ) + + +def graphql_enum( + cls=None, + *, + name: Optional[str] = None, + description: Optional[str] = None, + members_descriptions: Optional[dict[str, str]] = None, + members_include: Optional[Iterable[str]] = None, + members_exclude: Optional[Iterable[str]] = None, +): + def graphql_enum_decorator(cls): + graphql_model = create_graphql_enum_model( + cls, + name=name, + description=description, + members_descriptions=members_descriptions, + members_include=members_include, + members_exclude=members_exclude, + ) + + def __get_graphql_model__(*_) -> GraphQLEnumModel: # noqa: N807 + return graphql_model + + setattr(cls, "__get_graphql_model__", classmethod(__get_graphql_model__)) + return cls + + if cls: + return graphql_enum_decorator(cls) + + return graphql_enum_decorator diff --git a/ariadne_graphql_modules/enum_type/graphql_type.py b/ariadne_graphql_modules/enum_type/graphql_type.py new file mode 100644 index 0000000..702432f --- /dev/null +++ b/ariadne_graphql_modules/enum_type/graphql_type.py @@ -0,0 +1,251 @@ +from enum import Enum +from inspect import isclass +from typing import Any, Optional, Union, cast + +from graphql import EnumTypeDefinitionNode, EnumValueDefinitionNode, NameNode + +from ariadne_graphql_modules.base import GraphQLMetadata, GraphQLModel, GraphQLType +from ariadne_graphql_modules.description import get_description_node +from ariadne_graphql_modules.enum_type.models import GraphQLEnumModel +from ariadne_graphql_modules.utils import parse_definition +from ariadne_graphql_modules.validators import validate_description, validate_name + + +class GraphQLEnum(GraphQLType): + __abstract__: bool = True + __schema__: Optional[str] + __description__: Optional[str] + __members__: Optional[Union[type[Enum], dict[str, Any], list[str]]] + __members_descriptions__: Optional[dict[str, str]] + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + if cls.__dict__.get("__abstract__"): + return + + cls.__abstract__ = False + cls._validate() + + @classmethod + def __get_graphql_model__(cls, metadata: GraphQLMetadata) -> "GraphQLModel": + name = cls.__get_graphql_name__() + + if getattr(cls, "__schema__", None): + return cls.__get_graphql_model_with_schema__(name) + + return cls.__get_graphql_model_without_schema__(name) + + @classmethod + def __get_graphql_model_with_schema__(cls, name: str) -> "GraphQLEnumModel": + definition: EnumTypeDefinitionNode = cast( + EnumTypeDefinitionNode, + parse_definition(EnumTypeDefinitionNode, cls.__schema__), + ) + + members = getattr(cls, "__members__", []) + members_values: dict[str, Any] = {} + + if isinstance(members, dict): + members_values = dict(members.items()) + elif isclass(members) and issubclass(members, Enum): + members_values = {member.name: member for member in members} + else: + members_values = { + value.name.value: value.name.value + for value in definition.values # pylint: disable=no-member + } + + members_descriptions = getattr(cls, "__members_descriptions__", {}) + + return GraphQLEnumModel( + name=name, + members=members_values, + ast_type=EnumTypeDefinitionNode, + ast=EnumTypeDefinitionNode( + name=NameNode(value=name), + directives=definition.directives, + description=definition.description + or (get_description_node(getattr(cls, "__description__", None))), + values=tuple( + EnumValueDefinitionNode( + name=value.name, + directives=value.directives, + description=value.description + or ( + get_description_node( + members_descriptions.get(value.name.value), + ) + ), + ) + for value in definition.values # pylint: disable=no-member + ), + ), + ) + + @classmethod + def __get_graphql_model_without_schema__(cls, name: str) -> "GraphQLEnumModel": + members = getattr(cls, "__members__", []) + members_values = {} + if isinstance(members, dict): + members_values = dict(members.items()) + elif isclass(members) and issubclass(members, Enum): + members_values = {i.name: i for i in members} + elif isinstance(members, list): + members_values = {kv: kv for kv in members} + + members_descriptions = getattr(cls, "__members_descriptions__", {}) + + return GraphQLEnumModel( + name=name, + members=members_values, + ast_type=EnumTypeDefinitionNode, + ast=EnumTypeDefinitionNode( + name=NameNode(value=name), + description=get_description_node( + getattr(cls, "__description__", None), + ), + values=tuple( + EnumValueDefinitionNode( + name=NameNode(value=value_name), + description=get_description_node( + members_descriptions.get(value_name) + ), + ) + for value_name in members_values + ), + ), + ) + + @classmethod + def _validate(cls): + if getattr(cls, "__schema__", None): + cls._validate_enum_type_with_schema() + else: + cls._validate_enum_type() + + @classmethod + def _validate_enum_type_with_schema(cls): + definition = parse_definition(EnumTypeDefinitionNode, cls.__schema__) + + if not isinstance(definition, EnumTypeDefinitionNode): + raise ValueError( + f"Class '{cls.__name__}' defines '__schema__' attribute " + f"with declaration for an invalid GraphQL type. " + f"('{definition.__class__.__name__}' != " + f"'{EnumTypeDefinitionNode.__name__}')" + ) + + validate_name(cls, definition) + validate_description(cls, definition) + + members_names = { + value.name.value for value in definition.values # pylint: disable=no-member + } + if not members_names: + raise ValueError( + f"Class '{cls.__name__}' defines '__schema__' attribute " + "that doesn't declare any enum members." + ) + + members_values = getattr(cls, "__members__", None) + if members_values: + cls.validate_members_values(members_values, members_names) + + members_descriptions = getattr(cls, "__members_descriptions__", {}) + cls.validate_enum_members_descriptions(members_names, members_descriptions) + + duplicate_descriptions = [ + ast_member.name.value + for ast_member in definition.values # pylint: disable=no-member + if ast_member.description + and ast_member.description.value + and members_descriptions.get(ast_member.name.value) + ] + + if duplicate_descriptions: + raise ValueError( + f"Class '{cls.__name__}' '__members_descriptions__' attribute defines " + "descriptions for enum members that also " + "have description in '__schema__' " + f"attribute. (members: '{', '.join(duplicate_descriptions)}')" + ) + + @classmethod + def validate_members_values(cls, members_values, members_names): + if isinstance(members_values, list): + raise ValueError( + f"Class '{cls.__name__}' '__members__' attribute " + "can't be a list when used together with '__schema__'." + ) + + missing_members = None + if isinstance(members_values, dict): + missing_members = members_names - set(members_values) + elif isclass(members_values) and issubclass(members_values, Enum): + missing_members = members_names - {value.name for value in members_values} + + if missing_members: + raise ValueError( + f"Class '{cls.__name__}' '__members__' is missing values " + f"for enum members defined in '__schema__'. " + f"(missing items: '{', '.join(missing_members)}')" + ) + + @classmethod + def _validate_enum_type(cls): + members_values = getattr(cls, "__members__", None) + if not members_values: + raise ValueError( + f"Class '{cls.__name__}' '__members__' attribute is either missing or " + "empty. Either define it or provide full SDL for this enum using " + "the '__schema__' attribute." + ) + + if not any( + [ + isinstance(members_values, (dict, list)), + isclass(members_values) and issubclass(members_values, Enum), + ] + ): + raise ValueError( + f"Class '{cls.__name__}' '__members__' " + "attribute is of unsupported type. " + f"Expected 'Dict[str, Any]', 'Type[Enum]' or List[str]. " + f"(found: '{type(members_values)}')" + ) + + members_names = cls.get_members_set(members_values) + members_descriptions = getattr(cls, "__members_descriptions__", {}) + cls.validate_enum_members_descriptions(members_names, members_descriptions) + + @classmethod + def validate_enum_members_descriptions( + cls, members: set[str], members_descriptions: dict + ): + invalid_descriptions = set(members_descriptions) - members + if invalid_descriptions: + invalid_descriptions_str = "', '".join(invalid_descriptions) + raise ValueError( + f"Class '{cls.__name__}' '__members_descriptions__' attribute defines " + f"descriptions for undefined enum members. " + f"(undefined members: '{invalid_descriptions_str}')" + ) + + @staticmethod + def get_members_set( + members: Optional[Union[type[Enum], dict[str, Any], list[str]]], + ) -> set[str]: + if isinstance(members, dict): + return set(members.keys()) + + if isclass(members) and issubclass(members, Enum): + return set(member.name for member in members) + + if isinstance(members, list): + return set(members) + + raise TypeError( + f"Expected members to be of type Dict[str, Any], List[str], or Enum." + f"Got {type(members).__name__} instead." + ) diff --git a/ariadne_graphql_modules/enum_type/models.py b/ariadne_graphql_modules/enum_type/models.py new file mode 100644 index 0000000..6ccbd08 --- /dev/null +++ b/ariadne_graphql_modules/enum_type/models.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import Any + +from ariadne import EnumType +from graphql import GraphQLSchema + +from ariadne_graphql_modules.base_graphql_model import GraphQLModel + + +@dataclass(frozen=True) +class GraphQLEnumModel(GraphQLModel): + members: dict[str, Any] + + def bind_to_schema(self, schema: GraphQLSchema): + bindable = EnumType(self.name, values=self.members) + bindable.bind_to_schema(schema) diff --git a/ariadne_graphql_modules/executable_schema.py b/ariadne_graphql_modules/executable_schema.py index 1914742..fa40b99 100644 --- a/ariadne_graphql_modules/executable_schema.py +++ b/ariadne_graphql_modules/executable_schema.py @@ -1,236 +1,250 @@ -from typing import ( - Dict, - List, - Optional, - Sequence, - Tuple, - Type, - Union, - cast, -) +from collections.abc import Sequence +from enum import Enum +from typing import Any, Optional, Union from ariadne import ( SchemaBindable, SchemaDirectiveVisitor, + SchemaNameConverter, + convert_schema_names, repair_schema_default_enum_values, validate_schema_default_enum_values, ) from graphql import ( - ConstDirectiveNode, DocumentNode, - FieldDefinitionNode, GraphQLSchema, - NamedTypeNode, - ObjectTypeDefinitionNode, - TypeDefinitionNode, assert_valid_schema, build_ast_schema, concat_ast, parse, ) -from graphql.language import ast -from .bases import BaseType, BindableType, DeferredType, DefinitionType -from .enum_type import EnumType +from ariadne_graphql_modules.base import GraphQLMetadata, GraphQLType +from ariadne_graphql_modules.base_graphql_model import GraphQLModel +from ariadne_graphql_modules.roots import ROOTS_NAMES, merge_root_nodes +from ariadne_graphql_modules.sort import sort_schema_document -ROOT_TYPES = ["Query", "Mutation", "Subscription"] +SchemaType = Union[str, Enum, SchemaBindable, type[GraphQLType], type[Enum]] -def make_executable_schema( - *args: Union[Type[BaseType], SchemaBindable, str], +def make_executable_schema( # noqa: C901 + *types: Union[SchemaType, list[SchemaType]], + directives: Optional[dict[str, type[SchemaDirectiveVisitor]]] = None, + convert_names_case: Union[bool, SchemaNameConverter] = False, merge_roots: bool = True, - extra_directives: Optional[Dict[str, Type[SchemaDirectiveVisitor]]] = None, -): - all_types = get_all_types(args) - extra_defs = parse_extra_sdl(args) - extra_bindables: List[SchemaBindable] = [ - arg for arg in args if isinstance(arg, SchemaBindable) +) -> GraphQLSchema: + metadata = GraphQLMetadata() + type_defs: list[str] = find_type_defs(types) + types_list: list[SchemaType] = flatten_types(types, metadata) + + assert_types_unique(types_list, merge_roots) + assert_types_not_abstract(types_list) + + schema_bindables: list[Union[SchemaBindable, GraphQLModel]] = [] + for type_def in types_list: + if isinstance(type_def, SchemaBindable): + schema_bindables.append(type_def) + elif isinstance(type_def, type) and issubclass(type_def, (GraphQLType, Enum)): + schema_bindables.append(metadata.get_graphql_model(type_def)) + + schema_models: list[GraphQLModel] = [ + type_def for type_def in schema_bindables if isinstance(type_def, GraphQLModel) ] - type_defs: List[Type[DefinitionType]] = [] - for type_ in all_types: - if issubclass(type_, DefinitionType): - type_defs.append(type_) + models_document: Optional[DocumentNode] = None + type_defs_document: Optional[DocumentNode] = None - validate_no_missing_definitions(all_types, type_defs, extra_defs) + if schema_models: + models_document = DocumentNode( + definitions=tuple(schema_model.ast for schema_model in schema_models), + ) - schema = build_schema(type_defs, extra_defs, merge_roots) + if type_defs: + type_defs_document = parse("\n".join(type_defs)) - if extra_bindables: - for bindable in extra_bindables: - bindable.bind_to_schema(schema) + if models_document and type_defs_document: + document_node = concat_ast((models_document, type_defs_document)) + elif models_document: + document_node = models_document + elif type_defs_document: + document_node = type_defs_document + else: + raise ValueError( + "'make_executable_schema' was called without any GraphQL types." + ) - if extra_directives: - SchemaDirectiveVisitor.visit_schema_directives(schema, extra_directives) + if merge_roots: + document_node = merge_root_nodes(document_node) + + document_node = sort_schema_document(document_node) + schema = build_ast_schema(document_node) + + if directives: + SchemaDirectiveVisitor.visit_schema_directives(schema, directives) assert_valid_schema(schema) validate_schema_default_enum_values(schema) repair_schema_default_enum_values(schema) - add_directives_to_schema(schema, type_defs) + for schema_bindable in schema_bindables: + schema_bindable.bind_to_schema(schema) + + if convert_names_case: + convert_schema_names( + schema, + convert_names_case if callable(convert_names_case) else None, + ) return schema -def get_all_types( - args: Sequence[Union[Type[BaseType], SchemaBindable, str]] -) -> List[Type[BaseType]]: - all_types: List[Type[BaseType]] = [] - for arg in args: - if isinstance(arg, (str, SchemaBindable)): - continue # Skip args of unsupported types +def find_type_defs( + types: Union[ + tuple[Union[SchemaType, list[SchemaType]], ...], + list[SchemaType], + ], +) -> list[str]: + type_defs: list[str] = [] - for child_type in arg.__get_types__(): - if child_type not in all_types: - all_types.append(child_type) - return all_types + for type_def in types: + if isinstance(type_def, str): + type_defs.append(type_def) + elif isinstance(type_def, list): + type_defs += find_type_defs(type_def) + return type_defs -def parse_extra_sdl( - args: Sequence[Union[Type[BaseType], SchemaBindable, str]] -) -> List[TypeDefinitionNode]: - sdl_strings: List[str] = [cast(str, arg) for arg in args if isinstance(arg, str)] - if not sdl_strings: - return [] - extra_sdl = "\n\n".join(sdl_strings) - return cast( - List[TypeDefinitionNode], - list(parse(extra_sdl).definitions), +def flatten_types( + types: tuple[Union[SchemaType, list[SchemaType]], ...], + metadata: GraphQLMetadata, +) -> list[SchemaType]: + flat_schema_types_list: list[SchemaType] = flatten_schema_types( + types, metadata, dedupe=True ) + types_list: list[SchemaType] = [] + for type_def in flat_schema_types_list: + if isinstance(type_def, SchemaBindable): + types_list.append(type_def) -def validate_no_missing_definitions( - all_types: List[Type[BaseType]], - type_defs: List[Type[DefinitionType]], - extra_defs: List[TypeDefinitionNode], -): - deferred_names: List[str] = [] - for type_ in all_types: - if isinstance(type_, DeferredType): - deferred_names.append(type_.graphql_name) - - real_names = [type_.graphql_name for type_ in type_defs] - real_names += [definition.name.value for definition in extra_defs] - - missing_names = set(deferred_names) - set(real_names) - if missing_names: - raise ValueError( - "Following types are defined as deferred and are missing " - f"from schema: {', '.join(missing_names)}" - ) + elif isinstance(type_def, type) and issubclass(type_def, GraphQLType): + type_name = type_def.__name__ + if getattr(type_def, "__abstract__", None): + raise ValueError( + f"Type '{type_name}' is an abstract type and can't be used " + "for schema creation." + ) -def build_schema( - type_defs: List[Type[DefinitionType]], - extra_defs: List[TypeDefinitionNode], - merge_roots: bool = True, -) -> GraphQLSchema: - schema_definitions: List[ast.DocumentNode] = [] - if merge_roots: - schema_definitions.append(build_root_schema(type_defs, extra_defs)) - for type_ in type_defs: - if type_.graphql_name not in ROOT_TYPES or not merge_roots: - schema_definitions.append(parse(type_.__schema__)) - for extra_type_def in extra_defs: - if extra_type_def.name.value not in ROOT_TYPES or not merge_roots: - schema_definitions.append(DocumentNode(definitions=(extra_type_def,))) + types_list.append(type_def) - ast_document = concat_ast(schema_definitions) - schema = build_ast_schema(ast_document) + elif isinstance(type_def, type) and issubclass(type_def, Enum): + types_list.append(type_def) - for type_ in type_defs: - if issubclass(type_, BindableType): - type_.__bind_to_schema__(schema) + elif isinstance(type_def, list): + types_list += find_type_defs(type_def) - return schema + return types_list -RootTypeDef = Tuple[str, DocumentNode] +def flatten_schema_types( # noqa: C901 + types: Sequence[Union[SchemaType, list[SchemaType]]], + metadata: GraphQLMetadata, + dedupe: bool, +) -> list[SchemaType]: + flat_list: list[SchemaType] = [] + checked_types: list[type[GraphQLType]] = [] + for type_def in types: + if isinstance(type_def, str): + continue + if isinstance(type_def, list): + flat_list += flatten_schema_types(type_def, metadata, dedupe=False) + elif isinstance(type_def, SchemaBindable): + flat_list.append(type_def) + elif isinstance(type_def, type) and issubclass(type_def, Enum): + flat_list.append(type_def) + elif isinstance(type_def, type) and issubclass(type_def, GraphQLType): + add_graphql_type_to_flat_list(flat_list, checked_types, type_def, metadata) + elif get_graphql_type_name(type_def): + flat_list.append(type_def) -def build_root_schema( - type_defs: List[Type[DefinitionType]], - extra_defs: List[TypeDefinitionNode], -) -> DocumentNode: - root_types: Dict[str, List[RootTypeDef]] = { - "Query": [], - "Mutation": [], - "Subscription": [], - } + if not dedupe: + return flat_list - for type_def in type_defs: - if type_def.graphql_name in root_types: - root_types[type_def.graphql_name].append( - (type_def.__name__, parse(type_def.__schema__)) - ) + unique_list: list[SchemaType] = [] + for type_def in flat_list: + if type_def not in unique_list: + unique_list.append(type_def) - for extra_type_def in extra_defs: - if extra_type_def.name.value in root_types: - root_types[extra_type_def.name.value].append( - ("extra_sdl", DocumentNode(definitions=(extra_type_def,))) - ) + return unique_list - schema: List[DocumentNode] = [] - for root_name, root_type_defs in root_types.items(): - if len(root_type_defs) == 1: - schema.append(root_type_defs[0][1]) - elif root_type_defs: - schema.append(merge_root_types(root_name, root_type_defs)) - return concat_ast(schema) +def add_graphql_type_to_flat_list( + flat_list: list[SchemaType], + checked_types: list[type[GraphQLType]], + type_def: type[GraphQLType], + metadata: GraphQLMetadata, +) -> None: + if type_def in checked_types: + return + checked_types.append(type_def) -def merge_root_types(root_name: str, type_defs: List[RootTypeDef]) -> DocumentNode: - interfaces: List[NamedTypeNode] = [] - directives: List[ConstDirectiveNode] = [] - fields: Dict[str, Tuple[str, FieldDefinitionNode]] = {} + for child_type in type_def.__get_graphql_types__(metadata): + flat_list.append(child_type) - for type_source, type_def in type_defs: - type_definition = cast(ObjectTypeDefinitionNode, type_def.definitions[0]) - interfaces.extend(type_definition.interfaces) - directives.extend(type_definition.directives) + if issubclass(child_type, GraphQLType): + add_graphql_type_to_flat_list( + flat_list, checked_types, child_type, metadata + ) - for field_def in type_definition.fields: - field_name = field_def.name.value - if field_name in fields: - other_type_source = fields[field_name][0] - raise ValueError( - f"Multiple {root_name} types are defining same field " - f"'{field_name}': {other_type_source}, {type_source}" - ) - fields[field_name] = (type_source, field_def) +def get_graphql_type_name(type_def: SchemaType) -> Optional[str]: + if isinstance(type_def, SchemaBindable): + return None - merged_definition = ast.ObjectTypeDefinitionNode() - merged_definition.name = ast.NameNode() - merged_definition.name.value = root_name - merged_definition.interfaces = tuple(interfaces) - merged_definition.directives = tuple(directives) - merged_definition.fields = tuple( - fields[field_name][1] for field_name in sorted(fields) - ) + if isinstance(type_def, type) and issubclass(type_def, Enum): + return type_def.__name__ - merged_document = DocumentNode() - merged_document.definitions = (merged_definition,) + if isinstance(type_def, type) and issubclass(type_def, GraphQLType): + return type_def.__get_graphql_name__() - return merged_document + return None -def add_directives_to_schema( - schema: GraphQLSchema, type_defs: List[Type[DefinitionType]] -): - directives: Dict[str, Type[SchemaDirectiveVisitor]] = {} +def assert_types_unique(type_defs: list[SchemaType], merge_roots: bool): + types_names: dict[str, Any] = {} for type_def in type_defs: - visitor = getattr(type_def, "__visitor__", None) - if visitor and issubclass(visitor, SchemaDirectiveVisitor): - directives[type_def.graphql_name] = visitor + type_name = get_graphql_type_name(type_def) + if not type_name: + continue + + if merge_roots and type_name in ROOTS_NAMES: + continue + + if type_name in types_names: + type_def_name = getattr(type_def, "__name__") or type_def + raise ValueError( + f"Types '{type_def_name}' and '{types_names[type_name]}' both define " + f"GraphQL type with name '{type_name}'." + ) - if directives: - SchemaDirectiveVisitor.visit_schema_directives(schema, directives) + types_names[type_name] = type_def -def repair_default_enum_values(schema, types_list: List[Type[DefinitionType]]) -> None: - for type_ in types_list: - if issubclass(type_, EnumType): - type_.__bind_to_default_values__(schema) +def assert_types_not_abstract(type_defs: list[SchemaType]): + for type_def in type_defs: + if isinstance(type_def, SchemaBindable): + continue + + if ( + isinstance(type_def, type) + and issubclass(type_def, GraphQLType) + and getattr(type_def, "__abstract__", None) + ): + raise ValueError( + f"Type '{type_def.__name__}' is an abstract type and can't be used " + "for schema creation." + ) diff --git a/ariadne_graphql_modules/idtype.py b/ariadne_graphql_modules/idtype.py new file mode 100644 index 0000000..0b2af7c --- /dev/null +++ b/ariadne_graphql_modules/idtype.py @@ -0,0 +1,23 @@ +from typing import Any, Union + + +class GraphQLID: + __slots__ = ("value",) + + value: str + + def __init__(self, value: Union[int, str]): + self.value = str(value) + + def __eq__(self, value: Any) -> bool: + if isinstance(value, (str, int)): + return self.value == str(value) + if isinstance(value, GraphQLID): + return self.value == value.value + return False + + def __int__(self) -> int: + return int(self.value) + + def __str__(self) -> str: + return self.value diff --git a/ariadne_graphql_modules/input_type/__init__.py b/ariadne_graphql_modules/input_type/__init__.py new file mode 100644 index 0000000..2f8c31d --- /dev/null +++ b/ariadne_graphql_modules/input_type/__init__.py @@ -0,0 +1,7 @@ +from ariadne_graphql_modules.input_type.graphql_type import GraphQLInput +from ariadne_graphql_modules.input_type.models import GraphQLInputModel + +__all__ = [ + "GraphQLInput", + "GraphQLInputModel", +] diff --git a/ariadne_graphql_modules/input_type/graphql_field.py b/ariadne_graphql_modules/input_type/graphql_field.py new file mode 100644 index 0000000..4cbbff7 --- /dev/null +++ b/ariadne_graphql_modules/input_type/graphql_field.py @@ -0,0 +1,21 @@ +from typing import Any, Optional + + +class GraphQLInputField: + name: Optional[str] + description: Optional[str] + graphql_type: Optional[Any] + default_value: Optional[Any] + + def __init__( + self, + *, + name: Optional[str] = None, + description: Optional[str] = None, + graphql_type: Optional[Any] = None, + default_value: Optional[Any] = None, + ): + self.name = name + self.description = description + self.graphql_type = graphql_type + self.default_value = default_value diff --git a/ariadne_graphql_modules/input_type/graphql_type.py b/ariadne_graphql_modules/input_type/graphql_type.py new file mode 100644 index 0000000..4057898 --- /dev/null +++ b/ariadne_graphql_modules/input_type/graphql_type.py @@ -0,0 +1,243 @@ +from collections.abc import Iterable +from copy import deepcopy +from enum import Enum +from typing import Any, Optional, Union, cast + +from graphql import InputObjectTypeDefinitionNode, InputValueDefinitionNode, NameNode + +from ariadne_graphql_modules.base import GraphQLMetadata, GraphQLType +from ariadne_graphql_modules.base_graphql_model import GraphQLModel +from ariadne_graphql_modules.convert_name import ( + convert_graphql_name_to_python, + convert_python_name_to_graphql, +) +from ariadne_graphql_modules.description import get_description_node +from ariadne_graphql_modules.input_type.graphql_field import GraphQLInputField +from ariadne_graphql_modules.input_type.models import GraphQLInputModel +from ariadne_graphql_modules.input_type.validators import ( + validate_input_type, + validate_input_type_with_schema, +) +from ariadne_graphql_modules.typing import get_graphql_type, get_type_node +from ariadne_graphql_modules.utils import parse_definition +from ariadne_graphql_modules.value import get_value_node + + +class GraphQLInput(GraphQLType): + __kwargs__: dict[str, Any] + __schema__: Optional[str] + __out_names__: Optional[dict[str, str]] = None + + def __init__(self, **kwargs: Any): + for kwarg in kwargs: + if kwarg not in self.__kwargs__: + valid_kwargs = "', '".join(self.__kwargs__) + raise TypeError( + f"{type(self).__name__}.__init__() got an unexpected " + f"keyword argument '{kwarg}'. " + f"Valid keyword arguments: '{valid_kwargs}'" + ) + + for kwarg, default in self.__kwargs__.items(): + setattr(self, kwarg, kwargs.get(kwarg, deepcopy(default))) + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + if cls.__dict__.get("__abstract__"): + return + + cls.__abstract__ = False + + if cls.__dict__.get("__schema__"): + cls.__kwargs__ = validate_input_type_with_schema(cls) + else: + cls.__kwargs__ = validate_input_type(cls) + + @classmethod + def create_from_data(cls, data: dict[str, Any]) -> "GraphQLInput": + return cls(**data) + + @classmethod + def __get_graphql_model__(cls, metadata: GraphQLMetadata) -> "GraphQLModel": + name = cls.__get_graphql_name__() + metadata.set_graphql_name(cls, name) + + if getattr(cls, "__schema__", None): + return cls.__get_graphql_model_with_schema__() + + return cls.__get_graphql_model_without_schema__(metadata, name) + + @classmethod + def __get_graphql_model_with_schema__(cls) -> "GraphQLInputModel": + definition = cast( + InputObjectTypeDefinitionNode, + parse_definition(InputObjectTypeDefinitionNode, cls.__schema__), + ) + + out_names: dict[str, str] = getattr(cls, "__out_names__") or {} + + fields: list[InputValueDefinitionNode] = [] + for field in definition.fields: + fields.append( + InputValueDefinitionNode( + name=field.name, + description=field.description, + directives=field.directives, + type=field.type, + default_value=field.default_value, + ) + ) + + field_name = field.name.value + if field_name not in out_names: + out_names[field_name] = convert_graphql_name_to_python(field_name) + + return GraphQLInputModel( + name=definition.name.value, + ast_type=InputObjectTypeDefinitionNode, + ast=InputObjectTypeDefinitionNode( + name=NameNode(value=definition.name.value), + fields=tuple(fields), + ), + out_type=cls.create_from_data, + out_names=out_names, + ) + + @classmethod + def __get_graphql_model_without_schema__( + cls, metadata: GraphQLMetadata, name: str + ) -> "GraphQLInputModel": + type_hints = cls.__annotations__ # pylint: disable=no-member + fields_instances: dict[str, GraphQLInputField] = { + attr_name: getattr(cls, attr_name) + for attr_name in dir(cls) + if isinstance(getattr(cls, attr_name), GraphQLInputField) + } + + fields_ast: list[InputValueDefinitionNode] = [] + out_names: dict[str, str] = {} + + for hint_name, hint_type in type_hints.items(): + if hint_name.startswith("__"): + continue + + cls_attr = getattr(cls, hint_name, None) + default_name = convert_python_name_to_graphql(hint_name) + + if isinstance(cls_attr, GraphQLInputField): + fields_ast.append( + get_field_node_from_type_hint( + cls, + metadata, + cls_attr.name or default_name, + cls_attr.graphql_type or hint_type, + cls_attr.description, + cls_attr.default_value, + ) + ) + out_names[cls_attr.name or default_name] = hint_name + fields_instances.pop(hint_name, None) + elif not callable(cls_attr): + fields_ast.append( + get_field_node_from_type_hint( + cls, + metadata, + default_name, + hint_type, + None, + cls_attr, + ) + ) + out_names[default_name] = hint_name + + for attr_name, field_instance in fields_instances.items(): + default_name = convert_python_name_to_graphql(attr_name) + fields_ast.append( + get_field_node_from_type_hint( + cls, + metadata, + field_instance.name or default_name, + field_instance.graphql_type, + field_instance.description, + field_instance.default_value, + ) + ) + out_names[field_instance.name or default_name] = attr_name + + return GraphQLInputModel( + name=name, + ast_type=InputObjectTypeDefinitionNode, + ast=InputObjectTypeDefinitionNode( + name=NameNode(value=name), + description=get_description_node( + getattr(cls, "__description__", None), + ), + fields=tuple(fields_ast), + ), + out_type=cls.create_from_data, + out_names=out_names, + ) + + @classmethod + def __get_graphql_types__( + cls, _: "GraphQLMetadata" + ) -> Iterable[Union[type["GraphQLType"], type[Enum]]]: + """Returns iterable with GraphQL types associated with this type""" + types: list[Union[type[GraphQLType], type[Enum]]] = [cls] + + for attr_name in dir(cls): + cls_attr = getattr(cls, attr_name) + if isinstance(cls_attr, GraphQLInputField): + if cls_attr.graphql_type: + field_graphql_type = get_graphql_type(cls_attr.graphql_type) + if field_graphql_type and field_graphql_type not in types: + types.append(field_graphql_type) + + type_hints = cls.__annotations__ # pylint: disable=no-member + for hint_name, hint_type in type_hints.items(): + if hint_name.startswith("__"): + continue + + hint_graphql_type = get_graphql_type(hint_type) + if hint_graphql_type and hint_graphql_type not in types: + types.append(hint_graphql_type) + + return types + + @staticmethod + def field( + *, + name: Optional[str] = None, + graphql_type: Optional[Any] = None, + description: Optional[str] = None, + default_value: Optional[Any] = None, + ) -> Any: + """Shortcut for GraphQLInputField()""" + return GraphQLInputField( + name=name, + graphql_type=graphql_type, + description=description, + default_value=default_value, + ) + + +def get_field_node_from_type_hint( + parent_type: type[GraphQLInput], + metadata: GraphQLMetadata, + field_name: str, + field_type: Any, + field_description: Optional[str] = None, + field_default_value: Optional[Any] = None, +) -> InputValueDefinitionNode: + if field_default_value is not None: + default_value = get_value_node(field_default_value) + else: + default_value = None + + return InputValueDefinitionNode( + description=get_description_node(field_description), + name=NameNode(value=field_name), + type=get_type_node(metadata, field_type, parent_type), + default_value=default_value, + ) diff --git a/ariadne_graphql_modules/input_type/models.py b/ariadne_graphql_modules/input_type/models.py new file mode 100644 index 0000000..bcffa56 --- /dev/null +++ b/ariadne_graphql_modules/input_type/models.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from typing import Any + +from ariadne import InputType as InputTypeBindable +from graphql import GraphQLSchema + +from ariadne_graphql_modules.base_graphql_model import GraphQLModel + + +@dataclass(frozen=True) +class GraphQLInputModel(GraphQLModel): + out_type: Any + out_names: dict[str, str] + + def bind_to_schema(self, schema: GraphQLSchema): + bindable = InputTypeBindable(self.name, self.out_type, self.out_names) + bindable.bind_to_schema(schema) diff --git a/ariadne_graphql_modules/input_type/validators.py b/ariadne_graphql_modules/input_type/validators.py new file mode 100644 index 0000000..099c475 --- /dev/null +++ b/ariadne_graphql_modules/input_type/validators.py @@ -0,0 +1,127 @@ +from typing import TYPE_CHECKING, Any, cast + +from graphql import InputObjectTypeDefinitionNode + +from ariadne_graphql_modules.convert_name import convert_graphql_name_to_python +from ariadne_graphql_modules.input_type.graphql_field import GraphQLInputField +from ariadne_graphql_modules.utils import parse_definition +from ariadne_graphql_modules.validators import validate_description, validate_name +from ariadne_graphql_modules.value import get_value_from_node, get_value_node + +if TYPE_CHECKING: + from ariadne_graphql_modules.input_type.graphql_type import GraphQLInput + + +def validate_input_type_with_schema(cls: type["GraphQLInput"]) -> dict[str, Any]: + definition = cast( + InputObjectTypeDefinitionNode, + parse_definition(InputObjectTypeDefinitionNode, cls.__schema__), + ) + + if not isinstance(definition, InputObjectTypeDefinitionNode): + raise ValueError( + f"Class '{cls.__name__}' defines '__schema__' attribute " + "with declaration for an invalid GraphQL type. " + f"('{definition.__class__.__name__}' != " + f"'{InputObjectTypeDefinitionNode.__name__}')" + ) + + validate_name(cls, definition) + validate_description(cls, definition) + + if not definition.fields: + raise ValueError( + f"Class '{cls.__name__}' defines '__schema__' attribute " + "with declaration for an input type without any fields. " + ) + + fields_names: list[str] = [field.name.value for field in definition.fields] + used_out_names: list[str] = [] + + out_names: dict[str, str] = getattr(cls, "__out_names__", {}) or {} + for field_name, out_name in out_names.items(): + if field_name not in fields_names: + raise ValueError( + f"Class '{cls.__name__}' defines an outname for '{field_name}' " + "field in it's '__out_names__' attribute which is not defined " + "in '__schema__'." + ) + + if out_name in used_out_names: + raise ValueError( + f"Class '{cls.__name__}' defines multiple fields with an outname " + f"'{out_name}' in it's '__out_names__' attribute." + ) + + used_out_names.append(out_name) + + return get_input_type_with_schema_kwargs(cls, definition, out_names) + + +def get_input_type_with_schema_kwargs( + cls: type["GraphQLInput"], + definition: InputObjectTypeDefinitionNode, + out_names: dict[str, str], +) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + for field in definition.fields: + try: + python_name = out_names[field.name.value] + except KeyError: + python_name = convert_graphql_name_to_python(field.name.value) + + attr_default_value = getattr(cls, python_name, None) + if attr_default_value is not None and not callable(attr_default_value): + default_value = attr_default_value + elif field.default_value: + default_value = get_value_from_node(field.default_value) + else: + default_value = None + + kwargs[python_name] = default_value + + return kwargs + + +def validate_input_type(cls: type["GraphQLInput"]) -> dict[str, Any]: + if cls.__out_names__: + raise ValueError( + f"Class '{cls.__name__}' defines '__out_names__' attribute. " + "This is not supported for types not defining '__schema__'." + ) + + return get_input_type_kwargs(cls) + + +def get_input_type_kwargs(cls: type["GraphQLInput"]) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + for attr_name in cls.__annotations__: + if attr_name.startswith("__"): + continue + + attr_value = getattr(cls, attr_name, None) + if isinstance(attr_value, GraphQLInputField): + validate_field_default_value(cls, attr_name, attr_value.default_value) + kwargs[attr_name] = attr_value.default_value + elif not callable(attr_value): + validate_field_default_value(cls, attr_name, attr_value) + kwargs[attr_name] = attr_value + + return kwargs + + +def validate_field_default_value( + cls: type["GraphQLInput"], field_name: str, default_value: Any +): + if default_value is None: + return + + try: + get_value_node(default_value) + except TypeError as e: + raise TypeError( + f"Class '{cls.__name__}' defines default value " + f"for the '{field_name}' field that can't be " + "represented in GraphQL schema." + ) from e diff --git a/ariadne_graphql_modules/interface_type/__init__.py b/ariadne_graphql_modules/interface_type/__init__.py new file mode 100644 index 0000000..f6ddb46 --- /dev/null +++ b/ariadne_graphql_modules/interface_type/__init__.py @@ -0,0 +1,7 @@ +from ariadne_graphql_modules.interface_type.graphql_type import GraphQLInterface +from ariadne_graphql_modules.interface_type.models import GraphQLInterfaceModel + +__all__ = [ + "GraphQLInterface", + "GraphQLInterfaceModel", +] diff --git a/ariadne_graphql_modules/interface_type/graphql_type.py b/ariadne_graphql_modules/interface_type/graphql_type.py new file mode 100644 index 0000000..a1da0e5 --- /dev/null +++ b/ariadne_graphql_modules/interface_type/graphql_type.py @@ -0,0 +1,145 @@ +from typing import Any, Optional, cast + +from ariadne.types import Resolver +from graphql import ( + FieldDefinitionNode, + InterfaceTypeDefinitionNode, + NamedTypeNode, + NameNode, +) + +from ariadne_graphql_modules.base import GraphQLMetadata +from ariadne_graphql_modules.base_graphql_model import GraphQLModel +from ariadne_graphql_modules.base_object_type import ( + GraphQLBaseObject, + GraphQLFieldData, + GraphQLObjectData, + validate_object_type_with_schema, + validate_object_type_without_schema, +) +from ariadne_graphql_modules.base_object_type.graphql_field import GraphQLClassData +from ariadne_graphql_modules.description import get_description_node +from ariadne_graphql_modules.interface_type.models import GraphQLInterfaceModel +from ariadne_graphql_modules.object_type import GraphQLObject +from ariadne_graphql_modules.types import GraphQLClassType +from ariadne_graphql_modules.utils import parse_definition + + +class GraphQLInterface(GraphQLBaseObject): + __graphql_type__ = GraphQLClassType.INTERFACE + __abstract__ = True + __graphql_name__: Optional[str] = None + __description__: Optional[str] = None + + def __init_subclass__(cls) -> None: + if cls.__dict__.get("__abstract__"): + return + + cls.__abstract__ = False + + if cls.__dict__.get("__schema__"): + cls.__kwargs__ = validate_object_type_with_schema( + cls, InterfaceTypeDefinitionNode + ) + else: + cls.__kwargs__ = validate_object_type_without_schema(cls) + + @classmethod + def __get_graphql_model_with_schema__(cls) -> "GraphQLModel": + definition = cast( + InterfaceTypeDefinitionNode, + parse_definition(InterfaceTypeDefinitionNode, cls.__schema__), + ) + + resolvers: dict[str, Resolver] = {} + fields: tuple[FieldDefinitionNode, ...] = tuple() + fields, resolvers = cls._create_fields_and_resolvers_with_schema( + definition.fields + ) + + return GraphQLInterfaceModel( + name=definition.name.value, + ast_type=InterfaceTypeDefinitionNode, + ast=InterfaceTypeDefinitionNode( + name=NameNode(value=definition.name.value), + fields=tuple(fields), + interfaces=definition.interfaces, + ), + resolve_type=cls.resolve_type, + resolvers=resolvers, + aliases=getattr(cls, "__aliases__", {}), + out_names={}, + ) + + @classmethod + def __get_graphql_model_without_schema__( + cls, metadata: GraphQLMetadata, name: str + ) -> "GraphQLModel": + type_data = cls.get_graphql_object_data(metadata) + type_aliases = getattr(cls, "__aliases__", None) or {} + + object_model_data = GraphQLClassData() + cls._process_graphql_fields( + metadata, type_data, type_aliases, object_model_data + ) + + return GraphQLInterfaceModel( + name=name, + ast_type=InterfaceTypeDefinitionNode, + ast=InterfaceTypeDefinitionNode( + name=NameNode(value=name), + description=get_description_node( + getattr(cls, "__description__", None), + ), + fields=tuple(object_model_data.fields_ast.values()), + interfaces=tuple(type_data.interfaces), + ), + resolve_type=cls.resolve_type, + resolvers=object_model_data.resolvers, + aliases=object_model_data.aliases, + out_names=object_model_data.out_names, + ) + + @staticmethod + def resolve_type(obj: Any, *_) -> str: + if isinstance(obj, GraphQLObject): + return obj.__get_graphql_name__() + + raise ValueError( + f"Cannot resolve GraphQL type {obj} " + "for object of type '{type(obj).__name__}'." + ) + + @classmethod + def _collect_inherited_objects(cls): + return [ + inherited_obj + for inherited_obj in cls.__mro__[1:] + if getattr(inherited_obj, "__graphql_type__", None) + == GraphQLClassType.INTERFACE + and not getattr(inherited_obj, "__abstract__", True) + ] + + @classmethod + def create_graphql_object_data_without_schema(cls) -> GraphQLObjectData: + fields_data = GraphQLFieldData() + inherited_objects = list(reversed(cls._collect_inherited_objects())) + + for inherited_obj in inherited_objects: + fields_data.type_hints.update(inherited_obj.__annotations__) + fields_data.aliases.update(getattr(inherited_obj, "__aliases__", {})) + + cls._process_type_hints_and_aliases(fields_data) + for inherited_obj in inherited_objects: + cls._process_class_attributes(inherited_obj, fields_data) + cls._process_class_attributes(cls, fields_data) + + return GraphQLObjectData( + fields=cls._build_fields(fields_data=fields_data), + interfaces=[ + NamedTypeNode(name=NameNode(value=interface.__name__)) + for interface in inherited_objects + if getattr(interface, "__graphql_type__", None) + == GraphQLClassType.INTERFACE + ], + ) diff --git a/ariadne_graphql_modules/interface_type/models.py b/ariadne_graphql_modules/interface_type/models.py new file mode 100644 index 0000000..41f0270 --- /dev/null +++ b/ariadne_graphql_modules/interface_type/models.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass +from typing import cast + +from ariadne import InterfaceType +from ariadne.types import Resolver +from graphql import GraphQLField, GraphQLObjectType, GraphQLSchema, GraphQLTypeResolver + +from ariadne_graphql_modules.base_graphql_model import GraphQLModel + + +@dataclass(frozen=True) +class GraphQLInterfaceModel(GraphQLModel): + resolvers: dict[str, Resolver] + resolve_type: GraphQLTypeResolver + out_names: dict[str, dict[str, str]] + aliases: dict[str, str] + + def bind_to_schema(self, schema: GraphQLSchema): + bindable = InterfaceType(self.name, self.resolve_type) + for field, resolver in self.resolvers.items(): + bindable.set_field(field, resolver) + for alias, target in self.aliases.items(): + bindable.set_alias(alias, target) + + bindable.bind_to_schema(schema) + + graphql_type = cast(GraphQLObjectType, schema.get_type(self.name)) + for field_name, field_out_names in self.out_names.items(): + graphql_field = cast(GraphQLField, graphql_type.fields[field_name]) + for arg_name, out_name in field_out_names.items(): + graphql_field.args[arg_name].out_name = out_name diff --git a/ariadne_graphql_modules/object_type/__init__.py b/ariadne_graphql_modules/object_type/__init__.py new file mode 100644 index 0000000..0752509 --- /dev/null +++ b/ariadne_graphql_modules/object_type/__init__.py @@ -0,0 +1,31 @@ +from ariadne_graphql_modules.base_object_type.graphql_field import ( + GraphQLObjectFieldArg, + GraphQLObjectResolver, + GraphQLObjectSource, + object_field, + object_subscriber, +) +from ariadne_graphql_modules.base_object_type.utils import ( + get_field_args_from_resolver, + get_field_args_from_subscriber, + get_field_args_out_names, + get_field_node_from_obj_field, + update_field_args_options, +) +from ariadne_graphql_modules.object_type.graphql_type import GraphQLObject +from ariadne_graphql_modules.object_type.models import GraphQLObjectModel + +__all__ = [ + "GraphQLObject", + "object_field", + "GraphQLObjectModel", + "get_field_args_from_resolver", + "get_field_args_out_names", + "get_field_node_from_obj_field", + "update_field_args_options", + "GraphQLObjectResolver", + "GraphQLObjectSource", + "object_subscriber", + "get_field_args_from_subscriber", + "GraphQLObjectFieldArg", +] diff --git a/ariadne_graphql_modules/object_type/graphql_type.py b/ariadne_graphql_modules/object_type/graphql_type.py new file mode 100644 index 0000000..4bffd68 --- /dev/null +++ b/ariadne_graphql_modules/object_type/graphql_type.py @@ -0,0 +1,140 @@ +from typing import ( + Optional, + cast, +) + +from ariadne.types import Resolver +from graphql import ( + FieldDefinitionNode, + NamedTypeNode, + NameNode, + ObjectTypeDefinitionNode, +) + +from ariadne_graphql_modules.base import GraphQLMetadata +from ariadne_graphql_modules.base_graphql_model import GraphQLModel +from ariadne_graphql_modules.base_object_type import ( + GraphQLBaseObject, + GraphQLFieldData, + GraphQLObjectData, +) +from ariadne_graphql_modules.base_object_type.graphql_field import GraphQLClassData +from ariadne_graphql_modules.base_object_type.validators import ( + validate_object_type_with_schema, + validate_object_type_without_schema, +) +from ariadne_graphql_modules.description import get_description_node +from ariadne_graphql_modules.object_type.models import GraphQLObjectModel +from ariadne_graphql_modules.types import GraphQLClassType +from ariadne_graphql_modules.utils import parse_definition + + +class GraphQLObject(GraphQLBaseObject): + __graphql_type__ = GraphQLClassType.OBJECT + __abstract__ = True + __description__: Optional[str] = None + __schema__: Optional[str] = None + __graphql_name__: Optional[str] = None + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + if cls.__dict__.get("__abstract__"): + return + + cls.__abstract__ = False + + if cls.__dict__.get("__schema__"): + cls.__kwargs__ = validate_object_type_with_schema( + cls, ObjectTypeDefinitionNode + ) + else: + cls.__kwargs__ = validate_object_type_without_schema(cls) + + @classmethod + def __get_graphql_model_with_schema__(cls) -> "GraphQLModel": + definition = cast( + ObjectTypeDefinitionNode, + parse_definition(ObjectTypeDefinitionNode, cls.__schema__), + ) + + resolvers: dict[str, Resolver] = {} + fields: tuple[FieldDefinitionNode, ...] = tuple() + fields, resolvers = cls._create_fields_and_resolvers_with_schema( + definition.fields + ) + + return GraphQLObjectModel( + name=definition.name.value, + ast_type=ObjectTypeDefinitionNode, + ast=ObjectTypeDefinitionNode( + name=NameNode(value=definition.name.value), + fields=tuple(fields), + interfaces=definition.interfaces, + ), + resolvers=resolvers, + aliases=getattr(cls, "__aliases__", {}), + out_names={}, + ) + + @classmethod + def __get_graphql_model_without_schema__( + cls, metadata: GraphQLMetadata, name: str + ) -> "GraphQLModel": + type_data = cls.get_graphql_object_data(metadata) + type_aliases = getattr(cls, "__aliases__", {}) + + object_model_data = GraphQLClassData() + cls._process_graphql_fields( + metadata, type_data, type_aliases, object_model_data + ) + + return GraphQLObjectModel( + name=name, + ast_type=ObjectTypeDefinitionNode, + ast=ObjectTypeDefinitionNode( + name=NameNode(value=name), + description=get_description_node( + getattr(cls, "__description__", None), + ), + fields=tuple(object_model_data.fields_ast.values()), + interfaces=tuple(type_data.interfaces), + ), + resolvers=object_model_data.resolvers, + aliases=object_model_data.aliases, + out_names=object_model_data.out_names, + ) + + @classmethod + def _collect_inherited_objects(cls): + return [ + inherited_obj + for inherited_obj in cls.__mro__[1:] + if getattr(inherited_obj, "__graphql_type__", None) + in (GraphQLClassType.INTERFACE, GraphQLClassType.OBJECT) + and not getattr(inherited_obj, "__abstract__", True) + ] + + @classmethod + def create_graphql_object_data_without_schema(cls) -> GraphQLObjectData: + fields_data = GraphQLFieldData() + inherited_objects = list(reversed(cls._collect_inherited_objects())) + + for inherited_obj in inherited_objects: + fields_data.type_hints.update(inherited_obj.__annotations__) + fields_data.aliases.update(getattr(inherited_obj, "__aliases__", {})) + + cls._process_type_hints_and_aliases(fields_data) + + for inherited_obj in inherited_objects: + cls._process_class_attributes(inherited_obj, fields_data) + cls._process_class_attributes(cls, fields_data) + return GraphQLObjectData( + fields=cls._build_fields(fields_data=fields_data), + interfaces=[ + NamedTypeNode(name=NameNode(value=interface.__name__)) + for interface in inherited_objects + if getattr(interface, "__graphql_type__", None) + == GraphQLClassType.INTERFACE + ], + ) diff --git a/ariadne_graphql_modules/object_type/models.py b/ariadne_graphql_modules/object_type/models.py new file mode 100644 index 0000000..b58052e --- /dev/null +++ b/ariadne_graphql_modules/object_type/models.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass +from typing import cast + +from ariadne import ObjectType as ObjectTypeBindable +from ariadne.types import Resolver +from graphql import GraphQLField, GraphQLObjectType, GraphQLSchema + +from ariadne_graphql_modules.base_graphql_model import GraphQLModel + + +@dataclass(frozen=True) +class GraphQLObjectModel(GraphQLModel): + resolvers: dict[str, Resolver] + aliases: dict[str, str] + out_names: dict[str, dict[str, str]] + + def bind_to_schema(self, schema: GraphQLSchema): + bindable = ObjectTypeBindable(self.name) + + for field, resolver in self.resolvers.items(): + bindable.set_field(field, resolver) + for alias, target in self.aliases.items(): + bindable.set_alias(alias, target) + + bindable.bind_to_schema(schema) + + graphql_type = cast(GraphQLObjectType, schema.get_type(self.name)) + for field_name, field_out_names in self.out_names.items(): + graphql_field = cast(GraphQLField, graphql_type.fields[field_name]) + for arg_name, out_name in field_out_names.items(): + graphql_field.args[arg_name].out_name = out_name diff --git a/ariadne_graphql_modules/roots.py b/ariadne_graphql_modules/roots.py new file mode 100644 index 0000000..c4c035f --- /dev/null +++ b/ariadne_graphql_modules/roots.py @@ -0,0 +1,82 @@ +from typing import Optional, cast + +from graphql import ( + ConstDirectiveNode, + DefinitionNode, + DocumentNode, + FieldDefinitionNode, + NamedTypeNode, + ObjectTypeDefinitionNode, + StringValueNode, + TypeDefinitionNode, +) + +DefinitionsList = list[DefinitionNode] + +ROOTS_NAMES = ("Query", "Mutation", "Subscription") + + +def merge_root_nodes(document_node: DocumentNode) -> DocumentNode: + roots_definitions: dict[str, list[TypeDefinitionNode]] = { + root: [] for root in ROOTS_NAMES + } + final_definitions: DefinitionsList = [] + + for node in document_node.definitions: + if ( + isinstance(node, TypeDefinitionNode) + and node.name.value in roots_definitions + ): + roots_definitions[node.name.value].append(node) + else: + final_definitions.append(node) + + for definitions_to_merge in roots_definitions.values(): + if len(definitions_to_merge) > 1: + final_definitions.append(merge_nodes(definitions_to_merge)) + elif definitions_to_merge: + final_definitions.extend(definitions_to_merge) + + return DocumentNode(definitions=tuple(final_definitions)) + + +def merge_nodes(nodes: list[TypeDefinitionNode]) -> ObjectTypeDefinitionNode: + root_name = nodes[0].name.value + + description: Optional[StringValueNode] = None + interfaces: list[NamedTypeNode] = [] + directives: list[ConstDirectiveNode] = [] + fields: dict[str, FieldDefinitionNode] = {} + + for node in nodes: + node = cast(ObjectTypeDefinitionNode, node) + if node.description: + if description: + raise ValueError( + f"Multiple {root_name} types are defining descriptions." + ) + + description = node.description + + if node.interfaces: + interfaces.extend(node.interfaces) + if node.directives: + directives.extend(node.directives) + + for field_node in node.fields: + field_name = field_node.name.value + if field_name in fields: + other_type_source = fields[field_name] + raise ValueError( + f"Multiple {root_name} types are defining same field " + f"'{field_name}': {other_type_source}, {field_node}" + ) + fields[field_name] = field_node + + return ObjectTypeDefinitionNode( + name=nodes[0].name, + description=description, + interfaces=tuple(interfaces), + directives=tuple(directives), + fields=tuple(fields[field_name] for field_name in sorted(fields)), + ) diff --git a/ariadne_graphql_modules/scalar_type/__init__.py b/ariadne_graphql_modules/scalar_type/__init__.py new file mode 100644 index 0000000..29a7747 --- /dev/null +++ b/ariadne_graphql_modules/scalar_type/__init__.py @@ -0,0 +1,7 @@ +from ariadne_graphql_modules.scalar_type.graphql_type import GraphQLScalar +from ariadne_graphql_modules.scalar_type.models import GraphQLScalarModel + +__all__ = [ + "GraphQLScalar", + "GraphQLScalarModel", +] diff --git a/ariadne_graphql_modules/scalar_type/graphql_type.py b/ariadne_graphql_modules/scalar_type/graphql_type.py new file mode 100644 index 0000000..dc8d417 --- /dev/null +++ b/ariadne_graphql_modules/scalar_type/graphql_type.py @@ -0,0 +1,101 @@ +from typing import Any, Generic, Optional, TypeVar, cast + +from graphql import ( + NameNode, + ScalarTypeDefinitionNode, + ValueNode, + value_from_ast_untyped, +) + +from ariadne_graphql_modules.base import GraphQLMetadata, GraphQLType +from ariadne_graphql_modules.base_graphql_model import GraphQLModel +from ariadne_graphql_modules.description import get_description_node +from ariadne_graphql_modules.scalar_type.models import GraphQLScalarModel +from ariadne_graphql_modules.scalar_type.validators import ( + validate_scalar_type_with_schema, +) +from ariadne_graphql_modules.utils import parse_definition + +T = TypeVar("T") + + +class GraphQLScalar(GraphQLType, Generic[T]): + __abstract__: bool = True + __schema__: Optional[str] + + wrapped_value: T + + def __init__(self, value: T): + self.wrapped_value = value + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + if cls.__dict__.get("__abstract__"): + return + + cls.__abstract__ = False + + if cls.__dict__.get("__schema__"): + validate_scalar_type_with_schema(cls) + + @classmethod + def __get_graphql_model__(cls, metadata: GraphQLMetadata) -> "GraphQLModel": + name = cls.__get_graphql_name__() + + if getattr(cls, "__schema__", None): + return cls.__get_graphql_model_with_schema__() + + return cls.__get_graphql_model_without_schema__(name) + + @classmethod + def __get_graphql_model_with_schema__(cls) -> "GraphQLModel": + definition = cast( + ScalarTypeDefinitionNode, + parse_definition(ScalarTypeDefinitionNode, cls.__schema__), + ) + + return GraphQLScalarModel( + name=definition.name.value, + ast_type=ScalarTypeDefinitionNode, + ast=definition, + serialize=cls.serialize, + parse_value=cls.parse_value, + parse_literal=cls.parse_literal, + ) + + @classmethod + def __get_graphql_model_without_schema__(cls, name: str) -> "GraphQLModel": + return GraphQLScalarModel( + name=name, + ast_type=ScalarTypeDefinitionNode, + ast=ScalarTypeDefinitionNode( + name=NameNode(value=name), + description=get_description_node( + getattr(cls, "__description__", None), + ), + ), + serialize=cls.serialize, + parse_value=cls.parse_value, + parse_literal=cls.parse_literal, + ) + + @classmethod + def serialize(cls, value: Any) -> Any: + if isinstance(value, cls): + return value.unwrap() + + return value + + @classmethod + def parse_value(cls, value: Any) -> Any: + return value + + @classmethod + def parse_literal( + cls, node: ValueNode, variables: Optional[dict[str, Any]] = None + ) -> Any: + return cls.parse_value(value_from_ast_untyped(node, variables)) + + def unwrap(self) -> T: + return self.wrapped_value diff --git a/ariadne_graphql_modules/scalar_type/models.py b/ariadne_graphql_modules/scalar_type/models.py new file mode 100644 index 0000000..8cf07d8 --- /dev/null +++ b/ariadne_graphql_modules/scalar_type/models.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass +from typing import Optional + +from ariadne import ScalarType as ScalarTypeBindable +from graphql import ( + GraphQLScalarLiteralParser, + GraphQLScalarSerializer, + GraphQLScalarValueParser, + GraphQLSchema, +) + +from ariadne_graphql_modules.base_graphql_model import GraphQLModel + + +@dataclass(frozen=True) +class GraphQLScalarModel(GraphQLModel): + serialize: Optional[GraphQLScalarSerializer] + parse_value: Optional[GraphQLScalarValueParser] + parse_literal: Optional[GraphQLScalarLiteralParser] + + def bind_to_schema(self, schema: GraphQLSchema): + bindable = ScalarTypeBindable( + self.name, + serializer=self.serialize, + value_parser=self.parse_value, + literal_parser=self.parse_literal, + ) + + bindable.bind_to_schema(schema) diff --git a/ariadne_graphql_modules/scalar_type/validators.py b/ariadne_graphql_modules/scalar_type/validators.py new file mode 100644 index 0000000..03a5edd --- /dev/null +++ b/ariadne_graphql_modules/scalar_type/validators.py @@ -0,0 +1,24 @@ +from typing import TYPE_CHECKING + +from graphql import ScalarTypeDefinitionNode + +from ariadne_graphql_modules.utils import parse_definition +from ariadne_graphql_modules.validators import validate_description, validate_name + +if TYPE_CHECKING: + from ariadne_graphql_modules.scalar_type.graphql_type import GraphQLScalar + + +def validate_scalar_type_with_schema(cls: type["GraphQLScalar"]): + definition = parse_definition(cls.__name__, cls.__schema__) + + if not isinstance(definition, ScalarTypeDefinitionNode): + raise ValueError( + f"Class '{cls.__name__}' defines '__schema__' attribute " + "with declaration for an invalid GraphQL type. " + f"('{definition.__class__.__name__}' != " + f"'{ScalarTypeDefinitionNode.__name__}')" + ) + + validate_name(cls, definition) + validate_description(cls, definition) diff --git a/ariadne_graphql_modules/sort.py b/ariadne_graphql_modules/sort.py new file mode 100644 index 0000000..883e970 --- /dev/null +++ b/ariadne_graphql_modules/sort.py @@ -0,0 +1,130 @@ +from typing import Any, Union, cast + +from graphql import ( + DefinitionNode, + DirectiveDefinitionNode, + DocumentNode, + InputObjectTypeDefinitionNode, + InterfaceTypeDefinitionNode, + ListTypeNode, + NamedTypeNode, + NonNullTypeNode, + ObjectTypeDefinitionNode, + ScalarTypeDefinitionNode, + TypeDefinitionNode, + TypeNode, +) + +from ariadne_graphql_modules.roots import ROOTS_NAMES + + +def sort_schema_document(document: DocumentNode) -> DocumentNode: + unsorted_nodes: dict[str, TypeDefinitionNode] = {} + sorted_nodes: list[Union[TypeDefinitionNode, DefinitionNode]] = [] + + for node in document.definitions: + cast_node = cast(TypeDefinitionNode, node) + unsorted_nodes[cast_node.name.value] = cast_node + + # Start schema from directives and scalars + sorted_nodes += get_sorted_directives(unsorted_nodes) + sorted_nodes += get_sorted_scalars(unsorted_nodes) + + # Next, include Query, Mutation and Subscription branches + for root in ROOTS_NAMES: + sorted_nodes += get_sorted_type(root, unsorted_nodes) + + # Finally include unused types + sorted_nodes += list(unsorted_nodes.values()) + + return DocumentNode(definitions=tuple(sorted_nodes)) + + +def get_sorted_directives( + unsorted_nodes: dict[str, Any], +) -> list[DirectiveDefinitionNode]: + directives: list[DirectiveDefinitionNode] = [] + for name, model in tuple(unsorted_nodes.items()): + if isinstance(model, DirectiveDefinitionNode): + directives.append(unsorted_nodes.pop(name)) + return sorted(directives, key=lambda m: m.name.value) + + +def get_sorted_scalars( + unsorted_nodes: dict[str, Any], +) -> list[ScalarTypeDefinitionNode]: + scalars: list[ScalarTypeDefinitionNode] = [] + for name, model in tuple(unsorted_nodes.items()): + if isinstance(model, ScalarTypeDefinitionNode): + scalars.append(unsorted_nodes.pop(name)) + + return sorted(scalars, key=lambda m: m.name.value) + + +def get_sorted_type( + root: str, + unsorted_nodes: dict[str, TypeDefinitionNode], +) -> list[TypeDefinitionNode]: + sorted_nodes: list[TypeDefinitionNode] = [] + if root not in unsorted_nodes: + return sorted_nodes + + root_node = unsorted_nodes.pop(root) + sorted_nodes.append(root_node) + + if isinstance(root_node, (ObjectTypeDefinitionNode, InterfaceTypeDefinitionNode)): + sorted_nodes += get_sorted_object_dependencies(root_node, unsorted_nodes) + elif isinstance(root_node, InputObjectTypeDefinitionNode): + pass + + return sorted_nodes + + +def get_sorted_object_dependencies( + root_node: Union[ObjectTypeDefinitionNode, InterfaceTypeDefinitionNode], + unsorted_nodes: dict[str, TypeDefinitionNode], +) -> list[TypeDefinitionNode]: + sorted_nodes: list[TypeDefinitionNode] = [] + + if root_node.interfaces: + for interface in root_node.interfaces: + interface_name = interface.name.value + interface_node = unsorted_nodes.pop(interface_name, None) + + if isinstance(interface_node, InterfaceTypeDefinitionNode): + sorted_nodes.append(interface_node) + sorted_nodes += get_sorted_object_dependencies( + interface_node, unsorted_nodes + ) + + for field in root_node.fields: + if field.arguments: + for argument in field.arguments: + argument_type = unwrap_type_name(argument.type) + sorted_nodes += get_sorted_type(argument_type, unsorted_nodes) + + field_type = unwrap_type_name(field.type) + sorted_nodes += get_sorted_type(field_type, unsorted_nodes) + + return sorted_nodes + + +def get_sorted_input_dependencies( + root_node: InputObjectTypeDefinitionNode, + unsorted_nodes: dict[str, TypeDefinitionNode], +) -> list[TypeDefinitionNode]: + sorted_nodes: list[TypeDefinitionNode] = [] + + for field in root_node.fields: + field_type = unwrap_type_name(field.type) + sorted_nodes += get_sorted_type(field_type, unsorted_nodes) + + return sorted_nodes + + +def unwrap_type_name(type_node: TypeNode) -> str: + if isinstance(type_node, (ListTypeNode, NonNullTypeNode)): + return unwrap_type_name(type_node.type) + if isinstance(type_node, NamedTypeNode): + return type_node.name.value + raise ValueError("Unexpected type node encountered.") diff --git a/ariadne_graphql_modules/subscription_type/__init__.py b/ariadne_graphql_modules/subscription_type/__init__.py new file mode 100644 index 0000000..8aaa4a9 --- /dev/null +++ b/ariadne_graphql_modules/subscription_type/__init__.py @@ -0,0 +1,7 @@ +from ariadne_graphql_modules.subscription_type.graphql_type import GraphQLSubscription +from ariadne_graphql_modules.subscription_type.models import GraphQLSubscriptionModel + +__all__ = [ + "GraphQLSubscription", + "GraphQLSubscriptionModel", +] diff --git a/ariadne_graphql_modules/subscription_type/graphql_type.py b/ariadne_graphql_modules/subscription_type/graphql_type.py new file mode 100644 index 0000000..94aa075 --- /dev/null +++ b/ariadne_graphql_modules/subscription_type/graphql_type.py @@ -0,0 +1,307 @@ +from typing import Any, Optional, cast + +from ariadne.types import Resolver, Subscriber +from graphql import ( + FieldDefinitionNode, + InputValueDefinitionNode, + NameNode, + ObjectTypeDefinitionNode, + StringValueNode, +) + +from ariadne_graphql_modules.base import GraphQLMetadata +from ariadne_graphql_modules.base_graphql_model import GraphQLModel +from ariadne_graphql_modules.base_object_type import ( + GraphQLBaseObject, + GraphQLFieldData, + GraphQLObjectData, + validate_object_type_with_schema, + validate_object_type_without_schema, +) +from ariadne_graphql_modules.base_object_type.graphql_field import ( + GraphQLObjectField, + object_field, + object_resolver, +) +from ariadne_graphql_modules.convert_name import convert_python_name_to_graphql +from ariadne_graphql_modules.description import get_description_node +from ariadne_graphql_modules.object_type import ( + GraphQLObjectFieldArg, + GraphQLObjectResolver, + GraphQLObjectSource, + get_field_args_from_subscriber, + get_field_args_out_names, + get_field_node_from_obj_field, + object_subscriber, + update_field_args_options, +) +from ariadne_graphql_modules.subscription_type.models import GraphQLSubscriptionModel +from ariadne_graphql_modules.types import GraphQLClassType +from ariadne_graphql_modules.utils import parse_definition +from ariadne_graphql_modules.value import get_value_node + + +class GraphQLSubscription(GraphQLBaseObject): + __graphql_type__ = GraphQLClassType.SUBSCRIPTION + __abstract__: bool = True + __description__: Optional[str] = None + __graphql_name__ = GraphQLClassType.SUBSCRIPTION.value + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + if cls.__dict__.get("__abstract__"): + return + + cls.__abstract__ = False + + if cls.__dict__.get("__schema__"): + cls.__kwargs__ = validate_object_type_with_schema( + cls, ObjectTypeDefinitionNode + ) + else: + cls.__kwargs__ = validate_object_type_without_schema(cls) + + @classmethod + def __get_graphql_model_with_schema__(cls) -> "GraphQLModel": # noqa: C901 + definition = cast( + ObjectTypeDefinitionNode, + parse_definition(ObjectTypeDefinitionNode, cls.__schema__), + ) + + descriptions: dict[str, StringValueNode] = {} + args_descriptions: dict[str, dict[str, StringValueNode]] = {} + args_defaults: dict[str, dict[str, Any]] = {} + resolvers: dict[str, Resolver] = {} + subscribers: dict[str, Subscriber] = {} + + for attr_name in dir(cls): + cls_attr = getattr(cls, attr_name) + if isinstance(cls_attr, GraphQLObjectResolver): + resolver = cls_attr.resolver + if isinstance(resolver, staticmethod): + resolver = resolver.__func__ # type: ignore[attr-defined] + resolvers[cls_attr.field] = cls_attr.resolver + if isinstance(cls_attr, GraphQLObjectSource): + subscriber = cls_attr.subscriber + if isinstance(subscriber, staticmethod): + subscriber = subscriber.__func__ # type: ignore[attr-defined] + subscribers[cls_attr.field] = subscriber + description_node = get_description_node(cls_attr.description) + if description_node: + descriptions[cls_attr.field] = description_node + + field_args = get_field_args_from_subscriber(cls_attr.subscriber) + if field_args: + args_descriptions[cls_attr.field] = {} + args_defaults[cls_attr.field] = {} + + final_args = update_field_args_options(field_args, cls_attr.args) + + for arg_name, arg_options in final_args.items(): + arg_description = get_description_node(arg_options.description) + if arg_description: + args_descriptions[cls_attr.field][ + arg_name + ] = arg_description + + arg_default = arg_options.default_value + if arg_default is not None: + args_defaults[cls_attr.field][arg_name] = get_value_node( + arg_default + ) + + fields: list[FieldDefinitionNode] = [] + for field in definition.fields: + field_args_descriptions = args_descriptions.get(field.name.value, {}) + field_args_defaults = args_defaults.get(field.name.value, {}) + + args: list[InputValueDefinitionNode] = [] + for arg in field.arguments: + arg_name = arg.name.value + args.append( + InputValueDefinitionNode( + description=( + arg.description or field_args_descriptions.get(arg_name) + ), + name=arg.name, + directives=arg.directives, + type=arg.type, + default_value=( + arg.default_value or field_args_defaults.get(arg_name) + ), + ) + ) + + fields.append( + FieldDefinitionNode( + name=field.name, + description=( + field.description or descriptions.get(field.name.value) + ), + directives=field.directives, + arguments=tuple(args), + type=field.type, + ) + ) + + return GraphQLSubscriptionModel( + name=definition.name.value, + ast_type=ObjectTypeDefinitionNode, + ast=ObjectTypeDefinitionNode( + name=NameNode(value=definition.name.value), + fields=tuple(fields), + ), + resolvers=resolvers, + subscribers=subscribers, + aliases=getattr(cls, "__aliases__", {}), + out_names={}, + ) + + @classmethod + def __get_graphql_model_without_schema__( + cls, metadata: GraphQLMetadata, name: str + ) -> "GraphQLModel": + type_data = cls.get_graphql_object_data(metadata) + type_aliases = getattr(cls, "__aliases__", None) or {} + + fields_ast: list[FieldDefinitionNode] = [] + resolvers: dict[str, Resolver] = {} + subscribers: dict[str, Subscriber] = {} + aliases: dict[str, str] = {} + out_names: dict[str, dict[str, str]] = {} + + for attr_name, field in type_data.fields.items(): + fields_ast.append(get_field_node_from_obj_field(cls, metadata, field)) + if attr_name in type_aliases and field.name: + aliases[field.name] = type_aliases[attr_name] + elif field.name and attr_name != field.name and not field.resolver: + aliases[field.name] = attr_name + + if field.resolver and field.name: + resolvers[field.name] = field.resolver + + if field.subscriber and field.name: + subscribers[field.name] = field.subscriber + + if field.args and field.name: + out_names[field.name] = get_field_args_out_names(field.args) + + return GraphQLSubscriptionModel( + name=name, + ast_type=ObjectTypeDefinitionNode, + ast=ObjectTypeDefinitionNode( + name=NameNode(value=name), + description=get_description_node( + getattr(cls, "__description__", None), + ), + fields=tuple(fields_ast), + ), + resolvers=resolvers, + aliases=aliases, + out_names=out_names, + subscribers=subscribers, + ) + + @staticmethod + def source( + field: str, + graphql_type: Optional[Any] = None, + args: Optional[dict[str, GraphQLObjectFieldArg]] = None, + description: Optional[str] = None, + ): + """Shortcut for object_resolver()""" + return object_subscriber( + args=args, + field=field, + graphql_type=graphql_type, + description=description, + ) + + @staticmethod + def resolver(field: str, *args, **_): + """Shortcut for object_resolver()""" + return object_resolver( + field=field, + ) + + @staticmethod + def field( + f: Optional[Resolver] = None, + *, + name: Optional[str] = None, + **_, + ) -> Any: + """Shortcut for object_field()""" + return object_field( + f, + name=name, + ) + + @staticmethod + def _process_class_attributes( # noqa: C901 + target_cls, fields_data: GraphQLFieldData + ): + for attr_name in dir(target_cls): + if attr_name.startswith("__"): + continue + cls_attr = getattr(target_cls, attr_name) + if isinstance(cls_attr, GraphQLObjectField): + if attr_name not in fields_data.fields_order: + fields_data.fields_order.append(attr_name) + + fields_data.fields_names[attr_name] = ( + cls_attr.name or convert_python_name_to_graphql(attr_name) + ) + if cls_attr.resolver: + resolver = cls_attr.resolver + if isinstance(resolver, staticmethod): + resolver = resolver.__func__ # type: ignore[attr-defined] + fields_data.fields_resolvers[attr_name] = resolver + elif isinstance(cls_attr, GraphQLObjectResolver): + resolver = cls_attr.resolver + if isinstance(resolver, staticmethod): + resolver = resolver.__func__ # type: ignore[attr-defined] + fields_data.fields_resolvers[cls_attr.field] = resolver + elif isinstance(cls_attr, GraphQLObjectSource): + if ( + cls_attr.field_type + and cls_attr.field not in fields_data.fields_types + ): + fields_data.fields_types[cls_attr.field] = cls_attr.field_type + if ( + cls_attr.description + and cls_attr.field not in fields_data.fields_descriptions + ): + fields_data.fields_descriptions[cls_attr.field] = ( + cls_attr.description + ) + subscriber = cls_attr.subscriber + if isinstance(subscriber, staticmethod): + subscriber = subscriber.__func__ # type: ignore[attr-defined] + fields_data.fields_subscribers[cls_attr.field] = subscriber + field_args = get_field_args_from_subscriber(subscriber) + if field_args: + fields_data.fields_args[cls_attr.field] = update_field_args_options( + field_args, cls_attr.args + ) + + elif attr_name not in fields_data.aliases_targets and not callable( + cls_attr + ): + fields_data.fields_defaults[attr_name] = cls_attr + + @classmethod + def create_graphql_object_data_without_schema(cls) -> GraphQLObjectData: + fields_data = GraphQLFieldData() + cls._process_type_hints_and_aliases(fields_data) + cls._process_class_attributes(cls, fields_data) + + return GraphQLObjectData( + fields=cls._build_fields(fields_data=fields_data), + interfaces=[], + ) + + @classmethod + def _collect_inherited_objects(cls): + return [] diff --git a/ariadne_graphql_modules/subscription_type/models.py b/ariadne_graphql_modules/subscription_type/models.py new file mode 100644 index 0000000..8d491b9 --- /dev/null +++ b/ariadne_graphql_modules/subscription_type/models.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from typing import cast + +from ariadne import SubscriptionType +from ariadne.types import Resolver, Subscriber +from graphql import GraphQLField, GraphQLObjectType, GraphQLSchema + +from ariadne_graphql_modules.base_graphql_model import GraphQLModel + + +@dataclass(frozen=True) +class GraphQLSubscriptionModel(GraphQLModel): + resolvers: dict[str, Resolver] + out_names: dict[str, dict[str, str]] + aliases: dict[str, str] + subscribers: dict[str, Subscriber] + + def bind_to_schema(self, schema: GraphQLSchema): + bindable = SubscriptionType() + for field, resolver in self.resolvers.items(): + bindable.set_field(field, resolver) + for alias, target in self.aliases.items(): + bindable.set_alias(alias, target) + for source, generator in self.subscribers.items(): + bindable.set_source(source, generator) + bindable.bind_to_schema(schema) + + graphql_type = cast(GraphQLObjectType, schema.get_type(self.name)) + for field_name, field_out_names in self.out_names.items(): + graphql_field = cast(GraphQLField, graphql_type.fields[field_name]) + for arg_name, out_name in field_out_names.items(): + graphql_field.args[arg_name].out_name = out_name diff --git a/ariadne_graphql_modules/types.py b/ariadne_graphql_modules/types.py index 9c2173b..d6bcd8c 100644 --- a/ariadne_graphql_modules/types.py +++ b/ariadne_graphql_modules/types.py @@ -1,4 +1,4 @@ -from typing import Dict, Type +from enum import Enum from graphql import ( DefinitionNode, @@ -6,6 +6,13 @@ InputValueDefinitionNode, ) -FieldsDict = Dict[str, FieldDefinitionNode] -InputFieldsDict = Dict[str, InputValueDefinitionNode] -RequirementsDict = Dict[str, Type[DefinitionNode]] +FieldsDict = dict[str, FieldDefinitionNode] +InputFieldsDict = dict[str, InputValueDefinitionNode] +RequirementsDict = dict[str, type[DefinitionNode]] + + +class GraphQLClassType(Enum): + BASE = "Base" + OBJECT = "Object" + INTERFACE = "Interface" + SUBSCRIPTION = "Subscription" diff --git a/ariadne_graphql_modules/typing.py b/ariadne_graphql_modules/typing.py new file mode 100644 index 0000000..bb2b529 --- /dev/null +++ b/ariadne_graphql_modules/typing.py @@ -0,0 +1,157 @@ +import sys +from enum import Enum +from importlib import import_module +from inspect import isclass +from typing import ( + Annotated, + Any, + ForwardRef, + Optional, + Union, + get_args, + get_origin, +) + +from graphql import ( + ListTypeNode, + NamedTypeNode, + NameNode, + NonNullTypeNode, + TypeNode, +) + +from ariadne_graphql_modules.base import GraphQLMetadata, GraphQLType +from ariadne_graphql_modules.deferredtype import DeferredTypeData +from ariadne_graphql_modules.idtype import GraphQLID + +if sys.version_info >= (3, 10): + from types import UnionType +else: + UnionType = Union + + +def get_type_node( # noqa: C901 + metadata: GraphQLMetadata, + type_hint: Any, + parent_type: Optional[type[GraphQLType]] = None, +) -> TypeNode: + if is_nullable(type_hint): + nullable = True + type_hint = unwrap_type(type_hint) + else: + nullable = False + + type_node: Optional[TypeNode] = None + if is_list(type_hint): + list_item_type_hint = unwrap_type(type_hint) + type_node = ListTypeNode( + type=get_type_node(metadata, list_item_type_hint, parent_type=parent_type) + ) + elif type_hint is str: + type_node = NamedTypeNode(name=NameNode(value="String")) + elif type_hint is int: + type_node = NamedTypeNode(name=NameNode(value="Int")) + elif type_hint is float: + type_node = NamedTypeNode(name=NameNode(value="Float")) + elif type_hint is bool: + type_node = NamedTypeNode(name=NameNode(value="Boolean")) + elif get_origin(type_hint) is Annotated: + forward_ref, type_meta = get_args(type_hint) + if not type_meta or not isinstance(type_meta, DeferredTypeData): + raise ValueError( + f"Can't create a GraphQL return type for '{type_hint}'. " + "Second argument of 'Annotated' is expected to be a return " + "value from the 'deferred()' utility." + ) + + deferred_type = get_deferred_type(type_hint, forward_ref, type_meta) + type_node = NamedTypeNode( + name=NameNode(value=metadata.get_graphql_name(deferred_type)), + ) + elif isinstance(type_hint, ForwardRef): + type_name = type_hint.__forward_arg__ + if not parent_type or parent_type.__name__ != type_name: + raise ValueError( + "Can't create a GraphQL return type" + f"for forward reference '{type_name}'." + ) + + type_node = NamedTypeNode( + name=NameNode(value=metadata.get_graphql_name(parent_type)), + ) + elif isinstance(type_hint, str): + type_node = NamedTypeNode( + name=NameNode(value=type_hint), + ) + elif isclass(type_hint): + if issubclass(type_hint, GraphQLID): + type_node = NamedTypeNode(name=NameNode(value="ID")) + elif issubclass(type_hint, (GraphQLType, Enum)): + type_node = NamedTypeNode( + name=NameNode(value=metadata.get_graphql_name(type_hint)), + ) + + if not type_node: + raise ValueError(f"Can't create a GraphQL return type for '{type_hint}'.") + + if nullable: + return type_node + + return NonNullTypeNode(type=type_node) + + +def is_list(type_hint: Any) -> bool: + return get_origin(type_hint) is list + + +def is_nullable(type_hint: Any) -> bool: + origin = get_origin(type_hint) + if origin in (UnionType, Union): + return type(None) in get_args(type_hint) + + return False + + +def unwrap_type(type_hint: Any) -> Any: + args = list(get_args(type_hint)) + if type(None) in args: + args.remove(type(None)) + if len(args) != 1: + raise ValueError( + f"Type {type_hint} is a wrapper type for multiple other " + "types and can't be unwrapped." + ) + return args[0] + + +def get_deferred_type( + type_hint: Any, forward_ref: ForwardRef, deferred_type: DeferredTypeData +) -> Union[type[GraphQLType], type[Enum]]: + type_name = forward_ref.__forward_arg__ + module = import_module(deferred_type.path) + graphql_type = getattr(module, type_name) + + if not isclass(graphql_type) or not issubclass(graphql_type, (GraphQLType, Enum)): + raise ValueError(f"Can't create a GraphQL return type for '{type_hint}'.") + + return graphql_type + + +def get_graphql_type(annotation: Any) -> Optional[Union[type[GraphQLType], type[Enum]]]: + """Utility that extracts GraphQL type from type annotation""" + if is_nullable(annotation) or is_list(annotation): + return get_graphql_type(unwrap_type(annotation)) + + if get_origin(annotation) is Annotated: + forward_ref, type_meta = get_args(annotation) + if not type_meta or not isinstance(type_meta, DeferredTypeData): + return None + return get_deferred_type(annotation, forward_ref, type_meta) + + if not isclass(annotation): + return None + + if issubclass(annotation, (GraphQLType, Enum)): + return annotation + + return None diff --git a/ariadne_graphql_modules/union_type/__init__.py b/ariadne_graphql_modules/union_type/__init__.py new file mode 100644 index 0000000..7d6b6ca --- /dev/null +++ b/ariadne_graphql_modules/union_type/__init__.py @@ -0,0 +1,7 @@ +from ariadne_graphql_modules.union_type.graphql_type import GraphQLUnion +from ariadne_graphql_modules.union_type.models import GraphQLUnionModel + +__all__ = [ + "GraphQLUnion", + "GraphQLUnionModel", +] diff --git a/ariadne_graphql_modules/union_type/graphql_type.py b/ariadne_graphql_modules/union_type/graphql_type.py new file mode 100644 index 0000000..51fa7fc --- /dev/null +++ b/ariadne_graphql_modules/union_type/graphql_type.py @@ -0,0 +1,92 @@ +from collections.abc import Iterable, Sequence +from typing import Any, Optional, cast + +from graphql import NamedTypeNode, NameNode, UnionTypeDefinitionNode + +from ariadne_graphql_modules.base import GraphQLMetadata, GraphQLType +from ariadne_graphql_modules.base_graphql_model import GraphQLModel +from ariadne_graphql_modules.description import get_description_node +from ariadne_graphql_modules.object_type.graphql_type import GraphQLObject +from ariadne_graphql_modules.union_type.models import GraphQLUnionModel +from ariadne_graphql_modules.union_type.validators import ( + validate_union_type, + validate_union_type_with_schema, +) +from ariadne_graphql_modules.utils import parse_definition + + +class GraphQLUnion(GraphQLType): + __types__: Sequence[type[GraphQLType]] + __schema__: Optional[str] + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + if cls.__dict__.get("__abstract__"): + return + + cls.__abstract__ = False + + if cls.__dict__.get("__schema__"): + validate_union_type_with_schema(cls) + else: + validate_union_type(cls) + + @classmethod + def __get_graphql_model__(cls, metadata: GraphQLMetadata) -> "GraphQLModel": + name = cls.__get_graphql_name__() + metadata.set_graphql_name(cls, name) + + if getattr(cls, "__schema__", None): + return cls.__get_graphql_model_with_schema__() + + return cls.__get_graphql_model_without_schema__(name) + + @classmethod + def __get_graphql_model_with_schema__(cls) -> "GraphQLModel": + definition = cast( + UnionTypeDefinitionNode, + parse_definition(UnionTypeDefinitionNode, cls.__schema__), + ) + + return GraphQLUnionModel( + name=definition.name.value, + ast_type=UnionTypeDefinitionNode, + ast=definition, + resolve_type=cls.resolve_type, + ) + + @classmethod + def __get_graphql_model_without_schema__(cls, name: str) -> "GraphQLModel": + return GraphQLUnionModel( + name=name, + ast_type=UnionTypeDefinitionNode, + ast=UnionTypeDefinitionNode( + name=NameNode(value=name), + description=get_description_node( + getattr(cls, "__description__", None), + ), + types=tuple( + NamedTypeNode(name=NameNode(value=t.__get_graphql_name__())) + for t in cls.__types__ + ), + ), + resolve_type=cls.resolve_type, + ) + + @classmethod + def __get_graphql_types__( + cls, _: "GraphQLMetadata" + ) -> Iterable[type["GraphQLType"]]: + """Returns iterable with GraphQL types associated with this type""" + return [cls, *cls.__types__] + + @staticmethod + def resolve_type(obj: Any, *_) -> str: + if isinstance(obj, GraphQLObject): + return obj.__get_graphql_name__() + + raise ValueError( + f"Cannot resolve GraphQL type {obj} " + "for object of type '{type(obj).__name__}'." + ) diff --git a/ariadne_graphql_modules/union_type/models.py b/ariadne_graphql_modules/union_type/models.py new file mode 100644 index 0000000..51860d0 --- /dev/null +++ b/ariadne_graphql_modules/union_type/models.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + +from ariadne import UnionType +from graphql import GraphQLSchema, GraphQLTypeResolver + +from ariadne_graphql_modules.base_graphql_model import GraphQLModel + + +@dataclass(frozen=True) +class GraphQLUnionModel(GraphQLModel): + resolve_type: GraphQLTypeResolver + + def bind_to_schema(self, schema: GraphQLSchema): + bindable = UnionType(self.name, self.resolve_type) + bindable.bind_to_schema(schema) diff --git a/ariadne_graphql_modules/union_type/validators.py b/ariadne_graphql_modules/union_type/validators.py new file mode 100644 index 0000000..7d8d244 --- /dev/null +++ b/ariadne_graphql_modules/union_type/validators.py @@ -0,0 +1,58 @@ +from typing import TYPE_CHECKING, cast + +from graphql import UnionTypeDefinitionNode + +from ariadne_graphql_modules.utils import parse_definition +from ariadne_graphql_modules.validators import validate_description, validate_name + +if TYPE_CHECKING: + from ariadne_graphql_modules.union_type.graphql_type import GraphQLUnion + + +def validate_union_type(cls: type["GraphQLUnion"]) -> None: + types = getattr(cls, "__types__", None) + if not types: + raise ValueError( + f"Class '{cls.__name__}' is missing a '__types__' attribute " + "with list of types belonging to a union." + ) + + +def validate_union_type_with_schema(cls: type["GraphQLUnion"]) -> None: + definition = cast( + UnionTypeDefinitionNode, + parse_definition(UnionTypeDefinitionNode, cls.__schema__), + ) + + if not isinstance(definition, UnionTypeDefinitionNode): + raise ValueError( + f"Class '{cls.__name__}' defines a '__schema__' attribute " + "with declaration for an invalid GraphQL type. " + f"('{definition.__class__.__name__}' != " + f"'{UnionTypeDefinitionNode.__name__}')" + ) + + validate_name(cls, definition) + validate_description(cls, definition) + + schema_type_names = { + type_node.name.value + for type_node in definition.types # pylint: disable=no-member + } + + class_type_names = {t.__get_graphql_name__() for t in cls.__types__} + if not class_type_names.issubset(schema_type_names): + missing_in_schema = sorted(class_type_names - schema_type_names) + missing_in_schema_str = "', '".join(missing_in_schema) + raise ValueError( + f"Types '{missing_in_schema_str}' are in '__types__' " + "but not in '__schema__'." + ) + + if not schema_type_names.issubset(class_type_names): + missing_in_types = sorted(schema_type_names - class_type_names) + missing_in_types_str = "', '".join(missing_in_types) + raise ValueError( + f"Types '{missing_in_types_str}' are in '__schema__' " + "but not in '__types__'." + ) diff --git a/ariadne_graphql_modules/utils.py b/ariadne_graphql_modules/utils.py index be29116..14b9465 100644 --- a/ariadne_graphql_modules/utils.py +++ b/ariadne_graphql_modules/utils.py @@ -1,4 +1,5 @@ -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any from graphql import ( DefinitionNode, @@ -10,7 +11,7 @@ ) -def parse_definition(type_name: str, schema: Any) -> DefinitionNode: +def parse_definition(type_name: Any, schema: Any) -> DefinitionNode: if not isinstance(schema, str): raise TypeError( f"{type_name} class was defined with __schema__ of invalid type: " diff --git a/ariadne_graphql_modules/v1/__init__.py b/ariadne_graphql_modules/v1/__init__.py new file mode 100644 index 0000000..d6406a3 --- /dev/null +++ b/ariadne_graphql_modules/v1/__init__.py @@ -0,0 +1,38 @@ +from ariadne import gql + +from .bases import BaseType, BindableType, DeferredType, DefinitionType +from .collection_type import CollectionType +from .convert_case import convert_case +from .directive_type import DirectiveType +from .enum_type import EnumType +from .executable_schema import make_executable_schema +from .input_type import InputType +from .interface_type import InterfaceType +from .mutation_type import MutationType +from .object_type import ObjectType +from .scalar_type import ScalarType +from .subscription_type import SubscriptionType +from .union_type import UnionType +from ..utils import create_alias_resolver, parse_definition + +__all__ = [ + "BaseType", + "BindableType", + "CollectionType", + "DeferredType", + "DefinitionType", + "DirectiveType", + "EnumType", + "InputType", + "InterfaceType", + "MutationType", + "ObjectType", + "ScalarType", + "SubscriptionType", + "UnionType", + "convert_case", + "create_alias_resolver", + "gql", + "make_executable_schema", + "parse_definition", +] diff --git a/ariadne_graphql_modules/bases.py b/ariadne_graphql_modules/v1/bases.py similarity index 89% rename from ariadne_graphql_modules/bases.py rename to ariadne_graphql_modules/v1/bases.py index c926c0b..cfc983c 100644 --- a/ariadne_graphql_modules/bases.py +++ b/ariadne_graphql_modules/v1/bases.py @@ -1,9 +1,14 @@ from typing import List, Type, Union -from graphql import DefinitionNode, GraphQLSchema, ObjectTypeDefinitionNode +from graphql import ( + DefinitionNode, + GraphQLSchema, + ObjectTypeDefinitionNode, + TypeSystemDefinitionNode, +) from .dependencies import Dependencies -from .types import RequirementsDict +from ..types import RequirementsDict __all__ = ["BaseType", "BindableType", "DeferredType", "DefinitionType"] @@ -31,6 +36,7 @@ class DefinitionType(BaseType): graphql_name: str graphql_type: Type[DefinitionNode] + graphql_def: TypeSystemDefinitionNode @classmethod def __get_requirements__(cls) -> RequirementsDict: diff --git a/ariadne_graphql_modules/collection_type.py b/ariadne_graphql_modules/v1/collection_type.py similarity index 100% rename from ariadne_graphql_modules/collection_type.py rename to ariadne_graphql_modules/v1/collection_type.py diff --git a/ariadne_graphql_modules/convert_case.py b/ariadne_graphql_modules/v1/convert_case.py similarity index 97% rename from ariadne_graphql_modules/convert_case.py rename to ariadne_graphql_modules/v1/convert_case.py index d0c7b18..f44e878 100644 --- a/ariadne_graphql_modules/convert_case.py +++ b/ariadne_graphql_modules/v1/convert_case.py @@ -4,7 +4,7 @@ from ariadne import convert_camel_case_to_snake from graphql import FieldDefinitionNode -from .types import FieldsDict, InputFieldsDict +from ..types import FieldsDict, InputFieldsDict def convert_case( diff --git a/ariadne_graphql_modules/dependencies.py b/ariadne_graphql_modules/v1/dependencies.py similarity index 99% rename from ariadne_graphql_modules/dependencies.py rename to ariadne_graphql_modules/v1/dependencies.py index f3b436d..cd04c06 100644 --- a/ariadne_graphql_modules/dependencies.py +++ b/ariadne_graphql_modules/v1/dependencies.py @@ -15,7 +15,7 @@ UnionTypeExtensionNode, ) -from .utils import unwrap_type_node +from ..utils import unwrap_type_node GRAPHQL_TYPES = ("ID", "Int", "String", "Boolean", "Float") diff --git a/ariadne_graphql_modules/directive_type.py b/ariadne_graphql_modules/v1/directive_type.py similarity index 97% rename from ariadne_graphql_modules/directive_type.py rename to ariadne_graphql_modules/v1/directive_type.py index d6e2706..14d9817 100644 --- a/ariadne_graphql_modules/directive_type.py +++ b/ariadne_graphql_modules/v1/directive_type.py @@ -7,7 +7,7 @@ ) from .bases import DefinitionType -from .utils import parse_definition +from ..utils import parse_definition class DirectiveType(DefinitionType): diff --git a/ariadne_graphql_modules/enum_type.py b/ariadne_graphql_modules/v1/enum_type.py similarity index 90% rename from ariadne_graphql_modules/enum_type.py rename to ariadne_graphql_modules/v1/enum_type.py index 9b6b044..66236d4 100644 --- a/ariadne_graphql_modules/enum_type.py +++ b/ariadne_graphql_modules/v1/enum_type.py @@ -10,8 +10,8 @@ ) from .bases import BindableType -from .types import RequirementsDict -from .utils import parse_definition +from ..types import RequirementsDict +from ..utils import parse_definition EnumNodeType = Union[EnumTypeDefinitionNode, EnumTypeExtensionNode] @@ -30,17 +30,19 @@ def __init_subclass__(cls) -> None: cls.__abstract__ = False - graphql_def = cls.__validate_schema__( + cls.graphql_def = cls.__validate_schema__( parse_definition(cls.__name__, cls.__schema__) ) - cls.graphql_name = graphql_def.name.value - cls.graphql_type = type(graphql_def) + cls.graphql_name = cls.graphql_def.name.value + cls.graphql_type = type(cls.graphql_def) requirements = cls.__get_requirements__() - cls.__validate_requirements_contain_extended_type__(graphql_def, requirements) + cls.__validate_requirements_contain_extended_type__( + cls.graphql_def, requirements + ) - values = cls.__get_values__(graphql_def) + values = cls.__get_values__(cls.graphql_def) cls.__validate_values__(values) @classmethod diff --git a/ariadne_graphql_modules/v1/executable_schema.py b/ariadne_graphql_modules/v1/executable_schema.py new file mode 100644 index 0000000..1914742 --- /dev/null +++ b/ariadne_graphql_modules/v1/executable_schema.py @@ -0,0 +1,236 @@ +from typing import ( + Dict, + List, + Optional, + Sequence, + Tuple, + Type, + Union, + cast, +) + +from ariadne import ( + SchemaBindable, + SchemaDirectiveVisitor, + repair_schema_default_enum_values, + validate_schema_default_enum_values, +) +from graphql import ( + ConstDirectiveNode, + DocumentNode, + FieldDefinitionNode, + GraphQLSchema, + NamedTypeNode, + ObjectTypeDefinitionNode, + TypeDefinitionNode, + assert_valid_schema, + build_ast_schema, + concat_ast, + parse, +) +from graphql.language import ast + +from .bases import BaseType, BindableType, DeferredType, DefinitionType +from .enum_type import EnumType + +ROOT_TYPES = ["Query", "Mutation", "Subscription"] + + +def make_executable_schema( + *args: Union[Type[BaseType], SchemaBindable, str], + merge_roots: bool = True, + extra_directives: Optional[Dict[str, Type[SchemaDirectiveVisitor]]] = None, +): + all_types = get_all_types(args) + extra_defs = parse_extra_sdl(args) + extra_bindables: List[SchemaBindable] = [ + arg for arg in args if isinstance(arg, SchemaBindable) + ] + + type_defs: List[Type[DefinitionType]] = [] + for type_ in all_types: + if issubclass(type_, DefinitionType): + type_defs.append(type_) + + validate_no_missing_definitions(all_types, type_defs, extra_defs) + + schema = build_schema(type_defs, extra_defs, merge_roots) + + if extra_bindables: + for bindable in extra_bindables: + bindable.bind_to_schema(schema) + + if extra_directives: + SchemaDirectiveVisitor.visit_schema_directives(schema, extra_directives) + + assert_valid_schema(schema) + validate_schema_default_enum_values(schema) + repair_schema_default_enum_values(schema) + + add_directives_to_schema(schema, type_defs) + + return schema + + +def get_all_types( + args: Sequence[Union[Type[BaseType], SchemaBindable, str]] +) -> List[Type[BaseType]]: + all_types: List[Type[BaseType]] = [] + for arg in args: + if isinstance(arg, (str, SchemaBindable)): + continue # Skip args of unsupported types + + for child_type in arg.__get_types__(): + if child_type not in all_types: + all_types.append(child_type) + return all_types + + +def parse_extra_sdl( + args: Sequence[Union[Type[BaseType], SchemaBindable, str]] +) -> List[TypeDefinitionNode]: + sdl_strings: List[str] = [cast(str, arg) for arg in args if isinstance(arg, str)] + if not sdl_strings: + return [] + + extra_sdl = "\n\n".join(sdl_strings) + return cast( + List[TypeDefinitionNode], + list(parse(extra_sdl).definitions), + ) + + +def validate_no_missing_definitions( + all_types: List[Type[BaseType]], + type_defs: List[Type[DefinitionType]], + extra_defs: List[TypeDefinitionNode], +): + deferred_names: List[str] = [] + for type_ in all_types: + if isinstance(type_, DeferredType): + deferred_names.append(type_.graphql_name) + + real_names = [type_.graphql_name for type_ in type_defs] + real_names += [definition.name.value for definition in extra_defs] + + missing_names = set(deferred_names) - set(real_names) + if missing_names: + raise ValueError( + "Following types are defined as deferred and are missing " + f"from schema: {', '.join(missing_names)}" + ) + + +def build_schema( + type_defs: List[Type[DefinitionType]], + extra_defs: List[TypeDefinitionNode], + merge_roots: bool = True, +) -> GraphQLSchema: + schema_definitions: List[ast.DocumentNode] = [] + if merge_roots: + schema_definitions.append(build_root_schema(type_defs, extra_defs)) + for type_ in type_defs: + if type_.graphql_name not in ROOT_TYPES or not merge_roots: + schema_definitions.append(parse(type_.__schema__)) + for extra_type_def in extra_defs: + if extra_type_def.name.value not in ROOT_TYPES or not merge_roots: + schema_definitions.append(DocumentNode(definitions=(extra_type_def,))) + + ast_document = concat_ast(schema_definitions) + schema = build_ast_schema(ast_document) + + for type_ in type_defs: + if issubclass(type_, BindableType): + type_.__bind_to_schema__(schema) + + return schema + + +RootTypeDef = Tuple[str, DocumentNode] + + +def build_root_schema( + type_defs: List[Type[DefinitionType]], + extra_defs: List[TypeDefinitionNode], +) -> DocumentNode: + root_types: Dict[str, List[RootTypeDef]] = { + "Query": [], + "Mutation": [], + "Subscription": [], + } + + for type_def in type_defs: + if type_def.graphql_name in root_types: + root_types[type_def.graphql_name].append( + (type_def.__name__, parse(type_def.__schema__)) + ) + + for extra_type_def in extra_defs: + if extra_type_def.name.value in root_types: + root_types[extra_type_def.name.value].append( + ("extra_sdl", DocumentNode(definitions=(extra_type_def,))) + ) + + schema: List[DocumentNode] = [] + for root_name, root_type_defs in root_types.items(): + if len(root_type_defs) == 1: + schema.append(root_type_defs[0][1]) + elif root_type_defs: + schema.append(merge_root_types(root_name, root_type_defs)) + + return concat_ast(schema) + + +def merge_root_types(root_name: str, type_defs: List[RootTypeDef]) -> DocumentNode: + interfaces: List[NamedTypeNode] = [] + directives: List[ConstDirectiveNode] = [] + fields: Dict[str, Tuple[str, FieldDefinitionNode]] = {} + + for type_source, type_def in type_defs: + type_definition = cast(ObjectTypeDefinitionNode, type_def.definitions[0]) + interfaces.extend(type_definition.interfaces) + directives.extend(type_definition.directives) + + for field_def in type_definition.fields: + field_name = field_def.name.value + if field_name in fields: + other_type_source = fields[field_name][0] + raise ValueError( + f"Multiple {root_name} types are defining same field " + f"'{field_name}': {other_type_source}, {type_source}" + ) + + fields[field_name] = (type_source, field_def) + + merged_definition = ast.ObjectTypeDefinitionNode() + merged_definition.name = ast.NameNode() + merged_definition.name.value = root_name + merged_definition.interfaces = tuple(interfaces) + merged_definition.directives = tuple(directives) + merged_definition.fields = tuple( + fields[field_name][1] for field_name in sorted(fields) + ) + + merged_document = DocumentNode() + merged_document.definitions = (merged_definition,) + + return merged_document + + +def add_directives_to_schema( + schema: GraphQLSchema, type_defs: List[Type[DefinitionType]] +): + directives: Dict[str, Type[SchemaDirectiveVisitor]] = {} + for type_def in type_defs: + visitor = getattr(type_def, "__visitor__", None) + if visitor and issubclass(visitor, SchemaDirectiveVisitor): + directives[type_def.graphql_name] = visitor + + if directives: + SchemaDirectiveVisitor.visit_schema_directives(schema, directives) + + +def repair_default_enum_values(schema, types_list: List[Type[DefinitionType]]) -> None: + for type_ in types_list: + if issubclass(type_, EnumType): + type_.__bind_to_default_values__(schema) diff --git a/ariadne_graphql_modules/input_type.py b/ariadne_graphql_modules/v1/input_type.py similarity index 87% rename from ariadne_graphql_modules/input_type.py rename to ariadne_graphql_modules/v1/input_type.py index 282e78b..fa2eedb 100644 --- a/ariadne_graphql_modules/input_type.py +++ b/ariadne_graphql_modules/v1/input_type.py @@ -8,8 +8,8 @@ from .bases import BindableType from .dependencies import Dependencies, get_dependencies_from_input_type -from .types import InputFieldsDict, RequirementsDict -from .utils import parse_definition +from ..types import InputFieldsDict, RequirementsDict +from ..utils import parse_definition Args = Dict[str, str] InputNodeType = Union[InputObjectTypeDefinitionNode, InputObjectTypeExtensionNode] @@ -20,6 +20,7 @@ class InputType(BindableType): __args__: Optional[Union[Args, Callable[..., Args]]] = None graphql_fields: InputFieldsDict + graphql_def: InputNodeType def __init_subclass__(cls) -> None: super().__init_subclass__() @@ -29,13 +30,13 @@ def __init_subclass__(cls) -> None: cls.__abstract__ = False - graphql_def = cls.__validate_schema__( + cls.graphql_def = cls.__validate_schema__( parse_definition(cls.__name__, cls.__schema__) ) - cls.graphql_name = graphql_def.name.value - cls.graphql_type = type(graphql_def) - cls.graphql_fields = cls.__get_fields__(graphql_def) + cls.graphql_name = cls.graphql_def.name.value + cls.graphql_type = type(cls.graphql_def) + cls.graphql_fields = cls.__get_fields__(cls.graphql_def) if callable(cls.__args__): # pylint: disable=not-callable @@ -44,9 +45,11 @@ def __init_subclass__(cls) -> None: cls.__validate_args__() requirements = cls.__get_requirements__() - cls.__validate_requirements_contain_extended_type__(graphql_def, requirements) + cls.__validate_requirements_contain_extended_type__( + cls.graphql_def, requirements + ) - dependencies = cls.__get_dependencies__(graphql_def) + dependencies = cls.__get_dependencies__(cls.graphql_def) cls.__validate_requirements__(requirements, dependencies) @classmethod diff --git a/ariadne_graphql_modules/interface_type.py b/ariadne_graphql_modules/v1/interface_type.py similarity index 89% rename from ariadne_graphql_modules/interface_type.py rename to ariadne_graphql_modules/v1/interface_type.py index a9b054a..c51f28f 100644 --- a/ariadne_graphql_modules/interface_type.py +++ b/ariadne_graphql_modules/v1/interface_type.py @@ -16,8 +16,8 @@ from .bases import BindableType from .dependencies import Dependencies, get_dependencies_from_object_type from .resolvers_mixin import ResolversMixin -from .types import FieldsDict, RequirementsDict -from .utils import parse_definition +from ..types import FieldsDict, RequirementsDict +from ..utils import parse_definition InterfaceNodeType = Union[InterfaceTypeDefinitionNode, InterfaceTypeExtensionNode] @@ -41,18 +41,20 @@ def __init_subclass__(cls) -> None: cls.__abstract__ = False - graphql_def = cls.__validate_schema__( + cls.graphql_def = cls.__validate_schema__( parse_definition(cls.__name__, cls.__schema__) ) - cls.graphql_name = graphql_def.name.value - cls.graphql_type = type(graphql_def) - cls.graphql_fields = cls.__get_fields__(graphql_def) + cls.graphql_name = cls.graphql_def.name.value + cls.graphql_type = type(cls.graphql_def) + cls.graphql_fields = cls.__get_fields__(cls.graphql_def) requirements = cls.__get_requirements__() - cls.__validate_requirements_contain_extended_type__(graphql_def, requirements) + cls.__validate_requirements_contain_extended_type__( + cls.graphql_def, requirements + ) - dependencies = cls.__get_dependencies__(graphql_def) + dependencies = cls.__get_dependencies__(cls.graphql_def) cls.__validate_requirements__(requirements, dependencies) if callable(cls.__fields_args__): diff --git a/ariadne_graphql_modules/mutation_type.py b/ariadne_graphql_modules/v1/mutation_type.py similarity index 91% rename from ariadne_graphql_modules/mutation_type.py rename to ariadne_graphql_modules/v1/mutation_type.py index ba41e2b..f579d3f 100644 --- a/ariadne_graphql_modules/mutation_type.py +++ b/ariadne_graphql_modules/v1/mutation_type.py @@ -10,8 +10,8 @@ from .bases import BindableType from .dependencies import Dependencies, get_dependencies_from_object_type -from .types import RequirementsDict -from .utils import parse_definition +from ..types import RequirementsDict +from ..utils import parse_definition MutationArgs = Dict[str, str] ObjectNodeType = Union[ObjectTypeDefinitionNode, ObjectTypeExtensionNode] @@ -35,20 +35,22 @@ def __init_subclass__(cls) -> None: cls.__abstract__ = False - graphql_def = cls.__validate_schema__( + cls.graphql_def = cls.__validate_schema__( parse_definition(cls.__name__, cls.__schema__) ) - cls.graphql_name = graphql_def.name.value - cls.graphql_type = type(graphql_def) + cls.graphql_name = cls.graphql_def.name.value + cls.graphql_type = type(cls.graphql_def) - field = cls.__get_field__(graphql_def) + field = cls.__get_field__(cls.graphql_def) cls.mutation_name = field.name.value requirements = cls.__get_requirements__() - cls.__validate_requirements_contain_extended_type__(graphql_def, requirements) + cls.__validate_requirements_contain_extended_type__( + cls.graphql_def, requirements + ) - dependencies = cls.__get_dependencies__(graphql_def) + dependencies = cls.__get_dependencies__(cls.graphql_def) cls.__validate_requirements__(requirements, dependencies) if callable(cls.__args__): diff --git a/ariadne_graphql_modules/object_type.py b/ariadne_graphql_modules/v1/object_type.py similarity index 89% rename from ariadne_graphql_modules/object_type.py rename to ariadne_graphql_modules/v1/object_type.py index c252090..e5183a6 100644 --- a/ariadne_graphql_modules/object_type.py +++ b/ariadne_graphql_modules/v1/object_type.py @@ -10,8 +10,8 @@ from .bases import BindableType from .dependencies import Dependencies, get_dependencies_from_object_type from .resolvers_mixin import ResolversMixin -from .types import FieldsDict, RequirementsDict -from .utils import parse_definition +from ..types import FieldsDict, RequirementsDict +from ..utils import parse_definition ObjectNodeType = Union[ObjectTypeDefinitionNode, ObjectTypeExtensionNode] @@ -32,18 +32,20 @@ def __init_subclass__(cls) -> None: cls.__abstract__ = False - graphql_def = cls.__validate_schema__( + cls.graphql_def = cls.__validate_schema__( parse_definition(cls.__name__, cls.__schema__) ) - cls.graphql_name = graphql_def.name.value - cls.graphql_type = type(graphql_def) - cls.graphql_fields = cls.__get_fields__(graphql_def) + cls.graphql_name = cls.graphql_def.name.value + cls.graphql_type = type(cls.graphql_def) + cls.graphql_fields = cls.__get_fields__(cls.graphql_def) requirements = cls.__get_requirements__() - cls.__validate_requirements_contain_extended_type__(graphql_def, requirements) + cls.__validate_requirements_contain_extended_type__( + cls.graphql_def, requirements + ) - dependencies = cls.__get_dependencies__(graphql_def) + dependencies = cls.__get_dependencies__(cls.graphql_def) cls.__validate_requirements__(requirements, dependencies) if callable(cls.__fields_args__): diff --git a/ariadne_graphql_modules/resolvers_mixin.py b/ariadne_graphql_modules/v1/resolvers_mixin.py similarity index 93% rename from ariadne_graphql_modules/resolvers_mixin.py rename to ariadne_graphql_modules/v1/resolvers_mixin.py index c91fee4..ec24112 100644 --- a/ariadne_graphql_modules/resolvers_mixin.py +++ b/ariadne_graphql_modules/v1/resolvers_mixin.py @@ -1,9 +1,9 @@ -from typing import Callable, Dict, Optional, Union +from typing import Callable, Dict, Optional, Tuple, Union -from graphql import GraphQLFieldResolver +from graphql import GraphQLFieldResolver, NamedTypeNode -from .types import FieldsDict -from .utils import create_alias_resolver +from ..types import FieldsDict +from ..utils import create_alias_resolver Aliases = Dict[str, str] FieldsArgs = Dict[str, Dict[str, str]] @@ -19,6 +19,7 @@ class ResolversMixin: graphql_fields: FieldsDict resolvers: Dict[str, GraphQLFieldResolver] + interfaces: Tuple[NamedTypeNode, ...] @classmethod def __validate_aliases__(cls): diff --git a/ariadne_graphql_modules/scalar_type.py b/ariadne_graphql_modules/v1/scalar_type.py similarity index 88% rename from ariadne_graphql_modules/scalar_type.py rename to ariadne_graphql_modules/v1/scalar_type.py index 1eeb3d7..3da456d 100644 --- a/ariadne_graphql_modules/scalar_type.py +++ b/ariadne_graphql_modules/v1/scalar_type.py @@ -12,8 +12,8 @@ ) from .bases import BindableType -from .types import RequirementsDict -from .utils import parse_definition +from ..types import RequirementsDict +from ..utils import parse_definition ScalarNodeType = Union[ScalarTypeDefinitionNode, ScalarTypeExtensionNode] @@ -35,15 +35,17 @@ def __init_subclass__(cls) -> None: cls.__abstract__ = False - graphql_def = cls.__validate_schema__( + cls.graphql_def = cls.__validate_schema__( parse_definition(cls.__name__, cls.__schema__) ) - cls.graphql_name = graphql_def.name.value - cls.graphql_type = type(graphql_def) + cls.graphql_name = cls.graphql_def.name.value + cls.graphql_type = type(cls.graphql_def) requirements = cls.__get_requirements__() - cls.__validate_requirements_contain_extended_type__(graphql_def, requirements) + cls.__validate_requirements_contain_extended_type__( + cls.graphql_def, requirements + ) @classmethod def __validate_schema__(cls, type_def: DefinitionNode) -> ScalarNodeType: diff --git a/ariadne_graphql_modules/subscription_type.py b/ariadne_graphql_modules/v1/subscription_type.py similarity index 100% rename from ariadne_graphql_modules/subscription_type.py rename to ariadne_graphql_modules/v1/subscription_type.py diff --git a/ariadne_graphql_modules/union_type.py b/ariadne_graphql_modules/v1/union_type.py similarity index 85% rename from ariadne_graphql_modules/union_type.py rename to ariadne_graphql_modules/v1/union_type.py index 6551c46..94b8575 100644 --- a/ariadne_graphql_modules/union_type.py +++ b/ariadne_graphql_modules/v1/union_type.py @@ -11,8 +11,8 @@ from .bases import BindableType from .dependencies import Dependencies, get_dependencies_from_union_type -from .types import RequirementsDict -from .utils import parse_definition +from ..types import RequirementsDict +from ..utils import parse_definition UnionNodeType = Union[UnionTypeDefinitionNode, UnionTypeExtensionNode] @@ -31,17 +31,19 @@ def __init_subclass__(cls) -> None: cls.__abstract__ = False - graphql_def = cls.__validate_schema__( + cls.graphql_def = cls.__validate_schema__( parse_definition(cls.__name__, cls.__schema__) ) - cls.graphql_name = graphql_def.name.value - cls.graphql_type = type(graphql_def) + cls.graphql_name = cls.graphql_def.name.value + cls.graphql_type = type(cls.graphql_def) # type: ignore requirements = cls.__get_requirements__() - cls.__validate_requirements_contain_extended_type__(graphql_def, requirements) + cls.__validate_requirements_contain_extended_type__( + cls.graphql_def, requirements + ) - dependencies = cls.__get_dependencies__(graphql_def) + dependencies = cls.__get_dependencies__(cls.graphql_def) cls.__validate_requirements__(requirements, dependencies) @classmethod diff --git a/ariadne_graphql_modules/validators.py b/ariadne_graphql_modules/validators.py new file mode 100644 index 0000000..a266768 --- /dev/null +++ b/ariadne_graphql_modules/validators.py @@ -0,0 +1,24 @@ +from typing import Any + +from ariadne_graphql_modules.base import GraphQLType + + +def validate_name(cls: type[GraphQLType], definition: Any): + graphql_name = getattr(cls, "__graphql_name__", None) + + if graphql_name and definition.name.value != graphql_name: + raise ValueError( + f"Class '{cls.__name__}' defines both '__graphql_name__' and " + f"'__schema__' attributes, but names in those don't match. " + f"('{graphql_name}' != '{definition.name.value}')" + ) + + setattr(cls, "__graphql_name__", definition.name.value) + + +def validate_description(cls: type[GraphQLType], definition: Any): + if getattr(cls, "__description__", None) and definition.description: + raise ValueError( + f"Class '{cls.__name__}' defines description in both " + "'__description__' and '__schema__' attributes." + ) diff --git a/ariadne_graphql_modules/value.py b/ariadne_graphql_modules/value.py new file mode 100644 index 0000000..2dfbd66 --- /dev/null +++ b/ariadne_graphql_modules/value.py @@ -0,0 +1,91 @@ +from collections.abc import Iterable, Mapping +from decimal import Decimal +from enum import Enum +from typing import Any + +from graphql import ( + BooleanValueNode, + ConstListValueNode, + ConstObjectFieldNode, + ConstObjectValueNode, + ConstValueNode, + EnumValueNode, + FloatValueNode, + IntValueNode, + ListValueNode, + NameNode, + NullValueNode, + ObjectValueNode, + StringValueNode, +) + + +def get_value_node(value: Any): + if value is False or value is True: + return BooleanValueNode(value=value) + + if isinstance(value, Enum): + return EnumValueNode(value=value.name) + + if isinstance(value, (float, Decimal)): + return FloatValueNode(value=str(value)) + + if isinstance(value, int): + return IntValueNode(value=str(value)) + + if value is None: + return NullValueNode() + + if isinstance(value, str): + return StringValueNode(value=value, block="\n" in value) + + if isinstance(value, Mapping): + return ConstObjectValueNode( + fields=tuple( + ConstObjectFieldNode( + name=NameNode(value=str(name)), value=get_value_node(val) + ) + for name, val in value.items() + ), + ) + + if isinstance(value, Iterable): + return ConstListValueNode( + values=tuple(get_value_node(val) for val in value), + ) + + raise TypeError( + f"Python value '{repr(value)}' can't be represented as a GraphQL value node." + ) + + +def get_value_from_node(node: ConstValueNode) -> Any: + if isinstance(node, BooleanValueNode): + return node.value + + if isinstance(node, EnumValueNode): + return node.value + + if isinstance(node, FloatValueNode): + return Decimal(node.value) + + if isinstance(node, IntValueNode): + return int(node.value) + + if isinstance(node, NullValueNode): + return None + + if isinstance(node, StringValueNode): + return node.value + + if isinstance(node, (ConstObjectValueNode, ObjectValueNode)): + return { + field.name.value: get_value_from_node(field.value) for field in node.fields + } + + if isinstance(node, ListValueNode): + return [get_value_from_node(value) for value in node.values] + + raise TypeError( + f"GraphQL node '{repr(node)}' can't be represented as a Python value." + ) diff --git a/docs/api/base_type.md b/docs/api/base_type.md new file mode 100644 index 0000000..9937659 --- /dev/null +++ b/docs/api/base_type.md @@ -0,0 +1,63 @@ + +# `GraphQLType` Class Documentation + +The `GraphQLType` class is a foundational class in the `ariadne_graphql_modules` library. It provides the absolute minimum structure needed to create a custom GraphQL type. This class is designed to be subclassed, allowing developers to define custom types with specific behaviors and properties in a GraphQL schema. + +## Class Attributes + +- **`__graphql_name__`**: *(Optional[str])* The custom name for the GraphQL type. If not provided, it will be generated based on the class name. +- **`__description__`**: *(Optional[str])* A description of the GraphQL type, included in the schema documentation. +- **`__abstract__`**: *(bool)* Indicates whether the class is abstract. Defaults to `True`. + +## Core Methods + +### `__get_graphql_name__(cls) -> str` + +This method returns the GraphQL name of the type. If the `__graphql_name__` attribute is set, it returns that value. Otherwise, it generates a name based on the class name by removing common suffixes like "GraphQL", "Type", etc. + +- **Returns:** `str` - The GraphQL name of the type. + +### `__get_graphql_model__(cls, metadata: "GraphQLMetadata") -> "GraphQLModel"` + +This method must be implemented by subclasses. It is responsible for generating the GraphQL model for the type. The model defines how the type is represented in a GraphQL schema. + +- **Parameters:** + - `metadata` (GraphQLMetadata): Metadata for the GraphQL model. +- **Returns:** `GraphQLModel` - The model representation of the type. + +### `__get_graphql_types__(cls, _: "GraphQLMetadata") -> Iterable[Union[Type["GraphQLType"], Type[Enum]]]` + +This method returns an iterable containing the GraphQL types associated with this type. By default, it returns the class itself. + +- **Parameters:** + - `_` (GraphQLMetadata): Metadata for the GraphQL model (unused in this method). +- **Returns:** `Iterable[Union[Type[GraphQLType], Type[Enum]]]` - The associated GraphQL types. + +## Example Usage + +### Creating a Custom GraphQL Type + +To create a custom GraphQL type, subclass `GraphQLType` and implement the required methods. + +```python +from ariadne_graphql_modules import GraphQLType, GraphQLMetadata, GraphQLModel + +class MyCustomType(GraphQLType): + @classmethod + def __get_graphql_model__(cls, metadata: GraphQLMetadata) -> GraphQLModel: + # Implement the logic to create and return a GraphQLModel + pass +``` + +### Custom Naming + +You can specify a custom GraphQL name by setting the `__graphql_name__` attribute: + +```python +class MyCustomType(GraphQLType): + __graphql_name__ = "MyType" + + @classmethod + def __get_graphql_model__(cls, metadata: GraphQLMetadata) -> GraphQLModel: + pass +``` diff --git a/docs/api/deferred_type.md b/docs/api/deferred_type.md new file mode 100644 index 0000000..bd55a24 --- /dev/null +++ b/docs/api/deferred_type.md @@ -0,0 +1,45 @@ +# deferred + +This module provides a mechanism to defer type information, particularly useful for handling forward references and circular dependencies in Python modules. The key components include the `DeferredTypeData` data class and the `deferred` function. + +## deferred Function + +The `deferred` function creates a `DeferredTypeData` object from a given module path. If the module path is relative (i.e., starts with a '.'), the function resolves it based on the caller's package context. + +### Parameters + +- `module_path` (str): The module path where the deferred type resides. This can be an absolute or a relative path. + +### Example Usage + +```python +from ariadne_graphql_modules import deferred + +# Deferring a type with an absolute module path +deferred_type = deferred('some_module.TypeName') + +# Deferring a type with a relative module path +deferred_type_relative = deferred('.TypeName') +``` + +### Error Handling + +- Raises `RuntimeError` if the `deferred` function is not called within a class attribute definition context. +- Raises `ValueError` if the relative module path points outside of the current package. + +### Advanced Usage Example + +This example demonstrates how to use the `deferred` function with `Annotated` for forward type references within a `GraphQLObject`. + +```python +from typing import TYPE_CHECKING, Annotated +from ariadne_graphql_modules import GraphQLObject, deferred + +if TYPE_CHECKING: + from .types import ForwardScalar + +class MockType(GraphQLObject): + field: Annotated["ForwardScalar", deferred("tests.types")] +``` + +In this example, the `deferred` function is used in conjunction with `Annotated` to specify that the `field` in `MockType` references a type (`ForwardScalar`) that is defined in the `tests.types` module. This approach is particularly useful when dealing with forward references or circular dependencies in type annotations. diff --git a/docs/api/enum_type.md b/docs/api/enum_type.md new file mode 100644 index 0000000..a036a3f --- /dev/null +++ b/docs/api/enum_type.md @@ -0,0 +1,121 @@ +# `GraphQLEnum` + +`GraphQLEnum` is a base class for defining GraphQL enum types in a modular and reusable manner. It allows for the creation of enums using Python's `Enum` class or custom mappings, providing flexibility and ease of integration into your GraphQL schema. + +## Inheritance + +- Inherits from `GraphQLType`. + +## Attributes + +- **`__schema__`**: *(Optional[str])* Holds the GraphQL schema definition string for the object type if provided. +- **`__graphql_name__`**: *(Optional[str])* The custom name for the GraphQL object type. +- **`__description__`**: *(Optional)* A description for the enum, which is included in the schema. +- **`__members__`**: Specifies the members of the enum. This can be a Python `Enum`, a dictionary, or a list of strings. +- **`__members_descriptions__`**: *(Optional)* A dictionary mapping enum members to their descriptions. + + +### `graphql_enum` Function + +The `graphql_enum` function is a decorator that simplifies the process of converting a Python `Enum` class into a GraphQL enum type. This function allows you to specify additional properties such as the GraphQL enum's name, description, and member descriptions, as well as control which members are included or excluded in the GraphQL schema. + +#### Parameters + +- **`cls`**: *(Optional[Type[Enum]])* + The Python `Enum` class that you want to convert to a GraphQL enum. This parameter is optional because the function can be used as a decorator. + +- **`name`**: *(Optional[str])* + The name of the GraphQL enum. If not provided, the name of the `Enum` class will be used. + +- **`description`**: *(Optional[str])* + A description for the GraphQL enum, which will be included in the schema. + +- **`members_descriptions`**: *(Optional[Dict[str, str]])* + A dictionary mapping enum members to their descriptions. This allows for detailed documentation of each enum member in the GraphQL schema. + +- **`members_include`**: *(Optional[Iterable[str]])* + A list of member names to include in the GraphQL enum. If not provided, all members of the `Enum` will be included. + +- **`members_exclude`**: *(Optional[Iterable[str]])* + A list of member names to exclude from the GraphQL enum. This allows you to omit specific members from the GraphQL schema. + +#### Returns + +- **`graphql_enum_decorator`**: *(Callable)* + A decorator function that attaches a `__get_graphql_model__` method to the `Enum` class. This method returns the GraphQL model for the enum, making it ready to be integrated into your GraphQL schema. + +## Usage Examples + +### Basic Enum Definition + +Here’s how to define a basic enum type using `GraphQLEnum` with a Python `Enum`: + +```python +from enum import Enum +from ariadne_graphql_modules import GraphQLEnum + +class UserLevelEnum(Enum): + GUEST = 0 + MEMBER = 1 + ADMIN = 2 + +class UserLevel(GraphQLEnum): + __members__ = UserLevelEnum +``` + +### Enum with Custom Schema + +You can define the enum schema directly using SDL: + +```python +class UserLevel(GraphQLEnum): + __schema__ = """ + enum UserLevel { + GUEST + MEMBER + ADMIN + } + """ + __members__ = UserLevelEnum +``` + +### Enum with Descriptions + +You can add descriptions to both the enum and its members: + +```python +class UserLevel(GraphQLEnum): + __description__ = "User access levels." + __members__ = UserLevelEnum + __members_descriptions__ = { + "MEMBER": "A registered user.", + "ADMIN": "An administrator with full access." + } +``` + +### Example Usage of *graphql_enum* function + +Here’s an example of how to use the `graphql_enum` decorator: + +```python +from enum import Enum +from ariadne_graphql_modules import graphql_enum + +@graphql_enum +class SeverityLevel(Enum): + LOW = 0 + MEDIUM = 1 + HIGH = 2 + +# Access the GraphQL model +graphql_model = SeverityLevel.__get_graphql_model__() + +# The GraphQL model can now be used in your schema +``` + +In this example: + +- The `SeverityLevel` enum is decorated with `graphql_enum`, automatically converting it into a GraphQL enum. +- The `__get_graphql_model__` method is added to `SeverityLevel`, which returns the GraphQL model, including the enum name, members, and corresponding AST. + +This function allows for an easy transition from Python enums to GraphQL enums, providing flexibility in customizing the GraphQL schema with descriptions and member selections. diff --git a/docs/api/id_type.md b/docs/api/id_type.md new file mode 100644 index 0000000..ba7f717 --- /dev/null +++ b/docs/api/id_type.md @@ -0,0 +1,85 @@ + +# `GraphQLID` + +The `GraphQLID` class represents a unique identifier in a GraphQL schema. This class is designed to handle ID values in a consistent manner by allowing them to be treated as either strings or integers. + +## Class Attributes + +- `value`: **str**. This attribute stores the string representation of the ID value. + +## Usage + +Below is an example of how the `GraphQLID` class can be used to create and compare ID values: + +```python +id1 = GraphQLID(123) +id2 = GraphQLID("123") +assert id1 == id2 # True, because both represent the same ID +``` + +## Methods + +### `__init__(self, value: Union[int, str])` +The constructor accepts either an integer or a string and stores it as a string in the `value` attribute. + +- **Parameters:** + - `value` (Union[int, str]): The ID value to be stored. + +### `__eq__(self, value: Any) -> bool` +Compares the `GraphQLID` instance with another value. It returns `True` if the other value is either a string, an integer, or another `GraphQLID` instance that represents the same ID. + +- **Parameters:** + - `value` (Any): The value to compare with. +- **Returns:** `bool` - `True` if the values are equal, `False` otherwise. + +### `__int__(self) -> int` +Converts the `GraphQLID` value to an integer. + +- **Returns:** `int` - The integer representation of the ID. + +### `__str__(self) -> str` +Returns the string representation of the `GraphQLID` value. + +- **Returns:** `str` - The string representation of the ID. + +## Example Use Cases + +### 1. Creating IDs +You can create a `GraphQLID` from either an integer or a string, and it will always store the value as a string internally. + +```python +id1 = GraphQLID(123) +id2 = GraphQLID("456") +``` + +### 2. Comparing IDs +The `GraphQLID` class allows for comparison between different types (e.g., integers, strings, or other `GraphQLID` instances) as long as they represent the same ID. + +```python +id1 = GraphQLID(123) +id2 = GraphQLID("123") +assert id1 == id2 # True +``` + +### 3. Converting to Integer or String +You can easily convert a `GraphQLID` to either an integer or a string. + +```python +id1 = GraphQLID(789) +print(int(id1)) # Outputs: 789 +print(str(id1)) # Outputs: "789" +``` + +### 4. Using `GraphQLID` in Other Object Types +The `GraphQLID` type can be used as an identifier in other GraphQL object types, allowing you to define unique fields across your schema. + +```python +from ariadne_graphql_modules import GraphQLObject, GraphQLID + +class Message(GraphQLObject): + id: GraphQLID + content: str + author: str +``` + +In this example, `Message` is a GraphQL object type where `id` is a `GraphQLID`, ensuring each message has a unique identifier. diff --git a/docs/api/input_type.md b/docs/api/input_type.md new file mode 100644 index 0000000..9898812 --- /dev/null +++ b/docs/api/input_type.md @@ -0,0 +1,240 @@ +# `GraphQLInput` + +`GraphQLInput` is a base class used to define GraphQL input types in a modular way. It allows you to create structured input objects with fields, default values, and custom validation, making it easier to handle complex input scenarios in your GraphQL API. + +## Inheritance + +- Inherits from `GraphQLType`. + +## Class Attributes + +- **`__schema__`**: *(Optional[str])* - The GraphQL schema definition string for the union, if provided. +- **`__graphql_name__`**: *(Optional[str])* The custom name for the GraphQL type. If not provided, it will be generated based on the class name. +- **`__description__`**: *(Optional[str])* A description of the GraphQL type, included in the schema documentation. +- **`__out_names__`**: *(Optional)* A dictionary to customize the names of fields when they are used as output in other parts of the schema. + +## Class Methods + +### `field` + +A static method that defines a field within the input type. It allows you to specify the field's name, type, description, and default value. + +```python +@staticmethod +def field( + *, + name: Optional[str] = None, + graphql_type: Optional[Any] = None, + description: Optional[str] = None, + default_value: Optional[Any] = None, +) -> Any: + ... +``` + +## Examples + +### Basic Input Type + +Here’s how to define a basic input type using `GraphQLInput`: + +```python +from ariadne_graphql_modules import GraphQLInput + +class SearchInput(GraphQLInput): + query: str + age: int +``` + +### Input Type with Default Values + +You can define default values for input fields using class attributes: + +```python +class SearchInput(GraphQLInput): + query: str = "default search" + age: int = 18 +``` + +### Input Type with Custom Field Definitions + +You can use the `field` method to define fields with more control over their attributes, such as setting a default value or adding a description: + +```python +class SearchInput(GraphQLInput): + query: str = GraphQLInput.field(default_value="default search", description="Search query") + age: int = GraphQLInput.field(default_value=18, description="Age filter") +``` + +### Using `GraphQLInput` in a Query + +The `GraphQLInput` can be used as an argument in GraphQL object types. This allows you to pass complex structured data to queries or mutations. + +```python +from ariadne_graphql_modules import GraphQLInput, GraphQLObject, make_executable_schema + + +class SearchInput(GraphQLInput): + query: Optional[str] + age: Optional[int] + +class QueryType(GraphQLObject): + search: str + + @GraphQLObject.resolver("search") + def resolve_search(*_, input: SearchInput) -> str: + return f"{repr([input.query, input.age])}" +``` + +In this example, `SearchInput` is used as an argument in the `search` query, allowing clients to pass in a structured input object. + +## Validation + +Validation is a key feature of `GraphQLInput`. The class provides built-in validation to ensure that the input schema and fields are correctly defined and that they adhere to GraphQL standards. + +### Validation Scenarios + +#### 1. Invalid Schema Type + +If the schema defined in `__schema__` does not correspond to an input type, a `ValueError` is raised. + +```python +from ariadne import gql +from ariadne_graphql_modules import GraphQLInput + +try: + class CustomType(GraphQLInput): + __schema__ = gql("scalar Custom") +except ValueError as e: + print(e) # Outputs error regarding invalid type schema +``` + +#### 2. Name Mismatch + +If the class name and the name defined in the schema do not match, a `ValueError` is raised. + +```python +try: + class CustomType(GraphQLInput): + __graphql_name__ = "Lorem" + __schema__ = gql( + ''' + input Custom { + hello: String! + } + ''' + ) +except ValueError as e: + print(e) # Outputs error regarding name mismatch +``` + +#### 3. Duplicate Descriptions + +If both the class and the schema define a description, a `ValueError` is raised due to the conflict. + +```python +try: + class CustomType(GraphQLInput): + __description__ = "Hello world!" + __schema__ = gql( + """ + Other description + """ + input Custom { + hello: String! + } + ''' + ) +except ValueError as e: + print(e) # Outputs error regarding duplicate descriptions +``` + +#### 4. Missing Fields in Schema + +If the input schema is missing fields, a `ValueError` is raised. + +```python +try: + class CustomType(GraphQLInput): + __schema__ = gql("input Custom") +except ValueError as e: + print(e) # Outputs error regarding missing fields +``` + +#### 5. Invalid `__out_names__` without Schema + +If `__out_names__` is defined without a schema, a `ValueError` is raised since this feature requires an explicit schema. + +```python +try: + class CustomType(GraphQLInput): + hello: str + + __out_names__ = { + "hello": "ok", + } +except ValueError as e: + print(e) # Outputs error regarding unsupported out_names without schema +``` + +#### 6. Invalid or Duplicate Out Names + +If an out name is defined for a non-existent field or if there are duplicate out names, a `ValueError` is raised. + +```python +try: + class CustomType(GraphQLInput): + __schema__ = gql( + ''' + input Query { + hello: String! + } + ''' + ) + + __out_names__ = { + "invalid": "ok", # Invalid field name + } +except ValueError as e: + print(e) # Outputs error regarding invalid out_name + +try: + class CustomType(GraphQLInput): + __schema__ = gql( + ''' + input Query { + hello: String! + name: String! + } + ''' + ) + + __out_names__ = { + "hello": "ok", + "name": "ok", # Duplicate out name + } +except ValueError as e: + print(e) # Outputs error regarding duplicate out_names +``` + +#### 7. Unsupported Default Values + +If a default value cannot be represented in the GraphQL schema, a `TypeError` is raised. + +```python +class InvalidType: + pass + +try: + class QueryType(GraphQLInput): + attr: str = InvalidType() +except TypeError as e: + print(e) # Outputs error regarding unsupported default value + +try: + class QueryType(GraphQLInput): + attr: str = GraphQLInput.field(default_value=InvalidType()) +except TypeError as e: + print(e) # Outputs error regarding unsupported field default option +``` + +These validation mechanisms ensure that your `GraphQLInput` types are correctly configured and adhere to GraphQL standards, helping you catch errors early in development. diff --git a/docs/api/interface_type.md b/docs/api/interface_type.md new file mode 100644 index 0000000..c24c8ce --- /dev/null +++ b/docs/api/interface_type.md @@ -0,0 +1,119 @@ + +# `GraphQLInterface` + +`GraphQLInterface` is a base class used to define GraphQL interfaces in a modular and reusable manner. It provides a structured way to create interfaces with fields, resolvers, and descriptions, making it easier to manage complex GraphQL schemas. + +## Inheritance + +- Inherits from `GraphQLBaseObject`. + +## Class Attributes + +- **`__schema__`**: *(Optional[str])* - The GraphQL schema definition string for the union, if provided. +- **`__graphql_name__`**: *(Optional[str])* The custom name for the GraphQL type. If not provided, it will be generated based on the class name. +- **`__description__`**: *(Optional[str])* A description of the GraphQL type, included in the schema documentation. +- **`__graphql_type__`**: Defines the GraphQL class type, set to `GraphQLClassType.BASE`. +- **`__aliases__`**: *(Optional[Dict[str, str]])* Defines field aliases for the object type. +- **`__requires__`**: *(Optional[Iterable[Union[Type[GraphQLType], Type[Enum]]]])* Specifies other types or enums that are required by this object type. + +## Core Methods + +### `resolve_type` + +A static method that resolves the type of an object when using interfaces or unions. This method can be overridden to provide custom type resolution logic. + +### `resolver` + +Defines a resolver for a subscription. The resolver processes the data provided by the source before sending it to the client. + +- **Parameters:** + - `field: str`: The name of the resolver field. + +### `field` + +Defines a field in the subscription type. This is a shortcut for defining subscription fields without a full schema. + +- **Parameters:** + - `name: Optional[str]`: The name of the field that will be created. + +## Inheritance feature + +The `GraphQLInterface` class supports inheritance, allowing interfaces to extend other interfaces. This enables the creation of complex and reusable schemas by composing interfaces from other interfaces. + +- **Interface Inheritance**: When an interface inherits from another interface, it automatically implements the inherited interface, and the `implements` clause will be included in the GraphQL schema. +- **Inheritance Example**: + +```python +class BaseInterface(GraphQLInterface): + summary: str + +class AdvancedInterface(BaseInterface): + details: str +``` + +In this example, `AdvancedInterface` inherits from `BaseInterface`, meaning it will include all fields and logic from `BaseInterface`, and the resulting schema will reflect that `AdvancedInterface` implements `BaseInterface`. + +## Example + +### Basic Interface Definition + +Here’s how to define a basic interface using `GraphQLInterface`: + +```python +from ariadne_graphql_modules import GraphQLInterface + +class UserInterface(GraphQLInterface): + summary: str + score: int +``` + +### Interface with Schema Definition + +You can define the interface schema directly using SDL: + +```python +class UserInterface(GraphQLInterface): + __schema__ = ''' + interface UserInterface { + summary: String! + score: Int! + } + ''' +``` + +### Custom Type Resolution + +You can implement custom logic for resolving types when using the interface: + +```python +class UserInterface(GraphQLInterface): + @staticmethod + def resolve_type(obj, *_): + if isinstance(obj, UserType): + return "UserType" + raise ValueError(f"Cannot resolve type for object {obj}.") +``` + +## Validation + +The `GraphQLInterface` class includes validation logic to ensure that the defined interface schema is correct and consistent with the class attributes. This validation process includes: + +- **Schema Validation**: If the `__schema__` attribute is defined, it is parsed and validated to ensure that it corresponds to a valid GraphQL interface. +- **Name and Description Validation**: Validates that the names and descriptions of the interface and its fields are consistent with GraphQL conventions. +- **Field Validation**: Ensures that all fields defined in the schema have corresponding attributes in the class, and that their types and default values are valid. + +### Validation Example + +If the schema definition or fields are not correctly defined, a `ValueError` will be raised during the validation process: + +```python +from ariadne_graphql_modules import GraphQLInterface + +class InvalidInterface(GraphQLInterface): + __schema__ = ''' + interface InvalidInterface { + summary: String! + invalidField: NonExistentType! + } + ''' +``` diff --git a/docs/api/make_executable_schema.md b/docs/api/make_executable_schema.md new file mode 100644 index 0000000..3d4c47f --- /dev/null +++ b/docs/api/make_executable_schema.md @@ -0,0 +1,110 @@ + +## `make_executable_schema` + +The `make_executable_schema` function constructs an executable GraphQL schema from a list of types, including custom types, SDL strings, and various other GraphQL elements. This function also validates the types for consistency and allows for customization through options like `convert_names_case` and `merge_roots`. + +### Basic Usage + +```python +class UserType(GraphQLObject): + __schema__ = """ + type User { + id: ID! + username: String! + } + """ + + +class QueryType(GraphQLObject): + __schema__ = """ + type Query { + user: User + } + """ + __requires__ = [UserType] + + @staticmethod + def user(*_): + return { + "id": 1, + "username": "Alice", + } + + +schema = make_executable_schema(QueryType) +``` + +### Automatic Merging of Roots + +By default, when multiple `Query`, `Mutation`, or `Subscription` types are passed to `make_executable_schema`, they are merged into a single type containing all the fields from the provided definitions, ordered alphabetically by field name. + +```python +class UserQueriesType(GraphQLObject): + __schema__ = """ + type Query { + user(id: ID!): User + } + """ + + +class ProductsQueriesType(GraphQLObject): + __schema__ = """ + type Query { + product(id: ID!): Product + } + """ + +schema = make_executable_schema(UserQueriesType, ProductsQueriesType) +``` + +This will result in a single `Query` type in the schema: + +```graphql +type Query { + product(id: ID!): Product + user(id: ID!): User +} +``` + +To disable this behavior, you can use the `merge_roots=False` option: + +```python +schema = make_executable_schema( + UserQueriesType, + ProductsQueriesType, + merge_roots=False, +) +``` + +### Name Conversion with `convert_names_case` + +The `convert_names_case` option allows you to apply custom naming conventions to the types, fields, and other elements within the schema. When this option is enabled, it triggers the `convert_schema_names` function. + +```python +def uppercase_name_converter(name: str, schema: GraphQLSchema, path: Tuple[str, ...]) -> str: + return name.upper() + +schema = make_executable_schema(QueryType, convert_names_case=uppercase_name_converter) +``` + +#### How `convert_schema_names` Works + +The `convert_schema_names` function traverses the schema and applies a given `SchemaNameConverter` to rename elements according to custom logic. The converter is a callable that receives the original name, the schema, and the path to the element, and it returns the new name. + +- **`schema`**: The GraphQL schema being modified. +- **`converter`**: A callable that transforms the names based on your custom logic. + +The function ensures that all types, fields, and other schema elements adhere to the naming conventions specified by your converter function. + +#### Example + +If `convert_names_case` is set to `True`, the function will automatically convert names to the convention defined by the `SchemaNameConverter`. For instance, you might convert all names to uppercase: + +```python +def uppercase_converter(name: str, schema: GraphQLSchema, path: Tuple[str, ...]) -> str: + return name.upper() + +schema = make_executable_schema(QueryType, convert_names_case=uppercase_converter) +``` + +This would convert all type and field names in the schema to uppercase. diff --git a/docs/api/object_type.md b/docs/api/object_type.md new file mode 100644 index 0000000..b738b77 --- /dev/null +++ b/docs/api/object_type.md @@ -0,0 +1,229 @@ + +# `GraphQLObject` + +The `GraphQLObject` class is a core component of the `ariadne_graphql_modules` library used to define GraphQL object types. This class allows you to create GraphQL objects that represent structured data with fields, interfaces, and custom resolvers. The class also supports schema-based and schema-less definitions. + +## Inheritance + +- Inherits from `GraphQLBaseObject`. + +## Class Attributes + +- **`__schema__`**: *(Optional[str])* Holds the GraphQL schema definition string for the object type if provided. +- **`__graphql_name__`**: *(Optional[str])* The custom name for the GraphQL object type. +- **`__description__`**: *(Optional[str])* A description of the object type, included in the GraphQL schema. +- **`__graphql_type__`**: Defines the GraphQL type as `GraphQLClassType.OBJECT`. +- **`__aliases__`**: *(Optional[Dict[str, str]])* Defines field aliases for the object type. +- **`__requires__`**: *(Optional[Iterable[Union[Type[GraphQLType], Type[Enum]]]])* Specifies other types or enums that are required by this object type. + +## Core Methods + +### `field` + +The `field` method is a static method that provides a shortcut for defining fields in a `GraphQLObject`. It allows for custom configuration of fields, including setting a specific name, type, arguments, and a description. + +- **Parameters:** + - `f` (Optional[Resolver]): The resolver function for the field. If not provided, the field will use the default resolver. + - `name` (Optional[str]): The name of the field in the GraphQL schema. If not provided, the field name defaults to the attribute name in the class. + - `graphql_type` (Optional[Any]): The GraphQL type of the field. This can be a basic GraphQL type, a custom type, or a list type. + - `args` (Optional[Dict[str, GraphQLObjectFieldArg]]): A dictionary of arguments that the field can accept. + - `description` (Optional[str]): A description for the field, which is included in the GraphQL schema. + - `default_value` (Optional[Any]): A default value for the field. + +- **Returns:** A configured field that can be used in a `GraphQLObject`. + +**Example Usage:** + +```python +class QueryType(GraphQLObject): + hello: str = GraphQLObject.field(description="Returns a greeting", default_value="Hello World!") +``` + +### `resolver` + +The `resolver` method is a static method used to define a resolver for a specific field in a `GraphQLObject`. The resolver processes the data before returning it to the client. + +- **Parameters:** + - `field` (str): The name of the field for which the resolver is being defined. + - `graphql_type` (Optional[Any]): The GraphQL type of the field. + - `args` (Optional[Dict[str, GraphQLObjectFieldArg]]): A dictionary of arguments that the field can accept. + - `description` (Optional[str]): A description for the resolver, which is included in the GraphQL schema. + +- **Returns:** A decorator that can be used to wrap a function, making it a resolver for the specified field. + +**Example Usage:** + +```python +class QueryType(GraphQLObject): + hello: str + + @GraphQLObject.resolver("hello") + def resolve_hello(*_): + return "Hello World!" +``` + +### `argument` + +The `argument` method is a static method used to define an argument for a field in a `GraphQLObject`. This method is particularly useful for adding descriptions and default values to arguments. + +- **Parameters:** + - `name` (Optional[str]): The name of the argument. + - `description` (Optional[str]): A description for the argument. + - `graphql_type` (Optional[Any]): The GraphQL type of the argument. + - `default_value` (Optional[Any]): A default value for the argument. + +- **Returns:** A `GraphQLObjectFieldArg` instance that represents the argument. + +**Example Usage:** + +```python +class QueryType(GraphQLObject): + @GraphQLObject.field( + args={"message": GraphQLObject.argument(description="Message to echo", graphql_type=str)} + ) + def echo_message(obj, info, message: str) -> str: + return message +``` + +## Inheritance Feature and Interface Implementation + +The `GraphQLObject` class supports inheritance, allowing you to extend existing object types or interfaces. This feature enables you to reuse and extend functionality across multiple object types, enhancing modularity and code reuse. + +### Inheriting from `GraphQLInterface` + +When you inherit from a `GraphQLInterface`, the resulting object type will automatically include an `implements` clause in the GraphQL schema. This means the object type will implement all the fields defined by the interface. + +```python +from ariadne_graphql_modules import GraphQLObject, GraphQLInterface + +class NodeInterface(GraphQLInterface): + id: str + +class UserType(GraphQLObject, NodeInterface): + name: str +``` + +In this example, `UserType` will implement the `Node` interface, and the GraphQL schema will include `type User implements Node`. + +### Inheriting from Another Object Type + +When you inherit from another `GraphQLObject`, the derived class will inherit all attributes, resolvers, and custom fields from the base object type. However, the derived object type will not include an `implements` clause in the schema. + +```python +class BaseCategoryType(GraphQLObject): + name: str + description: str + +class CategoryType(BaseCategoryType): + posts: int +``` + +In this example, `CategoryType` inherits the `name` and `description` fields from `BaseCategoryType`, along with any custom resolvers or field configurations. + +## Example Usage + +### Defining an Object Type + +To define an object type, subclass `GraphQLObject` and specify fields using class attributes. + +```python +from ariadne_graphql_modules import GraphQLObject, GraphQLID + +class CategoryType(GraphQLObject): + name: str + posts: int +``` + +### Using a Schema to Define an Object Type + +You can define an object type using a GraphQL schema by setting the `__schema__` attribute. + +```python +from ariadne import gql +from ariadne_graphql_modules import GraphQLObject + +class CategoryType(GraphQLObject): + __schema__ = gql( + """ + type Category { + name: String + posts: Int + } + """ + ) + + name: str + posts: int +``` + +### Adding Resolvers to an Object Type + +You can add custom resolvers to fields in an object type by using the `@GraphQLObject.resolver` decorator. + +```python +class QueryType(GraphQLObject): + hello: str + + @GraphQLObject.resolver("hello") + def resolve_hello(*_): + return "Hello World!" +``` + +### Handling Aliases in Object Types + +You can define aliases for fields in an object type using the `__aliases__` attribute. + +```python +class CategoryType(GraphQLObject): + __aliases__ = {"name": "title"} + + title: str + posts: int +``` + +## Validation + +The `GraphQLObject` class includes validation mechanisms to ensure that object types are correctly defined. Validation is performed both for schema-based and schema-less object types. + +### Validation Process + +1. **Type Checking**: Ensures that the class is of the correct GraphQL type (`OBJECT`). +2. **Schema Parsing and Validation**: If a schema is provided, it is parsed and validated against the class definition. +3. **Field and Alias Validation**: Ensures that all fields and aliases are correctly defined and do not conflict. + +### Validation Example + +Here is an example where validation checks that the provided schema matches the class definition: + +```python +from graphql import gql +from ariadne_graphql_modules import GraphQLObject + +class CategoryType(GraphQLObject): + __schema__ = gql( + """ + type Category { + name: String + posts: Int + } + """ + ) + + name: str + posts: int +``` + +If the class definition and the schema do not match, a `ValueError` will be raised. + +## Example: Using a Field with a Custom Resolver + +```python +from ariadne_graphql_modules import GraphQLObject + +class QueryType(GraphQLObject): + @GraphQLObject.field() + def hello(obj, info) -> str: + return "Hello World!" +``` + +This method will resolve the `hello` field to return `"Hello World!"`. \ No newline at end of file diff --git a/docs/api/scalar_type.md b/docs/api/scalar_type.md new file mode 100644 index 0000000..4793007 --- /dev/null +++ b/docs/api/scalar_type.md @@ -0,0 +1,152 @@ +# `GraphQLScalar` + +The `GraphQLScalar` class is a generic base class in the `ariadne_graphql_modules` library used to define custom scalar types in GraphQL. Scalars in GraphQL represent primitive data types like `Int`, `Float`, `String`, `Boolean`, etc. This class provides the framework for creating custom scalars with serialization and parsing logic. + +## Inheritance + +- Inherits from `GraphQLType` and `Generic[T]`. + +## Class Attributes + +- **`__schema__`**: *(Optional[str])* - The GraphQL schema definition string for the union, if provided. +- **`__graphql_name__`**: *(Optional[str])* The custom name for the GraphQL type. If not provided, it will be generated based on the class name. +- **`__description__`**: *(Optional[str])* A description of the GraphQL type, included in the schema documentation. +- **`wrapped_value`** (T): Stores the value wrapped by the scalar. + +## Initialization + +### `__init__(self, value: T)` + +- **Parameters:** + - `value` (T): The value to be wrapped by the scalar instance. Saved on `wrapped_value` class attribute + +## Class Methods + +### `serialize` + +Serializes the scalar's value. If the value is an instance of the scalar, it unwraps the value before serialization. + +- **Parameters:** + - `value: Any`: The value to serialize. + +### `parse_value` + +Parses the given value, typically used when receiving input from a GraphQL query. + +- **Parameters:** + - `value: Any`: The value to parse. + +### `parse_literal` + +Parses a literal GraphQL value. This method is used to handle literals in GraphQL queries. + +- **Parameters:** + - `node: ValueNode`: The AST node representing the value. + - `variables: (Optional[Dict[str, Any]])`: A dictionary of variables in the GraphQL query. + +## Example + +### Defining a Custom Scalar + +To define a custom scalar, subclass `GraphQLScalar` and implement the required serialization and parsing methods. + +```python +from datetime import date +from ariadne_graphql_modules import GraphQLScalar + +class DateScalar(GraphQLScalar[date]): + @classmethod + def serialize(cls, value): + if isinstance(value, cls): + return str(value.unwrap()) + return str(value) +``` + +### Using a Custom Scalar in a Schema + +```python +from ariadne_graphql_modules import GraphQLObject, make_executable_schema + +class QueryType(GraphQLObject): + date: DateScalar + + @GraphQLObject.resolver("date") + def resolve_date(*_) -> DateScalar: + return DateScalar(date(1989, 10, 30)) + +schema = make_executable_schema(QueryType) +``` + +### Handling Scalars with and without Schema + +You can define a scalar with or without an explicit schema definition: + +```python +class SchemaDateScalar(GraphQLScalar[date]): + __schema__ = "scalar Date" + + @classmethod + def serialize(cls, value): + if isinstance(value, cls): + return str(value.unwrap()) + return str(value) +``` + +### Using `parse_value` and `parse_literal` + +The `parse_value` and `parse_literal` methods are crucial for processing input values in GraphQL queries. They ensure that the values provided by the client are correctly interpreted according to the scalar's logic. + +#### Example: `parse_value` + +The `parse_value` method is typically used to convert input values from GraphQL queries into the appropriate type for use in the application. + +```python +class DateScalar(GraphQLScalar[str]): + @classmethod + def parse_value(cls, value): + # Assume value is a string representing a date, e.g., "2023-01-01" + try: + return datetime.strptime(value, "%Y-%m-%d").date() + except ValueError: + raise ValueError(f"Invalid date format: {value}") + +# Example usage in a query +parsed_date = DateScalar.parse_value("2023-01-01") +print(parsed_date) # Outputs: 2023-01-01 as a `date` object +``` + +In this example, `parse_value` converts a string into a `date` object. If the string does not match the expected format, an error is raised. + +#### Example: `parse_literal` + +The `parse_literal` method is used to handle literal values directly from the GraphQL query's Abstract Syntax Tree (AST). This is useful when dealing with inline values in queries. + +```python +from graphql import StringValueNode + +class DateScalar(GraphQLScalar[str]): + @classmethod + def parse_literal(cls, node, variables=None): + if isinstance(node, StringValueNode): + return cls.parse_value(node.value) + raise ValueError("Invalid AST node type") + +# Example usage in a query +parsed_date = DateScalar.parse_literal(StringValueNode(value="2023-01-01")) +print(parsed_date) # Outputs: 2023-01-01 as a `date` object +``` + +In this example, `parse_literal` checks if the AST node is of the correct type (`StringValueNode`) and then applies the `parse_value` logic to convert it. + +### Validation + +The `GraphQLScalar` class includes a validation mechanism to ensure that custom scalar types are correctly defined and conform to GraphQL standards. This validation is especially important when defining a scalar with an explicit schema using the `__schema__` attribute. + +#### Validation Process + +When a custom scalar class defines a schema using the `__schema__` attribute, the following validation steps occur: + +1. **Schema Parsing**: The schema string is parsed to ensure it corresponds to a valid GraphQL scalar type. +2. **Type Checking**: The parsed schema is checked to confirm it is of the correct type (`ScalarTypeDefinitionNode`). +3. **Name Validation**: The scalar's name is validated to ensure it adheres to GraphQL naming conventions. +4. **Description Validation**: The scalar's description is validated to ensure it is consistent and correctly formatted. diff --git a/docs/api/subscription_type.md b/docs/api/subscription_type.md new file mode 100644 index 0000000..e5f83a9 --- /dev/null +++ b/docs/api/subscription_type.md @@ -0,0 +1,136 @@ + +# `GraphQLSubscription` + +The `GraphQLSubscription` class is designed to facilitate the creation and management of GraphQL subscriptions within a schema. Subscriptions in GraphQL allow clients to receive real-time updates when specific events occur. + +## Inheritance + +- Inherits from `GraphQLBaseObject`. + +## Class Attributes + +- **`__schema__`**: *(Optional[str])* - The GraphQL schema definition string for the union, if provided. +- **`__graphql_name__`**: *(Optional[str])* The custom name for the GraphQL type. If not provided, it will be generated based on the class name. +- **`__description__`**: *(Optional[str])* A description of the GraphQL type, included in the schema documentation. +- **`__graphql_type__`**: Defines the GraphQL class type, set to `GraphQLClassType.BASE`. +- **`__aliases__`**: *(Optional[Dict[str, str]])* Defines field aliases for the object type. +- **`__requires__`**: *(Optional[Iterable[Union[Type[GraphQLType], Type[Enum]]]])* Specifies other types or enums that are required by this object type. + +## Class Methods and Decorators + +### `source` + +Defines a source for a subscription. The source is an async generator that yields the data to be sent to clients. +- **Parameters:** + - `field: str`: The name of the subscription field. + - `graphql_type: Optional[Any]`: The GraphQL type of the field. + - `args: Optional[Dict[str, GraphQLObjectFieldArg]]`: Optional arguments that the subscription can accept. + - `description: Optional[str]`: An optional description for the subscription field. + +### `resolver` + +Defines a resolver for a subscription. The resolver processes the data provided by the source before sending it to the client. + +- **Parameters:** + - `field: str`: The name of the resolver field. + +### `field` + +Defines a field in the subscription type. This is a shortcut for defining subscription fields without a full schema. + +- **Parameters:** + - `name: Optional[str]`: The name of the field that will be created. + +## Field Definition Limitations + +In `GraphQLSubscription`, detailed field definitions, such as specifying arguments or custom types, must be done using the `source` method. This ensures that the source and resolver methods are properly aligned and that the subscription functions as expected. + +## Inheritance feature + +`GraphQLSubscription` does not support inheritance from other subscription classes. Each subscription class must define its fields and logic independently. This limitation is intentional to prevent issues with overlapping field definitions and resolver conflicts. + +## Example + +### Basic Subscription + +Define a simple subscription that notifies clients whenever a new message is added: + +```python +from ariadne_graphql_modules import GraphQLSubscription, GraphQLObject, make_executable_schema + +class Message(GraphQLObject): + id: GraphQLID + content: str + author: str + +class SubscriptionType(GraphQLSubscription): + message_added: Message + + @GraphQLSubscription.source("message_added", graphql_type=Message) + async def message_added_generator(obj, info): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + @GraphQLSubscription.resolver("message_added") + async def resolve_message_added(message, info): + return message + +schema = make_executable_schema(SubscriptionType) +``` + +### Subscriptions with Arguments + +Handle subscriptions that accept arguments: + +```python +class SubscriptionType(GraphQLSubscription): + + @GraphQLSubscription.source( + "message_added", + args={"channel": GraphQLObject.argument(description="Lorem ipsum.")}, + graphql_type=Message, + ) + async def message_added_generator(obj, info, channel: GraphQLID): + while True: + yield {"id": "some_id", "content": f"message_{channel}", "author": "Anon"} + + @GraphQLSubscription.field() + def message_added(message, info, channel: GraphQLID): + return message +``` + +### Schema-based Subscription + +Define a subscription using a GraphQL schema: + +```python +from ariadne import gql + +class SubscriptionType(GraphQLSubscription): + __schema__ = gql( + ''' + type Subscription { + messageAdded: Message! + } + ''' + ) + + @GraphQLSubscription.source("messageAdded", graphql_type=Message) + async def message_added_generator(obj, info): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + @GraphQLSubscription.resolver("messageAdded") + async def resolve_message_added(message, info): + return message +``` + +## Validation + +Validation in `GraphQLSubscription` works similarly to that in `GraphQLObject`. The following validation checks are performed: + +- **Field Definitions**: Fields must be properly defined, either through SDL in the `__schema__` attribute or directly within the class using the `source` and `resolver` decorators. +- **Schema Consistency**: The schema is checked for consistency, ensuring that all fields and their types are correctly defined. +- **Field Names**: The field names defined in the subscription must match those in the schema. + +If any of these checks fail, a `ValueError` will be raised during schema construction. diff --git a/docs/api/union_type.md b/docs/api/union_type.md new file mode 100644 index 0000000..352c8a2 --- /dev/null +++ b/docs/api/union_type.md @@ -0,0 +1,106 @@ +# `GraphQLUnion` + +The `GraphQLUnion` class is used to define custom union types in GraphQL. A union type is a GraphQL feature that allows a field to return one of several different types, but only one of them at any given time. This class provides the framework for defining unions, managing their types, and handling type resolution. + +## Inheritance + +- Inherits from `GraphQLType`. + +## Class Attributes + +- **`__types__`**: **Sequence[Type[GraphQLType]]** - A sequence of GraphQL types that belong to the union. +- **`__schema__`**: *(Optional[str])* - The GraphQL schema definition string for the union, if provided. + +## Class Methods + +### `resolve_type(obj: Any, *_) -> str` + +Resolves the type of an object at runtime. It returns the name of the GraphQL type that matches the object's type. + +- **Parameters:** + - `obj` (Any): The object to resolve the type for. + +- **Returns:** `str` - The name of the resolved GraphQL type. + +## Example Usage + +### Defining a Union Type + +To define a union type, subclass `GraphQLUnion` and specify the types that belong to the union using the `__types__` attribute. + +```python +from ariadne_graphql_modules import GraphQLUnion, GraphQLObject + +class UserType(GraphQLObject): + id: GraphQLID + username: str + +class CommentType(GraphQLObject): + id: GraphQLID + content: str + +class ResultType(GraphQLUnion): + __types__ = [UserType, CommentType] +``` + +### Using a Union Type in a Schema + +You can use the defined union type in a GraphQL schema to handle fields that can return multiple types. + +```python +from ariadne_graphql_modules import GraphQLObject, make_executable_schema + +class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=List[ResultType]) + @staticmethod + def search(*_) -> List[Union[UserType, CommentType]]: + return [ + UserType(id=1, username="Bob"), + CommentType(id=2, content="Hello World!"), + ] + +schema = make_executable_schema(QueryType) +``` + +### Handling Unions with and without Schema + +You can define a union with or without an explicit schema definition: + +```python +class SearchResultUnion(GraphQLUnion): + __schema__ = "union SearchResult = User | Comment" + __types__ = [UserType, CommentType] +``` + +### Validation + +The `GraphQLUnion` class includes validation mechanisms to ensure that union types are correctly defined. The validation checks whether the types in `__types__` match those in `__schema__` if a schema is provided. + +#### Validation Process + +1. **Type List Validation**: Ensures that the `__types__` attribute is provided and contains valid types. +2. **Schema Parsing and Validation**: If `__schema__` is provided, it is parsed to ensure it corresponds to a valid GraphQL union type. +3. **Type Matching**: Validates that the types in `__types__` match the types declared in `__schema__`, if a schema is provided. + +#### Validation Example + +```python +from graphql import gql +from ariadne_graphql_modules import GraphQLUnion, GraphQLObject + +class UserType(GraphQLObject): + id: GraphQLID + username: str + +class CommentType(GraphQLObject): + id: GraphQLID + content: str + +class ResultType(GraphQLUnion): + __schema__ = gql(""" + union Result = User | Comment + """) + __types__ = [UserType, CommentType] +``` + +If the `__schema__` defines types that do not match those in `__types__`, a `ValueError` will be raised. diff --git a/docs/api/wrap_legacy_types.md b/docs/api/wrap_legacy_types.md new file mode 100644 index 0000000..9fcee9d --- /dev/null +++ b/docs/api/wrap_legacy_types.md @@ -0,0 +1,93 @@ + +# `wrap_legacy_types` + +The `wrap_legacy_types` function is part of the `ariadne_graphql_modules` library and provides a way to migrate legacy GraphQL types from version 1 of the library to be compatible with the newer version. This function wraps the legacy types in a way that they can be used seamlessly in the new system without rewriting their definitions. + +## Function Signature + +```python +def wrap_legacy_types( + *bindable_types: Type[BaseType], +) -> List[Type["LegacyGraphQLType"]]: +``` + +## Parameters + +- `*bindable_types`: A variable number of legacy GraphQL type classes (from v1 of the library) that should be wrapped to work with the newer version of the library. + + - **Type:** `Type[BaseType]` + - **Description:** Each of these types could be one of the legacy types such as `ObjectType`, `InterfaceType`, `UnionType`, etc. + +## Returns + +- **List[Type["LegacyGraphQLType"]]:** A list of new classes that inherit from `LegacyGraphQLType`, each corresponding to one of the legacy types passed in as arguments. + +## Usage Example + +Here’s an example of how to use the `wrap_legacy_types` function: + +```python +from ariadne_graphql_modules.compatibility_layer import wrap_legacy_types +from ariadne_graphql_modules.v1.object_type import ObjectType +from ariadne_graphql_modules.v1.enum_type import EnumType + +class FancyObjectType(ObjectType): + __schema__ = ''' + type FancyObject { + id: ID! + someInt: Int! + someFloat: Float! + someBoolean: Boolean! + someString: String! + } + ''' + +class UserRoleEnum(EnumType): + __schema__ = ''' + enum UserRole { + USER + MOD + ADMIN + } + ''' + +wrapped_types = wrap_legacy_types(FancyObjectType, UserRoleEnum) +``` + +## Detailed Example + +### Wrapping and Using Legacy Types in Schema + +```python +from ariadne_graphql_modules.compatibility_layer import wrap_legacy_types +from ariadne_graphql_modules.v1.object_type import ObjectType +from ariadne_graphql_modules.v1.scalar_type import ScalarType +from ariadne_graphql_modules.executable_schema import make_executable_schema + +class DateScalar(ScalarType): + __schema__ = "scalar Date" + + @staticmethod + def serialize(value): + return value.isoformat() + + @staticmethod + def parse_value(value): + return datetime.strptime(value, "%Y-%m-%d").date() + +class QueryType(ObjectType): + __schema__ = ''' + type Query { + today: Date! + } + ''' + + @staticmethod + def resolve_today(*_): + return date.today() + +wrapped_types = wrap_legacy_types(QueryType, DateScalar) +schema = make_executable_schema(*wrapped_types) +``` + +In this example, `QueryType` and `DateScalar` are legacy types that have been wrapped and used to create an executable schema compatible with the new version of the library. diff --git a/CHANGELOG.md b/docs/changelog.md similarity index 53% rename from CHANGELOG.md rename to docs/changelog.md index 7327b4e..1fbb546 100644 --- a/CHANGELOG.md +++ b/docs/changelog.md @@ -1,5 +1,15 @@ # CHANGELOG + +## 1.0.0 (DEV RELEASE) + +- Major API Redesign: The entire API has been restructured for better modularity and flexibility. +- New Type System: Introduced a new type system, replacing the old v1 types. +- Migration Support: Added wrap_legacy_types to help transition from v1 types to the new system without a complete rewrite. +- Enhanced make_executable_schema: Now supports both legacy and new types with improved validation and root type merging. +- Deprecation Notice: Direct use of v1 types is deprecated. Transition to the new system or use wrap_legacy_types for continued support. + + ## 0.8.0 (2024-02-21) - Added support for Ariadne 0.22. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..dffbc5c --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,8 @@ +## Contributing + +We welcome contributions to Ariadne GraphQL Modules! If you've found a bug or issue, feel free to use [GitHub issues](https://github.com/mirumee/ariadne/issues). If you have any questions or feedback, please let us know via [GitHub discussions](https://github.com/mirumee/ariadne/discussions/). + +Also, make sure to follow [@AriadneGraphQL](https://twitter.com/AriadneGraphQL) on Twitter for the latest updates, news, and random musings! + +**Crafted with ❤️ by [Mirumee Software](http://mirumee.com)** +hello@mirumee.com diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..a616065 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,93 @@ + +[![Ariadne](https://ariadnegraphql.org/img/logo-horizontal-sm.png)](https://ariadnegraphql.org) + +[![Build Status](https://github.com/mirumee/ariadne-graphql-modules/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/mirumee/ariadne-graphql-modules/actions) + +## ⚠️ Important Migration Warning: Version 1.0.0 + +With the release of version 1.0.0, there have been significant changes to the `ariadne_graphql_modules` API. If you are upgrading from a previous version, **you will need to update your imports** to ensure your code continues to function correctly. + +### What You Need to Do + +To maintain compatibility with existing code, you must explicitly import types from the `v1` module of `ariadne_graphql_modules`. This is necessary for any code that relies on the legacy API from versions prior to 1.0.0. + +### Example + +**Before upgrading:** + +```python +from ariadne_graphql_modules import ObjectType, EnumType +``` + +**After upgrading to 1.0.0:** + +```python +from ariadne_graphql_modules.v1 import ObjectType, EnumType +``` + +### Why This Change? + +The introduction of version 1.0.0 brings a more robust and streamlined API, with better support for modular GraphQL schemas. To facilitate this, legacy types and functionality have been moved to the `v1` submodule, allowing new projects to take full advantage of the updated architecture while providing a clear path for migrating existing codebases. + +# Ariadne GraphQL Modules + +**Ariadne GraphQL Modules** is an extension for the [Ariadne](https://ariadnegraphql.org/) framework, designed to help developers structure and manage GraphQL schemas in a modular way. This library provides an organized approach to building GraphQL APIs by dividing your schema into self-contained, reusable modules, each responsible for its own part of the schema. + +## How It Works + +Ariadne GraphQL Modules operates by allowing you to define your GraphQL schema in isolated modules, each with its own types, resolvers, and dependencies. These modules can then be combined into a single executable schema using the provided utility functions. + +## Key Functionalities + +- **Modular Schema Design**: Enables the breakdown of GraphQL schemas into smaller, independent modules. Each module can define its own types, queries, mutations, and subscriptions. +- **Flexible Schema Definitions**: Supports both declarative (using schema strings) and programmatic (using Python code) approaches to defining schemas, allowing developers to choose the most appropriate method for their project. +- **Automatic Merging of Roots**: Automatically merges `Query`, `Mutation`, and `Subscription` types from different modules into a single schema, ensuring that your API is consistent and well-organized. +- **Case Conversion**: Includes tools for automatically converting field names and arguments between different naming conventions (e.g., `snake_case` to `camelCase`), making it easier to integrate with various client conventions. +- **Deferred Dependencies**: Allows for the declaration of deferred dependencies that can be resolved at the time of schema creation, giving developers more control over module initialization. + +## Installation + +Ariadne GraphQL Modules can be installed using pip: + +```bash +pip install ariadne-graphql-modules +``` + +Ariadne 0.23 or later is required for the library to work. + +## Basic Usage + +Here is a basic example of how to use Ariadne GraphQL Modules to create a simple GraphQL API: + +```python +from datetime import date + +from ariadne.asgi import GraphQL +from ariadne_graphql_modules import ObjectType, gql, make_executable_schema + + +class Query(ObjectType): + __schema__ = gql( + """ + type Query { + message: String! + year: Int! + } + """ + ) + + @staticmethod + def resolve_message(*_): + return "Hello world!" + + @staticmethod + def resolve_year(*_): + return date.today().year + + +schema = make_executable_schema(Query) +app = GraphQL(schema=schema, debug=True) +``` + +In this example, a simple `Query` type is defined within a module. The `make_executable_schema` function is then used to combine the module into a complete schema, which can be used to create a GraphQL server. + diff --git a/docs/migration_guide.md b/docs/migration_guide.md new file mode 100644 index 0000000..79e74e0 --- /dev/null +++ b/docs/migration_guide.md @@ -0,0 +1,38 @@ +# Migration Guide + +## `ariadne_graphql_modules.v1` to Current Version + +This guide provides a streamlined process for migrating your code from `ariadne_graphql_modules.v1` to the current version. The focus is on maintaining compatibility using the `wrap_legacy_types` function, which allows you to transition smoothly without needing to rewrite your entire codebase. + +## Migration Overview + +### Wrapping Legacy Types + +To maintain compatibility with the current version, all legacy types from `ariadne_graphql_modules.v1` can be wrapped using the `wrap_legacy_types` function. This approach allows you to continue using your existing types with minimal changes. + +### Example: + +```python +from ariadne_graphql_modules.v1 import ObjectType, EnumType +from ariadne_graphql_modules import make_executable_schema, wrap_legacy_types, GraphQLObject + +# Your existing types can remain as is +class QueryType(ObjectType): + ... + +class UserRoleEnum(EnumType): + ... + +# You can mix new types with old types +class NewType(GraphQLObject): + ... + +# Wrap them for the new system +my_legacy_types = wrap_legacy_types(QueryType, UserRoleEnum) +schema = make_executable_schema(*my_legacy_types, NewType) +``` + + +### Encouragement to Migrate + +While `wrap_legacy_types` provides a quick solution, it is recommended to gradually transition to the current version’s types for better support and new features. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..f614727 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,62 @@ +site_name: Ariadne Graphql Modules +site_url: https://mirumee.github.io/ariadne-graphql-modules/ +repo_url: https://github.com/mirumee/ariadne-graphql-modules +repo_name: mirumee/ariadne-graphql-modules +copyright: Copyright © 2024 - Mirumee Software + +theme: + name: material + +nav: + - Get Started: + - Welcome to Ariadne Graphql Modules: index.md + - Migration Guide: migration_guide.md + - Contributing: contributing.md + - Changelog: changelog.md + - API Documentation: + - GraphQLObject: api/object_type.md + - GraphQLSubscription: api/subscription_type.md + - GraphQLInput: api/input_type.md + - GraphQLScalar: api/scalar_type.md + - GraphQLEnum: api/enum_type.md + - GraphQLInterface: api/interface_type.md + - GraphQLUnion: api/union_type.md + - Deferred: api/deferred_type.md + - GraphQLType: api/base_type.md + - GraphQLID: api/id_type.md + - make_executable_schema: api/make_executable_schema.md + - wrap_legacy_types: api/wrap_legacy_types.md + +plugins: + - offline: + enabled: !ENV [OFFLINE, false] + - search + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + slugify: !!python/object/apply:pymdownx.slugs.slugify + kwds: + case: lower + - footnotes + - tables + - toc: + permalink: true diff --git a/pyproject.toml b/pyproject.toml index 363ba31..053c3b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,36 +4,37 @@ build-backend = "hatchling.build" [project] name = "ariadne-graphql-modules" -version = "0.8.0" +version = "1.0.0.dev1" description = "Ariadne toolkit for defining GraphQL schemas in modular fashion." authors = [{ name = "Mirumee Software", email = "hello@mirumee.com" }] -readme = "README.md" +readme = { file = "README.md", content-type = "text/markdown" } license = { file = "LICENSE" } +requires-python = ">=3.9,<3.13" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Python Modules", ] -dependencies = [ - "ariadne>=0.22.0", -] + +dependencies = ["ariadne==0.23.0", "mkdocs-material"] [project.optional-dependencies] test = [ - "black", - "mypy", - "pylint", "pytest", + "pytest-cov", "pytest-asyncio", - "snapshottest", + "pytest-regressions", + "pytest-datadir", ] +lint = ["black", "mypy", "pylint", "ruff"] +docs = ["mkdocs-material"] [project.urls] "Homepage" = "https://ariadnegraphql.org/" @@ -41,6 +42,7 @@ test = [ "Bug Tracker" = "https://github.com/mirumee/ariadne-graphql-modules/issues" "Community" = "https://github.com/mirumee/ariadne/discussions" "Twitter" = "https://twitter.com/AriadneGraphQL" +"Documentation" = "https://mirumee.github.io/ariadne-graphql-modules/" [tool.hatch.build] include = [ @@ -49,31 +51,37 @@ include = [ ] exclude = [ "tests", + "tests_v1", + "*.pyc", + "*.pyo", + "__pycache__", + ".git", + ".idea", ] [tool.hatch.envs.default] -features = ["test"] +features = ["test", "lint", "docs"] +python = "3.12" -[tool.black] +[tool.ruff] line-length = 88 -target-version = ["py38", "py39", "py310", "py311"] -include = '\.pyi?$' -exclude = ''' -/( - \.eggs - | \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | _build - | buck-out - | build - | dist - | snapshots -)/ -''' +target-version = "py39" +exclude = ["tests_v1", "ariadne_graphql_modules/v1"] + +[tool.ruff.format] +docstring-code-format = true +docstring-code-line-length = 80 + +[tool.ruff.lint] +select = ["E", "F", "G", "I", "N", "Q", "UP", "C90", "T20", "TID"] + +[tool.ruff.lint.mccabe] +max-complexity = 30 + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false +mark-parentheses = false [tool.pytest.ini_options] -testpaths = ["tests"] +testpaths = ["tests", "tests_v1"] asyncio_mode = "strict" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3752a68 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,49 @@ +import glob +import os +from pathlib import Path +from textwrap import dedent + +import pytest +from graphql import GraphQLSchema, TypeDefinitionNode, print_ast, print_schema + +from ariadne_graphql_modules import GraphQLMetadata + + +@pytest.fixture +def assert_schema_equals(): + def schema_equals_assertion(schema: GraphQLSchema, target: str): + schema_str = print_schema(schema) + assert schema_str == dedent(target).strip() + + return schema_equals_assertion + + +@pytest.fixture +def assert_ast_equals(): + def ast_equals_assertion(ast: TypeDefinitionNode, target: str): + ast_str = print_ast(ast) + assert ast_str == dedent(target).strip() + + return ast_equals_assertion + + +@pytest.fixture +def metadata(): + return GraphQLMetadata() + + +@pytest.fixture(scope="session") +def datadir() -> Path: + return Path(__file__).parent / "snapshots" + + +@pytest.fixture(scope="session") +def original_datadir() -> Path: + return Path(__file__).parent / "snapshots" + + +def pytest_sessionfinish(*_): + # This will be called after all tests are done + obtained_files = glob.glob("**/*.obtained.yml", recursive=True) + for file in obtained_files: + os.remove(file) diff --git a/tests/snapshots/snap_test_definition_parser.py b/tests/snapshots/snap_test_definition_parser.py deleted file mode 100644 index f78c1f4..0000000 --- a/tests/snapshots/snap_test_definition_parser.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - -from snapshottest import GenericRepr, Snapshot - - -snapshots = Snapshot() - -snapshots['test_definition_parser_raises_error_schema_str_contains_multiple_types 1'] = GenericRepr("") - -snapshots['test_definition_parser_raises_error_when_schema_str_has_invalid_syntax 1'] = GenericRepr('') - -snapshots['test_definition_parser_raises_error_when_schema_type_is_invalid 1'] = GenericRepr("") diff --git a/tests/snapshots/snap_test_directive_type.py b/tests/snapshots/snap_test_directive_type.py deleted file mode 100644 index 4691c16..0000000 --- a/tests/snapshots/snap_test_directive_type.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - -from snapshottest import GenericRepr, Snapshot - - -snapshots = Snapshot() - -snapshots['test_directive_type_raises_attribute_error_when_defined_without_schema 1'] = GenericRepr('') - -snapshots['test_directive_type_raises_attribute_error_when_defined_without_visitor 1'] = GenericRepr("") - -snapshots['test_directive_type_raises_error_when_defined_with_invalid_graphql_type_schema 1'] = GenericRepr("") - -snapshots['test_directive_type_raises_error_when_defined_with_invalid_schema_str 1'] = GenericRepr('') - -snapshots['test_directive_type_raises_error_when_defined_with_invalid_schema_type 1'] = GenericRepr("") - -snapshots['test_directive_type_raises_error_when_defined_with_multiple_types_schema 1'] = GenericRepr("") diff --git a/tests/snapshots/snap_test_enum_type.py b/tests/snapshots/snap_test_enum_type.py deleted file mode 100644 index 0ee40db..0000000 --- a/tests/snapshots/snap_test_enum_type.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - -from snapshottest import GenericRepr, Snapshot - - -snapshots = Snapshot() - -snapshots['test_enum_type_raises_attribute_error_when_defined_without_schema 1'] = GenericRepr('') - -snapshots['test_enum_type_raises_error_when_defined_with_invalid_graphql_type_schema 1'] = GenericRepr("") - -snapshots['test_enum_type_raises_error_when_defined_with_invalid_schema_str 1'] = GenericRepr('') - -snapshots['test_enum_type_raises_error_when_defined_with_invalid_schema_type 1'] = GenericRepr("") - -snapshots['test_enum_type_raises_error_when_defined_with_multiple_types_schema 1'] = GenericRepr("") - -snapshots['test_enum_type_raises_error_when_dict_mapping_has_extra_items_not_in_definition 1'] = GenericRepr("") - -snapshots['test_enum_type_raises_error_when_dict_mapping_misses_items_from_definition 1'] = GenericRepr("") - -snapshots['test_enum_type_raises_error_when_enum_mapping_has_extra_items_not_in_definition 1'] = GenericRepr("") - -snapshots['test_enum_type_raises_error_when_enum_mapping_misses_items_from_definition 1'] = GenericRepr("") diff --git a/tests/snapshots/snap_test_executable_schema.py b/tests/snapshots/snap_test_executable_schema.py deleted file mode 100644 index afca833..0000000 --- a/tests/snapshots/snap_test_executable_schema.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - -from snapshottest import GenericRepr, Snapshot - - -snapshots = Snapshot() - -snapshots['test_executable_schema_raises_value_error_if_merged_types_define_same_field 1'] = GenericRepr('') diff --git a/tests/snapshots/snap_test_input_type.py b/tests/snapshots/snap_test_input_type.py deleted file mode 100644 index 8df4264..0000000 --- a/tests/snapshots/snap_test_input_type.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - -from snapshottest import GenericRepr, Snapshot - - -snapshots = Snapshot() - -snapshots['test_input_type_raises_attribute_error_when_defined_without_schema 1'] = GenericRepr('') - -snapshots['test_input_type_raises_error_when_defined_with_args_map_for_nonexisting_field 1'] = GenericRepr("") - -snapshots['test_input_type_raises_error_when_defined_with_invalid_graphql_type_schema 1'] = GenericRepr("") - -snapshots['test_input_type_raises_error_when_defined_with_invalid_schema_str 1'] = GenericRepr('') - -snapshots['test_input_type_raises_error_when_defined_with_invalid_schema_type 1'] = GenericRepr("") - -snapshots['test_input_type_raises_error_when_defined_with_multiple_types_schema 1'] = GenericRepr("") - -snapshots['test_input_type_raises_error_when_defined_without_extended_dependency 1'] = GenericRepr('') - -snapshots['test_input_type_raises_error_when_defined_without_field_type_dependency 1'] = GenericRepr('') - -snapshots['test_input_type_raises_error_when_defined_without_fields 1'] = GenericRepr("") - -snapshots['test_input_type_raises_error_when_extended_dependency_is_wrong_type 1'] = GenericRepr('') diff --git a/tests/snapshots/snap_test_interface_type.py b/tests/snapshots/snap_test_interface_type.py deleted file mode 100644 index b21bd35..0000000 --- a/tests/snapshots/snap_test_interface_type.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - -from snapshottest import GenericRepr, Snapshot - - -snapshots = Snapshot() - -snapshots['test_interface_type_raises_attribute_error_when_defined_without_schema 1'] = GenericRepr('') - -snapshots['test_interface_type_raises_error_when_defined_with_alias_for_nonexisting_field 1'] = GenericRepr("") - -snapshots['test_interface_type_raises_error_when_defined_with_invalid_graphql_type_schema 1'] = GenericRepr("") - -snapshots['test_interface_type_raises_error_when_defined_with_invalid_schema_str 1'] = GenericRepr('') - -snapshots['test_interface_type_raises_error_when_defined_with_invalid_schema_type 1'] = GenericRepr("") - -snapshots['test_interface_type_raises_error_when_defined_with_multiple_types_schema 1'] = GenericRepr("") - -snapshots['test_interface_type_raises_error_when_defined_with_resolver_for_nonexisting_field 1'] = GenericRepr("") - -snapshots['test_interface_type_raises_error_when_defined_without_argument_type_dependency 1'] = GenericRepr('') - -snapshots['test_interface_type_raises_error_when_defined_without_extended_dependency 1'] = GenericRepr("") - -snapshots['test_interface_type_raises_error_when_defined_without_fields 1'] = GenericRepr("") - -snapshots['test_interface_type_raises_error_when_defined_without_return_type_dependency 1'] = GenericRepr('') - -snapshots['test_interface_type_raises_error_when_extended_dependency_is_wrong_type 1'] = GenericRepr('') diff --git a/tests/snapshots/snap_test_mutation_type.py b/tests/snapshots/snap_test_mutation_type.py deleted file mode 100644 index f5d3262..0000000 --- a/tests/snapshots/snap_test_mutation_type.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - -from snapshottest import GenericRepr, Snapshot - - -snapshots = Snapshot() - -snapshots['test_mutation_type_raises_attribute_error_when_defined_without_schema 1'] = GenericRepr('') - -snapshots['test_mutation_type_raises_error_when_defined_for_different_type_name 1'] = GenericRepr('') - -snapshots['test_mutation_type_raises_error_when_defined_with_invalid_graphql_type_schema 1'] = GenericRepr("") - -snapshots['test_mutation_type_raises_error_when_defined_with_invalid_schema_type 1'] = GenericRepr("") - -snapshots['test_mutation_type_raises_error_when_defined_with_multiple_fields 1'] = GenericRepr('') - -snapshots['test_mutation_type_raises_error_when_defined_with_multiple_types_schema 1'] = GenericRepr("") - -snapshots['test_mutation_type_raises_error_when_defined_with_nonexistant_args 1'] = GenericRepr('') - -snapshots['test_mutation_type_raises_error_when_defined_without_callable_resolve_mutation_attr 1'] = GenericRepr('') - -snapshots['test_mutation_type_raises_error_when_defined_without_fields 1'] = GenericRepr("") - -snapshots['test_mutation_type_raises_error_when_defined_without_resolve_mutation_attr 1'] = GenericRepr('') - -snapshots['test_mutation_type_raises_error_when_defined_without_return_type_dependency 1'] = GenericRepr('') - -snapshots['test_object_type_raises_error_when_defined_with_invalid_schema_str 1'] = GenericRepr('') diff --git a/tests/snapshots/snap_test_object_type.py b/tests/snapshots/snap_test_object_type.py deleted file mode 100644 index d5ed96c..0000000 --- a/tests/snapshots/snap_test_object_type.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - -from snapshottest import GenericRepr, Snapshot - - -snapshots = Snapshot() - -snapshots['test_object_type_raises_attribute_error_when_defined_without_schema 1'] = GenericRepr('') - -snapshots['test_object_type_raises_error_when_defined_with_alias_for_nonexisting_field 1'] = GenericRepr("") - -snapshots['test_object_type_raises_error_when_defined_with_field_args_for_nonexisting_arg 1'] = GenericRepr('') - -snapshots['test_object_type_raises_error_when_defined_with_field_args_for_nonexisting_field 1'] = GenericRepr("") - -snapshots['test_object_type_raises_error_when_defined_with_invalid_graphql_type_schema 1'] = GenericRepr("") - -snapshots['test_object_type_raises_error_when_defined_with_invalid_schema_str 1'] = GenericRepr('') - -snapshots['test_object_type_raises_error_when_defined_with_invalid_schema_type 1'] = GenericRepr("") - -snapshots['test_object_type_raises_error_when_defined_with_multiple_types_schema 1'] = GenericRepr("") - -snapshots['test_object_type_raises_error_when_defined_with_resolver_for_nonexisting_field 1'] = GenericRepr("") - -snapshots['test_object_type_raises_error_when_defined_without_argument_type_dependency 1'] = GenericRepr('') - -snapshots['test_object_type_raises_error_when_defined_without_extended_dependency 1'] = GenericRepr('') - -snapshots['test_object_type_raises_error_when_defined_without_fields 1'] = GenericRepr("") - -snapshots['test_object_type_raises_error_when_defined_without_return_type_dependency 1'] = GenericRepr('') - -snapshots['test_object_type_raises_error_when_extended_dependency_is_wrong_type 1'] = GenericRepr('') diff --git a/tests/snapshots/snap_test_scalar_type.py b/tests/snapshots/snap_test_scalar_type.py deleted file mode 100644 index 8de8a0e..0000000 --- a/tests/snapshots/snap_test_scalar_type.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - -from snapshottest import GenericRepr, Snapshot - - -snapshots = Snapshot() - -snapshots['test_scalar_type_raises_attribute_error_when_defined_without_schema 1'] = GenericRepr('') - -snapshots['test_scalar_type_raises_error_when_defined_with_invalid_graphql_type_schema 1'] = GenericRepr("") - -snapshots['test_scalar_type_raises_error_when_defined_with_invalid_schema_str 1'] = GenericRepr('') - -snapshots['test_scalar_type_raises_error_when_defined_with_invalid_schema_type 1'] = GenericRepr("") - -snapshots['test_scalar_type_raises_error_when_defined_with_multiple_types_schema 1'] = GenericRepr("") diff --git a/tests/snapshots/snap_test_subscription_type.py b/tests/snapshots/snap_test_subscription_type.py deleted file mode 100644 index 2b46bb1..0000000 --- a/tests/snapshots/snap_test_subscription_type.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - -from snapshottest import GenericRepr, Snapshot - - -snapshots = Snapshot() - -snapshots['test_subscription_type_raises_attribute_error_when_defined_without_schema 1'] = GenericRepr('') - -snapshots['test_subscription_type_raises_error_when_defined_with_alias_for_nonexisting_field 1'] = GenericRepr("") - -snapshots['test_subscription_type_raises_error_when_defined_with_invalid_graphql_type_name 1'] = GenericRepr('') - -snapshots['test_subscription_type_raises_error_when_defined_with_invalid_graphql_type_schema 1'] = GenericRepr("") - -snapshots['test_subscription_type_raises_error_when_defined_with_invalid_schema_str 1'] = GenericRepr('') - -snapshots['test_subscription_type_raises_error_when_defined_with_invalid_schema_type 1'] = GenericRepr("") - -snapshots['test_subscription_type_raises_error_when_defined_with_resolver_for_nonexisting_field 1'] = GenericRepr("") - -snapshots['test_subscription_type_raises_error_when_defined_with_sub_for_nonexisting_field 1'] = GenericRepr("") - -snapshots['test_subscription_type_raises_error_when_defined_without_argument_type_dependency 1'] = GenericRepr('') - -snapshots['test_subscription_type_raises_error_when_defined_without_extended_dependency 1'] = GenericRepr('') - -snapshots['test_subscription_type_raises_error_when_defined_without_fields 1'] = GenericRepr("") - -snapshots['test_subscription_type_raises_error_when_defined_without_return_type_dependency 1'] = GenericRepr('') - -snapshots['test_subscription_type_raises_error_when_extended_dependency_is_wrong_type 1'] = GenericRepr('') diff --git a/tests/snapshots/snap_test_union_type.py b/tests/snapshots/snap_test_union_type.py deleted file mode 100644 index 26fd0c5..0000000 --- a/tests/snapshots/snap_test_union_type.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - -from snapshottest import GenericRepr, Snapshot - - -snapshots = Snapshot() - -snapshots['test_interface_type_raises_error_when_extended_dependency_is_wrong_type 1'] = GenericRepr('') - -snapshots['test_union_type_raises_attribute_error_when_defined_without_schema 1'] = GenericRepr('') - -snapshots['test_union_type_raises_error_when_defined_with_invalid_graphql_type_schema 1'] = GenericRepr("") - -snapshots['test_union_type_raises_error_when_defined_with_invalid_schema_str 1'] = GenericRepr('') - -snapshots['test_union_type_raises_error_when_defined_with_invalid_schema_type 1'] = GenericRepr("") - -snapshots['test_union_type_raises_error_when_defined_with_multiple_types_schema 1'] = GenericRepr("") - -snapshots['test_union_type_raises_error_when_defined_without_extended_dependency 1'] = GenericRepr('') - -snapshots['test_union_type_raises_error_when_defined_without_member_type_dependency 1'] = GenericRepr('') diff --git a/tests/snapshots/test_arg_with_description_in_source_with_schema.yml b/tests/snapshots/test_arg_with_description_in_source_with_schema.yml new file mode 100644 index 0000000..37ea424 --- /dev/null +++ b/tests/snapshots/test_arg_with_description_in_source_with_schema.yml @@ -0,0 +1,3 @@ +Class 'SubscriptionType' defines 'type' option for 'channel' argument of the 'messageAdded' + field. This is not supported for types defining '__schema__'. +... diff --git a/tests/snapshots/test_arg_with_name_in_source_with_schema.yml b/tests/snapshots/test_arg_with_name_in_source_with_schema.yml new file mode 100644 index 0000000..5e93ffd --- /dev/null +++ b/tests/snapshots/test_arg_with_name_in_source_with_schema.yml @@ -0,0 +1,3 @@ +Class 'SubscriptionType' defines 'name' option for 'channel' argument of the 'messageAdded' + field. This is not supported for types defining '__schema__'. +... diff --git a/tests/snapshots/test_arg_with_type_in_source_with_schema.yml b/tests/snapshots/test_arg_with_type_in_source_with_schema.yml new file mode 100644 index 0000000..37ea424 --- /dev/null +++ b/tests/snapshots/test_arg_with_type_in_source_with_schema.yml @@ -0,0 +1,3 @@ +Class 'SubscriptionType' defines 'type' option for 'channel' argument of the 'messageAdded' + field. This is not supported for types defining '__schema__'. +... diff --git a/tests/snapshots/test_deferred_raises_error_for_invalid_relative_path.yml b/tests/snapshots/test_deferred_raises_error_for_invalid_relative_path.yml new file mode 100644 index 0000000..82cb498 --- /dev/null +++ b/tests/snapshots/test_deferred_raises_error_for_invalid_relative_path.yml @@ -0,0 +1 @@ +'''...types'' points outside of the ''lorem'' package.' diff --git a/tests/snapshots/test_description_not_str_without_schema.yml b/tests/snapshots/test_description_not_str_without_schema.yml new file mode 100644 index 0000000..b43461c --- /dev/null +++ b/tests/snapshots/test_description_not_str_without_schema.yml @@ -0,0 +1,2 @@ +The description for message_added_generator must be a string if provided. +... diff --git a/tests/snapshots/test_description_validator_raises_error_for_type_with_two_descriptions.yml b/tests/snapshots/test_description_validator_raises_error_for_type_with_two_descriptions.yml new file mode 100644 index 0000000..ba71cfb --- /dev/null +++ b/tests/snapshots/test_description_validator_raises_error_for_type_with_two_descriptions.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines description in both '__description__' and '__schema__' + attributes. +... diff --git a/tests/snapshots/test_enum_type_validation_fails_for_invalid_members.yml b/tests/snapshots/test_enum_type_validation_fails_for_invalid_members.yml new file mode 100644 index 0000000..f905b16 --- /dev/null +++ b/tests/snapshots/test_enum_type_validation_fails_for_invalid_members.yml @@ -0,0 +1,2 @@ +'Class ''UserLevel'' ''__members__'' attribute is of unsupported type. Expected ''Dict[str, + Any]'', ''Type[Enum]'' or List[str]. (found: '''')' diff --git a/tests/snapshots/test_enum_type_validation_fails_for_missing_members.yml b/tests/snapshots/test_enum_type_validation_fails_for_missing_members.yml new file mode 100644 index 0000000..1be2153 --- /dev/null +++ b/tests/snapshots/test_enum_type_validation_fails_for_missing_members.yml @@ -0,0 +1,3 @@ +Class 'UserLevel' '__members__' attribute is either missing or empty. Either define + it or provide full SDL for this enum using the '__schema__' attribute. +... diff --git a/tests/snapshots/test_field_name_not_str_without_schema.yml b/tests/snapshots/test_field_name_not_str_without_schema.yml new file mode 100644 index 0000000..17a44f2 --- /dev/null +++ b/tests/snapshots/test_field_name_not_str_without_schema.yml @@ -0,0 +1,2 @@ +The field name for message_added_generator must be a string. +... diff --git a/tests/snapshots/test_input_type_instance_with_invalid_attrs_raising_error.yml b/tests/snapshots/test_input_type_instance_with_invalid_attrs_raising_error.yml new file mode 100644 index 0000000..c51434c --- /dev/null +++ b/tests/snapshots/test_input_type_instance_with_invalid_attrs_raising_error.yml @@ -0,0 +1,2 @@ +'SearchInput.__init__() got an unexpected keyword argument ''invalid''. Valid keyword + arguments: ''query'', ''age''' diff --git a/tests/snapshots/test_input_type_validation_fails_for_out_names_without_schema.yml b/tests/snapshots/test_input_type_validation_fails_for_out_names_without_schema.yml new file mode 100644 index 0000000..2eeb09c --- /dev/null +++ b/tests/snapshots/test_input_type_validation_fails_for_out_names_without_schema.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines '__out_names__' attribute. This is not supported for types + not defining '__schema__'. +... diff --git a/tests/snapshots/test_input_type_validation_fails_for_unsupported_attr_default.yml b/tests/snapshots/test_input_type_validation_fails_for_unsupported_attr_default.yml new file mode 100644 index 0000000..87d2d4e --- /dev/null +++ b/tests/snapshots/test_input_type_validation_fails_for_unsupported_attr_default.yml @@ -0,0 +1,3 @@ +Class 'QueryType' defines default value for the 'attr' field that can't be represented + in GraphQL schema. +... diff --git a/tests/snapshots/test_input_type_validation_fails_for_unsupported_field_default_option.yml b/tests/snapshots/test_input_type_validation_fails_for_unsupported_field_default_option.yml new file mode 100644 index 0000000..87d2d4e --- /dev/null +++ b/tests/snapshots/test_input_type_validation_fails_for_unsupported_field_default_option.yml @@ -0,0 +1,3 @@ +Class 'QueryType' defines default value for the 'attr' field that can't be represented + in GraphQL schema. +... diff --git a/tests/snapshots/test_interface_no_interface_in_schema.yml b/tests/snapshots/test_interface_no_interface_in_schema.yml new file mode 100644 index 0000000..63f9405 --- /dev/null +++ b/tests/snapshots/test_interface_no_interface_in_schema.yml @@ -0,0 +1,2 @@ +Unknown type 'BaseInterface'. +... diff --git a/tests/snapshots/test_interface_with_schema_object_with_no_schema.yml b/tests/snapshots/test_interface_with_schema_object_with_no_schema.yml new file mode 100644 index 0000000..e583dda --- /dev/null +++ b/tests/snapshots/test_interface_with_schema_object_with_no_schema.yml @@ -0,0 +1,2 @@ +'Class ''UserType'' defines resolver for an undefined field ''score''. (Valid fields: + ''name'')' diff --git a/tests/snapshots/test_invalid_arg_name_in_source_with_schema.yml b/tests/snapshots/test_invalid_arg_name_in_source_with_schema.yml new file mode 100644 index 0000000..78d9da1 --- /dev/null +++ b/tests/snapshots/test_invalid_arg_name_in_source_with_schema.yml @@ -0,0 +1,3 @@ +Class 'SubscriptionType' defines options for 'channelID' argument of the 'messageAdded' + field that doesn't exist. +... diff --git a/tests/snapshots/test_invalid_resolver_arg_option_default.yml b/tests/snapshots/test_invalid_resolver_arg_option_default.yml new file mode 100644 index 0000000..6fb04ed --- /dev/null +++ b/tests/snapshots/test_invalid_resolver_arg_option_default.yml @@ -0,0 +1,3 @@ +Class 'QueryType' defines default value for 'name' argument of the 'hello' field that + can't be represented in GraphQL schema. +... diff --git a/tests/snapshots/test_make_executable_schema_raises_error_if_called_without_any_types.yml b/tests/snapshots/test_make_executable_schema_raises_error_if_called_without_any_types.yml new file mode 100644 index 0000000..e939646 --- /dev/null +++ b/tests/snapshots/test_make_executable_schema_raises_error_if_called_without_any_types.yml @@ -0,0 +1 @@ +'''make_executable_schema'' was called without any GraphQL types.' diff --git a/tests/snapshots/test_metadata_raises_key_error_for_unset_data.yml b/tests/snapshots/test_metadata_raises_key_error_for_unset_data.yml new file mode 100644 index 0000000..ef7ccb8 --- /dev/null +++ b/tests/snapshots/test_metadata_raises_key_error_for_unset_data.yml @@ -0,0 +1 @@ +'"No data is set for ''''."' diff --git a/tests/snapshots/test_missing_type_in_schema.yml b/tests/snapshots/test_missing_type_in_schema.yml new file mode 100644 index 0000000..8c91f42 --- /dev/null +++ b/tests/snapshots/test_missing_type_in_schema.yml @@ -0,0 +1,2 @@ +Types 'Comment', 'Post' are in '__types__' but not in '__schema__'. +... diff --git a/tests/snapshots/test_missing_type_in_types.yml b/tests/snapshots/test_missing_type_in_types.yml new file mode 100644 index 0000000..e504065 --- /dev/null +++ b/tests/snapshots/test_missing_type_in_types.yml @@ -0,0 +1,2 @@ +Types 'Comment' are in '__schema__' but not in '__types__'. +... diff --git a/tests/snapshots/test_multiple_descriptions_for_source_with_schema.yml b/tests/snapshots/test_multiple_descriptions_for_source_with_schema.yml new file mode 100644 index 0000000..fb20bdf --- /dev/null +++ b/tests/snapshots/test_multiple_descriptions_for_source_with_schema.yml @@ -0,0 +1,2 @@ +Class 'SubscriptionType' defines multiple descriptions for field 'messageAdded'. +... diff --git a/tests/snapshots/test_multiple_roots_fail_validation_if_merge_roots_is_disabled.yml b/tests/snapshots/test_multiple_roots_fail_validation_if_merge_roots_is_disabled.yml new file mode 100644 index 0000000..c22028c --- /dev/null +++ b/tests/snapshots/test_multiple_roots_fail_validation_if_merge_roots_is_disabled.yml @@ -0,0 +1,3 @@ +Types 'SecondRoot' and '.FirstRoot'>' + both define GraphQL type with name 'Query'. +... diff --git a/tests/snapshots/test_multiple_sourced_for_field_with_schema.yml b/tests/snapshots/test_multiple_sourced_for_field_with_schema.yml new file mode 100644 index 0000000..5fc64fd --- /dev/null +++ b/tests/snapshots/test_multiple_sourced_for_field_with_schema.yml @@ -0,0 +1,2 @@ +Class 'SubscriptionType' defines multiple sources for field 'messageAdded'. +... diff --git a/tests/snapshots/test_multiple_sources_without_schema.yml b/tests/snapshots/test_multiple_sources_without_schema.yml new file mode 100644 index 0000000..ba187af --- /dev/null +++ b/tests/snapshots/test_multiple_sources_without_schema.yml @@ -0,0 +1,2 @@ +Class 'SubscriptionType' defines multiple sources for field 'message_added'. +... diff --git a/tests/snapshots/test_name_validator_raises_error_for_name_and_definition_mismatch.yml b/tests/snapshots/test_name_validator_raises_error_for_name_and_definition_mismatch.yml new file mode 100644 index 0000000..d649dfe --- /dev/null +++ b/tests/snapshots/test_name_validator_raises_error_for_name_and_definition_mismatch.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines both '__graphql_name__' and '__schema__' attributes, but + names in those don't match. ('Example' != 'Custom') +... diff --git a/tests/snapshots/test_object_type_instance_with_invalid_attrs_raising_error.yml b/tests/snapshots/test_object_type_instance_with_invalid_attrs_raising_error.yml new file mode 100644 index 0000000..b5049ef --- /dev/null +++ b/tests/snapshots/test_object_type_instance_with_invalid_attrs_raising_error.yml @@ -0,0 +1,2 @@ +'CategoryType.__init__() got an unexpected keyword argument ''invalid''. Valid keyword + arguments: ''name'', ''posts''' diff --git a/tests/snapshots/test_object_type_validation_fails_for_alias_resolver.yml b/tests/snapshots/test_object_type_validation_fails_for_alias_resolver.yml new file mode 100644 index 0000000..c38c902 --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_alias_resolver.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines an alias for a field 'hello' that already has a custom + resolver. +... diff --git a/tests/snapshots/test_object_type_validation_fails_for_alias_target_resolver.yml b/tests/snapshots/test_object_type_validation_fails_for_alias_target_resolver.yml new file mode 100644 index 0000000..ed6cc30 --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_alias_target_resolver.yml @@ -0,0 +1,2 @@ +'Class ''CustomType'' defines resolver for an undefined field ''welcome''. (Valid + fields: ''hello'')' diff --git a/tests/snapshots/test_object_type_validation_fails_for_attr_and_field_with_same_graphql_name.yml b/tests/snapshots/test_object_type_validation_fails_for_attr_and_field_with_same_graphql_name.yml new file mode 100644 index 0000000..874b9cc --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_attr_and_field_with_same_graphql_name.yml @@ -0,0 +1,2 @@ +Class 'CustomType' defines multiple fields with GraphQL name 'userId'. +... diff --git a/tests/snapshots/test_object_type_validation_fails_for_field_with_multiple_args.yml b/tests/snapshots/test_object_type_validation_fails_for_field_with_multiple_args.yml new file mode 100644 index 0000000..9868e1a --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_field_with_multiple_args.yml @@ -0,0 +1,2 @@ +Class 'CustomType' defines multiple resolvers for field 'lorem'. +... diff --git a/tests/snapshots/test_object_type_validation_fails_for_field_with_multiple_descriptions.yml b/tests/snapshots/test_object_type_validation_fails_for_field_with_multiple_descriptions.yml new file mode 100644 index 0000000..694fd36 --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_field_with_multiple_descriptions.yml @@ -0,0 +1,2 @@ +Class 'CustomType' defines multiple descriptions for field 'hello'. +... diff --git a/tests/snapshots/test_object_type_validation_fails_for_invalid_alias.yml b/tests/snapshots/test_object_type_validation_fails_for_invalid_alias.yml new file mode 100644 index 0000000..352c185 --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_invalid_alias.yml @@ -0,0 +1,2 @@ +'Class ''CustomType'' defines an alias for an undefined field ''invalid''. (Valid + fields: ''hello'')' diff --git a/tests/snapshots/test_object_type_validation_fails_for_missing_field_resolver_arg.yml b/tests/snapshots/test_object_type_validation_fails_for_missing_field_resolver_arg.yml new file mode 100644 index 0000000..2741e54 --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_missing_field_resolver_arg.yml @@ -0,0 +1,2 @@ +'Class ''CustomType'' defines ''hello'' field with extra configuration for ''invalid'' + argument thats not defined on the resolver function. (expected one of: ''name'')' diff --git a/tests/snapshots/test_object_type_validation_fails_for_missing_resolver_arg.yml b/tests/snapshots/test_object_type_validation_fails_for_missing_resolver_arg.yml new file mode 100644 index 0000000..80d1f4b --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_missing_resolver_arg.yml @@ -0,0 +1,3 @@ +'Class ''CustomType'' defines ''resolve_hello'' resolver with extra configuration + for ''invalid'' argument thats not defined on the resolver function. (expected one + of: ''name'')' diff --git a/tests/snapshots/test_object_type_validation_fails_for_multiple_attrs_with_same_graphql_name.yml b/tests/snapshots/test_object_type_validation_fails_for_multiple_attrs_with_same_graphql_name.yml new file mode 100644 index 0000000..874b9cc --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_multiple_attrs_with_same_graphql_name.yml @@ -0,0 +1,2 @@ +Class 'CustomType' defines multiple fields with GraphQL name 'userId'. +... diff --git a/tests/snapshots/test_object_type_validation_fails_for_multiple_field_resolvers.yml b/tests/snapshots/test_object_type_validation_fails_for_multiple_field_resolvers.yml new file mode 100644 index 0000000..81b2a07 --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_multiple_field_resolvers.yml @@ -0,0 +1,2 @@ +Class 'CustomType' defines multiple resolvers for field 'hello'. +... diff --git a/tests/snapshots/test_object_type_validation_fails_for_multiple_fields_with_same_graphql_name.yml b/tests/snapshots/test_object_type_validation_fails_for_multiple_fields_with_same_graphql_name.yml new file mode 100644 index 0000000..d792e40 --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_multiple_fields_with_same_graphql_name.yml @@ -0,0 +1,2 @@ +Class 'CustomType' defines multiple fields with GraphQL name 'hello'. +... diff --git a/tests/snapshots/test_object_type_validation_fails_for_undefined_attr_resolver.yml b/tests/snapshots/test_object_type_validation_fails_for_undefined_attr_resolver.yml new file mode 100644 index 0000000..bcb3882 --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_undefined_attr_resolver.yml @@ -0,0 +1,2 @@ +'Class ''QueryType'' defines resolver for an undefined field ''other''. (Valid fields: + ''hello'')' diff --git a/tests/snapshots/test_object_type_validation_fails_for_undefined_field_resolver_arg.yml b/tests/snapshots/test_object_type_validation_fails_for_undefined_field_resolver_arg.yml new file mode 100644 index 0000000..2de19e8 --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_undefined_field_resolver_arg.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines 'hello' field with extra configuration for 'invalid' argument + thats not defined on the resolver function. (function accepts no extra arguments) +... diff --git a/tests/snapshots/test_object_type_validation_fails_for_undefined_resolver_arg.yml b/tests/snapshots/test_object_type_validation_fails_for_undefined_resolver_arg.yml new file mode 100644 index 0000000..dc19fb7 --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_undefined_resolver_arg.yml @@ -0,0 +1,4 @@ +Class 'CustomType' defines 'resolve_hello' resolver with extra configuration for 'invalid' + argument thats not defined on the resolver function. (function accepts no extra + arguments) +... diff --git a/tests/snapshots/test_object_type_validation_fails_for_unsupported_resolver_arg_default.yml b/tests/snapshots/test_object_type_validation_fails_for_unsupported_resolver_arg_default.yml new file mode 100644 index 0000000..6fb04ed --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_unsupported_resolver_arg_default.yml @@ -0,0 +1,3 @@ +Class 'QueryType' defines default value for 'name' argument of the 'hello' field that + can't be represented in GraphQL schema. +... diff --git a/tests/snapshots/test_object_type_validation_fails_for_unsupported_resolver_arg_default_option.yml b/tests/snapshots/test_object_type_validation_fails_for_unsupported_resolver_arg_default_option.yml new file mode 100644 index 0000000..6fb04ed --- /dev/null +++ b/tests/snapshots/test_object_type_validation_fails_for_unsupported_resolver_arg_default_option.yml @@ -0,0 +1,3 @@ +Class 'QueryType' defines default value for 'name' argument of the 'hello' field that + can't be represented in GraphQL schema. +... diff --git a/tests/snapshots/test_schema_enum_type_validation_fails_for_duplicated_members_descriptions.yml b/tests/snapshots/test_schema_enum_type_validation_fails_for_duplicated_members_descriptions.yml new file mode 100644 index 0000000..96581ba --- /dev/null +++ b/tests/snapshots/test_schema_enum_type_validation_fails_for_duplicated_members_descriptions.yml @@ -0,0 +1,2 @@ +'Class ''UserLevel'' ''__members_descriptions__'' attribute defines descriptions for + enum members that also have description in ''__schema__'' attribute. (members: ''MEMBER'')' diff --git a/tests/snapshots/test_schema_enum_type_validation_fails_for_empty_enum.yml b/tests/snapshots/test_schema_enum_type_validation_fails_for_empty_enum.yml new file mode 100644 index 0000000..f472c0f --- /dev/null +++ b/tests/snapshots/test_schema_enum_type_validation_fails_for_empty_enum.yml @@ -0,0 +1,2 @@ +Class 'UserLevel' defines '__schema__' attribute that doesn't declare any enum members. +... diff --git a/tests/snapshots/test_schema_enum_type_validation_fails_for_invalid_members_descriptions.yml b/tests/snapshots/test_schema_enum_type_validation_fails_for_invalid_members_descriptions.yml new file mode 100644 index 0000000..69ed30b --- /dev/null +++ b/tests/snapshots/test_schema_enum_type_validation_fails_for_invalid_members_descriptions.yml @@ -0,0 +1,2 @@ +'Class ''UserLevel'' ''__members_descriptions__'' attribute defines descriptions for + undefined enum members. (undefined members: ''INVALID'')' diff --git a/tests/snapshots/test_schema_enum_type_validation_fails_for_invalid_type_schema.yml b/tests/snapshots/test_schema_enum_type_validation_fails_for_invalid_type_schema.yml new file mode 100644 index 0000000..ec84efc --- /dev/null +++ b/tests/snapshots/test_schema_enum_type_validation_fails_for_invalid_type_schema.yml @@ -0,0 +1,3 @@ +Class 'UserLevel' defines '__schema__' attribute with declaration for an invalid GraphQL + type. ('ScalarTypeDefinitionNode' != 'EnumTypeDefinitionNode') +... diff --git a/tests/snapshots/test_schema_enum_type_validation_fails_for_names_not_matching.yml b/tests/snapshots/test_schema_enum_type_validation_fails_for_names_not_matching.yml new file mode 100644 index 0000000..76417dc --- /dev/null +++ b/tests/snapshots/test_schema_enum_type_validation_fails_for_names_not_matching.yml @@ -0,0 +1,3 @@ +Class 'UserLevel' defines both '__graphql_name__' and '__schema__' attributes, but + names in those don't match. ('UserRank' != 'Custom') +... diff --git a/tests/snapshots/test_schema_enum_type_validation_fails_for_schema_and_members_dict_mismatch.yml b/tests/snapshots/test_schema_enum_type_validation_fails_for_schema_and_members_dict_mismatch.yml new file mode 100644 index 0000000..7945146 --- /dev/null +++ b/tests/snapshots/test_schema_enum_type_validation_fails_for_schema_and_members_dict_mismatch.yml @@ -0,0 +1,2 @@ +'Class ''UserLevel'' ''__members__'' is missing values for enum members defined in + ''__schema__''. (missing items: ''MEMBER'')' diff --git a/tests/snapshots/test_schema_enum_type_validation_fails_for_schema_and_members_enum_mismatch.yml b/tests/snapshots/test_schema_enum_type_validation_fails_for_schema_and_members_enum_mismatch.yml new file mode 100644 index 0000000..9221777 --- /dev/null +++ b/tests/snapshots/test_schema_enum_type_validation_fails_for_schema_and_members_enum_mismatch.yml @@ -0,0 +1,2 @@ +'Class ''UserLevel'' ''__members__'' is missing values for enum members defined in + ''__schema__''. (missing items: ''MODERATOR'')' diff --git a/tests/snapshots/test_schema_enum_type_validation_fails_for_schema_and_members_list.yml b/tests/snapshots/test_schema_enum_type_validation_fails_for_schema_and_members_list.yml new file mode 100644 index 0000000..ac227b0 --- /dev/null +++ b/tests/snapshots/test_schema_enum_type_validation_fails_for_schema_and_members_list.yml @@ -0,0 +1,3 @@ +Class 'UserLevel' '__members__' attribute can't be a list when used together with + '__schema__'. +... diff --git a/tests/snapshots/test_schema_enum_type_validation_fails_for_two_descriptions.yml b/tests/snapshots/test_schema_enum_type_validation_fails_for_two_descriptions.yml new file mode 100644 index 0000000..7e8271a --- /dev/null +++ b/tests/snapshots/test_schema_enum_type_validation_fails_for_two_descriptions.yml @@ -0,0 +1,2 @@ +Class 'UserLevel' defines description in both '__description__' and '__schema__' attributes. +... diff --git a/tests/snapshots/test_schema_input_type_instance_with_invalid_attrs_raising_error.yml b/tests/snapshots/test_schema_input_type_instance_with_invalid_attrs_raising_error.yml new file mode 100644 index 0000000..c51434c --- /dev/null +++ b/tests/snapshots/test_schema_input_type_instance_with_invalid_attrs_raising_error.yml @@ -0,0 +1,2 @@ +'SearchInput.__init__() got an unexpected keyword argument ''invalid''. Valid keyword + arguments: ''query'', ''age''' diff --git a/tests/snapshots/test_schema_input_type_validation_fails_for_duplicate_out_name.yml b/tests/snapshots/test_schema_input_type_validation_fails_for_duplicate_out_name.yml new file mode 100644 index 0000000..58146cd --- /dev/null +++ b/tests/snapshots/test_schema_input_type_validation_fails_for_duplicate_out_name.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines multiple fields with an outname 'ok' in it's '__out_names__' + attribute. +... diff --git a/tests/snapshots/test_schema_input_type_validation_fails_for_invalid_out_name.yml b/tests/snapshots/test_schema_input_type_validation_fails_for_invalid_out_name.yml new file mode 100644 index 0000000..3b496e3 --- /dev/null +++ b/tests/snapshots/test_schema_input_type_validation_fails_for_invalid_out_name.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines an outname for 'invalid' field in it's '__out_names__' + attribute which is not defined in '__schema__'. +... diff --git a/tests/snapshots/test_schema_input_type_validation_fails_for_invalid_type_schema.yml b/tests/snapshots/test_schema_input_type_validation_fails_for_invalid_type_schema.yml new file mode 100644 index 0000000..c7632c5 --- /dev/null +++ b/tests/snapshots/test_schema_input_type_validation_fails_for_invalid_type_schema.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines '__schema__' attribute with declaration for an invalid + GraphQL type. ('ScalarTypeDefinitionNode' != 'InputObjectTypeDefinitionNode') +... diff --git a/tests/snapshots/test_schema_input_type_validation_fails_for_names_not_matching.yml b/tests/snapshots/test_schema_input_type_validation_fails_for_names_not_matching.yml new file mode 100644 index 0000000..5d0a017 --- /dev/null +++ b/tests/snapshots/test_schema_input_type_validation_fails_for_names_not_matching.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines both '__graphql_name__' and '__schema__' attributes, but + names in those don't match. ('Lorem' != 'Custom') +... diff --git a/tests/snapshots/test_schema_input_type_validation_fails_for_schema_missing_fields.yml b/tests/snapshots/test_schema_input_type_validation_fails_for_schema_missing_fields.yml new file mode 100644 index 0000000..7a624a4 --- /dev/null +++ b/tests/snapshots/test_schema_input_type_validation_fails_for_schema_missing_fields.yml @@ -0,0 +1,2 @@ +'Class ''CustomType'' defines ''__schema__'' attribute with declaration for an input + type without any fields. ' diff --git a/tests/snapshots/test_schema_input_type_validation_fails_for_two_descriptions.yml b/tests/snapshots/test_schema_input_type_validation_fails_for_two_descriptions.yml new file mode 100644 index 0000000..ba71cfb --- /dev/null +++ b/tests/snapshots/test_schema_input_type_validation_fails_for_two_descriptions.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines description in both '__description__' and '__schema__' + attributes. +... diff --git a/tests/snapshots/test_schema_object_type_instance_with_invalid_attrs_raising_error.yml b/tests/snapshots/test_schema_object_type_instance_with_invalid_attrs_raising_error.yml new file mode 100644 index 0000000..b5049ef --- /dev/null +++ b/tests/snapshots/test_schema_object_type_instance_with_invalid_attrs_raising_error.yml @@ -0,0 +1,2 @@ +'CategoryType.__init__() got an unexpected keyword argument ''invalid''. Valid keyword + arguments: ''name'', ''posts''' diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_alias_resolver.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_alias_resolver.yml new file mode 100644 index 0000000..c38c902 --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_alias_resolver.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines an alias for a field 'hello' that already has a custom + resolver. +... diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_alias_target_resolver.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_alias_target_resolver.yml new file mode 100644 index 0000000..e114d51 --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_alias_target_resolver.yml @@ -0,0 +1,2 @@ +'Class ''CustomType'' defines resolver for an undefined field ''ok''. (Valid fields: + ''hello'')' diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_arg_with_double_description.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_arg_with_double_description.yml new file mode 100644 index 0000000..7fb95f5 --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_arg_with_double_description.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines duplicate descriptions for 'name' argument of the 'hello' + field. +... diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_arg_with_name_option.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_arg_with_name_option.yml new file mode 100644 index 0000000..f9ac74d --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_arg_with_name_option.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines 'name' option for 'name' argument of the 'hello' field. + This is not supported for types defining '__schema__'. +... diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_arg_with_type_option.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_arg_with_type_option.yml new file mode 100644 index 0000000..021b821 --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_arg_with_type_option.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines 'type' option for 'name' argument of the 'hello' field. + This is not supported for types defining '__schema__'. +... diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_field_instance.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_field_instance.yml new file mode 100644 index 0000000..1fb66ce --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_field_instance.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines 'GraphQLObjectField' instance. This is not supported for + types defining '__schema__'. +... diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_field_with_invalid_arg_name.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_field_with_invalid_arg_name.yml new file mode 100644 index 0000000..5c96002 --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_field_with_invalid_arg_name.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines options for 'other' argument of the 'hello' field that + doesn't exist. +... diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_field_with_multiple_descriptions.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_field_with_multiple_descriptions.yml new file mode 100644 index 0000000..694fd36 --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_field_with_multiple_descriptions.yml @@ -0,0 +1,2 @@ +Class 'CustomType' defines multiple descriptions for field 'hello'. +... diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_invalid_alias.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_invalid_alias.yml new file mode 100644 index 0000000..352c185 --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_invalid_alias.yml @@ -0,0 +1,2 @@ +'Class ''CustomType'' defines an alias for an undefined field ''invalid''. (Valid + fields: ''hello'')' diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_invalid_type_schema.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_invalid_type_schema.yml new file mode 100644 index 0000000..998c1f6 --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_invalid_type_schema.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines '__schema__' attribute with declaration for an invalid + GraphQL type. ('ScalarTypeDefinitionNode' != 'ObjectTypeDefinitionNode') +... diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_missing_fields.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_missing_fields.yml new file mode 100644 index 0000000..246403c --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_missing_fields.yml @@ -0,0 +1,2 @@ +'Class ''CustomType'' defines ''__schema__'' attribute with declaration for an object + type without any fields. ' diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_multiple_field_resolvers.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_multiple_field_resolvers.yml new file mode 100644 index 0000000..81b2a07 --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_multiple_field_resolvers.yml @@ -0,0 +1,2 @@ +Class 'CustomType' defines multiple resolvers for field 'hello'. +... diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_names_not_matching.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_names_not_matching.yml new file mode 100644 index 0000000..5d0a017 --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_names_not_matching.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines both '__graphql_name__' and '__schema__' attributes, but + names in those don't match. ('Lorem' != 'Custom') +... diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_two_descriptions.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_two_descriptions.yml new file mode 100644 index 0000000..ba71cfb --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_two_descriptions.yml @@ -0,0 +1,3 @@ +Class 'CustomType' defines description in both '__description__' and '__schema__' + attributes. +... diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_undefined_field_resolver.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_undefined_field_resolver.yml new file mode 100644 index 0000000..bcb3882 --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_undefined_field_resolver.yml @@ -0,0 +1,2 @@ +'Class ''QueryType'' defines resolver for an undefined field ''other''. (Valid fields: + ''hello'')' diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_unsupported_resolver_arg_default.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_unsupported_resolver_arg_default.yml new file mode 100644 index 0000000..6fb04ed --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_unsupported_resolver_arg_default.yml @@ -0,0 +1,3 @@ +Class 'QueryType' defines default value for 'name' argument of the 'hello' field that + can't be represented in GraphQL schema. +... diff --git a/tests/snapshots/test_schema_object_type_validation_fails_for_unsupported_resolver_arg_option_default.yml b/tests/snapshots/test_schema_object_type_validation_fails_for_unsupported_resolver_arg_option_default.yml new file mode 100644 index 0000000..6fb04ed --- /dev/null +++ b/tests/snapshots/test_schema_object_type_validation_fails_for_unsupported_resolver_arg_option_default.yml @@ -0,0 +1,3 @@ +Class 'QueryType' defines default value for 'name' argument of the 'hello' field that + can't be represented in GraphQL schema. +... diff --git a/tests/snapshots/test_schema_scalar_type_validation_fails_for_different_names.yml b/tests/snapshots/test_schema_scalar_type_validation_fails_for_different_names.yml new file mode 100644 index 0000000..5d15013 --- /dev/null +++ b/tests/snapshots/test_schema_scalar_type_validation_fails_for_different_names.yml @@ -0,0 +1,3 @@ +Class 'CustomScalar' defines both '__graphql_name__' and '__schema__' attributes, + but names in those don't match. ('Date' != 'Custom') +... diff --git a/tests/snapshots/test_schema_scalar_type_validation_fails_for_invalid_type_schema.yml b/tests/snapshots/test_schema_scalar_type_validation_fails_for_invalid_type_schema.yml new file mode 100644 index 0000000..0eec05e --- /dev/null +++ b/tests/snapshots/test_schema_scalar_type_validation_fails_for_invalid_type_schema.yml @@ -0,0 +1,3 @@ +Class 'CustomScalar' defines '__schema__' attribute with declaration for an invalid + GraphQL type. ('ObjectTypeDefinitionNode' != 'ScalarTypeDefinitionNode') +... diff --git a/tests/snapshots/test_schema_scalar_type_validation_fails_for_two_descriptions.yml b/tests/snapshots/test_schema_scalar_type_validation_fails_for_two_descriptions.yml new file mode 100644 index 0000000..e10ef27 --- /dev/null +++ b/tests/snapshots/test_schema_scalar_type_validation_fails_for_two_descriptions.yml @@ -0,0 +1,3 @@ +Class 'CustomScalar' defines description in both '__description__' and '__schema__' + attributes. +... diff --git a/tests/snapshots/test_schema_validation_fails_if_lazy_type_doesnt_exist.yml b/tests/snapshots/test_schema_validation_fails_if_lazy_type_doesnt_exist.yml new file mode 100644 index 0000000..f68971b --- /dev/null +++ b/tests/snapshots/test_schema_validation_fails_if_lazy_type_doesnt_exist.yml @@ -0,0 +1,2 @@ +Unknown type 'Missing'. +... diff --git a/tests/snapshots/test_source_args_field_arg_not_dict_without_schema.yml b/tests/snapshots/test_source_args_field_arg_not_dict_without_schema.yml new file mode 100644 index 0000000..51d3732 --- /dev/null +++ b/tests/snapshots/test_source_args_field_arg_not_dict_without_schema.yml @@ -0,0 +1,3 @@ +Argument channel for message_added_generator must have a GraphQLObjectFieldArg as + its info. +... diff --git a/tests/snapshots/test_source_args_not_dict_without_schema.yml b/tests/snapshots/test_source_args_not_dict_without_schema.yml new file mode 100644 index 0000000..d390e7e --- /dev/null +++ b/tests/snapshots/test_source_args_not_dict_without_schema.yml @@ -0,0 +1,2 @@ +The args for message_added_generator must be a dictionary if provided. +... diff --git a/tests/snapshots/test_source_for_undefined_field_with_schema.yml b/tests/snapshots/test_source_for_undefined_field_with_schema.yml new file mode 100644 index 0000000..d520895 --- /dev/null +++ b/tests/snapshots/test_source_for_undefined_field_with_schema.yml @@ -0,0 +1,2 @@ +'Class ''SubscriptionType'' defines source for an undefined field ''message_added''. + (Valid fields: ''messageAdded'')' diff --git a/tests/snapshots/test_undefined_name_without_schema.yml b/tests/snapshots/test_undefined_name_without_schema.yml new file mode 100644 index 0000000..c82b237 --- /dev/null +++ b/tests/snapshots/test_undefined_name_without_schema.yml @@ -0,0 +1,2 @@ +'Class ''SubscriptionType'' defines source for an undefined field ''messageAdded''. + (Valid fields: ''message_added'')' diff --git a/tests/snapshots/test_value_error_is_raised_if_exclude_and_include_members_are_combined.yml b/tests/snapshots/test_value_error_is_raised_if_exclude_and_include_members_are_combined.yml new file mode 100644 index 0000000..393636c --- /dev/null +++ b/tests/snapshots/test_value_error_is_raised_if_exclude_and_include_members_are_combined.yml @@ -0,0 +1 @@ +'''members_include'' and ''members_exclude'' options are mutually exclusive.' diff --git a/tests/snapshots/test_value_error_is_raised_if_member_description_is_set_for_excluded_item.yml b/tests/snapshots/test_value_error_is_raised_if_member_description_is_set_for_excluded_item.yml new file mode 100644 index 0000000..87f633f --- /dev/null +++ b/tests/snapshots/test_value_error_is_raised_if_member_description_is_set_for_excluded_item.yml @@ -0,0 +1,3 @@ +Member description was specified for a member 'ADMINISTRATOR' not present in final + GraphQL enum. +... diff --git a/tests/snapshots/test_value_error_is_raised_if_member_description_is_set_for_missing_item.yml b/tests/snapshots/test_value_error_is_raised_if_member_description_is_set_for_missing_item.yml new file mode 100644 index 0000000..06e660c --- /dev/null +++ b/tests/snapshots/test_value_error_is_raised_if_member_description_is_set_for_missing_item.yml @@ -0,0 +1,3 @@ +Member description was specified for a member 'MISSING' not present in final GraphQL + enum. +... diff --git a/tests/snapshots/test_value_error_is_raised_if_member_description_is_set_for_omitted_item.yml b/tests/snapshots/test_value_error_is_raised_if_member_description_is_set_for_omitted_item.yml new file mode 100644 index 0000000..87f633f --- /dev/null +++ b/tests/snapshots/test_value_error_is_raised_if_member_description_is_set_for_omitted_item.yml @@ -0,0 +1,3 @@ +Member description was specified for a member 'ADMINISTRATOR' not present in final + GraphQL enum. +... diff --git a/tests/test_compatibility_layer.py b/tests/test_compatibility_layer.py new file mode 100644 index 0000000..c358d58 --- /dev/null +++ b/tests/test_compatibility_layer.py @@ -0,0 +1,474 @@ +from dataclasses import dataclass +from datetime import date, datetime + +from graphql import StringValueNode + +from ariadne_graphql_modules.compatibility_layer import wrap_legacy_types +from ariadne_graphql_modules.executable_schema import make_executable_schema +from ariadne_graphql_modules.v1.bases import DeferredType +from ariadne_graphql_modules.v1.collection_type import CollectionType +from ariadne_graphql_modules.v1.enum_type import EnumType +from ariadne_graphql_modules.v1.input_type import InputType +from ariadne_graphql_modules.v1.interface_type import InterfaceType +from ariadne_graphql_modules.v1.object_type import ObjectType +from ariadne_graphql_modules.v1.scalar_type import ScalarType +from ariadne_graphql_modules.v1.subscription_type import SubscriptionType +from ariadne_graphql_modules.v1.union_type import UnionType + +TEST_DATE = date(2006, 9, 13) + + +def test_object_type( + assert_schema_equals, +): + # pylint: disable=unused-variable + class FancyObjectType(ObjectType): + __schema__ = """ + type FancyObject { + id: ID! + someInt: Int! + someFloat: Float! + someBoolean: Boolean! + someString: String! + } + """ + + class QueryType(ObjectType): + __schema__ = """ + type Query { + field: String! + other: String! + firstField: String! + secondField: String! + fieldWithArg(someArg: String): String! + } + """ + __aliases__ = { + "firstField": "first_field", + "secondField": "second_field", + "fieldWithArg": "field_with_arg", + } + __fields_args__ = {"fieldWithArg": {"someArg": "some_arg"}} + + @staticmethod + def resolve_other(*_): + return "Word Up!" + + @staticmethod + def resolve_second_field(obj, *_): + return "Obj: {}".format(obj["secondField"]) + + @staticmethod + def resolve_field_with_arg(*_, some_arg): + return some_arg + + class UserRoleEnum(EnumType): + __schema__ = """ + enum UserRole { + USER + MOD + ADMIN + } + """ + + my_legacy_types = wrap_legacy_types(QueryType, UserRoleEnum, FancyObjectType) + schema = make_executable_schema(*my_legacy_types) + + assert_schema_equals( + schema, + """ + type Query { + field: String! + other: String! + firstField: String! + secondField: String! + fieldWithArg(someArg: String): String! + } + + enum UserRole { + USER + MOD + ADMIN + } + + type FancyObject { + id: ID! + someInt: Int! + someFloat: Float! + someBoolean: Boolean! + someString: String! + } + """, + ) + + +def test_collection_types_are_included_in_schema(assert_schema_equals): + class QueryType(ObjectType): + __schema__ = """ + type Query { + user: User + } + """ + __requires__ = [DeferredType("User")] + + class UserGroupType(ObjectType): + __schema__ = """ + type UserGroup { + id: ID! + } + """ + + class UserType(ObjectType): + __schema__ = """ + type User { + id: ID! + group: UserGroup! + } + """ + __requires__ = [UserGroupType] + + class UserTypes(CollectionType): + __types__ = [ + QueryType, + UserType, + ] + + my_legacy_types = wrap_legacy_types(UserTypes) + schema = make_executable_schema(*my_legacy_types) + + assert_schema_equals( + schema, + """ + type Query { + user: User + } + + type User { + id: ID! + group: UserGroup! + } + + type UserGroup { + id: ID! + } + """, + ) + + +def test_input_type(assert_schema_equals): + class UserInput(InputType): + __schema__ = """ + input UserInput { + id: ID! + fullName: String! + } + """ + __args__ = { + "fullName": "full_name", + } + + class GenericScalar(ScalarType): + __schema__ = "scalar Generic" + + class QueryType(ObjectType): + __schema__ = """ + type Query { + reprInput(input: UserInput): Generic! + } + """ + __aliases__ = {"reprInput": "repr_input"} + __requires__ = [GenericScalar, UserInput] + + @staticmethod + def resolve_repr_input(*_, input): # pylint: disable=redefined-builtin + return input + + my_legacy_types = wrap_legacy_types(QueryType) + schema = make_executable_schema(*my_legacy_types) + + assert_schema_equals( + schema, + """ + scalar Generic + + type Query { + reprInput(input: UserInput): Generic! + } + + input UserInput { + id: ID! + fullName: String! + } + """, + ) + + +def test_interface_type(assert_schema_equals): + @dataclass + class User: + id: int + name: str + summary: str + + @dataclass + class Comment: + id: int + message: str + summary: str + + class ResultInterface(InterfaceType): + __schema__ = """ + interface Result { + summary: String! + score: Int! + } + """ + + @staticmethod + def resolve_type(instance, *_): + if isinstance(instance, Comment): + return "Comment" + + if isinstance(instance, User): + return "User" + + return None + + class QueryType(ObjectType): + __schema__ = """ + type Query { + results: [Result!]! + } + """ + __requires__ = [ResultInterface] + + my_legacy_types = wrap_legacy_types(QueryType) + schema = make_executable_schema(*my_legacy_types) + + assert_schema_equals( + schema, + """ + type Query { + results: [Result!]! + } + + interface Result { + summary: String! + score: Int! + } + """, + ) + + +def test_scalar_type(assert_schema_equals): + class DateReadOnlyScalar(ScalarType): + __schema__ = "scalar DateReadOnly" + + @staticmethod + def serialize(date): + return date.strftime("%Y-%m-%d") + + class DateInputScalar(ScalarType): + __schema__ = "scalar DateInput" + + @staticmethod + def parse_value(formatted_date): + parsed_datetime = datetime.strptime(formatted_date, "%Y-%m-%d") + return parsed_datetime.date() + + @staticmethod + def parse_literal(ast, variable_values=None): # pylint: disable=unused-argument + if not isinstance(ast, StringValueNode): + raise ValueError() + + formatted_date = ast.value + parsed_datetime = datetime.strptime(formatted_date, "%Y-%m-%d") + return parsed_datetime.date() + + class DefaultParserScalar(ScalarType): + __schema__ = "scalar DefaultParser" + + @staticmethod + def parse_value(value): + return type(value).__name__ + + class QueryType(ObjectType): + __schema__ = """ + type Query { + testSerialize: DateReadOnly! + testInput(value: DateInput!): Boolean! + testInputValueType(value: DefaultParser!): String! + } + """ + __requires__ = [ + DateReadOnlyScalar, + DateInputScalar, + DefaultParserScalar, + ] + __aliases__ = { + "testSerialize": "test_serialize", + "testInput": "test_input", + "testInputValueType": "test_input_value_type", + } + + @staticmethod + def resolve_test_serialize(*_): + return TEST_DATE + + @staticmethod + def resolve_test_input(*_, value): + assert value == TEST_DATE + return True + + @staticmethod + def resolve_test_input_value_type(*_, value): + return value + + my_legacy_types = wrap_legacy_types(QueryType) + schema = make_executable_schema(*my_legacy_types) + + assert_schema_equals( + schema, + """ + scalar DateInput + + scalar DateReadOnly + + scalar DefaultParser + + type Query { + testSerialize: DateReadOnly! + testInput(value: DateInput!): Boolean! + testInputValueType(value: DefaultParser!): String! + } + """, + ) + + +def test_subscription_type(assert_schema_equals): + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Interface { + threads: ID! + } + """ + + @staticmethod + def resolve_type(*_): + return "Threads" + + class ChatSubscription(SubscriptionType): + __schema__ = """ + type Subscription implements Interface { + chat: ID! + threads: ID! + } + """ + __requires__ = [ExampleInterface] + + class QueryType(ObjectType): + __schema__ = """ + type Query { + testSubscription: String! + } + """ + + my_legacy_types = wrap_legacy_types( + QueryType, + ExampleInterface, + ChatSubscription, + ) + + schema = make_executable_schema(*my_legacy_types) + + assert_schema_equals( + schema, + """ + type Query { + testSubscription: String! + } + + type Subscription implements Interface { + chat: ID! + threads: ID! + } + + interface Interface { + threads: ID! + } + """, + ) + + +def test_union_type(assert_schema_equals): + @dataclass + class User: + id: int + name: str + + @dataclass + class Comment: + id: int + message: str + + class UserType(ObjectType): + __schema__ = """ + type User { + id: ID! + name: String! + } + """ + + class CommentType(ObjectType): + __schema__ = """ + type Comment { + id: ID! + message: String! + } + """ + + class ExampleUnion(UnionType): + __schema__ = "union Result = User | Comment" + __requires__ = [UserType, CommentType] + + @staticmethod + def resolve_type(instance, *_): + if isinstance(instance, Comment): + return "Comment" + + if isinstance(instance, User): + return "User" + + return None + + class QueryType(ObjectType): + __schema__ = """ + type Query { + testUnion: String! + } + """ + + my_legacy_types = wrap_legacy_types(ExampleUnion, CommentType, QueryType) + + schema = make_executable_schema(*my_legacy_types) + + assert_schema_equals( + schema, + """ + type Query { + testUnion: String! + } + + union Result = User | Comment + + type User { + id: ID! + name: String! + } + + type Comment { + id: ID! + message: String! + } + """, + ) diff --git a/tests/test_deferred_type.py b/tests/test_deferred_type.py new file mode 100644 index 0000000..015a024 --- /dev/null +++ b/tests/test_deferred_type.py @@ -0,0 +1,65 @@ +# pylint: disable=unused-variable +from unittest.mock import Mock + +import pytest + +from ariadne_graphql_modules import deferred +from ariadne_graphql_modules.deferredtype import ( + DeferredTypeData, + _resolve_module_path_suffix, +) + + +def test_deferred_type_data(): + data = DeferredTypeData(path="some.module.path") + assert data.path == "some.module.path" + + +def test_deferred_abs_path(): + deferred_type = deferred("tests.types") + assert deferred_type.path == "tests.types" + + +def test_deferred_relative_path(): + class MockType: + deferred_type = deferred(".types") + + assert MockType.deferred_type.path == "tests.types" + + +def test_deferred_returns_deferred_type_with_higher_level_relative_path(monkeypatch): + frame_mock = Mock(f_globals={"__package__": "lorem.ipsum"}) + monkeypatch.setattr( + "ariadne_graphql_modules.deferredtype.sys._getframe", + Mock(return_value=frame_mock), + ) + + class MockType: + deferred_type = deferred("..types") + + assert MockType.deferred_type.path == "lorem.types" + + +def test_deferred_raises_error_for_invalid_relative_path(monkeypatch, data_regression): + frame_mock = Mock(f_globals={"__package__": "lorem"}) + monkeypatch.setattr( + "ariadne_graphql_modules.deferredtype.sys._getframe", + Mock(return_value=frame_mock), + ) + + with pytest.raises(ValueError) as exc_info: + + class MockType: + deferred_type = deferred("...types") + + data_regression.check(str(exc_info.value)) + + +def test_resolve_module_path_suffix(): + result = _resolve_module_path_suffix(".types", "current.package") + assert result == "current.package.types" + + +def test_resolve_module_path_suffix_outside_package(): + with pytest.raises(ValueError): + _resolve_module_path_suffix("...module", "current.package") diff --git a/tests/test_description_node.py b/tests/test_description_node.py new file mode 100644 index 0000000..88d42d2 --- /dev/null +++ b/tests/test_description_node.py @@ -0,0 +1,41 @@ +from graphql import StringValueNode + +from ariadne_graphql_modules import get_description_node + + +def test_no_description_is_returned_for_none(): + description = get_description_node(None) + assert description is None + + +def test_no_description_is_returned_for_empty_str(): + description = get_description_node("") + assert description is None + + +def test_description_is_returned_for_str(): + description = get_description_node("Example string.") + assert isinstance(description, StringValueNode) + assert description.value == "Example string." + assert description.block is False + + +def test_description_is_stripped_for_whitespace(): + description = get_description_node(" Example string.\n") + assert isinstance(description, StringValueNode) + assert description.value == "Example string." + assert description.block is False + + +def test_block_description_is_returned_for_multiline_str(): + description = get_description_node("Example string.\nNext line.") + assert isinstance(description, StringValueNode) + assert description.value == "Example string.\nNext line." + assert description.block is True + + +def test_block_description_is_dedented(): + description = get_description_node(" Example string.\n Next line.") + assert isinstance(description, StringValueNode) + assert description.value == "Example string.\nNext line." + assert description.block is True diff --git a/tests/test_enum_type.py b/tests/test_enum_type.py index 730eb8c..3be906f 100644 --- a/tests/test_enum_type.py +++ b/tests/test_enum_type.py @@ -1,302 +1,569 @@ from enum import Enum -import pytest -from ariadne import SchemaDirectiveVisitor -from graphql import GraphQLError, graphql_sync +from graphql import graphql_sync from ariadne_graphql_modules import ( - DirectiveType, - EnumType, - ObjectType, + GraphQLEnum, + GraphQLObject, make_executable_schema, ) -def test_enum_type_raises_attribute_error_when_defined_without_schema(snapshot): - with pytest.raises(AttributeError) as err: - # pylint: disable=unused-variable - class UserRoleEnum(EnumType): - pass +class UserLevelEnum(Enum): + GUEST = 0 + MEMBER = 1 + ADMIN = 2 - snapshot.assert_match(err) +def test_enum_field_returning_enum_value(assert_schema_equals): + class UserLevel(GraphQLEnum): + __members__ = UserLevelEnum -def test_enum_type_raises_error_when_defined_with_invalid_schema_type(snapshot): - with pytest.raises(TypeError) as err: - # pylint: disable=unused-variable - class UserRoleEnum(EnumType): - __schema__ = True + class QueryType(GraphQLObject): + level: UserLevel - snapshot.assert_match(err) + @GraphQLObject.resolver("level") + @staticmethod + def resolve_level(*_) -> UserLevelEnum: + return UserLevelEnum.MEMBER + schema = make_executable_schema(QueryType) -def test_enum_type_raises_error_when_defined_with_invalid_schema_str(snapshot): - with pytest.raises(GraphQLError) as err: - # pylint: disable=unused-variable - class UserRoleEnum(EnumType): - __schema__ = "enom UserRole" + assert_schema_equals( + schema, + """ + type Query { + level: UserLevel! + } + + enum UserLevel { + GUEST + MEMBER + ADMIN + } + """, + ) - snapshot.assert_match(err) + result = graphql_sync(schema, "{ level }") + assert not result.errors + assert result.data == {"level": "MEMBER"} -def test_enum_type_raises_error_when_defined_with_invalid_graphql_type_schema( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserRoleEnum(EnumType): - __schema__ = "scalar UserRole" - snapshot.assert_match(err) +def test_enum_field_returning_dict_value(assert_schema_equals): + class UserLevel(GraphQLEnum): + __members__ = { + "GUEST": 0, + "MEMBER": 1, + "ADMIN": 2, + } + class QueryType(GraphQLObject): + level: UserLevel -def test_enum_type_raises_error_when_defined_with_multiple_types_schema(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserRoleEnum(EnumType): - __schema__ = """ - enum UserRole { - USER - MOD - ADMIN - } + @GraphQLObject.resolver("level") + @staticmethod + def resolve_level(*_) -> int: + return 0 + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + level: UserLevel! + } + + enum UserLevel { + GUEST + MEMBER + ADMIN + } + """, + ) + + result = graphql_sync(schema, "{ level }") + + assert not result.errors + assert result.data == {"level": "GUEST"} + + +def test_enum_field_returning_str_value(assert_schema_equals): + class UserLevel(GraphQLEnum): + __members__ = [ + "GUEST", + "MEMBER", + "ADMIN", + ] + + class QueryType(GraphQLObject): + level: UserLevel + + @GraphQLObject.resolver("level") + @staticmethod + def resolve_level(*_) -> str: + return "ADMIN" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + level: UserLevel! + } + + enum UserLevel { + GUEST + MEMBER + ADMIN + } + """, + ) + + result = graphql_sync(schema, "{ level }") + + assert not result.errors + assert result.data == {"level": "ADMIN"} + + +def test_enum_type_with_custom_name(assert_schema_equals): + class UserLevel(GraphQLEnum): + __graphql_name__ = "UserLevelEnum" + __members__ = UserLevelEnum + + class QueryType(GraphQLObject): + level: UserLevel + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + level: UserLevelEnum! + } + + enum UserLevelEnum { + GUEST + MEMBER + ADMIN + } + """, + ) + + +def test_enum_type_with_description(assert_schema_equals): + class UserLevel(GraphQLEnum): + __description__ = "Hello world." + __members__ = UserLevelEnum + + class QueryType(GraphQLObject): + level: UserLevel + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + level: UserLevel! + } + + \"\"\"Hello world.\"\"\" + enum UserLevel { + GUEST + MEMBER + ADMIN + } + """, + ) + + +def test_enum_type_with_member_description(assert_schema_equals): + class UserLevel(GraphQLEnum): + __members__ = UserLevelEnum + __members_descriptions__ = {"MEMBER": "Hello world."} + + class QueryType(GraphQLObject): + level: UserLevel + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + level: UserLevel! + } - enum Category { - CATEGORY - LINK + enum UserLevel { + GUEST + + \"\"\"Hello world.\"\"\" + MEMBER + ADMIN + } + """, + ) + + +def test_schema_enum_field_returning_enum_value(assert_schema_equals): + class UserLevel(GraphQLEnum): + __schema__ = """ + enum UserLevel { + GUEST + MEMBER + ADMIN } """ + __members__ = UserLevelEnum + + class QueryType(GraphQLObject): + level: UserLevel + + @GraphQLObject.resolver("level") + @staticmethod + def resolve_level(*_) -> UserLevelEnum: + return UserLevelEnum.MEMBER - snapshot.assert_match(err) + schema = make_executable_schema(QueryType) + assert_schema_equals( + schema, + """ + type Query { + level: UserLevel! + } -def test_enum_type_extracts_graphql_name(): - class UserRoleEnum(EnumType): + enum UserLevel { + GUEST + MEMBER + ADMIN + } + """, + ) + + result = graphql_sync(schema, "{ level }") + + assert not result.errors + assert result.data == {"level": "MEMBER"} + + +def test_schema_enum_field_returning_dict_value(assert_schema_equals): + class UserLevel(GraphQLEnum): __schema__ = """ - enum UserRole { - USER - MOD - ADMIN + enum UserLevel { + GUEST + MEMBER + ADMIN } + """ + __members__ = { + "GUEST": 0, + "MEMBER": 1, + "ADMIN": 2, + } + + class QueryType(GraphQLObject): + level: UserLevel + + @GraphQLObject.resolver("level") + @staticmethod + def resolve_level(*_) -> int: + return 2 + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ + type Query { + level: UserLevel! + } - assert UserRoleEnum.graphql_name == "UserRole" + enum UserLevel { + GUEST + MEMBER + ADMIN + } + """, + ) + + result = graphql_sync(schema, "{ level }") + assert not result.errors + assert result.data == {"level": "ADMIN"} -def test_enum_type_can_be_extended_with_new_values(): - # pylint: disable=unused-variable - class UserRoleEnum(EnumType): + +def test_schema_enum_field_returning_str_value(assert_schema_equals): + class UserLevel(GraphQLEnum): __schema__ = """ - enum UserRole { - USER - MOD - ADMIN + enum UserLevel { + GUEST + MEMBER + ADMIN } + """ + + class QueryType(GraphQLObject): + level: UserLevel + + @GraphQLObject.resolver("level") + @staticmethod + def resolve_level(*_) -> str: + return "GUEST" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ + type Query { + level: UserLevel! + } - class ExtendUserRoleEnum(EnumType): - __schema__ = """ - extend enum UserRole { - MVP + enum UserLevel { + GUEST + MEMBER + ADMIN } - """ - __requires__ = [UserRoleEnum] + """, + ) + + result = graphql_sync(schema, "{ level }") + assert not result.errors + assert result.data == {"level": "GUEST"} -def test_enum_type_can_be_extended_with_directive(): - # pylint: disable=unused-variable - class ExampleDirective(DirectiveType): - __schema__ = "directive @example on ENUM" - __visitor__ = SchemaDirectiveVisitor - class UserRoleEnum(EnumType): +def test_schema_enum_with_description_attr(assert_schema_equals): + class UserLevel(GraphQLEnum): __schema__ = """ - enum UserRole { - USER - MOD - ADMIN + enum UserLevel { + GUEST + MEMBER + ADMIN } - """ + """ + __members__ = { + "GUEST": 0, + "MEMBER": 1, + "ADMIN": 2, + } + __description__ = "Hello world." - class ExtendUserRoleEnum(EnumType): - __schema__ = "extend enum UserRole @example" - __requires__ = [UserRoleEnum, ExampleDirective] + class QueryType(GraphQLObject): + level: UserLevel + @GraphQLObject.resolver("level") + @staticmethod + def resolve_level(*_) -> int: + return 2 -class BaseQueryType(ObjectType): - __abstract__ = True - __schema__ = """ - type Query { - enumToRepr(enum: UserRole = USER): String! - reprToEnum: UserRole! - } - """ - __aliases__ = { - "enumToRepr": "enum_repr", - } + schema = make_executable_schema(QueryType) - @staticmethod - def resolve_enum_repr(*_, enum) -> str: - return repr(enum) + assert_schema_equals( + schema, + """ + type Query { + level: UserLevel! + } + \"\"\"Hello world.\"\"\" + enum UserLevel { + GUEST + MEMBER + ADMIN + } + """, + ) -def make_test_schema(enum_type): - class QueryType(BaseQueryType): - __requires__ = [enum_type] + result = graphql_sync(schema, "{ level }") - return make_executable_schema(QueryType) + assert not result.errors + assert result.data == {"level": "ADMIN"} -def test_enum_type_can_be_defined_with_dict_mapping(): - class UserRoleEnum(EnumType): +def test_schema_enum_with_schema_description(assert_schema_equals): + class UserLevel(GraphQLEnum): __schema__ = """ - enum UserRole { - USER - MOD - ADMIN + \"\"\"Hello world.\"\"\" + enum UserLevel { + GUEST + MEMBER + ADMIN } - """ - __enum__ = { - "USER": 0, - "MOD": 1, + """ + __members__ = { + "GUEST": 0, + "MEMBER": 1, "ADMIN": 2, } - schema = make_test_schema(UserRoleEnum) + class QueryType(GraphQLObject): + level: UserLevel - # Specfied enum value is reversed - result = graphql_sync(schema, "{ enumToRepr(enum: MOD) }") - assert result.data["enumToRepr"] == "1" + @GraphQLObject.resolver("level") + @staticmethod + def resolve_level(*_) -> int: + return 2 - # Default enum value is reversed - result = graphql_sync(schema, "{ enumToRepr }") - assert result.data["enumToRepr"] == "0" + schema = make_executable_schema(QueryType) - # Python value is converted to enum - result = graphql_sync(schema, "{ reprToEnum }", root_value={"reprToEnum": 2}) - assert result.data["reprToEnum"] == "ADMIN" + assert_schema_equals( + schema, + """ + type Query { + level: UserLevel! + } + \"\"\"Hello world.\"\"\" + enum UserLevel { + GUEST + MEMBER + ADMIN + } + """, + ) -def test_enum_type_raises_error_when_dict_mapping_misses_items_from_definition( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserRoleEnum(EnumType): - __schema__ = """ - enum UserRole { - USER - MOD - ADMIN - } - """ - __enum__ = { - "USER": 0, - "MODERATOR": 1, - "ADMIN": 2, - } + result = graphql_sync(schema, "{ level }") - snapshot.assert_match(err) - - -def test_enum_type_raises_error_when_dict_mapping_has_extra_items_not_in_definition( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserRoleEnum(EnumType): - __schema__ = """ - enum UserRole { - USER - MOD - ADMIN - } - """ - __enum__ = { - "USER": 0, - "REVIEW": 1, - "MOD": 2, - "ADMIN": 3, + assert not result.errors + assert result.data == {"level": "ADMIN"} + + +def test_schema_enum_with_member_description(assert_schema_equals): + class UserLevel(GraphQLEnum): + __schema__ = """ + enum UserLevel { + GUEST + MEMBER + ADMIN } + """ + __members__ = { + "GUEST": 0, + "MEMBER": 1, + "ADMIN": 2, + } + __members_descriptions__ = {"MEMBER": "Hello world."} + + class QueryType(GraphQLObject): + level: UserLevel - snapshot.assert_match(err) + @GraphQLObject.resolver("level") + @staticmethod + def resolve_level(*_) -> int: + return 2 + schema = make_executable_schema(QueryType) -def test_enum_type_can_be_defined_with_str_enum_mapping(): - class RoleEnum(str, Enum): - USER = "user" - MOD = "moderator" - ADMIN = "administrator" + assert_schema_equals( + schema, + """ + type Query { + level: UserLevel! + } + + enum UserLevel { + GUEST - class UserRoleEnum(EnumType): + \"\"\"Hello world.\"\"\" + MEMBER + ADMIN + } + """, + ) + + result = graphql_sync(schema, "{ level }") + + assert not result.errors + assert result.data == {"level": "ADMIN"} + + +def test_schema_enum_with_member_schema_description(assert_schema_equals): + class UserLevel(GraphQLEnum): __schema__ = """ - enum UserRole { - USER - MOD - ADMIN + enum UserLevel { + GUEST + \"\"\"Hello world.\"\"\" + MEMBER + ADMIN } + """ + __members__ = { + "GUEST": 0, + "MEMBER": 1, + "ADMIN": 2, + } + + class QueryType(GraphQLObject): + level: UserLevel + + @GraphQLObject.resolver("level") + @staticmethod + def resolve_level(*_) -> int: + return 2 + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ - __enum__ = RoleEnum + type Query { + level: UserLevel! + } + + enum UserLevel { + GUEST + + \"\"\"Hello world.\"\"\" + MEMBER + ADMIN + } + """, + ) + + result = graphql_sync(schema, "{ level }") + + assert not result.errors + assert result.data == {"level": "ADMIN"} - schema = make_test_schema(UserRoleEnum) - # Specfied enum value is reversed - result = graphql_sync(schema, "{ enumToRepr(enum: MOD) }") - assert result.data["enumToRepr"] == repr(RoleEnum.MOD) +def test_enum_field_as_argument(assert_schema_equals): + class UserLevel(GraphQLEnum): + __members__ = UserLevelEnum - # Default enum value is reversed - result = graphql_sync(schema, "{ enumToRepr }") - assert result.data["enumToRepr"] == repr(RoleEnum.USER) + class QueryType(GraphQLObject): + set_level: UserLevel - # Python value is converted to enum - result = graphql_sync( - schema, "{ reprToEnum }", root_value={"reprToEnum": "administrator"} + @GraphQLObject.resolver( + "set_level", args={"level": GraphQLObject.argument(graphql_type=UserLevel)} + ) + @staticmethod + def resolve_level(*_, level: UserLevelEnum) -> UserLevelEnum: + return level + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + setLevel(level: UserLevel!): UserLevel! + } + + enum UserLevel { + GUEST + MEMBER + ADMIN + } + """, ) - assert result.data["reprToEnum"] == "ADMIN" - - -def test_enum_type_raises_error_when_enum_mapping_misses_items_from_definition( - snapshot, -): - class RoleEnum(str, Enum): - USER = "user" - MODERATOR = "moderator" - ADMIN = "administrator" - - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserRoleEnum(EnumType): - __schema__ = """ - enum UserRole { - USER - MOD - ADMIN - } - """ - __enum__ = RoleEnum - - snapshot.assert_match(err) - - -def test_enum_type_raises_error_when_enum_mapping_has_extra_items_not_in_definition( - snapshot, -): - class RoleEnum(str, Enum): - USER = "user" - REVIEW = "review" - MOD = "moderator" - ADMIN = "administrator" - - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserRoleEnum(EnumType): - __schema__ = """ - enum UserRole { - USER - MOD - ADMIN - } - """ - __enum__ = RoleEnum - snapshot.assert_match(err) + result = graphql_sync(schema, "{ setLevel(level: GUEST) }") + + assert not result.errors + assert result.data == {"setLevel": "GUEST"} diff --git a/tests/test_enum_type_validation.py b/tests/test_enum_type_validation.py new file mode 100644 index 0000000..2ef8b4a --- /dev/null +++ b/tests/test_enum_type_validation.py @@ -0,0 +1,183 @@ +# pylint: disable=unused-variable +from enum import Enum + +import pytest +from ariadne import gql + +from ariadne_graphql_modules import GraphQLEnum + + +def test_schema_enum_type_validation_fails_for_invalid_type_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class UserLevel(GraphQLEnum): + __schema__ = gql("scalar Custom") + + data_regression.check(str(exc_info.value)) + + +def test_schema_enum_type_validation_fails_for_names_not_matching( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class UserLevel(GraphQLEnum): + __graphql_name__ = "UserRank" + __schema__ = gql( + """ + enum Custom { + GUEST + MEMBER + } + """ + ) + + data_regression.check(str(exc_info.value)) + + +def test_schema_enum_type_validation_fails_for_empty_enum(data_regression): + with pytest.raises(ValueError) as exc_info: + + class UserLevel(GraphQLEnum): + __schema__ = gql("enum UserLevel") + + data_regression.check(str(exc_info.value)) + + +def test_schema_enum_type_validation_fails_for_two_descriptions(data_regression): + with pytest.raises(ValueError) as exc_info: + + class UserLevel(GraphQLEnum): + __description__ = "Hello world!" + __schema__ = gql( + """ + \"\"\"Other description\"\"\" + enum Custom { + GUEST + MEMBER + } + """ + ) + + data_regression.check(str(exc_info.value)) + + +def test_schema_enum_type_validation_fails_for_schema_and_members_list(data_regression): + with pytest.raises(ValueError) as exc_info: + + class UserLevel(GraphQLEnum): + __schema__ = gql( + """ + enum Custom { + GUEST + MEMBER + } + """ + ) + __members__ = ["GUEST", "MEMBER"] + + data_regression.check(str(exc_info.value)) + + +def test_schema_enum_type_validation_fails_for_schema_and_members_dict_mismatch( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class UserLevel(GraphQLEnum): + __schema__ = gql( + """ + enum Custom { + GUEST + MEMBER + } + """ + ) + __members__ = { + "GUEST": 0, + "MODERATOR": 1, + } + + data_regression.check(str(exc_info.value)) + + +def test_schema_enum_type_validation_fails_for_schema_and_members_enum_mismatch( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class UserLevelEnum(Enum): + GUEST = 0 + MEMBER = 1 + ADMIN = 2 + + class UserLevel(GraphQLEnum): + __schema__ = gql( + """ + enum Custom { + GUEST + MEMBER + MODERATOR + } + """ + ) + __members__ = UserLevelEnum + + data_regression.check(str(exc_info.value)) + + +def test_schema_enum_type_validation_fails_for_duplicated_members_descriptions( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class UserLevel(GraphQLEnum): + __schema__ = gql( + """ + enum Custom { + GUEST + \"Lorem ipsum.\" + MEMBER + } + """ + ) + __members_descriptions__ = {"MEMBER": "Other description."} + + data_regression.check(str(exc_info.value)) + + +def test_schema_enum_type_validation_fails_for_invalid_members_descriptions( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class UserLevel(GraphQLEnum): + __schema__ = gql( + """ + enum Custom { + GUEST + MEMBER + } + """ + ) + __members_descriptions__ = {"INVALID": "Other description."} + + data_regression.check(str(exc_info.value)) + + +def test_enum_type_validation_fails_for_missing_members(data_regression): + with pytest.raises(ValueError) as exc_info: + + class UserLevel(GraphQLEnum): + pass + + data_regression.check(str(exc_info.value)) + + +def test_enum_type_validation_fails_for_invalid_members(data_regression): + with pytest.raises(ValueError) as exc_info: + + class UserLevel(GraphQLEnum): + __members__ = "INVALID" # type: ignore + + data_regression.check(str(exc_info.value)) diff --git a/tests/test_get_field_args_from_resolver.py b/tests/test_get_field_args_from_resolver.py new file mode 100644 index 0000000..0cc824a --- /dev/null +++ b/tests/test_get_field_args_from_resolver.py @@ -0,0 +1,156 @@ +# pylint: disable=unused-argument +from ariadne_graphql_modules.object_type import get_field_args_from_resolver + + +def test_field_has_no_args_after_obj_and_info_args(): + def field_resolver(obj, info): + pass + + field_args = get_field_args_from_resolver(field_resolver) + assert not field_args + + +def test_field_has_no_args_in_resolver_with_catch_all_args_list(): + def field_resolver(*_): + pass + + field_args = get_field_args_from_resolver(field_resolver) + assert not field_args + + +def test_field_has_arg_after_excess_positional_args(): + def field_resolver(*_, name): + pass + + field_args = get_field_args_from_resolver(field_resolver) + assert len(field_args) == 1 + + field_arg = field_args["name"] + assert field_arg.name == "name" + assert field_arg.out_name == "name" + assert field_arg.field_type is None + + +def test_field_has_arg_after_positional_args_separator(): + def field_resolver(obj, info, *, name): + pass + + field_args = get_field_args_from_resolver(field_resolver) + assert len(field_args) == 1 + + field_arg = field_args["name"] + assert field_arg.name == "name" + assert field_arg.out_name == "name" + assert field_arg.field_type is None + + +def test_field_has_arg_after_obj_and_info_args(): + def field_resolver(obj, info, name): + pass + + field_args = get_field_args_from_resolver(field_resolver) + assert len(field_args) == 1 + + field_arg = field_args["name"] + assert field_arg.name == "name" + assert field_arg.out_name == "name" + assert field_arg.field_type is None + + +def test_field_has_multiple_args_after_excess_positional_args(): + def field_resolver(*_, name, age_cutoff: int): + pass + + field_args = get_field_args_from_resolver(field_resolver) + assert len(field_args) == 2 + + name_arg = field_args["name"] + assert name_arg.name == "name" + assert name_arg.out_name == "name" + assert name_arg.field_type is None + + age_arg = field_args["age_cutoff"] + assert age_arg.name == "ageCutoff" + assert age_arg.out_name == "age_cutoff" + assert age_arg.field_type is int + + +def test_field_has_multiple_args_after_positional_args_separator(): + def field_resolver(obj, info, *, name, age_cutoff: int): + pass + + field_args = get_field_args_from_resolver(field_resolver) + assert len(field_args) == 2 + + name_arg = field_args["name"] + assert name_arg.name == "name" + assert name_arg.out_name == "name" + assert name_arg.field_type is None + + age_arg = field_args["age_cutoff"] + assert age_arg.name == "ageCutoff" + assert age_arg.out_name == "age_cutoff" + assert age_arg.field_type is int + + +def test_field_has_multiple_args_after_obj_and_info_args(): + def field_resolver(obj, info, name, age_cutoff: int): + pass + + field_args = get_field_args_from_resolver(field_resolver) + assert len(field_args) == 2 + + name_arg = field_args["name"] + assert name_arg.name == "name" + assert name_arg.out_name == "name" + assert name_arg.field_type is None + + age_arg = field_args["age_cutoff"] + assert age_arg.name == "ageCutoff" + assert age_arg.out_name == "age_cutoff" + assert age_arg.field_type is int + + +def test_field_has_arg_after_obj_and_info_args_on_class_function(): + class CustomObject: + @staticmethod + def field_resolver(obj, info, name): + pass + + field_args = get_field_args_from_resolver(CustomObject.field_resolver) + assert len(field_args) == 1 + + field_arg = field_args["name"] + assert field_arg.name == "name" + assert field_arg.out_name == "name" + assert field_arg.field_type is None + + +def test_field_has_arg_after_obj_and_info_args_on_class_method(): + class CustomObject: + @classmethod + def field_resolver(cls, obj, info, name): + pass + + field_args = get_field_args_from_resolver(CustomObject.field_resolver) + assert len(field_args) == 1 + + field_arg = field_args["name"] + assert field_arg.name == "name" + assert field_arg.out_name == "name" + assert field_arg.field_type is None + + +def test_field_has_arg_after_obj_and_info_args_on_static_method(): + class CustomObject: + @staticmethod + def field_resolver(obj, info, name): + pass + + field_args = get_field_args_from_resolver(CustomObject.field_resolver) + assert len(field_args) == 1 + + field_arg = field_args["name"] + assert field_arg.name == "name" + assert field_arg.out_name == "name" + assert field_arg.field_type is None diff --git a/tests/test_id_type.py b/tests/test_id_type.py new file mode 100644 index 0000000..c313de8 --- /dev/null +++ b/tests/test_id_type.py @@ -0,0 +1,161 @@ +from graphql import graphql_sync + +from ariadne_graphql_modules import ( + GraphQLID, + GraphQLInput, + GraphQLObject, + make_executable_schema, +) + + +def test_graphql_id_instance_from_int_is_casted_to_str(): + gid = GraphQLID(123) + assert gid.value == "123" + + +def test_graphql_id_instance_from_str_remains_as_str(): + gid = GraphQLID("obj-id") + assert gid.value == "obj-id" + + +def test_graphql_id_can_be_cast_to_int(): + gid = GraphQLID(123) + assert int(gid) == 123 + + +def test_graphql_id_can_be_cast_to_str(): + gid = GraphQLID(123) + assert str(gid) == "123" + + +def test_graphql_id_can_be_compared_to_other_id(): + assert GraphQLID("123") == GraphQLID("123") + assert GraphQLID(123) == GraphQLID(123) + assert GraphQLID(123) == GraphQLID("123") + assert GraphQLID("123") == GraphQLID(123) + + assert GraphQLID("123") != GraphQLID("321") + assert GraphQLID(123) != GraphQLID(321) + assert GraphQLID(321) != GraphQLID("123") + assert GraphQLID("123") != GraphQLID(321) + + +def test_graphql_id_can_be_compared_to_str(): + assert GraphQLID("123") == "123" + assert GraphQLID(123) == "123" + + assert GraphQLID("123") != "321" + assert GraphQLID(123) != "321" + + +def test_graphql_id_can_be_compared_to_int(): + assert GraphQLID("123") == 123 + assert GraphQLID(123) == 123 + + assert GraphQLID("123") != 321 + assert GraphQLID(123) != 321 + + +def test_graphql_id_can_be_compared_to_others(): + assert GraphQLID("123") != 123.0 + assert GraphQLID(123) != 123.0 + + +def test_graphql_id_object_field_type_hint(assert_schema_equals): + class QueryType(GraphQLObject): + id: GraphQLID + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + id: ID! + } + """, + ) + + result = graphql_sync(schema, "{ id }", root_value={"id": 123}) + + assert not result.errors + assert result.data == {"id": "123"} + + +def test_graphql_id_object_field_instance(assert_schema_equals): + class QueryType(GraphQLObject): + @GraphQLObject.field(name="id") + @staticmethod + def id_type(*_) -> GraphQLID: + return GraphQLID(115) + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + id: ID! + } + """, + ) + + result = graphql_sync(schema, "{ id }") + + assert not result.errors + assert result.data == {"id": "115"} + + +def test_graphql_id_object_field_instance_arg(assert_schema_equals): + class QueryType(GraphQLObject): + @GraphQLObject.field(name="id") + @staticmethod + def id_type(*_, arg: GraphQLID) -> str: + return str(arg) + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + id(arg: ID!): String! + } + """, + ) + + result = graphql_sync(schema, '{ id(arg: "123") }') + + assert not result.errors + assert result.data == {"id": "123"} + + +def test_graphql_id_input_field(assert_schema_equals): + class ArgType(GraphQLInput): + id: GraphQLID + + class QueryType(GraphQLObject): + @GraphQLObject.field(name="id") + @staticmethod + def id_type(*_, arg: ArgType) -> str: + return str(arg.id) + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + id(arg: Arg!): String! + } + + input Arg { + id: ID! + } + """, + ) + + result = graphql_sync(schema, '{ id(arg: {id: "123"}) }') + + assert not result.errors + assert result.data == {"id": "123"} diff --git a/tests/test_input_type.py b/tests/test_input_type.py index ded8d94..b2a0e0a 100644 --- a/tests/test_input_type.py +++ b/tests/test_input_type.py @@ -1,285 +1,720 @@ +from typing import Optional + import pytest -from ariadne import SchemaDirectiveVisitor -from graphql import GraphQLError, graphql_sync +from ariadne import gql +from graphql import graphql_sync from ariadne_graphql_modules import ( - DeferredType, - DirectiveType, - EnumType, - InputType, - InterfaceType, - ObjectType, - ScalarType, + GraphQLInput, + GraphQLObject, make_executable_schema, ) -def test_input_type_raises_attribute_error_when_defined_without_schema(snapshot): - with pytest.raises(AttributeError) as err: - # pylint: disable=unused-variable - class UserInput(InputType): - pass +def test_input_type_instance_with_all_attrs_values(): + class SearchInput(GraphQLInput): + query: str + age: int + + obj = SearchInput(query="search", age=20) + assert obj.query == "search" + assert obj.age == 20 + + +def test_input_type_instance_with_omitted_attrs_being_none(): + class SearchInput(GraphQLInput): + query: str + age: int + + obj = SearchInput(age=20) + assert obj.query is None + assert obj.age == 20 + - snapshot.assert_match(err) +def test_input_type_instance_with_default_attrs_values(): + class SearchInput(GraphQLInput): + query: str = "default" + age: int = 42 + obj = SearchInput(age=20) + assert obj.query == "default" + assert obj.age == 20 -def test_input_type_raises_error_when_defined_with_invalid_schema_type(snapshot): - with pytest.raises(TypeError) as err: - # pylint: disable=unused-variable - class UserInput(InputType): - __schema__ = True - snapshot.assert_match(err) +def test_input_type_instance_with_all_fields_values(): + class SearchInput(GraphQLInput): + query: str = GraphQLInput.field() + age: int = GraphQLInput.field() + obj = SearchInput(query="search", age=20) + assert obj.query == "search" + assert obj.age == 20 -def test_input_type_raises_error_when_defined_with_invalid_schema_str(snapshot): - with pytest.raises(GraphQLError) as err: - # pylint: disable=unused-variable - class UserInput(InputType): - __schema__ = "inpet UserInput" - snapshot.assert_match(err) +def test_input_type_instance_with_all_fields_default_values(): + class SearchInput(GraphQLInput): + query: str = GraphQLInput.field(default_value="default") + age: int = GraphQLInput.field(default_value=42) + obj = SearchInput(age=20) + assert obj.query == "default" + assert obj.age == 20 -def test_input_type_raises_error_when_defined_with_invalid_graphql_type_schema( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserInput(InputType): - __schema__ = """ - type User { - id: ID! + +def test_input_type_instance_with_invalid_attrs_raising_error(data_regression): + class SearchInput(GraphQLInput): + query: str + age: int + + with pytest.raises(TypeError) as exc_info: + SearchInput(age=20, invalid="Ok") + + data_regression.check(str(exc_info.value)) + + +def test_schema_input_type_instance_with_all_attrs_values(): + class SearchInput(GraphQLInput): + __schema__ = gql( + """ + input Search { + query: String + age: Int } """ + ) - snapshot.assert_match(err) + query: str + age: int + obj = SearchInput(query="search", age=20) + assert obj.query == "search" + assert obj.age == 20 -def test_input_type_raises_error_when_defined_with_multiple_types_schema(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserInput(InputType): - __schema__ = """ - input User - input Group +def test_schema_input_type_instance_with_omitted_attrs_being_none(): + class SearchInput(GraphQLInput): + __schema__ = gql( """ + input Search { + query: String + age: Int + } + """ + ) - snapshot.assert_match(err) + query: str + age: int + obj = SearchInput(age=20) + assert obj.query is None + assert obj.age == 20 -def test_input_type_raises_error_when_defined_without_fields(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserInput(InputType): - __schema__ = "input User" - snapshot.assert_match(err) +def test_schema_input_type_instance_with_default_attrs_values(): + class SearchInput(GraphQLInput): + __schema__ = gql( + """ + input Search { + query: String = "default" + age: Int = 42 + } + """ + ) + query: str + age: int -def test_input_type_extracts_graphql_name(): - class UserInput(InputType): - __schema__ = """ - input User { - id: ID! - } - """ + obj = SearchInput(age=20) + assert obj.query == "default" + assert obj.age == 20 - assert UserInput.graphql_name == "User" +def test_schema_input_type_instance_with_all_attrs_default_values(): + class SearchInput(GraphQLInput): + __schema__ = gql( + """ + input Search { + query: String = "default" + age: Int = 42 + } + """ + ) -def test_input_type_raises_error_when_defined_without_field_type_dependency(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserInput(InputType): - __schema__ = """ - input User { - id: ID! - role: Role! + query: str + age: int + + obj = SearchInput() + assert obj.query == "default" + assert obj.age == 42 + + +def test_schema_input_type_instance_with_default_attrs_python_values(): + class SearchInput(GraphQLInput): + __schema__ = gql( + """ + input Search { + query: String + age: Int + } + """ + ) + + query: str = "default" + age: int = 42 + + obj = SearchInput(age=20) + assert obj.query == "default" + assert obj.age == 20 + + +def test_schema_input_type_instance_with_invalid_attrs_raising_error(data_regression): + class SearchInput(GraphQLInput): + __schema__ = gql( + """ + input Search { + query: String + age: Int } """ + ) - snapshot.assert_match(err) + query: str + age: int + with pytest.raises(TypeError) as exc_info: + SearchInput(age=20, invalid="Ok") + + data_regression.check(str(exc_info.value)) + + +def test_input_type_arg(assert_schema_equals): + class SearchInput(GraphQLInput): + query: Optional[str] + age: Optional[int] + + class QueryType(GraphQLObject): + search: str + + @GraphQLObject.resolver("search") + @staticmethod + def resolve_search(*_, query_input: SearchInput) -> str: + return f"{repr([query_input.query, query_input.age])}" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + search(queryInput: SearchInput!): String! + } + + input SearchInput { + query: String + age: Int + } + """, + ) -def test_input_type_verifies_field_dependency(): - # pylint: disable=unused-variable - class RoleEnum(EnumType): + result = graphql_sync(schema, '{ search(queryInput: { query: "Hello" }) }') + + assert not result.errors + assert result.data == {"search": "['Hello', None]"} + + +def test_schema_input_type_arg(assert_schema_equals): + class SearchInput(GraphQLInput): __schema__ = """ - enum Role { - USER - ADMIN + input SearchInput { + query: String + age: Int } """ - class UserInput(InputType): - __schema__ = """ - input User { - id: ID! - role: Role! + query: Optional[str] + age: Optional[int] + + class QueryType(GraphQLObject): + search: str + + @GraphQLObject.resolver("search") + @staticmethod + def resolve_search(*_, query_input: SearchInput) -> str: + return f"{repr([query_input.query, query_input.age])}" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + search(queryInput: SearchInput!): String! + } + + input SearchInput { + query: String + age: Int } + """, + ) + + result = graphql_sync(schema, '{ search(queryInput: { query: "Hello" }) }') + + assert not result.errors + assert result.data == {"search": "['Hello', None]"} + + +def test_input_type_automatic_out_name_arg(assert_schema_equals): + class SearchInput(GraphQLInput): + query: Optional[str] + min_age: Optional[int] + + class QueryType(GraphQLObject): + search: str + + @GraphQLObject.resolver("search") + @staticmethod + def resolve_search(*_, query_input: SearchInput) -> str: + return f"{repr([query_input.query, query_input.min_age])}" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ - __requires__ = [RoleEnum] + type Query { + search(queryInput: SearchInput!): String! + } + + input SearchInput { + query: String + minAge: Int + } + """, + ) + result = graphql_sync(schema, "{ search(queryInput: { minAge: 21 }) }") -def test_input_type_verifies_circular_dependency(): - # pylint: disable=unused-variable - class UserInput(InputType): + assert not result.errors + assert result.data == {"search": "[None, 21]"} + + +def test_schema_input_type_automatic_out_name_arg(assert_schema_equals): + class SearchInput(GraphQLInput): __schema__ = """ - input User { - id: ID! - patron: User + input SearchInput { + query: String + minAge: Int } """ + query: Optional[str] + min_age: Optional[int] + + class QueryType(GraphQLObject): + search: str + + @GraphQLObject.resolver("search") + @staticmethod + def resolve_search(*_, query_input: SearchInput) -> str: + return f"{repr([query_input.query, query_input.min_age])}" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + search(queryInput: SearchInput!): String! + } + + input SearchInput { + query: String + minAge: Int + } + """, + ) + + result = graphql_sync(schema, "{ search(queryInput: { minAge: 21 }) }") -def test_input_type_verifies_circular_dependency_using_deferred_type(): - # pylint: disable=unused-variable - class GroupInput(InputType): + assert not result.errors + assert result.data == {"search": "[None, 21]"} + + +def test_schema_input_type_explicit_out_name_arg(assert_schema_equals): + class SearchInput(GraphQLInput): __schema__ = """ - input Group { - id: ID! - patron: User + input SearchInput { + query: String + minAge: Int } """ - __requires__ = [DeferredType("User")] + __out_names__ = {"minAge": "age"} - class UserInput(InputType): - __schema__ = """ - input User { - id: ID! - group: Group + query: Optional[str] + age: Optional[int] + + class QueryType(GraphQLObject): + search: str + + @GraphQLObject.resolver("search") + @staticmethod + def resolve_search(*_, query_input: SearchInput) -> str: + return f"{repr([query_input.query, query_input.age])}" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + search(queryInput: SearchInput!): String! + } + + input SearchInput { + query: String + minAge: Int + } + """, + ) + + result = graphql_sync(schema, "{ search(queryInput: { minAge: 21 }) }") + + assert not result.errors + assert result.data == {"search": "[None, 21]"} + + +def test_input_type_self_reference(assert_schema_equals): + class SearchInput(GraphQLInput): + query: Optional[str] + extra: Optional["SearchInput"] + + class QueryType(GraphQLObject): + search: str + + @GraphQLObject.resolver("search") + @staticmethod + def resolve_search(*_, query_input: SearchInput) -> str: + if query_input.extra: + extra_repr = query_input.extra.query + else: + extra_repr = None + + return f"{repr([query_input.query, extra_repr])}" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + search(queryInput: SearchInput!): String! + } + + input SearchInput { + query: String + extra: SearchInput } + """, + ) + + result = graphql_sync( + schema, """ - __requires__ = [GroupInput] + { + search( + queryInput: { query: "Hello", extra: { query: "Other" } } + ) + } + """, + ) + + assert not result.errors + assert result.data == {"search": "['Hello', 'Other']"} -def test_input_type_can_be_extended_with_new_fields(): - # pylint: disable=unused-variable - class UserInput(InputType): +def test_schema_input_type_with_default_value(assert_schema_equals): + class SearchInput(GraphQLInput): __schema__ = """ - input User { - id: ID! + input SearchInput { + query: String = "Search" + age: Int = 42 } """ - class ExtendUserInput(InputType): - __schema__ = """ - extend input User { - name: String! + query: str + age: int + + class QueryType(GraphQLObject): + search: str + + @GraphQLObject.resolver("search") + @staticmethod + def resolve_search(*_, query_input: SearchInput) -> str: + return f"{repr([query_input.query, query_input.age])}" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + search(queryInput: SearchInput!): String! + } + + input SearchInput { + query: String = "Search" + age: Int = 42 } + """, + ) + + result = graphql_sync(schema, "{ search(queryInput: {}) }") + + assert not result.errors + assert result.data == {"search": "['Search', 42]"} + + +def test_input_type_with_field_default_value(assert_schema_equals): + class SearchInput(GraphQLInput): + query: str = "default" + age: int = 42 + flag: bool = False + + class QueryType(GraphQLObject): + search: str + + @GraphQLObject.resolver("search") + @staticmethod + def resolve_search(*_, query_input: SearchInput) -> str: + return f"{repr([query_input.query, query_input.age, query_input.flag])}" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ - __requires__ = [UserInput] + type Query { + search(queryInput: SearchInput!): String! + } + input SearchInput { + query: String! = "default" + age: Int! = 42 + flag: Boolean! = false + } + """, + ) -def test_input_type_can_be_extended_with_directive(): - # pylint: disable=unused-variable - class ExampleDirective(DirectiveType): - __schema__ = "directive @example on INPUT_OBJECT" - __visitor__ = SchemaDirectiveVisitor + result = graphql_sync(schema, "{ search(queryInput: {}) }") - class UserInput(InputType): - __schema__ = """ - input User { - id: ID! + assert not result.errors + assert result.data == {"search": "['default', 42, False]"} + + +def test_input_type_with_field_instance_default_value(assert_schema_equals): + class SearchInput(GraphQLInput): + query: str = GraphQLInput.field(default_value="default") + age: int = GraphQLInput.field(default_value=42) + flag: bool = GraphQLInput.field(default_value=False) + + class QueryType(GraphQLObject): + search: str + + @GraphQLObject.resolver("search") + @staticmethod + def resolve_search(*_, query_input: SearchInput) -> str: + return f"{repr([query_input.query, query_input.age, query_input.flag])}" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + search(queryInput: SearchInput!): String! + } + + input SearchInput { + query: String! = "default" + age: Int! = 42 + flag: Boolean! = false } + """, + ) + + result = graphql_sync(schema, "{ search(queryInput: {}) }") + + assert not result.errors + assert result.data == {"search": "['default', 42, False]"} + + +def test_input_type_with_field_type(assert_schema_equals): + class SearchInput(GraphQLInput): + query: str = GraphQLInput.field(graphql_type=int) + + class QueryType(GraphQLObject): + search: str + + @GraphQLObject.resolver("search") + @staticmethod + def resolve_search(*_, query_input: SearchInput) -> str: + return str(query_input) + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ + type Query { + search(queryInput: SearchInput!): String! + } + + input SearchInput { + query: Int! + } + """, + ) - class ExtendUserInput(InputType): + +def test_schema_input_type_with_field_description(assert_schema_equals): + class SearchInput(GraphQLInput): __schema__ = """ - extend input User @example + input SearchInput { + \"\"\"Hello world.\"\"\" + query: String! + } """ - __requires__ = [UserInput, ExampleDirective] + class QueryType(GraphQLObject): + search: str -def test_input_type_raises_error_when_defined_without_extended_dependency(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExtendUserInput(InputType): - __schema__ = """ - extend input User { - name: String! - } - """ + @GraphQLObject.resolver("search") + @staticmethod + def resolve_search(*_, query_input: SearchInput) -> str: + return str(query_input) - snapshot.assert_match(err) + schema = make_executable_schema(QueryType) + assert_schema_equals( + schema, + """ + type Query { + search(queryInput: SearchInput!): String! + } -def test_input_type_raises_error_when_extended_dependency_is_wrong_type(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = """ - interface User { - id: ID! - } - """ + input SearchInput { + \"\"\"Hello world.\"\"\" + query: String! + } + """, + ) - class ExtendUserInput(InputType): - __schema__ = """ - extend input User { - name: String! - } - """ - __requires__ = [ExampleInterface] - snapshot.assert_match(err) +def test_input_type_with_field_description(assert_schema_equals): + class SearchInput(GraphQLInput): + query: str = GraphQLInput.field(description="Hello world.") + class QueryType(GraphQLObject): + search: str -def test_input_type_raises_error_when_defined_with_args_map_for_nonexisting_field( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserInput(InputType): - __schema__ = """ - input User { - id: ID! - } - """ - __args__ = { - "fullName": "full_name", - } + @GraphQLObject.resolver("search") + @staticmethod + def resolve_search(*_, query_input: SearchInput) -> str: + return str(query_input) + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + search(queryInput: SearchInput!): String! + } + + input SearchInput { + \"\"\"Hello world.\"\"\" + query: String! + } + """, + ) + + +def test_input_type_omit_magic_fields(assert_schema_equals): + class SearchInput(GraphQLInput): + query: str = GraphQLInput.field(description="Hello world.") + __i_am_magic_field__: str + + class QueryType(GraphQLObject): + search: str + + @GraphQLObject.resolver("search") + @staticmethod + def resolve_search(*_, query_input: SearchInput) -> str: + return str(query_input) - snapshot.assert_match(err) + schema = make_executable_schema(QueryType) + assert_schema_equals( + schema, + """ + type Query { + search(queryInput: SearchInput!): String! + } + + input SearchInput { + \"\"\"Hello world.\"\"\" + query: String! + } + """, + ) -class UserInput(InputType): - __schema__ = """ - input UserInput { - id: ID! - fullName: String! - } - """ - __args__ = { - "fullName": "full_name", - } +def test_input_type_in_input_type_fields(assert_schema_equals): + class SearchInput(GraphQLInput): + query: str = GraphQLInput.field(description="Hello world.") -class GenericScalar(ScalarType): - __schema__ = "scalar Generic" + class UserInput(GraphQLInput): + username: str + class MainInput(GraphQLInput): + search = GraphQLInput.field( + description="Hello world.", graphql_type=SearchInput + ) + search_user: UserInput + __i_am_magic_field__: str -class QueryType(ObjectType): - __schema__ = """ - type Query { - reprInput(input: UserInput): Generic! - } - """ - __aliases__ = {"reprInput": "repr_input"} - __requires__ = [GenericScalar, UserInput] + class QueryType(GraphQLObject): + search: str - @staticmethod - def resolve_repr_input(*_, input): # pylint: disable=redefined-builtin - return input + @GraphQLObject.resolver("search") + @staticmethod + def resolve_search(*_, query_input: MainInput) -> str: + return str(query_input) + schema = make_executable_schema(QueryType) -schema = make_executable_schema(QueryType) + assert_schema_equals( + schema, + """ + type Query { + search(queryInput: MainInput!): String! + } + input MainInput { + searchUser: UserInput! -def test_input_type_maps_args_to_python_dict_keys(): - result = graphql_sync(schema, '{ reprInput(input: {id: "1", fullName: "Alice"}) }') - assert result.data == { - "reprInput": {"id": "1", "full_name": "Alice"}, - } + \"\"\"Hello world.\"\"\" + search: SearchInput! + } + + input SearchInput { + \"\"\"Hello world.\"\"\" + query: String! + } + + input UserInput { + username: String! + } + """, + ) diff --git a/tests/test_input_type_validation.py b/tests/test_input_type_validation.py new file mode 100644 index 0000000..8b3705f --- /dev/null +++ b/tests/test_input_type_validation.py @@ -0,0 +1,133 @@ +# pylint: disable=unused-variable +import pytest +from ariadne import gql + +from ariadne_graphql_modules import GraphQLInput + + +def test_schema_input_type_validation_fails_for_invalid_type_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLInput): + __schema__ = gql("scalar Custom") + + data_regression.check(str(exc_info.value)) + + +def test_schema_input_type_validation_fails_for_names_not_matching(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLInput): + __graphql_name__ = "Lorem" + __schema__ = gql( + """ + input Custom { + hello: String! + } + """ + ) + + data_regression.check(str(exc_info.value)) + + +def test_schema_input_type_validation_fails_for_two_descriptions(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLInput): + __description__ = "Hello world!" + __schema__ = gql( + """ + \"\"\"Other description\"\"\" + input Custom { + hello: String! + } + """ + ) + + data_regression.check(str(exc_info.value)) + + +def test_schema_input_type_validation_fails_for_schema_missing_fields(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLInput): + __schema__ = gql("input Custom") + + data_regression.check(str(exc_info.value)) + + +def test_input_type_validation_fails_for_out_names_without_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLInput): + hello: str + + __out_names__ = { + "hello": "ok", + } + + data_regression.check(str(exc_info.value)) + + +def test_schema_input_type_validation_fails_for_invalid_out_name(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLInput): + __schema__ = gql( + """ + input Query { + hello: String! + } + """ + ) + + __out_names__ = { + "invalid": "ok", + } + + data_regression.check(str(exc_info.value)) + + +def test_schema_input_type_validation_fails_for_duplicate_out_name(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLInput): + __schema__ = gql( + """ + input Query { + hello: String! + name: String! + } + """ + ) + + __out_names__ = { + "hello": "ok", + "name": "ok", + } + + data_regression.check(str(exc_info.value)) + + +class InvalidType: + pass + + +def test_input_type_validation_fails_for_unsupported_attr_default(data_regression): + with pytest.raises(TypeError) as exc_info: + + class QueryType(GraphQLInput): + attr: str = InvalidType() # type: ignore + + data_regression.check(str(exc_info.value)) + + +def test_input_type_validation_fails_for_unsupported_field_default_option( + data_regression, +): + with pytest.raises(TypeError) as exc_info: + + class QueryType(GraphQLInput): + attr: str = GraphQLInput.field(default_value=InvalidType()) + + data_regression.check(str(exc_info.value)) diff --git a/tests/test_interface_type.py b/tests/test_interface_type.py index 4a064f4..d0b2bf0 100644 --- a/tests/test_interface_type.py +++ b/tests/test_interface_type.py @@ -1,450 +1,454 @@ -from dataclasses import dataclass +from typing import Optional, Union -import pytest -from ariadne import SchemaDirectiveVisitor -from graphql import GraphQLError, graphql_sync +from graphql import graphql_sync from ariadne_graphql_modules import ( - DeferredType, - DirectiveType, - InterfaceType, - ObjectType, + GraphQLID, + GraphQLInterface, + GraphQLObject, + GraphQLUnion, make_executable_schema, ) -def test_interface_type_raises_attribute_error_when_defined_without_schema(snapshot): - with pytest.raises(AttributeError) as err: - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - pass +class CommentType(GraphQLObject): + id: GraphQLID + content: str - snapshot.assert_match(err) +def test_interface_without_schema(assert_schema_equals): + class UserInterface(GraphQLInterface): + summary: str + score: int -def test_interface_type_raises_error_when_defined_with_invalid_schema_type(snapshot): - with pytest.raises(TypeError) as err: - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = True + class UserType(GraphQLObject, UserInterface): + name: str - snapshot.assert_match(err) + class ResultType(GraphQLUnion): + __types__ = [UserType, CommentType] + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=list[ResultType]) + @staticmethod + def search(*_) -> list[Union[UserType, CommentType]]: + return [ + UserType(id=1, username="Bob"), + CommentType(id=2, content="Hello World!"), + ] -def test_interface_type_raises_error_when_defined_with_invalid_schema_str(snapshot): - with pytest.raises(GraphQLError) as err: - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = "interfaco Example" - - snapshot.assert_match(err) + schema = make_executable_schema(QueryType, UserInterface, UserType) + assert_schema_equals( + schema, + """ + type Query { + search: [Result!]! + } -def test_interface_type_raises_error_when_defined_with_invalid_graphql_type_schema( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = "type Example" + union Result = User | Comment - snapshot.assert_match(err) + type User implements UserInterface { + summary: String! + score: Int! + name: String! + } + type Comment { + id: ID! + content: String! + } -def test_interface_type_raises_error_when_defined_with_multiple_types_schema(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = """ - interface Example + interface UserInterface { + summary: String! + score: Int! + } - interface Other - """ + """, + ) + + +def test_interface_inheritance_without_schema(assert_schema_equals): + def hello_resolver(*_, name: str) -> str: + return f"Hello {name}!" + + class UserInterface(GraphQLInterface): + summary: str + score: str = GraphQLInterface.field( + hello_resolver, + name="better_score", + graphql_type=str, + args={"name": GraphQLInterface.argument(name="json")}, + description="desc", + default_value="my_json", + ) + + class UserType(GraphQLObject, UserInterface): + name: str = GraphQLInterface.field( + name="name", + graphql_type=str, + args={"name": GraphQLInterface.argument(name="json")}, + default_value="my_json", + ) + + class ResultType(GraphQLUnion): + __types__ = [UserType, CommentType] + + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=list[ResultType]) + @staticmethod + def search(*_) -> list[Union[UserType, CommentType]]: + return [ + UserType(), + CommentType(id=2, content="Hello World!"), + ] + + schema = make_executable_schema(QueryType, UserInterface, UserType) + + assert_schema_equals( + schema, + """ + type Query { + search: [Result!]! + } - snapshot.assert_match(err) + union Result = User | Comment + type User implements UserInterface { + summary: String! -def test_interface_type_raises_error_when_defined_without_fields(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = "interface Example" + \"\"\"desc\"\"\" + better_score(json: String!): String! + name: String! + } - snapshot.assert_match(err) + type Comment { + id: ID! + content: String! + } + interface UserInterface { + summary: String! -def test_interface_type_extracts_graphql_name(): - class ExampleInterface(InterfaceType): - __schema__ = """ - interface Example { - id: ID! + \"\"\"desc\"\"\" + better_score(json: String!): String! } - """ - assert ExampleInterface.graphql_name == "Example" + """, + ) + result = graphql_sync( + schema, '{ search { ... on User{ better_score(json: "test") } } }' + ) -def test_interface_type_raises_error_when_defined_without_return_type_dependency( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = """ - interface Example { - group: Group - groups: [Group!] - } - """ + assert not result.errors + assert result.data == {"search": [{"better_score": "Hello test!"}, {}]} - snapshot.assert_match(err) - -def test_interface_type_verifies_field_dependency(): - # pylint: disable=unused-variable - class GroupType(ObjectType): +def test_interface_with_schema(assert_schema_equals): + class UserInterface(GraphQLInterface): __schema__ = """ - type Group { - id: ID! + interface UserInterface { + summary: String! + score: Int! } """ - class ExampleInterface(InterfaceType): + class UserType(GraphQLObject): __schema__ = """ - interface Example { - group: Group - groups: [Group!] + type User implements UserInterface { + id: ID! + name: String! + summary: String! + score: Int! } """ - __requires__ = [GroupType] + __requires__ = [UserInterface] -def test_interface_type_verifies_circural_dependency(): - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = """ - interface Example { - parent: Example - } - """ + class ResultType(GraphQLUnion): + __types__ = [UserType, CommentType] + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=list[ResultType]) + @staticmethod + def search(*_) -> list[Union[UserType, CommentType]]: + return [ + UserType(id=1, username="Bob"), + CommentType(id=2, content="Hello World!"), + ] -def test_interface_type_raises_error_when_defined_without_argument_type_dependency( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = """ - interface Example { - actions(input: UserInput): [String!]! - } - """ + schema = make_executable_schema(QueryType, UserType) - snapshot.assert_match(err) + assert_schema_equals( + schema, + """ + type Query { + search: [Result!]! + } + union Result = User | Comment -def test_interface_type_verifies_circular_dependency_using_deferred_type(): - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = """ - interface Example { - id: ID! - users: [User] + type User implements UserInterface { + id: ID! + name: String! + summary: String! + score: Int! } - """ - __requires__ = [DeferredType("User")] - class UserType(ObjectType): - __schema__ = """ - type User { - roles: [Example] + interface UserInterface { + summary: String! + score: Int! } - """ - __requires__ = [ExampleInterface] - -def test_interface_type_can_be_extended_with_new_fields(): - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = """ - interface Example { - id: ID! + type Comment { + id: ID! + content: String! } + + """, + ) + + +def test_interface_inherit_interface(assert_schema_equals): + class BaseEntityInterface(GraphQLInterface): + id: GraphQLID + + class UserInterface(BaseEntityInterface): + username: str + + class UserType(GraphQLObject, UserInterface): + name: str + + class SuperUserType(UserType): + is_super_user: bool + + class QueryType(GraphQLObject): + @GraphQLObject.field + @staticmethod + def users(*_) -> list[UserInterface]: + return [ + UserType(id="1", username="test_user"), + SuperUserType( + id="2", + username="test_super_user", + is_super_user=True, + ), + ] + + schema = make_executable_schema( + QueryType, BaseEntityInterface, UserInterface, UserType, SuperUserType + ) + + assert_schema_equals( + schema, """ + type Query { + users: [UserInterface!]! + } - class ExtendExampleInterface(InterfaceType): - __schema__ = """ - extend interface Example { - name: String + interface UserInterface implements BaseEntityInterface { + id: ID! + username: String! } - """ - __requires__ = [ExampleInterface] + interface BaseEntityInterface { + id: ID! + } -def test_interface_type_can_be_extended_with_directive(): - # pylint: disable=unused-variable - class ExampleDirective(DirectiveType): - __schema__ = "directive @example on INTERFACE" - __visitor__ = SchemaDirectiveVisitor + type User implements BaseEntityInterface & UserInterface { + id: ID! + username: String! + name: String! + } - class ExampleInterface(InterfaceType): - __schema__ = """ - interface Example { - id: ID! + type SuperUser implements BaseEntityInterface & UserInterface { + id: ID! + username: String! + name: String! + isSuperUser: Boolean! } - """ + """, + ) - class ExtendExampleInterface(InterfaceType): - __schema__ = """ - extend interface Example @example - """ - __requires__ = [ExampleInterface, ExampleDirective] +def test_interface_descriptions(assert_schema_equals): + class UserInterface(GraphQLInterface): + summary: str + score: int -def test_interface_type_can_be_extended_with_other_interface(): - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = """ - interface Example { - id: ID! - } + __description__: Optional[str] = "Lorem ipsum." + + class UserType(GraphQLObject, UserInterface): + id: GraphQLID + username: str + + class QueryType(GraphQLObject): + @GraphQLObject.field + @staticmethod + def user(*_) -> UserType: + return UserType(id="1", username="test_user") + + schema = make_executable_schema(QueryType, UserType, UserInterface) + + assert_schema_equals( + schema, """ + type Query { + user: User! + } - class OtherInterface(InterfaceType): - __schema__ = """ - interface Other { - depth: Int! + type User implements UserInterface { + summary: String! + score: Int! + id: ID! + username: String! } - """ - class ExtendExampleInterface(InterfaceType): - __schema__ = """ - extend interface Example implements Other + \"\"\"Lorem ipsum.\"\"\" + interface UserInterface { + summary: String! + score: Int! + } + """, + ) + + +def test_interface_resolvers_and_field_descriptions(assert_schema_equals): + class UserInterface(GraphQLInterface): + summary: str + score: int + + @GraphQLInterface.resolver("score", description="Lorem ipsum.") + @staticmethod + def resolve_score(*_): + return 200 + + class UserType(GraphQLObject, UserInterface): + id: GraphQLID + + class MyType(GraphQLObject, UserInterface): + id: GraphQLID + name: str + + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=list[UserInterface]) + @staticmethod + def users(*_) -> list[UserInterface]: + return [MyType(id="2", name="old", summary="ss", score=22)] + + schema = make_executable_schema(QueryType, UserType, MyType, UserInterface) + + assert_schema_equals( + schema, """ - __requires__ = [ExampleInterface, OtherInterface] - - -def test_interface_type_raises_error_when_defined_without_extended_dependency(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExtendExampleInterface(ObjectType): - __schema__ = """ - extend interface Example { - name: String - } - """ - - snapshot.assert_match(err) - - -def test_interface_type_raises_error_when_extended_dependency_is_wrong_type(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleType(ObjectType): - __schema__ = """ - type Example { - id: ID! - } - """ - - class ExampleInterface(InterfaceType): - __schema__ = """ - extend interface Example { - name: String - } - """ - __requires__ = [ExampleType] - - snapshot.assert_match(err) - - -def test_interface_type_raises_error_when_defined_with_alias_for_nonexisting_field( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = """ - interface User { - name: String - } - """ - __aliases__ = { - "joinedDate": "joined_date", - } - - snapshot.assert_match(err) - - -def test_interface_type_raises_error_when_defined_with_resolver_for_nonexisting_field( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = """ - interface User { - name: String - } - """ - - @staticmethod - def resolve_group(*_): - return None - - snapshot.assert_match(err) - - -@dataclass -class User: - id: int - name: str - summary: str - - -@dataclass -class Comment: - id: int - message: str - summary: str - - -class ResultInterface(InterfaceType): - __schema__ = """ - interface Result { - summary: String! - score: Int! - } - """ + type Query { + users: [UserInterface!]! + } - @staticmethod - def resolve_type(instance, *_): - if isinstance(instance, Comment): - return "Comment" + interface UserInterface { + summary: String! - if isinstance(instance, User): - return "User" + \"\"\"Lorem ipsum.\"\"\" + score: Int! + } - return None + type User implements UserInterface { + summary: String! - @staticmethod - def resolve_score(*_): - return 42 + \"\"\"Lorem ipsum.\"\"\" + score: Int! + id: ID! + } + type My implements UserInterface { + summary: String! -class UserType(ObjectType): - __schema__ = """ - type User implements Result { - id: ID! - name: String! - summary: String! - score: Int! - } - """ - __requires__ = [ResultInterface] + \"\"\"Lorem ipsum.\"\"\" + score: Int! + id: ID! + name: String! + } + """, + ) + result = graphql_sync(schema, "{ users { ... on My { __typename score } } }") + assert not result.errors + assert result.data == {"users": [{"__typename": "My", "score": 200}]} -class CommentType(ObjectType): - __schema__ = """ - type Comment implements Result { - id: ID! - message: String! - summary: String! - score: Int! - } - """ - __requires__ = [ResultInterface] - @staticmethod - def resolve_score(*_): - return 16 +def test_interface_with_schema_object_with_schema(assert_schema_equals): + class UserInterface(GraphQLInterface): + __schema__ = """ + interface UserInterface { + summary: String! + score: Int! + } + """ + @GraphQLInterface.resolver("summary") + @staticmethod + def resolve_summary(*_): + return "base_line" -class QueryType(ObjectType): - __schema__ = """ - type Query { - results: [Result!]! - } - """ - __requires__ = [ResultInterface] - - @staticmethod - def resolve_results(*_): - return [ - User(id=1, name="Alice", summary="Summary for Alice"), - Comment(id=1, message="Hello world!", summary="Summary for comment"), - ] + class UserType(GraphQLObject): + __schema__ = """ + type User implements UserInterface { + summary: String! + score: Int! + name: String! + } + """ + __requires__ = [UserInterface] + class ResultType(GraphQLUnion): + __types__ = [UserType, CommentType] -schema = make_executable_schema(QueryType, UserType, CommentType) + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=list[ResultType]) + @staticmethod + def search(*_) -> list[Union[UserType, CommentType]]: + return [ + UserType(name="Bob"), + CommentType(id=2, content="Hello World!"), + ] + schema = make_executable_schema(QueryType, UserType) -def test_interface_type_binds_type_resolver(): - query = """ - query { - results { - ... on User { - __typename - id - name - summary - } - ... on Comment { - __typename - id - message - summary - } + assert_schema_equals( + schema, + """ + type Query { + search: [Result!]! } - } - """ - result = graphql_sync(schema, query) - assert result.data == { - "results": [ - { - "__typename": "User", - "id": "1", - "name": "Alice", - "summary": "Summary for Alice", - }, - { - "__typename": "Comment", - "id": "1", - "message": "Hello world!", - "summary": "Summary for comment", - }, - ], - } + union Result = User | Comment + type User implements UserInterface { + summary: String! + score: Int! + name: String! + } -def test_interface_type_binds_field_resolvers_to_implementing_types_fields(): - query = """ - query { - results { - ... on User { - __typename - score - } - ... on Comment { - __typename - score - } + interface UserInterface { + summary: String! + score: Int! } - } - """ - result = graphql_sync(schema, query) + type Comment { + id: ID! + content: String! + } + + """, + ) + result = graphql_sync(schema, "{ search { ... on User{ summary } } }") + + assert not result.errors assert result.data == { - "results": [ + "search": [ { - "__typename": "User", - "score": 42, + "summary": "base_line", }, - { - "__typename": "Comment", - "score": 16, - }, - ], + {}, + ] } diff --git a/tests/test_interface_type_validation.py b/tests/test_interface_type_validation.py new file mode 100644 index 0000000..2ba5684 --- /dev/null +++ b/tests/test_interface_type_validation.py @@ -0,0 +1,50 @@ +# pylint: disable=unused-variable +from typing import Optional + +import pytest + +from ariadne_graphql_modules import ( + GraphQLID, + GraphQLInterface, + GraphQLObject, + make_executable_schema, +) + + +def test_interface_no_interface_in_schema(data_regression): + with pytest.raises(TypeError) as exc_info: + + class BaseInterface(GraphQLInterface): + id: GraphQLID + + class UserType(GraphQLObject, BaseInterface): + username: str + email: str + + make_executable_schema(UserType) + + data_regression.check(str(exc_info.value)) + + +def test_interface_with_schema_object_with_no_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class UserInterface(GraphQLInterface): + __schema__: Optional[ + str + ] = """ + interface UserInterface { + summary: String! + score: Int! + } + """ + + @GraphQLInterface.resolver("score") + @staticmethod + def resolve_score(*_): + return 2211 + + class UserType(GraphQLObject, UserInterface): + name: str + + data_regression.check(str(exc_info.value)) diff --git a/tests/test_make_executable_schema.py b/tests/test_make_executable_schema.py new file mode 100644 index 0000000..02a1fca --- /dev/null +++ b/tests/test_make_executable_schema.py @@ -0,0 +1,323 @@ +from typing import Union + +import pytest +from ariadne import QueryType, SchemaDirectiveVisitor +from graphql import ( + GraphQLField, + GraphQLInterfaceType, + GraphQLObjectType, + default_field_resolver, + graphql_sync, +) + +from ariadne_graphql_modules import GraphQLObject, make_executable_schema + + +def test_executable_schema_from_vanilla_schema_definition(assert_schema_equals): + query_type = QueryType() + query_type.set_field("message", lambda *_: "Hello world!") + + schema = make_executable_schema( + """ + type Query { + message: String! + } + """, + query_type, + ) + + assert_schema_equals( + schema, + """ + type Query { + message: String! + } + """, + ) + + result = graphql_sync(schema, "{ message }") + + assert not result.errors + assert result.data == {"message": "Hello world!"} + + +def test_executable_schema_from_combined_vanilla_and_new_schema_definition( + assert_schema_equals, +): + class UserType(GraphQLObject): + name: str + email: str + + query_type = QueryType() + query_type.set_field( + "user", lambda *_: UserType(name="Bob", email="test@example.com") + ) + + schema = make_executable_schema( + """ + type Query { + user: User! + } + """, + query_type, + UserType, + ) + + assert_schema_equals( + schema, + """ + type Query { + user: User! + } + + type User { + name: String! + email: String! + } + """, + ) + + result = graphql_sync(schema, "{ user { name email } }") + + assert not result.errors + assert result.data == { + "user": {"name": "Bob", "email": "test@example.com"}, + } + + +def test_executable_schema_with_merged_roots(assert_schema_equals): + class FirstRoot(GraphQLObject): + __graphql_name__ = "Query" + + name: str + surname: str + + class SecondRoot(GraphQLObject): + __graphql_name__ = "Query" + + message: str + + class ThirdRoot(GraphQLObject): + __graphql_name__ = "Query" + + score: int + + schema = make_executable_schema([FirstRoot, SecondRoot, ThirdRoot]) + + assert_schema_equals( + schema, + """ + type Query { + message: String! + name: String! + score: Int! + surname: String! + } + """, + ) + + +def test_executable_schema_with_merged_object_and_vanilla_roots(assert_schema_equals): + class FirstRoot(GraphQLObject): + __graphql_name__ = "Query" + + name: str + surname: str + + class SecondRoot(GraphQLObject): + __graphql_name__ = "Query" + + message: str + + schema = make_executable_schema( + FirstRoot, + SecondRoot, + """ + type Query { + score: Int! + } + """, + ) + + assert_schema_equals( + schema, + """ + type Query { + message: String! + name: String! + score: Int! + surname: String! + } + """, + ) + + +def test_multiple_roots_fail_validation_if_merge_roots_is_disabled(data_regression): + class FirstRoot(GraphQLObject): + __graphql_name__ = "Query" + + name: str + surname: str + + class SecondRoot(GraphQLObject): + __graphql_name__ = "Query" + + message: str + + class ThirdRoot(GraphQLObject): + __graphql_name__ = "Query" + + score: int + + with pytest.raises(ValueError) as exc_info: + make_executable_schema(FirstRoot, SecondRoot, ThirdRoot, merge_roots=False) + + data_regression.check(str(exc_info.value)) + + +def test_schema_validation_fails_if_lazy_type_doesnt_exist(data_regression): + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=list["Missing"]) # type: ignore # noqa: F821 + @staticmethod + def other(*_): + return None + + with pytest.raises(TypeError) as exc_info: + make_executable_schema(QueryType) + + data_regression.check(str(exc_info.value)) + + +def test_schema_validation_passes_if_lazy_type_exists(): + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=list["Exists"]) # type: ignore # noqa: F821 + @staticmethod + def other(*_): + return None + + type_def = """ + type Exists { + id: ID! + } + """ + + make_executable_schema(QueryType, type_def) + + +def test_make_executable_schema_raises_error_if_called_without_any_types( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + make_executable_schema(QueryType) + + data_regression.check(str(exc_info.value)) + + +def test_resolvers_not_set_without_name_case_conversion(): + class QueryType(GraphQLObject): + other_field: str + + type_def = """ + type Query { + firstField: String! + } + """ + + schema = make_executable_schema(QueryType, type_def) + result = graphql_sync( + schema, + "{ firstField otherField }", + root_value={"firstField": "first", "other_field": "other"}, + ) + assert result.data == {"firstField": "first", "otherField": "other"} + + +def test_make_executable_schema_sets_resolvers_if_convert_names_case_is_enabled(): + class QueryType(GraphQLObject): + other_field: str + + type_def = """ + type Query { + firstField: String! + } + """ + + schema = make_executable_schema(QueryType, type_def, convert_names_case=True) + result = graphql_sync( + schema, + "{ firstField otherField }", + root_value={"first_field": "first", "other_field": "other"}, + ) + assert result.data == {"firstField": "first", "otherField": "other"} + + +class UpperDirective(SchemaDirectiveVisitor): + def visit_field_definition( + self, + field: GraphQLField, + object_type: Union[GraphQLObjectType, GraphQLInterfaceType], + ) -> GraphQLField: + resolver = field.resolve or default_field_resolver + + def resolve_upper(obj, info, **kwargs): + result = resolver(obj, info, **kwargs) + return result.upper() + + field.resolve = resolve_upper + return field + + +def test_make_executable_schema_supports_vanilla_directives(): + type_def = """ + directive @upper on FIELD_DEFINITION + + type Query { + field: String! @upper + } + """ + schema = make_executable_schema( + type_def, + directives={ + "upper": UpperDirective, # type: ignore + }, + ) + result = graphql_sync( + schema, + "{ field }", + root_value={"field": "first"}, + ) + assert result.data == {"field": "FIRST"} + + +def test_make_executable_schema_custom_convert_name_case(assert_schema_equals): + def custom_name_converter(name: str, *_) -> str: + return f"custom_{name}" + + class QueryType(GraphQLObject): + other_field: str + + type_def = """ + type Query { + firstField: String! + } + """ + + schema = make_executable_schema( + QueryType, type_def, convert_names_case=custom_name_converter + ) + assert_schema_equals( + schema, + """ + type Query { + firstField: String! + otherField: String! + } + """, + ) + result = graphql_sync( + schema, + "{ firstField otherField }", + root_value={"custom_firstField": "first", "other_field": "other"}, + ) + + assert result.data == {"firstField": "first", "otherField": "other"} diff --git a/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 0000000..51523ac --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,100 @@ +from enum import Enum + +import pytest + +from ariadne_graphql_modules import GraphQLObject, graphql_enum + + +class QueryType(GraphQLObject): + hello: str + + +def test_metadata_returns_sets_and_returns_data_for_type(metadata): + assert metadata.set_data(QueryType, 42) == 42 + assert metadata.get_data(QueryType) == 42 + + +def test_metadata_raises_key_error_for_unset_data(data_regression, metadata): + with pytest.raises(KeyError) as exc_info: + metadata.get_data(QueryType) + + data_regression.check(str(exc_info.value)) + + +def test_metadata_returns_model_for_type(assert_ast_equals, metadata): + model = metadata.get_graphql_model(QueryType) + assert model.name == "Query" + + assert_ast_equals( + model.ast, + ( + """ + type Query { + hello: String! + } + """ + ), + ) + + +def test_metadata_returns_graphql_name_for_type(metadata): + assert metadata.get_graphql_name(QueryType) == "Query" + + +class UserLevel(Enum): + GUEST = 0 + MEMBER = 1 + MODERATOR = 2 + ADMINISTRATOR = 3 + + +def test_metadata_returns_model_for_standard_enum(assert_ast_equals, metadata): + model = metadata.get_graphql_model(UserLevel) + assert model.name == "UserLevel" + + assert_ast_equals( + model.ast, + ( + """ + enum UserLevel { + GUEST + MEMBER + MODERATOR + ADMINISTRATOR + } + """ + ), + ) + + +def test_metadata_returns_graphql_name_for_standard_enum(metadata): + assert metadata.get_graphql_name(UserLevel) == "UserLevel" + + +@graphql_enum(name="SeverityEnum") +class SeverityLevel(Enum): + LOW = 0 + MEDIUM = 1 + HIGH = 2 + + +def test_metadata_returns_model_for_annotated_enum(assert_ast_equals, metadata): + model = metadata.get_graphql_model(SeverityLevel) + assert model.name == "SeverityEnum" + + assert_ast_equals( + model.ast, + ( + """ + enum SeverityEnum { + LOW + MEDIUM + HIGH + } + """ + ), + ) + + +def test_metadata_returns_graphql_name_for_annotated_enum(metadata): + assert metadata.get_graphql_name(SeverityLevel) == "SeverityEnum" diff --git a/tests/test_object_type.py b/tests/test_object_type.py index b9ca048..8046bcf 100644 --- a/tests/test_object_type.py +++ b/tests/test_object_type.py @@ -1,398 +1,1030 @@ +# pylint: disable=too-many-lines +from typing import Optional + import pytest -from ariadne import SchemaDirectiveVisitor -from graphql import GraphQLError, graphql_sync +from ariadne import gql +from graphql import graphql_sync -from ariadne_graphql_modules import ( - DeferredType, - DirectiveType, - InterfaceType, - ObjectType, - make_executable_schema, -) +from ariadne_graphql_modules import GraphQLObject, make_executable_schema -def test_object_type_raises_attribute_error_when_defined_without_schema(snapshot): - with pytest.raises(AttributeError) as err: - # pylint: disable=unused-variable - class UserType(ObjectType): - pass +def test_object_type_instance_with_all_attrs_values(): + class CategoryType(GraphQLObject): + name: str + posts: int - snapshot.assert_match(err) + obj = CategoryType(name="Welcome", posts=20) + assert obj.name == "Welcome" + assert obj.posts == 20 -def test_object_type_raises_error_when_defined_with_invalid_schema_type(snapshot): - with pytest.raises(TypeError) as err: - # pylint: disable=unused-variable - class UserType(ObjectType): - __schema__ = True +def test_object_type_instance_with_omitted_attrs_being_none(): + class CategoryType(GraphQLObject): + name: str + posts: int - snapshot.assert_match(err) + obj = CategoryType(posts=20) + assert obj.name is None + assert obj.posts == 20 -def test_object_type_raises_error_when_defined_with_invalid_schema_str(snapshot): - with pytest.raises(GraphQLError) as err: - # pylint: disable=unused-variable - class UserType(ObjectType): - __schema__ = "typo User" +def test_object_type_instance_with_aliased_attrs_values(): + class CategoryType(GraphQLObject): + name: str + posts: int - snapshot.assert_match(err) + __aliases__ = {"name": "title"} + title: str -def test_object_type_raises_error_when_defined_with_invalid_graphql_type_schema( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserType(ObjectType): - __schema__ = "scalar DateTime" + obj = CategoryType(title="Welcome", posts=20) + assert obj.title == "Welcome" + assert obj.posts == 20 + + +def test_object_type_instance_with_omitted_attrs_being_default_values(): + class CategoryType(GraphQLObject): + name: str = "Hello" + posts: int = 42 + + obj = CategoryType(posts=20) + assert obj.name == "Hello" + assert obj.posts == 20 + + +def test_object_type_instance_with_all_attrs_being_default_values(): + class CategoryType(GraphQLObject): + name: str = "Hello" + posts: int = 42 + + obj = CategoryType() + assert obj.name == "Hello" + assert obj.posts == 42 + + +def test_object_type_instance_with_invalid_attrs_raising_error(data_regression): + class CategoryType(GraphQLObject): + name: str + posts: int - snapshot.assert_match(err) + with pytest.raises(TypeError) as exc_info: + CategoryType(name="Welcome", invalid="Ok") + data_regression.check(str(exc_info.value)) -def test_object_type_raises_error_when_defined_with_multiple_types_schema(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserType(ObjectType): - __schema__ = """ - type User - type Group +def test_schema_object_type_instance_with_all_attrs_values(): + class CategoryType(GraphQLObject): + __schema__ = gql( """ + type Category { + name: String + posts: Int + } + """ + ) - snapshot.assert_match(err) + name: str + posts: int + obj = CategoryType(name="Welcome", posts=20) + assert obj.name == "Welcome" + assert obj.posts == 20 -def test_object_type_raises_error_when_defined_without_fields(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserType(ObjectType): - __schema__ = "type User" - snapshot.assert_match(err) +def test_schema_object_type_instance_with_omitted_attrs_being_none(): + class CategoryType(GraphQLObject): + __schema__ = gql( + """ + type Category { + name: String + posts: Int + } + """ + ) + name: str + posts: int -def test_object_type_extracts_graphql_name(): - class GroupType(ObjectType): - __schema__ = """ - type Group { - id: ID! - } - """ + obj = CategoryType(posts=20) + assert obj.name is None + assert obj.posts == 20 - assert GroupType.graphql_name == "Group" +def test_schema_object_type_instance_with_omitted_attrs_being_default_values(): + class CategoryType(GraphQLObject): + __schema__ = gql( + """ + type Category { + name: String + posts: Int + } + """ + ) -def test_object_type_accepts_all_builtin_scalar_types(): - # pylint: disable=unused-variable - class FancyObjectType(ObjectType): - __schema__ = """ - type FancyObject { - id: ID! - someInt: Int! - someFloat: Float! - someBoolean: Boolean! - someString: String! - } - """ + name: str = "Hello" + posts: int = 42 + obj = CategoryType(posts=20) + assert obj.name == "Hello" + assert obj.posts == 20 -def test_object_type_raises_error_when_defined_without_return_type_dependency(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserType(ObjectType): - __schema__ = """ - type User { - group: Group - groups: [Group!] + +def test_schema_object_type_instance_with_all_attrs_being_default_values(): + class CategoryType(GraphQLObject): + __schema__ = gql( + """ + type Category { + name: String + posts: Int } """ + ) - snapshot.assert_match(err) + name: str = "Hello" + posts: int = 42 + obj = CategoryType() + assert obj.name == "Hello" + assert obj.posts == 42 -def test_object_type_verifies_field_dependency(): - # pylint: disable=unused-variable - class GroupType(ObjectType): - __schema__ = """ - type Group { - id: ID! - } - """ - class UserType(ObjectType): - __schema__ = """ - type User { - group: Group - groups: [Group!] - } - """ - __requires__ = [GroupType] +def test_schema_object_type_instance_with_aliased_attrs_values(): + class CategoryType(GraphQLObject): + __schema__ = gql( + """ + type Category { + name: String + posts: Int + } + """ + ) + __aliases__ = {"name": "title"} + title: str = "Hello" + posts: int = 42 -def test_object_type_verifies_circular_dependency(): - # pylint: disable=unused-variable - class UserType(ObjectType): - __schema__ = """ - type User { - follows: User - } - """ + obj = CategoryType(title="Ok") + assert obj.title == "Ok" + assert obj.posts == 42 -def test_object_type_raises_error_when_defined_without_argument_type_dependency( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserType(ObjectType): - __schema__ = """ - type User { - actions(input: UserInput): [String!]! +def test_schema_object_type_instance_with_aliased_attrs_default_values(): + class CategoryType(GraphQLObject): + __schema__ = gql( + """ + type Category { + name: String + posts: Int } """ + ) + __aliases__ = {"name": "title"} - snapshot.assert_match(err) + title: str = "Hello" + posts: int = 42 + obj = CategoryType() + assert obj.title == "Hello" + assert obj.posts == 42 -def test_object_type_verifies_circular_dependency_using_deferred_type(): - # pylint: disable=unused-variable - class GroupType(ObjectType): - __schema__ = """ - type Group { - id: ID! - users: [User] - } + +def test_schema_object_type_instance_with_invalid_attrs_raising_error(data_regression): + class CategoryType(GraphQLObject): + __schema__ = gql( + """ + type Category { + name: String + posts: Int + } + """ + ) + + name: str + posts: int + + with pytest.raises(TypeError) as exc_info: + CategoryType(name="Welcome", invalid="Ok") + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_instance_with_aliased_attr_value(): + class CategoryType(GraphQLObject): + __schema__ = gql( + """ + type Category { + name: String + posts: Int + } + """ + ) + __aliases__ = {"name": "title"} + + title: str + posts: int + + obj = CategoryType(title="Welcome", posts=20) + assert obj.title == "Welcome" + assert obj.posts == 20 + + +def test_object_type_with_field(assert_schema_equals): + class QueryType(GraphQLObject): + hello: str + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ - __requires__ = [DeferredType("User")] + type Query { + hello: String! + } + """, + ) - class UserType(ObjectType): - __schema__ = """ - type User { - group: Group + result = graphql_sync(schema, "{ hello }", root_value={"hello": "Hello World!"}) + + assert not result.errors + assert result.data == {"hello": "Hello World!"} + + +def test_object_type_with_alias(assert_schema_equals): + class QueryType(GraphQLObject): + __aliases__ = {"hello": "welcome_message"} + + hello: str + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello: String! } + """, + ) + + result = graphql_sync( + schema, "{ hello }", root_value={"welcome_message": "Hello World!"} + ) + + assert not result.errors + assert result.data == {"hello": "Hello World!"} + + +def test_object_type_with_alias_excludes_alias_targets(assert_schema_equals): + class QueryType(GraphQLObject): + __aliases__ = {"hello": "welcome"} + + hello: str + welcome: str + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ - __requires__ = [GroupType] + type Query { + hello: String! + } + """, + ) + result = graphql_sync(schema, "{ hello }", root_value={"welcome": "Hello World!"}) -def test_object_type_can_be_extended_with_new_fields(): - # pylint: disable=unused-variable - class UserType(ObjectType): - __schema__ = """ - type User { - id: ID! + assert not result.errors + assert result.data == {"hello": "Hello World!"} + + +def test_object_type_with_alias_includes_aliased_field_instances(assert_schema_equals): + class QueryType(GraphQLObject): + __aliases__ = {"hello": "welcome"} + + hello: str + welcome: str = GraphQLObject.field() + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello: String! + welcome: String! } + """, + ) + + result = graphql_sync( + schema, "{ hello welcome }", root_value={"welcome": "Hello World!"} + ) + + assert not result.errors + assert result.data == {"hello": "Hello World!", "welcome": "Hello World!"} + + +def test_object_type_with_attr_automatic_alias(assert_schema_equals): + class QueryType(GraphQLObject): + test_message: str + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ + type Query { + testMessage: String! + } + """, + ) + + result = graphql_sync( + schema, "{ testMessage }", root_value={"test_message": "Hello World!"} + ) - class ExtendUserType(ObjectType): - __schema__ = """ - extend type User { - name: String + assert not result.errors + assert result.data == {"testMessage": "Hello World!"} + + +def test_object_type_with_field_instance_automatic_alias(assert_schema_equals): + class QueryType(GraphQLObject): + message: str = GraphQLObject.field(name="testMessage") + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + testMessage: String! } + """, + ) + + result = graphql_sync( + schema, "{ testMessage }", root_value={"message": "Hello World!"} + ) + + assert not result.errors + assert result.data == {"testMessage": "Hello World!"} + + +def test_object_type_with_field_resolver(assert_schema_equals): + class QueryType(GraphQLObject): + @GraphQLObject.field + @staticmethod + def hello(*_) -> str: + return "Hello World!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ - __requires__ = [UserType] + type Query { + hello: String! + } + """, + ) + result = graphql_sync(schema, "{ hello }") -def test_object_type_can_be_extended_with_directive(): - # pylint: disable=unused-variable - class ExampleDirective(DirectiveType): - __schema__ = "directive @example on OBJECT" - __visitor__ = SchemaDirectiveVisitor + assert not result.errors + assert result.data == {"hello": "Hello World!"} - class UserType(ObjectType): - __schema__ = """ - type User { - id: ID! + +def test_object_type_with_typed_field_instance(assert_schema_equals): + class QueryType(GraphQLObject): + hello = GraphQLObject.field(lambda *_: "Hello World!", graphql_type=str) + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello: String! } + """, + ) + + result = graphql_sync(schema, "{ hello }") + + assert not result.errors + assert result.data == {"hello": "Hello World!"} + + +def test_object_type_with_annotated_field_instance(assert_schema_equals): + class QueryType(GraphQLObject): + hello: str = GraphQLObject.field(lambda *_: "Hello World!") + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ + type Query { + hello: String! + } + """, + ) + + result = graphql_sync(schema, "{ hello }") + + assert not result.errors + assert result.data == {"hello": "Hello World!"} + - class ExtendUserType(ObjectType): - __schema__ = """ - extend type User @example +def test_object_type_with_typed_field_and_field_resolver(assert_schema_equals): + class QueryType(GraphQLObject): + name: str + + @GraphQLObject.field + @staticmethod + def hello(*_) -> str: + return "Hello World!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ - __requires__ = [UserType, ExampleDirective] + type Query { + name: String! + hello: String! + } + """, + ) + result = graphql_sync(schema, "{ name hello }", root_value={"name": "Ok"}) -def test_object_type_can_be_extended_with_interface(): - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = """ - interface Interface { - id: ID! + assert not result.errors + assert result.data == {"name": "Ok", "hello": "Hello World!"} + + +def test_object_type_with_schema(assert_schema_equals): + class QueryType(GraphQLObject): + __schema__ = gql( + """ + type Query { + hello: String! + } + """ + ) + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello: String! } + """, + ) + + result = graphql_sync(schema, "{ hello }", root_value={"hello": "Hello World!"}) + + assert not result.errors + assert result.data == {"hello": "Hello World!"} + + +def test_object_type_with_nested_types(assert_schema_equals): + class UserType(GraphQLObject): + name: str + + class PostType(GraphQLObject): + message: str + + class QueryType(GraphQLObject): + user: UserType + + @GraphQLObject.field(graphql_type=PostType) + @staticmethod + def post(*_): + return {"message": "test"} + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ + type Query { + user: User! + post: Post! + } - class UserType(ObjectType): - __schema__ = """ type User { - id: ID! + name: String! } + + type Post { + message: String! + } + """, + ) + + result = graphql_sync( + schema, + "{ user { name } post { message } }", + root_value={"user": {"name": "Bob"}}, + ) + + assert not result.errors + assert result.data == { + "user": { + "name": "Bob", + }, + "post": {"message": "test"}, + } + + +def test_resolver_decorator_sets_resolver_for_type_hint_field(assert_schema_equals): + class QueryType(GraphQLObject): + hello: str + + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello(*_): + return "Hello World!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ + type Query { + hello: String! + } + """, + ) + + result = graphql_sync(schema, "{ hello }") + + assert not result.errors + assert result.data == {"hello": "Hello World!"} + + +def test_resolver_decorator_sets_resolver_for_instance_field(assert_schema_equals): + class QueryType(GraphQLObject): + hello: str = GraphQLObject.field(name="hello") + + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello(*_): + return "Hello World!" - class ExtendUserType(ObjectType): - __schema__ = """ - extend type User implements Interface + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, """ - __requires__ = [UserType, ExampleInterface] + type Query { + hello: String! + } + """, + ) + result = graphql_sync(schema, "{ hello }") -def test_object_type_raises_error_when_defined_without_extended_dependency(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExtendUserType(ObjectType): - __schema__ = """ - extend type User { - name: String + assert not result.errors + assert result.data == {"hello": "Hello World!"} + + +def test_resolver_decorator_sets_resolver_for_field_in_schema(assert_schema_equals): + class QueryType(GraphQLObject): + __schema__ = gql( + """ + type Query { + hello: String! } """ + ) - snapshot.assert_match(err) + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello(*_): + return "Hello World!" + schema = make_executable_schema(QueryType) -def test_object_type_raises_error_when_extended_dependency_is_wrong_type(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = """ - interface Example { - id: ID! - } - """ + assert_schema_equals( + schema, + """ + type Query { + hello: String! + } + """, + ) - class ExampleType(ObjectType): - __schema__ = """ - extend type Example { - name: String - } - """ - __requires__ = [ExampleInterface] + result = graphql_sync(schema, "{ hello }") - snapshot.assert_match(err) + assert not result.errors + assert result.data == {"hello": "Hello World!"} -def test_object_type_raises_error_when_defined_with_alias_for_nonexisting_field( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserType(ObjectType): - __schema__ = """ - type User { - name: String - } +def test_object_type_with_description(assert_schema_equals): + class QueryType(GraphQLObject): + __description__ = "Lorem ipsum." + + hello: str + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + \"\"\"Lorem ipsum.\"\"\" + type Query { + hello: String! + } + """, + ) + + result = graphql_sync(schema, "{ hello }", root_value={"hello": "Hello World!"}) + + assert not result.errors + assert result.data == {"hello": "Hello World!"} + + +def test_field_decorator_sets_description_for_field(assert_schema_equals): + class QueryType(GraphQLObject): + @GraphQLObject.field(description="Lorem ipsum.") + @staticmethod + def hello(*_) -> str: + return "Hello World!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + \"\"\"Lorem ipsum.\"\"\" + hello: String! + } + """, + ) + + result = graphql_sync(schema, "{ hello }") + + assert not result.errors + assert result.data == {"hello": "Hello World!"} + + +def test_field_decorator_sets_description_for_field_arg(assert_schema_equals): + class QueryType(GraphQLObject): + @GraphQLObject.field( + args={"name": GraphQLObject.argument(description="Lorem ipsum.")} + ) + @staticmethod + def hello(*_, name: str) -> str: + return f"Hello {name}!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello( + \"\"\"Lorem ipsum.\"\"\" + name: String! + ): String! + } + """, + ) + + result = graphql_sync(schema, '{ hello(name: "Bob") }') + + assert not result.errors + assert result.data == {"hello": "Hello Bob!"} + + +def test_resolver_decorator_sets_description_for_type_hint_field(assert_schema_equals): + class QueryType(GraphQLObject): + hello: str + + @GraphQLObject.resolver("hello", description="Lorem ipsum.") + @staticmethod + def resolve_hello(*_): + return "Hello World!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + \"\"\"Lorem ipsum.\"\"\" + hello: String! + } + """, + ) + + result = graphql_sync(schema, "{ hello }") + + assert not result.errors + assert result.data == {"hello": "Hello World!"} + + +def test_resolver_decorator_sets_description_for_field_in_schema(assert_schema_equals): + class QueryType(GraphQLObject): + __schema__ = gql( """ - __aliases__ = { - "joinedDate": "joined_date", + type Query { + hello: String! } + """ + ) - snapshot.assert_match(err) + @GraphQLObject.resolver("hello", description="Lorem ipsum.") + @staticmethod + def resolve_hello(*_): + return "Hello World!" + schema = make_executable_schema(QueryType) -def test_object_type_raises_error_when_defined_with_resolver_for_nonexisting_field( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserType(ObjectType): - __schema__ = """ - type User { - name: String + assert_schema_equals( + schema, + """ + type Query { + \"\"\"Lorem ipsum.\"\"\" + hello: String! + } + """, + ) + + result = graphql_sync(schema, "{ hello }") + + assert not result.errors + assert result.data == {"hello": "Hello World!"} + + +def test_resolver_decorator_sets_description_for_field_arg(assert_schema_equals): + class QueryType(GraphQLObject): + hello: str + + @GraphQLObject.resolver( + "hello", args={"name": GraphQLObject.argument(description="Lorem ipsum.")} + ) + @staticmethod + def resolve_hello(*_, name: str) -> str: + return f"Hello {name}!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello( + \"\"\"Lorem ipsum.\"\"\" + name: String! + ): String! + } + """, + ) + + result = graphql_sync(schema, '{ hello(name: "Bob") }') + + assert not result.errors + assert result.data == {"hello": "Hello Bob!"} + + +def test_schema_sets_description_for_field_arg(assert_schema_equals): + class QueryType(GraphQLObject): + __schema__ = gql( + """ + type Query { + hello( + \"\"\"Lorem ipsum.\"\"\" + name: String! + ): String! } """ + ) + + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello(*_, name: str): + return f"Hello {name}!" - @staticmethod - def resolve_group(*_): - return None + schema = make_executable_schema(QueryType) - snapshot.assert_match(err) + assert_schema_equals( + schema, + """ + type Query { + hello( + \"\"\"Lorem ipsum.\"\"\" + name: String! + ): String! + } + """, + ) + result = graphql_sync(schema, '{ hello(name: "Bob") }') -def test_object_type_raises_error_when_defined_with_field_args_for_nonexisting_field( - snapshot, + assert not result.errors + assert result.data == {"hello": "Hello Bob!"} + + +def test_resolver_decorator_sets_description_for_field_arg_in_schema( + assert_schema_equals, ): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserType(ObjectType): - __schema__ = """ - type User { - name: String + class QueryType(GraphQLObject): + __schema__ = gql( + """ + type Query { + hello(name: String!): String! } """ - __fields_args__ = {"group": {}} + ) - snapshot.assert_match(err) + @GraphQLObject.resolver( + "hello", args={"name": GraphQLObject.argument(description="Description")} + ) + @staticmethod + def resolve_hello(*_, name: str): + return f"Hello {name}!" + schema = make_executable_schema(QueryType) -def test_object_type_raises_error_when_defined_with_field_args_for_nonexisting_arg( - snapshot, + assert_schema_equals( + schema, + """ + type Query { + hello( + \"\"\"Description\"\"\" + name: String! + ): String! + } + """, + ) + + result = graphql_sync(schema, '{ hello(name: "Bob") }') + + assert not result.errors + assert result.data == {"hello": "Hello Bob!"} + + +def test_object_type_self_reference( + assert_schema_equals, ): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UserType(ObjectType): - __schema__ = """ - type User { - name: String - } - """ - __fields_args__ = {"name": {"arg": "arg2"}} + class CategoryType(GraphQLObject): + name: str + parent: Optional["CategoryType"] - snapshot.assert_match(err) + class QueryType(GraphQLObject): + category: CategoryType + schema = make_executable_schema(QueryType) -class QueryType(ObjectType): - __schema__ = """ - type Query { - field: String! - other: String! - firstField: String! - secondField: String! - fieldWithArg(someArg: String): String! - } - """ - __aliases__ = { - "firstField": "first_field", - "secondField": "second_field", - "fieldWithArg": "field_with_arg", + assert_schema_equals( + schema, + """ + type Query { + category: Category! + } + + type Category { + name: String! + parent: Category + } + """, + ) + + result = graphql_sync( + schema, + "{ category { name parent { name } } }", + root_value={ + "category": { + "name": "Lorem", + "parent": { + "name": "Ipsum", + }, + }, + }, + ) + + assert not result.errors + assert result.data == { + "category": { + "name": "Lorem", + "parent": { + "name": "Ipsum", + }, + }, } - __fields_args__ = {"fieldWithArg": {"someArg": "some_arg"}} - @staticmethod - def resolve_other(*_): - return "Word Up!" - @staticmethod - def resolve_second_field(obj, *_): - return "Obj: %s" % obj["secondField"] +def test_object_type_return_instance( + assert_schema_equals, +): + class CategoryType(GraphQLObject): + name: str + color: str + + class QueryType(GraphQLObject): + @GraphQLObject.field() + @staticmethod + def category(*_) -> CategoryType: + return CategoryType( + name="Welcome", + color="#FF00FF", + ) + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + category: Category! + } - @staticmethod - def resolve_field_with_arg(*_, some_arg): - return some_arg + type Category { + name: String! + color: String! + } + """, + ) + result = graphql_sync(schema, "{ category { name color } }") -schema = make_executable_schema(QueryType) + assert not result.errors + assert result.data == { + "category": { + "name": "Welcome", + "color": "#FF00FF", + }, + } -def test_object_resolves_field_with_default_resolver(): - result = graphql_sync(schema, "{ field }", root_value={"field": "Hello!"}) - assert result.data["field"] == "Hello!" +def test_object_type_nested_type( + assert_schema_equals, +): + class UserType(GraphQLObject): + username: str + class CategoryType(GraphQLObject): + name: str + parent: Optional["CategoryType"] + owner: UserType -def test_object_resolves_field_with_custom_resolver(): - result = graphql_sync(schema, "{ other }") - assert result.data["other"] == "Word Up!" + class QueryType(GraphQLObject): + category: CategoryType + schema = make_executable_schema(QueryType) -def test_object_resolves_field_with_aliased_default_resolver(): - result = graphql_sync( - schema, "{ firstField }", root_value={"first_field": "Howdy?"} - ) - assert result.data["firstField"] == "Howdy?" + assert_schema_equals( + schema, + """ + type Query { + category: Category! + } + type Category { + name: String! + parent: Category + owner: User! + } -def test_object_resolves_field_with_aliased_custom_resolver(): - result = graphql_sync(schema, "{ secondField }", root_value={"secondField": "Hey!"}) - assert result.data["secondField"] == "Obj: Hey!" + type User { + username: String! + } + """, + ) + result = graphql_sync( + schema, + "{ category { name parent { name } owner { username } } }", + root_value={ + "category": { + "name": "Lorem", + "parent": { + "name": "Ipsum", + }, + "owner": { + "username": "John", + }, + }, + }, + ) -def test_object_resolves_field_with_arg_out_name_customized(): - result = graphql_sync(schema, '{ fieldWithArg(someArg: "test") }') - assert result.data["fieldWithArg"] == "test" + assert not result.errors + assert result.data == { + "category": { + "name": "Lorem", + "parent": { + "name": "Ipsum", + }, + "owner": { + "username": "John", + }, + }, + } diff --git a/tests/test_object_type_field_args.py b/tests/test_object_type_field_args.py new file mode 100644 index 0000000..87fa422 --- /dev/null +++ b/tests/test_object_type_field_args.py @@ -0,0 +1,452 @@ +from graphql import graphql_sync + +from ariadne_graphql_modules import GraphQLObject, make_executable_schema + + +def test_object_type_field_resolver_with_scalar_arg(assert_schema_equals): + class QueryType(GraphQLObject): + @GraphQLObject.field + @staticmethod + def hello(*_, name: str) -> str: + return f"Hello {name}!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello(name: String!): String! + } + """, + ) + + result = graphql_sync(schema, '{ hello(name: "Bob") }') + + assert not result.errors + assert result.data == {"hello": "Hello Bob!"} + + +def test_object_type_field_instance_with_scalar_arg(assert_schema_equals): + def hello_resolver(*_, name: str) -> str: + return f"Hello {name}!" + + class QueryType(GraphQLObject): + hello = GraphQLObject.field(hello_resolver) + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello(name: String!): String! + } + """, + ) + + result = graphql_sync(schema, '{ hello(name: "Bob") }') + + assert not result.errors + assert result.data == {"hello": "Hello Bob!"} + + +def test_object_type_field_resolver_with_arg_default_value(assert_schema_equals): + class QueryType(GraphQLObject): + @GraphQLObject.field + @staticmethod + def hello(*_, name: str = "Anon") -> str: + return f"Hello {name}!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello(name: String! = "Anon"): String! + } + """, + ) + + result = graphql_sync(schema, "{ hello }") + + assert not result.errors + assert result.data == {"hello": "Hello Anon!"} + + +def test_object_type_field_instance_with_arg_default_value(assert_schema_equals): + def hello_resolver(*_, name: str = "Anon") -> str: + return f"Hello {name}!" + + class QueryType(GraphQLObject): + hello = GraphQLObject.field(hello_resolver) + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello(name: String! = "Anon"): String! + } + """, + ) + + result = graphql_sync(schema, "{ hello }") + + assert not result.errors + assert result.data == {"hello": "Hello Anon!"} + + +def test_object_type_field_resolver_with_arg_option_default_value( + assert_schema_equals, +): + class QueryType(GraphQLObject): + @GraphQLObject.field( + args={"name": GraphQLObject.argument(default_value="Anon")}, + ) + @staticmethod + def hello(*_, name: str) -> str: + return f"Hello {name}!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello(name: String! = "Anon"): String! + } + """, + ) + + result = graphql_sync(schema, "{ hello }") + + assert not result.errors + assert result.data == {"hello": "Hello Anon!"} + + +def test_object_type_field_instance_with_arg_option_default_value( + assert_schema_equals, +): + def hello_resolver(*_, name: str) -> str: + return f"Hello {name}!" + + class QueryType(GraphQLObject): + hello = GraphQLObject.field( + hello_resolver, + args={"name": GraphQLObject.argument(default_value="Anon")}, + ) + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello(name: String! = "Anon"): String! + } + """, + ) + + result = graphql_sync(schema, "{ hello }") + + assert not result.errors + assert result.data == {"hello": "Hello Anon!"} + + +def test_schema_object_type_field_with_arg_default_value( + assert_schema_equals, +): + class QueryType(GraphQLObject): + __schema__ = """ + type Query { + hello(name: String! = "Anon"): String! + } + """ + + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello(*_, name: str) -> str: + return f"Hello {name}!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello(name: String! = "Anon"): String! + } + """, + ) + + result = graphql_sync(schema, "{ hello }") + + assert not result.errors + assert result.data == {"hello": "Hello Anon!"} + + +def test_schema_object_type_field_with_arg_default_value_from_resolver_arg( + assert_schema_equals, +): + class QueryType(GraphQLObject): + __schema__ = """ + type Query { + hello(name: String!): String! + } + """ + + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello(*_, name: str = "Anon") -> str: + return f"Hello {name}!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello(name: String! = "Anon"): String! + } + """, + ) + + result = graphql_sync(schema, "{ hello }") + + assert not result.errors + assert result.data == {"hello": "Hello Anon!"} + + +def test_schema_object_type_field_with_arg_default_value_from_resolver_arg_option( + assert_schema_equals, +): + class QueryType(GraphQLObject): + __schema__ = """ + type Query { + hello(name: String!): String! + } + """ + + @GraphQLObject.resolver( + "hello", args={"name": GraphQLObject.argument(default_value="Other")} + ) + @staticmethod + def resolve_hello(*_, name: str = "Anon") -> str: + return f"Hello {name}!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello(name: String! = "Other"): String! + } + """, + ) + + result = graphql_sync(schema, "{ hello }") + + assert not result.errors + assert result.data == {"hello": "Hello Other!"} + + +def test_object_type_field_instance_with_description(assert_schema_equals): + def hello_resolver(*_, name: str) -> str: + return f"Hello {name}!" + + class QueryType(GraphQLObject): + hello = GraphQLObject.field( + hello_resolver, + args={ + "name": GraphQLObject.argument( + description="Lorem ipsum dolor met!", + ), + }, + ) + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello( + \"\"\"Lorem ipsum dolor met!\"\"\" + name: String! + ): String! + } + """, + ) + + result = graphql_sync(schema, '{ hello(name: "Bob") }') + + assert not result.errors + assert result.data == {"hello": "Hello Bob!"} + + +def test_resolver_decorator_sets_resolver_with_arg_for_type_hint_field( + assert_schema_equals, +): + class QueryType(GraphQLObject): + hello: str + + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello(*_, name: str): + return f"Hello {name}!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello(name: String!): String! + } + """, + ) + + result = graphql_sync(schema, '{ hello(name: "Bob") }') + + assert not result.errors + assert result.data == {"hello": "Hello Bob!"} + + +def test_object_type_field_instance_with_argument_description(assert_schema_equals): + def hello_resolver(*_, name: str) -> str: + return f"Hello {name}!" + + class QueryType(GraphQLObject): + hello = GraphQLObject.field( + hello_resolver, + args={ + "name": GraphQLObject.argument( + description="Lorem ipsum dolor met!", + ), + }, + ) + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello( + \"\"\"Lorem ipsum dolor met!\"\"\" + name: String! + ): String! + } + """, + ) + + result = graphql_sync(schema, '{ hello(name: "Bob") }') + + assert not result.errors + assert result.data == {"hello": "Hello Bob!"} + + +def test_object_type_field_resolver_instance_arg_with_out_name(assert_schema_equals): + class QueryType(GraphQLObject): + hello: str + + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello(*_, first_name: str) -> str: + return f"Hello {first_name}!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello(firstName: String!): String! + } + """, + ) + + result = graphql_sync(schema, '{ hello(firstName: "Bob") }') + + assert not result.errors + assert result.data == {"hello": "Hello Bob!"} + + +def test_object_type_field_instance_arg_with_out_name(assert_schema_equals): + class QueryType(GraphQLObject): + @GraphQLObject.field + @staticmethod + def hello(*_, first_name: str) -> str: + return f"Hello {first_name}!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello(firstName: String!): String! + } + """, + ) + + result = graphql_sync(schema, '{ hello(firstName: "Bob") }') + + assert not result.errors + assert result.data == {"hello": "Hello Bob!"} + + +def test_object_type_field_resolver_instance_arg_with_custom_name(assert_schema_equals): + class QueryType(GraphQLObject): + hello: str + + @GraphQLObject.resolver( + "hello", args={"first_name": GraphQLObject.argument(name="name")} + ) + @staticmethod + def resolve_hello(*_, first_name: str) -> str: + return f"Hello {first_name}!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello(name: String!): String! + } + """, + ) + + result = graphql_sync(schema, '{ hello(name: "Bob") }') + + assert not result.errors + assert result.data == {"hello": "Hello Bob!"} + + +def test_object_type_field_instance_arg_with_custom_name(assert_schema_equals): + class QueryType(GraphQLObject): + @GraphQLObject.field(args={"first_name": GraphQLObject.argument(name="name")}) + @staticmethod + def hello(*_, first_name: str) -> str: + return f"Hello {first_name}!" + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + hello(name: String!): String! + } + """, + ) + + result = graphql_sync(schema, '{ hello(name: "Bob") }') + + assert not result.errors + assert result.data == {"hello": "Hello Bob!"} diff --git a/tests/test_object_type_validation.py b/tests/test_object_type_validation.py new file mode 100644 index 0000000..d739802 --- /dev/null +++ b/tests/test_object_type_validation.py @@ -0,0 +1,605 @@ +# pylint: disable=unused-variable +import pytest +from ariadne import gql + +from ariadne_graphql_modules import GraphQLObject + + +def test_schema_object_type_validation_fails_for_invalid_type_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + __schema__ = gql("scalar Custom") + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_names_not_matching(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + __graphql_name__ = "Lorem" + __schema__ = gql( + """ + type Custom { + hello: String + } + """ + ) + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_two_descriptions(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + __description__ = "Hello world!" + __schema__ = gql( + """ + \"\"\"Other description\"\"\" + type Query { + hello: String! + } + """ + ) + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_missing_fields(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + __schema__ = gql("type Custom") + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_undefined_attr_resolver(data_regression): + with pytest.raises(ValueError) as exc_info: + + class QueryType(GraphQLObject): + hello: str + + @GraphQLObject.resolver("other") + @staticmethod + def resolve_hello(*_): + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_undefined_field_resolver( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class QueryType(GraphQLObject): + __schema__ = gql( + """ + type Query { + hello: String! + } + """ + ) + + @GraphQLObject.resolver("other") + @staticmethod + def resolve_hello(*_): + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_multiple_attrs_with_same_graphql_name( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + user_id: str + user__id: str + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_attr_and_field_with_same_graphql_name( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + user_id: str + + @GraphQLObject.field(name="userId") + @staticmethod + def lorem(*_) -> str: + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_multiple_fields_with_same_graphql_name( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + @GraphQLObject.field(name="hello") + @staticmethod + def lorem(*_) -> str: + return "Hello World!" + + @GraphQLObject.field(name="hello") + @staticmethod + def ipsum(*_) -> str: + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_undefined_field_resolver_arg(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + @GraphQLObject.field(args={"invalid": GraphQLObject.argument(name="test")}) + @staticmethod + def hello(*_) -> str: + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_undefined_resolver_arg(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + hello: str + + @GraphQLObject.resolver( + "hello", args={"invalid": GraphQLObject.argument(name="test")} + ) + @staticmethod + def resolve_hello(*_): + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_missing_field_resolver_arg(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + @GraphQLObject.field(args={"invalid": GraphQLObject.argument(name="test")}) + @staticmethod + def hello(*_, name: str) -> str: + return f"Hello {name}!" + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_missing_resolver_arg(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + hello: str + + @GraphQLObject.resolver( + "hello", args={"invalid": GraphQLObject.argument(name="test")} + ) + @staticmethod + def resolve_hello(*_, name: str): + return f"Hello {name}!" + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_multiple_field_resolvers(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + hello: str + + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello(*_): + return "Hello World!" + + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello_other(*_): + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_multiple_field_resolvers( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + __schema__ = gql( + """ + type Query { + hello: String! + } + """ + ) + + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello(*_): + return "Hello World!" + + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello_other(*_): + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_field_with_multiple_descriptions( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + hello: str = GraphQLObject.field(description="Description") + + @GraphQLObject.resolver("hello", description="Other") + @staticmethod + def ipsum(*_) -> str: + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_field_with_multiple_descriptions( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + __schema__ = """ + type Custom { + \"\"\"Description\"\"\" + hello: String! + } + """ + + @GraphQLObject.resolver("hello", description="Other") + @staticmethod + def ipsum(*_) -> str: + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_field_with_invalid_arg_name( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + __schema__ = """ + type Custom { + hello(name: String!): String! + } + """ + + @GraphQLObject.resolver( + "hello", args={"other": GraphQLObject.argument(description="Ok")} + ) + @staticmethod + def ipsum(*_, name: str) -> str: + return f"Hello {name}!" + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_arg_with_name_option( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + __schema__ = """ + type Custom { + hello(name: String!): String! + } + """ + + @GraphQLObject.resolver( + "hello", args={"name": GraphQLObject.argument(name="Other")} + ) + @staticmethod + def ipsum(*_, name: str) -> str: + return f"Hello {name}!" + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_arg_with_type_option( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + __schema__ = """ + type Custom { + hello(name: String!): String! + } + """ + + @GraphQLObject.resolver( + "hello", args={"name": GraphQLObject.argument(graphql_type=str)} + ) + @staticmethod + def ipsum(*_, name: str) -> str: + return f"Hello {name}!" + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_arg_with_double_description( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + __schema__ = """ + type Custom { + hello( + \"\"\"Description\"\"\" + name: String! + ): String! + } + """ + + @GraphQLObject.resolver( + "hello", args={"name": GraphQLObject.argument(description="Other")} + ) + @staticmethod + def ipsum(*_, name: str) -> str: + return f"Hello {name}!" + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_field_with_multiple_args( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + @GraphQLObject.field(name="hello", args={""}) # type: ignore + @staticmethod + def lorem(*_, a: int) -> str: + return f"Hello {a}!" + + @GraphQLObject.resolver("lorem", description="Other") + @staticmethod + def ipsum(*_) -> str: + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_invalid_alias(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + hello: str + + __aliases__ = { + "invalid": "target", + } + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_invalid_alias(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + __schema__ = gql( + """ + type Query { + hello: String! + } + """ + ) + + __aliases__ = { + "invalid": "welcome", + } + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_alias_resolver(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + hello: str + + __aliases__ = { + "hello": "welcome", + } + + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello(*_): + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_alias_target_resolver(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + hello: str + welcome: str + + __aliases__ = { + "hello": "welcome", + } + + @GraphQLObject.resolver("welcome") + @staticmethod + def resolve_welcome(*_): + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_alias_resolver(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + __schema__ = gql( + """ + type Query { + hello: String! + } + """ + ) + + __aliases__ = { + "hello": "ok", + } + + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello(*_): + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_alias_target_resolver(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + __schema__ = gql( + """ + type Query { + hello: String! + } + """ + ) + + __aliases__ = { + "hello": "ok", + } + + ok: str + + @GraphQLObject.resolver("ok") + @staticmethod + def resolve_hello(*_): + return "Hello World!" + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_field_instance(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType(GraphQLObject): + __schema__ = gql( + """ + type Query { + hello: String! + } + """ + ) + + hello = GraphQLObject.field(lambda *_: "noop") + + data_regression.check(str(exc_info.value)) + + +class InvalidType: + pass + + +def test_object_type_validation_fails_for_unsupported_resolver_arg_default( + data_regression, +): + with pytest.raises(TypeError) as exc_info: + + class QueryType(GraphQLObject): + @GraphQLObject.field + @staticmethod + def hello(*_, name: str = InvalidType): # type: ignore + return f"Hello {name}!" + + data_regression.check(str(exc_info.value)) + + +def test_object_type_validation_fails_for_unsupported_resolver_arg_default_option( + data_regression, +): + with pytest.raises(TypeError) as exc_info: + + class QueryType(GraphQLObject): + @GraphQLObject.field( + args={"name": GraphQLObject.argument(default_value=InvalidType)} + ) + @staticmethod + def hello(*_, name: str): + return f"Hello {name}!" + + data_regression.check(str(exc_info.value)) + + +def test_schema_object_type_validation_fails_for_unsupported_resolver_arg_default( + data_regression, +): + with pytest.raises(TypeError) as exc_info: + + class QueryType(GraphQLObject): + __schema__ = gql( + """ + type Query { + hello: String! + } + """ + ) + + @GraphQLObject.resolver("hello") + @staticmethod + def resolve_hello(*_, name: str = InvalidType): # type: ignore + return f"Hello {name}!" + + data_regression.check(str(exc_info.value)) + + +def test_invalid_resolver_arg_option_default( + data_regression, +): + with pytest.raises(TypeError) as exc_info: + + class QueryType(GraphQLObject): + __schema__ = gql( + """ + type Query { + hello(name: String!): String! + } + """ + ) + + @GraphQLObject.resolver( + "hello", + args={"name": GraphQLObject.argument(default_value=InvalidType)}, + ) + @staticmethod + def resolve_hello(*_, name: str): + return f"Hello {name}!" + + data_regression.check(str(exc_info.value)) diff --git a/tests/test_scalar_type.py b/tests/test_scalar_type.py index ebdc18d..706dd1e 100644 --- a/tests/test_scalar_type.py +++ b/tests/test_scalar_type.py @@ -1,281 +1,146 @@ -from datetime import date, datetime +from datetime import date -import pytest -from ariadne import SchemaDirectiveVisitor -from graphql import GraphQLError, StringValueNode, graphql_sync +from ariadne import gql +from graphql import graphql_sync from ariadne_graphql_modules import ( - DirectiveType, - ObjectType, - ScalarType, + GraphQLObject, + GraphQLScalar, make_executable_schema, ) -def test_scalar_type_raises_attribute_error_when_defined_without_schema(snapshot): - with pytest.raises(AttributeError) as err: - # pylint: disable=unused-variable - class DateScalar(ScalarType): - pass +class DateScalar(GraphQLScalar[date]): + @classmethod + def serialize(cls, value): + if isinstance(value, cls): + return str(value.unwrap()) - snapshot.assert_match(err) + return str(value) -def test_scalar_type_raises_error_when_defined_with_invalid_schema_type(snapshot): - with pytest.raises(TypeError) as err: - # pylint: disable=unused-variable - class DateScalar(ScalarType): - __schema__ = True +class SerializeTestScalar(GraphQLScalar[str]): + pass - snapshot.assert_match(err) +def test_scalar_field_returning_scalar_instance(assert_schema_equals): + class QueryType(GraphQLObject): + date: DateScalar -def test_scalar_type_raises_error_when_defined_with_invalid_schema_str(snapshot): - with pytest.raises(GraphQLError) as err: - # pylint: disable=unused-variable - class DateScalar(ScalarType): - __schema__ = "scalor Date" - - snapshot.assert_match(err) - - -def test_scalar_type_raises_error_when_defined_with_invalid_graphql_type_schema( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class DateScalar(ScalarType): - __schema__ = "type DateTime" - - snapshot.assert_match(err) - - -def test_scalar_type_raises_error_when_defined_with_multiple_types_schema(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class DateScalar(ScalarType): - __schema__ = """ - scalar Date - - scalar DateTime - """ - - snapshot.assert_match(err) - - -def test_scalar_type_extracts_graphql_name(): - class DateScalar(ScalarType): - __schema__ = "scalar Date" - - assert DateScalar.graphql_name == "Date" - - -def test_scalar_type_can_be_extended_with_directive(): - # pylint: disable=unused-variable - class ExampleDirective(DirectiveType): - __schema__ = "directive @example on SCALAR" - __visitor__ = SchemaDirectiveVisitor - - class DateScalar(ScalarType): - __schema__ = "scalar Date" - - class ExtendDateScalar(ScalarType): - __schema__ = "extend scalar Date @example" - __requires__ = [DateScalar, ExampleDirective] - - -class DateReadOnlyScalar(ScalarType): - __schema__ = "scalar DateReadOnly" - - @staticmethod - def serialize(date): - return date.strftime("%Y-%m-%d") - - -class DateInputScalar(ScalarType): - __schema__ = "scalar DateInput" - - @staticmethod - def parse_value(formatted_date): - parsed_datetime = datetime.strptime(formatted_date, "%Y-%m-%d") - return parsed_datetime.date() - - @staticmethod - def parse_literal(ast, variable_values=None): # pylint: disable=unused-argument - if not isinstance(ast, StringValueNode): - raise ValueError() - - formatted_date = ast.value - parsed_datetime = datetime.strptime(formatted_date, "%Y-%m-%d") - return parsed_datetime.date() - - -class DefaultParserScalar(ScalarType): - __schema__ = "scalar DefaultParser" - - @staticmethod - def parse_value(value): - return type(value).__name__ - - -TEST_DATE = date(2006, 9, 13) -TEST_DATE_SERIALIZED = TEST_DATE.strftime("%Y-%m-%d") - - -class QueryType(ObjectType): - __schema__ = """ - type Query { - testSerialize: DateReadOnly! - testInput(value: DateInput!): Boolean! - testInputValueType(value: DefaultParser!): String! - } - """ - __requires__ = [ - DateReadOnlyScalar, - DateInputScalar, - DefaultParserScalar, - ] - __aliases__ = { - "testSerialize": "test_serialize", - "testInput": "test_input", - "testInputValueType": "test_input_value_type", - } - - @staticmethod - def resolve_test_serialize(*_): - return TEST_DATE - - @staticmethod - def resolve_test_input(*_, value): - assert value == TEST_DATE - return True + @GraphQLObject.resolver("date") + @staticmethod + def resolve_date(*_) -> DateScalar: + return DateScalar(date(1989, 10, 30)) - @staticmethod - def resolve_test_input_value_type(*_, value): - return value + schema = make_executable_schema(QueryType) + assert_schema_equals( + schema, + """ + scalar Date -schema = make_executable_schema(QueryType) + type Query { + date: Date! + } + """, + ) + result = graphql_sync(schema, "{ date }") -def test_attempt_deserialize_str_literal_without_valid_date_raises_error(): - test_input = "invalid string" - result = graphql_sync(schema, '{ testInput(value: "%s") }' % test_input) - assert result.errors is not None - assert str(result.errors[0]).splitlines()[:1] == [ - "Expected value of type 'DateInput!', found \"invalid string\"; " - "time data 'invalid string' does not match format '%Y-%m-%d'" - ] + assert not result.errors + assert result.data == {"date": "1989-10-30"} -def test_attempt_deserialize_wrong_type_literal_raises_error(): - test_input = 123 - result = graphql_sync(schema, "{ testInput(value: %s) }" % test_input) - assert result.errors is not None - assert str(result.errors[0]).splitlines()[:1] == [ - "Expected value of type 'DateInput!', found 123; " - ] +def test_scalar_field_returning_scalar_wrapped_type(assert_schema_equals): + class QueryType(GraphQLObject): + scalar_date: DateScalar + @GraphQLObject.resolver("scalar_date", graphql_type=DateScalar) + @staticmethod + def resolve_date(*_) -> date: + return date(1989, 10, 30) -def test_default_literal_parser_is_used_to_extract_value_str_from_ast_node(): - class ValueParserOnlyScalar(ScalarType): - __schema__ = "scalar DateInput" + schema = make_executable_schema(QueryType) - @staticmethod - def parse_value(formatted_date): - parsed_datetime = datetime.strptime(formatted_date, "%Y-%m-%d") - return parsed_datetime.date() + assert_schema_equals( + schema, + """ + scalar Date - class ValueParserOnlyQueryType(ObjectType): - __schema__ = """ type Query { - parse(value: DateInput!): String! + scalarDate: Date! } - """ - __requires__ = [ValueParserOnlyScalar] - - @staticmethod - def resolve_parse(*_, value): - return value + """, + ) - schema = make_executable_schema(ValueParserOnlyQueryType) - result = graphql_sync(schema, """{ parse(value: "%s") }""" % TEST_DATE_SERIALIZED) - assert result.errors is None - assert result.data == {"parse": "2006-09-13"} + result = graphql_sync(schema, "{ scalarDate }") + assert not result.errors + assert result.data == {"scalarDate": "1989-10-30"} -parametrized_query = """ - query parseValueTest($value: DateInput!) { - testInput(value: $value) - } -""" +class SchemaDateScalar(GraphQLScalar[date]): + __schema__ = gql("scalar Date") -def test_variable_with_valid_date_string_is_deserialized_to_python_date(): - variables = {"value": TEST_DATE_SERIALIZED} - result = graphql_sync(schema, parametrized_query, variable_values=variables) - assert result.errors is None - assert result.data == {"testInput": True} + @classmethod + def serialize(cls, value): + if isinstance(value, cls): + return str(value.unwrap()) + return str(value) -def test_attempt_deserialize_str_variable_without_valid_date_raises_error(): - variables = {"value": "invalid string"} - result = graphql_sync(schema, parametrized_query, variable_values=variables) - assert result.errors is not None - assert str(result.errors[0]).splitlines()[:1] == [ - "Variable '$value' got invalid value 'invalid string'; " - "Expected type 'DateInput'. " - "time data 'invalid string' does not match format '%Y-%m-%d'" - ] +def test_unwrap_scalar_field_returning_scalar_instance(assert_schema_equals): + class QueryType(GraphQLObject): + test: SerializeTestScalar -def test_attempt_deserialize_wrong_type_variable_raises_error(): - variables = {"value": 123} - result = graphql_sync(schema, parametrized_query, variable_values=variables) - assert result.errors is not None - assert str(result.errors[0]).splitlines()[:1] == [ - "Variable '$value' got invalid value 123; Expected type 'DateInput'. " - "strptime() argument 1 must be str, not int" - ] - + @GraphQLObject.resolver("test", graphql_type=str) + @staticmethod + def resolve_date(*_) -> SerializeTestScalar: + return SerializeTestScalar(value="Hello!") -def test_literal_string_is_deserialized_by_default_parser(): - result = graphql_sync(schema, '{ testInputValueType(value: "test") }') - assert result.errors is None - assert result.data == {"testInputValueType": "str"} + schema = make_executable_schema(QueryType) + assert_schema_equals( + schema, + """ + scalar SerializeTest -def test_literal_int_is_deserialized_by_default_parser(): - result = graphql_sync(schema, "{ testInputValueType(value: 123) }") - assert result.errors is None - assert result.data == {"testInputValueType": "int"} + type Query { + test: SerializeTest! + } + """, + ) + result = graphql_sync(schema, "{ test }") -def test_literal_float_is_deserialized_by_default_parser(): - result = graphql_sync(schema, "{ testInputValueType(value: 1.5) }") - assert result.errors is None - assert result.data == {"testInputValueType": "float"} + assert not result.errors + assert result.data == {"test": "Hello!"} -def test_literal_bool_true_is_deserialized_by_default_parser(): - result = graphql_sync(schema, "{ testInputValueType(value: true) }") - assert result.errors is None - assert result.data == {"testInputValueType": "bool"} +def test_schema_scalar_field_returning_scalar_instance(assert_schema_equals): + class QueryType(GraphQLObject): + date: SchemaDateScalar + @GraphQLObject.resolver("date") + @staticmethod + def resolve_date(*_) -> SchemaDateScalar: + return SchemaDateScalar(date(1989, 10, 30)) -def test_literal_bool_false_is_deserialized_by_default_parser(): - result = graphql_sync(schema, "{ testInputValueType(value: false) }") - assert result.errors is None - assert result.data == {"testInputValueType": "bool"} + schema = make_executable_schema(QueryType) + assert_schema_equals( + schema, + """ + scalar Date -def test_literal_object_is_deserialized_by_default_parser(): - result = graphql_sync(schema, "{ testInputValueType(value: {}) }") - assert result.errors is None - assert result.data == {"testInputValueType": "dict"} + type Query { + date: Date! + } + """, + ) + result = graphql_sync(schema, "{ date }") -def test_literal_list_is_deserialized_by_default_parser(): - result = graphql_sync(schema, "{ testInputValueType(value: []) }") - assert result.errors is None - assert result.data == {"testInputValueType": "list"} + assert not result.errors + assert result.data == {"date": "1989-10-30"} diff --git a/tests/test_scalar_type_validation.py b/tests/test_scalar_type_validation.py new file mode 100644 index 0000000..8d25f95 --- /dev/null +++ b/tests/test_scalar_type_validation.py @@ -0,0 +1,41 @@ +# pylint: disable=unused-variable +import pytest +from ariadne import gql + +from ariadne_graphql_modules import GraphQLScalar + + +def test_schema_scalar_type_validation_fails_for_invalid_type_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomScalar(GraphQLScalar[str]): + __schema__ = gql("type Custom") + + data_regression.check(str(exc_info.value)) + + +def test_schema_scalar_type_validation_fails_for_different_names( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class CustomScalar(GraphQLScalar[str]): + __graphql_name__ = "Date" + __schema__ = gql("scalar Custom") + + data_regression.check(str(exc_info.value)) + + +def test_schema_scalar_type_validation_fails_for_two_descriptions(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomScalar(GraphQLScalar[str]): + __description__ = "Hello world!" + __schema__ = gql( + """ + \"\"\"Other description\"\"\" + scalar Lorem + """ + ) + + data_regression.check(str(exc_info.value)) diff --git a/tests/test_standard_enum.py b/tests/test_standard_enum.py new file mode 100644 index 0000000..82453c9 --- /dev/null +++ b/tests/test_standard_enum.py @@ -0,0 +1,317 @@ +from enum import Enum + +import pytest +from graphql import graphql_sync + +from ariadne_graphql_modules import ( + GraphQLObject, + create_graphql_enum_model, + graphql_enum, + make_executable_schema, +) + + +class UserLevel(Enum): + GUEST = 0 + MEMBER = 1 + MODERATOR = 2 + ADMINISTRATOR = 3 + + +def test_graphql_enum_model_is_created_from_python_enum(assert_ast_equals): + graphql_model = create_graphql_enum_model(UserLevel) + + assert graphql_model.name == "UserLevel" + assert graphql_model.members == { + "GUEST": UserLevel.GUEST, + "MEMBER": UserLevel.MEMBER, + "MODERATOR": UserLevel.MODERATOR, + "ADMINISTRATOR": UserLevel.ADMINISTRATOR, + } + + assert_ast_equals( + graphql_model.ast, + """ + enum UserLevel { + GUEST + MEMBER + MODERATOR + ADMINISTRATOR + } + """, + ) + + +def test_graphql_enum_model_is_created_with_custom_name(): + graphql_model = create_graphql_enum_model(UserLevel, name="CustomName") + assert graphql_model.name == "CustomName" + + +def test_graphql_enum_model_is_created_with_method(): + class UserLevel(Enum): + GUEST = 0 + MEMBER = 1 + MODERATOR = 2 + ADMINISTRATOR = 3 + + @staticmethod + def __get_graphql_model__(): + return "CustomModel" + + graphql_model = create_graphql_enum_model(UserLevel) + assert graphql_model == "CustomModel" + + +def test_graphql_enum_model_is_created_with_name_from_method(): + class UserLevel(Enum): + GUEST = 0 + MEMBER = 1 + MODERATOR = 2 + ADMINISTRATOR = 3 + + @staticmethod + def __get_graphql_name__(): + return "CustomName" + + graphql_model = create_graphql_enum_model(UserLevel) + assert graphql_model.name == "CustomName" + + +def test_graphql_enum_model_is_created_with_description(assert_ast_equals): + graphql_model = create_graphql_enum_model(UserLevel, description="Test enum.") + + assert graphql_model.name == "UserLevel" + assert graphql_model.members == { + "GUEST": UserLevel.GUEST, + "MEMBER": UserLevel.MEMBER, + "MODERATOR": UserLevel.MODERATOR, + "ADMINISTRATOR": UserLevel.ADMINISTRATOR, + } + + assert_ast_equals( + graphql_model.ast, + """ + "Test enum." + enum UserLevel { + GUEST + MEMBER + MODERATOR + ADMINISTRATOR + } + """, + ) + + +def test_graphql_enum_model_is_created_with_specified_members(assert_ast_equals): + graphql_model = create_graphql_enum_model( + UserLevel, + members_include=["GUEST", "MODERATOR"], + ) + + assert graphql_model.name == "UserLevel" + assert graphql_model.members == { + "GUEST": UserLevel.GUEST, + "MODERATOR": UserLevel.MODERATOR, + } + + assert_ast_equals( + graphql_model.ast, + """ + enum UserLevel { + GUEST + MODERATOR + } + """, + ) + + +def test_graphql_enum_model_is_created_without_specified_members(assert_ast_equals): + graphql_model = create_graphql_enum_model( + UserLevel, + members_exclude=["GUEST", "MODERATOR"], + ) + + assert graphql_model.name == "UserLevel" + assert graphql_model.members == { + "MEMBER": UserLevel.MEMBER, + "ADMINISTRATOR": UserLevel.ADMINISTRATOR, + } + + assert_ast_equals( + graphql_model.ast, + """ + enum UserLevel { + MEMBER + ADMINISTRATOR + } + """, + ) + + +def test_graphql_enum_model_is_created_with_members_descriptions(assert_ast_equals): + graphql_model = create_graphql_enum_model( + UserLevel, + members_descriptions={ + "GUEST": "Default role.", + "ADMINISTRATOR": "Can use admin panel.", + }, + ) + + assert graphql_model.name == "UserLevel" + assert graphql_model.members == { + "GUEST": UserLevel.GUEST, + "MEMBER": UserLevel.MEMBER, + "MODERATOR": UserLevel.MODERATOR, + "ADMINISTRATOR": UserLevel.ADMINISTRATOR, + } + + assert_ast_equals( + graphql_model.ast, + """ + enum UserLevel { + "Default role." + GUEST + MEMBER + MODERATOR + "Can use admin panel." + ADMINISTRATOR + } + """, + ) + + +def test_value_error_is_raised_if_exclude_and_include_members_are_combined( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + create_graphql_enum_model( + UserLevel, + members_exclude=["MEMBER"], + members_include=["ADMINISTRATOR"], + ) + + data_regression.check(str(exc_info.value)) + + +def test_value_error_is_raised_if_member_description_is_set_for_missing_item( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + create_graphql_enum_model(UserLevel, members_descriptions={"MISSING": "Hello!"}) + + data_regression.check(str(exc_info.value)) + + +def test_value_error_is_raised_if_member_description_is_set_for_omitted_item( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + create_graphql_enum_model( + UserLevel, + members_include=["GUEST"], + members_descriptions={"ADMINISTRATOR": "Hello!"}, + ) + + data_regression.check(str(exc_info.value)) + + +def test_value_error_is_raised_if_member_description_is_set_for_excluded_item( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + create_graphql_enum_model( + UserLevel, + members_exclude=["ADMINISTRATOR"], + members_descriptions={"ADMINISTRATOR": "Hello!"}, + ) + + data_regression.check(str(exc_info.value)) + + +def test_enum_field_returning_enum_instance(assert_schema_equals): + class QueryType(GraphQLObject): + level: UserLevel + + @GraphQLObject.resolver("level") + @staticmethod + def resolve_level(*_) -> UserLevel: + return UserLevel.MODERATOR + + schema = make_executable_schema(QueryType) + + assert_schema_equals( + schema, + """ + type Query { + level: UserLevel! + } + + enum UserLevel { + GUEST + MEMBER + MODERATOR + ADMINISTRATOR + } + """, + ) + + result = graphql_sync(schema, "{ level }") + + assert not result.errors + assert result.data == {"level": "MODERATOR"} + + +# pylint: disable=no-member +def test_graphql_enum_decorator_without_options_sets_model_on_enum(assert_ast_equals): + @graphql_enum + class SeverityLevel(Enum): + LOW = 0 + MEDIUM = 1 + HIGH = 2 + + graphql_model = SeverityLevel.__get_graphql_model__() # type: ignore + + assert graphql_model.name == "SeverityLevel" + assert graphql_model.members == { + "LOW": SeverityLevel.LOW, + "MEDIUM": SeverityLevel.MEDIUM, + "HIGH": SeverityLevel.HIGH, + } + + assert_ast_equals( + graphql_model.ast, + """ + enum SeverityLevel { + LOW + MEDIUM + HIGH + } + """, + ) + + +# pylint: disable=no-member +def test_graphql_enum_decorator_with_options_sets_model_on_enum(assert_ast_equals): + @graphql_enum(name="SeverityEnum", members_exclude=["HIGH"]) + class SeverityLevel(Enum): + LOW = 0 + MEDIUM = 1 + HIGH = 2 + + graphql_model = SeverityLevel.__get_graphql_model__() # type: ignore + + assert graphql_model.name == "SeverityEnum" + assert graphql_model.members == { + "LOW": SeverityLevel.LOW, + "MEDIUM": SeverityLevel.MEDIUM, + } + + assert_ast_equals( + graphql_model.ast, + """ + enum SeverityEnum { + LOW + MEDIUM + } + """, + ) diff --git a/tests/test_subscription_type.py b/tests/test_subscription_type.py index d870060..0e82871 100644 --- a/tests/test_subscription_type.py +++ b/tests/test_subscription_type.py @@ -1,319 +1,968 @@ +# pylint: disable=unnecessary-dunder-call + import pytest -from ariadne import SchemaDirectiveVisitor -from graphql import GraphQLError, build_schema +from ariadne import gql +from graphql import parse, subscribe from ariadne_graphql_modules import ( - DirectiveType, - InterfaceType, - ObjectType, - SubscriptionType, + GraphQLID, + GraphQLObject, + GraphQLSubscription, + GraphQLUnion, + make_executable_schema, ) -def test_subscription_type_raises_attribute_error_when_defined_without_schema(snapshot): - with pytest.raises(AttributeError) as err: - # pylint: disable=unused-variable - class UsersSubscription(SubscriptionType): - pass +class Message(GraphQLObject): + id: GraphQLID + content: str + author: str + + +class User(GraphQLObject): + id: GraphQLID + username: str + - snapshot.assert_match(err) +class Notification(GraphQLUnion): + __types__ = [Message, User] + + +@pytest.mark.asyncio +async def test_basic_subscription_without_schema(assert_schema_equals): + class SubscriptionType(GraphQLSubscription): + message_added: Message + + @GraphQLSubscription.source("message_added", graphql_type=Message) + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + @GraphQLSubscription.resolver("message_added") + @staticmethod + async def resolve_message_added(message, *_): + return message + + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=str) + @staticmethod + def search_sth(*_) -> str: + return "search" + + schema = make_executable_schema(QueryType, SubscriptionType) + + assert_schema_equals( + schema, + """ + type Query { + searchSth: String! + } + type Subscription { + messageAdded: Message! + } -def test_subscription_type_raises_error_when_defined_with_invalid_schema_type(snapshot): - with pytest.raises(TypeError) as err: - # pylint: disable=unused-variable - class UsersSubscription(SubscriptionType): - __schema__ = True + type Message { + id: ID! + content: String! + author: String! + } + """, + ) - snapshot.assert_match(err) + query = parse("subscription { messageAdded {id content author} }") + sub = await subscribe(schema, query) + # Ensure the subscription is an async iterator + assert hasattr(sub, "__aiter__") -def test_subscription_type_raises_error_when_defined_with_invalid_schema_str(snapshot): - with pytest.raises(GraphQLError) as err: - # pylint: disable=unused-variable - class UsersSubscription(SubscriptionType): - __schema__ = "typo Subscription" + # Fetch the first result + result = await sub.__anext__() # type: ignore - snapshot.assert_match(err) + # Validate the result + assert not result.errors + assert result.data == { + "messageAdded": {"id": "some_id", "content": "message", "author": "Anon"} + } -def test_subscription_type_raises_error_when_defined_with_invalid_graphql_type_schema( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UsersSubscription(SubscriptionType): - __schema__ = "scalar Subscription" +@pytest.mark.asyncio +async def test_basic_many_subscription_without_schema(assert_schema_equals): + class SubscriptionType(GraphQLSubscription): + message_added: Message - snapshot.assert_match(err) + @GraphQLSubscription.source("message_added", graphql_type=Message) + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + @GraphQLSubscription.resolver("message_added") + @staticmethod + async def resolve_message_added(message, *_): + return message -def test_subscription_type_raises_error_when_defined_with_invalid_graphql_type_name( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UsersSubscription(SubscriptionType): - __schema__ = "type Other" + class SubscriptionSecondType(GraphQLSubscription): + message_added_second: Message - snapshot.assert_match(err) + @GraphQLSubscription.source("message_added_second", graphql_type=Message) + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + @GraphQLSubscription.resolver("message_added_second") + @staticmethod + async def resolve_message_added(message, *_): + return message -def test_subscription_type_raises_error_when_defined_without_fields(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class UsersSubscription(SubscriptionType): - __schema__ = "type Subscription" + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=str) + @staticmethod + def search_sth(*_) -> str: + return "search" - snapshot.assert_match(err) + schema = make_executable_schema(QueryType, SubscriptionType, SubscriptionSecondType) + assert_schema_equals( + schema, + """ + type Query { + searchSth: String! + } -def test_subscription_type_extracts_graphql_name(): - class UsersSubscription(SubscriptionType): - __schema__ = """ type Subscription { - thread: ID! + messageAdded: Message! + messageAddedSecond: Message! } - """ - assert UsersSubscription.graphql_name == "Subscription" + type Message { + id: ID! + content: String! + author: String! + } + """, + ) + query = parse("subscription { messageAdded {id content author} }") + sub = await subscribe(schema, query) -def test_subscription_type_raises_error_when_defined_without_return_type_dependency( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ChatSubscription(SubscriptionType): - __schema__ = """ - type Subscription { - chat: Chat - Chats: [Chat!] - } - """ + # Ensure the subscription is an async iterator + assert hasattr(sub, "__aiter__") - snapshot.assert_match(err) + # Fetch the first result + result = await sub.__anext__() # type: ignore + # Validate the result + assert not result.errors + assert result.data == { + "messageAdded": {"id": "some_id", "content": "message", "author": "Anon"} + } -def test_subscription_type_verifies_field_dependency(): - # pylint: disable=unused-variable - class ChatType(ObjectType): - __schema__ = """ - type Chat { - id: ID! - } + +@pytest.mark.asyncio +async def test_subscription_with_arguments_without_schema(assert_schema_equals): + class SubscriptionType(GraphQLSubscription): + @GraphQLSubscription.source( + "message_added", + args={"channel": GraphQLObject.argument(description="Lorem ipsum.")}, + graphql_type=Message, + ) + @staticmethod + async def message_added_generator(*_, channel: GraphQLID): + while True: + yield { + "id": "some_id", + "content": f"message_{channel}", + "author": "Anon", + } + + @GraphQLSubscription.field + @staticmethod + def message_added( + message, *_, channel: GraphQLID + ): # pylint: disable=unused-argument + return message + + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=str) + @staticmethod + def search_sth(*_) -> str: + return "search" + + schema = make_executable_schema(QueryType, SubscriptionType) + + assert_schema_equals( + schema, """ + type Query { + searchSth: String! + } - class ChatSubscription(SubscriptionType): - __schema__ = """ type Subscription { - chat: Chat - Chats: [Chat!] + messageAdded( + \"\"\"Lorem ipsum.\"\"\" + channel: ID! + ): Message! } - """ - __requires__ = [ChatType] + type Message { + id: ID! + content: String! + author: String! + } + """, + ) + + query = parse('subscription { messageAdded(channel: "123") {id content author} }') + sub = await subscribe(schema, query) + + # Ensure the subscription is an async iterator + assert hasattr(sub, "__aiter__") + + # Fetch the first result + result = await sub.__anext__() # type: ignore + + # Validate the result + assert not result.errors + assert result.data == { + "messageAdded": {"id": "some_id", "content": "message_123", "author": "Anon"} + } -def test_subscription_type_raises_error_when_defined_without_argument_type_dependency( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ChatSubscription(SubscriptionType): - __schema__ = """ - type Subscription { - chat(input: ChannelInput): [String!]! - } - """ - snapshot.assert_match(err) +@pytest.mark.asyncio +async def test_multiple_supscriptions_without_schema(assert_schema_equals): + class SubscriptionType(GraphQLSubscription): + message_added: Message + user_joined: User + + @GraphQLSubscription.source( + "message_added", + args={"channel": GraphQLObject.argument(description="Lorem ipsum.")}, + graphql_type=Message, + ) + @staticmethod + async def message_added_generator(*_, channel: GraphQLID): + while True: + yield { + "id": "some_id", + "content": f"message_{channel}", + "author": "Anon", + } + + @GraphQLSubscription.resolver( + "message_added", + ) + @staticmethod + async def resolve_message_added( + message, + *_, + channel: GraphQLID, # pylint: disable=unused-argument + ): + return message + + @GraphQLSubscription.source( + "user_joined", + graphql_type=Message, + ) + @staticmethod + async def user_joined_generator(*_): + while True: + yield { + "id": "some_id", + "username": "username", + } + + @GraphQLSubscription.resolver( + "user_joined", + ) + @staticmethod + async def resolve_user_joined(user, *_): + return user + + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=str) + @staticmethod + def search_sth(*_) -> str: + return "search" + schema = make_executable_schema(QueryType, SubscriptionType) + + assert_schema_equals( + schema, + """ + type Query { + searchSth: String! + } -def test_subscription_type_can_be_extended_with_new_fields(): - # pylint: disable=unused-variable - class ChatSubscription(SubscriptionType): - __schema__ = """ type Subscription { - chat: ID! + messageAdded( + \"\"\"Lorem ipsum.\"\"\" + channel: ID! + ): Message! + userJoined: User! } - """ - class ExtendChatSubscription(SubscriptionType): - __schema__ = """ - extend type Subscription { - thread: ID! + type Message { + id: ID! + content: String! + author: String! } - """ - __requires__ = [ChatSubscription] + type User { + id: ID! + username: String! + } + """, + ) + + query = parse("subscription { userJoined {id username} }") + sub = await subscribe(schema, query) -def test_subscription_type_can_be_extended_with_directive(): - # pylint: disable=unused-variable - class ExampleDirective(DirectiveType): - __schema__ = "directive @example on OBJECT" - __visitor__ = SchemaDirectiveVisitor + # Ensure the subscription is an async iterator + assert hasattr(sub, "__aiter__") + + # Fetch the first result + result = await sub.__anext__() # type: ignore + + # Validate the result + assert not result.errors + assert result.data == {"userJoined": {"id": "some_id", "username": "username"}} + + +@pytest.mark.asyncio +async def test_subscription_with_complex_data_without_schema(assert_schema_equals): + class SubscriptionType(GraphQLSubscription): + messages_in_channel: list[Message] + + @GraphQLSubscription.source( + "messages_in_channel", + args={"channel_id": GraphQLObject.argument(description="Lorem ipsum.")}, + graphql_type=list[Message], + ) + @staticmethod + async def message_added_generator(*_, channel_id: GraphQLID): + while True: + yield [ + { + "id": "some_id", + "content": f"message_{channel_id}", + "author": "Anon", + } + ] + + @GraphQLSubscription.resolver( + "messages_in_channel", + ) + @staticmethod + async def resolve_message_added( + message, *_, channel_id: GraphQLID + ): # pylint: disable=unused-argument + return message + + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=str) + @staticmethod + def search_sth(*_) -> str: + return "search" + + schema = make_executable_schema(QueryType, SubscriptionType) + + assert_schema_equals( + schema, + """ + type Query { + searchSth: String! + } - class ChatSubscription(SubscriptionType): - __schema__ = """ type Subscription { - chat: ID! + messagesInChannel( + \"\"\"Lorem ipsum.\"\"\" + channelId: ID! + ): [Message!]! } - """ - class ExtendChatSubscription(SubscriptionType): - __schema__ = "extend type Subscription @example" - __requires__ = [ChatSubscription, ExampleDirective] + type Message { + id: ID! + content: String! + author: String! + } + """, + ) + query = parse( + 'subscription { messagesInChannel(channelId: "123") {id content author} }' + ) + sub = await subscribe(schema, query) -def test_subscription_type_can_be_extended_with_interface(): - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = """ - interface Interface { - threads: ID! - } + # Ensure the subscription is an async iterator + assert hasattr(sub, "__aiter__") + + # Fetch the first result + result = await sub.__anext__() # type: ignore + + # Validate the result + assert not result.errors + assert result.data == { + "messagesInChannel": [ + {"id": "some_id", "content": "message_123", "author": "Anon"} + ] + } + + +@pytest.mark.asyncio +async def test_subscription_with_union_without_schema(assert_schema_equals): + class SubscriptionType(GraphQLSubscription): + notification_received: Notification + __description__ = "test test" + + @GraphQLSubscription.source( + "notification_received", description="hello", graphql_type=Message + ) + @staticmethod + async def message_added_generator(*_): + while True: + yield Message(id=1, content="content", author="anon") + + @GraphQLSubscription.resolver( + "notification_received", + ) + @staticmethod + async def resolve_message_added(message: Message, *_): + return message + + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=str) + @staticmethod + def search_sth(*_) -> str: + return "search" + + schema = make_executable_schema(QueryType, SubscriptionType) + + assert_schema_equals( + schema, """ + type Query { + searchSth: String! + } - class ChatSubscription(SubscriptionType): - __schema__ = """ + \"\"\"test test\"\"\" type Subscription { - chat: ID! + \"\"\"hello\"\"\" + notificationReceived: Notification! } - """ - class ExtendChatSubscription(SubscriptionType): - __schema__ = """ - extend type Subscription implements Interface { - threads: ID! + union Notification = Message | User + + type Message { + id: ID! + content: String! + author: String! } - """ - __requires__ = [ChatSubscription, ExampleInterface] - - -def test_subscription_type_raises_error_when_defined_without_extended_dependency( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExtendChatSubscription(SubscriptionType): - __schema__ = """ - extend type Subscription { - thread: ID! - } - """ - snapshot.assert_match(err) + type User { + id: ID! + username: String! + } + """, + ) + query = parse("subscription { notificationReceived { ... on Message { id } } }") + sub = await subscribe(schema, query) + + # Ensure the subscription is an async iterator + assert hasattr(sub, "__aiter__") + + # Fetch the first result + result = await sub.__anext__() # type: ignore + + # Validate the result + assert not result.errors + assert result.data == {"notificationReceived": {"id": "1"}} -def test_subscription_type_raises_error_when_extended_dependency_is_wrong_type( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleInterface(InterfaceType): - __schema__ = """ - interface Subscription { - id: ID! - } - """ - class ExtendChatSubscription(SubscriptionType): - __schema__ = """ - extend type Subscription { - thread: ID! +@pytest.mark.asyncio +async def test_basic_subscription_with_schema(assert_schema_equals): + class SubscriptionType(GraphQLSubscription): + __schema__ = gql( + """ + type Subscription { + messageAdded: Message! } """ - __requires__ = [ExampleInterface] + ) + + @GraphQLSubscription.source("messageAdded", graphql_type=Message) + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + @GraphQLSubscription.resolver("messageAdded") + @staticmethod + async def resolve_message_added(message, *_): + return message + + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=str) + @staticmethod + def search_sth(*_) -> str: + return "search" + + schema = make_executable_schema(QueryType, SubscriptionType, Message) - snapshot.assert_match(err) + assert_schema_equals( + schema, + """ + type Query { + searchSth: String! + } + type Subscription { + messageAdded: Message! + } -def test_subscription_type_raises_error_when_defined_with_alias_for_nonexisting_field( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ChatSubscription(SubscriptionType): - __schema__ = """ + type Message { + id: ID! + content: String! + author: String! + } + """, + ) + + query = parse("subscription { messageAdded {id content author} }") + sub = await subscribe(schema, query) + + # Ensure the subscription is an async iterator + assert hasattr(sub, "__aiter__") + + # Fetch the first result + result = await sub.__anext__() # type: ignore + + # Validate the result + assert not result.errors + assert result.data == { + "messageAdded": {"id": "some_id", "content": "message", "author": "Anon"} + } + + +@pytest.mark.asyncio +async def test_subscription_with_arguments_with_schema(assert_schema_equals): + class SubscriptionType(GraphQLSubscription): + __schema__ = gql( + """ type Subscription { - chat: ID! + messageAdded(channel: ID!): Message! } """ - __aliases__ = { - "userAlerts": "user_alerts", - } + ) + + @GraphQLSubscription.source( + "messageAdded", + graphql_type=Message, + ) + @staticmethod + async def message_added_generator(*_, channel: GraphQLID): + while True: + yield { + "id": "some_id", + "content": f"message_{channel}", + "author": "Anon", + } + + @GraphQLSubscription.resolver( + "messageAdded", + ) + @staticmethod + async def resolve_message_added( + message, *_, channel: GraphQLID + ): # pylint: disable=unused-argument + return message - snapshot.assert_match(err) + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=str) + @staticmethod + def search_sth(*_) -> str: + return "search" + + schema = make_executable_schema(QueryType, SubscriptionType, Message) + + assert_schema_equals( + schema, + """ + type Query { + searchSth: String! + } + + type Subscription { + messageAdded(channel: ID!): Message! + } + + type Message { + id: ID! + content: String! + author: String! + } + """, + ) + query = parse('subscription { messageAdded(channel: "123") {id content author} }') + sub = await subscribe(schema, query) -def test_subscription_type_raises_error_when_defined_with_resolver_for_nonexisting_field( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ChatSubscription(SubscriptionType): - __schema__ = """ + # Ensure the subscription is an async iterator + assert hasattr(sub, "__aiter__") + + # Fetch the first result + result = await sub.__anext__() # type: ignore + + # Validate the result + assert not result.errors + assert result.data == { + "messageAdded": {"id": "some_id", "content": "message_123", "author": "Anon"} + } + + +@pytest.mark.asyncio +async def test_multiple_supscriptions_with_schema(assert_schema_equals): + class SubscriptionType(GraphQLSubscription): + __schema__ = gql( + """ type Subscription { - chat: ID! + messageAdded: Message! + userJoined: User! } """ + ) - @staticmethod - def resolve_group(*_): - return None + @GraphQLSubscription.source( + "messageAdded", + ) + @staticmethod + async def message_added_generator(*_): + while True: + yield { + "id": "some_id", + "content": "message", + "author": "Anon", + } + + @GraphQLSubscription.resolver( + "messageAdded", + ) + @staticmethod + async def resolve_message_added(message, *_): + return message - snapshot.assert_match(err) + @GraphQLSubscription.source( + "userJoined", + ) + @staticmethod + async def user_joined_generator(*_): + while True: + yield { + "id": "some_id", + "username": "username", + } + + @GraphQLSubscription.resolver( + "userJoined", + ) + @staticmethod + async def resolve_user_joined(user, *_): + return user + + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=str) + @staticmethod + def search_sth(*_) -> str: + return "search" + schema = make_executable_schema(QueryType, SubscriptionType, Message, User) -def test_subscription_type_raises_error_when_defined_with_sub_for_nonexisting_field( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ChatSubscription(SubscriptionType): - __schema__ = """ + assert_schema_equals( + schema, + """ + type Query { + searchSth: String! + } + + type Subscription { + messageAdded: Message! + userJoined: User! + } + + type Message { + id: ID! + content: String! + author: String! + } + + type User { + id: ID! + username: String! + } + """, + ) + + query = parse("subscription { userJoined {id username} }") + sub = await subscribe(schema, query) + + # Ensure the subscription is an async iterator + assert hasattr(sub, "__aiter__") + + # Fetch the first result + result = await sub.__anext__() # type: ignore + + # Validate the result + assert not result.errors + assert result.data == {"userJoined": {"id": "some_id", "username": "username"}} + + +@pytest.mark.asyncio +async def test_subscription_with_complex_data_with_schema(assert_schema_equals): + class SubscriptionType(GraphQLSubscription): + __schema__ = gql( + """ type Subscription { - chat: ID! + messagesInChannel(channelId: ID!): [Message!]! } """ + ) - @staticmethod - def subscribe_group(*_): - return None + @GraphQLSubscription.source( + "messagesInChannel", + ) + @staticmethod + async def message_added_generator(*_, channel_id: GraphQLID): + while True: + yield [ + { + "id": "some_id", + "content": f"message_{channel_id}", + "author": "Anon", + } + ] + + @GraphQLSubscription.resolver( + "messagesInChannel", + ) + @staticmethod + async def resolve_message_added( + message, *_, channel_id: GraphQLID + ): # pylint: disable=unused-argument + return message - snapshot.assert_match(err) + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=str) + @staticmethod + def search_sth(*_) -> str: + return "search" + schema = make_executable_schema( + QueryType, SubscriptionType, Message, convert_names_case=True + ) -def test_subscription_type_binds_resolver_and_subscriber_to_schema(): - schema = build_schema( + assert_schema_equals( + schema, """ - type Query { - hello: String - } + type Query { + searchSth: String! + } + + type Subscription { + messagesInChannel(channelId: ID!): [Message!]! + } + + type Message { + id: ID! + content: String! + author: String! + } + """, + ) + + query = parse( + 'subscription { messagesInChannel(channelId: "123") {id content author} }' + ) + sub = await subscribe(schema, query) + + # Ensure the subscription is an async iterator + assert hasattr(sub, "__aiter__") + + # Fetch the first result + result = await sub.__anext__() # type: ignore + # Validate the result + assert not result.errors + assert result.data == { + "messagesInChannel": [ + {"id": "some_id", "content": "message_123", "author": "Anon"} + ] + } + + +@pytest.mark.asyncio +async def test_subscription_with_union_with_schema(assert_schema_equals): + class SubscriptionType(GraphQLSubscription): + __schema__ = gql( + """ type Subscription { - chat: ID! + notificationReceived(channel: String): Notification! + name: String } + """ + ) + __aliases__ = {"name": "title"} + + @GraphQLSubscription.resolver("notificationReceived") + @staticmethod + async def resolve_message_added( + message, + *_, + channel: str, # pylint: disable=unused-argument + ): + return message + + @GraphQLSubscription.source( + "notificationReceived", + description="my description", + args={ + "channel": GraphQLObject.argument( + description="Lorem ipsum.", default_value="123" + ) + }, + ) + @staticmethod + async def message_added_generator( + *_, + channel: str, # pylint: disable=unused-argument + ): + while True: + yield Message(id=1, content="content", author="anon") + + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=str) + @staticmethod + def search_sth(*_) -> str: + return "search" + + schema = make_executable_schema(QueryType, SubscriptionType, Notification) + + assert_schema_equals( + schema, """ - ) + type Query { + searchSth: String! + } - class ChatSubscription(SubscriptionType): - __schema__ = """ type Subscription { - chat: ID! + \"\"\"my description\"\"\" + notificationReceived( + \"\"\"Lorem ipsum.\"\"\" + channel: String = "123" + ): Notification! + name: String } - """ + union Notification = Message | User + + type Message { + id: ID! + content: String! + author: String! + } + + type User { + id: ID! + username: String! + } + """, + ) + + query = parse( + 'subscription { notificationReceived(channel: "hello") ' + "{ ... on Message { id } } }" + ) + sub = await subscribe(schema, query) + + # Ensure the subscription is an async iterator + assert hasattr(sub, "__aiter__") + + # Fetch the first result + result = await sub.__anext__() # type: ignore + + # Validate the result + assert not result.errors + assert result.data == {"notificationReceived": {"id": "1"}} + + +@pytest.mark.asyncio +async def test_subscription_descriptions_without_schema(assert_schema_equals): + class SubscriptionType(GraphQLSubscription): + notification_received: Notification + __description__ = "test test" + + @GraphQLSubscription.source( + "notification_received", description="hello", graphql_type=Message + ) @staticmethod - def resolve_chat(*_): - return None + async def message_added_generator(*_): + while True: + yield Message(id=1, content="content", author="anon") + @GraphQLSubscription.resolver( + "notification_received", + ) @staticmethod - def subscribe_chat(*_): - return None + async def resolve_message_added(message: Message, *_): + return message + + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=str) + @staticmethod + def search_sth(*_) -> str: + return "search" + + schema = make_executable_schema(QueryType, SubscriptionType) + + assert_schema_equals( + schema, + """ + type Query { + searchSth: String! + } + + \"\"\"test test\"\"\" + type Subscription { + \"\"\"hello\"\"\" + notificationReceived: Notification! + } + + union Notification = Message | User + + type Message { + id: ID! + content: String! + author: String! + } + + type User { + id: ID! + username: String! + } + """, + ) + + query = parse("subscription { notificationReceived { ... on Message { id } } }") + sub = await subscribe(schema, query) + + # Ensure the subscription is an async iterator + assert hasattr(sub, "__aiter__") - ChatSubscription.__bind_to_schema__(schema) + # Fetch the first result + result = await sub.__anext__() # type: ignore - field = schema.type_map.get("Subscription").fields["chat"] - assert field.resolve is ChatSubscription.resolve_chat - assert field.subscribe is ChatSubscription.subscribe_chat + # Validate the result + assert not result.errors + assert result.data == {"notificationReceived": {"id": "1"}} diff --git a/tests/test_subscription_type_validation.py b/tests/test_subscription_type_validation.py new file mode 100644 index 0000000..4d8ebbb --- /dev/null +++ b/tests/test_subscription_type_validation.py @@ -0,0 +1,321 @@ +# pylint: disable=unused-variable +import pytest +from ariadne import gql + +from ariadne_graphql_modules import ( + GraphQLID, + GraphQLObject, + GraphQLSubscription, + GraphQLUnion, +) + + +class Message(GraphQLObject): + id: GraphQLID + content: str + author: str + + +class User(GraphQLObject): + id: GraphQLID + username: str + + +class Notification(GraphQLUnion): + __types__ = [Message, User] + + +@pytest.mark.asyncio +async def test_undefined_name_without_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class SubscriptionType(GraphQLSubscription): + message_added: Message + + @GraphQLSubscription.source("messageAdded") + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + data_regression.check(str(exc_info.value)) + + +@pytest.mark.asyncio +async def test_field_name_not_str_without_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class SubscriptionType(GraphQLSubscription): + message_added: Message + + @GraphQLSubscription.source(23) # type: ignore + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + data_regression.check(str(exc_info.value)) + + +@pytest.mark.asyncio +async def test_multiple_sources_without_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class SubscriptionType(GraphQLSubscription): + message_added: Message + + @GraphQLSubscription.source("message_added") + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + @GraphQLSubscription.source("message_added") + @staticmethod + async def message_added_generator_2(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + data_regression.check(str(exc_info.value)) + + +@pytest.mark.asyncio +async def test_description_not_str_without_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class SubscriptionType(GraphQLSubscription): + message_added: Message + + @GraphQLSubscription.source("message_added", description=12) # type: ignore + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + data_regression.check(str(exc_info.value)) + + +@pytest.mark.asyncio +async def test_source_args_field_arg_not_dict_without_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class SubscriptionType(GraphQLSubscription): + message_added: Message + + @GraphQLSubscription.source( + "message_added", + args={"channel": 123}, # type: ignore + ) + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + data_regression.check(str(exc_info.value)) + + +@pytest.mark.asyncio +async def test_source_args_not_dict_without_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class SubscriptionType(GraphQLSubscription): + message_added: Message + + @GraphQLSubscription.source( + "message_added", + args=123, # type: ignore + ) + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + data_regression.check(str(exc_info.value)) + + +@pytest.mark.asyncio +async def test_source_for_undefined_field_with_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class SubscriptionType(GraphQLSubscription): + __schema__ = gql( + """ + type Subscription { + messageAdded: Message! + } + """ + ) + + @GraphQLSubscription.source("message_added") + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + data_regression.check(str(exc_info.value)) + + +@pytest.mark.asyncio +async def test_multiple_sourced_for_field_with_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class SubscriptionType(GraphQLSubscription): + __schema__ = gql( + """ + type Subscription { + messageAdded: Message! + } + """ + ) + + @GraphQLSubscription.source("messageAdded") + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + @GraphQLSubscription.source("messageAdded") + @staticmethod + async def message_added_generator_2(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + data_regression.check(str(exc_info.value)) + + +@pytest.mark.asyncio +async def test_multiple_descriptions_for_source_with_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class SubscriptionType(GraphQLSubscription): + __schema__ = gql( + """ + type Subscription { + \"\"\"Hello!\"\"\" + messageAdded: Message! + } + """ + ) + + @GraphQLSubscription.source("messageAdded", description="hello") + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + data_regression.check(str(exc_info.value)) + + +@pytest.mark.asyncio +async def test_invalid_arg_name_in_source_with_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class SubscriptionType(GraphQLSubscription): + __schema__ = gql( + """ + type Subscription { + messageAdded(channel: ID!): Message! + } + """ + ) + + @GraphQLSubscription.source( + "messageAdded", + args={"channelID": GraphQLObject.argument(description="Lorem ipsum.")}, + ) + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + data_regression.check(str(exc_info.value)) + + +@pytest.mark.asyncio +async def test_arg_with_name_in_source_with_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class SubscriptionType(GraphQLSubscription): + __schema__ = gql( + """ + type Subscription { + messageAdded(channel: ID!): Message! + } + """ + ) + + @GraphQLSubscription.source( + "messageAdded", + args={ + "channel": GraphQLObject.argument( + name="channelID", description="Lorem ipsum." + ) + }, + ) + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + data_regression.check(str(exc_info.value)) + + +@pytest.mark.asyncio +async def test_arg_with_type_in_source_with_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class SubscriptionType(GraphQLSubscription): + __schema__ = gql( + """ + type Subscription { + messageAdded(channel: ID!): Message! + } + """ + ) + + @GraphQLSubscription.source( + "messageAdded", + args={ + "channel": GraphQLObject.argument( + graphql_type=str, description="Lorem ipsum." + ) + }, + ) + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + data_regression.check(str(exc_info.value)) + + +@pytest.mark.asyncio +async def test_arg_with_description_in_source_with_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class SubscriptionType(GraphQLSubscription): + __schema__ = gql( + """ + type Subscription { + messageAdded( + \"\"\"Lorem ipsum.\"\"\" + channel: ID! + ): Message! + } + """ + ) + + @GraphQLSubscription.source( + "messageAdded", + args={ + "channel": GraphQLObject.argument( + graphql_type=str, description="Lorem ipsum." + ) + }, + ) + @staticmethod + async def message_added_generator(*_): + while True: + yield {"id": "some_id", "content": "message", "author": "Anon"} + + data_regression.check(str(exc_info.value)) diff --git a/tests/test_typing.py b/tests/test_typing.py new file mode 100644 index 0000000..e7365ea --- /dev/null +++ b/tests/test_typing.py @@ -0,0 +1,181 @@ +# pylint: disable=no-member, unsupported-binary-operation +import sys +from enum import Enum +from typing import TYPE_CHECKING, Annotated, Optional, Union + +import pytest +from graphql import ListTypeNode, NamedTypeNode, NameNode, NonNullTypeNode + +from ariadne_graphql_modules import GraphQLObject, deferred, graphql_enum +from ariadne_graphql_modules.typing import get_graphql_type, get_type_node + +if TYPE_CHECKING: + from .types import ForwardEnum, ForwardScalar + + +def assert_non_null_type(type_node, name: str): + assert isinstance(type_node, NonNullTypeNode) + assert_named_type(type_node.type, name) + + +def assert_non_null_list_type(type_node, name: str): + assert isinstance(type_node, NonNullTypeNode) + assert isinstance(type_node.type, ListTypeNode) + assert isinstance(type_node.type.type, NonNullTypeNode) + assert_named_type(type_node.type.type.type, name) + + +def assert_list_type(type_node, name: str): + assert isinstance(type_node, ListTypeNode) + assert isinstance(type_node.type, NonNullTypeNode) + assert_named_type(type_node.type.type, name) + + +def assert_named_type(type_node, name: str): + assert isinstance(type_node, NamedTypeNode) + assert isinstance(type_node.name, NameNode) + assert type_node.name.value == name + + +def test_get_graphql_type_from_python_builtin_type_returns_none(): + assert get_graphql_type(Optional[str]) is None + assert get_graphql_type(Union[int, None]) is None + assert get_graphql_type(Optional[bool]) is None + + +@pytest.mark.skipif( + sys.version_info >= (3, 9) and sys.version_info < (3, 10), + reason="Skip test for Python 3.9", +) +def test_get_graphql_type_from_python_builtin_type_returns_none_pipe_union(): + assert get_graphql_type(float | None) is None + + +def test_get_graphql_type_from_graphql_type_subclass_returns_type(): + class UserType(GraphQLObject): ... + + assert get_graphql_type(UserType) == UserType + assert get_graphql_type(Optional[UserType]) == UserType + assert get_graphql_type(list[UserType]) == UserType + assert get_graphql_type(Optional[list[Optional[UserType]]]) == UserType + + +def test_get_graphql_type_from_enum_returns_type(): + class UserLevel(Enum): + GUEST = 0 + MEMBER = 1 + MODERATOR = 2 + ADMINISTRATOR = 3 + + assert get_graphql_type(UserLevel) == UserLevel + assert get_graphql_type(Optional[UserLevel]) == UserLevel + assert get_graphql_type(list[UserLevel]) == UserLevel + assert get_graphql_type(Optional[list[Optional[UserLevel]]]) == UserLevel + + +def test_get_graphql_type_node_from_python_builtin_type(metadata): + assert_named_type(get_type_node(metadata, Optional[str]), "String") + assert_named_type(get_type_node(metadata, Union[int, None]), "Int") + assert_named_type(get_type_node(metadata, Optional[bool]), "Boolean") + + +@pytest.mark.skipif( + sys.version_info >= (3, 9) and sys.version_info < (3, 10), + reason="Skip test for Python 3.9", +) +def test_get_graphql_type_node_from_python_builtin_type_pipe_union(metadata): + assert_named_type(get_type_node(metadata, float | None), "Float") + + +def test_get_non_null_graphql_type_node_from_python_builtin_type(metadata): + assert_non_null_type(get_type_node(metadata, str), "String") + assert_non_null_type(get_type_node(metadata, int), "Int") + assert_non_null_type(get_type_node(metadata, float), "Float") + assert_non_null_type(get_type_node(metadata, bool), "Boolean") + + +def test_get_graphql_type_node_from_graphql_type(metadata): + class UserType(GraphQLObject): ... + + assert_non_null_type(get_type_node(metadata, UserType), "User") + assert_named_type(get_type_node(metadata, Optional[UserType]), "User") + + +def test_get_graphql_list_type_node_from_python_builtin_type(metadata): + assert_list_type(get_type_node(metadata, Optional[list[str]]), "String") + assert_list_type(get_type_node(metadata, Union[list[int], None]), "Int") + + assert_list_type(get_type_node(metadata, Optional[list[bool]]), "Boolean") + + +@pytest.mark.skipif( + sys.version_info >= (3, 9) and sys.version_info < (3, 10), + reason="Skip test for Python 3.9", +) +def test_get_graphql_list_type_node_from_python_builtin_type_pipe_union(metadata): + assert_list_type(get_type_node(metadata, list[float] | None), "Float") + + +def test_get_non_null_graphql_list_type_node_from_python_builtin_type(metadata): + assert_non_null_list_type(get_type_node(metadata, list[str]), "String") + assert_non_null_list_type(get_type_node(metadata, list[int]), "Int") + assert_non_null_list_type(get_type_node(metadata, list[float]), "Float") + assert_non_null_list_type(get_type_node(metadata, list[bool]), "Boolean") + + +def test_get_graphql_type_node_from_annotated_type(metadata): + class MockType(GraphQLObject): + custom_field: Annotated["ForwardScalar", deferred("tests.types")] + + assert_non_null_type( + get_type_node(metadata, MockType.__annotations__["custom_field"]), "Forward" + ) + + +def test_get_graphql_type_node_from_annotated_type_with_relative_path(metadata): + class MockType(GraphQLObject): + custom_field: Annotated["ForwardScalar", deferred(".types")] + + assert_non_null_type( + get_type_node(metadata, MockType.__annotations__["custom_field"]), "Forward" + ) + + +def test_get_graphql_type_node_from_nullable_annotated_type(metadata): + class MockType(GraphQLObject): + custom_field: Optional[Annotated["ForwardScalar", deferred("tests.types")]] + + assert_named_type( + get_type_node(metadata, MockType.__annotations__["custom_field"]), "Forward" + ) + + +def test_get_graphql_type_node_from_annotated_enum(metadata): + class MockType(GraphQLObject): + custom_field: Annotated["ForwardEnum", deferred("tests.types")] + + assert_non_null_type( + get_type_node(metadata, MockType.__annotations__["custom_field"]), "ForwardEnum" + ) + + +def test_get_graphql_type_node_from_enum_type(metadata): + class UserLevel(Enum): + GUEST = 0 + MEMBER = 1 + MODERATOR = 2 + ADMINISTRATOR = 3 + + assert_non_null_type(get_type_node(metadata, UserLevel), "UserLevel") + assert_named_type(get_type_node(metadata, Optional[UserLevel]), "UserLevel") + + +def test_get_graphql_type_node_from_annotated_enum_type(metadata): + @graphql_enum(name="SeverityEnum") + class SeverityLevel(Enum): + LOW = 0 + MEDIUM = 1 + HIGH = 2 + + assert_non_null_type(get_type_node(metadata, SeverityLevel), "SeverityEnum") + assert_named_type(get_type_node(metadata, Optional[SeverityLevel]), "SeverityEnum") diff --git a/tests/test_union_type.py b/tests/test_union_type.py index 5b1f30f..5104e9b 100644 --- a/tests/test_union_type.py +++ b/tests/test_union_type.py @@ -1,243 +1,228 @@ -from dataclasses import dataclass +from typing import Union -import pytest -from ariadne import SchemaDirectiveVisitor -from graphql import GraphQLError, graphql_sync +from graphql import graphql_sync from ariadne_graphql_modules import ( - DirectiveType, - ObjectType, - UnionType, + GraphQLID, + GraphQLObject, + GraphQLUnion, make_executable_schema, ) -def test_union_type_raises_attribute_error_when_defined_without_schema(snapshot): - with pytest.raises(AttributeError) as err: - # pylint: disable=unused-variable - class ExampleUnion(UnionType): - pass +class UserType(GraphQLObject): + id: GraphQLID + username: str - snapshot.assert_match(err) +class CommentType(GraphQLObject): + id: GraphQLID + content: str -def test_union_type_raises_error_when_defined_with_invalid_schema_type(snapshot): - with pytest.raises(TypeError) as err: - # pylint: disable=unused-variable - class ExampleUnion(UnionType): - __schema__ = True - snapshot.assert_match(err) +def test_union_field_returning_object_instance(assert_schema_equals): + class ResultType(GraphQLUnion): + __types__ = [UserType, CommentType] + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=list[ResultType]) + @staticmethod + def search(*_) -> list[Union[UserType, CommentType]]: + return [ + UserType(id=1, username="Bob"), + CommentType(id=2, content="Hello World!"), + ] -def test_union_type_raises_error_when_defined_with_invalid_schema_str(snapshot): - with pytest.raises(GraphQLError) as err: - # pylint: disable=unused-variable - class ExampleUnion(UnionType): - __schema__ = "unien Example = A | B" - - snapshot.assert_match(err) - - -def test_union_type_raises_error_when_defined_with_invalid_graphql_type_schema( - snapshot, -): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleUnion(UnionType): - __schema__ = "scalar DateTime" - - snapshot.assert_match(err) - - -def test_union_type_raises_error_when_defined_with_multiple_types_schema(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleUnion(UnionType): - __schema__ = """ - union A = C | D - - union B = C | D - """ - - snapshot.assert_match(err) - - -@dataclass -class User: - id: int - name: str + schema = make_executable_schema(QueryType) + assert_schema_equals( + schema, + """ + type Query { + search: [Result!]! + } -@dataclass -class Comment: - id: int - message: str + union Result = User | Comment + type User { + id: ID! + username: String! + } -class UserType(ObjectType): - __schema__ = """ - type User { - id: ID! - name: String! - } - """ + type Comment { + id: ID! + content: String! + } + """, + ) + result = graphql_sync( + schema, + """ + { + search { + ... on User { + id + username + } + ... on Comment { + id + content + } + } + } + """, + ) -class CommentType(ObjectType): - __schema__ = """ - type Comment { - id: ID! - message: String! + assert not result.errors + assert result.data == { + "search": [ + {"id": "1", "username": "Bob"}, + {"id": "2", "content": "Hello World!"}, + ] } - """ -class ResultUnion(UnionType): - __schema__ = "union Result = Comment | User" - __requires__ = [CommentType, UserType] +def test_union_field_returning_empty_list(): + class ResultType(GraphQLUnion): + __types__ = [UserType, CommentType] - @staticmethod - def resolve_type(instance, *_): - if isinstance(instance, Comment): - return "Comment" + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=list[ResultType]) + @staticmethod + def search(*_) -> list[Union[UserType, CommentType]]: + return [] - if isinstance(instance, User): - return "User" - - return None + schema = make_executable_schema(QueryType) + result = graphql_sync( + schema, + """ + { + search { + ... on User { + id + username + } + ... on Comment { + id + content + } + } + } + """, + ) + assert not result.errors + assert result.data == {"search": []} -class QueryType(ObjectType): - __schema__ = """ - type Query { - results: [Result!]! - } - """ - __requires__ = [ResultUnion] - - @staticmethod - def resolve_results(*_): - return [ - User(id=1, name="Alice"), - Comment(id=1, message="Hello world!"), - ] +def test_union_field_with_invalid_type_access(): + class ResultType(GraphQLUnion): + __types__ = [UserType, CommentType] -schema = make_executable_schema(QueryType, UserType, CommentType) + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=list[ResultType]) + @staticmethod + def search(*_) -> list[Union[UserType, CommentType]]: + return [ + UserType(id=1, username="Bob"), + "InvalidType", # type: ignore + ] + schema = make_executable_schema(QueryType) -def test_union_type_extracts_graphql_name(): - class ExampleUnion(UnionType): - __schema__ = "union Example = User | Comment" - __requires__ = [UserType, CommentType] + result = graphql_sync( + schema, + """ + { + search { + ... on User { + id + username + } + ... on Comment { + id + content + } + } + } + """, + ) + assert result.errors + assert "InvalidType" in str(result.errors) - assert ExampleUnion.graphql_name == "Example" +def test_serialization_error_handling(): + class InvalidType: + def __init__(self, value): + self.value = value -def test_union_type_raises_error_when_defined_without_member_type_dependency(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleUnion(UnionType): - __schema__ = "union Example = User | Comment" - __requires__ = [UserType] + class ResultType(GraphQLUnion): + __types__ = [UserType, CommentType] - snapshot.assert_match(err) + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=list[ResultType]) + @staticmethod + def search(*_) -> list[Union[UserType, CommentType, InvalidType]]: + return [InvalidType("This should cause an error")] + schema = make_executable_schema(QueryType) -def test_interface_type_binds_type_resolver(): - query = """ - query { - results { - ... on User { - __typename - id - name - } - ... on Comment { - __typename - id - message + result = graphql_sync( + schema, + """ + { + search { + ... on User { + id + username + } } } - } - """ + """, + ) + assert result.errors - result = graphql_sync(schema, query) - assert result.data == { - "results": [ - { - "__typename": "User", - "id": "1", - "name": "Alice", - }, - { - "__typename": "Comment", - "id": "1", - "message": "Hello world!", - }, - ], - } - - -def test_union_type_can_be_extended_with_new_types(): - # pylint: disable=unused-variable - class ExampleUnion(UnionType): - __schema__ = "union Result = User | Comment" - __requires__ = [UserType, CommentType] - class ThreadType(ObjectType): +def test_union_with_schema_definition(): + class SearchResultUnion(GraphQLUnion): __schema__ = """ - type Thread { - id: ID! - title: String! - } + union SearchResult = User | Comment """ + __types__ = [UserType, CommentType] - class ExtendExampleUnion(UnionType): - __schema__ = "union Result = Thread" - __requires__ = [ExampleUnion, ThreadType] - - -def test_union_type_can_be_extended_with_directive(): - # pylint: disable=unused-variable - class ExampleDirective(DirectiveType): - __schema__ = "directive @example on UNION" - __visitor__ = SchemaDirectiveVisitor + class QueryType(GraphQLObject): + @GraphQLObject.field(graphql_type=list[SearchResultUnion]) + @staticmethod + def search(*_) -> list[Union[UserType, CommentType]]: + return [ + UserType(id="1", username="Alice"), + CommentType(id="2", content="Test post"), + ] - class ExampleUnion(UnionType): - __schema__ = "union Result = User | Comment" - __requires__ = [UserType, CommentType] + schema = make_executable_schema(QueryType, SearchResultUnion) - class ExtendExampleUnion(UnionType): - __schema__ = """ - extend union Result @example + result = graphql_sync( + schema, """ - __requires__ = [ExampleUnion, ExampleDirective] - - -def test_union_type_raises_error_when_defined_without_extended_dependency(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExtendExampleUnion(UnionType): - __schema__ = "extend union Result = User" - __requires__ = [UserType] - - snapshot.assert_match(err) - - -def test_interface_type_raises_error_when_extended_dependency_is_wrong_type(snapshot): - with pytest.raises(ValueError) as err: - # pylint: disable=unused-variable - class ExampleType(ObjectType): - __schema__ = """ - type Example { - id: ID! + { + search { + ... on User { + id + username + } + ... on Comment { + id + content + } } - """ - - class ExtendExampleUnion(UnionType): - __schema__ = "extend union Example = User" - __requires__ = [ExampleType, UserType] - - snapshot.assert_match(err) + } + """, + ) + assert not result.errors + assert result.data == { + "search": [ + {"id": "1", "username": "Alice"}, + {"id": "2", "content": "Test post"}, + ] + } diff --git a/tests/test_union_type_validation.py b/tests/test_union_type_validation.py new file mode 100644 index 0000000..d54c4ff --- /dev/null +++ b/tests/test_union_type_validation.py @@ -0,0 +1,64 @@ +import pytest + +from ariadne_graphql_modules import ( + GraphQLID, + GraphQLObject, + GraphQLUnion, +) +from ariadne_graphql_modules.union_type.validators import ( + validate_union_type_with_schema, +) + + +class UserType(GraphQLObject): + id: GraphQLID + username: str + + +class CommentType(GraphQLObject): + id: GraphQLID + content: str + + +class PostType(GraphQLObject): + id: GraphQLID + content: str + + +def test_missing_type_in_schema(data_regression): + with pytest.raises(ValueError) as exc_info: + + class MyUnion(GraphQLUnion): + __types__ = [UserType, CommentType, PostType] + __schema__ = """ + union MyUnion = User + """ + + validate_union_type_with_schema(MyUnion) + data_regression.check(str(exc_info.value)) + + +def test_missing_type_in_types(data_regression): + with pytest.raises(ValueError) as exc_info: + + class MyUnion(GraphQLUnion): + __types__ = [UserType] + __schema__ = """ + union MyUnion = User | Comment + """ + + validate_union_type_with_schema(MyUnion) + data_regression.check(str(exc_info.value)) + + +def test_all_types_present(): + class MyUnion(GraphQLUnion): + __types__ = [UserType, CommentType] + __schema__ = """ + union MyUnion = User | Comment + """ + + try: + validate_union_type_with_schema(MyUnion) + except ValueError as e: + pytest.fail(f"Unexpected ValueError raised: {e}") diff --git a/tests/test_validators.py b/tests/test_validators.py new file mode 100644 index 0000000..8690f82 --- /dev/null +++ b/tests/test_validators.py @@ -0,0 +1,67 @@ +import pytest +from graphql import parse + +from ariadne_graphql_modules.validators import validate_description, validate_name + + +def test_description_validator_passes_type_without_description(): + class CustomType: + pass + + validate_description(CustomType, parse("scalar Custom").definitions[0]) # type: ignore + + +def test_description_validator_passes_type_with_description_attr(): + class CustomType: + __description__ = "Example scalar" + + validate_description(CustomType, parse("scalar Custom").definitions[0]) # type: ignore + + +def test_description_validator_raises_error_for_type_with_two_descriptions( + data_regression, +): + with pytest.raises(ValueError) as exc_info: + + class CustomType: + __description__ = "Example scalar" + + validate_description( + CustomType, # type: ignore + parse( + """ + \"\"\"Lorem ipsum\"\"\" + scalar Custom + """ + ).definitions[0], + ) + + data_regression.check(str(exc_info.value)) + + +def test_name_validator_passes_type_without_explicit_name(): + class CustomType: + pass + + validate_name(CustomType, parse("type Custom").definitions[0]) # type: ignore + + +def test_name_validator_passes_type_with_graphql_name_attr_matching_definition(): + class CustomType: + __graphql_name__ = "Custom" + + validate_name(CustomType, parse("type Custom").definitions[0]) # type: ignore + + +def test_name_validator_raises_error_for_name_and_definition_mismatch(data_regression): + with pytest.raises(ValueError) as exc_info: + + class CustomType: + __graphql_name__ = "Example" + + validate_name( + CustomType, # type: ignore + parse("type Custom").definitions[0], + ) + + data_regression.check(str(exc_info.value)) diff --git a/tests/test_value_node.py b/tests/test_value_node.py new file mode 100644 index 0000000..e557438 --- /dev/null +++ b/tests/test_value_node.py @@ -0,0 +1,160 @@ +from decimal import Decimal +from enum import Enum + +import pytest +from graphql import ( + BooleanValueNode, + ConstListValueNode, + ConstObjectValueNode, + EnumValueNode, + FloatValueNode, + IntValueNode, + NullValueNode, + StringValueNode, + print_ast, +) + +from ariadne_graphql_modules import get_value_from_node, get_value_node + + +def test_get_false_value(): + node = get_value_node(False) + assert isinstance(node, BooleanValueNode) + assert print_ast(node) == "false" + + +def test_get_true_value(): + node = get_value_node(True) + assert isinstance(node, BooleanValueNode) + assert print_ast(node) == "true" + + +class PlainEnum(Enum): + VALUE = "ok" + + +class IntEnum(int, Enum): + VALUE = 21 + + +class StrEnum(str, Enum): + VALUE = "val" + + +def test_get_enum_value(): + node = get_value_node(PlainEnum.VALUE) + assert isinstance(node, EnumValueNode) + assert print_ast(node) == "VALUE" + + +def test_get_int_enum_value(): + node = get_value_node(IntEnum.VALUE) + assert isinstance(node, EnumValueNode) + assert print_ast(node) == "VALUE" + + +def test_get_str_enum_value(): + node = get_value_node(StrEnum.VALUE) + assert isinstance(node, EnumValueNode) + assert print_ast(node) == "VALUE" + + +def test_get_float_value(): + node = get_value_node(2.5) + assert isinstance(node, FloatValueNode) + assert print_ast(node) == "2.5" + + +def test_get_decimal_value(): + node = get_value_node(Decimal("2.33")) + assert isinstance(node, FloatValueNode) + assert print_ast(node) == "2.33" + + +def test_get_int_value(): + node = get_value_node(42) + assert isinstance(node, IntValueNode) + assert print_ast(node) == "42" + + +def test_get_str_value(): + node = get_value_node("Hello") + assert isinstance(node, StringValueNode) + assert print_ast(node) == '"Hello"' + + +def test_get_str_block_value(): + node = get_value_node("Hello\nWorld!") + assert isinstance(node, StringValueNode) + assert print_ast(node) == '"""\nHello\nWorld!\n"""' + + +def test_get_none_value(): + node = get_value_node(None) + assert isinstance(node, NullValueNode) + assert print_ast(node) == "null" + + +def test_get_list_value(): + node = get_value_node([1, 3, None]) + assert isinstance(node, ConstListValueNode) + assert print_ast(node) == "[1, 3, null]" + + +def test_get_tuple_value(): + node = get_value_node((1, 3, None)) + assert isinstance(node, ConstListValueNode) + assert print_ast(node) == "[1, 3, null]" + + +def test_get_dict_value(): + node = get_value_node({"a": 1, "c": 3, "d": None}) + assert isinstance(node, ConstObjectValueNode) + assert print_ast(node) == "{a: 1, c: 3, d: null}" + + +def test_type_error_is_raised_for_unsupported_python_value(): + class CustomType: + pass + + with pytest.raises(TypeError) as exc_info: + get_value_node(CustomType()) + + error_message = str(exc_info.value) + assert error_message.startswith("Python value '' can't be represented as a GraphQL value node.") + + +def test_get_false_value_from_node(): + value = get_value_from_node(BooleanValueNode(value=False)) + assert value is False + + +def test_get_true_value_from_node(): + value = get_value_from_node(BooleanValueNode(value=True)) + assert value is True + + +def test_get_enum_value_from_node(): + value = get_value_from_node(EnumValueNode(value="USER")) + assert value == "USER" + + +def test_get_float_value_from_node(): + value = get_value_from_node(FloatValueNode(value="2.5")) + assert value == Decimal("2.5") + + +def test_get_int_value_from_node(): + value = get_value_from_node(IntValueNode(value="42")) + assert value == 42 + + +def test_get_str_value_from_node(): + value = get_value_from_node(StringValueNode(value="Hello")) + assert value == "Hello" + + +def test_get_str_value_from_block_node(): + value = get_value_from_node(StringValueNode(value="Hello", block=True)) + assert value == "Hello" diff --git a/tests/types.py b/tests/types.py new file mode 100644 index 0000000..96233ee --- /dev/null +++ b/tests/types.py @@ -0,0 +1,12 @@ +from enum import Enum + +from ariadne_graphql_modules import GraphQLScalar + + +class ForwardScalar(GraphQLScalar): + __schema__ = "scalar Forward" + + +class ForwardEnum(Enum): + RED = "RED" + BLU = "BLU" diff --git a/tests/snapshots/__init__.py b/tests_v1/__init__.py similarity index 100% rename from tests/snapshots/__init__.py rename to tests_v1/__init__.py diff --git a/tests_v1/conftest.py b/tests_v1/conftest.py new file mode 100644 index 0000000..0f16709 --- /dev/null +++ b/tests_v1/conftest.py @@ -0,0 +1,12 @@ +from pathlib import Path +import pytest + + +@pytest.fixture(scope="session") +def datadir() -> Path: + return Path(__file__).parent / "snapshots" + + +@pytest.fixture(scope="session") +def original_datadir() -> Path: + return Path(__file__).parent / "snapshots" diff --git a/tests_v1/snapshots/test_definition_parser_raises_error_schema_str_contains_multiple_types.yml b/tests_v1/snapshots/test_definition_parser_raises_error_schema_str_contains_multiple_types.yml new file mode 100644 index 0000000..1901e66 --- /dev/null +++ b/tests_v1/snapshots/test_definition_parser_raises_error_schema_str_contains_multiple_types.yml @@ -0,0 +1,2 @@ +'MyType class was defined with __schema__ containing more than one GraphQL definition + (found: ObjectTypeDefinitionNode, ObjectTypeDefinitionNode)' diff --git a/tests_v1/snapshots/test_definition_parser_raises_error_when_schema_str_has_invalid_syntax.yml b/tests_v1/snapshots/test_definition_parser_raises_error_when_schema_str_has_invalid_syntax.yml new file mode 100644 index 0000000..d16bce5 --- /dev/null +++ b/tests_v1/snapshots/test_definition_parser_raises_error_when_schema_str_has_invalid_syntax.yml @@ -0,0 +1,2 @@ +"Syntax Error: Unexpected Name 'typo'.\n\nGraphQL request:1:1\n1 | typo User\n |\ + \ ^" diff --git a/tests_v1/snapshots/test_definition_parser_raises_error_when_schema_type_is_invalid.yml b/tests_v1/snapshots/test_definition_parser_raises_error_when_schema_type_is_invalid.yml new file mode 100644 index 0000000..e12dbfa --- /dev/null +++ b/tests_v1/snapshots/test_definition_parser_raises_error_when_schema_type_is_invalid.yml @@ -0,0 +1 @@ +'MyType class was defined with __schema__ of invalid type: bool' diff --git a/tests_v1/snapshots/test_directive_type_raises_attribute_error_when_defined_without_schema.yml b/tests_v1/snapshots/test_directive_type_raises_attribute_error_when_defined_without_schema.yml new file mode 100644 index 0000000..4b6d226 --- /dev/null +++ b/tests_v1/snapshots/test_directive_type_raises_attribute_error_when_defined_without_schema.yml @@ -0,0 +1,2 @@ +type object 'ExampleDirective' has no attribute '__schema__' +... diff --git a/tests_v1/snapshots/test_directive_type_raises_attribute_error_when_defined_without_visitor.yml b/tests_v1/snapshots/test_directive_type_raises_attribute_error_when_defined_without_visitor.yml new file mode 100644 index 0000000..ccef661 --- /dev/null +++ b/tests_v1/snapshots/test_directive_type_raises_attribute_error_when_defined_without_visitor.yml @@ -0,0 +1,2 @@ +ExampleDirective class was defined without __visitor__ attribute +... diff --git a/tests_v1/snapshots/test_directive_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml b/tests_v1/snapshots/test_directive_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml new file mode 100644 index 0000000..f43a447 --- /dev/null +++ b/tests_v1/snapshots/test_directive_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml @@ -0,0 +1,2 @@ +ExampleDirective class was defined with __schema__ without GraphQL directive +... diff --git a/tests_v1/snapshots/test_directive_type_raises_error_when_defined_with_invalid_schema_str.yml b/tests_v1/snapshots/test_directive_type_raises_error_when_defined_with_invalid_schema_str.yml new file mode 100644 index 0000000..2b09463 --- /dev/null +++ b/tests_v1/snapshots/test_directive_type_raises_error_when_defined_with_invalid_schema_str.yml @@ -0,0 +1,2 @@ +"Syntax Error: Unexpected Name 'directivo'.\n\nGraphQL request:1:1\n1 | directivo\ + \ @example on FIELD_DEFINITION\n | ^" diff --git a/tests_v1/snapshots/test_directive_type_raises_error_when_defined_with_invalid_schema_type.yml b/tests_v1/snapshots/test_directive_type_raises_error_when_defined_with_invalid_schema_type.yml new file mode 100644 index 0000000..b287e70 --- /dev/null +++ b/tests_v1/snapshots/test_directive_type_raises_error_when_defined_with_invalid_schema_type.yml @@ -0,0 +1 @@ +'ExampleDirective class was defined with __schema__ of invalid type: bool' diff --git a/tests_v1/snapshots/test_directive_type_raises_error_when_defined_with_multiple_types_schema.yml b/tests_v1/snapshots/test_directive_type_raises_error_when_defined_with_multiple_types_schema.yml new file mode 100644 index 0000000..417c07b --- /dev/null +++ b/tests_v1/snapshots/test_directive_type_raises_error_when_defined_with_multiple_types_schema.yml @@ -0,0 +1,2 @@ +'ExampleDirective class was defined with __schema__ containing more than one GraphQL + definition (found: DirectiveDefinitionNode, DirectiveDefinitionNode)' diff --git a/tests_v1/snapshots/test_enum_type_raises_attribute_error_when_defined_without_schema.yml b/tests_v1/snapshots/test_enum_type_raises_attribute_error_when_defined_without_schema.yml new file mode 100644 index 0000000..3a7b959 --- /dev/null +++ b/tests_v1/snapshots/test_enum_type_raises_attribute_error_when_defined_without_schema.yml @@ -0,0 +1,2 @@ +type object 'UserRoleEnum' has no attribute '__schema__' +... diff --git a/tests_v1/snapshots/test_enum_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml b/tests_v1/snapshots/test_enum_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml new file mode 100644 index 0000000..bc4de25 --- /dev/null +++ b/tests_v1/snapshots/test_enum_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml @@ -0,0 +1,2 @@ +UserRoleEnum class was defined with __schema__ without GraphQL enum +... diff --git a/tests_v1/snapshots/test_enum_type_raises_error_when_defined_with_invalid_schema_str.yml b/tests_v1/snapshots/test_enum_type_raises_error_when_defined_with_invalid_schema_str.yml new file mode 100644 index 0000000..c01e216 --- /dev/null +++ b/tests_v1/snapshots/test_enum_type_raises_error_when_defined_with_invalid_schema_str.yml @@ -0,0 +1,2 @@ +"Syntax Error: Unexpected Name 'enom'.\n\nGraphQL request:1:1\n1 | enom UserRole\n\ + \ | ^" diff --git a/tests_v1/snapshots/test_enum_type_raises_error_when_defined_with_invalid_schema_type.yml b/tests_v1/snapshots/test_enum_type_raises_error_when_defined_with_invalid_schema_type.yml new file mode 100644 index 0000000..6ee07b7 --- /dev/null +++ b/tests_v1/snapshots/test_enum_type_raises_error_when_defined_with_invalid_schema_type.yml @@ -0,0 +1 @@ +'UserRoleEnum class was defined with __schema__ of invalid type: bool' diff --git a/tests_v1/snapshots/test_enum_type_raises_error_when_defined_with_multiple_types_schema.yml b/tests_v1/snapshots/test_enum_type_raises_error_when_defined_with_multiple_types_schema.yml new file mode 100644 index 0000000..f513b5e --- /dev/null +++ b/tests_v1/snapshots/test_enum_type_raises_error_when_defined_with_multiple_types_schema.yml @@ -0,0 +1,2 @@ +'UserRoleEnum class was defined with __schema__ containing more than one GraphQL definition + (found: EnumTypeDefinitionNode, EnumTypeDefinitionNode)' diff --git a/tests_v1/snapshots/test_enum_type_raises_error_when_dict_mapping_has_extra_items_not_in_definition.yml b/tests_v1/snapshots/test_enum_type_raises_error_when_dict_mapping_has_extra_items_not_in_definition.yml new file mode 100644 index 0000000..e71b37e --- /dev/null +++ b/tests_v1/snapshots/test_enum_type_raises_error_when_dict_mapping_has_extra_items_not_in_definition.yml @@ -0,0 +1,2 @@ +'UserRoleEnum class was defined with __enum__ containing extra items missing in GraphQL + definition: REVIEW' diff --git a/tests_v1/snapshots/test_enum_type_raises_error_when_dict_mapping_misses_items_from_definition.yml b/tests_v1/snapshots/test_enum_type_raises_error_when_dict_mapping_misses_items_from_definition.yml new file mode 100644 index 0000000..afa4a5a --- /dev/null +++ b/tests_v1/snapshots/test_enum_type_raises_error_when_dict_mapping_misses_items_from_definition.yml @@ -0,0 +1,2 @@ +'UserRoleEnum class was defined with __enum__ missing following items required by + GraphQL definition: MOD' diff --git a/tests_v1/snapshots/test_enum_type_raises_error_when_enum_mapping_has_extra_items_not_in_definition.yml b/tests_v1/snapshots/test_enum_type_raises_error_when_enum_mapping_has_extra_items_not_in_definition.yml new file mode 100644 index 0000000..e71b37e --- /dev/null +++ b/tests_v1/snapshots/test_enum_type_raises_error_when_enum_mapping_has_extra_items_not_in_definition.yml @@ -0,0 +1,2 @@ +'UserRoleEnum class was defined with __enum__ containing extra items missing in GraphQL + definition: REVIEW' diff --git a/tests_v1/snapshots/test_enum_type_raises_error_when_enum_mapping_misses_items_from_definition.yml b/tests_v1/snapshots/test_enum_type_raises_error_when_enum_mapping_misses_items_from_definition.yml new file mode 100644 index 0000000..afa4a5a --- /dev/null +++ b/tests_v1/snapshots/test_enum_type_raises_error_when_enum_mapping_misses_items_from_definition.yml @@ -0,0 +1,2 @@ +'UserRoleEnum class was defined with __enum__ missing following items required by + GraphQL definition: MOD' diff --git a/tests_v1/snapshots/test_executable_schema_raises_value_error_if_merged_types_define_same_field.yml b/tests_v1/snapshots/test_executable_schema_raises_value_error_if_merged_types_define_same_field.yml new file mode 100644 index 0000000..15490e9 --- /dev/null +++ b/tests_v1/snapshots/test_executable_schema_raises_value_error_if_merged_types_define_same_field.yml @@ -0,0 +1 @@ +'Multiple Query types are defining same field ''city'': CityQueryType, YearQueryType' diff --git a/tests_v1/snapshots/test_input_type_raises_attribute_error_when_defined_without_schema.yml b/tests_v1/snapshots/test_input_type_raises_attribute_error_when_defined_without_schema.yml new file mode 100644 index 0000000..e789b2a --- /dev/null +++ b/tests_v1/snapshots/test_input_type_raises_attribute_error_when_defined_without_schema.yml @@ -0,0 +1,2 @@ +type object 'UserInput' has no attribute '__schema__' +... diff --git a/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_args_map_for_nonexisting_field.yml b/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_args_map_for_nonexisting_field.yml new file mode 100644 index 0000000..e522523 --- /dev/null +++ b/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_args_map_for_nonexisting_field.yml @@ -0,0 +1 @@ +'UserInput class was defined with args for fields not in GraphQL input: fullName' diff --git a/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml b/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml new file mode 100644 index 0000000..755ecaa --- /dev/null +++ b/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml @@ -0,0 +1,2 @@ +UserInput class was defined with __schema__ without GraphQL input +... diff --git a/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_invalid_schema_str.yml b/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_invalid_schema_str.yml new file mode 100644 index 0000000..b0e2a72 --- /dev/null +++ b/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_invalid_schema_str.yml @@ -0,0 +1,2 @@ +"Syntax Error: Unexpected Name 'inpet'.\n\nGraphQL request:1:1\n1 | inpet UserInput\n\ + \ | ^" diff --git a/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_invalid_schema_type.yml b/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_invalid_schema_type.yml new file mode 100644 index 0000000..4b5db00 --- /dev/null +++ b/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_invalid_schema_type.yml @@ -0,0 +1 @@ +'UserInput class was defined with __schema__ of invalid type: bool' diff --git a/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_multiple_types_schema.yml b/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_multiple_types_schema.yml new file mode 100644 index 0000000..77630d7 --- /dev/null +++ b/tests_v1/snapshots/test_input_type_raises_error_when_defined_with_multiple_types_schema.yml @@ -0,0 +1,2 @@ +'UserInput class was defined with __schema__ containing more than one GraphQL definition + (found: InputObjectTypeDefinitionNode, InputObjectTypeDefinitionNode)' diff --git a/tests_v1/snapshots/test_input_type_raises_error_when_defined_without_extended_dependency.yml b/tests_v1/snapshots/test_input_type_raises_error_when_defined_without_extended_dependency.yml new file mode 100644 index 0000000..5140f86 --- /dev/null +++ b/tests_v1/snapshots/test_input_type_raises_error_when_defined_without_extended_dependency.yml @@ -0,0 +1,3 @@ +ExtendUserInput graphql type was defined without required GraphQL type definition + for 'User' in __requires__ +... diff --git a/tests_v1/snapshots/test_input_type_raises_error_when_defined_without_field_type_dependency.yml b/tests_v1/snapshots/test_input_type_raises_error_when_defined_without_field_type_dependency.yml new file mode 100644 index 0000000..97a9adf --- /dev/null +++ b/tests_v1/snapshots/test_input_type_raises_error_when_defined_without_field_type_dependency.yml @@ -0,0 +1,2 @@ +UserInput class was defined without required GraphQL definition for 'Role' in __requires__ +... diff --git a/tests_v1/snapshots/test_input_type_raises_error_when_defined_without_fields.yml b/tests_v1/snapshots/test_input_type_raises_error_when_defined_without_fields.yml new file mode 100644 index 0000000..3959a6b --- /dev/null +++ b/tests_v1/snapshots/test_input_type_raises_error_when_defined_without_fields.yml @@ -0,0 +1,2 @@ +UserInput class was defined with __schema__ containing empty GraphQL input definition +... diff --git a/tests_v1/snapshots/test_input_type_raises_error_when_extended_dependency_is_wrong_type.yml b/tests_v1/snapshots/test_input_type_raises_error_when_extended_dependency_is_wrong_type.yml new file mode 100644 index 0000000..258b184 --- /dev/null +++ b/tests_v1/snapshots/test_input_type_raises_error_when_extended_dependency_is_wrong_type.yml @@ -0,0 +1,3 @@ +ExtendUserInput requires 'User' to be GraphQL input but other type was provided in + '__requires__' +... diff --git a/tests_v1/snapshots/test_interface_type_raises_attribute_error_when_defined_without_schema.yml b/tests_v1/snapshots/test_interface_type_raises_attribute_error_when_defined_without_schema.yml new file mode 100644 index 0000000..41480da --- /dev/null +++ b/tests_v1/snapshots/test_interface_type_raises_attribute_error_when_defined_without_schema.yml @@ -0,0 +1,2 @@ +type object 'ExampleInterface' has no attribute '__schema__' +... diff --git a/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_alias_for_nonexisting_field.yml b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_alias_for_nonexisting_field.yml new file mode 100644 index 0000000..00161da --- /dev/null +++ b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_alias_for_nonexisting_field.yml @@ -0,0 +1 @@ +'ExampleInterface class was defined with aliases for fields not in GraphQL type: joinedDate' diff --git a/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml new file mode 100644 index 0000000..c03787f --- /dev/null +++ b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml @@ -0,0 +1,2 @@ +ExampleInterface class was defined with __schema__ without GraphQL interface +... diff --git a/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_invalid_schema_str.yml b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_invalid_schema_str.yml new file mode 100644 index 0000000..8217a60 --- /dev/null +++ b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_invalid_schema_str.yml @@ -0,0 +1,2 @@ +"Syntax Error: Unexpected Name 'interfaco'.\n\nGraphQL request:1:1\n1 | interfaco\ + \ Example\n | ^" diff --git a/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_invalid_schema_type.yml b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_invalid_schema_type.yml new file mode 100644 index 0000000..6efffd8 --- /dev/null +++ b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_invalid_schema_type.yml @@ -0,0 +1 @@ +'ExampleInterface class was defined with __schema__ of invalid type: bool' diff --git a/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_multiple_types_schema.yml b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_multiple_types_schema.yml new file mode 100644 index 0000000..c4a1d3d --- /dev/null +++ b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_multiple_types_schema.yml @@ -0,0 +1,2 @@ +'ExampleInterface class was defined with __schema__ containing more than one GraphQL + definition (found: InterfaceTypeDefinitionNode, InterfaceTypeDefinitionNode)' diff --git a/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_resolver_for_nonexisting_field.yml b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_resolver_for_nonexisting_field.yml new file mode 100644 index 0000000..4b67ef2 --- /dev/null +++ b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_with_resolver_for_nonexisting_field.yml @@ -0,0 +1,2 @@ +'ExampleInterface class was defined with resolvers for fields not in GraphQL type: + resolve_group' diff --git a/tests_v1/snapshots/test_interface_type_raises_error_when_defined_without_argument_type_dependency.yml b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_without_argument_type_dependency.yml new file mode 100644 index 0000000..c70e6fa --- /dev/null +++ b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_without_argument_type_dependency.yml @@ -0,0 +1,3 @@ +ExampleInterface class was defined without required GraphQL definition for 'UserInput' + in __requires__ +... diff --git a/tests_v1/snapshots/test_interface_type_raises_error_when_defined_without_extended_dependency.yml b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_without_extended_dependency.yml new file mode 100644 index 0000000..8c4dc90 --- /dev/null +++ b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_without_extended_dependency.yml @@ -0,0 +1,2 @@ +ExtendExampleInterface class was defined with __schema__ without GraphQL type +... diff --git a/tests_v1/snapshots/test_interface_type_raises_error_when_defined_without_fields.yml b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_without_fields.yml new file mode 100644 index 0000000..5e0e878 --- /dev/null +++ b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_without_fields.yml @@ -0,0 +1,3 @@ +ExampleInterface class was defined with __schema__ containing empty GraphQL interface + definition +... diff --git a/tests_v1/snapshots/test_interface_type_raises_error_when_defined_without_return_type_dependency.yml b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_without_return_type_dependency.yml new file mode 100644 index 0000000..8503826 --- /dev/null +++ b/tests_v1/snapshots/test_interface_type_raises_error_when_defined_without_return_type_dependency.yml @@ -0,0 +1,3 @@ +ExampleInterface class was defined without required GraphQL definition for 'Group' + in __requires__ +... diff --git a/tests_v1/snapshots/test_interface_type_raises_error_when_extended_dependency_is_wrong_type.yml b/tests_v1/snapshots/test_interface_type_raises_error_when_extended_dependency_is_wrong_type.yml new file mode 100644 index 0000000..0b64116 --- /dev/null +++ b/tests_v1/snapshots/test_interface_type_raises_error_when_extended_dependency_is_wrong_type.yml @@ -0,0 +1,3 @@ +ExampleInterface requires 'Example' to be GraphQL interface but other type was provided + in '__requires__' +... diff --git a/tests_v1/snapshots/test_mutation_type_raises_attribute_error_when_defined_without_schema.yml b/tests_v1/snapshots/test_mutation_type_raises_attribute_error_when_defined_without_schema.yml new file mode 100644 index 0000000..c33b88b --- /dev/null +++ b/tests_v1/snapshots/test_mutation_type_raises_attribute_error_when_defined_without_schema.yml @@ -0,0 +1,2 @@ +type object 'UserCreateMutation' has no attribute '__schema__' +... diff --git a/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_for_different_type_name.yml b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_for_different_type_name.yml new file mode 100644 index 0000000..c529e91 --- /dev/null +++ b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_for_different_type_name.yml @@ -0,0 +1,3 @@ +UserCreateMutation class was defined with __schema__ containing GraphQL definition + for 'type User' while 'type Mutation' was expected +... diff --git a/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml new file mode 100644 index 0000000..04cd779 --- /dev/null +++ b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml @@ -0,0 +1,2 @@ +UserCreateMutation class was defined with __schema__ without GraphQL type +... diff --git a/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_invalid_schema_type.yml b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_invalid_schema_type.yml new file mode 100644 index 0000000..769faab --- /dev/null +++ b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_invalid_schema_type.yml @@ -0,0 +1 @@ +'UserCreateMutation class was defined with __schema__ of invalid type: bool' diff --git a/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_multiple_fields.yml b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_multiple_fields.yml new file mode 100644 index 0000000..1b4c4d3 --- /dev/null +++ b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_multiple_fields.yml @@ -0,0 +1,3 @@ +UserCreateMutation class subclasses 'MutationType' class which requires __schema__ + to define exactly one field +... diff --git a/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_multiple_types_schema.yml b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_multiple_types_schema.yml new file mode 100644 index 0000000..686b110 --- /dev/null +++ b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_multiple_types_schema.yml @@ -0,0 +1,2 @@ +'UserCreateMutation class was defined with __schema__ containing more than one GraphQL + definition (found: ObjectTypeDefinitionNode, ObjectTypeDefinitionNode)' diff --git a/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_nonexistant_args.yml b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_nonexistant_args.yml new file mode 100644 index 0000000..141c105 --- /dev/null +++ b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_with_nonexistant_args.yml @@ -0,0 +1,2 @@ +'UserCreateMutation class was defined with args not on ''userCreate'' GraphQL field: + realName' diff --git a/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_without_callable_resolve_mutation_attr.yml b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_without_callable_resolve_mutation_attr.yml new file mode 100644 index 0000000..52d3d13 --- /dev/null +++ b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_without_callable_resolve_mutation_attr.yml @@ -0,0 +1,3 @@ +UserCreateMutation class was defined with attribute 'resolve_mutation' but it's not + callable +... diff --git a/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_without_fields.yml b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_without_fields.yml new file mode 100644 index 0000000..b5d09d3 --- /dev/null +++ b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_without_fields.yml @@ -0,0 +1,3 @@ +UserCreateMutation class was defined with __schema__ containing empty GraphQL type + definition +... diff --git a/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_without_resolve_mutation_attr.yml b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_without_resolve_mutation_attr.yml new file mode 100644 index 0000000..ce96568 --- /dev/null +++ b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_without_resolve_mutation_attr.yml @@ -0,0 +1,2 @@ +UserCreateMutation class was defined without required 'resolve_mutation' attribute +... diff --git a/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_without_return_type_dependency.yml b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_without_return_type_dependency.yml new file mode 100644 index 0000000..bcb3e21 --- /dev/null +++ b/tests_v1/snapshots/test_mutation_type_raises_error_when_defined_without_return_type_dependency.yml @@ -0,0 +1,3 @@ +UserCreateMutation class was defined without required GraphQL definition for 'UserCreateResult' + in __requires__ +... diff --git a/tests_v1/snapshots/test_object_type_raises_attribute_error_when_defined_without_schema.yml b/tests_v1/snapshots/test_object_type_raises_attribute_error_when_defined_without_schema.yml new file mode 100644 index 0000000..ed1a122 --- /dev/null +++ b/tests_v1/snapshots/test_object_type_raises_attribute_error_when_defined_without_schema.yml @@ -0,0 +1,2 @@ +type object 'UserType' has no attribute '__schema__' +... diff --git a/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_alias_for_nonexisting_field.yml b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_alias_for_nonexisting_field.yml new file mode 100644 index 0000000..a8109e2 --- /dev/null +++ b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_alias_for_nonexisting_field.yml @@ -0,0 +1 @@ +'UserType class was defined with aliases for fields not in GraphQL type: joinedDate' diff --git a/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_field_args_for_nonexisting_arg.yml b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_field_args_for_nonexisting_arg.yml new file mode 100644 index 0000000..52b6f4e --- /dev/null +++ b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_field_args_for_nonexisting_arg.yml @@ -0,0 +1 @@ +'UserType class was defined with args mappings not in not in ''name'' field: arg' diff --git a/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_field_args_for_nonexisting_field.yml b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_field_args_for_nonexisting_field.yml new file mode 100644 index 0000000..db29360 --- /dev/null +++ b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_field_args_for_nonexisting_field.yml @@ -0,0 +1,2 @@ +'UserType class was defined with fields args mappings for fields not in GraphQL type: + group' diff --git a/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml new file mode 100644 index 0000000..8d5f64d --- /dev/null +++ b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml @@ -0,0 +1,2 @@ +UserType class was defined with __schema__ without GraphQL type +... diff --git a/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_invalid_schema_str.yml b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_invalid_schema_str.yml new file mode 100644 index 0000000..d16bce5 --- /dev/null +++ b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_invalid_schema_str.yml @@ -0,0 +1,2 @@ +"Syntax Error: Unexpected Name 'typo'.\n\nGraphQL request:1:1\n1 | typo User\n |\ + \ ^" diff --git a/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_invalid_schema_type.yml b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_invalid_schema_type.yml new file mode 100644 index 0000000..a32b7b5 --- /dev/null +++ b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_invalid_schema_type.yml @@ -0,0 +1 @@ +'UserType class was defined with __schema__ of invalid type: bool' diff --git a/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_multiple_types_schema.yml b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_multiple_types_schema.yml new file mode 100644 index 0000000..54101dd --- /dev/null +++ b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_multiple_types_schema.yml @@ -0,0 +1,2 @@ +'UserType class was defined with __schema__ containing more than one GraphQL definition + (found: ObjectTypeDefinitionNode, ObjectTypeDefinitionNode)' diff --git a/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_resolver_for_nonexisting_field.yml b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_resolver_for_nonexisting_field.yml new file mode 100644 index 0000000..59ac07b --- /dev/null +++ b/tests_v1/snapshots/test_object_type_raises_error_when_defined_with_resolver_for_nonexisting_field.yml @@ -0,0 +1 @@ +'UserType class was defined with resolvers for fields not in GraphQL type: resolve_group' diff --git a/tests_v1/snapshots/test_object_type_raises_error_when_defined_without_argument_type_dependency.yml b/tests_v1/snapshots/test_object_type_raises_error_when_defined_without_argument_type_dependency.yml new file mode 100644 index 0000000..a26f534 --- /dev/null +++ b/tests_v1/snapshots/test_object_type_raises_error_when_defined_without_argument_type_dependency.yml @@ -0,0 +1,3 @@ +UserType class was defined without required GraphQL definition for 'UserInput' in + __requires__ +... diff --git a/tests_v1/snapshots/test_object_type_raises_error_when_defined_without_extended_dependency.yml b/tests_v1/snapshots/test_object_type_raises_error_when_defined_without_extended_dependency.yml new file mode 100644 index 0000000..9b74478 --- /dev/null +++ b/tests_v1/snapshots/test_object_type_raises_error_when_defined_without_extended_dependency.yml @@ -0,0 +1,3 @@ +ExtendUserType graphql type was defined without required GraphQL type definition for + 'User' in __requires__ +... diff --git a/tests_v1/snapshots/test_object_type_raises_error_when_defined_without_fields.yml b/tests_v1/snapshots/test_object_type_raises_error_when_defined_without_fields.yml new file mode 100644 index 0000000..d1ef4ac --- /dev/null +++ b/tests_v1/snapshots/test_object_type_raises_error_when_defined_without_fields.yml @@ -0,0 +1,2 @@ +UserType class was defined with __schema__ containing empty GraphQL type definition +... diff --git a/tests_v1/snapshots/test_object_type_raises_error_when_defined_without_return_type_dependency.yml b/tests_v1/snapshots/test_object_type_raises_error_when_defined_without_return_type_dependency.yml new file mode 100644 index 0000000..19d2bbe --- /dev/null +++ b/tests_v1/snapshots/test_object_type_raises_error_when_defined_without_return_type_dependency.yml @@ -0,0 +1,2 @@ +UserType class was defined without required GraphQL definition for 'Group' in __requires__ +... diff --git a/tests_v1/snapshots/test_object_type_raises_error_when_extended_dependency_is_wrong_type.yml b/tests_v1/snapshots/test_object_type_raises_error_when_extended_dependency_is_wrong_type.yml new file mode 100644 index 0000000..5222a0e --- /dev/null +++ b/tests_v1/snapshots/test_object_type_raises_error_when_extended_dependency_is_wrong_type.yml @@ -0,0 +1,2 @@ +ExampleType requires 'Example' to be GraphQL type but other type was provided in '__requires__' +... diff --git a/tests_v1/snapshots/test_scalar_type_raises_attribute_error_when_defined_without_schema.yml b/tests_v1/snapshots/test_scalar_type_raises_attribute_error_when_defined_without_schema.yml new file mode 100644 index 0000000..343ade4 --- /dev/null +++ b/tests_v1/snapshots/test_scalar_type_raises_attribute_error_when_defined_without_schema.yml @@ -0,0 +1,2 @@ +type object 'DateScalar' has no attribute '__schema__' +... diff --git a/tests_v1/snapshots/test_scalar_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml b/tests_v1/snapshots/test_scalar_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml new file mode 100644 index 0000000..1090fab --- /dev/null +++ b/tests_v1/snapshots/test_scalar_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml @@ -0,0 +1,2 @@ +DateScalar class was defined with __schema__ without GraphQL scalar +... diff --git a/tests_v1/snapshots/test_scalar_type_raises_error_when_defined_with_invalid_schema_str.yml b/tests_v1/snapshots/test_scalar_type_raises_error_when_defined_with_invalid_schema_str.yml new file mode 100644 index 0000000..8cb39c7 --- /dev/null +++ b/tests_v1/snapshots/test_scalar_type_raises_error_when_defined_with_invalid_schema_str.yml @@ -0,0 +1,2 @@ +"Syntax Error: Unexpected Name 'scalor'.\n\nGraphQL request:1:1\n1 | scalor Date\n\ + \ | ^" diff --git a/tests_v1/snapshots/test_scalar_type_raises_error_when_defined_with_invalid_schema_type.yml b/tests_v1/snapshots/test_scalar_type_raises_error_when_defined_with_invalid_schema_type.yml new file mode 100644 index 0000000..b55ebcd --- /dev/null +++ b/tests_v1/snapshots/test_scalar_type_raises_error_when_defined_with_invalid_schema_type.yml @@ -0,0 +1 @@ +'DateScalar class was defined with __schema__ of invalid type: bool' diff --git a/tests_v1/snapshots/test_scalar_type_raises_error_when_defined_with_multiple_types_schema.yml b/tests_v1/snapshots/test_scalar_type_raises_error_when_defined_with_multiple_types_schema.yml new file mode 100644 index 0000000..1775123 --- /dev/null +++ b/tests_v1/snapshots/test_scalar_type_raises_error_when_defined_with_multiple_types_schema.yml @@ -0,0 +1,2 @@ +'DateScalar class was defined with __schema__ containing more than one GraphQL definition + (found: ScalarTypeDefinitionNode, ScalarTypeDefinitionNode)' diff --git a/tests_v1/snapshots/test_subscription_type_raises_attribute_error_when_defined_without_schema.yml b/tests_v1/snapshots/test_subscription_type_raises_attribute_error_when_defined_without_schema.yml new file mode 100644 index 0000000..8f88a6f --- /dev/null +++ b/tests_v1/snapshots/test_subscription_type_raises_attribute_error_when_defined_without_schema.yml @@ -0,0 +1,2 @@ +type object 'UsersSubscription' has no attribute '__schema__' +... diff --git a/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_alias_for_nonexisting_field.yml b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_alias_for_nonexisting_field.yml new file mode 100644 index 0000000..d0e6232 --- /dev/null +++ b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_alias_for_nonexisting_field.yml @@ -0,0 +1 @@ +'ChatSubscription class was defined with aliases for fields not in GraphQL type: userAlerts' diff --git a/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_invalid_graphql_type_name.yml b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_invalid_graphql_type_name.yml new file mode 100644 index 0000000..c7df1c7 --- /dev/null +++ b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_invalid_graphql_type_name.yml @@ -0,0 +1,3 @@ +UsersSubscription class was defined with __schema__ containing GraphQL definition + for 'type Other' (expected 'type Subscription') +... diff --git a/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml new file mode 100644 index 0000000..5075396 --- /dev/null +++ b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml @@ -0,0 +1,2 @@ +UsersSubscription class was defined with __schema__ without GraphQL type +... diff --git a/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_invalid_schema_str.yml b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_invalid_schema_str.yml new file mode 100644 index 0000000..218344a --- /dev/null +++ b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_invalid_schema_str.yml @@ -0,0 +1,2 @@ +"Syntax Error: Unexpected Name 'typo'.\n\nGraphQL request:1:1\n1 | typo Subscription\n\ + \ | ^" diff --git a/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_invalid_schema_type.yml b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_invalid_schema_type.yml new file mode 100644 index 0000000..18f9545 --- /dev/null +++ b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_invalid_schema_type.yml @@ -0,0 +1 @@ +'UsersSubscription class was defined with __schema__ of invalid type: bool' diff --git a/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_resolver_for_nonexisting_field.yml b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_resolver_for_nonexisting_field.yml new file mode 100644 index 0000000..9bdf884 --- /dev/null +++ b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_resolver_for_nonexisting_field.yml @@ -0,0 +1,2 @@ +'ChatSubscription class was defined with resolvers for fields not in GraphQL type: + resolve_group' diff --git a/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_sub_for_nonexisting_field.yml b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_sub_for_nonexisting_field.yml new file mode 100644 index 0000000..d8dd479 --- /dev/null +++ b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_with_sub_for_nonexisting_field.yml @@ -0,0 +1,2 @@ +'ChatSubscription class was defined with subscribers for fields not in GraphQL type: + resolve_group' diff --git a/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_without_argument_type_dependency.yml b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_without_argument_type_dependency.yml new file mode 100644 index 0000000..822b19a --- /dev/null +++ b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_without_argument_type_dependency.yml @@ -0,0 +1,3 @@ +ChatSubscription class was defined without required GraphQL definition for 'ChannelInput' + in __requires__ +... diff --git a/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_without_extended_dependency.yml b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_without_extended_dependency.yml new file mode 100644 index 0000000..cd1dd29 --- /dev/null +++ b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_without_extended_dependency.yml @@ -0,0 +1,3 @@ +ExtendChatSubscription graphql type was defined without required GraphQL type definition + for 'Subscription' in __requires__ +... diff --git a/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_without_fields.yml b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_without_fields.yml new file mode 100644 index 0000000..aba35e1 --- /dev/null +++ b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_without_fields.yml @@ -0,0 +1,3 @@ +UsersSubscription class was defined with __schema__ containing empty GraphQL type + definition +... diff --git a/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_without_return_type_dependency.yml b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_without_return_type_dependency.yml new file mode 100644 index 0000000..2ed5d80 --- /dev/null +++ b/tests_v1/snapshots/test_subscription_type_raises_error_when_defined_without_return_type_dependency.yml @@ -0,0 +1,3 @@ +ChatSubscription class was defined without required GraphQL definition for 'Chat' + in __requires__ +... diff --git a/tests_v1/snapshots/test_subscription_type_raises_error_when_extended_dependency_is_wrong_type.yml b/tests_v1/snapshots/test_subscription_type_raises_error_when_extended_dependency_is_wrong_type.yml new file mode 100644 index 0000000..20aa4af --- /dev/null +++ b/tests_v1/snapshots/test_subscription_type_raises_error_when_extended_dependency_is_wrong_type.yml @@ -0,0 +1,3 @@ +ExtendChatSubscription requires 'Subscription' to be GraphQL type but other type was + provided in '__requires__' +... diff --git a/tests_v1/snapshots/test_union_type_raises_attribute_error_when_defined_without_schema.yml b/tests_v1/snapshots/test_union_type_raises_attribute_error_when_defined_without_schema.yml new file mode 100644 index 0000000..dc8ba31 --- /dev/null +++ b/tests_v1/snapshots/test_union_type_raises_attribute_error_when_defined_without_schema.yml @@ -0,0 +1,2 @@ +type object 'ExampleUnion' has no attribute '__schema__' +... diff --git a/tests_v1/snapshots/test_union_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml b/tests_v1/snapshots/test_union_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml new file mode 100644 index 0000000..7925073 --- /dev/null +++ b/tests_v1/snapshots/test_union_type_raises_error_when_defined_with_invalid_graphql_type_schema.yml @@ -0,0 +1,2 @@ +ExampleUnion class was defined with __schema__ without GraphQL union +... diff --git a/tests_v1/snapshots/test_union_type_raises_error_when_defined_with_invalid_schema_str.yml b/tests_v1/snapshots/test_union_type_raises_error_when_defined_with_invalid_schema_str.yml new file mode 100644 index 0000000..5bf1156 --- /dev/null +++ b/tests_v1/snapshots/test_union_type_raises_error_when_defined_with_invalid_schema_str.yml @@ -0,0 +1,2 @@ +"Syntax Error: Unexpected Name 'unien'.\n\nGraphQL request:1:1\n1 | unien Example\ + \ = A | B\n | ^" diff --git a/tests_v1/snapshots/test_union_type_raises_error_when_defined_with_invalid_schema_type.yml b/tests_v1/snapshots/test_union_type_raises_error_when_defined_with_invalid_schema_type.yml new file mode 100644 index 0000000..6213798 --- /dev/null +++ b/tests_v1/snapshots/test_union_type_raises_error_when_defined_with_invalid_schema_type.yml @@ -0,0 +1 @@ +'ExampleUnion class was defined with __schema__ of invalid type: bool' diff --git a/tests_v1/snapshots/test_union_type_raises_error_when_defined_with_multiple_types_schema.yml b/tests_v1/snapshots/test_union_type_raises_error_when_defined_with_multiple_types_schema.yml new file mode 100644 index 0000000..0613370 --- /dev/null +++ b/tests_v1/snapshots/test_union_type_raises_error_when_defined_with_multiple_types_schema.yml @@ -0,0 +1,2 @@ +'ExampleUnion class was defined with __schema__ containing more than one GraphQL definition + (found: UnionTypeDefinitionNode, UnionTypeDefinitionNode)' diff --git a/tests_v1/snapshots/test_union_type_raises_error_when_defined_without_extended_dependency.yml b/tests_v1/snapshots/test_union_type_raises_error_when_defined_without_extended_dependency.yml new file mode 100644 index 0000000..db77044 --- /dev/null +++ b/tests_v1/snapshots/test_union_type_raises_error_when_defined_without_extended_dependency.yml @@ -0,0 +1,3 @@ +ExtendExampleUnion class was defined without required GraphQL union definition for + 'Result' in __requires__ +... diff --git a/tests_v1/snapshots/test_union_type_raises_error_when_defined_without_member_type_dependency.yml b/tests_v1/snapshots/test_union_type_raises_error_when_defined_without_member_type_dependency.yml new file mode 100644 index 0000000..b4673df --- /dev/null +++ b/tests_v1/snapshots/test_union_type_raises_error_when_defined_without_member_type_dependency.yml @@ -0,0 +1,3 @@ +ExampleUnion class was defined without required GraphQL definition for 'Comment' in + __requires__ +... diff --git a/tests_v1/snapshots/test_union_type_raises_error_when_extended_dependency_is_wrong_type.yml b/tests_v1/snapshots/test_union_type_raises_error_when_extended_dependency_is_wrong_type.yml new file mode 100644 index 0000000..0ce118e --- /dev/null +++ b/tests_v1/snapshots/test_union_type_raises_error_when_extended_dependency_is_wrong_type.yml @@ -0,0 +1,3 @@ +ExtendExampleUnion requires 'Example' to be GraphQL union but other type was provided + in '__requires__' +... diff --git a/tests/test_collection_type.py b/tests_v1/test_collection_type.py similarity index 96% rename from tests/test_collection_type.py rename to tests_v1/test_collection_type.py index c5f43c5..9c5d784 100644 --- a/tests/test_collection_type.py +++ b/tests_v1/test_collection_type.py @@ -1,6 +1,6 @@ from graphql import graphql_sync -from ariadne_graphql_modules import ( +from ariadne_graphql_modules.v1 import ( CollectionType, DeferredType, ObjectType, diff --git a/tests/test_convert_case.py b/tests_v1/test_convert_case.py similarity index 97% rename from tests/test_convert_case.py rename to tests_v1/test_convert_case.py index f4ea36f..e7147a8 100644 --- a/tests/test_convert_case.py +++ b/tests_v1/test_convert_case.py @@ -1,4 +1,4 @@ -from ariadne_graphql_modules import InputType, MutationType, ObjectType, convert_case +from ariadne_graphql_modules.v1 import InputType, MutationType, ObjectType, convert_case def test_cases_are_mapped_for_aliases(): diff --git a/tests/test_definition_parser.py b/tests_v1/test_definition_parser.py similarity index 83% rename from tests/test_definition_parser.py rename to tests_v1/test_definition_parser.py index c5e49d3..9cc8ebc 100644 --- a/tests/test_definition_parser.py +++ b/tests_v1/test_definition_parser.py @@ -2,7 +2,7 @@ from graphql import GraphQLError from graphql.language.ast import ObjectTypeDefinitionNode -from ariadne_graphql_modules import parse_definition +from ariadne_graphql_modules.v1 import parse_definition def test_definition_parser_returns_definition_type_from_valid_schema_string(): @@ -34,21 +34,25 @@ def test_definition_parser_parses_definition_with_description(): assert type_def.description.value == "Test user type" -def test_definition_parser_raises_error_when_schema_type_is_invalid(snapshot): +def test_definition_parser_raises_error_when_schema_type_is_invalid(data_regression): with pytest.raises(TypeError) as err: parse_definition("MyType", True) - snapshot.assert_match(err) + data_regression.check(str(err.value)) -def test_definition_parser_raises_error_when_schema_str_has_invalid_syntax(snapshot): +def test_definition_parser_raises_error_when_schema_str_has_invalid_syntax( + data_regression, +): with pytest.raises(GraphQLError) as err: parse_definition("MyType", "typo User") - snapshot.assert_match(err) + data_regression.check(str(err.value)) -def test_definition_parser_raises_error_schema_str_contains_multiple_types(snapshot): +def test_definition_parser_raises_error_schema_str_contains_multiple_types( + data_regression, +): with pytest.raises(ValueError) as err: parse_definition( "MyType", @@ -59,4 +63,4 @@ def test_definition_parser_raises_error_schema_str_contains_multiple_types(snaps """, ) - snapshot.assert_match(err) + data_regression.check(str(err.value)) diff --git a/tests/test_directive_type.py b/tests_v1/test_directive_type.py similarity index 84% rename from tests/test_directive_type.py rename to tests_v1/test_directive_type.py index fcd3846..a3f38cf 100644 --- a/tests/test_directive_type.py +++ b/tests_v1/test_directive_type.py @@ -2,48 +2,56 @@ from ariadne import SchemaDirectiveVisitor from graphql import GraphQLError, default_field_resolver, graphql_sync -from ariadne_graphql_modules import DirectiveType, ObjectType, make_executable_schema +from ariadne_graphql_modules.v1 import DirectiveType, ObjectType, make_executable_schema -def test_directive_type_raises_attribute_error_when_defined_without_schema(snapshot): +def test_directive_type_raises_attribute_error_when_defined_without_schema( + data_regression, +): with pytest.raises(AttributeError) as err: # pylint: disable=unused-variable class ExampleDirective(DirectiveType): pass - snapshot.assert_match(err) + data_regression.check(str(err.value)) -def test_directive_type_raises_error_when_defined_with_invalid_schema_type(snapshot): +def test_directive_type_raises_error_when_defined_with_invalid_schema_type( + data_regression, +): with pytest.raises(TypeError) as err: # pylint: disable=unused-variable class ExampleDirective(DirectiveType): __schema__ = True - snapshot.assert_match(err) + data_regression.check(str(err.value)) -def test_directive_type_raises_error_when_defined_with_invalid_schema_str(snapshot): +def test_directive_type_raises_error_when_defined_with_invalid_schema_str( + data_regression, +): with pytest.raises(GraphQLError) as err: # pylint: disable=unused-variable class ExampleDirective(DirectiveType): __schema__ = "directivo @example on FIELD_DEFINITION" - snapshot.assert_match(err) + data_regression.check(str(err.value)) def test_directive_type_raises_error_when_defined_with_invalid_graphql_type_schema( - snapshot, + data_regression, ): with pytest.raises(ValueError) as err: # pylint: disable=unused-variable class ExampleDirective(DirectiveType): __schema__ = "scalar example" - snapshot.assert_match(err) + data_regression.check(str(err.value)) -def test_directive_type_raises_error_when_defined_with_multiple_types_schema(snapshot): +def test_directive_type_raises_error_when_defined_with_multiple_types_schema( + data_regression, +): with pytest.raises(ValueError) as err: # pylint: disable=unused-variable class ExampleDirective(DirectiveType): @@ -53,16 +61,18 @@ class ExampleDirective(DirectiveType): directive @other on OBJECT """ - snapshot.assert_match(err) + data_regression.check(str(err.value)) -def test_directive_type_raises_attribute_error_when_defined_without_visitor(snapshot): +def test_directive_type_raises_attribute_error_when_defined_without_visitor( + data_regression, +): with pytest.raises(AttributeError) as err: # pylint: disable=unused-variable class ExampleDirective(DirectiveType): __schema__ = "directive @example on FIELD_DEFINITION" - snapshot.assert_match(err) + data_regression.check(str(err.value)) class ExampleSchemaVisitor(SchemaDirectiveVisitor): diff --git a/tests_v1/test_enum_type.py b/tests_v1/test_enum_type.py new file mode 100644 index 0000000..084365a --- /dev/null +++ b/tests_v1/test_enum_type.py @@ -0,0 +1,304 @@ +from enum import Enum + +import pytest +from ariadne import SchemaDirectiveVisitor +from graphql import GraphQLError, graphql_sync + +from ariadne_graphql_modules.v1 import ( + DirectiveType, + EnumType, + ObjectType, + make_executable_schema, +) + + +def test_enum_type_raises_attribute_error_when_defined_without_schema(data_regression): + with pytest.raises(AttributeError) as err: + # pylint: disable=unused-variable + class UserRoleEnum(EnumType): + pass + + data_regression.check(str(err.value)) + + +def test_enum_type_raises_error_when_defined_with_invalid_schema_type(data_regression): + with pytest.raises(TypeError) as err: + # pylint: disable=unused-variable + class UserRoleEnum(EnumType): + __schema__ = True + + data_regression.check(str(err.value)) + + +def test_enum_type_raises_error_when_defined_with_invalid_schema_str(data_regression): + with pytest.raises(GraphQLError) as err: + # pylint: disable=unused-variable + class UserRoleEnum(EnumType): + __schema__ = "enom UserRole" + + data_regression.check(str(err.value)) + + +def test_enum_type_raises_error_when_defined_with_invalid_graphql_type_schema( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserRoleEnum(EnumType): + __schema__ = "scalar UserRole" + + data_regression.check(str(err.value)) + + +def test_enum_type_raises_error_when_defined_with_multiple_types_schema( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserRoleEnum(EnumType): + __schema__ = """ + enum UserRole { + USER + MOD + ADMIN + } + + enum Category { + CATEGORY + LINK + } + """ + + data_regression.check(str(err.value)) + + +def test_enum_type_extracts_graphql_name(): + class UserRoleEnum(EnumType): + __schema__ = """ + enum UserRole { + USER + MOD + ADMIN + } + """ + + assert UserRoleEnum.graphql_name == "UserRole" + + +def test_enum_type_can_be_extended_with_new_values(): + # pylint: disable=unused-variable + class UserRoleEnum(EnumType): + __schema__ = """ + enum UserRole { + USER + MOD + ADMIN + } + """ + + class ExtendUserRoleEnum(EnumType): + __schema__ = """ + extend enum UserRole { + MVP + } + """ + __requires__ = [UserRoleEnum] + + +def test_enum_type_can_be_extended_with_directive(): + # pylint: disable=unused-variable + class ExampleDirective(DirectiveType): + __schema__ = "directive @example on ENUM" + __visitor__ = SchemaDirectiveVisitor + + class UserRoleEnum(EnumType): + __schema__ = """ + enum UserRole { + USER + MOD + ADMIN + } + """ + + class ExtendUserRoleEnum(EnumType): + __schema__ = "extend enum UserRole @example" + __requires__ = [UserRoleEnum, ExampleDirective] + + +class BaseQueryType(ObjectType): + __abstract__ = True + __schema__ = """ + type Query { + enumToRepr(enum: UserRole = USER): String! + reprToEnum: UserRole! + } + """ + __aliases__ = { + "enumToRepr": "enum_repr", + } + + @staticmethod + def resolve_enum_repr(*_, enum) -> str: + return repr(enum) + + +def make_test_schema(enum_type): + class QueryType(BaseQueryType): + __requires__ = [enum_type] + + return make_executable_schema(QueryType) + + +def test_enum_type_can_be_defined_with_dict_mapping(): + class UserRoleEnum(EnumType): + __schema__ = """ + enum UserRole { + USER + MOD + ADMIN + } + """ + __enum__ = { + "USER": 0, + "MOD": 1, + "ADMIN": 2, + } + + schema = make_test_schema(UserRoleEnum) + + # Specfied enum value is reversed + result = graphql_sync(schema, "{ enumToRepr(enum: MOD) }") + assert result.data["enumToRepr"] == "1" + + # Default enum value is reversed + result = graphql_sync(schema, "{ enumToRepr }") + assert result.data["enumToRepr"] == "0" + + # Python value is converted to enum + result = graphql_sync(schema, "{ reprToEnum }", root_value={"reprToEnum": 2}) + assert result.data["reprToEnum"] == "ADMIN" + + +def test_enum_type_raises_error_when_dict_mapping_misses_items_from_definition( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserRoleEnum(EnumType): + __schema__ = """ + enum UserRole { + USER + MOD + ADMIN + } + """ + __enum__ = { + "USER": 0, + "MODERATOR": 1, + "ADMIN": 2, + } + + data_regression.check(str(err.value)) + + +def test_enum_type_raises_error_when_dict_mapping_has_extra_items_not_in_definition( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserRoleEnum(EnumType): + __schema__ = """ + enum UserRole { + USER + MOD + ADMIN + } + """ + __enum__ = { + "USER": 0, + "REVIEW": 1, + "MOD": 2, + "ADMIN": 3, + } + + data_regression.check(str(err.value)) + + +def test_enum_type_can_be_defined_with_str_enum_mapping(): + class RoleEnum(str, Enum): + USER = "user" + MOD = "moderator" + ADMIN = "administrator" + + class UserRoleEnum(EnumType): + __schema__ = """ + enum UserRole { + USER + MOD + ADMIN + } + """ + __enum__ = RoleEnum + + schema = make_test_schema(UserRoleEnum) + + # Specfied enum value is reversed + result = graphql_sync(schema, "{ enumToRepr(enum: MOD) }") + assert result.data["enumToRepr"] == repr(RoleEnum.MOD) + + # Default enum value is reversed + result = graphql_sync(schema, "{ enumToRepr }") + assert result.data["enumToRepr"] == repr(RoleEnum.USER) + + # Python value is converted to enum + result = graphql_sync( + schema, "{ reprToEnum }", root_value={"reprToEnum": "administrator"} + ) + assert result.data["reprToEnum"] == "ADMIN" + + +def test_enum_type_raises_error_when_enum_mapping_misses_items_from_definition( + data_regression, +): + class RoleEnum(str, Enum): + USER = "user" + MODERATOR = "moderator" + ADMIN = "administrator" + + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserRoleEnum(EnumType): + __schema__ = """ + enum UserRole { + USER + MOD + ADMIN + } + """ + __enum__ = RoleEnum + + data_regression.check(str(err.value)) + + +def test_enum_type_raises_error_when_enum_mapping_has_extra_items_not_in_definition( + data_regression, +): + class RoleEnum(str, Enum): + USER = "user" + REVIEW = "review" + MOD = "moderator" + ADMIN = "administrator" + + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserRoleEnum(EnumType): + __schema__ = """ + enum UserRole { + USER + MOD + ADMIN + } + """ + __enum__ = RoleEnum + + data_regression.check(str(err.value)) diff --git a/tests/test_executable_schema.py b/tests_v1/test_executable_schema.py similarity index 93% rename from tests/test_executable_schema.py rename to tests_v1/test_executable_schema.py index f5dfe13..584b471 100644 --- a/tests/test_executable_schema.py +++ b/tests_v1/test_executable_schema.py @@ -1,7 +1,7 @@ import pytest from graphql import graphql_sync -from ariadne_graphql_modules import ObjectType, make_executable_schema +from ariadne_graphql_modules.v1 import ObjectType, make_executable_schema def test_executable_schema_is_created_from_object_types(): @@ -67,7 +67,7 @@ def resolve_year(*_): def test_executable_schema_raises_value_error_if_merged_types_define_same_field( - snapshot, + data_regression, ): class CityQueryType(ObjectType): __schema__ = """ @@ -87,4 +87,4 @@ class YearQueryType(ObjectType): with pytest.raises(ValueError) as err: make_executable_schema(CityQueryType, YearQueryType) - snapshot.assert_match(err) + data_regression.check(str(err.value)) diff --git a/tests/test_executable_schema_compat.py b/tests_v1/test_executable_schema_compat.py similarity index 98% rename from tests/test_executable_schema_compat.py rename to tests_v1/test_executable_schema_compat.py index 5833845..a1f5e7b 100644 --- a/tests/test_executable_schema_compat.py +++ b/tests_v1/test_executable_schema_compat.py @@ -1,5 +1,5 @@ from ariadne import ObjectType as OldObjectType, QueryType, gql, graphql_sync -from ariadne_graphql_modules import DeferredType, ObjectType, make_executable_schema +from ariadne_graphql_modules.v1 import DeferredType, ObjectType, make_executable_schema def test_old_schema_definition_is_executable(): diff --git a/tests_v1/test_input_type.py b/tests_v1/test_input_type.py new file mode 100644 index 0000000..350e7af --- /dev/null +++ b/tests_v1/test_input_type.py @@ -0,0 +1,293 @@ +import pytest +from ariadne import SchemaDirectiveVisitor +from graphql import GraphQLError, graphql_sync + +from ariadne_graphql_modules.v1 import ( + DeferredType, + DirectiveType, + EnumType, + InputType, + InterfaceType, + ObjectType, + ScalarType, + make_executable_schema, +) + + +def test_input_type_raises_attribute_error_when_defined_without_schema(data_regression): + with pytest.raises(AttributeError) as err: + # pylint: disable=unused-variable + class UserInput(InputType): + pass + + data_regression.check(str(err.value)) + + +def test_input_type_raises_error_when_defined_with_invalid_schema_type(data_regression): + with pytest.raises(TypeError) as err: + # pylint: disable=unused-variable + class UserInput(InputType): + __schema__ = True + + data_regression.check(str(err.value)) + + +def test_input_type_raises_error_when_defined_with_invalid_schema_str(data_regression): + with pytest.raises(GraphQLError) as err: + # pylint: disable=unused-variable + class UserInput(InputType): + __schema__ = "inpet UserInput" + + data_regression.check(str(err.value)) + + +def test_input_type_raises_error_when_defined_with_invalid_graphql_type_schema( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserInput(InputType): + __schema__ = """ + type User { + id: ID! + } + """ + + data_regression.check(str(err.value)) + + +def test_input_type_raises_error_when_defined_with_multiple_types_schema( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserInput(InputType): + __schema__ = """ + input User + + input Group + """ + + data_regression.check(str(err.value)) + + +def test_input_type_raises_error_when_defined_without_fields(data_regression): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserInput(InputType): + __schema__ = "input User" + + data_regression.check(str(err.value)) + + +def test_input_type_extracts_graphql_name(): + class UserInput(InputType): + __schema__ = """ + input User { + id: ID! + } + """ + + assert UserInput.graphql_name == "User" + + +def test_input_type_raises_error_when_defined_without_field_type_dependency( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserInput(InputType): + __schema__ = """ + input User { + id: ID! + role: Role! + } + """ + + data_regression.check(str(err.value)) + + +def test_input_type_verifies_field_dependency(): + # pylint: disable=unused-variable + class RoleEnum(EnumType): + __schema__ = """ + enum Role { + USER + ADMIN + } + """ + + class UserInput(InputType): + __schema__ = """ + input User { + id: ID! + role: Role! + } + """ + __requires__ = [RoleEnum] + + +def test_input_type_verifies_circular_dependency(): + # pylint: disable=unused-variable + class UserInput(InputType): + __schema__ = """ + input User { + id: ID! + patron: User + } + """ + + +def test_input_type_verifies_circular_dependency_using_deferred_type(): + # pylint: disable=unused-variable + class GroupInput(InputType): + __schema__ = """ + input Group { + id: ID! + patron: User + } + """ + __requires__ = [DeferredType("User")] + + class UserInput(InputType): + __schema__ = """ + input User { + id: ID! + group: Group + } + """ + __requires__ = [GroupInput] + + +def test_input_type_can_be_extended_with_new_fields(): + # pylint: disable=unused-variable + class UserInput(InputType): + __schema__ = """ + input User { + id: ID! + } + """ + + class ExtendUserInput(InputType): + __schema__ = """ + extend input User { + name: String! + } + """ + __requires__ = [UserInput] + + +def test_input_type_can_be_extended_with_directive(): + # pylint: disable=unused-variable + class ExampleDirective(DirectiveType): + __schema__ = "directive @example on INPUT_OBJECT" + __visitor__ = SchemaDirectiveVisitor + + class UserInput(InputType): + __schema__ = """ + input User { + id: ID! + } + """ + + class ExtendUserInput(InputType): + __schema__ = """ + extend input User @example + """ + __requires__ = [UserInput, ExampleDirective] + + +def test_input_type_raises_error_when_defined_without_extended_dependency( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExtendUserInput(InputType): + __schema__ = """ + extend input User { + name: String! + } + """ + + data_regression.check(str(err.value)) + + +def test_input_type_raises_error_when_extended_dependency_is_wrong_type( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = """ + interface User { + id: ID! + } + """ + + class ExtendUserInput(InputType): + __schema__ = """ + extend input User { + name: String! + } + """ + __requires__ = [ExampleInterface] + + data_regression.check(str(err.value)) + + +def test_input_type_raises_error_when_defined_with_args_map_for_nonexisting_field( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserInput(InputType): + __schema__ = """ + input User { + id: ID! + } + """ + __args__ = { + "fullName": "full_name", + } + + data_regression.check(str(err.value)) + + +class UserInput(InputType): + __schema__ = """ + input UserInput { + id: ID! + fullName: String! + } + """ + __args__ = { + "fullName": "full_name", + } + + +class GenericScalar(ScalarType): + __schema__ = "scalar Generic" + + +class QueryType(ObjectType): + __schema__ = """ + type Query { + reprInput(input: UserInput): Generic! + } + """ + __aliases__ = {"reprInput": "repr_input"} + __requires__ = [GenericScalar, UserInput] + + @staticmethod + def resolve_repr_input(*_, input): # pylint: disable=redefined-builtin + return input + + +schema = make_executable_schema(QueryType) + + +def test_input_type_maps_args_to_python_dict_keys(): + result = graphql_sync(schema, '{ reprInput(input: {id: "1", fullName: "Alice"}) }') + assert result.data == { + "reprInput": {"id": "1", "full_name": "Alice"}, + } diff --git a/tests_v1/test_interface_type.py b/tests_v1/test_interface_type.py new file mode 100644 index 0000000..6bac4fa --- /dev/null +++ b/tests_v1/test_interface_type.py @@ -0,0 +1,462 @@ +from dataclasses import dataclass + +import pytest +from ariadne import SchemaDirectiveVisitor +from graphql import GraphQLError, graphql_sync + +from ariadne_graphql_modules.v1 import ( + DeferredType, + DirectiveType, + InterfaceType, + ObjectType, + make_executable_schema, +) + + +def test_interface_type_raises_attribute_error_when_defined_without_schema( + data_regression, +): + with pytest.raises(AttributeError) as err: + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + pass + + data_regression.check(str(err.value)) + + +def test_interface_type_raises_error_when_defined_with_invalid_schema_type( + data_regression, +): + with pytest.raises(TypeError) as err: + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = True + + data_regression.check(str(err.value)) + + +def test_interface_type_raises_error_when_defined_with_invalid_schema_str( + data_regression, +): + with pytest.raises(GraphQLError) as err: + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = "interfaco Example" + + data_regression.check(str(err.value)) + + +def test_interface_type_raises_error_when_defined_with_invalid_graphql_type_schema( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = "type Example" + + data_regression.check(str(err.value)) + + +def test_interface_type_raises_error_when_defined_with_multiple_types_schema( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Example + + interface Other + """ + + data_regression.check(str(err.value)) + + +def test_interface_type_raises_error_when_defined_without_fields(data_regression): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = "interface Example" + + data_regression.check(str(err.value)) + + +def test_interface_type_extracts_graphql_name(): + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Example { + id: ID! + } + """ + + assert ExampleInterface.graphql_name == "Example" + + +def test_interface_type_raises_error_when_defined_without_return_type_dependency( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Example { + group: Group + groups: [Group!] + } + """ + + data_regression.check(str(err.value)) + + +def test_interface_type_verifies_field_dependency(): + # pylint: disable=unused-variable + class GroupType(ObjectType): + __schema__ = """ + type Group { + id: ID! + } + """ + + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Example { + group: Group + groups: [Group!] + } + """ + __requires__ = [GroupType] + + +def test_interface_type_verifies_circural_dependency(): + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Example { + parent: Example + } + """ + + +def test_interface_type_raises_error_when_defined_without_argument_type_dependency( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Example { + actions(input: UserInput): [String!]! + } + """ + + data_regression.check(str(err.value)) + + +def test_interface_type_verifies_circular_dependency_using_deferred_type(): + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Example { + id: ID! + users: [User] + } + """ + __requires__ = [DeferredType("User")] + + class UserType(ObjectType): + __schema__ = """ + type User { + roles: [Example] + } + """ + __requires__ = [ExampleInterface] + + +def test_interface_type_can_be_extended_with_new_fields(): + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Example { + id: ID! + } + """ + + class ExtendExampleInterface(InterfaceType): + __schema__ = """ + extend interface Example { + name: String + } + """ + __requires__ = [ExampleInterface] + + +def test_interface_type_can_be_extended_with_directive(): + # pylint: disable=unused-variable + class ExampleDirective(DirectiveType): + __schema__ = "directive @example on INTERFACE" + __visitor__ = SchemaDirectiveVisitor + + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Example { + id: ID! + } + """ + + class ExtendExampleInterface(InterfaceType): + __schema__ = """ + extend interface Example @example + """ + __requires__ = [ExampleInterface, ExampleDirective] + + +def test_interface_type_can_be_extended_with_other_interface(): + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Example { + id: ID! + } + """ + + class OtherInterface(InterfaceType): + __schema__ = """ + interface Other { + depth: Int! + } + """ + + class ExtendExampleInterface(InterfaceType): + __schema__ = """ + extend interface Example implements Other + """ + __requires__ = [ExampleInterface, OtherInterface] + + +def test_interface_type_raises_error_when_defined_without_extended_dependency( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExtendExampleInterface(ObjectType): + __schema__ = """ + extend interface Example { + name: String + } + """ + + data_regression.check(str(err.value)) + + +def test_interface_type_raises_error_when_extended_dependency_is_wrong_type( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleType(ObjectType): + __schema__ = """ + type Example { + id: ID! + } + """ + + class ExampleInterface(InterfaceType): + __schema__ = """ + extend interface Example { + name: String + } + """ + __requires__ = [ExampleType] + + data_regression.check(str(err.value)) + + +def test_interface_type_raises_error_when_defined_with_alias_for_nonexisting_field( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = """ + interface User { + name: String + } + """ + __aliases__ = { + "joinedDate": "joined_date", + } + + data_regression.check(str(err.value)) + + +def test_interface_type_raises_error_when_defined_with_resolver_for_nonexisting_field( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = """ + interface User { + name: String + } + """ + + @staticmethod + def resolve_group(*_): + return None + + data_regression.check(str(err.value)) + + +@dataclass +class User: + id: int + name: str + summary: str + + +@dataclass +class Comment: + id: int + message: str + summary: str + + +class ResultInterface(InterfaceType): + __schema__ = """ + interface Result { + summary: String! + score: Int! + } + """ + + @staticmethod + def resolve_type(instance, *_): + if isinstance(instance, Comment): + return "Comment" + + if isinstance(instance, User): + return "User" + + return None + + @staticmethod + def resolve_score(*_): + return 42 + + +class UserType(ObjectType): + __schema__ = """ + type User implements Result { + id: ID! + name: String! + summary: String! + score: Int! + } + """ + __requires__ = [ResultInterface] + + +class CommentType(ObjectType): + __schema__ = """ + type Comment implements Result { + id: ID! + message: String! + summary: String! + score: Int! + } + """ + __requires__ = [ResultInterface] + + @staticmethod + def resolve_score(*_): + return 16 + + +class QueryType(ObjectType): + __schema__ = """ + type Query { + results: [Result!]! + } + """ + __requires__ = [ResultInterface] + + @staticmethod + def resolve_results(*_): + return [ + User(id=1, name="Alice", summary="Summary for Alice"), + Comment(id=1, message="Hello world!", summary="Summary for comment"), + ] + + +schema = make_executable_schema(QueryType, UserType, CommentType) + + +def test_interface_type_binds_type_resolver(): + query = """ + query { + results { + ... on User { + __typename + id + name + summary + } + ... on Comment { + __typename + id + message + summary + } + } + } + """ + + result = graphql_sync(schema, query) + assert result.data == { + "results": [ + { + "__typename": "User", + "id": "1", + "name": "Alice", + "summary": "Summary for Alice", + }, + { + "__typename": "Comment", + "id": "1", + "message": "Hello world!", + "summary": "Summary for comment", + }, + ], + } + + +def test_interface_type_binds_field_resolvers_to_implementing_types_fields(): + query = """ + query { + results { + ... on User { + __typename + score + } + ... on Comment { + __typename + score + } + } + } + """ + + result = graphql_sync(schema, query) + assert result.data == { + "results": [ + { + "__typename": "User", + "score": 42, + }, + { + "__typename": "Comment", + "score": 16, + }, + ], + } diff --git a/tests/test_mutation_type.py b/tests_v1/test_mutation_type.py similarity index 89% rename from tests/test_mutation_type.py rename to tests_v1/test_mutation_type.py index 2b73477..0f6f4b9 100644 --- a/tests/test_mutation_type.py +++ b/tests_v1/test_mutation_type.py @@ -1,52 +1,58 @@ import pytest from graphql import GraphQLError, graphql_sync -from ariadne_graphql_modules import ( +from ariadne_graphql_modules.v1 import ( MutationType, ObjectType, make_executable_schema, ) -def test_mutation_type_raises_attribute_error_when_defined_without_schema(snapshot): +def test_mutation_type_raises_attribute_error_when_defined_without_schema( + data_regression, +): with pytest.raises(AttributeError) as err: # pylint: disable=unused-variable class UserCreateMutation(MutationType): pass - snapshot.assert_match(err) + data_regression.check(str(err.value)) -def test_mutation_type_raises_error_when_defined_with_invalid_schema_type(snapshot): +def test_mutation_type_raises_error_when_defined_with_invalid_schema_type( + data_regression, +): with pytest.raises(TypeError) as err: # pylint: disable=unused-variable class UserCreateMutation(MutationType): __schema__ = True - snapshot.assert_match(err) + data_regression.check(str(err.value)) -def test_object_type_raises_error_when_defined_with_invalid_schema_str(snapshot): +def test_object_type_raises_error_when_defined_with_invalid_schema_str(data_regression): with pytest.raises(GraphQLError) as err: # pylint: disable=unused-variable class UserCreateMutation(MutationType): __schema__ = "typo User" - snapshot.assert_match(err) + data_regression.check(str(err.value)) def test_mutation_type_raises_error_when_defined_with_invalid_graphql_type_schema( - snapshot, + data_regression, ): with pytest.raises(ValueError) as err: # pylint: disable=unused-variable class UserCreateMutation(MutationType): __schema__ = "scalar DateTime" - snapshot.assert_match(err) + data_regression.check(str(err.value)) -def test_mutation_type_raises_error_when_defined_with_multiple_types_schema(snapshot): +def test_mutation_type_raises_error_when_defined_with_multiple_types_schema( + data_regression, +): with pytest.raises(ValueError) as err: # pylint: disable=unused-variable class UserCreateMutation(MutationType): @@ -56,10 +62,12 @@ class UserCreateMutation(MutationType): type Group """ - snapshot.assert_match(err) + data_regression.check(str(err.value)) -def test_mutation_type_raises_error_when_defined_for_different_type_name(snapshot): +def test_mutation_type_raises_error_when_defined_for_different_type_name( + data_regression, +): with pytest.raises(ValueError) as err: # pylint: disable=unused-variable class UserCreateMutation(MutationType): @@ -69,10 +77,10 @@ class UserCreateMutation(MutationType): } """ - snapshot.assert_match(err) + data_regression.check(str(err.value)) -def test_mutation_type_raises_error_when_defined_without_fields(snapshot): +def test_mutation_type_raises_error_when_defined_without_fields(data_regression): with pytest.raises(ValueError) as err: # pylint: disable=unused-variable class UserCreateMutation(MutationType): @@ -80,10 +88,10 @@ class UserCreateMutation(MutationType): type Mutation """ - snapshot.assert_match(err) + data_regression.check(str(err.value)) -def test_mutation_type_raises_error_when_defined_with_multiple_fields(snapshot): +def test_mutation_type_raises_error_when_defined_with_multiple_fields(data_regression): with pytest.raises(ValueError) as err: # pylint: disable=unused-variable class UserCreateMutation(MutationType): @@ -94,11 +102,11 @@ class UserCreateMutation(MutationType): } """ - snapshot.assert_match(err) + data_regression.check(str(err.value)) def test_mutation_type_raises_error_when_defined_without_resolve_mutation_attr( - snapshot, + data_regression, ): with pytest.raises(AttributeError) as err: # pylint: disable=unused-variable @@ -109,11 +117,11 @@ class UserCreateMutation(MutationType): } """ - snapshot.assert_match(err) + data_regression.check(str(err.value)) def test_mutation_type_raises_error_when_defined_without_callable_resolve_mutation_attr( - snapshot, + data_regression, ): with pytest.raises(TypeError) as err: # pylint: disable=unused-variable @@ -126,11 +134,11 @@ class UserCreateMutation(MutationType): resolve_mutation = True - snapshot.assert_match(err) + data_regression.check(str(err.value)) def test_mutation_type_raises_error_when_defined_without_return_type_dependency( - snapshot, + data_regression, ): with pytest.raises(ValueError) as err: # pylint: disable=unused-variable @@ -145,7 +153,7 @@ class UserCreateMutation(MutationType): def resolve_mutation(*_args): pass - snapshot.assert_match(err) + data_regression.check(str(err.value)) def test_mutation_type_verifies_field_dependency(): @@ -171,7 +179,7 @@ def resolve_mutation(*_args): def test_mutation_type_raises_error_when_defined_with_nonexistant_args( - snapshot, + data_regression, ): with pytest.raises(ValueError) as err: # pylint: disable=unused-variable @@ -187,7 +195,7 @@ class UserCreateMutation(MutationType): def resolve_mutation(*_args): pass - snapshot.assert_match(err) + data_regression.check(str(err.value)) class QueryType(ObjectType): diff --git a/tests_v1/test_object_type.py b/tests_v1/test_object_type.py new file mode 100644 index 0000000..50f48f4 --- /dev/null +++ b/tests_v1/test_object_type.py @@ -0,0 +1,410 @@ +import pytest +from ariadne import SchemaDirectiveVisitor +from graphql import GraphQLError, graphql_sync + +from ariadne_graphql_modules.v1 import ( + DeferredType, + DirectiveType, + InterfaceType, + ObjectType, + make_executable_schema, +) + + +def test_object_type_raises_attribute_error_when_defined_without_schema( + data_regression, +): + with pytest.raises(AttributeError) as err: + # pylint: disable=unused-variable + class UserType(ObjectType): + pass + + data_regression.check(str(err.value)) + + +def test_object_type_raises_error_when_defined_with_invalid_schema_type( + data_regression, +): + with pytest.raises(TypeError) as err: + # pylint: disable=unused-variable + class UserType(ObjectType): + __schema__ = True + + data_regression.check(str(err.value)) + + +def test_object_type_raises_error_when_defined_with_invalid_schema_str(data_regression): + with pytest.raises(GraphQLError) as err: + # pylint: disable=unused-variable + class UserType(ObjectType): + __schema__ = "typo User" + + data_regression.check(str(err.value)) + + +def test_object_type_raises_error_when_defined_with_invalid_graphql_type_schema( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserType(ObjectType): + __schema__ = "scalar DateTime" + + data_regression.check(str(err.value)) + + +def test_object_type_raises_error_when_defined_with_multiple_types_schema( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserType(ObjectType): + __schema__ = """ + type User + + type Group + """ + + data_regression.check(str(err.value)) + + +def test_object_type_raises_error_when_defined_without_fields(data_regression): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserType(ObjectType): + __schema__ = "type User" + + data_regression.check(str(err.value)) + + +def test_object_type_extracts_graphql_name(): + class GroupType(ObjectType): + __schema__ = """ + type Group { + id: ID! + } + """ + + assert GroupType.graphql_name == "Group" + + +def test_object_type_accepts_all_builtin_scalar_types(): + # pylint: disable=unused-variable + class FancyObjectType(ObjectType): + __schema__ = """ + type FancyObject { + id: ID! + someInt: Int! + someFloat: Float! + someBoolean: Boolean! + someString: String! + } + """ + + +def test_object_type_raises_error_when_defined_without_return_type_dependency( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserType(ObjectType): + __schema__ = """ + type User { + group: Group + groups: [Group!] + } + """ + + data_regression.check(str(err.value)) + + +def test_object_type_verifies_field_dependency(): + # pylint: disable=unused-variable + class GroupType(ObjectType): + __schema__ = """ + type Group { + id: ID! + } + """ + + class UserType(ObjectType): + __schema__ = """ + type User { + group: Group + groups: [Group!] + } + """ + __requires__ = [GroupType] + + +def test_object_type_verifies_circular_dependency(): + # pylint: disable=unused-variable + class UserType(ObjectType): + __schema__ = """ + type User { + follows: User + } + """ + + +def test_object_type_raises_error_when_defined_without_argument_type_dependency( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserType(ObjectType): + __schema__ = """ + type User { + actions(input: UserInput): [String!]! + } + """ + + data_regression.check(str(err.value)) + + +def test_object_type_verifies_circular_dependency_using_deferred_type(): + # pylint: disable=unused-variable + class GroupType(ObjectType): + __schema__ = """ + type Group { + id: ID! + users: [User] + } + """ + __requires__ = [DeferredType("User")] + + class UserType(ObjectType): + __schema__ = """ + type User { + group: Group + } + """ + __requires__ = [GroupType] + + +def test_object_type_can_be_extended_with_new_fields(): + # pylint: disable=unused-variable + class UserType(ObjectType): + __schema__ = """ + type User { + id: ID! + } + """ + + class ExtendUserType(ObjectType): + __schema__ = """ + extend type User { + name: String + } + """ + __requires__ = [UserType] + + +def test_object_type_can_be_extended_with_directive(): + # pylint: disable=unused-variable + class ExampleDirective(DirectiveType): + __schema__ = "directive @example on OBJECT" + __visitor__ = SchemaDirectiveVisitor + + class UserType(ObjectType): + __schema__ = """ + type User { + id: ID! + } + """ + + class ExtendUserType(ObjectType): + __schema__ = """ + extend type User @example + """ + __requires__ = [UserType, ExampleDirective] + + +def test_object_type_can_be_extended_with_interface(): + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Interface { + id: ID! + } + """ + + class UserType(ObjectType): + __schema__ = """ + type User { + id: ID! + } + """ + + class ExtendUserType(ObjectType): + __schema__ = """ + extend type User implements Interface + """ + __requires__ = [UserType, ExampleInterface] + + +def test_object_type_raises_error_when_defined_without_extended_dependency( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExtendUserType(ObjectType): + __schema__ = """ + extend type User { + name: String + } + """ + + data_regression.check(str(err.value)) + + +def test_object_type_raises_error_when_extended_dependency_is_wrong_type( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Example { + id: ID! + } + """ + + class ExampleType(ObjectType): + __schema__ = """ + extend type Example { + name: String + } + """ + __requires__ = [ExampleInterface] + + data_regression.check(str(err.value)) + + +def test_object_type_raises_error_when_defined_with_alias_for_nonexisting_field( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserType(ObjectType): + __schema__ = """ + type User { + name: String + } + """ + __aliases__ = { + "joinedDate": "joined_date", + } + + data_regression.check(str(err.value)) + + +def test_object_type_raises_error_when_defined_with_resolver_for_nonexisting_field( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserType(ObjectType): + __schema__ = """ + type User { + name: String + } + """ + + @staticmethod + def resolve_group(*_): + return None + + data_regression.check(str(err.value)) + + +def test_object_type_raises_error_when_defined_with_field_args_for_nonexisting_field( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserType(ObjectType): + __schema__ = """ + type User { + name: String + } + """ + __fields_args__ = {"group": {}} + + data_regression.check(str(err.value)) + + +def test_object_type_raises_error_when_defined_with_field_args_for_nonexisting_arg( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UserType(ObjectType): + __schema__ = """ + type User { + name: String + } + """ + __fields_args__ = {"name": {"arg": "arg2"}} + + data_regression.check(str(err.value)) + + +class QueryType(ObjectType): + __schema__ = """ + type Query { + field: String! + other: String! + firstField: String! + secondField: String! + fieldWithArg(someArg: String): String! + } + """ + __aliases__ = { + "firstField": "first_field", + "secondField": "second_field", + "fieldWithArg": "field_with_arg", + } + __fields_args__ = {"fieldWithArg": {"someArg": "some_arg"}} + + @staticmethod + def resolve_other(*_): + return "Word Up!" + + @staticmethod + def resolve_second_field(obj, *_): + return "Obj: %s" % obj["secondField"] + + @staticmethod + def resolve_field_with_arg(*_, some_arg): + return some_arg + + +schema = make_executable_schema(QueryType) + + +def test_object_resolves_field_with_default_resolver(): + result = graphql_sync(schema, "{ field }", root_value={"field": "Hello!"}) + assert result.data["field"] == "Hello!" + + +def test_object_resolves_field_with_custom_resolver(): + result = graphql_sync(schema, "{ other }") + assert result.data["other"] == "Word Up!" + + +def test_object_resolves_field_with_aliased_default_resolver(): + result = graphql_sync( + schema, "{ firstField }", root_value={"first_field": "Howdy?"} + ) + assert result.data["firstField"] == "Howdy?" + + +def test_object_resolves_field_with_aliased_custom_resolver(): + result = graphql_sync(schema, "{ secondField }", root_value={"secondField": "Hey!"}) + assert result.data["secondField"] == "Obj: Hey!" + + +def test_object_resolves_field_with_arg_out_name_customized(): + result = graphql_sync(schema, '{ fieldWithArg(someArg: "test") }') + assert result.data["fieldWithArg"] == "test" diff --git a/tests_v1/test_scalar_type.py b/tests_v1/test_scalar_type.py new file mode 100644 index 0000000..d66f66e --- /dev/null +++ b/tests_v1/test_scalar_type.py @@ -0,0 +1,287 @@ +from datetime import date, datetime + +import pytest +from ariadne import SchemaDirectiveVisitor +from graphql import GraphQLError, StringValueNode, graphql_sync + +from ariadne_graphql_modules.v1 import ( + DirectiveType, + ObjectType, + ScalarType, + make_executable_schema, +) + + +def test_scalar_type_raises_attribute_error_when_defined_without_schema( + data_regression, +): + with pytest.raises(AttributeError) as err: + # pylint: disable=unused-variable + class DateScalar(ScalarType): + pass + + data_regression.check(str(err.value)) + + +def test_scalar_type_raises_error_when_defined_with_invalid_schema_type( + data_regression, +): + with pytest.raises(TypeError) as err: + # pylint: disable=unused-variable + class DateScalar(ScalarType): + __schema__ = True + + data_regression.check(str(err.value)) + + +def test_scalar_type_raises_error_when_defined_with_invalid_schema_str(data_regression): + with pytest.raises(GraphQLError) as err: + # pylint: disable=unused-variable + class DateScalar(ScalarType): + __schema__ = "scalor Date" + + data_regression.check(str(err.value)) + + +def test_scalar_type_raises_error_when_defined_with_invalid_graphql_type_schema( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class DateScalar(ScalarType): + __schema__ = "type DateTime" + + data_regression.check(str(err.value)) + + +def test_scalar_type_raises_error_when_defined_with_multiple_types_schema( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class DateScalar(ScalarType): + __schema__ = """ + scalar Date + + scalar DateTime + """ + + data_regression.check(str(err.value)) + + +def test_scalar_type_extracts_graphql_name(): + class DateScalar(ScalarType): + __schema__ = "scalar Date" + + assert DateScalar.graphql_name == "Date" + + +def test_scalar_type_can_be_extended_with_directive(): + # pylint: disable=unused-variable + class ExampleDirective(DirectiveType): + __schema__ = "directive @example on SCALAR" + __visitor__ = SchemaDirectiveVisitor + + class DateScalar(ScalarType): + __schema__ = "scalar Date" + + class ExtendDateScalar(ScalarType): + __schema__ = "extend scalar Date @example" + __requires__ = [DateScalar, ExampleDirective] + + +class DateReadOnlyScalar(ScalarType): + __schema__ = "scalar DateReadOnly" + + @staticmethod + def serialize(date): + return date.strftime("%Y-%m-%d") + + +class DateInputScalar(ScalarType): + __schema__ = "scalar DateInput" + + @staticmethod + def parse_value(formatted_date): + parsed_datetime = datetime.strptime(formatted_date, "%Y-%m-%d") + return parsed_datetime.date() + + @staticmethod + def parse_literal(ast, variable_values=None): # pylint: disable=unused-argument + if not isinstance(ast, StringValueNode): + raise ValueError() + + formatted_date = ast.value + parsed_datetime = datetime.strptime(formatted_date, "%Y-%m-%d") + return parsed_datetime.date() + + +class DefaultParserScalar(ScalarType): + __schema__ = "scalar DefaultParser" + + @staticmethod + def parse_value(value): + return type(value).__name__ + + +TEST_DATE = date(2006, 9, 13) +TEST_DATE_SERIALIZED = TEST_DATE.strftime("%Y-%m-%d") + + +class QueryType(ObjectType): + __schema__ = """ + type Query { + testSerialize: DateReadOnly! + testInput(value: DateInput!): Boolean! + testInputValueType(value: DefaultParser!): String! + } + """ + __requires__ = [ + DateReadOnlyScalar, + DateInputScalar, + DefaultParserScalar, + ] + __aliases__ = { + "testSerialize": "test_serialize", + "testInput": "test_input", + "testInputValueType": "test_input_value_type", + } + + @staticmethod + def resolve_test_serialize(*_): + return TEST_DATE + + @staticmethod + def resolve_test_input(*_, value): + assert value == TEST_DATE + return True + + @staticmethod + def resolve_test_input_value_type(*_, value): + return value + + +schema = make_executable_schema(QueryType) + + +def test_attempt_deserialize_str_literal_without_valid_date_raises_error(): + test_input = "invalid string" + result = graphql_sync(schema, '{ testInput(value: "%s") }' % test_input) + assert result.errors is not None + assert str(result.errors[0]).splitlines()[:1] == [ + "Expected value of type 'DateInput!', found \"invalid string\"; " + "time data 'invalid string' does not match format '%Y-%m-%d'" + ] + + +def test_attempt_deserialize_wrong_type_literal_raises_error(): + test_input = 123 + result = graphql_sync(schema, "{ testInput(value: %s) }" % test_input) + assert result.errors is not None + assert str(result.errors[0]).splitlines()[:1] == [ + "Expected value of type 'DateInput!', found 123; " + ] + + +def test_default_literal_parser_is_used_to_extract_value_str_from_ast_node(): + class ValueParserOnlyScalar(ScalarType): + __schema__ = "scalar DateInput" + + @staticmethod + def parse_value(formatted_date): + parsed_datetime = datetime.strptime(formatted_date, "%Y-%m-%d") + return parsed_datetime.date() + + class ValueParserOnlyQueryType(ObjectType): + __schema__ = """ + type Query { + parse(value: DateInput!): String! + } + """ + __requires__ = [ValueParserOnlyScalar] + + @staticmethod + def resolve_parse(*_, value): + return value + + schema = make_executable_schema(ValueParserOnlyQueryType) + result = graphql_sync(schema, """{ parse(value: "%s") }""" % TEST_DATE_SERIALIZED) + assert result.errors is None + assert result.data == {"parse": "2006-09-13"} + + +parametrized_query = """ + query parseValueTest($value: DateInput!) { + testInput(value: $value) + } +""" + + +def test_variable_with_valid_date_string_is_deserialized_to_python_date(): + variables = {"value": TEST_DATE_SERIALIZED} + result = graphql_sync(schema, parametrized_query, variable_values=variables) + assert result.errors is None + assert result.data == {"testInput": True} + + +def test_attempt_deserialize_str_variable_without_valid_date_raises_error(): + variables = {"value": "invalid string"} + result = graphql_sync(schema, parametrized_query, variable_values=variables) + assert result.errors is not None + assert str(result.errors[0]).splitlines()[:1] == [ + "Variable '$value' got invalid value 'invalid string'; " + "Expected type 'DateInput'. " + "time data 'invalid string' does not match format '%Y-%m-%d'" + ] + + +def test_attempt_deserialize_wrong_type_variable_raises_error(): + variables = {"value": 123} + result = graphql_sync(schema, parametrized_query, variable_values=variables) + assert result.errors is not None + assert str(result.errors[0]).splitlines()[:1] == [ + "Variable '$value' got invalid value 123; Expected type 'DateInput'. " + "strptime() argument 1 must be str, not int" + ] + + +def test_literal_string_is_deserialized_by_default_parser(): + result = graphql_sync(schema, '{ testInputValueType(value: "test") }') + assert result.errors is None + assert result.data == {"testInputValueType": "str"} + + +def test_literal_int_is_deserialized_by_default_parser(): + result = graphql_sync(schema, "{ testInputValueType(value: 123) }") + assert result.errors is None + assert result.data == {"testInputValueType": "int"} + + +def test_literal_float_is_deserialized_by_default_parser(): + result = graphql_sync(schema, "{ testInputValueType(value: 1.5) }") + assert result.errors is None + assert result.data == {"testInputValueType": "float"} + + +def test_literal_bool_true_is_deserialized_by_default_parser(): + result = graphql_sync(schema, "{ testInputValueType(value: true) }") + assert result.errors is None + assert result.data == {"testInputValueType": "bool"} + + +def test_literal_bool_false_is_deserialized_by_default_parser(): + result = graphql_sync(schema, "{ testInputValueType(value: false) }") + assert result.errors is None + assert result.data == {"testInputValueType": "bool"} + + +def test_literal_object_is_deserialized_by_default_parser(): + result = graphql_sync(schema, "{ testInputValueType(value: {}) }") + assert result.errors is None + assert result.data == {"testInputValueType": "dict"} + + +def test_literal_list_is_deserialized_by_default_parser(): + result = graphql_sync(schema, "{ testInputValueType(value: []) }") + assert result.errors is None + assert result.data == {"testInputValueType": "list"} diff --git a/tests_v1/test_subscription_type.py b/tests_v1/test_subscription_type.py new file mode 100644 index 0000000..90375a8 --- /dev/null +++ b/tests_v1/test_subscription_type.py @@ -0,0 +1,325 @@ +import pytest +from ariadne import SchemaDirectiveVisitor +from graphql import GraphQLError, build_schema + +from ariadne_graphql_modules.v1 import ( + DirectiveType, + InterfaceType, + ObjectType, + SubscriptionType, +) + + +def test_subscription_type_raises_attribute_error_when_defined_without_schema( + data_regression, +): + with pytest.raises(AttributeError) as err: + # pylint: disable=unused-variable + class UsersSubscription(SubscriptionType): + pass + + data_regression.check(str(err.value)) + + +def test_subscription_type_raises_error_when_defined_with_invalid_schema_type( + data_regression, +): + with pytest.raises(TypeError) as err: + # pylint: disable=unused-variable + class UsersSubscription(SubscriptionType): + __schema__ = True + + data_regression.check(str(err.value)) + + +def test_subscription_type_raises_error_when_defined_with_invalid_schema_str( + data_regression, +): + with pytest.raises(GraphQLError) as err: + # pylint: disable=unused-variable + class UsersSubscription(SubscriptionType): + __schema__ = "typo Subscription" + + data_regression.check(str(err.value)) + + +def test_subscription_type_raises_error_when_defined_with_invalid_graphql_type_schema( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UsersSubscription(SubscriptionType): + __schema__ = "scalar Subscription" + + data_regression.check(str(err.value)) + + +def test_subscription_type_raises_error_when_defined_with_invalid_graphql_type_name( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UsersSubscription(SubscriptionType): + __schema__ = "type Other" + + data_regression.check(str(err.value)) + + +def test_subscription_type_raises_error_when_defined_without_fields(data_regression): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class UsersSubscription(SubscriptionType): + __schema__ = "type Subscription" + + data_regression.check(str(err.value)) + + +def test_subscription_type_extracts_graphql_name(): + class UsersSubscription(SubscriptionType): + __schema__ = """ + type Subscription { + thread: ID! + } + """ + + assert UsersSubscription.graphql_name == "Subscription" + + +def test_subscription_type_raises_error_when_defined_without_return_type_dependency( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ChatSubscription(SubscriptionType): + __schema__ = """ + type Subscription { + chat: Chat + Chats: [Chat!] + } + """ + + data_regression.check(str(err.value)) + + +def test_subscription_type_verifies_field_dependency(): + # pylint: disable=unused-variable + class ChatType(ObjectType): + __schema__ = """ + type Chat { + id: ID! + } + """ + + class ChatSubscription(SubscriptionType): + __schema__ = """ + type Subscription { + chat: Chat + Chats: [Chat!] + } + """ + __requires__ = [ChatType] + + +def test_subscription_type_raises_error_when_defined_without_argument_type_dependency( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ChatSubscription(SubscriptionType): + __schema__ = """ + type Subscription { + chat(input: ChannelInput): [String!]! + } + """ + + data_regression.check(str(err.value)) + + +def test_subscription_type_can_be_extended_with_new_fields(): + # pylint: disable=unused-variable + class ChatSubscription(SubscriptionType): + __schema__ = """ + type Subscription { + chat: ID! + } + """ + + class ExtendChatSubscription(SubscriptionType): + __schema__ = """ + extend type Subscription { + thread: ID! + } + """ + __requires__ = [ChatSubscription] + + +def test_subscription_type_can_be_extended_with_directive(): + # pylint: disable=unused-variable + class ExampleDirective(DirectiveType): + __schema__ = "directive @example on OBJECT" + __visitor__ = SchemaDirectiveVisitor + + class ChatSubscription(SubscriptionType): + __schema__ = """ + type Subscription { + chat: ID! + } + """ + + class ExtendChatSubscription(SubscriptionType): + __schema__ = "extend type Subscription @example" + __requires__ = [ChatSubscription, ExampleDirective] + + +def test_subscription_type_can_be_extended_with_interface(): + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Interface { + threads: ID! + } + """ + + class ChatSubscription(SubscriptionType): + __schema__ = """ + type Subscription { + chat: ID! + } + """ + + class ExtendChatSubscription(SubscriptionType): + __schema__ = """ + extend type Subscription implements Interface { + threads: ID! + } + """ + __requires__ = [ChatSubscription, ExampleInterface] + + +def test_subscription_type_raises_error_when_defined_without_extended_dependency( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExtendChatSubscription(SubscriptionType): + __schema__ = """ + extend type Subscription { + thread: ID! + } + """ + + data_regression.check(str(err.value)) + + +def test_subscription_type_raises_error_when_extended_dependency_is_wrong_type( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleInterface(InterfaceType): + __schema__ = """ + interface Subscription { + id: ID! + } + """ + + class ExtendChatSubscription(SubscriptionType): + __schema__ = """ + extend type Subscription { + thread: ID! + } + """ + __requires__ = [ExampleInterface] + + data_regression.check(str(err.value)) + + +def test_subscription_type_raises_error_when_defined_with_alias_for_nonexisting_field( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ChatSubscription(SubscriptionType): + __schema__ = """ + type Subscription { + chat: ID! + } + """ + __aliases__ = { + "userAlerts": "user_alerts", + } + + data_regression.check(str(err.value)) + + +def test_subscription_type_raises_error_when_defined_with_resolver_for_nonexisting_field( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ChatSubscription(SubscriptionType): + __schema__ = """ + type Subscription { + chat: ID! + } + """ + + @staticmethod + def resolve_group(*_): + return None + + data_regression.check(str(err.value)) + + +def test_subscription_type_raises_error_when_defined_with_sub_for_nonexisting_field( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ChatSubscription(SubscriptionType): + __schema__ = """ + type Subscription { + chat: ID! + } + """ + + @staticmethod + def subscribe_group(*_): + return None + + data_regression.check(str(err.value)) + + +def test_subscription_type_binds_resolver_and_subscriber_to_schema(): + schema = build_schema( + """ + type Query { + hello: String + } + + type Subscription { + chat: ID! + } + """ + ) + + class ChatSubscription(SubscriptionType): + __schema__ = """ + type Subscription { + chat: ID! + } + """ + + @staticmethod + def resolve_chat(*_): + return None + + @staticmethod + def subscribe_chat(*_): + return None + + ChatSubscription.__bind_to_schema__(schema) + + field = schema.type_map.get("Subscription").fields["chat"] + assert field.resolve is ChatSubscription.resolve_chat + assert field.subscribe is ChatSubscription.subscribe_chat diff --git a/tests_v1/test_union_type.py b/tests_v1/test_union_type.py new file mode 100644 index 0000000..4965981 --- /dev/null +++ b/tests_v1/test_union_type.py @@ -0,0 +1,251 @@ +from dataclasses import dataclass + +import pytest +from ariadne import SchemaDirectiveVisitor +from graphql import GraphQLError, graphql_sync + +from ariadne_graphql_modules.v1 import ( + DirectiveType, + ObjectType, + UnionType, + make_executable_schema, +) + + +def test_union_type_raises_attribute_error_when_defined_without_schema(data_regression): + with pytest.raises(AttributeError) as err: + # pylint: disable=unused-variable + class ExampleUnion(UnionType): + pass + + data_regression.check(str(err.value)) + + +def test_union_type_raises_error_when_defined_with_invalid_schema_type(data_regression): + with pytest.raises(TypeError) as err: + # pylint: disable=unused-variable + class ExampleUnion(UnionType): + __schema__ = True + + data_regression.check(str(err.value)) + + +def test_union_type_raises_error_when_defined_with_invalid_schema_str(data_regression): + with pytest.raises(GraphQLError) as err: + # pylint: disable=unused-variable + class ExampleUnion(UnionType): + __schema__ = "unien Example = A | B" + + data_regression.check(str(err.value)) + + +def test_union_type_raises_error_when_defined_with_invalid_graphql_type_schema( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleUnion(UnionType): + __schema__ = "scalar DateTime" + + data_regression.check(str(err.value)) + + +def test_union_type_raises_error_when_defined_with_multiple_types_schema( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleUnion(UnionType): + __schema__ = """ + union A = C | D + + union B = C | D + """ + + data_regression.check(str(err.value)) + + +@dataclass +class User: + id: int + name: str + + +@dataclass +class Comment: + id: int + message: str + + +class UserType(ObjectType): + __schema__ = """ + type User { + id: ID! + name: String! + } + """ + + +class CommentType(ObjectType): + __schema__ = """ + type Comment { + id: ID! + message: String! + } + """ + + +class ResultUnion(UnionType): + __schema__ = "union Result = Comment | User" + __requires__ = [CommentType, UserType] + + @staticmethod + def resolve_type(instance, *_): + if isinstance(instance, Comment): + return "Comment" + + if isinstance(instance, User): + return "User" + + return None + + +class QueryType(ObjectType): + __schema__ = """ + type Query { + results: [Result!]! + } + """ + __requires__ = [ResultUnion] + + @staticmethod + def resolve_results(*_): + return [ + User(id=1, name="Alice"), + Comment(id=1, message="Hello world!"), + ] + + +schema = make_executable_schema(QueryType, UserType, CommentType) + + +def test_union_type_extracts_graphql_name(): + class ExampleUnion(UnionType): + __schema__ = "union Example = User | Comment" + __requires__ = [UserType, CommentType] + + assert ExampleUnion.graphql_name == "Example" + + +def test_union_type_raises_error_when_defined_without_member_type_dependency( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleUnion(UnionType): + __schema__ = "union Example = User | Comment" + __requires__ = [UserType] + + data_regression.check(str(err.value)) + + +def test_interface_type_binds_type_resolver(): + query = """ + query { + results { + ... on User { + __typename + id + name + } + ... on Comment { + __typename + id + message + } + } + } + """ + + result = graphql_sync(schema, query) + assert result.data == { + "results": [ + { + "__typename": "User", + "id": "1", + "name": "Alice", + }, + { + "__typename": "Comment", + "id": "1", + "message": "Hello world!", + }, + ], + } + + +def test_union_type_can_be_extended_with_new_types(): + # pylint: disable=unused-variable + class ExampleUnion(UnionType): + __schema__ = "union Result = User | Comment" + __requires__ = [UserType, CommentType] + + class ThreadType(ObjectType): + __schema__ = """ + type Thread { + id: ID! + title: String! + } + """ + + class ExtendExampleUnion(UnionType): + __schema__ = "union Result = Thread" + __requires__ = [ExampleUnion, ThreadType] + + +def test_union_type_can_be_extended_with_directive(): + # pylint: disable=unused-variable + class ExampleDirective(DirectiveType): + __schema__ = "directive @example on UNION" + __visitor__ = SchemaDirectiveVisitor + + class ExampleUnion(UnionType): + __schema__ = "union Result = User | Comment" + __requires__ = [UserType, CommentType] + + class ExtendExampleUnion(UnionType): + __schema__ = """ + extend union Result @example + """ + __requires__ = [ExampleUnion, ExampleDirective] + + +def test_union_type_raises_error_when_defined_without_extended_dependency( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExtendExampleUnion(UnionType): + __schema__ = "extend union Result = User" + __requires__ = [UserType] + + data_regression.check(str(err.value)) + + +def test_union_type_raises_error_when_extended_dependency_is_wrong_type( + data_regression, +): + with pytest.raises(ValueError) as err: + # pylint: disable=unused-variable + class ExampleType(ObjectType): + __schema__ = """ + type Example { + id: ID! + } + """ + + class ExtendExampleUnion(UnionType): + __schema__ = "extend union Example = User" + __requires__ = [ExampleType, UserType] + + data_regression.check(str(err.value))