graphql-python / graphql-core

A Python 3.6+ port of the GraphQL.js reference implementation of GraphQL.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Support adding types to GraphQLSchema

alexchamberlain opened this issue · comments

I have a use case where part of the schema is specified using the SDL, and part of the schema is generated in code. In particular, interfaces are in SDL, while the concrete types are in code. To correctly modify the GraphQLSchema object that is generated by parsing the initial schema, you need to:

  • add the type concrete type to type_map
  • add the type to _implementations_map appropriately
  • reset _sub_type_map

Would you be open to adding an add_type method or similar that takes care of all of the above?

Hi @alexchamberlain. Thanks for the suggestion.

Before extending the API in our own ways, we should ask at GraphQL.js how they would do it. They usually have some good ideas and if something is really missing, then I prefer if it can be added there, and then ported to Python.

If you don't feel at home in the JavaScript world, I can also create an issue for you there. But you could help me by better explaining your use case with a runnable code example and maybe some motivation why you're doing it that way.

I think this is a minimal(ish) example; I tested in a python3.9 virtual environment with only graphql-core installed. This is a shameless rip off of the example on "Using the Schema Definition Language" docs.

Imagine that CHARACTER_CLASSES may be read from a configuration file or even a database. The basic schema is specified using SDL for documentation and readability purposes, but the concrete types are generated dynamically using the type subpackage.

from graphql import build_schema
from graphql.type import (
    GraphQLField,
    GraphQLList,
    GraphQLNonNull,
    GraphQLObjectType,
    GraphQLString,
    assert_valid_schema,
)
from graphql.type.schema import InterfaceImplementations
from graphql.utilities import print_schema

CHARACTER_CLASSES = ["Human", "Droid", "Animal", "Fungus", "Alien"]

schema = build_schema(
    """
        enum Episode { NEWHOPE, EMPIRE, JEDI }

        interface Character {
          id: String!
          name: String
          friends: [Character]
          appearsIn: [Episode]
        }

        type Query {
          hero(episode: Episode): Character
        }
    """
)

character_interface = schema.get_type("Character")
episode_class = schema.get_type("Episode")
query = schema.get_type("Query")

# TODO: Upstream an add type method on GraphQLSchema
if character_interface.name in schema._implementations_map:
    implementations = schema._implementations_map[character_interface.name]
else:
    implementations = schema._implementations_map[character_interface.name] = InterfaceImplementations(
        objects=[], interfaces=[]
    )


for character in CHARACTER_CLASSES:
    concrete_class = GraphQLObjectType(
        character,
        {
            "id": GraphQLField(GraphQLNonNull(GraphQLString)),
            "name": GraphQLField(GraphQLString),
            "friends": GraphQLField(GraphQLList(character_interface)),
            "appearsIn": GraphQLField(GraphQLList(episode_class)),
            "primaryFunction": GraphQLField(GraphQLString),
        },
        interfaces=[character_interface],
    )

    schema.type_map[character] = concrete_class
    implementations.objects.append(concrete_class)

    query.fields[character.lower()] = GraphQLField(concrete_class, args={"id": GraphQLNonNull(GraphQLString)})


schema._sub_type_map = {}

assert_valid_schema(schema)

print(print_schema(schema))

This is somewhat akin to extending a schema or similar. The output is rather long, so I posted to a gist.

Before extending the API in our own ways, we should ask at GraphQL.js how they would do it. They usually have some good ideas and if something is really missing, then I prefer if it can be added there, and then ported to Python.

I've not really engaged with the JS side of the GraphQL community before, but I can certainly give it a go if that is preferred.

Ok, thanks for the example code. Just for better understanding your use case, why don't you do it this way?

# only the interfaces
sdl = """
enum Episode { NEWHOPE, EMPIRE, JEDI }

interface Character {
  id: String!
  name: String
  friends: [Character]
  appearsIn: [Episode]
}

type Query {
  hero(episode: Episode): Character
}
"""

# add concrete types
sdl += '\n'.join(
    f"""
    type {character} implements Character {{
        id: String!
        name: String
        friends: [Character]
        appearsIn: [Episode]
        primaryFunction: String
    }}
    """ for character in CHARACTER_CLASSES)

schema = build_schema(sdl)

Update: Forgot to add the fields to the query class, but that could be done similarly.

In the real case, the interface is quite light - basically, just the id and all the concrete cases have different fields (also from the configuration system).

Ok, but the question is why don't you compose the SDL dynamically (using the config system), as shown above?

Or on the other hand, why don't you compose everything programmatically without SDL, like shown below?

from enum import Enum

class EpisodeEnum(Enum):
    NEWHOPE = 4
    EMPIRE = 5
    JEDI = 6

episode_enum = GraphQLEnumType('Episode', EpisodeEnum)

character_interface = GraphQLInterfaceType('Character', lambda: {
    'id': GraphQLField(
        GraphQLNonNull(GraphQLString)),
    'name': GraphQLField(
        GraphQLString),
    'friends': GraphQLField(
        GraphQLList(character_interface)),
    'appearsIn': GraphQLField(
        GraphQLList(episode_enum))})

for character_class in CHARACTER_CLASSES:
    concrete_character = GraphQLObjectType(
            character_class,
            {
                "id": GraphQLField(GraphQLNonNull(GraphQLString)),
                "name": GraphQLField(GraphQLString),
                "friends": GraphQLField(GraphQLList(character_interface)),
                "appearsIn": GraphQLField(GraphQLList(episode_enum)),
                "primaryFunction": GraphQLField(GraphQLString),
            },
            interfaces=[character_interface])
    query_fields[character_class.lower()] = GraphQLField(
        concrete_character, args={
            "id": GraphQLArgument(GraphQLNonNull(GraphQLString))})

query_type = GraphQLObjectType('Query', query_fields)

schema = GraphQLSchema(query_type)

Ok, but the question is why don't you compose the SDL dynamically (using the config system), as shown above?

I guess I didn't consider that. It feels odd to generate a string, just for that string to be parsed to objects we can construct directly.

Or on the other hand, why don't you compose everything programmatically without SDL, like shown below?

That is certainly an option - we do this in other services. For this service, we are only dynamically generating part of the API, so it felt nicer to be able to write the bit we weren't generating in SDL, as it's easier to review.

Ok, thanks for the feedback. Actually I think the way you do is not so bad, and doable already with the public API.

You can simply replace the following code:

if character_interface.name in schema._implementations_map:
    implementations = schema._implementations_map[character_interface.name]
else:
    implementations = schema._implementations_map[character_interface.name] = InterfaceImplementations(
        objects=[], interfaces=[]
    )

with this:

implementations = schema.get_implementations(character_interface)

And regarding resetting the _sub_type_map - you don't need to do this if you don't validate the schema before adding the types, but only afterwards.

I may be wrong, as I haven't actually checked it, but if the interface is not in _implementations_map, I don't think the returned object is a added to the map; see

interface_type.name, InterfaceImplementations(objects=[], interfaces=[])

Sorry, yes, you're right about that. I think I will open an issue upstream.

Thanks Christoph. Please let me know if there's anything I can do to help - if there are code changes that come out of this discussion, I'd be more than happy to contribute.

@alexchamberlain feel free to clarify or make suggestions upstream.

@alexchamberlain: @yaacovCR suggested to consider the schema frozen after it has been created, and to recreate it from the config (keyword args in Python), see the example below. Would that work for you? It has the advantage that it's very clean, and you don't need to care about updating internal data like type or implementation maps.

# Create schema from SDL
schema = build_schema(sdl)

# Add new types to query
query = schema.query_type
character_interface = schema.get_type("Character")
episode_enum = schema.get_type("Episode")
for character in CHARACTER_CLASSES:
    character_type = GraphQLObjectType(
        character,
        {
            "id": GraphQLField(GraphQLNonNull(GraphQLString)),
            "name": GraphQLField(GraphQLString),
            "friends": GraphQLField(GraphQLList(character_interface)),
            "appearsIn": GraphQLField(GraphQLList(episode_enum)),
            "primaryFunction": GraphQLField(GraphQLString),
        },
        interfaces=[character_interface],
    )
    query.fields[character.lower()] = GraphQLField(character_type, args={
        "id": GraphQLNonNull(GraphQLString)})

# Recreate schema
kwargs = schema.to_kwargs()
kwargs.update(query=query)
schema = GraphQLSchema(**kwargs)

Upstream it was recommended to consider a schema immutable once it has been created, for various good reasons. If you want to modify it, you must create a new schema from schema.to_kwargs() as shown above. Eventually, there will be a schema transformation function that might simplify this use case.

Thanks again @Cito for your help on this issue and escalating upstream. Happy New Year!