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:
(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_ml → volumeMl); 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-downCOUNT(*); always equals the list'stotal. UnderasOfit 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.fieldtakes either spelling (slot name or camelCase). Entities with no stored value are not counted — ask about absence withIS_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):
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 } }
Full-text search¶
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 (scoreis 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 innotices, never silent truncation. UnderasOf, link-table edges replay from provenance and node states reconstruct at that time, while column edges are disclosed as out of scope (edgeSourcesnames 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 nestedand/or/not; filtering through reference edges (to-one nesting, to-manysome/nonequantifiers) arrives in later ADR-0006 increments. Multivalued references remain unfilterable (relatedTocovers reverse lookups). - Updates cannot null-out a field — omitted and
nullinput fields are both dropped from the patch.