Skip to content

[mirror] fix(graphqlgen): emit a document that parses and defines every type it names - #63

Open
jdsika wants to merge 1 commit into
mainfrom
fix/graphqlgen-valid-document
Open

jdsika wants to merge 1 commit into
mainfrom
fix/graphqlgen-valid-document

Conversation

@jdsika

@jdsika jdsika commented Nov 12, 2026 •

Copy link
Copy Markdown

Summary

Mirror of upstream linkml#4246. Review here; the
upstream PR carries the same branch and the same body.

gen-graphql emits a document that is not valid GraphQL. Eight independent defects
are fixed here; each one was reproduced against a minimal schema before being
touched. Section references are to the
GraphQL specification, October 2021.

Defect Symptom Spec
A class resolving to no slot emitted an empty body Syntax Error: Expected Name, found '}' §3.6 and §3.7 — an object type and an interface each define at least one field
A type the schema defines itself was named by a field but never declared Unknown type 'CityCode' §3.5, Custom Scalars — a custom scalar has to be declared
An enumeration drawing its values from an ontology was dropped, while fields kept naming it Unknown type 'VehicleKind' §3.9 — an enum type defines one or more values, so such an enumeration is carried as a custom scalar instead
A type whose name landed on that of a class or an enumeration was declared alongside it TypeError: There can be only one type named 'Person' §3.3 — every type of a schema shares one namespace, in which a name is unique
Implemented interfaces were joined with a comma Syntax Error: Unexpected Name 'RichMixin' §3.6 — ImplementsInterfaces joins with &
A linkml:Any class was declared under its raw name while fields referring to it used the camelcased one scalar any object → Unexpected Name 'object', and AnyObject left undefined §2.1.9
A mixin carrying mixins of its own stayed an object type although other types implemented it Type Consumer must only implement Interface types, it cannot implement Nested. §3.7 — an interface may implement an interface, and a type declares the interfaces its interfaces declare
The set of linkml:Any names lived on a mutable class attribute shared by every instance generating a second schema in the same process closes an unrelated class as a scalar, losing its } —

The last two interact. Promoting the nested mixin to an interface is not enough on
its own: Consumer implements Nested then has to declare implements Base as
well, so the generator now walks the transitive mixin closure. Likewise, a
fieldless mixin becomes interface BareMixin { _placeholder: Boolean }, and
every type implementing it has to repeat that field, so the placeholder is also
emitted for a class whose interface is in that position.

Evidence that this is not hypothetical: the reference snapshot in this repository,
tests/linkml/test_biolink_model/__snapshots__/biolink.graphql, does not parse
on main
. After the fix it parses. It gains 52 & corrections, 65
_placeholder fields, and declarations for nine types that were named but
dangling: BiologicalSequence, FrequencyValue, IriType, LabelType,
NarrativeText, PredicateType, SymbolType, TimeType, Unit.

Only a type a field actually names is declared; a name matching a built-in scalar,
or one the document already carries as a class or an enum, is left alone.
docs/generators/graphql.rst gains sections covering mixins, types,
enumerations, fieldless classes, and the gaps listed below.

How was this tested?

The pre-existing test_graphql_validity only calls parse(), which is why these
defects shipped. The new tests build the schema and run validate_schema.

Twelve test functions, eighteen cases, were added to
tests/linkml/test_generators/test_graphql.py:

  • test_the_generated_document_is_a_valid_schema, parametrized over seven schemas
    (empty class, custom type, mixin, nested mixin, linkml:Any, a type sharing a
    name with a class and an enum, an enumeration without permissible values)
  • test_a_class_without_a_slot_still_defines_a_field
  • test_an_implementation_repeats_the_field_of_a_fieldless_interface
  • test_interfaces_are_separated_by_an_ampersand
  • test_a_mixin_of_a_mixin_is_an_interface_and_is_implemented_along
  • test_a_type_of_the_schema_itself_is_declared_as_a_custom_scalar
  • test_a_type_no_slot_names_is_not_declared
  • test_a_type_named_after_a_built_in_scalar_is_not_redeclared
  • test_a_type_sharing_a_name_with_a_class_or_an_enum_is_not_declared_twice
  • test_an_enum_without_permissible_values_is_declared_as_a_scalar
  • test_an_any_class_is_declared_under_the_name_its_fields_use
  • test_one_generator_does_not_see_the_any_class_of_another

Running the file against the previous generator fails 16 of the 18 cases. The
two exceptions are test_a_type_named_after_a_built_in_scalar_is_not_redeclared
and the type-sharing-a-name case of the validity test, which pass either way by
construction — the previous generator declared no scalars at all, so it could not
declare a colliding one — and are kept as regression guards on the narrowness of
the new declaration rule.

Commands, with results:

  • uv run pytest tests/linkml/test_generators/test_graphql.py tests/linkml/test_scripts/test_gen_graphql.py -q → 29 passed
  • uv run pytest tests/linkml/test_biolink_model/test_biolink_model.py --with-biolink --with-slow -k graphql -q → 1 passed against the regenerated snapshot
  • uv run pytest tests/linkml/test_generators tests/linkml/test_scripts -q → 2050 passed, 64 skipped, 6 xfailed, 1 failed. The failure is
    test_markdowndatadictgen.py::test_datadict_personinfo, a network/kroki
    snapshot stale at metamodel version 1.11.0 against the current 1.12.0. It fails
    the same way on main and is unrelated to this change.
  • ruff 0.11.13 check and ruff 0.11.13 format --check on both changed Python
    files → clean. codespell 2.4.1 over both plus the .rst → clean.

tests/linkml/test_generators/__snapshots__/kitchen_sink.graphql is byte for
byte unchanged
: it references no schema-defined type, has no fieldless class and
no multi-mixin. That is the reason the rule is "declare the types a field names"
rather than "declare every type in the schema" — the latter was tried first and
added AgeInYearsType and PhoneNumberType to that snapshot, which nothing
references.

Areas of uncertainty

  • examples/ is deliberately untouched. Those artifacts come from the
    Makefile, not from a test, and personinfo.graphql is already stale at
    metamodel version 1.7.0 for reasons unrelated to this change. This change
    contributes exactly two & corrections and three scalar declarations there;
    regenerating would bury that in unrelated drift. Happy to do it in a separate
    commit if preferred.
  • packages/linkml_runtime/src/linkml_runtime/linkml_model/graphql/meta.graphql
    is also left untouched
    (linkml_model/graphql/meta.graphql shipped in the linkml-runtime wheel does not parse #67), although it is shipped inside the linkml-runtime
    wheel and does not parse today (Unexpected Name 'Extensible', the comma
    defect verbatim). It appears to be vendored from linkml/linkml-model rather
    than generated here, so regenerating it in this PR looked like the wrong place.
    Please say if it should be.
  • The built-in-scalar guard is not load-bearing for validity. graphql-core
    tolerates scalar Int and the built-in wins. The guard is a deliberate choice
    pinned by a test rather than a correctness requirement.
  • The _placeholder field name is a convention this introduces. It is a legal
    name and does not collide with the reserved __ prefix, but the spelling is
    open to discussion.
  • Four gaps are knowingly left out, each documented under "Known gaps" in the
    generator docs, because each needs a design decision rather than a bug fix:
    1. (gen-graphql: no root operation type is emitted, so the generated document is not servable #64) The document names no root operation type, required by
      §3.3.1, so
      it is not servable on its own. LinkML already carries tree_root, but the
      shape of the generated query fields is a design question.
    2. (common/naming.py: a GraphQL-encoded name can begin with the reserved '__' prefix, and the warning reports the unencoded name #65) NameCompatibility encodes a name that is not alphanumeric throughout with
      __: it prefixes a digit-leading name with it and wraps any other character
      in it, as in __PLUS_SIGN__, while GraphQL reserves __ for
      introspection (§2.1.9).
      This accounts for the nine remaining biolink errors, five from digit-leading
      names and four from character substitution. NameCompatibility
      is used only by this generator, so it is safely separable. A related cosmetic
      bug belongs with it: common/naming.py logs output before the encoding
      is assigned, so the warning reports the unencoded name as "the resulting
      encoded name".
    3. (gen-graphql: is_a is not mapped to an interface, so slot_usage narrowing breaks covariance and abstract classes become interfaces nothing implements #66) A class narrowing an inherited slot via slot_usage keeps the narrowed type,
      which an interface it implements may disagree with — roughly a hundred
      biolink errors of the form Interface field X.subject expects type NamedThing! but Y.subject is type Behavior!. LinkML is_a is not mapped to
      a GraphQL interface at all today, so this is a modelling decision.
    4. (gen-graphql: is_a is not mapped to an interface, so slot_usage narrowing breaks covariance and abstract classes become interfaces nothing implements #66) implements is derived from mixins alone. An abstract class that is never
      mixed into another therefore becomes an interface that no object type
      implements — twenty of the sixty-two interfaces in the biolink output, two of
      them used as field ranges. Conversely, 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; this still reaches
      LinkML's own meta.yaml, although as a type-system error rather than the
      syntax error it produced before. Both follow from the same decision as gap 3
      and belong with it.

The residual errors in the regenerated biolink snapshot are exactly those gaps:
one missing query root, nine reserved __ names and a hundred covariance
mismatches, 110 in total, with no other category.

This change went through an independent adversarial review before being opened,
which is where the third and seventh defects in the table above came from, along
with three corrected specification citations and gap 4. The duplicate-name defect
was found as a regression of the first attempt at the custom-scalar fix: before
it, no scalar was declared at all, so none could collide.

Checklist

  • My code follows the contributor guidelines
  • I have added tests that prove my fix/feature works
  • Existing tests pass locally with my changes

AI Assistance

If you used AI tools while preparing this PR, you are still the author and responsible for understanding, verifying, and defending your submission. Please engage with reviewers personally rather than through your agent during feedback and revisions. See our AI Covenant for details.

…t names

A generated document failed to parse, or failed schema validation, in eight
independent ways. A class resolving to no slot closed its body immediately,
which GraphQL has no syntax for. A type the schema defined itself was named
by a field but never declared. An enumeration drawing its values from an
ontology was dropped while fields kept naming it; an enum type defines one or
more values (GraphQL spec, October 2021, section 3.9), so such an enumeration
is carried as a custom scalar instead. A type whose name landed on that of a
class or an enumeration was declared alongside it, although every type of a
schema shares one namespace in which a name is unique (section 3.3); such a
name is now left undeclared, with a warning. The list of implemented
interfaces was joined with a comma rather than an ampersand. A linkml:Any
class was declared under its raw name while the fields referring to it used
the camelcased one. A mixin carrying mixins of its own stayed an object type
although other types implemented it. And the set of linkml:Any names lived on
a mutable class attribute shared by every instance, so generating a second
schema in the same process truncated it.

The reference snapshot of the biolink model in this repository did not parse
before this change and parses after it, with no type and no field lost.

The docs gain sections on mixins, types, enumerations and fieldless classes,
and record the gaps left open: no root operation type, names encoded with the
reserved double underscore, interface field covariance, and implements being
derived from mixins alone.

Signed-off-by: jdsika <carlo.van-driesten@bmw.de>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant