Skip to content

Commit

Permalink
Merge pull request #108 from evo-company/add-unions-support
Browse files Browse the repository at this point in the history
add unions support
  • Loading branch information
kindermax committed Jul 11, 2023
2 parents 6782b33 + c01f89c commit 0214d06
Show file tree
Hide file tree
Showing 42 changed files with 2,114 additions and 739 deletions.
13 changes: 12 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,15 @@ jobs:
if: startsWith(matrix.python-version, '3.7')
run: pdm run fmt --check
- name: Run unit tests
run: tox run -- --cov-report=term
run: tox run -- --cov-report=term

federation-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install Lets
uses: lets-cli/lets-action@v1.1
with:
version: latest
- name: Test federation compatibility
run: timeout 600 lets federation-compatibility-test
1 change: 1 addition & 0 deletions docs/changelog/changes_07.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Changes in 0.7
- Added support for Apollo Federation v2
- Added support for custom schema directives :ref:`Check directives documentation <directives-doc>`
- Added `ID` type.
- Added support for unions :ref:`Check unions documentation <unions-doc>`

Backward-incompatible changes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ User's Guide
asyncio
graphql
protobuf
unions
directives
federation
telemetry
Expand Down
111 changes: 111 additions & 0 deletions docs/unions.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
Unions
======

.. _unions-doc:

Union types are special types used to represent a value that could be one of a number of types.

In graphql you can use union types like this:

.. code-block::
type Audio {
id: ID!
duration: Int!
}
type Video {
id: ID!
thumbnailUrl: String!
}
union Media = Audio | Video
type Query {
search(text: String!): [Media!]!
}
In `hiku` you can use union types like this:

.. code-block:: python
from hiku.graph import Field, Graph, Link, Node, Root, Union
from hiku.types import ID, Integer, String, TypeRef, Sequence, Optional, UnionRef
def search_resolver():
return [
(1, TypeRef['Audio']),
(2, TypeRef['Video']),
]
unions = [
Union('Media', ['Audio', 'Video']),
]
GRAPH = Graph([
Node('Audio', [
Field('id', ID, audio_fields_resolver),
Field('duration', Integer, audio_fields_resolver),
]),
Node('Video', [
Field('id', ID, video_fields_resolver),
Field('thumbnailUrl', String, video_fields_resolver),
]),
Root([
Link('search', Sequence(UnionRef['Media']), search_resolver, requires=None),
]),
], unions=unions)
Lets look at the example above:

- `Union` type is defined with a list of types that are part of the union - `Union('Media', ['Audio', 'Video'])`
- `Link` type is defined with a return type of `Sequence[UnionRef['Media']]`
- `search_resolver` returns a list of tuples with an id as a first tuple element and type as a second tuple element

.. note::

`UnionRef` is a special type that is used to reference union types. It is used in the example above to define
the return type of the `search` link.

Now lets look at the query:

.. code-block:: python
query {
search(text: "test") {
__typename
id
... on Audio {
duration
}
... on Video {
thumbnailUrl
}
}
}
As a result of the query above you will get a list of objects with `__typename` and `id` fields and fields that are specific
to the type of the object.

.. code-block::
[
{
'__typename': 'Audio',
'id': 1,
'duration': 100,
},
{
'__typename': 'Video',
'id': 2,
'thumbnailUrl': 'http://example.com/thumbnail.jpg',
},
]
Type narrowing
--------------

Unlike other graphql implementations `hiku` supports type narrowing without
`__resolveType` function. It is possible because `hiku` knows all possible types
at the link resolution time.
2 changes: 1 addition & 1 deletion examples/federation-compatibility/federation.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ ENV PDM_VERSION=2.6
ENV PDM_USE_VENV=no
ENV PYTHONPATH=/work/__pypackages__/3.7/lib

RUN apt-get update && apt-get install -y libpq-dev && \
RUN apt-get update && apt-get install -y libpq-dev gcc && \
pip install --upgrade pip==${PIP_VERSION} && pip install pdm==${PDM_VERSION}

# for pyproject.toml to extract version
Expand Down
9 changes: 9 additions & 0 deletions examples/federation-compatibility/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,15 @@ def handle_graphql():
return resp


@app.route('/', methods={'GET'})
def graphiql():
path = Path(__file__).parent.parent / 'graphiql.html'
with open(path) as f:
page = f.read()
page = page.replace("localhost:5000", "localhost:4001")
return page.encode('utf-8')


def main():
logging.basicConfig(level=logging.DEBUG)

Expand Down
67 changes: 67 additions & 0 deletions examples/graphiql.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<!--
* Copyright (c) 2021 GraphQL Contributors
* All rights reserved.
*
* This source code is licensed under the license found in the
* LICENSE file in the root directory of this source tree.
-->
<!doctype html>
<html lang="en">
<head>
<title>GraphiQL</title>
<style>
body {
height: 100%;
margin: 0;
width: 100%;
overflow: hidden;
}

#graphiql {
height: 100vh;
}
</style>

<!--
This GraphiQL example depends on Promise and fetch, which are available in
modern browsers, but can be "polyfilled" for older browsers.
GraphiQL itself depends on React DOM.
If you do not want to rely on a CDN, you can host these files locally or
include them directly in your favored resource bundler.
-->
<script
crossorigin
src="https://unpkg.com/react@18/umd/react.development.js"
></script>
<script
crossorigin
src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"
></script>

<!--
These two files can be found in the npm module, however you may wish to
copy them directly into your environment, or perhaps include them in your
favored resource bundler.
-->
<link rel="stylesheet" href="https://unpkg.com/graphiql/graphiql.min.css" />
</head>

<body>
<div id="graphiql">Loading...</div>
<script
src="https://unpkg.com/graphiql/graphiql.min.js"
type="application/javascript"
></script>
<script>
const root = ReactDOM.createRoot(document.getElementById('graphiql'));
root.render(
React.createElement(GraphiQL, {
fetcher: GraphiQL.createFetcher({
url: 'http://localhost:5000/graphql',
}),
defaultEditorToolsVisibility: true,
}),
);
</script>
</body>
</html>
11 changes: 11 additions & 0 deletions examples/graphql_flask.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from pathlib import Path

from flask import Flask, request, jsonify

Expand Down Expand Up @@ -62,8 +63,18 @@ def handle_graphql():
return jsonify(result)


@app.route('/', methods={'GET'})
def graphiql():
path = Path(__file__).parent / 'graphiql.html'
with open(path) as f:
return f.read().encode('utf-8')


def main():
logging.basicConfig()
log.setLevel(logging.INFO)
log.info('GraphiQL is available on http://localhost:5000')
log.info('GraphQL endpoint is running on http://localhost:5000/graphql')
app.run(host='0.0.0.0', port=5000)


Expand Down
29 changes: 16 additions & 13 deletions hiku/denormalize/base.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import typing as t
from collections import deque
from typing import (
Deque,
Dict,
)

from ..graph import Graph
from ..graph import Graph, Union
from ..query import (
QueryVisitor,
Link,
Expand All @@ -13,11 +10,12 @@
)
from ..result import Proxy
from ..types import (
Record,
TypeRefMeta,
OptionalMeta,
SequenceMeta,
UnionRefMeta,
get_type,
RecordMeta,
)


Expand All @@ -26,11 +24,13 @@ def __init__(self, graph: Graph, result: Proxy) -> None:
self._types = graph.__types__
self._unions = graph.unions_map
self._result = result
self._type: Deque[RecordMeta] = deque([self._types["__root__"]])
self._type: t.Deque[t.Union[t.Type[Record], Union]] = deque(
[self._types["__root__"]]
)
self._data = deque([result])
self._res: Deque = deque()
self._res: t.Deque = deque()

def process(self, query: Node) -> Dict:
def process(self, query: Node) -> t.Dict:
assert not self._res, self._res
self._res.append({})
self.visit(query)
Expand All @@ -40,8 +40,11 @@ def visit_field(self, obj: Field) -> None:
self._res[-1][obj.result_key] = self._data[-1][obj.result_key]

def visit_link(self, obj: Link) -> None:
type_ = self._type[-1].__field_types__[obj.name]
if isinstance(type_, TypeRefMeta):
type_ = t.cast(
Record,
self._type[-1],
).__field_types__[obj.name]
if isinstance(type_, (TypeRefMeta, UnionRefMeta)):
self._type.append(get_type(self._types, type_))
self._res.append({})
self._data.append(self._data[-1][obj.result_key])
Expand All @@ -50,7 +53,7 @@ def visit_link(self, obj: Link) -> None:
self._res[-1][obj.result_key] = self._res.pop()
self._type.pop()
elif isinstance(type_, SequenceMeta):
assert isinstance(type_.__item_type__, TypeRefMeta)
assert isinstance(type_.__item_type__, (TypeRefMeta, UnionRefMeta))
self._type.append(get_type(self._types, type_.__item_type__))
items = []
for item in self._data[-1][obj.result_key]:
Expand All @@ -65,7 +68,7 @@ def visit_link(self, obj: Link) -> None:
if self._data[-1][obj.result_key] is None:
self._res[-1][obj.result_key] = None
else:
assert isinstance(type_.__type__, TypeRefMeta)
assert isinstance(type_.__type__, (TypeRefMeta, UnionRefMeta))
self._type.append(get_type(self._types, type_.__type__))
self._res.append({})
self._data.append(self._data[-1][obj.result_key])
Expand Down
26 changes: 20 additions & 6 deletions hiku/denormalize/graphql.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import typing as t
from collections import deque

from ..graph import Graph
from ..graph import Graph, Union
from ..query import Field, Link
from ..result import Proxy
from ..types import (
Record,
TypeRefMeta,
SequenceMeta,
OptionalMeta,
GenericMeta,
UnionRefMeta,
)

from .base import Denormalize
Expand All @@ -22,21 +25,32 @@ def __init__(

def visit_field(self, obj: Field) -> None:
if obj.name == "__typename":
self._res[-1][obj.result_key] = self._type_name[-1]
type_name = self._type_name[-1]
if isinstance(self._type[-1], Union):
type_name = self._data[-1].__ref__.node
self._res[-1][obj.result_key] = type_name
else:
if isinstance(self._type[-1], Union):
type_name = self._data[-1].__ref__.node

if obj.name not in self._types[type_name].__field_types__:
return
super().visit_field(obj)

def visit_link(self, obj: Link) -> None:
type_ = self._type[-1].__field_types__[obj.name]
type_ = t.cast(
Record,
self._type[-1],
).__field_types__[obj.name]
type_ref: GenericMeta
if isinstance(type_, TypeRefMeta):
if isinstance(type_, (TypeRefMeta, UnionRefMeta)):
type_ref = type_
elif isinstance(type_, SequenceMeta):
type_ref = type_.__item_type__
assert isinstance(type_ref, TypeRefMeta), type_ref
assert isinstance(type_ref, (TypeRefMeta, UnionRefMeta)), type_ref
elif isinstance(type_, OptionalMeta):
type_ref = type_.__type__
assert isinstance(type_ref, TypeRefMeta), type_ref
assert isinstance(type_ref, (TypeRefMeta, UnionRefMeta)), type_ref
else:
raise AssertionError(repr(type_))
self._type_name.append(type_ref.__type_name__)
Expand Down

0 comments on commit 0214d06

Please sign in to comment.