Package: graphlayer
GraphLayer is a Python library for implementing high-performance GraphQL APIs.
By running resolve functions for each node in the request rather than each node in the response,
the overhead of asynchronous function calls is reduced.
Queries can also be written directly to fetch batches directly to avoid the N+1 problem
without intermediate layers such as DataLoader.Why GraphLayer?What causes GraphQL APIs to be slow?
In most implementations of GraphQL,
resolve functions are run for each node in the response.
This can lead to poor performance for two main reasons.
The first is the N+1 problem.
The second is the overhead of calling a potentially asynchronous resolve function for every node in the response.
Although the overhead per call is small,
for large datasets, the sum of this overhead can be the vast majority of the time to respond.GraphLayer suggests instead that resolve functions could be called according to the shape of the request rather than the response.
This avoids the N+1 problem without introducing additional complexity,
such as batching requests in the manner of DataLoader and similar libraries,
and allowing resolve functions to be written in a style that more naturally maps to data stores such as SQL databases.
Secondly, although there’s still the overhead of calling resolve functions,
this overhead is multipled by the number of the nodes in the request rather than the response:
for large datasets, this is a considerable saving.As a concrete example, consider the query:query {
    books {
        title
        author {
            name
        }
    }
}A naive GraphQL implementation would issue one SQL query to get the list of all books,
then N queries to get the author of each book.
Using DataLoader, the N queries to get the author of each book would be combined into a single query.
Using GraphLayer, there would be a single query to get the authors without any batching.Installationpip install git+https://github.com/mwilliamson/python-graphlayer.git#egg=graphlayer[graphql]TutorialThis tutorial builds up a simple application using SQLAlchemy and GraphLayer.
The goal is to execute the following query:query {
    books(genre: "comedy") {
        title
        author {
            name
        }
    }
}That is, get the list of all books in the comedy genre,
with the title and name of the author for each book.In this tutorial, we’ll build up the necessary code from scratch,
using only the core of GraphLayer, to give an understanding of how GraphLayer works.
In practice, there are a number of helper functions that make implementation much simpler.
We’ll see how to write our example using those helpers at the end.EnvironmentYou’ll need a Python environment with at least Python 3.5 installed,
with graphlayer, graphql, and SQLAlchemy installed.
For instance, on the command line:python3 -m venv .venv
. .venv/bin/activate
pip install --upgrade pip setuptools wheel
pip install git+https://github.com/mwilliamson/python-graphlayer.git#egg=graphlayer[graphql]
pip install SQLAlchemyGetting startedLet’s start with a simple query, getting the count of books:query {
    bookCount
}All queries share the same root object,
but can ask for whatever fields they want.
As a first step, we’ll define the schema of the root object.
For now, we’ll define a single integer field calledbook_count(note that casing is automatically converted between camel case and snake case):importgraphlayerasgRoot=g.ObjectType("Root",fields=(g.field("book_count",type=g.Int),))We’ll also need to define how to resolve the book count by defining a resolver function.
Each resolver function takes the graph and a query of a particular type,
and returns the result of that query.
The decoratorg.resolver()is used to mark which type of query a resolver is for.
In this case, we’ll need create a resolver for the root type.
For now, we’ll define a resolver that returns a fixed object,
and prints out the query so we can a take a look at it.importgraphlayerasgfromgraphlayer.graphqlimportexecuteRoot=g.ObjectType("Root",fields=(g.field("book_count",type=g.Int),))@g.resolver(Root)defresolve_root(graph,query):print("query:",query)returnquery.create_object({"bookCount":3,})resolvers=(resolve_root,)graph_definition=g.define_graph(resolvers=resolvers)graph=graph_definition.create_graph({})execute("""
        query {
            bookCount
        }
    """,graph=graph,query_type=Root,)Running this will print out:query: ObjectQuery(
    type=Root,
    field_queries=(
        FieldQuery(
            key="bookCount",
            field=Root.fields.book_count,
            type_query=ScalarQuery(type=Int),
            args=(),
        ),
    ),
)Note that theFieldQueryhas akeyattribute.
Since the user can rename fields in the query,
we should use the key as passed in the field query.@g.resolver(Root)defresolve_root(graph,query):field_query=query.field_queries[0]returnquery.create_object({field_query.key:3,})At the moment,
since only one field is defined on Root,
we can always assume that field is being requested.
However, that often won’t be the case.
For instance, we could add an author count to the root:Root=g.ObjectType("Root",fields=(g.field("author_count",type=g.Int),g.field("book_count",type=g.Int),))Now we’ll need to check what field is being requested.@g.resolver(Root)defresolve_root(graph,query):defresolve_field(field):iffield==Root.fields.author_count:return2eliffield==Root.fields.book_count:return3else:raiseException("unknown field:{}".format(field))field_query=query.field_queries[0]returnquery.create_object({field_query.key:resolve_field(field_query.field),})What’s more, the user might request more than one field,
so we should iterate throughquery.field_querieswhen generating the result.@g.resolver(Root)defresolve_root(graph,query):defresolve_field(field):iffield==Root.fields.author_count:return2eliffield==Root.fields.book_count:return3else:raiseException("unknown field:{}".format(field))returnquery.create_object(dict((field_query.key,resolve_field(field_query.field))forfield_queryinquery.field_queries))If we print the data from the execution result:print("result:",execute("""
        query {
            bookCount
        }
    """,graph=graph,query_type=Root,).data)Then we should get the output:result: {'bookCount': 3}Adding SQLAlchemySo far, we’ve returned hard-coded values.
Let’s add in a database using SQLAlchemy and an in-memory SQLite database.
At the start of our script we’ll add some code to set up the database schema and add data:importsqlalchemy.ext.declarativeimportsqlalchemy.ormBase=sqlalchemy.ext.declarative.declarative_base()classAuthorRecord(Base):__tablename__="author"id=sqlalchemy.Column(sqlalchemy.Integer,primary_key=True)name=sqlalchemy.Column(sqlalchemy.Unicode,nullable=False)classBookRecord(Base):__tablename__="book"id=sqlalchemy.Column(sqlalchemy.Integer,primary_key=True)title=sqlalchemy.Column(sqlalchemy.Unicode,nullable=False)genre=sqlalchemy.Column(sqlalchemy.Unicode,nullable=False)author_id=sqlalchemy.Column(sqlalchemy.Integer,sqlalchemy.ForeignKey(AuthorRecord.id),nullable=False)engine=sqlalchemy.create_engine("sqlite:///:memory:")Base.metadata.create_all(engine)session=sqlalchemy.orm.Session(engine)author_wodehouse=AuthorRecord(name="PG Wodehouse")author_bernières=AuthorRecord(name="Louis de Bernières")session.add_all((author_wodehouse,author_bernières))session.flush()session.add(BookRecord(title="Leave It to Psmith",genre="comedy",author_id=author_wodehouse.id))session.add(BookRecord(title="Right Ho, Jeeves",genre="comedy",author_id=author_wodehouse.id))session.add(BookRecord(title="Captain Corelli's Mandolin",genre="historical_fiction",author_id=author_bernières.id))session.flush()Next, we’ll update our resolvers to use the database:@g.resolver(Root)defresolve_root(graph,query):defresolve_field(field):iffield==Root.fields.author_count:returnsession.query(AuthorRecord).count()eliffield==Root.fields.book_count:returnsession.query(BookRecord).count()else:raiseException("unknown field:{}".format(field))returnquery.create_object(dict((field_query.key,resolve_field(field_query.field))forfield_queryinquery.field_queries))Adding books to the rootSo far, we’ve added two scalar fields to the root.
Let’s add in abooksfield, which should be a little more interesting.
Our aim is to be able to run the query:query {
    books {
        title
    }
}We start by creating aBookobject type,
and using it to define thebooksfield onRoot:Book=g.ObjectType("Book",fields=(g.field("title",type=g.String),g.field("genre",type=g.String),))Root=g.ObjectType("Root",fields=(g.field("author_count",type=g.Int),g.field("book_count",type=g.Int),g.field("books",type=g.ListType(Book)),))We’ll need to update the root resolver to handle the new field.
Although we could handle the field directly in the root resolver,
we’ll instead ask the graph to resolve the query for us.
This allows us to have a common way to resolve books,
regardless of where they appear in the query.@g.resolver(Root)defresolve_root(graph,query):defresolve_field(field_query):iffield_query.field==Root.fields.author_count:returnsession.query(AuthorRecord).count()eliffield_query.field==Root.fields.book_count:returnsession.query(BookRecord).count()eliffield_query.field==Root.fields.books:returngraph.resolve(field_query.type_query)else:raiseException("unknown field:{}".format(field_query.field))returnquery.create_object(dict((field_query.key,resolve_field(field_query))forfield_queryinquery.field_queries))This means we need to define a resolver for a list of books.
For now, let’s just print the query and return an empty list so we can see what the query looks like.@g.resolver(g.ListType(Book))defresolve_books(graph,query):print("books query:",query)return[]resolvers=(resolve_root,resolve_books)If update the query we pass toexecute:print("result:",execute("""
        query {
            books {
                title
            }
        }
    """,graph=graph,query_type=Root,))Then our script should now produce the output:books query: ListQuery(
    type=List(Book),
    element_query=ObjectQuery(
        type=Book,
        field_queries=(
            FieldQuery(
                key="title",
                field=Book.fields.title,
                type_query=ScalarQuery(type=String),
                args=(),
            ),
        ),
    ),
)
result: {'books': []}Similarly to theObjectQuerywe had when resolving the root object,
we have anObjectQueryforBook.
Since a list is being requested, this is then wrapped in aListQuery,
with the object query being accessible through theelement_queryattribute.We can write a resolver for a list of books by first fetching all of the books,
and then mapping each fetched book to an object according to the fields requested in the query.@g.resolver(g.ListType(Book))defresolve_books(graph,query):books=session.query(BookRecord.title,BookRecord.genre).all()defresolve_field(book,field):iffield==Book.fields.title:returnbook.titleeliffield==Book.fields.genre:returnbook.genreelse:raiseException("unknown field:{}".format(field))return[query.element_query.create_object(dict((field_query.key,resolve_field(book,field_query.field))forfield_queryinquery.element_query.field_queries))forbookinbooks]Running this code should give the output:result: {'books': [{'title': 'Leave It to Psmith'}, {'title': 'Right Ho, Jeeves'}, {'title': "Captain Corelli's Mandolin"}]}We can make the resolver more efficient by only fetching those columns required by the query.
Although this makes comparatively little difference with the data we have at the moment,
this can help improve performance when there are many more fields the user can request,
and with larger data sets.@g.resolver(g.ListType(Book))defresolve_books(graph,query):field_to_expression={Book.fields.title:BookRecord.title,Book.fields.genre:BookRecord.genre,}expressions=frozenset(field_to_expression[field_query.field]forfield_queryinquery.element_query.field_queries)books=session.query(*expressions).all()defresolve_field(book,field):iffield==Book.fields.title:returnbook.titleeliffield==Book.fields.genre:returnbook.genreelse:raiseException("unknown field:{}".format(field))return[query.element_query.create_object(dict((field_query.key,resolve_field(book,field_query.field))forfield_queryinquery.element_query.field_queries))forbookinbooks]Adding a genre parameter to the books fieldSo far, thebooksfield returns all of the books in the database.
Let’s add an optionalgenreparameter, so we can run the following query:query {
    books(genre: "comedy") {
        title
    }
}Before we start actually adding the parameter,
we need to make a change to how books are resolved.
At the moment, the code resolves queries for lists of books,
which doesn’t provide a convenient way for us to tell the resolver to only fetch a subset of books.
To solve this, we’ll wrap the object query in our own custom query class.classBookQuery(object):def__init__(self,object_query):self.type=(BookQuery,object_query.type)self.object_query=object_queryWe can then create aBookQueryin the root resolver:eliffield_query.field==Root.fields.books:returngraph.resolve(BookQuery(field_query.type_query.element_query))And we’ll have to updateresolve_booksaccordingly.
Specifically, we need to replaceg.resolver(g.ListType(Book))withg.resolver((BookQuery,Book)),
and replacequery.element_querywithquery.object_query.@g.resolver((BookQuery,Book))defresolve_books(graph,query):field_to_expression={Book.fields.title:BookRecord.title,Book.fields.genre:BookRecord.genre,}expressions=frozenset(field_to_expression[field_query.field]forfield_queryinquery.object_query.field_queries)books=session.query(*expressions).all()defresolve_field(book,field):iffield==Book.fields.title:returnbook.titleeliffield==Book.fields.genre:returnbook.genreelse:raiseException("unknown field:{}".format(field))return[query.object_query.create_object(dict((field_query.key,resolve_field(book,field_query.field))forfield_queryinquery.object_query.field_queries))forbookinbooks]Now we can get on with actually adding the parameter.
We’ll first need to update the definition of thebooksfield onRoot:Root=g.ObjectType("Root",fields=(g.field("author_count",type=g.Int),g.field("book_count",type=g.Int),g.field("books",type=g.ListType(Book),params=(g.param("genre",type=g.String,default=None),)),))Next, we’ll updateBookQueryto support filtering by adding awheremethod:classBookQuery(object):def__init__(self,object_query,genre=None):self.type=(BookQuery,object_query.type)self.object_query=object_queryself.genre=genredefwhere(self,*,genre):returnBookQuery(self.object_query,genre=genre)We can use thiswheremethod when resolving thebooksfield in the root resolver.eliffield_query.field==Root.fields.books:book_query=BookQuery(field_query.type_query.element_query)iffield_query.args.genreisnotNone:book_query=book_query.where(genre=field_query.args.genre)returngraph.resolve(book_query)Finally, we need to filter the books we fetch from the database.
We’ll replace:books=session.query(*expressions).all()with:sqlalchemy_query=session.query(*expressions)ifquery.genreisnotNone:sqlalchemy_query=sqlalchemy_query.filter(BookRecord.genre==query.genre)books=sqlalchemy_query.all()If we update our script with the new query:print("result:",execute("""
        query {
            books(genre: "comedy") {
                title
            }
        }
    """,graph=graph,query_type=Root,))We should see only books in the comedy genre in the output:result: {'books': [{'title': 'Leave It to Psmith'}, {'title': 'Right Ho, Jeeves'}]}Adding authors to the rootSimilarly to thebooksfield on the root,
we can add anauthorsfield to the root.
We start by defining theAuthorobject type,
and adding theauthorsfield toRoot.Author=g.ObjectType("Author",fields=(g.field("name",type=g.String),))Root=g.ObjectType("Root",fields=(g.field("author_count",type=g.Int),g.field("authors",type=g.ListType(Author)),g.field("book_count",type=g.Int),g.field("books",type=g.ListType(Book),params=(g.param("genre",type=g.String,default=None),)),))We define anAuthorQuery,
which can be resolved by a new resolver.classAuthorQuery(object):def__init__(self,object_query):self.type=(AuthorQuery,object_query.type)self.object_query=object_query@g.resolver((AuthorQuery,Author))defresolve_authors(graph,query):authors=session.query(AuthorRecord.name).all()defresolve_field(author,field):iffield==Author.fields.name:returnauthor.nameelse:raiseException("unknown field:{}".format(field))return[query.object_query.create_object(dict((field_query.key,resolve_field(author,field_query.field))forfield_queryinquery.object_query.field_queries))forauthorinauthors]resolvers=(resolve_root,resolve_authors,resolve_books)Finally, we update the root resolver to resolve theauthorsfield.@g.resolver(Root)defresolve_root(graph,query):defresolve_field(field_query):iffield_query.field==Root.fields.author_count:returnsession.query(AuthorRecord).count()eliffield_query.field==Root.fields.authors:returngraph.resolve(AuthorQuery(field_query.type_query.element_query))eliffield_query.field==Root.fields.book_count:returnsession.query(BookRecord).count()Adding an author field to booksAs the last change to the schema,
let’s add anauthorfield toBook.
We start by updating the type:Book=g.ObjectType("Book",fields=(g.field("title",type=g.String),g.field("genre",type=g.String),g.field("author",type=Author),))We then need to update the resolver for books.
If theauthorfield is requested,
then we’ll need to fetch theauthor_idfrom the database,
so we updatefield_to_expression:field_to_expression={Book.fields.title:BookRecord.title,Book.fields.genre:BookRecord.genre,Book.fields.author:BookRecord.author_id,}As well as fetching books,
we’ll need to fetch the authors too.
We can do this by delegating to the graph.
When fetching authors for the root, having them returned as a list was the most convenient format.
However, when fetching authors for books,
it’d be more convenient to return them in a dictionary keyed by ID so they can easily matched to books byauthor_id.
We can change theAuthorQueryto optionally allow this alternative format:classAuthorQuery(object):def__init__(self,object_query,is_keyed_by_id=False):self.type=(AuthorQuery,object_query.type)self.object_query=object_queryself.is_keyed_by_id=is_keyed_by_iddefkey_by_id(self):returnAuthorQuery(self.object_query,is_keyed_by_id=True)We then need to update the resolver to handle this:@g.resolver((AuthorQuery,Author))defresolve_authors(graph,query):sqlalchemy_query=session.query(AuthorRecord.name)ifquery.is_keyed_by_id:sqlalchemy_query=sqlalchemy_query.add_columns(AuthorRecord.id)authors=sqlalchemy_query.all()defresolve_field(author,field):iffield==Author.fields.name:returnauthor.nameelse:raiseException("unknown field:{}".format(field))defto_object(author):returnquery.object_query.create_object(dict((field_query.key,resolve_field(author,field_query.field))forfield_queryinquery.object_query.field_queries))ifquery.is_keyed_by_id:returndict((author.id,to_object(author))forauthorinauthors)else:return[to_object(author)forauthorinauthors]Now we can update the books resolver to fetch the authors using the graph:books=sqlalchemy_query.all()authors=dict((field_query.key,graph.resolve(AuthorQuery(field_query.type_query).key_by_id()))forfield_queryinquery.object_query.field_queriesiffield_query.field==Book.fields.author)This creates a dictionary mapping from each field query to the authors fetched for that field query.
We can this use this dictionary when resolving each field:defresolve_field(book,field_query):iffield_query.field==Book.fields.title:returnbook.titleeliffield_query.field==Book.fields.genre:returnbook.genreeliffield_query.field==Book.fields.author:returnauthors[field_query.key][book.author_id]else:raiseException("unknown field:{}".format(field_query.field))return[query.object_query.create_object(dict((field_query.key,resolve_field(book,field_query))forfield_queryinquery.object_query.field_queries))forbookinbooks]Now if we update our executed query:print("result:",execute("""
        query {
            books(genre: "comedy") {
                title
                author {
                    name
                }
            }
        }
    """,graph=graph,query_type=Root,))We should see:result: {'books': [{'title': 'Leave It to Psmith', 'author': {'name': 'PG Wodehouse'}}, {'title': 'Right Ho, Jeeves', 'author': {'name': 'PG Wodehouse'}}]}One inefficiency in the current implementation is that we fetch all authors,
regardless of whether they’re the author of a book that we’ve fetched.
We can fix this by filtering the author query by IDs,
similarly to how we filtered the book query by genre.
We updateAuthorQueryto add in anidsattribute:classAuthorQuery(object):def__init__(self,object_query,ids=None,is_keyed_by_id=False):self.type=(AuthorQuery,object_query.type)self.object_query=object_queryself.ids=idsself.is_keyed_by_id=is_keyed_by_iddefkey_by_id(self):returnAuthorQuery(self.object_query,ids=self.ids,is_keyed_by_id=True)defwhere(self,*,ids):returnAuthorQuery(self.object_query,ids=ids,is_keyed_by_id=self.is_keyed_by_id)We use thatidsattribute in the author resolver:sqlalchemy_query=session.query(AuthorRecord.name)ifquery.idsisnotNone:sqlalchemy_query=sqlalchemy_query.filter(AuthorRecord.id.in_(query.ids))ifquery.is_keyed_by_id:sqlalchemy_query=sqlalchemy_query.add_columns(AuthorRecord.id)authors=sqlalchemy_query.all()And we set the IDs in the book resolver:books=sqlalchemy_query.all()defget_author_ids():returnfrozenset(book.author_idforbookinbooks)defget_authors_for_field_query(field_query):author_query=AuthorQuery(field_query.type_query)\.where(ids=get_author_ids())\.key_by_id()returngraph.resolve(author_query)authors=dict((field_query.key,get_authors_for_field_query(field_query))forfield_queryinquery.object_query.field_queriesiffield_query.field==Book.fields.author)Dependency injectionIn our example so far,
we’ve treated the SQLAlchemy session as a global variable.
In practice, it’s sometimes useful to pass the session (and other dependencies) around explicitly.
Dependencies for resolvers are marked using the decoratorg.dependencies,
which allow dependencies to be passed as keyword arguments to resolvers.
For instance, to add a dependency on a SQLAlchemy session toresolve_root:@g.resolver(Root)@g.dependencies(session=sqlalchemy.orm.Session)defresolve_root(graph,query,*,session):A dependency can be identified by any value.
In this case, we identify the session dependency by its class,sqlalchemy.orm.Session.
When creating the graph,
we need to pass in dependencies:graph=graph_definition.create_graph({sqlalchemy.orm.Session:session,})Extracting duplicationWhen implementing resolvers, there are common patterns that tend to occur.
By extracting these common patterns into functions that build resolvers,
we can reduce duplication and simplify the definition of resolvers.
For instance, our root resolver can be rewritten as:resolve_root=g.root_object_resolver(Root)@resolve_root.field(Root.fields.author_count)@g.dependencies(session=sqlalchemy.orm.Session)defroot_resolve_author_count(graph,query,args,*,session):returnsession.query(AuthorRecord).count()@resolve_root.field(Root.fields.authors)defroot_resolve_authors(graph,query,args):returngraph.resolve(AuthorQuery(query.element_query))@resolve_root.field(Root.fields.book_count)@g.dependencies(session=sqlalchemy.orm.Session)defroot_resolve_book_count(graph,query,args,*,session):returnsession.query(BookRecord).count()@resolve_root.field(Root.fields.books)defroot_resolve_books(graph,query,args):book_query=BookQuery(query.element_query)ifargs.genreisnotNone:book_query=book_query.where(genre=args.genre)returngraph.resolve(book_query)Similarly, we can use thegraphlayer.sqlalchemymodule to define the resolvers for authors and books:importgraphlayer.sqlalchemyasgsql@resolve_root.field(Root.fields.authors)defroot_resolve_authors(graph,query,args):returngraph.resolve(gsql.select(query))@resolve_root.field(Root.fields.books)defroot_resolve_books(graph,query,args):book_query=gsql.select(query)ifargs.genreisnotNone:book_query=book_query.where(BookRecord.genre==args.genre)returngraph.resolve(book_query)resolve_authors=gsql.sql_table_resolver(Author,AuthorRecord,fields={Author.fields.name:gsql.expression(AuthorRecord.name),},)resolve_books=gsql.sql_table_resolver(Book,BookRecord,fields={Book.fields.title:gsql.expression(BookRecord.title),Book.fields.genre:gsql.expression(BookRecord.genre),Book.fields.author:lambdagraph,field_query:gsql.join(key=BookRecord.author_id,resolve=lambdaauthor_ids:graph.resolve(gsql.select(field_query.type_query).by(AuthorRecord.id,author_ids),),),},)