Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions docs/generators/graphql.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,106 @@ Inheritance
}


Mixins
^^^^^^

A class that cannot be instantiated on its own, because it is ``abstract`` or a
``mixin``, becomes an interface, and a class mixing it in implements it. An
interface may implement an interface, so a mixin keeps that role when it is
itself mixed into another one, and a class declares the interfaces that the
interfaces it implements declare in turn::

interface Base
{
a: String
}

interface Nested implements Base
{
b: String
a: String
}

type Consumer implements Nested & Base
{
c: String
b: String
a: String
}


Types
^^^^^

A type imported from ``linkml:types`` maps onto one of GraphQL's built-in
scalars. A type the schema brings itself has no built-in to map onto and is
declared as a custom scalar, so that the document defines every type it names::

types:
city code:
typeof: string

becomes::

type Place
{
code: CityCode
}

scalar CityCode

Only a type that a field actually names is declared. A type whose name lands on
that of a class or of an enumeration is left undeclared, with a warning: GraphQL
carries every type of a schema in one namespace, in which a name is unique,
while LinkML holds its types, classes and enumerations apart.


Enumerations
^^^^^^^^^^^^

An enumeration listing ``permissible_values`` becomes a GraphQL enum. A GraphQL
enum defines one or more values, so an enumeration that draws its values from an
ontology through ``reachable_from`` cannot be one: its values are not known at
generation time. It is declared as a custom scalar instead, with a warning, so
that the fields naming it stay defined.


Classes without a slot
^^^^^^^^^^^^^^^^^^^^^^

GraphQL has no empty type: an object type and an interface each define at least
one field. A class resolving to no slot therefore carries a single
``_placeholder`` field. A class implementing an interface in that position
carries it as well, because it repeats the fields of the interfaces it
implements::

interface Annotation
{
_placeholder: Boolean
}


Known gaps
^^^^^^^^^^

The generated document describes types only: it names no root operation type,
which a schema has to provide, so it is not servable on its own.

A name that is not alphanumeric throughout is encoded with ``__``, a prefix
GraphQL reserves for introspection: an enum value starting with a digit is
prefixed with it, and any other character is replaced by its Unicode name
wrapped in it, as in ``__PLUS_SIGN__``.

A class narrowing the range of an inherited slot keeps the narrowed type, which
an interface it implements may disagree with.

``implements`` is derived from ``mixins`` alone, never from ``is_a``, so an
abstract class that is never mixed into another becomes an interface that no
object type implements. A class listed in another class's ``mixins`` without
being declared ``abstract`` or ``mixin`` stays an object type, which the class
mixing it in may then not implement.


Docs
----

Expand Down
122 changes: 110 additions & 12 deletions packages/linkml/src/linkml/generators/graphqlgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@
from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, SlotDefinition
from linkml_runtime.utils.formatutils import camelcase, lcamelcase

#: The schema that holds the LinkML built-in types, the ones that map onto GraphQL's scalars.
LINKML_TYPES_SCHEMA = "https://w3id.org/linkml/types"

#: The scalars every GraphQL service defines (GraphQL spec, October 2021, section 3.5).
GRAPHQL_BUILTIN_SCALARS = ("Int", "Float", "String", "Boolean", "ID")

#: An object type and an interface each have to define at least one field (GraphQL spec,
#: October 2021, sections 3.6 and 3.7), while a LinkML class may resolve to no slot. Such a
#: class carries this field instead. It is emitted only for a class that contributes no
#: field of its own, or that implements an interface in that position, so it never collides
#: with a field generated from a slot.
PLACEHOLDER_FIELD_NAME = "_placeholder"


@dataclass
class GraphqlGenerator(Generator):
Expand All @@ -24,13 +37,25 @@ class GraphqlGenerator(Generator):

strict_naming: bool = False
_permissible_value_valid_characters = re.compile("^[_A-Za-z][_0-9A-Za-z]*?$")
_types_any = []

def __post_init__(self):
self.name_compatiblity = NameCompatibility(profile=NamingProfiles.graphql, do_not_fix=self.strict_naming)
self._reset_visit_state()
super().__post_init__()

def _reset_visit_state(self) -> None:
"""Clear the names the visitor collects, so that a run never sees those of another.

Kept on the class, a name outlived the generator that recorded it: the next schema
holding a class of that name was then closed as if it were a scalar, leaving the
document a brace short.
"""
self._types_any: list[str] = []
self._custom_scalars: set[str] = set()
self._declared_names: set[str] = set()

def visit_schema(self, **kwargs) -> str:
self._reset_visit_state()
return self.generate_header()

def generate_header(self) -> str:
Expand All @@ -39,36 +64,98 @@ def generate_header(self) -> str:
out += f"# version: {self.schema.version}\n"
return out

def end_schema(self, **kwargs) -> str:
"""Declare the types the schema brings itself, which GraphQL carries as custom scalars.

``visit_class_slot`` names such a type as the type of a field, and a document has
to define every type it names; a custom scalar is how GraphQL carries a type that
it has no built-in for (GraphQL spec, October 2021, section 3.5, Custom Scalars).

A name the document already carries is left out. Every type of a GraphQL schema
shares one namespace, in which a name is unique (GraphQL spec, October 2021,
section 3.3), while LinkML holds its types, classes and enumerations apart and
lets one name stand in several of them. A built-in scalar is left out as well,
since every service defines it already (GraphQL spec, October 2021, section 3.5).
"""
for name in sorted(self._custom_scalars & self._declared_names):
logging.warning(
f"The name {name} is already declared by a class or an enumeration of the "
"schema, so the type of that name is not declared as a scalar. A field "
"naming it resolves to the class or the enumeration instead."
)
declared = self._declared_names.union(GRAPHQL_BUILTIN_SCALARS)
return "".join(f"scalar {name}\n\n" for name in sorted(self._custom_scalars - declared))

def implemented_interfaces(self, cls: ClassDefinition) -> list[str]:
"""Return the mixins of a class, including the ones its mixins carry themselves.

An object type or an interface declares every interface that the interfaces it
implements declare in turn (GraphQL spec, October 2021, sections 3.6 and 3.7), so
the direct mixins alone leave out a mixin that is mixed into a mixin.
"""
interfaces: list[str] = []

def collect(mixins: list[str]) -> None:
for mixin in mixins:
if mixin not in interfaces:
interfaces.append(mixin)
collect(self.schema.classes[mixin].mixins)

collect(cls.mixins)
return interfaces

def visit_class(self, cls: ClassDefinition) -> str:
# no type can be declared for subtypes of "Any"
if cls.class_uri == "linkml:Any":
self._types_any.append(cls.name)
return f"scalar {cls.name}"
etype = "interface" if (cls.abstract or cls.mixin) and not cls.mixins else "type"
mixins = ", ".join([camelcase(mixin) for mixin in cls.mixins])
self._declared_names.add(camelcase(cls.name))
return f"scalar {camelcase(cls.name)}"
self._declared_names.add(camelcase(cls.name))
# A class that cannot be instantiated is an interface, including when it is mixed
# into another one: an interface may implement an interface (GraphQL spec, October
# 2021, section 3.7), and a type implementing it needs it to be one.
etype = "interface" if cls.abstract or cls.mixin else "type"
# "implements A & B": the interfaces are separated by "&", a comma does not parse
# (GraphQL spec, October 2021, section 3.6, ImplementsInterfaces).
mixins = " & ".join([camelcase(mixin) for mixin in self.implemented_interfaces(cls)])
out = f"{etype} {camelcase(cls.name)}" + (f" implements {mixins}" if mixins else "")
out = "\n".join([out, " {"])
return out

def placeholder_field(self, cls: ClassDefinition) -> str:
"""Return the placeholder field a class needs to be well formed, else the empty string.

A class resolving to no slot would close its body without a field. A class
implementing an interface in that position carries the field as well, because an
object type or interface repeats every field of the interfaces it implements
(GraphQL spec, October 2021, sections 3.6 and 3.7).
"""
interfaces = (self.all_slots(mixin) for mixin in self.implemented_interfaces(cls))
needs_placeholder = not self.all_slots(cls) or not all(interfaces)
return f"\n {PLACEHOLDER_FIELD_NAME}: Boolean" if needs_placeholder else ""

def end_class(self, cls: ClassDefinition) -> str:
if cls.name in self._types_any:
return "\n\n"
else:
return "\n }\n\n"
return f"{self.placeholder_field(cls)}\n }}\n\n"

def visit_class_slot(self, cls: ClassDefinition, aliased_slot_name: str, slot: SlotDefinition) -> str:
if slot.range in self.schema.classes or slot.range in self.schema.slots or slot.range in self.schema.enums:
slotrange = camelcase(slot.range)
elif slot.range in self.schema.types:
if self.schema.types[slot.range].from_schema != "https://w3id.org/linkml/types":
if self.schema.types[slot.range].from_schema != LINKML_TYPES_SCHEMA:
slotrange = camelcase(slot.range)
# A type the schema brings itself has no built-in to map onto, so the
# document has to declare it; ``end_schema`` does, where the names the
# document already carries are known.
self._custom_scalars.add(slotrange)
else:
graphql_scalars = ["Int", "Float", "String", "Boolean", "ID"]
if slot.range == "integer":
slotrange = "Int"
elif slot.range == "decimal":
slotrange = "Float"
elif camelcase(slot.range) in graphql_scalars:
elif camelcase(slot.range) in GRAPHQL_BUILTIN_SCALARS:
slotrange = camelcase(slot.range)
else:
if self.schema.types[slot.range].repr:
Expand All @@ -86,19 +173,30 @@ def visit_class_slot(self, cls: ClassDefinition, aliased_slot_name: str, slot: S
slotrange = slotrange + "!"
return f"\n {lcamelcase(aliased_slot_name)}: {slotrange}"

def visit_enum(self, enum: EnumDefinition):
def enum_name(self, enum: EnumDefinition) -> str:
"""Return the name under which an enumeration is carried in the document."""
return camelcase(enum.name).replace(" ", "")

def visit_enum(self, enum: EnumDefinition) -> str:
name = self.enum_name(enum)
if enum.permissible_values:
permissible_values = []
for value in enum.permissible_values:
permissible_values.append(self.name_compatiblity.compatible(value))
values = "\n ".join(permissible_values)
return f"enum {camelcase(enum.name).replace(' ', '')}\n {{\n {values}\n }}\n\n"
self._declared_names.add(name)
return f"enum {name}\n {{\n {values}\n }}\n\n"
else:
# An enum type defines one or more values (GraphQL spec, October 2021, section
# 3.9), so an enumeration whose values are not known at generation time cannot
# be one. It is carried as a custom scalar instead, which leaves the fields
# naming it defined rather than pointing at a type the document never declares.
logging.warning(
f"Enumeration {enum.name} using `reachable_from` instead of `permissible_values` "
+ "to specify permissible values is not supported yet."
+ "Enumeration {enum.name} will be silently ignored!!"
"to specify permissible values is not supported yet. "
f"Enumeration {enum.name} is generated as the custom scalar {name} instead."
)
self._custom_scalars.add(name)
return ""


Expand Down
Loading
Loading