Directive reference
One SDL describes both the graph and the API, and directives are how you say what you mean. This page has every directive, each with what it does, its arguments and an example you can paste.
Every schema on this page is built by the package's test suite, and every query is validated against the schema above it.
How directives are checked
Unknown directives, unknown arguments and arguments of the wrong type are
errors at startup, because the directive definitions are prepended to
your SDL before it is validated. A directive in a position where it would
do nothing, such as @limit on a scalar field, is an error too, never
silently ignored.
To get the definitions for an editor or a codegen tool:
lora-graphql directives > directives.graphql
or import directiveTypeDefs from @loradb/lora-graphql.
A directive on extend type, extend interface, extend union,
extend scalar or extend schema counts as one on the definition.
Repeatable directives add up; a non-repeatable directive written on both
is an error.
All directives
Nodes and keys:
@node ·
@key ·
@unique ·
@uniqueTogether ·
@index ·
@relayId
Fields:
@alias ·
@private ·
@default ·
@timestamp ·
@populatedBy ·
@readonly ·
@settable ·
@selectable ·
@storedAs
Validation:
@length ·
@pattern ·
@format ·
@range ·
@multipleOf ·
@size ·
@normalize
Relationships:
@relationship ·
@relationshipProperties ·
@declareRelationship ·
@cardinality
Computed fields:
@cypher ·
@customResolver
Reading:
@query ·
@filterable ·
@sortable ·
@groupBy ·
@limit ·
@plural
Caching:
@cacheControl
Writing and events:
@mutation ·
@subscription ·
@trigger ·
@outbox
Access control:
@jwt ·
@jwtClaim ·
@viewer ·
@authentication ·
@authorization ·
@authorizationRule ·
@authorizationRules ·
@authorizationDefaults
Nodes and keys
@node
Makes a type a kind of node in the graph. Every type you query or relate to needs it.
type Festival @node {
key: String! @key
name: String!
}
type Person @node(labels: ["Person", "Account"], plural: "people") {
key: String! @key
}
Arguments:
labels: the node's labels. Default: the type name. The first label is the primary one: reads match on it, indexes and constraints go on it, and no two types may share it. Creates set every label.plural: the name used in root fields. Default: the type name pluss(ores, ories), soPersonwould bepersons.
{
festivals {
name
}
people {
key
}
festival(key: "sunland") {
name
}
}
@key
The node's identity: required, unique and never changed. Exactly one per
type, on a non-null String, ID, Int or BigInt.
type Festival @node @mutation {
key: ID! @key(generate: true)
name: String!
}
The key is how a node is looked up, updated, deleted and connected. It is
also the last sort key of every list and the anchor of every cursor, so
paging stays stable. The key field is always filterable by eq and in
and always sortable.
Arguments:
generate:truefills a UUID on create when the input leaves the key out. OnIDorString. Defaultfalse.scope:VIEWERmakes a created key start with the caller's@viewerclaim and the separator, as inlou:tomorrowland, so callers can only create under their own prefix.separator: the separator forscope. Default:.
mutation {
createFestivals(input: [{ name: "Sunland" }]) {
festivals {
key
}
}
}
See owner-scoped keys
for scope: VIEWER at work.
@unique
A uniqueness constraint on one field. A second node with the same value
fails with CONSTRAINT_VIOLATION.
type Person @node @mutation {
key: String! @key
email: String! @unique
}
@uniqueTogether
No two nodes share a combination of fields. A field may be a scalar, a single relationship (compared by the target's key) or one list relationship (compared as a set). Repeatable.
type Seat
@node
@mutation
@uniqueTogether(fields: ["venue", "row", "number"]) {
key: ID! @key(generate: true)
row: String!
number: Int!
venue: Venue! @relationship(type: "IN_VENUE", direction: OUT)
}
type Venue @node {
key: String! @key
}
Arguments:
fields: the field names that must be unique together.where: a filter limiting which nodes are compared, for a rule that applies only to some of them.
It is enforced by every generated mutation, for every caller. It is not a database constraint, so raw Cypher is not checked. See uniqueness across relationships.
@index
An explicit index. You rarely need it: indexes are inferred from
@filterable and @sortable. Use it for a field that only your own
@cypher statements filter on.
type Festival @node {
key: String! @key
externalId: String @index(kind: RANGE)
}
Argument:
kind:RANGE,TEXTorPOINT.
@relayId
On the @key field: adds an opaque global id to the type, the Node
interface, and the root fields node(id:) and nodes(ids:). Relay and
normalising client caches use these.
type Festival @node {
key: String! @key @relayId
name: String!
}
query ($id: ID!) {
node(id: $id) {
id
... on Festival {
name
}
}
}
The type may not have a field of its own named id. toGlobalId(type, key) and fromGlobalId(id) are exported for servers that need to build
or read one.
Fields
@alias
The API name differs from the stored property. Useful for renaming a field without migrating data.
type Festival @node {
key: String! @key
name: String! @alias(property: "title")
}
Clients read and filter name; the node stores title.
@private
Stored, never exposed. The field is in no output and no input, but rules
and @cypher statements can still use it.
type Person @node {
key: String! @key
name: String!
passwordHash: String @private
}
@default
The value stored on create when the input leaves the field out.
type Post @node @mutation {
slug: String! @key
title: String!
published: Boolean! @default(value: false)
status: String! @default(value: "DRAFT")
}
The default is applied once, on create. It also satisfies a non-null
field, so published is required in the output but optional in the
create input. On a relationship property it applies when the relationship
is created.
@timestamp
Set to the current time by the database. On DateTime, LocalDateTime
or Date.
type Post @node @mutation {
slug: String! @key
title: String!
createdAt: DateTime! @timestamp(operations: [CREATE])
updatedAt: DateTime @timestamp
}
Argument:
operations: which writes set it. Default[CREATE, UPDATE].
A timestamp field is not in the create or update input: clients cannot set it unless you opt in.
@populatedBy
Computed on write by a function you pass in the callbacks option.
type Festival @node @mutation {
key: String! @key
name: String!
slug: String! @populatedBy(callback: "slug", operations: [CREATE])
}
new LoraGraphQL({
typeDefs,
driver: loraDriver(db),
callbacks: {
slug: ({ operation, type, field, key, input, context }) =>
String(input.name).toLowerCase().replace(/\W+/g, "-"),
},
});
Arguments:
callback: the name of the function incallbacks. A missing one fails at startup.operations: which writes call it. Default[CREATE, UPDATE].
The callback receives the operation, the type and field names, the
node's key, the node's input in this mutation and the GraphQL context,
and may return a promise. The callbacks of a mutation's own inputs (the
nodes of a create whose key the input gives, a keyed update) start
together, before the transaction opens, so they do not hold the database's
writer lock; those of nested inputs, generated keys, upserts and bulk
updates run inside it. A callback may therefore run for a mutation that
is then refused: keep it free of side effects. It is the usual way to fill a field from the
caller's claims, such as a tenant id from context.jwt.
A non-null field is allowed when operations includes CREATE. A
callback that then returns null or undefined fails the mutation with
CONSTRAINT_VIOLATION. It cannot be combined with @timestamp or
@default, and it is not allowed on relationship properties.
Supplying a computed field
@timestamp and @populatedBy fields are computed, so they are not in
the inputs. To let some callers supply the value (a seed script
backfilling history, an import that keeps its dates), opt in with
@settable and a field-level validate rule for that operation saying
who may:
type Claims @jwt {
roles: [String!]
}
type Post @node @mutation {
slug: String! @key
title: String!
createdAt: DateTime!
@timestamp(operations: [CREATE])
@settable(onCreate: true)
@authorization(
validate: [
{
operations: [CREATE]
where: { jwt: { roles: { includes: "admin" } } }
}
]
)
}
A supplied value is stored as given; an omitted one is computed as before. Without the rule the combination is a model error, so a computed field never becomes client-settable by accident.
@readonly
Readable, never settable by clients. The field leaves the create and update inputs.
type Festival @node @mutation {
key: String! @key
name: String!
ticketsSold: Int @readonly
}
Use it for values your own code maintains, with Cypher or a @cypher
mutation. On a relationship it removes the field from both inputs.
@settable
Which mutations may set a field. @readonly is both set to false.
type Order @node @mutation {
key: String! @key
currency: String! @settable(onUpdate: false)
note: String @settable(onCreate: false)
}
Arguments:
onCreate: the create input has the field. Defaulttrue.onUpdate: the update input has the field. Defaulttrue.
currency is chosen once and cannot change. note can only be added
later. On a relationship it decides whether the inputs offer the field;
on a relationship property, onUpdate: false also refuses a re-connect
that would change it.
@selectable
Whether a field can be read or aggregated.
type Person @node @mutation @query(aggregate: true) {
key: String! @key
name: String! @sortable
pin: String @selectable(onRead: false)
salary: Int @sortable @selectable(onAggregate: false)
}
Arguments:
onRead:falsemakes the field write-only: it is in the inputs and not in the output type. Defaulttrue.onAggregate:falsekeeps the field out of aggregates. Defaulttrue.
@storedAs
On a custom scalar: how its values are stored.
scalar Email @storedAs(type: STRING)
type Person @node {
key: String! @key
email: Email @filterable
}
Argument:
type:STRING,INT,FLOAT,BOOLEAN,DATETIMEorDATE.
Filters, sorts and indexes follow the storage type. Values pass through unchanged unless you supply an implementation, which can validate and normalise them:
new LoraGraphQL({
typeDefs,
driver: loraDriver(db),
scalars: { Email: EmailScalar }, // a GraphQLScalarType
});
Validation
These directives say which values a field accepts. A mutation whose input
breaks one is refused with BAD_USER_INPUT before anything is written,
and the error lists every broken rule at once: see
validation errors.
Write a rule once, on the field. It applies wherever the field is set: create, update, upsert and nested creates, and for relationship properties on connect and on edge updates. Three things hold for all of them:
nulland absent values pass. Whether a value is required is the type's business (String!).- On a list field,
@sizeis about the list and every other rule applies to each item. - Every directive takes
message:, which replaces the default message.
They validate what clients send through the generated API. Raw Cypher and
@cypher mutations are not checked, and adding a rule does not revisit
values already stored.
The same directives go on an argument of a @cypher field,
where they are checked before the statement runs. @normalize goes
there too, and changes the argument before the rules and the statement
see it.
Clients can see the rules. Each generated input field says them in its
description ("1 to 80 characters. Must be an email address."), so they
show in GraphiQL and in the printed schema, and
lora-graphql json-schema exports them
as JSON Schema for form validation.
The order is fixed: @normalize changes the value first,
the rules then judge the normalised value, and that value is what is
stored. For what directives cannot say, such as a rule across two fields,
see validators.
@length
How long a String or ID may be.
type Person @node @mutation {
key: String! @key
name: String! @length(min: 1, max: 80)
bio: String @length(max: 2000, message: "keep it under 2000 characters")
}
Arguments:
min,max: inclusive, either or both.
Length is counted in characters as people see them in most scripts:
Unicode code points, not bytes and not JavaScript's UTF-16 units. An emoji
is one. This is also how LoraDB's size() counts.
@pattern
An expression the whole value must match.
type Product @node @mutation {
sku: String! @key @length(max: 12) @pattern(regex: "[A-Z]{3}-[0-9]{4,8}")
name: String!
}
Arguments:
regex(required): a JavaScript regular expression, compiled with theuflag. In SDL a backslash is written twice:"\\d+".
Two rules keep a pattern safe to run on input you do not control:
- The whole value must match.
"[a-z]+"refuses"abc1", with no need for^and$. This is how Cypher's=~works, and it differs from JSON Schema, where a pattern may match anywhere. - The field needs
@length(max:). A pattern is only ever run on a value within that length.
At startup each pattern is also run against inputs built to make it
backtrack, and a pattern such as (a+)+ that takes too long is refused.
That check finds the usual repetition mistakes; it is not a proof.
@format
A named shape a String or ID must have.
type Person @node @mutation {
key: String! @key
email: String @format(as: "email")
website: String @format(as: "url")
phone: String @format(as: "e164", message: "use the form +31201234567")
}
Argument:
as(required): one of the formats below. An unknown name is an error at startup.
The formats:
email: the rule browsers apply to an email input, at most 254 characters.email-strictalso requires a top-level domain, soada@localhostis refused. Neither proves the address exists.url: an absolutehttporhttpsURL. Other schemes are refused on purpose: a storedjavascript:orfile:URL is an attack waiting for the page that renders it.uuid: the 8-4-4-4-12 shape, in either case, any version.hostname,ipv4,ipv6.slug: lower-case letters and digits separated by single hyphens.hex-color:#and 3, 4, 6 or 8 hex digits.ulid.e164: the shape of an international phone number,+and 7 to 15 digits. A shape, not proof the number is assigned.
Dates and times need no format: Date, Time, DateTime and Duration
are types.
@range
The bounds of an Int, Float or BigInt.
type Product @node @mutation {
sku: String! @key
stock: Int! @range(min: 0)
discount: Float @range(min: 0, exclusiveMax: 1)
rating: Float @range(min: 1, max: 5)
}
Arguments:
min,max: inclusive bounds.exclusiveMin,exclusiveMax: bounds the value may not equal.
0 is a value like any other: stock: 0 passes min: 0, and
discount: 1 fails exclusiveMax: 1. A BigInt beyond 2^53 is compared
approximately, since the bounds are floats.
An adjust is computed
in the database, so its result is checked there: an adjustment that
leaves the range fails with CONSTRAINT_VIOLATION and is rolled back.
mutation {
updateProduct(sku: "ABC-1234", adjust: { stock: { subtract: 3 } }) {
product {
stock
}
}
}
On an argument of a @cypher field, @range is checked
before the statement runs:
type Festival @node {
key: String! @key
capacity: Int
}
type Query {
largest(count: Int = 10 @range(min: 1, max: 100)): [Festival!]!
@cypher(
statement: """
MATCH (f:Festival)
RETURN f ORDER BY f.capacity DESC LIMIT $count
"""
)
}
@multipleOf
An Int or Float must be a whole multiple of a value.
type Product @node @mutation {
sku: String! @key
price: Float @range(min: 0) @multipleOf(value: 0.01)
packSize: Int @multipleOf(value: 6)
}
Argument:
value(required): greater than zero, and an integer on anIntfield.
Floating point is handled with a tolerance, so 0.3 is a multiple of
0.1 here although 0.3 % 0.1 is not zero. For money, storing an
integer number of cents is still the more robust choice.
@size
How many items a list field holds.
type Post @node @mutation {
slug: String! @key
tags: [String!] @size(max: 5, unique: true) @length(max: 20)
}
Arguments:
min,max: inclusive, either or both.unique:truerefuses the same item twice.
@size is about the list, and @length here is about each tag. A
violation of an item rule carries the item's index in its path.
A list grown with adjust: { tags: { push: [...] } } is checked after
the push, like a @range.
On a list argument of a @cypher field, @size is checked
before the statement runs. Without a max the maxListArgument option
applies (1000 by default).
type Festival @node {
key: String! @key
}
type Query {
festivalsByKeys(keys: [String!]! @size(max: 50)): [Festival!]!
@cypher(statement: "MATCH (f:Festival) WHERE f.key IN $keys RETURN f")
}
On a list relationship field, @size(max:) bounds how many
relationships one input writes through it: each nested connect,
create, disconnect and update list takes at most max items. A
longer list is BAD_USER_INPUT with a maxItems violation, before the
transaction opens.
type Person @node @mutation @authentication {
key: String! @key
following: [Person!]!
@relationship(type: "FOLLOWS", direction: OUT)
@cardinality(max: 5000)
@size(max: 50)
}
mutation {
updatePerson(
key: "grace"
update: { following: { connect: [{ key: "ada" }, { key: "lin" }] } }
) {
info {
relationshipsCreated
}
}
}
- Only
maxapplies here;minanduniqueare a model error. - It is about one input list, not about what is stored: a person above
may follow five thousand people, fifty per list.
@cardinalityis the estimate of the stored number and refuses nothing. - Each list has its own bound, and so does each member type of an interface or union field.
- Over the whole mutation,
maxBatchstill applies: ten times as many relationships as nodes.
@normalize
Changes a String or ID before it is validated and stored. Validation
refuses a value; this is the one directive that alters it, so it is
strictly opt in.
type Account @node @mutation {
key: String! @key
email: String!
@normalize(trim: true, case: LOWER, unicode: NFC)
@format(as: "email")
@unique
displayName: String @normalize(trim: true, emptyToNull: true)
}
Arguments, applied in this order:
trim:trueremoves whitespace from both ends.unicode:NFC,NFD,NFKCorNFKD.NFCmakes the two ways of writing "é" (one character, or "e" plus a combining accent) the same string.case:LOWERorUPPER.emptyToNull:truestores no value for a string that is empty after the steps above. Useful for optional fields filled from a form.
On a list field each item is normalised.
On a String or ID argument of a @cypher field it
changes the argument before its rules are checked and before the
statement sees it. emptyToNull needs a nullable argument there.
type Comment @node {
key: String! @key
text: String!
}
type Mutation {
addComment(
key: String!
text: String! @normalize(trim: true) @length(min: 1, max: 280)
): Comment
@authentication
@cypher(
statement: "CREATE (c:Comment {key: $key, text: $text}) RETURN c, c AS created"
columnName: "c"
)
}
It matters most on a @unique field. LoraDB compares strings exactly:
without it, ada@example.com, Ada@Example.com and ada@example.com
are three different values and all three accounts are created. With the
example above, the second is a CONSTRAINT_VIOLATION.
What it does not do:
- Reads are not normalised. A filter or a lookup compares with what
is stored, so
email: { eq: "Ada@Example.com" }finds nothing. Clients should send the stored form, which is the form they got back. trimleaves zero-width characters, which are not whitespace.- Case mapping is not for display. It can change a string's length, and it ignores language-specific rules on purpose, so a stored value does not depend on where the server runs.
Reusable rules on a scalar
@length, @pattern, @format, @range, @multipleOf and
@normalize can sit on a custom scalar. Every field of that type then
has them, on node types and on relationship properties, and the type's
name says what the value is.
scalar Email
@storedAs(type: STRING)
@normalize(trim: true, case: LOWER)
@format(as: "email")
@length(max: 254)
scalar Percent @storedAs(type: INT) @range(min: 0, max: 100)
type Person @node @mutation {
key: String! @key
email: Email @unique
backupEmail: Email
profileComplete: Percent
}
A field may replace one of the scalar's rules by writing the same
directive itself: backupEmail: Email @length(max: 100). The other rules
of the scalar still apply.
The scalar needs no implementation in the scalars option for this: the
rules are the directives. Clients see Email and Percent as types in
the schema.
Rules across fields: validators
A directive judges one value. A rule about two values ("the stay ends
after it starts"), or one that needs your own code, goes in the
validators option: a validator per node type, written with the schema
library you already use.
import { z } from "zod";
const lora = new LoraGraphQL({
typeDefs,
driver: loraDriver(db),
validators: {
Booking: z
.object({ startsOn: z.string().optional(), endsOn: z.string().optional() })
.passthrough()
.refine((b) => !b.startsOn || !b.endsOn || b.endsOn >= b.startsOn, {
path: ["endsOn"],
message: "must not be before startsOn",
}),
},
});
Any Standard Schema validator works: zod, valibot, arktype, yup, joi and others implement it. The package depends on none of them.
- What it is given: each input of that type as the client sent it,
after
@normalize: a create's, an update's, an upsert's, and nested ones. Dates and times are ISO strings. - When: after the field rules, and only if they all hold, so a validator never has to defend against what a directive already refuses. Still before the transaction opens.
- Its issues are reported like any other rule's, in
extensions.violationswithrule: "custom", the path the validator gave, and its message. - What it returns is what is written, so a schema's own transforms apply. A strict object schema that strips unknown keys would drop fields it does not list: use the library's pass-through mode.
- It may be asynchronous.
An update's input holds only the fields it sets, and an upsert's only the key for certain. To require fields on create without breaking updates, give a schema per kind of input:
validators: {
Booking: { create: fullBooking, update: partialBooking },
}
A kind with no schema is not validated. A validator sees the input, not
what is stored: an update that sets only endsOn cannot be compared with
a stored startsOn here.
Relationships
The relationships page covers everything these generate, and many-to-many relationships walks through them from a relational join table.
@relationship
Connects two node types. The target is the field's type: a @node type,
or an interface or union over node types.
type Festival @node @mutation {
key: String! @key
genre: Genre @relationship(type: "IN_GENRE", direction: OUT) @filterable
stages: [Stage!]!
@relationship(type: "HAS_STAGE", direction: OUT, onDelete: CASCADE)
followers: [User!]!
@relationship(
type: "FOLLOWS"
direction: IN
properties: "Follows"
nestedOperations: [CONNECT, DISCONNECT]
)
@filterable
}
type Genre @node {
key: String! @key
}
type Stage @node @mutation {
key: String! @key
name: String!
}
type User @node {
key: String! @key
}
type Follows @relationshipProperties {
since: Int
}
Arguments:
type(required): the relationship type, inSCREAMING_SNAKE_CASE.direction(required):OUTorIN, seen from the declaring type.properties: the name of a@relationshipPropertiestype.queryDirection:UNDIRECTEDreads follow the relationship both ways, for symmetric relationships such as friendship. Writes always usedirection. DefaultDIRECTED.onDelete: what deleting this node does to the nodes the field reaches.DETACHremoves the relationships,CASCADEdeletes the related nodes too,RESTRICTrefuses while any exist. DefaultDETACH.nestedOperations: which nested writes the mutation inputs offer. Default[CREATE, CONNECT, DISCONNECT, UPDATE, DELETE]. A sixth value,UPDATE_EDGE, offers updating the relationship's properties without the connected node.aggregate:falsedrops the connection's aggregates and the aggregate filter. Defaulttrue.
The field's type sets the cardinality: Genre is zero or one, Genre!
exactly one, and [Stage!]! any number. Lists are always written
[T!]!. Cardinality is enforced on every write, from both sides.
{
festivals(where: { followers: { some: { key: { eq: "ada" } } } }) {
key
genre {
key
}
followersConnection(first: 10) {
edges {
properties {
since
}
node {
key
}
}
}
}
}
@relationshipProperties
A type holding the properties of a relationship: data that belongs to the
link, not to either node. Name it in @relationship(properties:).
type Team @node @mutation {
key: String! @key
members: [Person!]!
@relationship(type: "MEMBER_OF", direction: IN, properties: "Membership")
}
type Person @node {
key: String! @key
}
type Membership @relationshipProperties {
role: String! @default(value: "member") @filterable
joinedAt: DateTime @timestamp(operations: [CREATE]) @sortable
}
Fields must be scalars. The type is not a node: it has no key and no root
field. A property takes @filterable, @sortable, @alias, @private,
@readonly, @settable, @selectable, @default, @timestamp,
@authentication and @authorization.
{
team(key: "core") {
membersConnection(
where: { edge: { role: { eq: "owner" } } }
sort: [{ edge: { joinedAt: DESC } }]
) {
edges {
properties {
role
joinedAt
}
node {
key
}
}
}
}
}
@declareRelationship
On an interface field: every implementation declares this relationship, so clients can select it on the interface.
interface Event {
key: String!
venue: Venue @declareRelationship
}
type Concert implements Event @node {
key: String! @key
venue: Venue @relationship(type: "HELD_AT", direction: OUT)
}
type Talk implements Event @node {
key: String! @key
venue: Venue @relationship(type: "HOSTED_AT", direction: OUT)
}
type Venue @node {
key: String! @key
}
Implementations must agree on the target, the list shape and the properties type. The relationship type and direction may differ.
{
events {
key
venue {
key
}
}
}
@cardinality
The most related nodes a list relationship has. Cost estimates use it in place of assuming a full page.
type Festival @node {
key: String! @key
stages: [Stage!]!
@relationship(type: "HAS_STAGE", direction: OUT)
@cardinality(max: 12)
}
type Stage @node {
key: String! @key
}
It is a statement about your data, not a limit the library enforces. With
it, deeper nested queries fit under maxCost. See
statistics and cost.
Computed fields
@cypher
A field backed by your own Cypher statement, on a node type, on Query
or on Mutation.
type Festival @node {
key: String! @key
name: String!
similar(limit: Int = 3): [Festival!]!
@cypher(
statement: """
MATCH (this)-[:IN_GENRE]->(:Genre)<-[:IN_GENRE]-(other:Festival)
WHERE other.key <> this.key
RETURN other ORDER BY other.name LIMIT $limit
"""
columnName: "other"
)
}
type Query {
festivalCount: Int!
@cypher(statement: "MATCH (f:Festival) RETURN count(f) AS n")
}
Arguments:
statement(required): the Cypher.thisis the parent node, each field argument is a$parameter,$jwtholds the request's claims and$viewerthe key of the caller's node.columnName: the returned column. Inferred when the lastRETURNhas one item.cost: the rows one run of the statement examines, charged againstmaxCost: once for a root field, once per parent row for a field of a type. Without it only the rows the field returns are charged.
{
festivalCount
festival(key: "sunland") {
similar(limit: 5) {
name
}
}
}
A statement may write only on Mutation. Statements are trusted server
code: they run with the database's full access, and rules on the types
they touch do not apply inside them. The
@cypher fields page covers return types,
$viewer, guarding root fields and what the library checks.
@customResolver
A field computed in JavaScript by a resolver you pass in the resolvers
option.
type Festival @node {
key: String! @key
name: String!
capacity: Int
label: String! @customResolver(requires: "name capacity")
}
new LoraGraphQL({
typeDefs,
driver: loraDriver(db),
resolvers: {
Festival: { label: (source) => `${source.name} (${source.capacity})` },
},
});
Argument:
requires: a selection on the type, fetched in the same statement as the rest of the node, so the resolver finds it on its source even when the client did not ask for it.
A missing resolver or an invalid requires fails at startup. The field
cannot be filtered, sorted or grouped by, since it does not exist in the
database.
Reading
@query
Controls the generated reads of a type. Reads are on by default, so you only need it to change them.
type Festival @node @query(aggregate: true) {
key: String! @key
capacity: Int @sortable
}
type AuditEntry @node @query(read: false) {
key: String! @key
}
Arguments:
read:falseremoves the type's root fields, and with them its search and subscription fields. The type can still be reached through relationships. Defaulttrue.aggregate:trueadds<plural>Aggregateand<plural>Grouped, and theaggregatefield of the type's connections. Defaultfalse. It has no effect on an interface or union. Each relationship connection that leads to the type then gets connection and edge types of its own, named after the owner and the field (CityVenuesConnectionandCityVenuesEdgewhere it wasVenueConnectionandVenueEdge), because its aggregate covers that relationship. Turning the argument on for a type therefore renames types that clients may name in fragments. To count a type's nodes without it, select onlytotalCounton the root connection:{ venuesConnection { totalCount } }reads no page.
{
festivalsAggregate {
count
capacity {
min
max
avg
}
}
}
@filterable
Which filters clients may use on a field. Nothing is filterable without it, except the key.
type Festival @node {
key: String! @key
name: String! @filterable(byValue: [EQ, CONTAINS, CASE_INSENSITIVE])
country: String! @filterable
capacity: Int @filterable(byValue: [GTE, LT, IS_NULL])
tags: [String!] @filterable
location: Point @filterable(byValue: [WITHIN_BBOX, DISTANCE])
genre: Genre @relationship(type: "IN_GENRE", direction: OUT) @filterable
}
type Genre @node {
key: String! @key
}
Argument:
byValue: the operators. Without it:EQandIN(EQalone on aBoolean,INCLUDESon a list). On a relationship field it takes no argument and enables relationship filters.
The operators, and the index each one gets:
EQ,IN: strings, ids, numbers, temporals, durations and enums. No index needed: LoraDB indexes equality lazily.LT,LTE,GT,GTE: strings, ids, numbers and temporals. A RANGE index.CONTAINS,STARTS_WITH,ENDS_WITH: strings and ids. A TEXT index.CASE_INSENSITIVE: strings and ids. No index can apply; prefer@fulltexton large data.IS_NULL: nullable fields. No index can apply.INCLUDES: lists. A RANGE index on the list: it keeps each list's elements, so{ includes: "techno" }(compiled to$v IN this.genres) seeks the nodes whose list holds the value instead of scanning the label. It also addsincludesAny, for several picks:{ includesAny: ["techno", "house"] }(compiled toany(v IN $values WHERE v IN this.genres)) seeks each value and returns each node once. AnORofincludeson the same list seeks the same way.WITHIN_BBOX,DISTANCE:PointandCartesianPoint. A POINT index.
Each operator is checked against the field's type at startup, so
CONTAINS on an Int is an error, not a filter that never matches.
In a where, operators are camelCase:
{
festivals(
where: {
name: { caseInsensitive: { contains: "land" } }
country: { in: ["NL", "BE"] }
capacity: { gte: 1000, lt: 50000 }
tags: { includes: "outdoor" }
location: {
distance: { from: { latitude: 52.37, longitude: 4.9 }, lte: 50000 }
}
genre: { key: { eq: "techno" } }
}
) {
name
}
}
caseInsensitive nests the string operators the field also lists.
distance takes { from, lte } in metres for geographic points, and
withinBBox takes { lowerLeft, upperRight }; a lowerLeft longitude
greater than the upperRight one means the box crosses the antimeridian.
@sortable
Clients may sort and paginate by the field. It becomes part of the type's
aggregates, and gets a RANGE index when a root list or connection can sort
by it: the type's own, or an interface's or union's that lists the field.
A type with @query(read: false) that only nested lists sort by, such as
an album's tracks by number, gets no index: those sorts run per parent,
on its few related nodes, and no plan would seek it.
type Festival @node {
key: String! @key
name: String! @sortable
startsOn: Date @sortable
}
{
festivalsConnection(first: 10, sort: [{ startsOn: DESC }, { name: ASC }]) {
edges {
node {
name
}
}
}
}
Not on lists, points or durations. Nulls sort last ascending and first
descending, as in Cypher, unless the field says otherwise (below). On a
relationship property it enables sort: [{ edge: { ... } }] on the
connection.
Nulls first or last
@sortable(nulls: LAST) puts a nullable field's nulls after every value
whichever the direction asked for, and nulls: FIRST before them. A
score read biggest-first then starts with the biggest score, not with
every node that has none. The statement says NULLS LAST (or NULLS FIRST), a backward page reverses it with the direction, and a cursor
records it in its sort, so a cursor from before the setting changed is
INVALID_CURSOR.
type Artist @node {
key: String! @key
name: String!
popularityScore: Float @sortable(nulls: LAST)
}
CREATE (:Artist {key: 'amber', name: 'Amber Fields', popularityScore: 10.0}),
(:Artist {key: 'nova', name: 'Nova'}),
(:Artist {key: 'kite', name: 'Kite Avenue', popularityScore: 90.0}),
(:Artist {key: 'lumen', name: 'Lumen', popularityScore: 90.0}){
artistsConnection(first: 3, sort: [{ popularityScore: DESC }]) {
edges {
node {
name
popularityScore
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
{
"data": {
"artistsConnection": {
"edges": [
{ "node": { "name": "Kite Avenue", "popularityScore": 90 } },
{ "node": { "name": "Lumen", "popularityScore": 90 } },
{ "node": { "name": "Amber Fields", "popularityScore": 10 } }
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "eyJzIjoicG9wdWxhcml0eVNjb3JlOkRFU0M6TlVMTFNfTEFTVCxrZXk6QVNDIiwidiI6WzEwLCJhbWJlciJdfQ"
}
}
}
}
The next page starts after Amber Fields and reaches the nulls:
query ($after: String) {
artistsConnection(first: 3, after: $after, sort: [{ popularityScore: DESC }]) {
edges {
node {
name
popularityScore
}
}
pageInfo {
hasNextPage
hasPreviousPage
}
}
}
{ "after": "eyJzIjoicG9wdWxhcml0eVNjb3JlOkRFU0M6TlVMTFNfTEFTVCxrZXk6QVNDIiwidiI6WzEwLCJhbWJlciJdfQ" }
{
"data": {
"artistsConnection": {
"edges": [{ "node": { "name": "Nova", "popularityScore": null } }],
"pageInfo": { "hasNextPage": false, "hasPreviousPage": true }
}
}
}
Ascending, the nulls are last too:
{
artists(sort: [{ popularityScore: ASC }]) {
name
popularityScore
}
}
{
"data": {
"artists": [
{ "name": "Amber Fields", "popularityScore": 10 },
{ "name": "Kite Avenue", "popularityScore": 90 },
{ "name": "Lumen", "popularityScore": 90 },
{ "name": "Nova", "popularityScore": null }
]
}
}
The first page compiles to one ordered index walk, the scored artists from the highest down and then the unscored ones, and stops at the page:
MATCH (this:Artist)
WHERE this.popularityScore >= toFloat("-Infinity") OR this.popularityScore IS NULL
WITH this ORDER BY this.popularityScore DESC NULLS LAST, this.key ASC LIMIT $p0
RETURN this { .name, .popularityScore } AS node, [this.popularityScore, this.key] AS __cursorThe >= bound excludes no row; it lets the planner walk the RANGE index
that @sortable infers in order. A page after a scored cursor seeks from
it the same way ((this.popularityScore <= $v AND (...)) OR this.popularityScore IS NULL), and ties on a score are sorted by key one
run at a time (PartialSort in explain()), so a page reads about one
page of rows however many artists there are. A page whose cursor is
already among the nulls has no range left to walk: it reads the type's
nulls and sorts them by key. The walk covers String, ID, enum and number
fields; other types sort as before. Only for nullable fields: a non-null
field has no nulls, and nulls: on one is an error.
@groupBy
Offers the field as a grouping key of <plural>Grouped. Needs
@query(aggregate: true).
type Festival @node @query(aggregate: true) {
key: String! @key
country: String! @groupBy
capacity: Int @sortable
}
{
festivalsGrouped(by: [country]) {
by {
country
}
aggregate {
count
capacity {
sum
}
}
}
}
On a readable field that is not a list or a point.
@limit
Page size bounds, for a type's lists or for one relationship.
type Festival @node @limit(default: 10, max: 50) {
key: String! @key
stages: [Stage!]!
@relationship(type: "HAS_STAGE", direction: OUT)
@limit(default: 5, max: 20)
}
type Stage @node {
key: String! @key
}
Arguments:
default: the page size when a list gets nolimitorfirst. Globally 25, set with thedefaultLimitoption.max: the largest page. Globally 100, set withmaxLimit.@limitmay only lower it.
Asking for more than max is a LIMIT_EXCEEDED error, not a silently
shorter page.
@plural
The root field name of an interface or union. Types use
@node(plural:).
interface Event @plural(value: "happenings") {
key: String!
}
type Concert implements Event @node {
key: String! @key
}
union SearchResult @plural(value: "results") = Concert
{
happenings {
key
}
}
Search
@fulltext
Full-text search over one or more fields: a FULLTEXT index and a search root field per entry.
type Festival
@node
@fulltext(
indexes: [
{ fields: ["name", "description"] }
{ name: "festival_tags", fields: ["tags"], analyzer: SIMPLE }
]
) {
key: String! @key
name: String!
description: String
tags: [String!]
}
Each entry of indexes takes:
fields(required):StringorIDfields, or lists of them.name: the index name. The first defaults to<label>_search; later ones need a name.analyzer:STANDARD(default) orSIMPLE.queryName: the root field. Defaultsearch<Plural>for the first,search<Plural>By<Name>after.minPrefixLength: a*word shorter than this matches as a whole word, solo*is searched aslo. Without it, every*word is a prefix.maxMatches: the most matches one query ranks. The index keeps its bestmaxMatchesby score, andwhere,sort, pages andtotalCountapply within them. Without it, every match is ranked.
{
searchFestivals(query: "open air techno") {
score
node {
name
}
}
}
Terms are combined with AND, case and accents are folded, and a trailing
* matches a prefix. A field with a mask, a READ validate rule or READ
@authentication cannot be indexed, since searching it would reveal
which rows contain a hidden word.
Bounding a large index
A prefix of one or two letters matches a large share of a big index: lo*
over millions of song titles is hundreds of thousands of matches, each read,
ranked and counted before the page is cut. minPrefixLength and
maxMatches bound that work:
type Track
@node
@fulltext(
indexes: [{ fields: ["title"], minPrefixLength: 3, maxMatches: 1000 }]
) {
key: String! @key
title: String!
}
With the tracks Atlas, At the River, Atmosphere and Glue, a
two-letter prefix is searched as a whole word:
{
searchTracks(query: "at*") {
score
node {
title
}
}
}
{
"data": {
"searchTracks": [{ "score": 1, "node": { "title": "At the River" } }]
}
}
and a three-letter one is still a prefix:
{
searchTracks(query: "atm*") {
score
node {
title
}
}
}
{
"data": {
"searchTracks": [{ "score": 1, "node": { "title": "Atmosphere" } }]
}
}
The search's cost counts the matches it may rank:
maxMatches when it is set, otherwise every node of the type (from
statistics). A search over a large type without
maxMatches can therefore exceed maxCost, which is the point: set
maxMatches on it.
@vector
On a [Float!] field: a VECTOR index and a similarity root field.
type Festival @node {
key: String! @key
name: String!
embedding: [Float!] @vector(dimensions: 384, similarity: COSINE)
}
Arguments:
dimensions(required): 1 to 4096.similarity:COSINE(default) orEUCLIDEAN.queryName: the root field. Defaultsimilar<Plural>, orsimilar<Plural>By<Field>when the type has several vector fields.
Search from a query vector, or from an existing node with to:
{
similarFestivals(to: "sunland", limit: 5) {
score
node {
name
}
}
}
Caching
@cacheControl
How long, in seconds, a result over a type or field may be served from a
cache. The library caches nothing itself: execute() reports the
operation's policy as a non-enumerable
cacheControl on its result, for a response
cache or a Cache-Control header to use.
type Claims @jwt {
sub: String!
}
type Album @node @cacheControl(maxAge: 600) {
key: String! @key
title: String!
artist: Artist! @relationship(type: "RELEASED", direction: IN)
}
type Artist @node @cacheControl(maxAge: 300) {
key: String! @key
name: String!
viewerFollows: Boolean!
@cacheControl(maxAge: 0, scope: PRIVATE)
@cypher(
statement: "RETURN EXISTS { (this)<-[:FOLLOWS]-(:Fan {key: $jwt.sub}) } AS f"
columnName: "f"
)
}
type Fan @node {
key: String! @key
follows: [Artist!]! @relationship(type: "FOLLOWS", direction: OUT)
}
Arguments:
maxAge(required): seconds, 0 or more.scope:PUBLIC(default) orPRIVATE, a result only for the reader it was computed for.
An operation's policy is the smallest maxAge among the fields it selects.
Each field takes its own hint, or else its type's. A generated type such as
AlbumConnection, AlbumMatch or AlbumAggregate takes its type's, and a
scalar takes its parent's. One PRIVATE hint makes the whole result
private.
| Operation | cacheControl |
|---|---|
{ albums { title } } | { maxAge: 600, scope: "PUBLIC" } |
{ albums { title artist { name } } } | { maxAge: 300, scope: "PUBLIC" } |
{ albums { artist { viewerFollows } } } | { maxAge: 0, scope: "PRIVATE" } |
There is no policy, so nothing may be cached on the strength of a hint,
when the operation is a mutation or a subscription, selects a type with no
hint, or has a root field with none (an unhinted scalar @cypher field on
Query). It goes on node types, interfaces, unions, object types that
@cypher fields return, and their fields; a relationship properties type
takes its parent's.
A hint says how stale a result may get, not when it changes. Keep dropping
cached results on writes with read-sets, and
use maxAge as the most a result lives between them.
Writing and events
@mutation
Generates mutations for a type. There are none without it.
type Claims @jwt {
roles: [String!]
}
type Festival
@node
@mutation
@authentication(operations: [CREATE, UPDATE, DELETE]) {
key: String! @key
name: String!
}
type Genre
@node
@mutation(operations: [CREATE])
@authentication(operations: [CREATE]) {
key: String! @key
}
Argument:
operations:CREATE,UPDATEandDELETE. Default: all three.
CREATE generates create<Plural>, UPDATE generates update<Type>
and update<Plural>, DELETE generates delete<Type> and
delete<Plural>, and CREATE with UPDATE adds upsert<Plural>.
mutation {
createFestivals(input: [{ key: "sunland", name: "Sunland" }]) {
info {
nodesCreated
}
}
updateFestival(key: "sunland", update: { name: "Sunland Open Air" }) {
festival {
name
}
}
}
lora-graphql check fails on a @mutation type whose writes nothing
guards, which is why the example has @authentication.
@subscription
Generates a <type>Changed subscription. There is none without it.
type Festival
@node
@mutation
@subscription(relationships: true, previousState: true) {
key: String! @key
name: String!
stages: [Stage!]! @relationship(type: "HAS_STAGE", direction: OUT)
}
type Stage @node {
key: String! @key
}
Arguments:
operations: which events the type sends. Default[CREATE, UPDATE, DELETE].relationships:truealso sendsCONNECTandDISCONNECTevents, one per relationship. Defaultfalse.previousState:truemakesUPDATEandDELETEevents carry the stored values before the write, at the cost of one read per write. Defaultfalse.
subscription {
festivalChanged(operations: [UPDATE]) {
operation
key
node {
name
}
previousState {
name
}
}
}
@trigger
A Cypher statement run inside the transaction of every generated mutation that creates, updates or deletes nodes of the type, before the commit. Repeatable.
type Post @node {
key: String! @key
commentCount: Int
}
type Comment
@node
@mutation
@authentication
@trigger(
on: [CREATE, DELETE]
statement: """
MATCH (this)-[:ON]->(post:Post)
SET post.commentCount = coalesce(post.commentCount, 0)
+ CASE $operation WHEN 'DELETE' THEN -1 ELSE 1 END
RETURN post AS updated
"""
)
@trigger(
on: [UPDATE]
fields: ["text"]
statement: "SET this.edited = true RETURN this AS updated"
) {
key: String! @key
text: String!
edited: Boolean
post: Post! @relationship(type: "ON", direction: OUT)
}
Arguments:
on:CREATE,UPDATE,DELETE. The type must have the generated mutation for each. OrCONNECT,DISCONNECT, withfield.statement: runs once per mutation withthisbound to each node of the type it wrote.$jwt,$viewerand$operationare bound.fields: anUPDATEtrigger fires only for nodes whose update named one of these.field: the relationship field aCONNECTorDISCONNECTtrigger fires for. Its statement runs once per relationship the mutation created or removed, from either side, withthisthe node of this type andtargetthe node at the other end.
type Person
@node
@mutation(operations: [UPDATE])
@authentication
@trigger(
on: [CONNECT]
field: "following"
statement: """
SET target.followerCount = coalesce(target.followerCount, 0) + 1
RETURN target AS updated
"""
) {
key: String! @key
followerCount: Int
following: [Person!]! @relationship(type: "FOLLOWS", direction: OUT)
}
See a trigger per relationship.
Return the nodes the statement wrote as created or updated and they
join the mutation's write-set, so onWrite and subscriptions see them.
What a trigger writes fires no trigger, and raw Cypher and @cypher
mutations fire none. The whole picture, with trigger functions and a
tested example, is in triggers and workers.
@outbox
Makes each node of the type work for
lora.worker(): it is handed to the
worker's handler after the commit that created it, at least once, with
retries.
type Notification @node @outbox @subscription(operations: [CREATE]) {
key: String! @key
kind: String!
text: String!
}
No arguments. The delivery state is stored on the node, in properties
that are not part of the API. Nodes created by a generated mutation, or
returned as created by a @trigger statement, are work; nodes created
by raw Cypher are not.
Access control
Authentication and the authorization recipes show these at work, with the response each caller gets. The authorization reference defines every rule.
@jwt
Declares the claims your rules use. With it, a rule naming any other claim is an error at startup.
type Claims @jwt {
sub: String!
roles: [String!]
}
type Festival @node {
key: String! @key
}
The type's name does not matter, and it is not part of the public schema.
@jwtClaim
Where a declared claim lives in the token, when it is not at the top level.
type Claims @jwt {
sub: String!
roles: [String!] @jwtClaim(path: "app_metadata.roles")
tenant: String @jwtClaim(path: "org.id")
}
type Festival @node {
key: String! @key
}
Argument:
path: dots step into nested objects. Rules then use the short name,roles.
@viewer
Marks the claim that identifies the caller's own node. It enables
isViewer, viewer and ${viewer.key} in rules, and $viewer in
@cypher statements.
type Claims @jwt {
sub: String! @viewer(type: "Person", field: "subject")
}
type Person @node {
key: String! @key
subject: String! @unique
name: String!
}
Arguments:
type: the node type callers are.field: a@keyor@uniquefield of that type that the claim equals.
Pointing it at a separate unique field, as here, lets keys stay readable while the claim is an identity provider's opaque id.
@authentication
Requires a signed-in caller, and optionally a claim. On a type or a
field. A caller who fails gets UNAUTHENTICATED.
type Claims @jwt {
sub: String!
roles: [String!]
}
type Article
@node
@mutation
@authentication(operations: [CREATE, UPDATE, DELETE]) {
slug: String! @key
title: String!
internalNotes: String @authentication(operations: [READ])
}
type Report
@node
@authentication(jwt: { roles: { includes: "analyst" } }) {
key: String! @key
}
Arguments:
operations:READ,CREATE,UPDATE,DELETE,CREATE_RELATIONSHIP,DELETE_RELATIONSHIPandSUBSCRIBE. Default: all seven.jwt: a test the claims must also pass.
Anyone reads articles; only signed-in callers write them or read
internalNotes; only analysts touch reports.
@authorization
Row-level rules, compiled into the statements. On a type or a field.
type Claims @jwt {
sub: String! @viewer(type: "Person", field: "key")
roles: [String!]
}
type Person @node {
key: String! @key
name: String!
phone: String
@authorization(mask: [{ unless: { node: { isViewer: true } } }])
}
type Note
@node
@mutation
@authorization(
filter: [{ where: { node: { owner: { isViewer: true } } } }]
validate: [
{
operations: [CREATE]
where: { node: { owner: { isViewer: true } } }
}
]
) {
key: String! @key
text: String!
owner: Person!
@relationship(type: "OWNS", direction: IN)
@settable(onUpdate: false)
}
Arguments:
filter(on a type): rules that hide nodes the caller may not see. Each takesoperations(default[READ, UPDATE, DELETE]) andwhere. Any passing rule grants.validate(on a type or a field): rules that fail the request withFORBIDDEN. Each takesoperations(default[READ, CREATE, UPDATE, DELETE]),when(BEFORE,AFTER, default both) andwhere. On a relationship field they also takeCONNECT,DISCONNECT,UPDATE_EDGEandREAD_EDGE.mask(on a scalar field):[{ unless, value }]. A row failingunlessreads the field asvalue,nullwhen left out, with no error.bypass(on a type):falsekeeps the schema's bypass from skipping this type's rules.public(on a@mutationtype): operations deliberately open to every caller, socheckdoes not report them as unguarded.
A where takes node (a filter over the row), jwt (a test on the
claims), viewer (a filter over the caller's own node), rule (a named
rule), and AND, OR, NOT.
Here a note exists only for its owner, a note can only be created for
oneself, and a phone number reads as null to everyone but its owner.
@authorizationRule
Names a rule of a type, so several rules, and rules on related types, can share it. Repeatable.
type Claims @jwt {
sub: String! @viewer(type: "Person", field: "key")
}
type Person @node {
key: String! @key
}
type Project
@node
@authorizationRule(
name: "member"
where: { node: { members: { some: { isViewer: true } } } }
)
@authorization(filter: [{ where: { rule: "member" } }]) {
key: String! @key
members: [Person!]! @relationship(type: "MEMBER_OF", direction: IN)
}
type Task
@node
@authorization(
filter: [{ where: { node: { project: { rule: "member" } } } }]
) {
key: String! @key
project: Project! @relationship(type: "PART_OF", direction: OUT)
}
Arguments:
name: how rules refer to it, as{ rule: "member" }.where: the rule.
Task reaches the rule through its relationship, so "is a member" is
defined once.
@authorizationRules
Named rules over claims, for the whole schema. On extend schema.
extend schema
@authorizationRules(
rules: [
{ name: "admin", where: { jwt: { roles: { includes: "admin" } } } }
{ name: "staff", where: { jwt: { roles: { includes: "staff" } } } }
]
)
type Claims @jwt {
roles: [String!]
}
type Invoice
@node
@authorization(
filter: [{ where: { OR: [{ rule: "admin" }, { rule: "staff" }] } }]
) {
key: String! @key
total: Int!
}
Schema rules test claims only. A type's own
@authorizationRule of the same name is an error,
not an override.
@authorizationDefaults
Settings for every type at once. On extend schema.
extend schema
@authorizationDefaults(
bypass: { jwt: { roles: { includes: "admin" } } }
mutations: { jwt: { roles: { includes: "editor" } } }
requireAuthentication: false
)
type Claims @jwt {
roles: [String!]
}
type Product @node @mutation {
sku: String! @key
name: String!
}
Arguments:
bypass: a claims-only test. A request passing it skips every filter and validate rule.@authenticationstill applies.mutations: the write rule of every@mutationtype, for each ofCREATE,UPDATEandDELETEthat the type's own rules do not cover.requireAuthentication: defaulttrue, meaning every rule denies without a token.falselets a rule that reads no claims decide for anonymous callers too.
Product has no rules of its own, so editors write it, everyone reads
it, and admins skip rules everywhere.
Scalar types
Fields may be String, ID, Int, Float, Boolean, enums, BigInt
(a decimal string), Date, Time, LocalTime, DateTime,
LocalDateTime, Duration (ISO-8601 strings), Point and
CartesianPoint (objects, set with PointInput and
CartesianPointInput), custom scalars with @storedAs, and
non-null lists of these.
enum Kind {
INDOOR
OUTDOOR
}
type Festival @node {
key: ID! @key
name: String!
kind: Kind
capacity: Int
ticketPrice: Float
soldOut: Boolean
attendance: BigInt
startsOn: Date
doorsOpen: LocalTime
createdAt: DateTime
duration: Duration
location: Point
tags: [String!]
}