Skip to content

Commit

Permalink
Merge pull request #3 from lonelyteapot/parametrized-queries
Browse files Browse the repository at this point in the history
Support for queries with parameters
  • Loading branch information
lonelyteapot authored Jul 11, 2022
2 parents 177605d + d13cff1 commit 8eef732
Show file tree
Hide file tree
Showing 17 changed files with 261 additions and 14 deletions.
9 changes: 8 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
"request": "launch",
"module": "examples.basic_query.main",
"justMyCode": true,
}
},
{
"name": "Example :: Parametrized Query",
"type": "python",
"request": "launch",
"module": "examples.parametrized_query.main",
"justMyCode": true,
},
]
}
1 change: 1 addition & 0 deletions .vscode/ltex.dictionary.en-US.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ Pygraphic
turms
Semenov
Pydantic
camelCase
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ Pygraphic is in development stage. Major features might either be missing or wor
incorrectly. The API may change at any time.

- [x] Basic queries
- [ ] Queries with parameters
- [x] Queries with parameters
- [x] Custom scalars (not needed, comes with pydantic)
- [ ] Conversion between camelCase and snake_case
- [ ] Mutations
- [ ] Subscriptions
- [x] Tests
Expand Down Expand Up @@ -116,6 +117,8 @@ query GetAllUsers {
}
```

See more in [/examples](https://github.com/lonelyteapot/pygraphic/tree/main/examples).

## Contribution

This project is developed on [GitHub].
Expand Down
Empty file.
31 changes: 31 additions & 0 deletions examples/parametrized_query/get_users_born_after.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from __future__ import annotations

from datetime import date

from pydantic import Field

from pygraphic import GQLParameters, GQLQuery, GQLType
from pygraphic.types import register_graphql_type


# Register the type so that pygraphic knows how to convert it to GraphQL
register_graphql_type("Date", date)


class Parameters(GQLParameters):
bornAfter: date


class User(GQLType):
username: str
birthday: date
friends: list[UserFriend] = Field(onlineOnly=True)


class UserFriend(GQLType):
username: str
isOnline: bool


class GetUsersBornAfter(GQLQuery, parameters=Parameters):
users: list[User] = Field(bornAfter=Parameters.bornAfter)
38 changes: 38 additions & 0 deletions examples/parametrized_query/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import json
from datetime import date
from pprint import pprint

from examples.server import server_schema

import pygraphic

from .get_users_born_after import GetUsersBornAfter, Parameters


# We only want to get users born after this date
born_after = date(year=1990, month=1, day=1)


# Generate query string
gql = GetUsersBornAfter.get_query_string()
variables = Parameters(bornAfter=born_after)

# Typically you would send an HTTP Post request to a remote server.
# For simplicity, this query is processed locally.
# We dump and load json here to convert parameters from python types to strings.
response = server_schema.execute_sync(gql, json.loads(variables.json()))

# Handle errors
if response.data is None:
raise Exception("Query failed", response.errors)

# Parse the data
result = GetUsersBornAfter.parse_obj(response.data)

# Print validated data
for user in result.users:
print("User:", user.username)
print("Birthday:", user.birthday)
print("Online friends:")
pprint(user.friends)
print()
19 changes: 14 additions & 5 deletions examples/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from datetime import date, datetime
from random import choices, randint, randrange
from string import ascii_lowercase
from typing import Optional

import strawberry

Expand All @@ -13,13 +14,21 @@ class User:
username: str
is_online: bool
birthday: date
friends: list[User]
_friends: strawberry.Private[list[User]]

@strawberry.field
def friends(self, online_only: bool = False) -> list[User]:
if online_only:
return [friend for friend in self._friends if friend.is_online]
return self._friends


@strawberry.type
class Query:
@strawberry.field
def users(self) -> list[User]:
def users(self, born_after: Optional[date] = None) -> list[User]:
if born_after is not None:
return [user for user in _users if user.birthday > born_after]
return _users


Expand All @@ -29,16 +38,16 @@ def users(self) -> list[User]:
username="".join(choices(ascii_lowercase, k=randint(8, 16))),
is_online=bool(randint(0, 1)),
birthday=date.fromtimestamp(randrange(0, int(datetime.now().timestamp()))),
friends=list(),
_friends=list(),
)
for id in range(randrange(5, 10))
]

for next_i, user in enumerate(_users, 1):
for possible_friend in _users[next_i:]:
if randint(0, 1):
user.friends.append(possible_friend)
possible_friend.friends.append(user)
user._friends.append(possible_friend)
possible_friend._friends.append(user)


server_schema = strawberry.Schema(query=Query)
10 changes: 10 additions & 0 deletions golden_files/query_parametrized.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
query GetUsersBornAfter($bornAfter: Date) {
users(bornAfter: $bornAfter) {
username
birthday
friends(onlineOnly: true) {
username
isOnline
}
}
}
4 changes: 2 additions & 2 deletions golden_files/server_schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
scalar Date

type Query {
users: [User!]!
users(bornAfter: Date = null): [User!]!
}

type User {
id: Int!
username: String!
isOnline: Boolean!
birthday: Date!
friends: [User!]!
friends(onlineOnly: Boolean! = false): [User!]!
}
4 changes: 3 additions & 1 deletion pygraphic/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from . import types
from ._gql_parameters import GQLParameters
from ._gql_query import GQLQuery
from ._gql_type import GQLType


__all__ = ["GQLType", "GQLQuery"]
__all__ = ["GQLParameters", "GQLType", "GQLQuery", "types"]
21 changes: 21 additions & 0 deletions pygraphic/_gql_parameters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from __future__ import annotations

from typing import Any

import pydantic
import pydantic.main


class ModelMetaclass(pydantic.main.ModelMetaclass):
def __getattr__(cls, __name: str) -> Any:
try:
return cls.__fields__[__name]
except KeyError:
raise AttributeError(
f"type object '{cls.__name__}' has no attribute '{__name}'"
)


class GQLParameters(pydantic.BaseModel, metaclass=ModelMetaclass):
def __str__(self):
return ""
33 changes: 32 additions & 1 deletion pygraphic/_gql_query.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,47 @@
from typing import Any, Iterator, Optional

from ._gql_parameters import GQLParameters
from ._gql_type import GQLType
from .types import class_to_graphql_type


class GQLQuery(GQLType):
__parameters__ = None

@classmethod
def get_query_string(cls, named: bool = True) -> str:
if not named and cls.__parameters__ is not None:
# TODO Find a better exception type
raise Exception("Query with parameters must have a name")

def _gen():
if named:
yield "query " + cls.__name__ + " {"
params = "".join(_gen_parameter_string(cls.__parameters__))
yield "query " + cls.__name__ + params + " {"
else:
yield "query {"
for line in cls.generate_query_lines(nest_level=1):
yield line
yield "}"

return "\n".join(_gen())

def __init_subclass__(
cls,
parameters: Optional[type[GQLParameters]] = None,
**pydantic_kwargs: Any,
) -> None:
cls.__parameters__ = parameters
return super().__init_subclass__(**pydantic_kwargs)


def _gen_parameter_string(parameters: Optional[type[GQLParameters]]) -> Iterator[str]:
if parameters is None or not parameters.__fields__:
return
yield "("
for name, field in parameters.__fields__.items():
yield "$"
yield name
yield ": "
yield class_to_graphql_type(field.type_)
yield ")"
24 changes: 21 additions & 3 deletions pygraphic/_gql_type.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import inspect
import json
import typing
from typing import Iterator
from typing import Any, Iterator

import pydantic
from pydantic.fields import ModelField


class GQLType(pydantic.BaseModel):
Expand All @@ -16,12 +18,28 @@ def generate_query_lines(cls, nest_level: int = 0) -> Iterator[str]:
field_type = args[0]
if not inspect.isclass(field_type):
raise Exception(f"Type {field_type} not supported")
field_extra = cls.__fields__[field_name].field_info.extra
params = "".join(_gen_parameter_string(field_extra))
if issubclass(field_type, GQLType):
field_type.update_forward_refs()
yield " " * nest_level + field_name + " {"
yield " " * nest_level + field_name + params + " {"
for line in field_type.generate_query_lines(nest_level=nest_level + 1):
yield line
yield " " * nest_level + "}"
continue
yield " " * nest_level + field_name
yield " " * nest_level + field_name + params
continue


def _gen_parameter_string(parameters: dict[str, Any]) -> Iterator[str]:
if not parameters:
return
yield "("
for name, value in parameters.items():
yield name
yield ": "
if type(value) is ModelField:
yield "$" + value.name
continue
yield json.dumps(value, indent=None, default=str)
yield ")"
20 changes: 20 additions & 0 deletions pygraphic/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
_mapping: dict[type, str] = {
int: "Int",
float: "Float",
str: "String",
bool: "Boolean",
}


def register_graphql_type(graphql_type: str, python_class: type) -> None:
_mapping[python_class] = graphql_type


def class_to_graphql_type(python_class: type) -> str:
try:
return _mapping[python_class]
except KeyError:
raise KeyError(
f"Type '{python_class.__name__}' could not be converted to a GraphQL type. "
"See pygraphic.types.register_graphql_type"
)
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ classifiers = [
]
include = ["pygraphic/py.typed"]

[tool.poetry.urls]
"Examples" = "https://github.com/lonelyteapot/pygraphic/tree/main/examples"

[tool.poetry.dependencies]
python = "^3.10"
pydantic = "^1.9.1"
Expand Down
32 changes: 32 additions & 0 deletions tests/test_parametrized_query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from pathlib import Path

from examples.parametrized_query.get_users_born_after import GetUsersBornAfter
from examples.server import server_schema


def test_query_string_generation():
expected = Path("golden_files", "query_parametrized.gql").read_text("utf-8")
assert GetUsersBornAfter.get_query_string() == expected


def test_local_query_execution():
query = GetUsersBornAfter.get_query_string()
# variables = GetAllUsers.variables()
result = server_schema.execute_sync(query)
assert result.errors is None
assert result.data is not None


def test_pydantic_object_parsing():
query = GetUsersBornAfter.get_query_string()
result = server_schema.execute_sync(query)
assert type(result.data) is dict
result = GetUsersBornAfter.parse_obj(result.data)


def test_example():
from examples.parametrized_query.main import born_after, result

assert type(result) is GetUsersBornAfter
assert all(user.birthday > born_after for user in result.users)
assert all(all(friend.isOnline for friend in user.friends) for user in result.users)
Loading

0 comments on commit 8eef732

Please sign in to comment.