Writing with GQL
gql() writes too. This version supports INSERT (Cypher CREATE is its
alias), which creates nodes and edges, and SET and REMOVE, which change
the properties and labels of existing ones, and DELETE / DETACH DELETE, which remove
them; each runs once for each row that reaches it. Cypher's MERGE, which finds a pattern
or creates it when it is missing, is accepted as a compatibility form
(MERGE).
A writing query is one unit, like every traversal: it commits when it succeeds, and a query that fails, on any row, changes nothing (Units).
INSERT
INSERT takes one or more comma-separated patterns. A node pattern creates a node with the
labels written (none, one, or several joined by &; Cypher's :A:B is the alias) and the
properties of its map. An edge pattern creates an edge between the nodes beside it: it needs a
direction (-[:T]-> or <-[:T]-) and exactly one label.
g.gql("INSERT (:city {name: 'Berlin', population: 3.7})").iterate();
g.gql("INSERT (a:person {name: 'ann'})-[:knows {since: 2020}]->(b:person&admin {name: 'bob'})").iterate();
g.gql("MATCH (a {name: 'ann'})-[e:knows]->(b) RETURN e.since AS since, labels(b) AS labels").to_list() // #{"labels": ["person", "admin"], "since": 2020}
The same in Gremlin:
g.add_v("person").property("name", "ann").as("a").add_v(["person", "admin"]).property("name", "bob").add_e("knows").from("a").property("since", 2020).count().next() // 1
A query may end with the INSERT: it then returns no rows. GQL's FINISH ends a query
without a result too. RETURN after an INSERT reads the new elements, and RETURN * includes
the variables the INSERT named:
g.gql("INSERT (c:city {name: 'Berlin'}) RETURN c.name AS name").to_list() // #{"name": "Berlin"}
g.gql("CREATE (c:city:capital {name: 'Rome'}) RETURN labels(c) AS l").to_list() // #{"l": ["city", "capital"]}
g.gql("INSERT (:city {name: 'Paris'})").to_list() // no rows
g.gql("INSERT (:city {name: 'Paris'}) FINISH").to_list() // no rows
Property values are expressions: literals, $parameters, functions and the variables of the
row. A property whose value is NULL is not stored (GQL has no stored NULL; a Gremlin
property(k, ()) stores one). Lists and maps are stored as they are, also lists of mixed kinds
(a difference from Neo4j); a node, an edge or a path cannot be a property
value (22G03).
g.gql("INSERT (n:x {k: NULL, tags: ['a', 1], address: {city: 'Rome'}}) RETURN keys(n) AS keys, n.address.city AS city").to_list() // #{"city": "Rome", "keys": ["tags", "address"]}
Pattern rules. A variable bound before the INSERT is written alone, (p): it connects the
existing node and may not carry labels or properties (42002; change an existing node with
SET, as in MATCH (p:person) SET p:admin). An edge without a direction, an edge with
a label expression (:r|s), a node label expression other than a conjunction, a path variable
(p = ...) and WHERE or a quantifier inside the pattern are errors before the run; the help
shows the accepted form.
g.gql("INSERT (a:x)-[:r]-(b:y)") // error: INSERT needs a direction
g.gql("MATCH (p:person) INSERT (p:admin)") // error: the variable `p` is already bound
SET
SET takes one or more comma-separated items and applies them to the elements of each row that
reaches it, left to right, so a later item sees the earlier ones:
| Item | Effect |
|---|---|
SET n.k = expr (also SET (n).k = expr) | sets one property of the node or edge n |
SET n = {k: expr, ...} | replaces every property: the keys missing from the map are removed |
SET n += {k: expr, ...} | merges the map in (a Cypher compatibility form): other keys stay |
SET n:A / SET n IS A | adds the label A to the node n; SET n:A:B and SET n IS A & B add both |
g.gql("MATCH (p:person {name: 'marko'}) SET p.age = p.age + 1, p.checked = true RETURN p.age AS age, p.checked AS checked").to_list() // #{"age": 30, "checked": true}
g.gql("MATCH (p:person {name: 'marko'}) SET p:admin:staff RETURN labels(p) AS labels").to_list() // #{"labels": ["person", "admin", "staff"]}
g.gql("MATCH (p:person {name: 'marko'}) SET p += {city: 'Rome'} RETURN properties(p) AS props").to_list() // #{"props": #{"age": 29, "city": "Rome", "name": "marko"}}
g.gql("MATCH (p:person {name: 'marko'}) SET p = {name: 'marko', city: 'Rome'} RETURN properties(p) AS props").to_list() // #{"props": #{"city": "Rome", "name": "marko"}}
g.gql("MATCH (a {name: 'marko'})-[e:knows]->(b) SET e.checked = true RETURN b.name AS b, e.checked AS checked ORDER BY b").to_list() // josh true, vadas true
NULL removes. GQL stores no NULL, so SET n.k = NULL removes the property, and a NULL
value in the map of SET n = {..} or SET n += {..} removes its key. A NULL target (a
variable an OPTIONAL MATCH left NULL) is skipped: the item does nothing for that row. A
NULL map on the right of = or += is an error (22004): write SET n = {} to remove every
property, or coalesce($map, {}) for a parameter that may be NULL.
g.gql("MATCH (p:person {name: 'marko'}) SET p.age = NULL RETURN keys(p) AS keys").to_list() // #{"keys": ["name"]}
g.gql("MATCH (p:person {name: 'marko'}) SET p += {age: NULL, city: 'Rome'} RETURN properties(p) AS props").to_list() // #{"props": #{"city": "Rome", "name": "marko"}}
g.gql("OPTIONAL MATCH (p:person {name: 'nobody'}) SET p.seen = true RETURN p").to_list() // #{"p": ()}
g.gql("MATCH (p:person {name: 'marko'}) SET p = $props", #{props: ()}) // error: the right side of SET p = .. is NULL, not a map
Values follow the rules of INSERT: literals, $parameters, functions and the row's
variables; lists and maps are stored as they are; a node, an edge or a path cannot be a property
value (22G03). Every value of a map is checked before the item writes anything. On the right of
= and += a node or an edge stands for its properties (SET n = m copies them, SET n = n
changes nothing); any other value that is not a map is an error (22G03). An aggregate cannot be
a value: aggregate in a WITH first.
g.gql("MATCH (p:person {name: 'marko'}), (q:person {name: 'josh'}) SET p = q RETURN properties(p) AS props").to_list() // #{"props": #{"age": 32, "name": "josh"}}
g.gql("MATCH (p:person) OPTIONAL MATCH (p)-[:created]->(s) WITH p, count(s) AS n SET p.projects = n RETURN p.name AS name, p.projects AS n ORDER BY name").to_list()
// josh 2, marko 1, peter 1, vadas 0
g.gql("MATCH (p:person {name: 'marko'}) SET p.self = p") // error: a property cannot hold a graph element
Labels. A label item changes a node's label set: adding a label it has, like removing one it
lacks, is no change. An edge has exactly one label, its type, so a label item on an edge is an
error: 42002 before the run when the variable is an edge of a pattern, 22G03 when a row
holds an edge (Gremlin's set_label() changes an edge's label). The item names labels joined by
: or &; |, ! and % are syntax errors.
g.gql("MATCH (a)-[e:knows]->(b) SET e:friend") // error: an edge has exactly one label
Targets. The target of an item is a variable of the row: n.k, (n).k or n. A deeper
target (n.address.city = .., head(list).k = ..) is not supported yet (0A000); name the
element first, as in WITH head(list) AS x SET x.k = ... The variable must be bound before the
SET (42002).
REMOVE
REMOVE takes comma-separated items like SET: REMOVE n.k removes a property of a node or an
edge, REMOVE n:A (also REMOVE n IS A, REMOVE n:A:B) removes labels from a node, and the
last label may go too. Removing what is not there changes nothing, and a NULL target is
skipped.
g.gql("MATCH (p:person {name: 'marko'}) REMOVE p.age RETURN keys(p) AS keys").to_list() // #{"keys": ["name"]}
g.gql("MATCH (s:software {name: 'ripple'}) SET s:product REMOVE s:software RETURN labels(s) AS labels").to_list() // #{"labels": ["product"]}
g.gql("MATCH (p:person {name: 'marko'}) REMOVE p.nothing, p:nothing RETURN labels(p) AS labels, keys(p) AS keys").to_list() // #{"keys": ["name", "age"], "labels": ["person"]}
The same in Gremlin:
g.v().has("name", "marko").property("age", 30).add_label("admin").values("age").next() // 30
g.v().has("name", "marko").remove_property("age").properties().key().to_list() // "name"
g.v().has("name", "ripple").add_label("product").drop_label("software").label().next() // "product"
DELETE
DELETE takes one or more comma-separated expressions and removes the nodes and edges they
give, for every row that reaches it. DETACH DELETE removes a node together with all its
edges. A plain DELETE (GQL's default, also written NODETACH DELETE) removes only nodes
without edges: a node that still has an edge fails the query (G1001) and nothing is removed.
Edges the same clause deletes do not count, so DELETE e, n removes a node and the edge that
held it in one clause.
g.gql("MATCH (p:person {name: 'marko'}) DETACH DELETE p").iterate();
[g.v().count().next(), g.e().count().next()] // [5, 3]
g.gql("MATCH (a {name: 'marko'})-[e:knows]->(b {name: 'vadas'}) DELETE e RETURN b.name AS still_here").to_list() // #{"still_here": "vadas"}
g.gql("MATCH (s:software {name: 'ripple'})<-[e]-() DELETE e, s RETURN count(*) AS n").to_list() // #{"n": 1}
g.gql("MATCH (p:person {name: 'marko'}) DELETE p") // error: `p` is a node that still has edges (G1001)
The NODETACH check runs for the whole clause before it removes anything: the help of the
error names the DETACH DELETE that works, built from the failing query.
Targets are expressions: a node or an edge variable, a path (MATCH p = .. DETACH DELETE p removes its nodes and edges), a list of them (also nested, such as collect(n)), or an
expression that gives one (a map member, head(list)). A NULL target (an OPTIONAL MATCH
that found nothing) is skipped, and an element already removed (by an earlier row or clause) is
not removed twice. A target that gives another value fails the query: before the run when the
text shows it (DELETE 1 + 1, 42002), on the row otherwise (22G03). A label test is not a
target: DELETE n:L is a syntax error whose help names REMOVE n:L, which removes the label and
keeps the node.
g.gql("MATCH p = (:person {name: 'marko'})-[:knows]->(:person {name: 'vadas'}) DETACH DELETE p").iterate();
[g.v().count().next(), g.e().count().next()] // [4, 3]
g.gql("OPTIONAL MATCH (p:person {name: 'nobody'}) DELETE p RETURN p").to_list() // #{"p": ()}
g.gql("MATCH (p:person) WITH collect(p) AS people DETACH DELETE people RETURN size(people) AS n").to_list() // #{"n": 4}
g.gql("MATCH (n:person) DELETE n:person") // error: a label test is not an element; REMOVE n:person removes the label
After a DELETE. A deleted element is gone for the rest of the query: reading its
properties, labels, element_id() or endpoints is an error, never a silent NULL, and so
is returning it (RETURN n, also with LIMIT 0 after it, and RETURN p of a path holding it).
Read what you need before the DELETE and carry it in a WITH; count(n) and n IS NULL
still work, and type(e) of a deleted edge still gives its label (as in the openCypher tests).
A walk from a deleted node (MATCH (n)-->(m) after deleting n) finds nothing.
g.gql("MATCH (p:person {name: 'vadas'}) WITH p, properties(p) AS props DETACH DELETE p RETURN props").to_list() // #{"props": #{"age": 27, "name": "vadas"}}
g.gql("MATCH (p:person {name: 'vadas'}) DETACH DELETE p RETURN count(p) AS deleted").to_list() // #{"deleted": 1}
g.gql("MATCH (a)-[e:created]->(s:software {name: 'lop'}) DELETE e RETURN type(e) AS t, a.name AS who ORDER BY who").to_list() // three rows, t "created"
g.gql("MATCH (p:person {name: 'vadas'}) DETACH DELETE p RETURN p") // error: the node was deleted earlier in this query
g.gql("MATCH (p:person {name: 'vadas'}) DETACH DELETE p RETURN p.name AS name") // error: the node was deleted earlier in this query
Returning a deleted node is an error in gql() where Neo4j returns a value of it; the reasons
are on Semantics.
A query that fails after its DELETE changed nothing (Units): the
removed elements come back with their ids, labels, properties and edges. In Rust they are new
elements: a VertexId / EdgeId handle taken before the failed run is stale and must be looked
up again by id (What a rollback restores),
exactly as after a failing Gremlin drop(). A Rhai vertex or edge value finds the element by its
id and stays usable.
The same in Gremlin:
g.v().has("name", "marko").drop().count().next() // 0
g.v().has("name", "marko").out_e("knows").where(__.in_v().has("name", "vadas")).drop().iterate()
drop() of a vertex always removes its edges (it is DETACH DELETE); the NODETACH check has no
Gremlin twin.
MERGE
MERGE is Cypher's "find or create": for each row it looks for its one pattern, and every
match becomes a row; when nothing matches, it creates the whole pattern and the row goes on
with the new elements. ISO GQL has no MERGE statement; gql() accepts it as a
compatibility form with Cypher's meaning, so a query written for Neo4j runs
as it is. The second MERGE of the first block finds the city the first one created:
g.gql("MERGE (c:city {name: 'Berlin'}) RETURN c.name AS name").to_list(); // #{"name": "Berlin"}: created
g.gql("MERGE (c:city {name: 'Berlin'}) RETURN c.name AS name").to_list(); // #{"name": "Berlin"}: found
g.v().has_label("city").count().next() // 1
g.gql("MERGE (p:person {name: 'marko'}) RETURN p.age AS age").to_list() // #{"age": 29}: found, nothing created
g.gql("MERGE p = (:person {name: 'marko'})-[:knows]->(f:person) RETURN f.name AS friend, path_length(p) AS hops ORDER BY friend").to_list() // josh and vadas: two matches, two rows
The pattern. One path pattern (a second one after , is a syntax error: write one MERGE
per pattern). Its nodes have the labels written (:A:B or :A&B; a found node carries all of
them and may have more) and a property map compared with GQL =; its edges have exactly one
label and no quantifier, and the pattern has no WHERE. A path variable (MERGE p = ..) gives
the found or the created path.
A variable bound before stands for its element: write it alone, as (a), and MERGE
reuses it in both cases, so an edge between matched nodes is created only when it is missing.
Labels or properties on a bound node (MERGE (a:admin)), a bound edge variable, and a pattern
that is only a bound node (MATCH (a) MERGE (a)) are refused before the run (42002); change a
bound node with SET. An unbound variable is new: the MERGE finds or creates its element.
g.gql("MATCH (a:person {name: 'marko'}), (b:person {name: 'peter'}) MERGE (a)-[e:knows]->(b) RETURN b.name AS to").to_list(); // #{"to": "peter"}: created
g.gql("MATCH (a:person {name: 'marko'}), (b:person {name: 'peter'}) MERGE (a)-[e:knows]->(b) RETURN b.name AS to").to_list(); // found
g.e().has_label("knows").count().next() // 3: marko's two edges and the new one
g.gql("MATCH (a:person) MERGE (a)") // error: a bound variable alone has nothing to find or create
g.gql("MATCH (a:person) MERGE (a:admin)") // error: a bound node carries no new labels in MERGE; SET a:admin does
g.gql("MERGE (a:person {name: 'x'}), (b:person {name: 'y'})") // error: MERGE takes exactly one pattern
Either direction. An edge without a direction, -[:T]-, matches an edge in either
direction; when it is created, it points from left to right, from the node written first:
g.gql("MATCH (a:person {name: 'josh'}), (b:person {name: 'marko'}) MERGE (a)-[e:knows]-(b) RETURN startNode(e).name AS from").to_list() // #{"from": "marko"}: the stored edge marko -> josh
g.gql("MATCH (a:person {name: 'vadas'}), (b:person {name: 'josh'}) MERGE (a)-[e:knows]-(b) RETURN startNode(e).name AS from").to_list() // #{"from": "vadas"}: created left to right
Per row, and each row sees the rows before it. MERGE runs once for each row, in order,
and a row finds what an earlier row created: a repeated item is created once, so MERGE is
idempotent within a query as across queries. The whole pattern is matched or created: MATCH (p:person) MERGE (p)-[:in]->(g:group {name: 'x'}) creates a group for every person without
such an edge, because the pattern with the edge does not match for them; to share one group,
MERGE the group first and the edge second:
g.gql("FOR n IN ['Rome', 'Rome', 'Oslo'] MERGE (c:city {name: n}) RETURN count(*) AS rows, count(DISTINCT c) AS cities").to_list() // #{"cities": 2, "rows": 3}
g.gql("MATCH (p:person) WHERE p.age > 30 MERGE (g:group {name: 'over30'}) MERGE (p)-[:in]->(g) RETURN count(DISTINCT g) AS groups").to_list() // #{"groups": 1}
ON CREATE SET and ON MATCH SET. After the pattern, ON CREATE SET <items> changes the
elements of a row that the MERGE created, and ON MATCH SET <items> those of a row it found.
The items are the items of SET and may read every variable of the row (the pattern's
and the earlier ones). Both may be written several times and in any order; they apply in the
order written, each in its own case.
g.gql("FOR name IN ['marko', 'ann'] MERGE (p:person {name: name}) ON CREATE SET p.visits = 1 ON MATCH SET p.visits = coalesce(p.visits, 0) + 1").iterate();
g.gql("FOR name IN ['marko', 'ann'] MERGE (p:person {name: name}) ON CREATE SET p.visits = 1 ON MATCH SET p.visits = coalesce(p.visits, 0) + 1 RETURN p.name AS name, p.visits AS visits ORDER BY name").to_list() // ann 2, marko 2
g.gql("MERGE (p:person {name: 'marko'}) ON CREATE SET p.new = true ON MATCH SET p.new = false RETURN p.new AS new").to_list() // #{"new": false}
g.gql("MERGE (p:person {name: 'marko'}) ON MATCH SET p.seen = 1 ON MATCH SET p.seen = p.seen + 1 RETURN p.seen AS seen").to_list() // #{"seen": 2}
NULL in the pattern. MERGE cannot look for or create a NULL property value: a property
of the pattern that is NULL on a row (a missing parameter, an OPTIONAL MATCH that found
nothing) fails the query with 22004 when that row runs, and nothing of the query is kept.
Keep such rows away with FILTER x IS NOT NULL, or give a default with coalesce():
g.gql("FOR x IN [1, NULL] MERGE (n:num {v: x})") // error: MERGE cannot match or create `{v: NULL}` (22004)
g.gql("FOR x IN [1, NULL] FILTER x IS NOT NULL MERGE (n:num {v: x}) RETURN count(*) AS n").to_list() // #{"n": 1}
A MERGE that only finds changes nothing. It commits no change: a commit hook and the
journal see nothing, and the dev server's answer reports no change summary and no commit. An
ON MATCH SET that runs is a change, as a SET is.
The same in Gremlin: merge_v() finds or creates a vertex,
merge_e() an edge between given vertices, and the
fold().coalesce(__.unfold(), __.add_v(..)) idiom does it by hand. Their onCreate option map
replaces the creation map, while ON CREATE SET changes the created element afterwards.
g.merge_v(#{"T.label": "city", name: "Berlin"}).values("name").to_list() // "Berlin"
g.v().has_label("city").has("name", "Berlin").fold().coalesce(__.unfold(), __.add_v("city").property("name", "Berlin")).values("name").to_list() // "Berlin"
The same in plain GQL, without the compatibility form: a CALL body that inserts only when
nothing exists yet, then a MATCH that reads the result:
g.gql("CALL { FILTER NOT EXISTS { MATCH (:city {name: 'Berlin'}) } INSERT (:city {name: 'Berlin'}) } MATCH (c:city {name: 'Berlin'}) RETURN count(c) AS cities").to_list() // #{"cities": 1}
Once per row
A write clause runs once for each row that reaches it, so the clauses before it decide how many
elements an INSERT creates and how often a SET applies (SET n.c = n.c + 1 over two rows of
the same node adds 2). A MATCH gives one row per match, FOR (Cypher UNWIND) one per list
item, and a variable of the row connects the new elements to what it matched:
g.gql("MATCH (p:person {name: 'marko'}) INSERT (p)-[:likes]->(f:fruit {name: 'apple'}) RETURN f.name AS fruit").to_list(); // #{"fruit": "apple"}
g.gql("FOR n IN $names INSERT (:person {name: n, born: $year})", #{names: ["ann", "bob"], year: 1990}).iterate();
g.gql("MATCH (p:person) WHERE p.born = 1990 RETURN p.name AS name ORDER BY name").to_list() // #{"name": "ann"}, #{"name": "bob"}
A write clause may stand after MATCH, OPTIONAL MATCH, FOR, WITH, LET, FILTER and
another write clause, and further clauses may follow it: a MATCH after the INSERT sees the new
elements, and a new node can be connected to every row a later MATCH finds:
g.gql("INSERT (c:city {name: 'Berlin'}) MATCH (p:person) INSERT (p)-[:lives_in]->(c) RETURN count(*) AS n").to_list() // #{"n": 4}
g.gql("MATCH (p:person) WITH p, p.age + 1 AS later INSERT (p)-[:turns]->(:age {years: later}) RETURN p.name AS name, later ORDER BY name").to_list()
// #{"later": 33, "name": "josh"}, #{"later": 30, "name": "marko"}, #{"later": 36, "name": "peter"}, #{"later": 28, "name": "vadas"}
A node an OPTIONAL MATCH left NULL cannot be an edge's endpoint: the query fails (22004)
and changes nothing. Keep only the bound rows with FILTER x IS NOT NULL first:
g.gql("OPTIONAL MATCH (p:person {name: 'nobody'}) INSERT (p)-[:knows]->(:person)") // error: the source node `p` of the inserted `:knows` edge is NULL
g.gql("MATCH (p:person) OPTIONAL MATCH (p)-[:created]->(s) FILTER s IS NOT NULL INSERT (p)-[:made]->(s) RETURN count(*) AS n").to_list() // #{"n": 4}
Inside CALL. A CALL body may end with its write instead of RETURN (a unit subquery): it
runs once per incoming row and passes the row on unchanged, also when the body found nothing:
g.gql("MATCH (p:person) CALL (p) { INSERT (p)-[:likes]->(:fruit {name: p.name}) } RETURN count(*) AS c").to_list() // #{"c": 4}
A subquery expression (EXISTS { }, COUNT { }, COLLECT { }) and a comprehension cannot
write (42001), and a write in an operand of UNION is not supported yet.
What a row sees
Each clause sees every write of the clauses before it, for all rows; within one clause the rows
are processed in order, and each row sees the writes of the rows before it. A query never sees
its own writes while it is still finding the rows of an earlier clause: MATCH (n) INSERT (:X)
creates exactly one node per node that existed before, and MATCH (a)-[e]->(b) INSERT (a)-[:copy]->(b) copies each existing edge once, never the copies.
g.gql("MATCH (n) INSERT (:X)").iterate();
g.v().has_label("X").count().next() // 6
g.gql("FOR i IN range(1, 3) INSERT (:n {i: i}) WITH count(*) AS made MATCH (m:n) RETURN made, count(m) AS seen").to_list() // #{"made": 3, "seen": 3}
A value a row carried before a SET keeps the value it had: WITH p.age AS old SET p.age = 0
still returns the old age in old, while p.age reads the new one.
g.gql("MATCH (p:person {name: 'marko'}) WITH p, p.age AS old SET p.age = 0 RETURN old, p.age AS now").to_list() // #{"now": 0, "old": 29}
The details, and how this follows from the executor, are on Semantics.
One query is one unit
A gql() query is one traversal and so one unit (Transactions):
when any row fails (a data error on the last row, a schema violation, a permission, a resource
limit, cancellation), nothing the query wrote stays. Here the second item divides by zero, and
the node of the first item is rolled back with it (execute() keeps the error instead of
raising it, so the script goes on):
let run = g.gql("FOR x IN [1, 0] INSERT (:n {v: 1 / x})").execute(); // fails: division by zero
g.v().has_label("n").count().next() // 0
A SET that fails on one row leaves the rows before it unchanged too:
let set = g.gql("MATCH (p:person) SET p.score = 100 / (p.age - 27)").execute(); // fails on vadas (27)
g.v().has("score").count().next() // 0
Commit hooks see the query's ChangeSet and may veto it, a dry run shows its changes without
keeping them, and a Store's journal records it like any Gremlin write
(Change capture).
Schema
An INSERT creates each element in one storage call with all its properties, so a schema sees
the whole element (Schemas): defaults on create are
filled in, a required property is satisfied by the map, write-time coercion applies, and a
closed schema refuses undeclared labels and properties. SET and REMOVE are checked like the
Gremlin steps that do the same: a value of the wrong type, removing a required property (also
with SET n.k = NULL or a SET n = {..} without it) and a label the schema does not allow fail
the query. A violation fails the query (and changes nothing), with a help line written for
GQL:
g.set_schema(#{mode: "open", vertices: #{person: #{schema: #{type: "object", properties: #{
name: #{type: "string"}, age: #{type: "integer"}, active: #{type: "boolean", default: true}},
required: ["name"]}}}});
g.gql("INSERT (:person {name: 'alice'})").iterate();
g.gql("MATCH (p:person) RETURN p.name AS name, p.active AS active").to_list() // #{"active": true, "name": "alice"}
Caused by: Missing required property 'name' on vertex 'person'
Help: Give 'name' a value in the property map of the INSERT, as in g.gql("INSERT (:person {name: 'value'})"); a NULL value (such as a property the matched element lacks) is not stored and so is missing too: give it a fallback, as in {name: coalesce(n.name, 'unknown')}. Or remove it from the label's "required" in the schema, or give it a "default" (or "defaultFunction") in the schema: it is stored when an element is created without the property.
A MERGE that creates creates like an INSERT of its pattern: the schema's defaults are filled
in and the pattern's map must satisfy required. Its ON CREATE SET items run afterwards, as a
second write in the same query, so a required property given only there is missing when the
element is created and fails the query; put it into the pattern's map instead (where it is also
part of what MERGE looks for):
g.set_schema(#{mode: "open", vertices: #{person: #{schema: #{type: "object", properties: #{
name: #{type: "string"}, active: #{type: "boolean", default: true}}, required: ["name"]}}}});
g.gql("MERGE (p:person {name: 'ann'}) RETURN p.active AS active").to_list() // #{"active": true}
A value of the wrong type is not converted, and GQL never infers a type from text. Convert it where
the INSERT writes it:
Caused by: Type mismatch on vertex 'person.age': expected int64, got string
Help: Convert the value to the type 'int64' where the INSERT writes it, with toInteger(), as in g.gql("INSERT (:person {age: toInteger($v)})", #{v: "29"}). toInteger() gives NULL for a value it cannot convert, and a NULL value is not stored; CAST(x AS ..) fails the query for it instead.
Permissions
The permission check sees the lowered plan before anything
runs: an INSERT asks Create on Data(vertex "label") per label of each new node and Create
on Data(edge "label") per new edge, exactly as add_v(labels) and add_e(label) do, plus
the reads of its MATCH clauses. SET and REMOVE ask Update, as property(),
remove_property(), add_label() and drop_label() do: on Data(vertex) for a node (any
label, since a node's label set is not fixed by the pattern), on Data(edge "label") for an
edge whose pattern names its labels, and on any element when the kind is not known before the
run (an item of a FOR list). It asks nothing for the properties: they are part of creating
the element. DELETE asks Delete, as drop() does: on Data(vertex) for a node, on
Data(edge "label") for an edge whose pattern names its labels, and on any element for a path,
a list or a value whose kind is known only at run time; DETACH asks nothing more (as drop()
of a vertex, which removes its edges too). MERGE asks what its two halves need, whether a
row will find or create: Read for the search (as a MATCH of its pattern), Create per label
of every element the pattern can create (as the INSERT of it), and, only when it has
ON CREATE SET / ON MATCH SET items, their Updates (as SET). The step's own Create and
Update requests are checked first, then the Reads of its search; a policy that denies
Create refuses a MERGE even where every row would only find. merge_v() and merge_e()
ask Read on what they search first, then Create and Update on any vertex or edge (no
labels, and Update even without on_create / on_match options). (Gremlin's add_v(..).property(..) asks Create and Update, because the check
runs before the optimizer folds the property into the creation; a policy that denies only
Update allows the INSERT and refuses its Gremlin twin.) A read-only host
(AccessPolicy::read_only(), the MCP server's --mcp-read-only, a read-only view of a past
state) refuses a GQL write before it runs.
Saved queries and the DSL
A saved query may be written in GQL itself (Saved queries in
GQL): the body is the plain query text,
its $parameters are the declared ones, and an INSERT, SET, REMOVE, DELETE or MERGE makes it
a query that writes, whose effects name what its plan creates, changes and deletes. A call
writes in one unit and returns the rows. A GQL query calls it with CALL hire('lop', 'ann') YIELD name (the arguments in the order the body first reads them: $company, then $name); its writes then belong to the calling query's unit (Calling saved
queries):
g.define_query(#{name: "hire", language: "gql", params: #{name: "string", company: "string"},
body: "MATCH (c:software {name: $company}) INSERT (p:person {name: $name})-[:created]->(c) RETURN p.name AS name"});
g.get_query("hire")["effects"].map(|e| e.text); // ["Create Data(vertex \"person\")", "Create Data(edge \"created\")"]
g.query("hire", #{name: "ann", company: "lop"}).select("name").to_list() // "ann"
g.define_query(#{name: "birthday", language: "gql", params: #{name: "string"},
body: "MATCH (p:person {name: $name}) SET p.age = p.age + 1 RETURN p.age AS age"});
g.get_query("birthday")["effects"].map(|e| e.text); // ["Update Data(vertex)"]
g.query("birthday", #{name: "vadas"}).select("age").to_list() // 28
g.define_query(#{name: "unlink", language: "gql", params: #{}, body: "MATCH ()-[e:knows]->() DELETE e"});
g.get_query("unlink")["effects"].map(|e| e.text) // ["Delete Data(edge \"knows\")"]
g.define_query(#{name: "visit", language: "gql", params: #{name: "string"},
body: "MERGE (p:person {name: $name}) ON MATCH SET p.visits = coalesce(p.visits, 0) + 1 RETURN p.name AS name"});
g.get_query("visit")["effects"].map(|e| e.text) // ["Create Data(vertex \"person\")", "Update Data(vertex)"]
A saved query in Rhai whose body runs g.gql("INSERT ...")
with a literal query text is a writing query too: its effects name what it creates, changes
and deletes. A computed query text claims every write; pass values as $parameters instead.
In a script, a writing gql() takes the write lock like a Gremlin write (is_mutating() is
true), and the playground asks before running it again. Gremlin steps may follow:
g.gql("INSERT (c:city {name: 'Berlin'}) RETURN c").select("c").values("name").to_list() // "Berlin"
In Rust
Run a writing query on the writing source, traversal_mut(), under the write lock. The read
source refuses it with the error a Gremlin write gets there:
use graphersal::prelude::*;
let graph = Graph::new(TraversalGraph::tinkerpop_modern());
graph
.write()
.traversal_mut()
.gql("MATCH (p:person {name: 'marko'}) INSERT (p)-[:likes]->(:fruit {name: 'apple'})")
.iterate()?;
// The read source cannot write:
let error = graph.read().traversal().gql("INSERT (:x)").iterate().unwrap_err();
assert_eq!(
error.root_cause().to_string(),
"Invalid element state: Cannot mutate immutable graph reference"
);
The plan
An INSERT lowers to the internal steps gql:insert_node and gql:insert_edge, one per
element of the pattern, in text order (Plans and Performance); a SET clause
to one gql:set step and a REMOVE clause to one gql:remove step with its items, which pass
the row on unchanged. A DELETE clause lowers to one gql:delete step: it collects the targets
of all its rows first (the NODETACH check sees the whole clause), removes the edges, then the
nodes, and passes the rows on unchanged. A MERGE lowers to one gql:merge step with two
children: the search (the plan of a MATCH of the pattern, starting from a bound node or using
a property index where one serves it) and the creation (the gql:insert_node /
gql:insert_edge chain of the pattern); .profile() shows both under the step. Because the plan writes, its traversers are never merged into one with a
higher bulk: each row creates its own elements and applies its own changes.
g.gql("MATCH (p:person {name: 'marko'}) SET p.age = 1 RETURN p").profile()
g.gql("MERGE (c:city {name: 'Rome'}) RETURN c").profile()
Not yet
| Construct | Until then |
|---|---|
FOREACH (x IN list | ...) | FOR x IN list INSERT ... |
a write in a UNION operand, a path variable in INSERT | run the write on its own; name the nodes and edges |
a deeper SET / REMOVE target (n.a.b, head(l).k), Cypher's dynamic n[key] | name the element with WITH .. AS x and write the item on x; for a nested value Gremlin property() with a jpath key |
g.gql("MATCH (p:person {name: 'marko'}) SET p.address.city = 'Rome'") // error: `p.address.city` as a SET target is not yet supported