diff --git a/docs/generators/graphql.rst b/docs/generators/graphql.rst index e65a75ce74..e0457a0131 100644 --- a/docs/generators/graphql.rst +++ b/docs/generators/graphql.rst @@ -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 ---- diff --git a/packages/linkml/src/linkml/generators/graphqlgen.py b/packages/linkml/src/linkml/generators/graphqlgen.py index 609d148a51..3d6fcfdfb7 100644 --- a/packages/linkml/src/linkml/generators/graphqlgen.py +++ b/packages/linkml/src/linkml/generators/graphqlgen.py @@ -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): @@ -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: @@ -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: @@ -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 "" diff --git a/tests/linkml/test_biolink_model/__snapshots__/biolink.graphql b/tests/linkml/test_biolink_model/__snapshots__/biolink.graphql index b388884789..b5429984a4 100644 --- a/tests/linkml/test_biolink_model/__snapshots__/biolink.graphql +++ b/tests/linkml/test_biolink_model/__snapshots__/biolink.graphql @@ -256,7 +256,7 @@ enum StrandEnum __QUESTION_MARK__ } -type AccessibleDnaRegion implements GenomicEntity, ChemicalEntityOrGeneOrGeneProduct, PhysicalEssence, OntologyClass +type AccessibleDnaRegion implements GenomicEntity & ChemicalEntityOrGeneOrGeneProduct & PhysicalEssence & OntologyClass { id: String! iri: IriType @@ -273,6 +273,7 @@ type AccessibleDnaRegion implements GenomicEntity, ChemicalEntityOrGeneOrGenePro inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasBiologicalSequence: BiologicalSequence + _placeholder: Boolean } type Activity implements ActivityAndBehavior @@ -289,10 +290,12 @@ type Activity implements ActivityAndBehavior fullName: LabelType synonym: [LabelType] category: [String]! + _placeholder: Boolean } interface ActivityAndBehavior { + _placeholder: Boolean } interface AdministrativeEntity @@ -345,6 +348,7 @@ type AnatomicalEntity implements PhysicalEssence inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasAttribute: [Attribute] + _placeholder: Boolean } interface AnatomicalEntityToAnatomicalEntityAssociation @@ -478,6 +482,7 @@ type AnatomicalEntityToAnatomicalEntityPartOfAssociation interface Annotation { + _placeholder: Boolean } type Article @@ -590,7 +595,7 @@ type Bacterium hasAttribute: [Attribute] } -type Behavior implements OntologyClass, ActivityAndBehavior +type Behavior implements OntologyClass & ActivityAndBehavior { id: String! iri: IriType @@ -609,9 +614,10 @@ type Behavior implements OntologyClass, ActivityAndBehavior hasInput: [NamedThing] hasOutput: [NamedThing] enabledBy: [PhysicalEntity] + _placeholder: Boolean } -type BehaviorToBehavioralFeatureAssociation implements EntityToPhenotypicFeatureAssociationMixin +type BehaviorToBehavioralFeatureAssociation implements EntityToPhenotypicFeatureAssociationMixin & FrequencyQuantifier { id: String! iri: IriType @@ -709,9 +715,10 @@ type BehavioralFeature type BehavioralOutcome implements Outcome { + _placeholder: Boolean } -type BiologicalEntity implements ThingWithTaxon +interface BiologicalEntity implements ThingWithTaxon { id: String! iri: IriType @@ -729,7 +736,7 @@ type BiologicalEntity implements ThingWithTaxon inTaxonLabel: LabelType } -type BiologicalProcess implements Occurrent, OntologyClass +type BiologicalProcess implements Occurrent & OntologyClass { id: String! iri: IriType @@ -748,9 +755,10 @@ type BiologicalProcess implements Occurrent, OntologyClass hasInput: [NamedThing] hasOutput: [NamedThing] enabledBy: [PhysicalEntity] + _placeholder: Boolean } -type BiologicalProcessOrActivity implements Occurrent, OntologyClass +type BiologicalProcessOrActivity implements Occurrent & OntologyClass { id: String! iri: IriType @@ -769,6 +777,7 @@ type BiologicalProcessOrActivity implements Occurrent, OntologyClass hasInput: [NamedThing] hasOutput: [NamedThing] enabledBy: [PhysicalEntity] + _placeholder: Boolean } type BiologicalSex @@ -881,6 +890,7 @@ type Case implements SubjectOfInvestigation inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasAttribute: [Attribute] + _placeholder: Boolean } interface CaseToEntityAssociationMixin @@ -890,7 +900,7 @@ interface CaseToEntityAssociationMixin object: NamedThing! } -type CaseToPhenotypicFeatureAssociation implements EntityToPhenotypicFeatureAssociationMixin, CaseToEntityAssociationMixin +type CaseToPhenotypicFeatureAssociation implements EntityToPhenotypicFeatureAssociationMixin & FrequencyQuantifier & CaseToEntityAssociationMixin { id: String! iri: IriType @@ -948,7 +958,7 @@ type CaseToPhenotypicFeatureAssociation implements EntityToPhenotypicFeatureAsso hasPercentage: Float } -type CausalGeneToDiseaseAssociation implements EntityToDiseaseAssociationMixin, GeneToEntityAssociationMixin +type CausalGeneToDiseaseAssociation implements EntityToDiseaseAssociationMixin & GeneToEntityAssociationMixin { id: String! iri: IriType @@ -1040,9 +1050,10 @@ type CellLine implements SubjectOfInvestigation inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasAttribute: [Attribute] + _placeholder: Boolean } -type CellLineAsAModelOfDiseaseAssociation implements ModelToDiseaseAssociationMixin, EntityToDiseaseAssociationMixin +type CellLineAsAModelOfDiseaseAssociation implements ModelToDiseaseAssociationMixin & EntityToDiseaseAssociationMixin { id: String! iri: IriType @@ -1092,7 +1103,7 @@ type CellLineAsAModelOfDiseaseAssociation implements ModelToDiseaseAssociationMi diseaseContextQualifier: Disease } -type CellLineToDiseaseOrPhenotypicFeatureAssociation implements CellLineToEntityAssociationMixin, EntityToDiseaseOrPhenotypicFeatureAssociationMixin +type CellLineToDiseaseOrPhenotypicFeatureAssociation implements CellLineToEntityAssociationMixin & EntityToDiseaseOrPhenotypicFeatureAssociationMixin { id: String! iri: IriType @@ -1176,6 +1187,7 @@ type CellularOrganism implements SubjectOfInvestigation inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasAttribute: [Attribute] + _placeholder: Boolean } type ChemicalAffectsGeneAssociation @@ -1236,7 +1248,7 @@ type ChemicalAffectsGeneAssociation object: GeneOrGeneProduct! } -type ChemicalEntity implements PhysicalEssence, ChemicalOrDrugOrTreatment, ChemicalEntityOrGeneOrGeneProduct, ChemicalEntityOrProteinOrPolypeptide +type ChemicalEntity implements PhysicalEssence & ChemicalOrDrugOrTreatment & ChemicalEntityOrGeneOrGeneProduct & ChemicalEntityOrProteinOrPolypeptide { id: String! iri: IriType @@ -1255,6 +1267,7 @@ type ChemicalEntity implements PhysicalEssence, ChemicalOrDrugOrTreatment, Chemi maxToleratedDose: String isToxic: Boolean hasChemicalRole: [ChemicalRole] + _placeholder: Boolean } type ChemicalEntityAssessesNamedThingAssociation @@ -1302,6 +1315,7 @@ type ChemicalEntityAssessesNamedThingAssociation interface ChemicalEntityOrGeneOrGeneProduct { + _placeholder: Boolean } type ChemicalEntityOrGeneOrGeneProductRegulatesGeneAssociation @@ -1350,6 +1364,7 @@ type ChemicalEntityOrGeneOrGeneProductRegulatesGeneAssociation interface ChemicalEntityOrProteinOrPolypeptide { + _placeholder: Boolean } interface ChemicalEntityToEntityAssociationMixin @@ -1457,9 +1472,10 @@ type ChemicalMixture interface ChemicalOrDrugOrTreatment { + _placeholder: Boolean } -type ChemicalOrDrugOrTreatmentSideEffectDiseaseOrPhenotypicFeatureAssociation implements ChemicalToEntityAssociationMixin, EntityToDiseaseOrPhenotypicFeatureAssociationMixin +type ChemicalOrDrugOrTreatmentSideEffectDiseaseOrPhenotypicFeatureAssociation implements ChemicalToEntityAssociationMixin & EntityToDiseaseOrPhenotypicFeatureAssociationMixin { id: String! iri: IriType @@ -1510,7 +1526,7 @@ type ChemicalOrDrugOrTreatmentSideEffectDiseaseOrPhenotypicFeatureAssociation im predicate: PredicateType! } -type ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation implements ChemicalToEntityAssociationMixin, EntityToDiseaseOrPhenotypicFeatureAssociationMixin, EntityToFeatureOrDiseaseQualifiersMixin +type ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation implements ChemicalToEntityAssociationMixin & EntityToDiseaseOrPhenotypicFeatureAssociationMixin & EntityToFeatureOrDiseaseQualifiersMixin { id: String! iri: IriType @@ -1667,7 +1683,7 @@ type ChemicalToChemicalDerivationAssociation predicate: PredicateType! } -type ChemicalToDiseaseOrPhenotypicFeatureAssociation implements ChemicalToEntityAssociationMixin, EntityToDiseaseOrPhenotypicFeatureAssociationMixin +type ChemicalToDiseaseOrPhenotypicFeatureAssociation implements ChemicalToEntityAssociationMixin & EntityToDiseaseOrPhenotypicFeatureAssociationMixin { id: String! iri: IriType @@ -1957,6 +1973,7 @@ type Cohort implements SubjectOfInvestigation inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasAttribute: [Attribute] + _placeholder: Boolean } type CommonDataElement @@ -2106,7 +2123,7 @@ type ContributorAssociation qualifiers: [OntologyClass] } -type CorrelatedGeneToDiseaseAssociation implements EntityToDiseaseAssociationMixin, GeneToEntityAssociationMixin +type CorrelatedGeneToDiseaseAssociation implements EntityToDiseaseAssociationMixin & GeneToEntityAssociationMixin { id: String! iri: IriType @@ -2318,7 +2335,7 @@ type DiseaseOrPhenotypicFeature inTaxonLabel: LabelType } -type DiseaseOrPhenotypicFeatureExposure implements ExposureEvent, PathologicalEntityMixin +type DiseaseOrPhenotypicFeatureExposure implements ExposureEvent & PathologicalEntityMixin { id: String! type: [String] @@ -2336,10 +2353,12 @@ type DiseaseOrPhenotypicFeatureExposure implements ExposureEvent, PathologicalEn hasQualitativeValue: NamedThing iri: IriType timepoint: TimeType + _placeholder: Boolean } type DiseaseOrPhenotypicFeatureOutcome implements Outcome { + _placeholder: Boolean } interface DiseaseOrPhenotypicFeatureToEntityAssociationMixin @@ -2442,7 +2461,7 @@ interface DiseaseToEntityAssociationMixin object: NamedThing! } -type DiseaseToExposureEventAssociation implements DiseaseToEntityAssociationMixin, EntityToExposureEventAssociationMixin +type DiseaseToExposureEventAssociation implements DiseaseToEntityAssociationMixin & EntityToExposureEventAssociationMixin { id: String! iri: IriType @@ -2485,7 +2504,7 @@ type DiseaseToExposureEventAssociation implements DiseaseToEntityAssociationMixi category: [String] } -type DiseaseToPhenotypicFeatureAssociation implements FrequencyQuantifier, EntityToPhenotypicFeatureAssociationMixin, DiseaseToEntityAssociationMixin +type DiseaseToPhenotypicFeatureAssociation implements FrequencyQuantifier & EntityToPhenotypicFeatureAssociationMixin & DiseaseToEntityAssociationMixin { id: String! iri: IriType @@ -2544,7 +2563,7 @@ type DiseaseToPhenotypicFeatureAssociation implements FrequencyQuantifier, Entit anatomicalContextQualifier: String } -type Drug implements ChemicalOrDrugOrTreatment, OntologyClass +type Drug implements ChemicalOrDrugOrTreatment & OntologyClass { id: String! iri: IriType @@ -2567,6 +2586,7 @@ type Drug implements ChemicalOrDrugOrTreatment, OntologyClass highestFDAApprovalStatus: ApprovalStatusEnum drugRegulatoryStatusWorldWide: ApprovalStatusEnum routesOfDelivery: [DrugDeliveryEnum] + _placeholder: Boolean } type DrugExposure implements ExposureEvent @@ -2686,7 +2706,7 @@ type DrugToGeneInteractionExposure implements GeneGroupingMixin hasGeneOrGeneProduct: [Gene] } -type DruggableGeneToDiseaseAssociation implements EntityToDiseaseAssociationMixin, GeneToEntityAssociationMixin +type DruggableGeneToDiseaseAssociation implements EntityToDiseaseAssociationMixin & GeneToEntityAssociationMixin { id: String! iri: IriType @@ -2895,7 +2915,7 @@ type EntityToPhenotypicFeatureAssociation maxResearchPhase: ResearchPhaseEnum } -type EntityToPhenotypicFeatureAssociationMixin implements FrequencyQuantifier +interface EntityToPhenotypicFeatureAssociationMixin implements FrequencyQuantifier { frequencyQualifier: FrequencyValue subjectAspectQualifier: String @@ -2988,10 +3008,12 @@ type EnvironmentalProcess implements Occurrent fullName: LabelType synonym: [LabelType] category: [String]! + _placeholder: Boolean } type EpidemiologicalOutcome implements Outcome { + _placeholder: Boolean } interface EpigenomicEntity @@ -3147,7 +3169,7 @@ type ExposureEventToOutcomeAssociation implements EntityToOutcomeAssociationMixi temporalContextQualifier: TimeType } -type ExposureEventToPhenotypicFeatureAssociation implements EntityToPhenotypicFeatureAssociationMixin +type ExposureEventToPhenotypicFeatureAssociation implements EntityToPhenotypicFeatureAssociationMixin & FrequencyQuantifier { id: String! iri: IriType @@ -3341,7 +3363,7 @@ type Fungus hasAttribute: [Attribute] } -type Gene implements GeneOrGeneProduct, GenomicEntity, ChemicalEntityOrGeneOrGeneProduct, PhysicalEssence, OntologyClass +type Gene implements GeneOrGeneProduct & GenomicEntity & ChemicalEntityOrGeneOrGeneProduct & PhysicalEssence & OntologyClass { id: String! iri: IriType @@ -3359,6 +3381,7 @@ type Gene implements GeneOrGeneProduct, GenomicEntity, ChemicalEntityOrGeneOrGen symbol: String xref: [String] hasBiologicalSequence: BiologicalSequence + _placeholder: Boolean } type GeneAffectsChemicalAssociation @@ -3420,7 +3443,7 @@ type GeneAffectsChemicalAssociation object: ChemicalEntity! } -type GeneAsAModelOfDiseaseAssociation implements ModelToDiseaseAssociationMixin, EntityToDiseaseAssociationMixin +type GeneAsAModelOfDiseaseAssociation implements ModelToDiseaseAssociationMixin & EntityToDiseaseAssociationMixin { id: String! iri: IriType @@ -3486,7 +3509,7 @@ interface GeneExpressionMixin phenotypicState: DiseaseOrPhenotypicFeature } -type GeneFamily implements GeneGroupingMixin, ChemicalEntityOrGeneOrGeneProduct +type GeneFamily implements GeneGroupingMixin & ChemicalEntityOrGeneOrGeneProduct { id: String! iri: IriType @@ -3503,6 +3526,7 @@ type GeneFamily implements GeneGroupingMixin, ChemicalEntityOrGeneOrGeneProduct inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasGeneOrGeneProduct: [Gene] + _placeholder: Boolean } interface GeneGroupingMixin @@ -3635,7 +3659,7 @@ type GeneRegulatesGeneAssociation object: GeneOrGeneProduct! } -type GeneToDiseaseAssociation implements EntityToDiseaseAssociationMixin, GeneToEntityAssociationMixin +type GeneToDiseaseAssociation implements EntityToDiseaseAssociationMixin & GeneToEntityAssociationMixin { id: String! iri: IriType @@ -3693,7 +3717,7 @@ type GeneToDiseaseAssociation implements EntityToDiseaseAssociationMixin, GeneTo object: Disease! } -type GeneToDiseaseOrPhenotypicFeatureAssociation implements EntityToPhenotypicFeatureAssociationMixin, GeneToEntityAssociationMixin +type GeneToDiseaseOrPhenotypicFeatureAssociation implements EntityToPhenotypicFeatureAssociationMixin & FrequencyQuantifier & GeneToEntityAssociationMixin { id: String! iri: IriType @@ -4108,7 +4132,7 @@ type GeneToPathwayAssociation implements GeneToEntityAssociationMixin object: Pathway! } -type GeneToPhenotypicFeatureAssociation implements EntityToPhenotypicFeatureAssociationMixin, GeneToEntityAssociationMixin +type GeneToPhenotypicFeatureAssociation implements EntityToPhenotypicFeatureAssociationMixin & FrequencyQuantifier & GeneToEntityAssociationMixin { id: String! iri: IriType @@ -4184,7 +4208,7 @@ type GeneticInheritance inTaxonLabel: LabelType } -type Genome implements GenomicEntity, PhysicalEssence, OntologyClass +type Genome implements GenomicEntity & PhysicalEssence & OntologyClass { id: String! iri: IriType @@ -4201,9 +4225,10 @@ type Genome implements GenomicEntity, PhysicalEssence, OntologyClass inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasBiologicalSequence: BiologicalSequence + _placeholder: Boolean } -type GenomicBackgroundExposure implements ExposureEvent, GeneGroupingMixin, PhysicalEssence, GenomicEntity, ThingWithTaxon, OntologyClass +type GenomicBackgroundExposure implements ExposureEvent & GeneGroupingMixin & PhysicalEssence & GenomicEntity & ThingWithTaxon & OntologyClass { id: String! type: [String] @@ -4225,6 +4250,7 @@ type GenomicBackgroundExposure implements ExposureEvent, GeneGroupingMixin, Phys hasBiologicalSequence: BiologicalSequence inTaxon: [OrganismTaxon] inTaxonLabel: LabelType + _placeholder: Boolean } interface GenomicEntity @@ -4280,7 +4306,7 @@ type GenomicSequenceLocalization predicate: PredicateType! } -type Genotype implements PhysicalEssence, GenomicEntity, OntologyClass +type Genotype implements PhysicalEssence & GenomicEntity & OntologyClass { id: String! iri: IriType @@ -4298,9 +4324,10 @@ type Genotype implements PhysicalEssence, GenomicEntity, OntologyClass inTaxonLabel: LabelType hasZygosity: Zygosity hasBiologicalSequence: BiologicalSequence + _placeholder: Boolean } -type GenotypeAsAModelOfDiseaseAssociation implements ModelToDiseaseAssociationMixin, EntityToDiseaseAssociationMixin +type GenotypeAsAModelOfDiseaseAssociation implements ModelToDiseaseAssociationMixin & EntityToDiseaseAssociationMixin { id: String! iri: IriType @@ -4350,7 +4377,7 @@ type GenotypeAsAModelOfDiseaseAssociation implements ModelToDiseaseAssociationMi subject: Genotype! } -type GenotypeToDiseaseAssociation implements GenotypeToEntityAssociationMixin, EntityToDiseaseAssociationMixin +type GenotypeToDiseaseAssociation implements GenotypeToEntityAssociationMixin & EntityToDiseaseAssociationMixin { id: String! iri: IriType @@ -4493,7 +4520,7 @@ type GenotypeToGenotypePartAssociation object: Genotype! } -type GenotypeToPhenotypicFeatureAssociation implements EntityToPhenotypicFeatureAssociationMixin, GenotypeToEntityAssociationMixin +type GenotypeToPhenotypicFeatureAssociation implements EntityToPhenotypicFeatureAssociationMixin & FrequencyQuantifier & GenotypeToEntityAssociationMixin { id: String! iri: IriType @@ -4688,7 +4715,7 @@ type GrossAnatomicalStructure hasAttribute: [Attribute] } -type Haplotype implements GenomicEntity, PhysicalEssence, OntologyClass +type Haplotype implements GenomicEntity & PhysicalEssence & OntologyClass { id: String! iri: IriType @@ -4705,6 +4732,7 @@ type Haplotype implements GenomicEntity, PhysicalEssence, OntologyClass inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasBiologicalSequence: BiologicalSequence + _placeholder: Boolean } type Hospitalization @@ -4725,6 +4753,7 @@ type Hospitalization type HospitalizationOutcome implements Outcome { + _placeholder: Boolean } type Human implements SubjectOfInvestigation @@ -4743,6 +4772,7 @@ type Human implements SubjectOfInvestigation inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasAttribute: [Attribute] + _placeholder: Boolean } type IndividualOrganism implements SubjectOfInvestigation @@ -4761,6 +4791,7 @@ type IndividualOrganism implements SubjectOfInvestigation inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasAttribute: [Attribute] + _placeholder: Boolean } interface InformationContentEntity @@ -5091,6 +5122,7 @@ type Mammal implements SubjectOfInvestigation inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasAttribute: [Attribute] + _placeholder: Boolean } interface MappingCollection @@ -5112,6 +5144,7 @@ type MaterialSample implements SubjectOfInvestigation fullName: LabelType synonym: [LabelType] category: [String]! + _placeholder: Boolean } type MaterialSampleDerivationAssociation @@ -5157,7 +5190,7 @@ type MaterialSampleDerivationAssociation object: NamedThing! } -type MaterialSampleToDiseaseOrPhenotypicFeatureAssociation implements MaterialSampleToEntityAssociationMixin, EntityToDiseaseOrPhenotypicFeatureAssociationMixin +type MaterialSampleToDiseaseOrPhenotypicFeatureAssociation implements MaterialSampleToEntityAssociationMixin & EntityToDiseaseOrPhenotypicFeatureAssociationMixin { id: String! iri: IriType @@ -5232,7 +5265,7 @@ interface ModelToDiseaseAssociationMixin object: NamedThing! } -type MolecularActivity implements Occurrent, OntologyClass +type MolecularActivity implements Occurrent & OntologyClass { id: String! iri: IriType @@ -5251,6 +5284,7 @@ type MolecularActivity implements Occurrent, OntologyClass hasInput: [MolecularEntity] hasOutput: [MolecularEntity] enabledBy: [MacromolecularMachineMixin] + _placeholder: Boolean } type MolecularActivityToChemicalEntityAssociation @@ -5431,6 +5465,7 @@ type MolecularMixture type MortalityOutcome implements Outcome { + _placeholder: Boolean } type NamedThing @@ -5515,7 +5550,7 @@ type NoncodingRNAProduct inTaxonLabel: LabelType } -type NucleicAcidEntity implements GenomicEntity, ThingWithTaxon, PhysicalEssence, OntologyClass +type NucleicAcidEntity implements GenomicEntity & ThingWithTaxon & PhysicalEssence & OntologyClass { id: String! iri: IriType @@ -5538,6 +5573,7 @@ type NucleicAcidEntity implements GenomicEntity, ThingWithTaxon, PhysicalEssence hasBiologicalSequence: BiologicalSequence inTaxon: [OrganismTaxon] inTaxonLabel: LabelType + _placeholder: Boolean } type NucleicAcidSequenceMotif @@ -5558,7 +5594,7 @@ type NucleicAcidSequenceMotif inTaxonLabel: LabelType } -type NucleosomeModification implements GeneProductIsoformMixin, GenomicEntity, EpigenomicEntity +type NucleosomeModification implements GeneProductIsoformMixin & GenomicEntity & EpigenomicEntity { id: String! iri: IriType @@ -5599,6 +5635,7 @@ type ObservedExpectedFrequencyAnalysisResult interface Occurrent { + _placeholder: Boolean } type Onset @@ -5668,7 +5705,7 @@ interface OrganismTaxonToEntityAssociation object: NamedThing! } -type OrganismTaxonToEnvironmentAssociation implements OrganismTaxonToEntityAssociation +interface OrganismTaxonToEnvironmentAssociation implements OrganismTaxonToEntityAssociation { id: String! iri: IriType @@ -5711,7 +5748,7 @@ type OrganismTaxonToEnvironmentAssociation implements OrganismTaxonToEntityAssoc predicate: PredicateType! } -type OrganismTaxonToOrganismTaxonAssociation implements OrganismTaxonToEntityAssociation +interface OrganismTaxonToOrganismTaxonAssociation implements OrganismTaxonToEntityAssociation { id: String! iri: IriType @@ -5884,7 +5921,7 @@ type OrganismToOrganismAssociation object: IndividualOrganism! } -type OrganismalEntity implements SubjectOfInvestigation +interface OrganismalEntity implements SubjectOfInvestigation { id: String! iri: IriType @@ -5900,9 +5937,10 @@ type OrganismalEntity implements SubjectOfInvestigation inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasAttribute: [Attribute] + _placeholder: Boolean } -type OrganismalEntityAsAModelOfDiseaseAssociation implements ModelToDiseaseAssociationMixin, EntityToDiseaseAssociationMixin +type OrganismalEntityAsAModelOfDiseaseAssociation implements ModelToDiseaseAssociationMixin & EntityToDiseaseAssociationMixin { id: String! iri: IriType @@ -5954,6 +5992,7 @@ type OrganismalEntityAsAModelOfDiseaseAssociation implements ModelToDiseaseAssoc interface Outcome { + _placeholder: Boolean } type PairwiseGeneToGeneInteraction @@ -6071,6 +6110,7 @@ type Patent interface PathognomonicityQuantifier { + _placeholder: Boolean } type PathologicalAnatomicalExposure implements ExposureEvent @@ -6095,6 +6135,7 @@ type PathologicalAnatomicalExposure implements ExposureEvent type PathologicalAnatomicalOutcome implements Outcome { + _placeholder: Boolean } type PathologicalAnatomicalStructure implements PathologicalEntityMixin @@ -6113,10 +6154,12 @@ type PathologicalAnatomicalStructure implements PathologicalEntityMixin inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasAttribute: [Attribute] + _placeholder: Boolean } interface PathologicalEntityMixin { + _placeholder: Boolean } type PathologicalProcess implements PathologicalEntityMixin @@ -6138,6 +6181,7 @@ type PathologicalProcess implements PathologicalEntityMixin hasInput: [NamedThing] hasOutput: [NamedThing] enabledBy: [PhysicalEntity] + _placeholder: Boolean } type PathologicalProcessExposure implements ExposureEvent @@ -6162,6 +6206,7 @@ type PathologicalProcessExposure implements ExposureEvent type PathologicalProcessOutcome implements Outcome { + _placeholder: Boolean } type Pathway implements OntologyClass @@ -6199,6 +6244,7 @@ type Phenomenon implements Occurrent fullName: LabelType synonym: [LabelType] category: [String]! + _placeholder: Boolean } type PhenotypicFeature @@ -6219,7 +6265,7 @@ type PhenotypicFeature inTaxonLabel: LabelType } -type PhenotypicFeatureToDiseaseAssociation implements EntityToDiseaseAssociationMixin, PhenotypicFeatureToEntityAssociationMixin +type PhenotypicFeatureToDiseaseAssociation implements EntityToDiseaseAssociationMixin & PhenotypicFeatureToEntityAssociationMixin & FrequencyQuantifier { id: String! iri: IriType @@ -6274,7 +6320,7 @@ type PhenotypicFeatureToDiseaseAssociation implements EntityToDiseaseAssociation hasPercentage: Float } -type PhenotypicFeatureToEntityAssociationMixin implements FrequencyQuantifier +interface PhenotypicFeatureToEntityAssociationMixin implements FrequencyQuantifier { frequencyQualifier: FrequencyValue predicate: PredicateType! @@ -6292,7 +6338,7 @@ type PhenotypicFeatureToEntityAssociationMixin implements FrequencyQuantifier hasPercentage: Float } -type PhenotypicFeatureToPhenotypicFeatureAssociation implements PhenotypicFeatureToEntityAssociationMixin, EntityToPhenotypicFeatureAssociationMixin +type PhenotypicFeatureToPhenotypicFeatureAssociation implements PhenotypicFeatureToEntityAssociationMixin & FrequencyQuantifier & EntityToPhenotypicFeatureAssociationMixin { id: String! iri: IriType @@ -6402,14 +6448,17 @@ type PhysicalEntity implements PhysicalEssence fullName: LabelType synonym: [LabelType] category: [String]! + _placeholder: Boolean } interface PhysicalEssence { + _placeholder: Boolean } interface PhysicalEssenceOrOccurrent { + _placeholder: Boolean } type PhysiologicalProcess implements OntologyClass @@ -6467,7 +6516,7 @@ type Plant hasAttribute: [Attribute] } -type Polypeptide implements ChemicalEntityOrGeneOrGeneProduct, ChemicalEntityOrProteinOrPolypeptide +type Polypeptide implements ChemicalEntityOrGeneOrGeneProduct & ChemicalEntityOrProteinOrPolypeptide { id: String! iri: IriType @@ -6483,6 +6532,7 @@ type Polypeptide implements ChemicalEntityOrGeneOrGeneProduct, ChemicalEntityOrP category: [String]! inTaxon: [OrganismTaxon] inTaxonLabel: LabelType + _placeholder: Boolean } type PopulationOfIndividualOrganisms implements SubjectOfInvestigation @@ -6501,6 +6551,7 @@ type PopulationOfIndividualOrganisms implements SubjectOfInvestigation inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasAttribute: [Attribute] + _placeholder: Boolean } type PopulationToPopulationAssociation @@ -6629,6 +6680,7 @@ type Procedure implements ActivityAndBehavior fullName: LabelType synonym: [LabelType] category: [String]! + _placeholder: Boolean } type ProcessRegulatesProcessAssociation @@ -6717,7 +6769,7 @@ type Protein implements GeneProductMixin inTaxonLabel: LabelType } -type ProteinDomain implements GeneGroupingMixin, ChemicalEntityOrGeneOrGeneProduct +type ProteinDomain implements GeneGroupingMixin & ChemicalEntityOrGeneOrGeneProduct { id: String! iri: IriType @@ -6734,9 +6786,10 @@ type ProteinDomain implements GeneGroupingMixin, ChemicalEntityOrGeneOrGeneProdu inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasGeneOrGeneProduct: [Gene] + _placeholder: Boolean } -type ProteinFamily implements GeneGroupingMixin, ChemicalEntityOrGeneOrGeneProduct +type ProteinFamily implements GeneGroupingMixin & ChemicalEntityOrGeneOrGeneProduct { id: String! iri: IriType @@ -6753,6 +6806,7 @@ type ProteinFamily implements GeneGroupingMixin, ChemicalEntityOrGeneOrGeneProdu inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasGeneOrGeneProduct: [Gene] + _placeholder: Boolean } type ProteinIsoform implements GeneProductIsoformMixin @@ -6897,7 +6951,7 @@ type ReactionToParticipantAssociation subject: MolecularEntity! } -type ReagentTargetedGene implements GenomicEntity, PhysicalEssence, OntologyClass +type ReagentTargetedGene implements GenomicEntity & PhysicalEssence & OntologyClass { id: String! iri: IriType @@ -6914,9 +6968,10 @@ type ReagentTargetedGene implements GenomicEntity, PhysicalEssence, OntologyClas inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasBiologicalSequence: BiologicalSequence + _placeholder: Boolean } -type RegulatoryRegion implements GenomicEntity, ChemicalEntityOrGeneOrGeneProduct, PhysicalEssence, OntologyClass +type RegulatoryRegion implements GenomicEntity & ChemicalEntityOrGeneOrGeneProduct & PhysicalEssence & OntologyClass { id: String! iri: IriType @@ -6933,10 +6988,12 @@ type RegulatoryRegion implements GenomicEntity, ChemicalEntityOrGeneOrGeneProduc inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasBiologicalSequence: BiologicalSequence + _placeholder: Boolean } interface RelationshipQuantifier { + _placeholder: Boolean } type RelationshipType @@ -7025,6 +7082,7 @@ type RNAProductIsoform implements GeneProductIsoformMixin interface SensitivityQuantifier { + _placeholder: Boolean } type SequenceAssociation @@ -7113,7 +7171,7 @@ type SequenceFeatureRelationship object: NucleicAcidEntity! } -type SequenceVariant implements GenomicEntity, PhysicalEssence, OntologyClass +type SequenceVariant implements GenomicEntity & PhysicalEssence & OntologyClass { iri: IriType type: [String] @@ -7131,6 +7189,7 @@ type SequenceVariant implements GenomicEntity, PhysicalEssence, OntologyClass hasGene: [Gene] hasBiologicalSequence: BiologicalSequence id: String! + _placeholder: Boolean } interface SequenceVariantModulatesTreatmentAssociation @@ -7325,10 +7384,12 @@ type SocioeconomicExposure implements ExposureEvent type SocioeconomicOutcome implements Outcome { + _placeholder: Boolean } interface SpecificityQuantifier { + _placeholder: Boolean } type Study @@ -7407,6 +7468,7 @@ type StudyVariable interface SubjectOfInvestigation { + _placeholder: Boolean } type TaxonToTaxonAssociation @@ -7544,7 +7606,7 @@ type TranscriptToGeneRelationship object: Gene! } -type TranscriptionFactorBindingSite implements GenomicEntity, ChemicalEntityOrGeneOrGeneProduct, PhysicalEssence, OntologyClass +type TranscriptionFactorBindingSite implements GenomicEntity & ChemicalEntityOrGeneOrGeneProduct & PhysicalEssence & OntologyClass { id: String! iri: IriType @@ -7561,9 +7623,10 @@ type TranscriptionFactorBindingSite implements GenomicEntity, ChemicalEntityOrGe inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasBiologicalSequence: BiologicalSequence + _placeholder: Boolean } -type Treatment implements ExposureEvent, ChemicalOrDrugOrTreatment +type Treatment implements ExposureEvent & ChemicalOrDrugOrTreatment { id: String! iri: IriType @@ -7581,9 +7644,10 @@ type Treatment implements ExposureEvent, ChemicalOrDrugOrTreatment hasDevice: [Device] hasProcedure: [Procedure] timepoint: TimeType + _placeholder: Boolean } -type VariantAsAModelOfDiseaseAssociation implements ModelToDiseaseAssociationMixin, EntityToDiseaseAssociationMixin +type VariantAsAModelOfDiseaseAssociation implements ModelToDiseaseAssociationMixin & EntityToDiseaseAssociationMixin { id: String! iri: IriType @@ -7633,7 +7697,7 @@ type VariantAsAModelOfDiseaseAssociation implements ModelToDiseaseAssociationMix subject: SequenceVariant! } -type VariantToDiseaseAssociation implements VariantToEntityAssociationMixin, EntityToDiseaseAssociationMixin +type VariantToDiseaseAssociation implements VariantToEntityAssociationMixin & EntityToDiseaseAssociationMixin { id: String! iri: IriType @@ -7780,7 +7844,7 @@ type VariantToGeneExpressionAssociation implements GeneExpressionMixin phenotypicState: DiseaseOrPhenotypicFeature } -type VariantToPhenotypicFeatureAssociation implements VariantToEntityAssociationMixin, EntityToPhenotypicFeatureAssociationMixin +type VariantToPhenotypicFeatureAssociation implements VariantToEntityAssociationMixin & EntityToPhenotypicFeatureAssociationMixin & FrequencyQuantifier { id: String! iri: IriType @@ -7838,7 +7902,7 @@ type VariantToPhenotypicFeatureAssociation implements VariantToEntityAssociation hasPercentage: Float } -type VariantToPopulationAssociation implements VariantToEntityAssociationMixin, FrequencyQuantifier, FrequencyQualifierMixin +type VariantToPopulationAssociation implements VariantToEntityAssociationMixin & FrequencyQuantifier & FrequencyQualifierMixin { id: String! iri: IriType @@ -7920,6 +7984,7 @@ type Virus implements SubjectOfInvestigation inTaxon: [OrganismTaxon] inTaxonLabel: LabelType hasAttribute: [Attribute] + _placeholder: Boolean } type WebPage @@ -7966,3 +8031,21 @@ type Zygosity hasQualitativeValue: NamedThing iri: IriType } + +scalar BiologicalSequence + +scalar FrequencyValue + +scalar IriType + +scalar LabelType + +scalar NarrativeText + +scalar PredicateType + +scalar SymbolType + +scalar TimeType + +scalar Unit diff --git a/tests/linkml/test_generators/test_graphql.py b/tests/linkml/test_generators/test_graphql.py index 18c8e62516..5eb86fbd5d 100644 --- a/tests/linkml/test_generators/test_graphql.py +++ b/tests/linkml/test_generators/test_graphql.py @@ -1,7 +1,8 @@ import logging +from pathlib import Path import pytest -from graphql import parse +from graphql import build_schema, parse, validate_schema from linkml.generators.graphqlgen import GraphqlGenerator @@ -112,3 +113,338 @@ def test_graphql_validity(kitchen_sink_path): + f"{str(ex)}\n" + "^^^^^^^ End Error Message ^^^^^^^" ) + + +EMPTY_CLASS_SCHEMA = """ +id: https://example.org/empty +name: empty +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/empty/ +default_prefix: ex +default_range: string +imports: [linkml:types] +classes: + Marker: + description: A class that carries no slot. + AbstractMarker: + abstract: true + description: An abstract class that carries no slot. + Holder: + attributes: + marker: {range: Marker} +""" + +CUSTOM_TYPE_SCHEMA = """ +id: https://example.org/types +name: types +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/types/ +default_prefix: ex +default_range: string +imports: [linkml:types] +types: + city code: + typeof: string + unused code: + typeof: string + int: + typeof: string +classes: + Place: + attributes: + code: {range: city code} + ranking: {range: int} +""" + +MIXIN_SCHEMA = """ +id: https://example.org/mixin +name: mixin +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/mixin/ +default_prefix: ex +default_range: string +imports: [linkml:types] +classes: + BareMixin: + mixin: true + description: A mixin that carries no slot. + RichMixin: + mixin: true + attributes: + tag: {} + Consumer: + mixins: [BareMixin, RichMixin] + attributes: + name: {} +""" + +NESTED_MIXIN_SCHEMA = """ +id: https://example.org/nested +name: nested +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/nested/ +default_prefix: ex +default_range: string +imports: [linkml:types] +classes: + Base: + mixin: true + attributes: + a: {} + Nested: + mixin: true + mixins: [Base] + attributes: + b: {} + Consumer: + mixins: [Nested] + attributes: + c: {} +""" + +ANY_SCHEMA = """ +id: https://example.org/any +name: any +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/any/ +default_prefix: ex +default_range: string +imports: [linkml:types] +classes: + any object: + class_uri: linkml:Any + Holder: + attributes: + payload: {range: any object} +""" + +# The class of the name that ANY_SCHEMA declares to be "linkml:Any" is an ordinary one here. +PLAIN_SAME_NAME_SCHEMA = """ +id: https://example.org/plain +name: plain +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/plain/ +default_prefix: ex +default_range: string +imports: [linkml:types] +classes: + any object: + attributes: + label: {} +""" + +# LinkML holds its types, classes and enumerations apart, so one name may stand in several +# of them; GraphQL carries every type in one namespace. +COLLIDING_NAME_SCHEMA = """ +id: https://example.org/collide +name: collide +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/collide/ +default_prefix: ex +default_range: string +imports: [linkml:types] +types: + person: + typeof: string + colour: + typeof: string +enums: + Colour: + permissible_values: + red: +classes: + Person: + attributes: + name: {} + Holder: + attributes: + who: {range: Person} + reference: {range: person} + shade: {range: colour} +""" + +DYNAMIC_ENUM_SCHEMA = """ +id: https://example.org/dynamic +name: dynamic +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/dynamic/ +default_prefix: ex +default_range: string +imports: [linkml:types] +enums: + VehicleKind: + reachable_from: + source_ontology: obo:vehicle + source_nodes: [VEHICLE:1] +classes: + Car: + attributes: + kind: {range: VehicleKind} +""" + + +def generate(tmp_path: Path, schema_text: str, name: str = "schema.yaml") -> str: + """Write a schema and return the GraphQL document generated from it.""" + schema = tmp_path / name + schema.write_text(schema_text, encoding="utf-8") + return GraphqlGenerator(str(schema)).serialize() + + +def schema_errors(generated: str) -> list[str]: + """Return what the GraphQL type system rejects in a document, bar the missing query root. + + ``build_schema`` raises when the document does not parse. The generator names no root + operation type, which a schema has to provide (GraphQL spec, October 2021, section + 3.3.1); that gap is left aside here so the remaining rules can be checked. + """ + errors = validate_schema(build_schema(generated)) + return [str(error) for error in errors if "Query root type must be provided" not in str(error)] + + +@pytest.mark.parametrize( + "schema_text", + [ + pytest.param(EMPTY_CLASS_SCHEMA, id="class-without-a-slot"), + pytest.param(CUSTOM_TYPE_SCHEMA, id="type-of-the-schema-itself"), + pytest.param(MIXIN_SCHEMA, id="two-mixins"), + pytest.param(NESTED_MIXIN_SCHEMA, id="mixin-of-a-mixin"), + pytest.param(ANY_SCHEMA, id="any-class"), + pytest.param(COLLIDING_NAME_SCHEMA, id="type-sharing-a-name"), + pytest.param(DYNAMIC_ENUM_SCHEMA, id="enum-without-permissible-values"), + ], +) +def test_the_generated_document_is_a_valid_schema(tmp_path, schema_text): + """Every generated document parses and satisfies the GraphQL type system.""" + assert schema_errors(generate(tmp_path, schema_text)) == [] + + +def test_a_class_without_a_slot_still_defines_a_field(tmp_path): + """An object type and an interface each define at least one field. + + GraphQL spec, October 2021, sections 3.6 and 3.7. A LinkML class may carry no slot, + and an empty body does not parse. + """ + generated = generate(tmp_path, EMPTY_CLASS_SCHEMA) + assert "type Marker\n {\n _placeholder: Boolean\n }" in generated + assert "interface AbstractMarker\n {\n _placeholder: Boolean\n }" in generated + # A class with a slot of its own is left alone. + assert "_placeholder" not in generated.split("type Holder")[1].split("}")[0] + + +def test_an_implementation_repeats_the_field_of_a_fieldless_interface(tmp_path): + """An object type defines every field of the interfaces it implements. + + GraphQL spec, October 2021, section 3.6. The placeholder an interface needs is + therefore part of the implementing type as well. + """ + generated = generate(tmp_path, MIXIN_SCHEMA) + assert "interface BareMixin\n {\n _placeholder: Boolean\n }" in generated + consumer = generated.split("type Consumer")[1].split("}")[0] + assert "_placeholder: Boolean" in consumer + + +def test_interfaces_are_separated_by_an_ampersand(tmp_path): + """``implements`` separates interfaces with "&"; a comma does not parse. + + GraphQL spec, October 2021, section 3.6, ImplementsInterfaces. + """ + generated = generate(tmp_path, MIXIN_SCHEMA) + assert "type Consumer implements BareMixin & RichMixin" in generated + + +def test_a_mixin_of_a_mixin_is_an_interface_and_is_implemented_along(tmp_path): + """A mixin stays an interface when it is mixed into another, and is declared by its users. + + An interface may implement an interface, and a type declares the interfaces that the + interfaces it implements declare in turn (GraphQL spec, October 2021, sections 3.6 + and 3.7). + """ + generated = generate(tmp_path, NESTED_MIXIN_SCHEMA) + assert "interface Nested implements Base" in generated + assert "type Consumer implements Nested & Base" in generated + + +def test_a_type_of_the_schema_itself_is_declared_as_a_custom_scalar(tmp_path): + """A document defines every type it names, and a custom scalar carries one GraphQL lacks. + + GraphQL spec, October 2021, section 3.5, Custom Scalars. + """ + generated = generate(tmp_path, CUSTOM_TYPE_SCHEMA) + assert "code: CityCode" in generated + assert "scalar CityCode" in generated + + +def test_a_type_no_slot_names_is_not_declared(tmp_path): + """Only the types a field names are declared, so an unused one stays out of the document.""" + generated = generate(tmp_path, CUSTOM_TYPE_SCHEMA) + assert "scalar CityCode" in generated + assert "scalar UnusedCode" not in generated + + +def test_a_type_named_after_a_built_in_scalar_is_not_redeclared(tmp_path): + """A built-in scalar is defined by every service, so the document does not repeat it. + + GraphQL spec, October 2021, section 3.5. The type ``int`` of the schema collapses onto + the built-in ``Int`` rather than being declared, which is the behaviour of record; the + type it is declared with is lost in the process. + """ + generated = generate(tmp_path, CUSTOM_TYPE_SCHEMA) + assert "ranking: Int" in generated + assert "scalar Int" not in generated + + +def test_a_type_sharing_a_name_with_a_class_or_an_enum_is_not_declared_twice(tmp_path, caplog): + """Every type of a schema shares one namespace, in which a name is unique. + + GraphQL spec, October 2021, section 3.3. A LinkML type whose name lands on that of a + class or of an enumeration is therefore left undeclared rather than repeated, which + would make the document unloadable. + """ + with caplog.at_level(logging.WARNING): + generated = generate(tmp_path, COLLIDING_NAME_SCHEMA) + assert "type Person" in generated + assert "scalar Person" not in generated + assert "enum Colour" in generated + assert "scalar Colour" not in generated + assert "Person" in caplog.text + assert "Colour" in caplog.text + + +def test_an_enum_without_permissible_values_is_declared_as_a_scalar(tmp_path): + """An enum type defines one or more values, so an enumeration short of them is a scalar. + + GraphQL spec, October 2021, section 3.9. The values of an enumeration reached from an + ontology are not known at generation time; leaving it out altogether left the fields + naming it pointing at a type the document never declared. + """ + generated = generate(tmp_path, DYNAMIC_ENUM_SCHEMA) + assert "kind: VehicleKind" in generated + assert "scalar VehicleKind" in generated + assert "enum VehicleKind" not in generated + + +def test_an_any_class_is_declared_under_the_name_its_fields_use(tmp_path): + """A class standing for ``linkml:Any`` is a scalar named as the fields naming it are.""" + generated = generate(tmp_path, ANY_SCHEMA) + assert "scalar AnyObject" in generated + assert "payload: AnyObject" in generated + + +def test_one_generator_does_not_see_the_any_class_of_another(tmp_path): + """The names a run collects stay with it. + + Held on the class, the name of a ``linkml:Any`` class outlived its generator, and the + class of that name in the next schema was closed as if it were a scalar. + """ + generate(tmp_path, ANY_SCHEMA, name="any.yaml") + generated = generate(tmp_path, PLAIN_SAME_NAME_SCHEMA, name="plain.yaml") + parse(generated) + assert "type AnyObject\n {\n label: String\n }" in generated