Skip to content

GraphQL API

Mosaic can serve an autogenerated GraphQL API alongside the REST API. The GraphQL schema — object types, enums, relationship fields, queries, and mutations — is generated at startup from your deployment's LinkML schema, so it always matches your data model. No per-entity GraphQL code is ever written.

Like the REST API, the GraphQL transport is a thin wrapper: every resolver delegates to the same MosaicClient the SDK, REST, and TUI use, against the same storage.

Installation

GraphQL support ships in the optional graphql extra:

pip install 'datahelix-mosaic[graphql]'

(It is also included in datahelix-mosaic[all].)

Serving

mosaic serve --graphql                 # auto-detect config in the cwd
mosaic serve --graphql --config mosaic.yaml
mosaic serve --graphql --graphql-max-depth 6   # tighten the query-depth limit

The GraphQL endpoint mounts at http://host:port/graphql on the same server as the REST routes. Opening it in a browser (GET) serves the GraphiQL IDE; executing operations (POST) requires the same Authorization: Bearer <token> header as the REST API.

The deployment must be schema-backed: your config's schema_path (or --config) provides the LinkML schema the GraphQL surface is generated from. Without one, --graphql fails at startup with a configuration error.

Embedding

from mosaic.core.factory import create_client_from_config, load_config_autodetect
from mosaic.serve import create_default_app

config = load_config_autodetect("mosaic.yaml")
client = create_client_from_config(config)
app = create_default_app(client, graphql=True, graphql_max_query_depth=10)

What gets generated

For each concrete entity class in your schema (say Sample), Mosaic generates:

Surface Example
Object type type Sample with every slot as a typed field
Page type type SamplePage { items, total, limit, offset }
Input types input SampleCreateInput, input SampleUpdateInput
Queries sample(id), samples(filters, where, filterMode, limit, offset, orderBy, orderDir, asOf), searchSamples(q, filters, where, filterMode, limit, offset, orderBy, orderDir): SamplePage
Aggregations samplesCount(filters, where), samplesFacetCounts(field, filters, where), samplesFieldRange(field, filters, where)
Mutations createSample, updateSample, setSampleAvailability, setSampleAvailabilityBulk, supersedeSample

LinkML enums become GraphQL enums. Field names are camelCased (volume_mlvolumeMl); write payloads are mapped back to slot names before they reach the SDK.

A list query's filters argument accepts either spelling for its field — the LinkML slot name (volume_ml, as listed by hippoSchema) or the camelCased field name the type exposes (volumeMl). A field that matches neither is an error (extensions.code: UNKNOWN_FILTER_FIELD) rather than an empty page, as are the two kinds of name that look filterable but cannot be: read-time computed fields (createdAt, updatedAt, supersededBy — for temporal queries use asOf) and multivalued references, which are stored as relationship edges rather than columns (use relatedTo). Both report extensions.code: UNFILTERABLE_FIELD.

Each filter carries an operator (op, default EQ). Which operators a slot supports follows from its LinkML range (ADR-0006): numeric and temporal slots take GT/GTE/LT/LTE (typed comparisons on both backends — never lexicographic), string slots take CONTAINS (case-insensitive substring; %/_ are literals), most slots take NEQ and IN, and every slot takes IS_NULL with a boolean value (true matches entities with no stored value). An operator outside a slot's set is extensions.code: UNSUPPORTED_FILTER_OP, and a malformed value (value: null, non-boolean IS_NULL, non-list IN) is INVALID_FILTER_VALUE — never a silently-wrong result. SQL NULL semantics apply: comparisons (including NEQ) never match an entity that lacks the field; ask about absence with IS_NULL.

{
  samples(filters: [
    {field: "replicateCount", op: GT, value: 2},
    {field: "name", op: CONTAINS, value: "tumor"},
    {field: "notes", op: IS_NULL, value: false}
  ]) { total items { name } }
}

Typed where: filters

Every list query also takes a generated where: <Type>Filter argument (ADR-0006) — the typed form of the same operators, with boolean structure:

{
  samples(where: {
    isTumor: {eq: true},
    or: [
      {replicateCount: {gt: 8}},
      {and: [{volumeMl: {lte: 2.0}}, {notes: {isNull: false}}]}
    ]
  }) { total items { name } }
}

Each slot field takes a per-kind operator object (StringFilterOps, IntFilterOps, FloatFilterOps, DateTimeFilterOps, BooleanFilterOps, per-enum <Enum>FilterOps, …) carrying exactly the operators that slot supports — the introspected schema is the capability contract, and a wrong operator or mistyped value fails GraphQL validation before execution. Slot fields and multiple operators within one object AND together; and/or/not nest (depth cap 10; not is two-valued — an entity missing the field satisfies the negation). where composes with the flat filters: list by AND.

Relationship predicates (ADR-0006 M5a/M5b): reference edges nest predicates on their targets. A single-valued edge takes the target type's filter directly; a multivalued (relationship-backed) edge takes a {some, none} quantifier object over it —

{
  samples(where: {donor: {ageAtDeath: {gt: 60}}}) { total items { name } }
  studies: studys(where: {samples: {some: {isTumor: {eq: true}},
                                    none: {volumeMl: {lt: 0.5}}}}) { total }
}

— compiled to correlated EXISTS subqueries: on the FK column for to-one edges, against the relationships link table joined to the target for quantified to-many edges (some = at least one live edge to an available matching target; none = its complement — an entity with no edges at all matches none). A reverse edge declared with LinkML inverse (Donor.samples: {range: Sample, multivalued: true, inverse: donor} — see the Schema Guide) takes the same {some, none} object and compiles to EXISTS over the target table keyed on its forward FK column, so donors(where: {samples: {some: {isTumor: {eq: true}}}}) works with no link table involved. Edges nest arbitrarily (including self-referential edges) and count toward the depth cap; not over an edge is two-valued (an entity with no referenced target satisfies the negation). Relationship predicates compose with everything the where tree reaches — search, count, facetCounts, fieldRange, orderBy — but not with asOf (extensions.code: ASOF_RELATIONSHIP_FILTER_UNSUPPORTED).

Ordering and aggregation

Every list query takes orderBy: <Type>OrderField and orderDir: ASC | DESC (ADR-0007). The generated <Type>OrderField enum lists the class's orderable stored columns — single-valued scalar and enum slots, id included; multivalued slots, references, and the computed temporal fields are absent (those are provenance-derived, not columns). With orderBy set, ordering and pagination push down to storage (SQL ORDER BY/LIMIT/OFFSET; missing values sort last in either direction; ties break on id), and total comes from a COUNT(*) under the same predicate — paging through a large class no longer materializes it. Without orderBy, results keep the historical createdAt-ascending order. orderBy cannot combine with asOf (extensions.code: ASOF_ORDERING_UNSUPPORTED).

Three aggregation roots per class share the list surface's exact predicate — the availability-consistency rule: an aggregate counts precisely the entities the equivalent list query would return, never unavailable ones.

{
  samplesCount(where: {isTumor: {eq: true}})
  samplesFacetCounts(field: "tissue") { value count }
  samplesFieldRange(field: "volumeMl", where: {isTumor: {eq: true}}) { min max }
}
  • {plural}Count(filters, where, filterMode, asOf) — a pushed-down COUNT(*); always equals the list's total. Under asOf it counts the reconstructed as-of match set (the documented Python-path semantics).
  • {plural}FacetCounts(field, filters, where, filterMode) — per-value buckets {value, count} ordered by count descending then value. field takes either spelling (slot name or camelCase). Entities with no stored value are not counted — ask about absence with IS_NULL.
  • {plural}FieldRange(field, filters, where, filterMode){min, max} for numeric and temporal slots (range facets); both null when no matching entity has a value.

Unknown aggregation fields are extensions.code: UNKNOWN_AGGREGATION_FIELD; computed temporal fields, multivalued slots, and (for fieldRange) non-ordered ranges are UNAGGREGATABLE_FIELD. The facet/range roots are current-state only in this increment — they take no asOf.

The exposed class set is decided by Mosaic's shared type model (mosaic.core.schema_typing) — the same model behind the typed Python SDK — so the two surfaces never drift. Framework classes (Entity, ProvenanceRecord, Process, Validator, ReferenceLoader) are never exposed; ExternalID is.

Relationships traverse the graph

A class-ranged slot generates two fields: the raw stored UUID and a resolved field that walks the relationship.

{
  samples {
    items {
      name
      donorId          # raw stored UUID
      donor {          # resolved Donor — one batched query, no N+1
        name
        sex
      }
    }
  }
}

Resolved fields load through a per-request DataLoader: resolving the same target type N times in one request issues a single batched query.

A multivalued reference field gets a <field>Count: Int! sibling — its cardinality without resolving any member object (issue #132):

{ study(id: "st1") { samplesCount } }

A single indexed COUNT(*) over the relationships table (or over the target table's forward FK column for an inverse reverse edge); an edge whose target is unavailable is not counted (same availability rule the resolved list and the some/none quantifiers apply). A reverse edge also resolves as an ordinary list field (donor { samples { name } }) but is absent from the class's Create/Update inputs — it is derived from the forward slot and not writable. Useful for a relationship count badge that would otherwise resolve the whole list just to show its length.

System and temporal fields

Every entity type carries id and isAvailable (stored), plus the read-only computed fields version, createdAt, updatedAt, schemaVersion, createdBy, updatedBy, and supersededBy — derived from the provenance log at read time, never stored on entity rows.

Lifecycle, not deletion

There are no delete mutations. Mosaic never hard-deletes; use availability transitions:

mutation {
  setSampleAvailability(id: "…", isAvailable: false, reason: "depleted") {
    entityId
    isAvailable
  }
}

mutation {
  setSampleAvailabilityBulk(ids: ["…", "…"], isAvailable: false, reason: "audit") {
    total succeeded failed
    failures { entityId error }
  }
}

The bulk form mirrors the REST bulk-availability endpoint: per-record error isolation — one bad id never rolls back its siblings.

Supersession works the same way as in the SDK, with a read-side query for the chain:

mutation { supersedeSample(id: "old", replacementId: "new", reason: "remeasured") { entityId supersededBy } }

query { supersededBy(id: "old") { entityId supersededBy chain } }

Slots annotated hippo_search in your schema are searchable per type. Search composes with the list surface (issue #157): the twins take the same filters/where/filterMode arguments as list queries and return the same Page envelope:

{
  searchSamples(q: "hippocampus", where: {isTumor: {eq: true}}, limit: 20) {
    items { id name }
    total
  }
}

Under the hood the ranked FTS hit set feeds one composed list query (id IN (…) — one batched read per page, no per-hit fetches), so availability and filter semantics are identical to list queries and total honors both the FTS match set (bounded at 1000 hits) and the composed filters. Results come back in FTS rank order — the point of search — unless an explicit orderBy is given, which overrides rank (the pinned precedence rule). Unknown filter fields are the same coded errors as list queries.

Cross-class roots: searchAll and neighbors

Two heterogeneous roots answer questions no per-class query can, using the house JSON-envelope pattern (entityId + entityType + data: JSON, typed follow-up via the per-type queries):

{
  searchAll(q: "cortex", limit: 20) { entityId entityType score data }
  neighbors(id: "some-uuid", depth: 2) {
    nodes { entityId entityType data }
    edges { source target type edgeSource }
    edgeSources
    notices
  }
}
  • searchAll(q, limit) — ranked full-text search across every FTS-indexed class in one request (score is normalized per index, so scores compare in relative terms across classes). Materialization is batched by type; availability applies exactly as list queries. The per-class search twins remain for typed results.
  • neighbors(id, depth, asOf) — the renderable subgraph around one entity, covering both edge stores: link-table relationship edges (multivalued references, both directions) and column-stored single-valued references (forward and reverse, schema-driven). Depth is capped at 5 and the node budget at 1000 — a hit bound always lands in notices, never silent truncation. Under asOf, link-table edges replay from provenance and node states reconstruct at that time, while column edges are disclosed as out of scope (edgeSources names what a response covers).

Schema introspection (the Mosaic kind)

Beyond GraphQL's own __schema, the API exposes Mosaic's domain type model — useful for generic clients that need the LinkML classification:

{
  hippoSchema { name accessorName }
  hippoEntityType(name: "Sample") {
    fields { name kind range role required multivalued targetEntityType enumValues }
    relationships { field targetEntityType }
  }
}

Errors

SDK errors surface as structured GraphQL errors with a machine-readable extensions.code: VALIDATION_FAILED (with the tier-tagged failure envelope when available), NOT_FOUND, ALREADY_SUPERSEDED, AVAILABILITY_CHANGE_FAILED, or INTERNAL_ERROR.

Hardening

Queries nesting deeper than the configured depth limit (default 10) are rejected before execution — relationship fields make unbounded traversals expressible, so the cap is the transport's recursion guard. Introspection is exempt, so GraphiQL works regardless. Configure with --graphql-max-depth / max_query_depth.

Known limitations

  • Offset pagination only — cursor pagination follows the REST roadmap.
  • No schema versioning — the GraphQL schema regenerates from your LinkML schema at startup; breaking LinkML changes break the GraphQL contract the same way they break the typed SDK.
  • No relationship predicates yet — the generated where: argument covers scalar/enum slots with nested and/or/not; filtering through reference edges (to-one nesting, to-many some/none quantifiers) arrives in later ADR-0006 increments. Multivalued references remain unfilterable (relatedTo covers reverse lookups).
  • Updates cannot null-out a field — omitted and null input fields are both dropped from the patch.