Semantics
gql() follows ISO GQL. Where the standard leaves a choice to the implementation, or where its
text could not be checked yet, Graphersal follows Neo4j's Cypher 25, which implements GQL too;
this page says where.
Trail: different edges
Within one MATCH clause an edge is bound at most once (GQL's DIFFERENT EDGES match mode,
also called trail semantics; the GQL default and Neo4j's). Vertices may repeat.
g.gql("MATCH (a)-[:created]->(s)<-[:created]-(b) WHERE s.name = 'lop' RETURN a.name AS a, b.name AS b ORDER BY a, b").to_list()
// josh/marko, josh/peter, marko/josh, marko/peter, peter/josh, peter/marko: never a person with themselves
g.gql("MATCH (a {name: 'marko'})-[:knows]->(b)<-[:knows]-(c) RETURN c.name AS c").to_list() // no rows: c could only be marko again over the same edge
The second query has no rows: every knows edge into b comes from marko, and binding it again
for c would use the same edge twice. Gremlin has no such rule, so its twin returns marko:
g.v().has("name", "marko").out("knows").in("knows").values("name").to_list() // "marko", "marko"
Two separate MATCH clauses are checked separately: an edge bound in the first may be bound
again in the second. An edge variable written twice in one MATCH is an error.
g.gql("MATCH (a {name: 'marko'})-[:knows]->(b) MATCH (b)<-[:knows]-(c) RETURN c.name AS c").to_list() // #{"c": "marko"}, #{"c": "marko"}
g.gql("MATCH (a)-[e]->(b), (b)<-[e]-(a) RETURN a") // error: the edge variable `e` is used for more than one edge in the same MATCH
Quantified edges
A quantified edge (-[:knows]->{1,3}, The Supported Subset) is
part of its MATCH clause like any edge: within one match every edge is different, across
the repetitions and from the other edges of the clause (trail). Vertices may repeat, so a
quantified edge may walk around a cycle, but never over the same edge twice.
- The zero-length match of a lower bound 0 (
{0,n},*) binds the far node to the near one; the group variable is then[].{0}matches only that. - Cypher's empty interval (
[*2..1],[*..0]: the lower bound above the upper one) matches nothing, as in Neo4j: no row, and anOPTIONAL MATCHpart gets itsNULLrow. The GQL form{2,1}is a syntax error (ISO). - An upper bound larger than any trail can be (beyond about four billion) means "no bound";
such a quantifier behaves like
{m,}. - Unbounded quantifiers (
{m,},*,+, Cypher[*]) have no limit of their own: trail ends every walk once it has used every edge it can reach, which on a finite graph always happens. Two general limits still apply:repeat.max_loops(10 000 repetitions) andtraversal.max_traversers(live rows between two steps). Reaching either is an error, never a shortened result; its help names the quantifier and how to bound it or raise the limit withg.with(..).gql(..).
g.with("repeat.max_loops", 1).gql("MATCH (a {name: 'marko'})-[:knows|created]->*(b) RETURN b.name AS b") // error: repeat() exceeded the loop limit of 1 iterations
g.with("repeat.max_loops", 5).gql("MATCH (a {name: 'marko'})-[:knows|created]->*(b) RETURN b.name AS b ORDER BY b").to_list()
// josh, lop, lop, marko, ripple, vadas
g.gql("MATCH (a {name: 'marko'})-[e]->{0}(b) RETURN b.name AS b, e").to_list() // #{"b": "marko", "e": []}
The help of the first error reads (shortened):
Help: The loop of the quantified edge -[:knows|created]->{0,} of the gql() query ran 1 iterations, the limit of repeat.max_loops. ...
Give the quantifier an upper bound, as in -[:knows|created]->{0,5}, ... Or raise the limit for the query, g.with("repeat.max_loops", 2).gql(..), ...
Each match of a quantified edge is a row of its own (the edges it used keep the rows apart), so the number of rows can grow fast on a densely connected graph: bound the quantifier where the question allows it.
Quantified path patterns
A quantified path pattern ((a) ((x)-[e]->(y)){1,3} (b),
The Supported Subset) repeats a whole chain. Graphersal
follows ISO GQL and, where a detail is open, Neo4j's Cypher 25, which implements the same
construct:
- Merge rule. The node before the group is the first node of the first iteration, the node
after it the last node of the last iteration, and each iteration starts where the previous one
ended: in
(a) ((x)-->(y)){1,3} (b),ais the firstx,bthe lasty, and theyof one iteration is thexof the next. After zero iterationsaandbare one node, so(a) ((x)-->(y))* (a)matches the closed walks froma(the zero-length one included). - Trail per body edge. Within one match every edge is different: across the iterations,
between the body's edges and from the other edges of the
MATCHclause, so a quantified path pattern ends on every finite graph, like a quantified edge. A body edge that a?part did not bind (zero iterations) clashes with nothing. - Group lists. With a quantifier other than
?, each body variable is the list of its elements, one per iteration, in the order the text reads (also when the plan walks the pattern from its far end):x=[x1, x2, ...]. A zero-length match gives[]. A property of a group variable has no single value outside the body (42002); inside the body, in its conditions, the variable is the element of the current iteration. ?gives nullable singletons (ISO).((x)-[e]->(y))?matches its body once or not at all; its variables are single elements,NULLfor the match where it did not match (no iteration), not lists.{0,1}matches the same paths but keeps group lists ([]or one element).- No quantifier: singletons. A parenthesized path pattern without a quantifier matches its
body exactly once; its variables are single elements, as if the parentheses were not there,
and its
WHEREis a condition on that part of the path. - The body condition runs in every iteration and reads only body variables, parameters and
values: an iteration whose condition is not
TRUEends that walk.
g.gql("MATCH (a {name: 'marko'})-[e]->(m) ((x)-[f]-(y))? (b) RETURN m.name AS m, b.name AS b ORDER BY m, b").to_list()
// josh/josh, josh/lop, josh/ripple, lop/josh, lop/lop, lop/peter, vadas/vadas: never back to marko over the edge e
g.gql("MATCH (p:person {name: 'vadas'}) ((x)-[:created]->(s))? (q) RETURN s AS s, q.name AS q").to_list() // #{"q": "vadas", "s": ()}
g.gql("MATCH (p:person {name: 'vadas'}) ((x)-[:created]->(s)){0,1} (q) RETURN s AS s, q.name AS q").to_list() // #{"q": "vadas", "s": []}
Limits that hold now (E17; each refused with 0A000 and a help line naming the rewrite): one
chain per body, no quantifier nested inside a body (neither a group nor a quantified edge), no
path variable inside a body (name the whole path instead), and a body variable is a new name:
an outer variable written inside a body, a variable written twice in one body, a body condition
reading an outer variable, and a subquery inside a body are not supported yet. Every body edge
counts once toward the 64 edge patterns of a MATCH clause, however often the body repeats.
The unbounded quantifiers ({m,}, *, +) are bounded as for a quantified edge:
repeat.max_loops counts the iterations of the whole body, traversal.max_traversers the live
rows, and reaching either is an error naming the pattern
(Errors), never a shortened result.
Reading ((. Where an expression stands, (( followed by a pattern and a closing ) with
{..}, ?, a node, an edge, or * / + not followed by an operand reads as a quantified
path pattern (a pattern predicate). So ((a)-->(b)) + (c) is the pattern ((a)-->(b))+ (c),
not the boolean of the pattern predicate ((a)-->(b)) plus c; with an operand after it
(((a)-->(b)) + [1], + x, + 1) it stays arithmetic. Arithmetic on a predicate's boolean is
a type error (22G03) in either reading, so no working query changes meaning.
OPTIONAL MATCH and NULL
A row that reaches an OPTIONAL MATCH whose pattern does not match continues once, with every
variable the optional part introduces set to NULL: a property of it is NULL, a group
variable is NULL (not []), a path variable is NULL. The variables bound before keep their
values. The trail rule is checked per clause, so an optional part may bind an edge the MATCH
before it bound.
g.gql("MATCH (a {name: 'marko'})-[:knows]->(b) OPTIONAL MATCH (b)-[:created]->(s) RETURN b.name AS b, s.name AS s ORDER BY b, s").to_list()
// #{"b": "josh", "s": "lop"}, #{"b": "josh", "s": "ripple"}, #{"b": "vadas", "s": ()}
NULL and three-valued logic
A property that is missing reads as NULL, exactly like a property stored as null. WHERE
uses three-valued logic: a comparison with NULL is NULL (unknown), AND / OR / NOT
follow the SQL / GQL truth tables, and a row passes only when the condition is TRUE.
g.gql("MATCH (n) RETURN n.name AS name, n.age > 30 AS old, n.lang = 'java' AS java ORDER BY name").to_list()
// josh: old true, java (); lop: old (), java true; marko: old false, java (); ...
g.gql("MATCH (n) RETURN n.age = NULL AS a, NULL IS NULL AS b, 2 IN [1, NULL] AS c, NULL IN [] AS d LIMIT 1").to_list()
// #{"a": (), "b": true, "c": (), "d": false}
g.gql("MATCH (n) WHERE n.age IS NULL RETURN n.name AS name ORDER BY name").to_list() // #{"name": "lop"}, #{"name": "ripple"}
(() is how Rhai shows NULL; JSON shows null.) Test for a missing value with IS NULL,
never with = NULL.
The consequence that surprises Gremlin users: NOT of an unknown condition is still unknown, so
the software (which has no age) passes neither n.age < 30 nor NOT n.age < 30, while
Gremlin's not(has(..)) keeps them:
g.gql("MATCH (n) WHERE NOT n.age < 30 RETURN n.name AS name ORDER BY name").to_list() // #{"name": "josh"}, #{"name": "peter"}
g.v().not(__.has("age", P.lt(30))).values("name").order().to_list() // "josh", "lop", "peter", "ripple"
g.gql("MATCH (n) WHERE n.age <> 29 RETURN n.name AS name ORDER BY name").to_list() // josh, peter, vadas (not lop, not ripple)
The same holds for <>: a vertex without age is not "different from 29", its comparison is
unknown.
EXISTS and pattern predicates
A row is kept once, however often the body matches. EXISTS (and a pattern predicate,
The Supported Subset) is a semi-join: it asks whether
there is a match and adds no rows. The same pattern in MATCH gives one row per match, so josh,
who created two programs, comes twice:
g.gql("MATCH (p:person)-[:created]->() RETURN p.name AS name ORDER BY name").to_list() // josh, josh, marko, peter
g.gql("MATCH (p:person) WHERE (p)-[:created]->() RETURN p.name AS name ORDER BY name").to_list() // josh, marko, peter
TRUE or FALSE, never NULL. A body that matches is TRUE, one that does not is FALSE, also
when it reads a variable that is NULL on the row (an import from an OPTIONAL MATCH that did
not match): a NULL element matches nothing, so EXISTS is FALSE and NOT EXISTS TRUE. Around
it, three-valued logic works as usual: NULL OR EXISTS {..} is TRUE when the body matches and
NULL otherwise. (This follows Neo4j; the ISO text is still to be checked.)
g.gql("MATCH (p:person) OPTIONAL MATCH (p)-[:created]->(s WHERE s.name = 'ripple') RETURN p.name AS name, EXISTS { (s)<-[:created]-() } AS made, NOT EXISTS { (s)<-[:created]-() } AS not_made ORDER BY name").to_list()
// josh: made true, not_made false; marko, peter, vadas (s is NULL): made false, not_made true
g.gql("MATCH (p:person) RETURN p.name AS name, p.lang = 'x' OR EXISTS { (p)-[:knows]->() } AS a ORDER BY name").to_list()
// josh (), marko true, peter (), vadas ()
A pattern alone is a condition, not a value. A pattern predicate such as (p)-[:knows]->()
stands only where WHERE (or FILTER, a comprehension's WHERE, a WHEN of a searched
CASE) expects a condition, also under NOT, AND, OR and XOR there. Anywhere else (a
RETURN or WITH item, a function argument, a SET value) it is a syntax error (42001), as
in Neo4j: write EXISTS { .. } for whether it matches, COUNT { .. } for how often, or a
pattern comprehension for what it matches (stage 3f, the openCypher TCK Pattern1, List6):
g.gql("MATCH (p:person) RETURN p.name AS name, (p)-[:knows]->() AS k") // error: the pattern stands where a value is expected (42001)
g.gql("MATCH (p:person) RETURN p.name AS name, EXISTS { (p)-[:knows]->() } AS k, COUNT { (p)-[:knows]->() } AS n, [(p)-[:knows]->(f) | f.name] AS fs ORDER BY name").to_list()
// josh: false, 0, []; marko: true, 2, ["vadas", "josh"]; ...
Trail per body clause. Each MATCH clause inside a body is its own clause for the trail rule
(Trail): its edges are different from each other, but they may repeat
an edge of the query outside the body (an imported edge variable is that same edge) or of another
body clause. (Neo4j's rule; the ISO text is still to be checked.)
g.gql("MATCH (a {name: 'marko'}) WHERE EXISTS { MATCH (a)-[:knows]->(x)<-[:knows]-(a) } RETURN a.name AS a").to_list() // no rows: one clause, one edge twice
g.gql("MATCH (a {name: 'marko'}) WHERE EXISTS { MATCH (a)-[:knows]->(x) MATCH (x)<-[:knows]-(a) } RETURN a.name AS a").to_list() // #{"a": "marko"}
g.gql("MATCH (a {name: 'marko'})-[e:knows]->(b) WHERE EXISTS { MATCH (b)<-[e]-(x) } RETURN b.name AS b ORDER BY b").to_list() // josh, vadas: e imported
Evaluated only when needed, at most once per row. A body stops at its first match. Where
EXISTS stands inside a larger expression, a row runs the body only when the evaluation reaches
it (a OR EXISTS {..} runs nothing when a is TRUE), and once even when several columns or keys
of the row hold the same subquery. A body that imports nothing (EXISTS { (:robot) }) is still
run per row; its answer is not cached yet.
Filter order. The conditions of one element (or one WHERE) run in this order: those
pushed into has(), the other plain conditions, then those holding a subquery, which cost the
most (a subquery inside a larger expression before a plain [NOT] EXISTS). The result does not depend on the order, only the
work does. The global optimizer rule filter_reorder may still move a subquery's step before a
gql:filter by its own cost estimate, as it does for the same Gremlin steps
(Plans and Performance).
Map projections and comprehensions
A NULL variable projects to NULL. A map projection v {..} of a NULL variable (an
OPTIONAL MATCH that did not match) is NULL, and its entries are not evaluated at all, while
the GQL record constructor {name: v.name} builds a map whose values are NULL. A pattern
comprehension that imports a NULL variable matches nothing and gives [], never NULL:
g.gql("MATCH (p:person) OPTIONAL MATCH (p)-[:created]->(s {name: 'ripple'}) RETURN p.name AS name, s {.name} AS projection, {name: s.name} AS record, [(s)<-[:created]-(c) | c.name] AS creators ORDER BY name").to_list()
// josh: projection #{"name": "ripple"}, record #{"name": "ripple"}, creators ["josh"]
// marko, peter, vadas: projection (), record #{"name": ()}, creators []
g.gql("MATCH (p:person {name: 'vadas'}) OPTIONAL MATCH (p)-[:created]->(s) RETURN s {k: 1 / 0} AS m").to_list() // #{"m": ()}: the entry never runs
Keys. The entries come in the written order. .* puts every stored property at its place,
in stored order, without the id and the labels; a property stored as null is left out, as in
properties(). A key written twice (also through .*) keeps its first place and takes the
later value. The variable of a projection must be a vertex, an edge or a map; any other value is
the data error 22G03. An element inside the map (friend: f) stays the live element
(Result Shape).
g.gql("MATCH (p:person {name: 'marko'}) RETURN p {.name, age: 1, .age} AS m").to_json() // [{"m": {"name": "marko", "age": 29}}]
g.gql("WITH {a: 1, b: 2} AS m RETURN m {.a, .c, .*, d: 3} AS x").to_json() // [{"x": {"a": 1, "c": null, "b": 2, "d": 3}}]
g.gql("WITH 1 AS m RETURN m {.a} AS x") // error: a map projection reads a node, an edge or a map, not a number (22G03)
List comprehension. A NULL list gives NULL, a value that is not a list the data error
22G03. WHERE keeps an item only when the condition is TRUE: FALSE and NULL drop it
(three-valued logic). The result keeps the list's order.
g.gql("RETURN [x IN [1, NULL, 3] WHERE x > 1] AS kept, [x IN [1, NULL, 3] | x] AS every, [x IN NULL | x] AS none").to_list()
// #{"every": [1, (), 3], "kept": [3], "none": ()}
g.gql("RETURN [x IN 5 | x] AS m") // error: a list comprehension iterates over a list, not a number (22G03)
List predicates and reduce. all(), any(), none() and single() are three-valued,
not counts: a NULL list gives NULL; an item that decides the answer wins (all FALSE at a
FALSE item, any TRUE and none FALSE at a TRUE one, single FALSE at a second TRUE one) and
the later items are not evaluated; otherwise a NULL condition gives NULL. This is the
openCypher TCK (Quantifier1 to Quantifier4), which Neo4j Cypher 25 follows; Neo4j's manual
words the NULL rule of any() like that of all(), but its implementation and the TCK give
any(x IN [NULL, 2] WHERE x = 2) TRUE, as here. reduce(acc = init, x IN l | expr) gives
NULL for a NULL list, init for an empty one, and lets NULL items flow through expr.
Their variables are new names like a comprehension's.
g.gql("RETURN any(x IN [NULL, 2] WHERE x = 2) AS a, all(x IN [NULL, 2] WHERE x = 2) AS b, single(x IN [2, NULL] WHERE x = 2) AS c, single(x IN [2, NULL, 2] WHERE x = 2) AS d").to_list()
// #{"a": true, "b": (), "c": (), "d": false}
g.gql("RETURN reduce(s = 0, x IN [1, NULL] | s + x) AS a, reduce(s = 0, x IN [] | s + x) AS b, reduce(s = 0, x IN NULL | s + x) AS c").to_list()
// #{"a": (), "b": 0, "c": ()}
A comprehension variable hides no name in scope. It may not shadow a variable or column in
scope, nor the variable of a comprehension around it. A name that is no longer in scope (one a
WITH dropped, one bound inside a subquery) may be used again, as in Neo4j (stage 3f, the
openCypher TCK Quantifier1..4). One exception keeps a LET / WITH value right: a name
that the expression of such a value in scope reads is refused too, since gql() substitutes
the value by its expression, which still refers to the dropped variable by that name. So
MATCH (n) WITH n AS m RETURN [n IN [1] | n], which runs in Neo4j, is an error here; pick
another name ([x IN [1] | x]). The variable exists only inside its brackets.
g.gql("MATCH (n:person) WITH n AS m RETURN [n IN [1] | n] AS l") // error: the comprehension variable `n` is a name that a LET or WITH value in scope still reads
g.gql("MATCH (n:person) WITH collect(n) AS ns RETURN [n IN ns | n.name] AS names").to_list() // #{"names": ["marko", "vadas", "josh", "peter"]}
g.gql("MATCH (n:person) WITH n AS m RETURN [x IN [1] | x] AS l ORDER BY l LIMIT 1").to_list() // #{"l": [1]}
Pattern comprehension: one item per match, in no defined order. The list holds one item per
match of the pattern, so the same value may come several times (josh created two programs in
java). Like any subquery it runs on the row's imports, with the trail rule inside its own pattern
(EXISTS); its items come in the order the matches are found,
which depends on the plan. When the order matters, match the pattern in the query, sort the
rows and collect them with collect_list(), or sort the list after gql().
g.gql("MATCH (p:person {name: 'josh'}) RETURN [(p)-[:created]->(s) | s.lang] AS langs").to_list() // #{"langs": ["java", "java"]}
g.gql("MATCH (a {name: 'marko'}) RETURN [(a)-[:knows]->(x)<-[:knows]-(a) | x.name] AS one").to_list() // #{"one": []}: one pattern, one edge twice
Evaluated when reached, once per row. A pattern comprehension runs its pattern only for the
rows whose evaluation reaches it, at most once per row, like EXISTS; one that imports nothing
still runs per row. Comprehensions nested from the same element multiply the work:
[(m)-->(a) | [(m)-->(b) | ..]] runs the inner pattern once for every match of the outer one,
like nested Gremlin map(..fold()) children, and only the resource limits bound a deep chain of
them (Resource limits).
COUNT and COLLECT
An aggregating answer for every row. COUNT { } and COLLECT { } run their body once per
row, like EXISTS, but read it to the end: COUNT { } is the number of rows the body returns
(0, never NULL, when there are none) and COLLECT { } the list of its one RETURN item
([] when there are none). An imported NULL variable matches nothing, as in EXISTS, so both
give 0 and []. COLLECT { } keeps NULL items, unlike collect_list(), and keeps the body's
row order, so an ORDER BY inside it orders the list; without one the order is unspecified, as
for a pattern comprehension.
g.gql("MATCH (p:person) OPTIONAL MATCH (p)-[:created]->(s {name: 'ripple'}) RETURN p.name AS name, COUNT { (s)<-[:created]-() } AS n, COLLECT { MATCH (s)<-[:created]-(c) RETURN c.name } AS l ORDER BY name").to_list()
// josh 1 ["josh"]; marko, peter, vadas (s is NULL): 0 []
g.gql("MATCH (p:person {name: 'marko'}) RETURN COLLECT { MATCH (p)-[:knows]->(f) RETURN f.lang } AS l, [(p)-[:knows]->(f) | f.lang] AS c").to_list() // #{"c": [(), ()], "l": [(), ()]}
A deviation: the RETURN of a counted body is not evaluated. When a COUNT { } body is one
query whose final RETURN only projects (no aggregate, DISTINCT, ORDER BY or UNION, which
need the values), gql() counts the body's rows without evaluating the RETURN items, nor the
values a projecting WITH names only for them: the count is the same, but an expression there
that would fail on a row (a division by zero, a value of the wrong kind) raises no error, while
the same body run as a query (or in CALL) raises it. EXISTS and COLLECT { } evaluate
theirs.
g.gql("MATCH (p:person) RETURN p.name AS name, COUNT { MATCH (p)-[:created]->(s) RETURN 1 / 0 AS x } AS n ORDER BY name").to_list() // josh 2, marko 1, peter 1, vadas 0
g.gql("MATCH (p:person) RETURN COUNT { MATCH (p)-[:created]->(s) RETURN DISTINCT 1 / 0 AS x } AS n") // error: division by zero: DISTINCT needs the value
g.gql("MATCH (p:person) WHERE EXISTS { MATCH (p)-[:created]->(s) RETURN 1 / 0 AS x } RETURN p.name AS name") // error: division by zero: EXISTS evaluates its RETURN
UNION and NEXT
Columns by name. The operands of a UNION are matched by column name, not by position: an
operand may list the same names in another order, and the result takes the first operand's
order. (Whether the standard also allows another order is still to be checked; gql() takes
the permissive reading.)
UNION removes duplicate rows with GQL's equality, as DISTINCT does (1 and 1.0 are one
value, DISTINCT); which of two equal rows is kept is
unspecified.
g.gql("RETURN 1 AS x UNION RETURN 1.0 AS x").to_list() // #{"x": 1}: one row
g.gql("RETURN 1 AS x UNION ALL RETURN 1.0 AS x").to_list() // #{"x": 1}, #{"x": 1.0}
Per operand. Each operand is a query of its own: its variables are its own, and its
ORDER BY, OFFSET and LIMIT apply to it before the union. The rows of a union come in no
defined order: the operands' rows are not interleaved in any documented way, so an order within
an operand is not an order of the result. Sort or page the whole union after NEXT.
NEXT passes on the columns, not the variables. After NEXT the next statement sees the
RETURN columns of the statement before it as its variables and nothing else; an element column
is the live element, so a MATCH after NEXT continues from it. NEXT after one plain query is
that query with a WITH of the same items, so it gives the same rows and the same plan; after a
UNION the union's rows are the input. The order a RETURN .. ORDER BY makes before NEXT is
not kept by later clauses (Ordering): sort in the last RETURN.
CALL subqueries
Per row, joined. A CALL runs its body once for each incoming row, with the imported
variables set to the row's values, and each row the body returns extends the incoming row: an
incoming row with three results becomes three rows, one with none is dropped (OPTIONAL CALL
keeps it once, the returned names NULL). A body that aggregates without grouping keys always
returns one row (count 0, collect_list []), so it never drops a row. The body's
DISTINCT, ORDER BY, OFFSET and LIMIT apply to one row's results: ORDER BY .. LIMIT k
in a body is the top k per row, which a LIMIT of the whole query cannot express.
g.gql("MATCH (p:person) CALL (p) { MATCH (p)-[:knows]->(f) RETURN f.name AS friend ORDER BY f.age DESC LIMIT 1 } RETURN p.name AS who, friend").to_list() // #{"friend": "josh", "who": "marko"}
g.gql("MATCH (p:person) OPTIONAL CALL (p) { MATCH (p)-[:knows]->(f) RETURN f.name AS friend ORDER BY f.age DESC LIMIT 1 } RETURN p.name AS who, friend ORDER BY who").to_list()
// josh (), marko josh, peter (), vadas ()
Imports. The body reads only what it imports: CALL (a, b) names them, CALL (*) takes
every variable in scope, CALL { } and CALL () { } take none, and Cypher's importing WITH
names them as the body's first clause (Cypher Aliases). A
name the body binds that is also an outer name it did not import is a new variable of the body:
MATCH (p) CALL { MATCH (p)-->(x) RETURN x } matches every p in the body, for every outer row.
An imported NULL (from an OPTIONAL MATCH) matches nothing in the body's patterns, as in
EXISTS.
An uncorrelated body still runs per row. A body that imports nothing gives the same rows for
every incoming row, but gql() does not cache them yet: it runs once for each row, so its cost
multiplies with the rows before the CALL. Put such a CALL first, or compute the value once
with a WITH before the MATCH that multiplies the rows:
g.gql("MATCH (p:person) CALL { MATCH (s:software) RETURN count(s) AS n } RETURN p.name AS who, n ORDER BY who").to_list() // four rows with n 2: the count ran four times
g.gql("CALL { MATCH (s:software) RETURN count(s) AS n } MATCH (p:person) RETURN p.name AS who, n ORDER BY who").to_list() // the same rows: the count ran once
Not lazy inside one row. A CALL builds all the results of its body for one incoming row
before the first of them moves on (Gremlin's flat_map()), so a LIMIT after the CALL stops
the incoming rows, not a body run that has started: MATCH (p) CALL (p) { MATCH (p)-->{1,}(x) RETURN x } RETURN x LIMIT 1 still finds every x of the first p. Bound the body itself with
its own LIMIT when only a few results per row are needed. The rows the body builds count
against the resource limits like any other traversers.
Labels in the body stay in the body. Variables the body binds and does not return are gone after it; reading one is an error, as for a subquery. Returned names must be new names.
Calls of saved queries
CALL folder.name(args) [YIELD ..] (Calling saved queries)
runs a saved GQL query as a query of its own for each incoming row, like a CALL { } body that
imports nothing; Neo4j's procedures are its model, with these differences, each a deliberate
decision:
- Constant arguments. An argument is a constant or a
$parameter(lists, maps and arithmetic of them included), evaluated once when the query is compiled; Neo4j also takes expressions over the row. So arity, types and missing parameters are errors before the run, and the called query is planned once with its argument values. An argument that reads the row is0A000(not yet). - Saved GQL queries only. A procedure is a saved query of the graph's catalog written in GQL;
one written in Rhai is
0A000(call it withg.query(..)from the DSL), and the vendor namespacesdb,dbms,apocandgdsare refused (CALL db.labels()). - Names are unique across folders. The folder of
CALL a.b.name()must bea/bexactly (CALL name()is the root folder); since a saved query's name is unique in the whole catalog, a name found in another folder is an error with a "did you mean", never a second procedure. - Positional order. The arguments bind the parameters in the order the saved query first
reads them (
g.get_query(name)["params"]), not in a declared signature order. - Schema coercion. An argument is checked and coerced by its parameter's schema, as a write
is: an integer passed for a
numberparameter arrives as a float (42becomes42.0), where Neo4j'sNUMBERkeeps the integer. - No commit attribute. The called query's writes are part of the calling query's one unit;
unlike
g.query(..)of a query that writes, the commit carries no attribute naming the saved query and its arguments. - Not inside
EXISTS,COUNTorCOLLECTyet. A call stands wherever a clause stands, also inCALL { }bodies andUNIONoperands, but not yet in those subquery bodies (0A000).
g.define_query(#{name: "sw", language: "gql", body: "MATCH (s:software) RETURN s"});
g.define_query(#{name: "half", language: "gql", params: #{x: "number"}, body: "RETURN $x AS x, $x / 2 AS h"});
g.gql("CALL sw() YIELD s RETURN s.name AS n, labels(s) AS l ORDER BY n").to_list();
// [#{"l": ["software"], "n": "lop"}, #{"l": ["software"], "n": "ripple"}]
g.gql("CALL sw() YIELD s MATCH (s)<-[:created]-(p) RETURN s.name AS s, count(p) AS n ORDER BY s").to_list();
// [#{"n": 3, "s": "lop"}, #{"n": 1, "s": "ripple"}]: a yielded node starts a later pattern
g.gql("CALL half(5)").to_list(); // [#{"h": 2.5, "x": 5.0}]: the integer 5 arrives as 5.0
Writes
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.
This is Neo4j's behaviour for every case the openCypher tests check. It follows from the
executor, which runs a plan step at a time: a write step starts only after the read steps
before it consumed their whole input. So a query never matches what it is creating (the
"Halloween problem"): MATCH (n) INSERT (:X) creates exactly one node per node that existed
before, MATCH (a)-[e]->(b) INSERT (a)-[:copy]->(b) exactly one edge per existing edge, and
FOR i IN range(1, 3) MATCH (n) INSERT (:Y) three per node, because each row's MATCH ran
before any INSERT. No barrier step is added to the plan for this.
g.gql("MATCH (a)-[e]->(b) INSERT (a)-[:copy]->(b)").iterate();
g.e().has_label("copy").count().next() // 6
One element per row. A plan that writes never merges traversers into one with a higher bulk, and a write step treats a traverser of bulk n as n rows: each row creates its own elements.
Values are read before the write. A value a row carries (WITH n.age AS a, LET, a
RETURN column, an aggregate) is computed when the row is built; elements stay live, so
RETURN n.k after a write reads the new state, as in Neo4j.
NULL is never stored. INSERT ({k: NULL}) stores no k, and SET n.k = NULL removes
k; reading it gives NULL again. A null stored by Gremlin (property("k", ())) also reads
as NULL in GQL.
Permissions. INSERT asks Create on the labels it creates, exactly like add_v(labels) /
add_e(label), and nothing for the properties of the new element. Gremlin's
add_v(..).property(..) asks Create and Update (the check runs on the plan as written,
before the optimizer folds the property into the creation), so a policy that denies only
Update allows the INSERT and refuses its Gremlin twin
(Writing with GQL). SET and REMOVE ask exactly what their Gremlin
twins property(), remove_property(), add_label() and drop_label() ask: Update on
Data(vertex) for a node target, whatever labels its pattern names (a node's label set can
hold more labels than the pattern tests, and a label item changes it), Update on
Data(edge "label") for an edge whose pattern names its labels, and Update on any element
when the kind is known only at run time (an item of a FOR list, a WITH column of a list).
Where writes differ from Neo4j:
| Neo4j | gql() | Why |
|---|---|---|
a property value must be a scalar or a list of scalars of one kind; a map, a list of mixed kinds, a nested list or a NULL inside a list is a type error | stored as given: maps, mixed and nested lists (a NULL inside a list stays in the list) | Graphersal's data model stores JSON-like values (jpath keys); a schema restricts a property's type where wanted |
CREATE requires WITH between a write and a following MATCH (Cypher 5) | MATCH may follow INSERT directly | GQL's linear statements allow it, as Neo4j's Cypher 25 does |
SET n = NULL / SET n += NULL | an error (22004) whose help names SET n = {} and coalesce($map, {}) | a NULL map is more often a missing parameter than a wish to remove every property; an error can be relaxed later without changing a query that works, the opposite would silently start removing properties. GQL's own item takes a braced map only: standard GQL has no SET n = NULL form, so the error is our choice for the compatibility form |
SET of a list of maps, a map, or a list of mixed kinds is a type error | stored as given, as for INSERT | as above |
RETURN n of a node deleted earlier in the query returns it | an error whose help shows WITH n, properties(n) AS p DETACH DELETE n RETURN p (DELETE) | a deliberate deviation (user decision): a returned deleted node would carry no id, labels or properties Graphersal could give, and a silent empty value hides the mistake |
a plain DELETE of a node that still has edges is checked when the transaction commits: MATCH (n)-[r]-() DELETE n DELETE r works | checked per clause, before the clause removes anything (G1001): delete the edges in the same clause (DELETE r, n) or use DETACH DELETE | a query is one unit, but each clause states its own result; a per-clause check fails at the clause that is wrong, never later |
SET and REMOVE
Items run left to right. The items of one SET or REMOVE clause run in the order written,
for each row in turn, and each item sees the changes of the items before it: SET n.c = 1, n.c = n.c + 1 leaves 2. The same item may be written twice; the last one wins. The value of an
item is computed when the item runs, from the row and the current state of the graph.
g.gql("MATCH (p:person {name: 'marko'}) SET p.n = 1, p.n = p.n + 1 RETURN p.n AS n").to_list() // #{"n": 2}
NULL removes. A property item whose value is NULL removes the property (it is never
stored as NULL), and so does a NULL value in the map of SET n = {..} and SET n += {..}.
= replaces, += merges. SET n = {..} evaluates the whole map and checks every value
before it writes: then it removes every property the map does not name and sets the others.
SET n += {..} sets the keys of the map and keeps the rest. A node or an edge on the right
stands for its properties, read before the first write (so SET n = n changes nothing); another
value that is not a map is an error (22G03), and a NULL map is an error (22004).
The target. A target that is NULL (a variable an OPTIONAL MATCH left NULL) makes the
item a no-op for that row, whatever its right side: the right side is not evaluated. A target
that holds a value which is not a node or an edge is an error (22G03); a target removed
before the item runs fails the query, as the element no longer exists.
g.gql("OPTIONAL MATCH (p:person {name: 'nobody'}) SET p.k = 1 / 0 RETURN p").to_list() // #{"p": ()}
g.gql("FOR x IN [1] SET x.k = 1") // error: the target holds a value, not a node or an edge
Labels. A label item adds or removes each name in order. Adding a label the node has and
removing one it lacks change nothing; the last label may be removed (the node then has none). An
edge has exactly one label: a label item on it is an error before the run (42002) when the
pattern declares the variable as an edge, and at run time (22G03) when only the row knows it,
like add_label() on an edge.
g.gql("MATCH (s:software {name: 'ripple'}) REMOVE s:software RETURN labels(s) AS labels").to_list() // #{"labels": []}
What a row sees is as for INSERT (Writes): a value a row carried before the
clause (WITH n.age AS old) keeps its value, while the element itself is live, so RETURN n.k
reads the new state. A clause before the SET has found all its rows before the SET runs, so
a SET never changes which rows an earlier MATCH matched; a LIMIT 0 after the SET still
lets it run for every row.
DELETE
Targets. Each expression of a DELETE clause is evaluated per row and gives what the clause
removes: a node, an edge, every node and edge of a path, or the items of a list (nested lists
too). NULL (also inside a list) is skipped. A value of another kind is an error: before the run
(42002) when the expression cannot give an element (a literal, arithmetic, a comparison), at
run time (22G03) when only the row shows it (a WITH value, a list item). A label test
(n:L, n IS L) is a syntax error (42001), since REMOVE n:L removes a label.
One clause, all rows. The clause collects the targets of every row that reaches it, each element once (deleting the same element on several rows, or twice in one row, is no error, and an element an earlier clause removed is skipped), then removes the edges, then the nodes.
NODETACH is checked per clause. Without DETACH, every node to remove must have no edges
except those the same clause removes; the check covers the whole clause before anything is
removed, so a failing check leaves the graph as it was and fails the query (G1001). DETACH DELETE removes the edges of each node first.
g.gql("MATCH (a)-[r]-(b) DELETE r, a, b RETURN count(*) AS n").to_list() // #{"n": 12}
g.gql("MATCH (n:person {name: 'marko'}) DELETE n") // error: G1001, the node still has edges
Reading a deleted element is an error. After the clause, a property, labels(),
element_id(), keys(), properties(), an endpoint or a sort key of a deleted element fails
the query (the root cause is the storage's "element removed"), never a silent NULL or empty
value. Returning the element itself fails too (RETURN n, a path holding it, also when a LIMIT 0 follows, since the RETURN builds each row before the LIMIT counts it). Three reads still
work: count(n), n IS NULL (false: the variable holds a deleted element), and type(e) of a
deleted edge, which gives the label it had (the openCypher tests expect it). A pattern that walks
from a deleted node finds no edges. Values computed before the DELETE keep their value: a
WITH n, n.age AS age before it is evaluated before the clause runs.
g.gql("MATCH (p:person {name: 'vadas'}) WITH p, p.age AS age DETACH DELETE p RETURN age, p IS NULL AS gone").to_list() // #{"age": 27, "gone": false}
g.gql("MATCH (p:person {name: 'vadas'}) DETACH DELETE p RETURN labels(p) AS l") // error: the node was deleted earlier in this query
A deliberate deviation from Neo4j: Neo4j returns a node or relationship deleted earlier in
the same query from RETURN n; gql() fails the query with a help naming the
properties(n) pattern (see the table in Writes). An error can be relaxed later
without changing a query that works.
MERGE
MERGE is a compatibility form with Neo4j Cypher 25's meaning, checked
against the openCypher TCK's clauses/merge scenarios (TCK Compliance).
One pattern. A clause holds one path pattern; a second after , is a syntax error
(42001). Node labels are conjunctions only (:A:B, :A&B, IS A & B); an edge has exactly
one label, no quantifier, no inline WHERE, and no $parameter stands for a whole property map.
A path variable (MERGE p = ..) is a new variable holding the found or the created path.
Match or create, per row. For each row the clause searches the whole pattern, with GQL =
on the properties (so 1 finds 1.0), label-set containment (a node with more labels than the
pattern names is found) and trail semantics (each edge at most once per match). Every match is a
row: two matching nodes give two rows. When nothing matches, the whole pattern is created, the
variables bound before reused and every other element new; the row continues with exactly one
record.
NULL is an error. A pattern property whose value is NULL on a row fails the query with
22004 when that row runs, before the search: MERGE can neither find nor store a NULL
value. The check is at run time, also for a literal NULL, as the TCK expects.
Either direction. MERGE (a)-[:T]-(b) finds an edge in either direction (a found edge in
each direction is a row of its own) and creates one from left to right, a to b. Directed
edges are found and created as written.
Rows see earlier rows. The rows run in order, one at a time, and each row's search sees what
the rows before it created: FOR x IN [1, 1] MERGE (:N {x: x}) creates one node, and a second
row matching the first row's creation runs ON MATCH. Within a clause this is the only
uniqueness MERGE gives (there is no constraint); it is race-free because one query is one unit
under the write lock. The search runs inside the write step, row by row, so unlike a MATCH
before a write (Writes) it sees the clause's own earlier creations.
g.gql("FOR x IN [1, 1, 2] MERGE (n:N {x: x}) RETURN count(*) AS rows, count(DISTINCT n) AS nodes").to_list() // #{"nodes": 2, "rows": 3}
ON CREATE SET and ON MATCH SET. Any number of each, in any order; they apply in the
order written, each in its case, with the rules of SET (items left to right,
NULL removes). They read the pattern's variables and every variable bound before. They run
after the creation, as a second write in the same unit: the schema checks the created element
before them (Writing with GQL).
Bound variables. A node variable bound before is a reference only, (a): new labels or
properties on it, a bound edge variable, and a pattern that is only a bound node are refused
before the run (42002). After WITH a AS x, a is a new name and finds any node.
Schema defaults. A created element gets the schema's create-time defaults and its
write-time coercion, exactly as an INSERT of the pattern.
Where MERGE differs from Neo4j:
| Neo4j | gql() | Why |
|---|---|---|
a property existence constraint is checked when the transaction commits, so ON CREATE SET n.k = .. satisfies it | a required property of the schema is checked when the element is created, before the ON CREATE SET items: give it in the pattern's map | the schema checks each write; a later item is a second write in the same unit (Writing with GQL) |
two concurrent MERGEs of the same pattern may both create, unless a uniqueness constraint holds | never: a query writes under the write lock, and each row sees the rows before it | one query is one unit; Graphersal has no concurrent writers |
| no permission model on the clause | asks Create per label it can create and Update for its ON items before it runs, also when every row would only find; then the Reads of its search | the check sees the plan before anything runs (Writing with GQL) |
Static kind checks
When the kind of an operand is certain from the query text and its place never takes that
kind, gql() refuses the query before it runs, with error 22G03, also on an empty graph and
behind a short-circuit AND / OR, as Neo4j does (stage 3f, the openCypher TCK). The kind is
certain for a literal (and a list or map of literals), a node, edge or path variable of a
pattern (or a WITH column holding one), a LET / WITH value computed from those, a
comprehension variable over such a list (its items' kind), and the results of operators and of
functions with a fixed result kind (size() is an integer, labels() a list of strings,
nodes() a list of nodes). The checked places:
- a condition (
WHERE,FILTER,AND,OR,XOR,NOT, a comprehension'sWHERE, aWHENof a searchedCASE) takes a boolean:false AND 123,NOT 'x',WHERE (n); INtakes a list on its right:1 IN true;- arithmetic (
- * / % ^, a sign) takes numbers:'a' % 2(aNULLoperand makes itNULLfirst); - a property is read from a node, an edge or a map:
p.nameof a path,x.numofWITH 123 AS x; type()takes an edge,labels()/element_id()a node or an edge,length()a path,size()a string or a list,keys()/properties()a node, an edge or a map,upper()/lower()/char_length()a string; a label test a node or an edge; a map projection a node, an edge or a map.
A $parameter is never looked at: its kind counts as unknown, so a query is accepted or
refused whatever the parameter values are (a wrong one fails when a row reaches it). A NULL
literal is never refused (NULL AND TRUE is NULL).
g.gql("RETURN false AND 123 AS x").to_list() // error: AND takes booleans, not an integer (22G03), before the run
g.gql("MATCH (n) RETURN type(n) AS t").to_list() // error: type() takes an edge, not a node, also on the empty graph
g.gql("MATCH p = (a)-->(b) RETURN size(p) AS s").to_list() // error: size() takes a string or a list, not a path; length(p) counts its edges
g.gql("WITH 123 AS x RETURN x.num AS y").to_list() // error: cannot read the property `num` of an integer
g.gql("RETURN none(x IN ['a'] WHERE x % 2 = 0) AS r").to_list() // error: arithmetic needs numbers, not a string
g.gql("RETURN 1 IN [1, 2] AS a, NULL AND TRUE AS b").to_list() // #{"a": true, "b": ()}
Type errors surface at evaluation time
Where the kind of an operand is not certain from the query text (a property value, a
$parameter, an item of a list of mixed kinds, a FOR item), an operand of the wrong kind
is a type error (22G03) when a row reaches the expression, and a query whose rows never reach
it raises nothing. IN with a right operand that is not a list (nor NULL) is such an error
too (1 IN $p with $p = 3):
g.gql("MATCH (n) RETURN n.k AND true AS x").to_list() // [] on the empty graph: no row reaches the expression
g.gql("MATCH (p:person) RETURN p.name AND true AS x").to_list() // error: AND takes booleans, not a string (22G03), when the first row reaches it
A deliberate deviation from Neo4j: Neo4j may reject more queries at compile time with a
SyntaxError or TypeError (InvalidArgumentType), from the static types of its own type
system. Graphersal checks only the kinds that are certain from the query text
(Static kind checks), which covers every such case of the openCypher
TCK; any other wrong kind fails when a row reaches it. Errors that do not depend on a row
(syntax, unknown variables, misplaced aggregates, the static kind checks) are raised before the
run, as always (Errors).
Parameters are bound when the query is compiled
The parameters of gql_with() are bound when the query is compiled into its traversal, before
any row exists. So a $parameter of OFFSET / SKIP / LIMIT whose value is negative
(22G02) or not an integer (22G03) is refused before the run, also on a graph whose query
would return no row:
g.gql("MATCH (n:person) RETURN n.name AS name LIMIT $n", #{n: -1}).to_list() // error: LIMIT must be a non-negative integer, but $n is negative (22G02)
g.gql("MATCH (n:person) RETURN n.name AS name ORDER BY name LIMIT $n", #{n: 2}).to_list() // #{"name": "josh"}, #{"name": "marko"}
A deliberate deviation from Neo4j: the openCypher TCK expects this SyntaxError at runtime.
The user sees the same thing, an error before any row; the six TCK cases are excluded from the
measured scope with this reason (TCK Compliance). A constant
expression in their place (LIMIT 1 + 1) is evaluated at the same time
(ORDER BY, OFFSET, LIMIT).
Comparisons across types
Numbers compare by value across integers and floats (1 = 1.0 is TRUE). Strings, booleans,
UUIDs and dates compare within their own kind. Comparing values of different kinds is never an
error and never a conversion: = is FALSE and <> TRUE (a number is not equal to a string),
while the orderings <, <=, >, >= are NULL (a number is neither smaller nor larger
than a string). A NULL operand gives NULL for every operator:
g.gql("MATCH (n) RETURN n.name AS n, n.age > 'a' AS cmp ORDER BY n LIMIT 2").to_list() // #{"cmp": (), "n": "josh"}, #{"cmp": (), "n": "lop"}
g.gql("RETURN 1 = '1' AS a, [1] <> 1 AS b, 1 IN ['1'] AS c, 1 < 'a' AS d").to_list() // #{"a": false, "b": true, "c": false, "d": ()}
= and <> also compare lists (item by item, three-valued: [1, 2] = [null, 'foo'] is FALSE
because 2 and 'foo' differ, [1, 2] = [null, 2] is NULL), maps (key by key) and elements
(the same vertex or edge). Lists are also ordered, lexicographically: the first pair of
items that is not equal decides, a NULL (or incomparable) pair reached first makes the
result NULL, and a list that is a prefix of the other is the smaller one:
g.gql("RETURN [1, 2] < [1, 3] AS a, [1, null] >= [1] AS b, [1, 2] >= [1, null] AS c, [1, 2] >= [3, null] AS d").to_list() // #{"a": true, "b": true, "c": (), "d": false}
Maps, elements and paths are not ordered (NULL); ORDER BY still sorts them
(Ordering).
Floats follow IEEE 754. A float divided by zero is not an error: 1.0 / 0.0 is Infinity,
-1 / 0.0 is -Infinity, 0.0 / 0.0 and 1.0 % 0.0 are NaN; an integer divided by zero
stays error 22012, and a float result that overflows from finite operands (1.0e308 * 10)
stays error 22003. NaN equals nothing, itself included (NaN = NaN is FALSE, NaN <> 1
TRUE), and every ordering of NaN against a number is FALSE (NaN > 1, NaN <= NaN); against
another kind it is NULL (NaN > 'a'):
g.gql("WITH 0.0 / 0.0 AS n RETURN n = n AS a, n <> n AS b, n > 1 AS c, n > 'a' AS d, toString(1.0 / 0.0) AS e, toString(n) AS f").to_list() // #{"a": false, "b": true, "c": false, "d": (), "e": "Infinity", "f": "NaN"}
g.gql("RETURN 1 / 0 AS x").to_list() // error: division by zero (22012)
The playground and the dev server show such a value as NaN, Infinity or -Infinity; JSON
output writes it as a GraphSON typed double
(Displaying Results).
These rules follow Neo4j and the openCypher TCK, where ISO GQL calls a comparison of different
kinds "not comparable" and raises 22012 for a division by zero of every numeric type
(differences from the standard).
No value is ever turned into text implicitly: 'a' + 1 is an error, not 'a1'
(Neo4j converts here; Graphersal does not). Convert explicitly: 'a' || toString(1) or
'a' || CAST(1 AS STRING) (Conversions).
Aggregation
The aggregates follow GQL and, where the standard text could not be checked yet, Neo4j:
NULLis skipped by every aggregate exceptcount(*), which counts rows.- Typing.
sumof integers is an integer, and an overflow is an error (22003), never a wrap; one float makes it a float.avgis always a float.sumandavgof a non-number (a string, a list) are an error (22G03);countandcollect_listtake any value. min/maxcompare across kinds in the order ofORDER BY(strings before numbers), never an error.- Empty input. Without grouping keys, an aggregation of no rows still returns one row:
count0,sum0,collect_list[],avg/min/maxNULL(Neo4j's values; ISO GQL, from SQL, may giveNULLforsum: provisional). With grouping keys there is no group, so no row. - Reusing a key. An aggregating item may read a grouping key outside its aggregates by
repeating it when the key is a variable or a property (
RETURN p.age, p.age + count(*)), not when it is computed (RETURN a.age + b.age, a.age + b.age + count(*)is an error,42002, as in Neo4j: the item readsaandb, which are no keys). Compute such a key in aWITHfirst and read it by name. - Group order is unspecified (today: the order in which the groups first appear); write
ORDER BY. GROUP BYafter theRETURNitems lists the grouping keys explicitly; grouping stays implicit, so it changes nothing in the result. A key names a column (GROUP BY name) or, beyond ISO GQL's references to variables, repeats an item's expression (GROUP BY p.nameforRETURN p.name AS name, ...).
g.gql("MATCH (n) RETURN count(n.age) AS ages, count(*) AS rows, collect_list(n.lang) AS langs").to_list()
// #{"ages": 4, "langs": ["java", "java"], "rows": 6}
g.gql("MATCH (n:person) RETURN sum(n.age) AS i, sum(n.age * 1.0) AS f, avg(n.age) AS a").to_list() // #{"a": 30.75, "f": 123.0, "i": 123}
g.gql("MATCH (n) RETURN min(coalesce(n.age, n.lang)) AS lo, max(coalesce(n.age, n.lang)) AS hi").to_list() // #{"hi": 35, "lo": "java"}
g.gql("MATCH (n:robot) RETURN count(*) AS n, sum(n.age) AS s, avg(n.age) AS a, collect_list(n) AS l").to_list()
// #{"a": (), "l": [], "n": 0, "s": 0}
g.gql("MATCH (n:robot) RETURN n.name AS name, count(*) AS c").to_list() // no rows
g.gql("MATCH (n:person) RETURN sum(9223372036854775807) AS s") // error: numeric value out of range (22003)
Gremlin differs here: values("age").sum() of nothing gives no result, count() counts what
reaches it (Gremlin has no NULL property), and min() of mixed kinds is an error.
DISTINCT and grouping equality
RETURN DISTINCT, WITH DISTINCT, grouping keys and DISTINCT inside an aggregate use
GQL's equality: numbers are equal by value across integers and floats (1 and 1.0 are one
value), and NULLs are equal to each other (they form one group). The value kept for a group is
the first that arrives. Gremlin's dedup() keeps 1 and 1.0 apart:
g.gql("MATCH (p:person) RETURN DISTINCT CASE WHEN p.age > 30 THEN 1 ELSE 1.0 END AS one").to_list() // one row
g.v().has_label("person").map(__.choose(__.has("age", P.gt(30)), __.constant(1), __.constant(1.0))).dedup().to_list() // 1.0, 1: two values
g.gql("MATCH (n) RETURN n.lang AS lang, count(*) AS c ORDER BY lang").to_list() // #{"c": 2, "lang": "java"}, #{"c": 4, "lang": ()}
Indexing and slicing
x[i], x[a..b], m[k] and n[k] are Cypher forms with their Neo4j meaning (the openCypher
TCK List1, List2, Map2 and Graph7 cases pass):
- A list index is an integer counted from 0; a negative one counts from the end (
-1is the last item). An index outside the list givesNULL, never an error. A float index is an error even when it is whole (x[1.0]), and so is a string, a boolean, a list or a map: convert withtoInteger(i). - A slice
x[a..b]takes the items fromaup tob,bexcluded. A bound left out is the start (x[..b]) or the end (x[a..]); a negative bound counts from the end; bounds are clamped to the list, so[1, 2, 3][-5..5]is the whole list;a >= bgives[]. A bound written asNULL(or a parameter that isNULL) makes the sliceNULL. - A map is read with a string key (case-sensitive; missing:
NULL); a node or an edge with a string property key (missing:NULL).n['name']with a string literal is exactlyn.name. NULLas the list, map, element, index or bound givesNULL.- Other kinds of value are not indexable: a string, a number, a boolean or a path gives
22G03(a string is no list in GQL; usesubstring()). These errors arise when a row reaches the expression (Type errors surface at evaluation time).
g.gql("RETURN [1, 2, 3][-5..5] AS a, [1, 2, 3][3..1] AS b, [1, 2, 3][1..NULL] AS c, {Name: 1}['name'] AS d").to_list()
// #{"a": [1, 2, 3], "b": [], "c": (), "d": ()}
g.gql("RETURN [1, 2, 3][1.0] AS x") // error: a list index is an integer, not a float
FOR: NULL and values that are not lists
FOR x IN list (and UNWIND list AS x, one meaning for both spellings) gives one row per item
in the list's order. For a list expression that is not a list, Graphersal follows Neo4j (the
ISO text is still to be checked; GQL may make it a type error):
NULL(a missing property included) gives no row, like the empty list[];- any other value that is not a list (a number, a string, a map, a vertex) gives one row of that value.
g.gql("FOR x IN null RETURN x").to_list() // no row
g.gql("FOR x IN 5 RETURN x").to_list() // #{"x": 5}
g.gql("MATCH (p:person) FOR t IN p.tags RETURN count(*) AS c").to_list() // #{"c": 0}: no person has tags
g.gql("FOR x IN [1, null, [2, 3]] RETURN x").to_list() // #{"x": 1}, #{"x": ()}, #{"x": [2, 3]}: NULL items and nested lists are items
WITH ORDINALITY i numbers the items from 1, WITH OFFSET i from 0; both count the
position in the list, not the row: the numbers start again for every row the FOR reads. A row
keeps its multiplicity: a list item of a row that occurs twice gives two rows.
g.gql("MATCH (p:person) WHERE p.age < 30 FOR x IN ['a', 'b'] WITH OFFSET i RETURN p.name AS name, x, i ORDER BY name, i").to_list()
// marko a 0, marko b 1, vadas a 0, vadas b 1
Lookups use GQL equality
An item compared with a property or an element id (FOR n IN $names MATCH (p:person {name: n}),
WHERE element_id(n) = id) may run as a direct lookup by id or index instead of a scan
(Plans and Performance). The lookup keeps GQL's =
exactly, whichever way it runs: 29.0 finds the age 29, the string '29' does not, and a
NULL item finds nothing.
{ g.create_index(#{label: "person", key: "age"}); g.gql("FOR a IN [29.0, '27', null] MATCH (p:person {age: a}) RETURN a, p.name AS name").to_list() } // #{"a": 29.0, "name": "marko"}
g.gql("FOR a IN [29.0, '27', null] MATCH (p:person {age: a}) RETURN a, p.name AS name").to_list() // the same row without the index
WITH: the order of its sub-clauses
The sub-clauses of a WITH run in this order: the items (and their aggregation), DISTINCT,
ORDER BY, OFFSET / SKIP, LIMIT, and last the WHERE. So WITH p ORDER BY p.age LIMIT 3 WHERE p.age > 28 takes the three youngest and then filters them (Cypher's order; to be checked
against the ISO text). The WHERE sees the variables before the WITH too, as in Neo4j: their
elements are still on the row (a projecting WITH keeps it, ORDER BY / OFFSET / LIMIT
keep its elements); with DISTINCT it filters before the rows are folded, since one folded row
stands for many; after an aggregation only the keys and aggregates remain. A WITH that only passes values on (no DISTINCT, aggregate, ORDER BY,
OFFSET or LIMIT) changes nothing in how the query runs; it only limits the names in scope.
Either direction and self-loops
-[e]- matches an edge pointing either way. A self-loop (an edge from a vertex to itself)
matches once, not once per direction, as in GQL and Neo4j; Gremlin's both() sees it twice:
g.add_v("node").property("name", "a").as("a").add_e("link").to("a").iterate();
g.gql("MATCH (x)-[e:link]-(y) RETURN x.name AS x, y.name AS y").to_list() // #{"x": "a", "y": "a"}: one row
g.add_v("node").property("name", "a").as("a").add_e("link").to("a").iterate();
g.v().both("link").values("name").to_list() // "a", "a": two
Ordering
- Without
ORDER BYthe row order is unspecified: it may change with the data, the plan or the version. WriteORDER BYwhenever the order matters (every example in this chapter does). NULLsorts last ascending and first descending (Neo4j's rule; GQL leaves it to the implementation).NULLS FIRST/NULLS LASTare not supported yet.- Only the final
ORDER BYorders the result. The order of aWITH ... ORDER BYdecides what itsOFFSET/LIMITkeep, but later clauses do not preserve it. - Values of different types sort by type first, in this order (Neo4j's global order where
Graphersal has the type): map, vertex, edge, list, date/time, UUID, string, boolean, number,
vector,
NULL. Within a type: numbers by value, strings by code point, elements by id, lists item by item and then by length. - The sort is stable: rows with equal keys keep their relative order.
g.gql("MATCH (n) RETURN n.name AS name, n.age AS age ORDER BY age, name").to_list()
// vadas 27, marko 29, josh 32, peter 35, lop (), ripple ()
g.gql("MATCH (n) RETURN n.name AS name, n.age AS age ORDER BY age DESC, name").to_list()
// lop (), ripple (), peter 35, josh 32, marko 29, vadas 27
g.gql("MATCH (n) RETURN coalesce(n.age, n.lang) AS v ORDER BY v").to_list()
// "java", "java", 27, 29, 32, 35: strings before numbers
Gremlin's order().by("age") drops the vertices without age; ORDER BY keeps them.
Functions
The functions (The Supported Subset) are pure: the same arguments always
give the same result, so a LET or WITH name may stand for its expression anywhere. They
follow these rules:
- Characters. Every position and length counts characters (Unicode scalar values), never
bytes:
char_length('héllo')is 5,byte_length('héllo')is 6, andleft,right,substring,reverseandsplit(s, '')never cut a character in two. NULLin,NULLout. A function returnsNULLwhen any argument isNULL, before it checks the kinds, exceptcoalesce,nullifand the...OrNullconversions. An unmatchedOPTIONAL MATCHtherefore givesNULLthrough every function.- Wrong kind is an error. An argument of the wrong kind fails the query (
22G03), never a silentNULL(only the...OrNullconversions turn it intoNULL). An argument that must be an integer (left,substring,range, the precision ofround, thenof the listtrim) does not take a float. - No
NaNor infinity from finite numbers.sqrt(-1)(2201F),ln(0)(2201E) and anexp()orpower()that overflows (22003) are errors, like the arithmetic operators (whose one exception is a float divided by zero, IEEE 754: Comparisons across types). ANaNthat is already stored passes throughabs,floor,ceilandroundunchanged, andsign(NaN)is 0. Conversions are not arithmetic:toFloat('NaN')andtoFloat('Infinity')give the float values, as in Neo4j. - Result types. The GQL functions keep the argument's type:
abs,floorandceilof an integer are integers.round(Cypher) is always a float,signalways an integer;sqrt,exp,ln,log,log10andpowerare floats;mod(a, b)isa % b, except that a floatb = 0is error 22012 (ISOMOD) where%givesNaN. - Bounded results. A function whose result can be larger than its arguments (
range,split,replace,CAST(x AS STRING)of a list or map, and the operator||) computes the size of the result first and asks for it againsttraversal.max_materialized_bytesand the memory budget, likecollect_list(). A result that is too large fails the query with the limit error; it is never cut short and never runs the process out of memory (Errors). - No plan change. A condition with a function is evaluated by the GQL filter step and never
pushed into
has()or a property index:WHERE lower(n.name) = 'marko'scans the label.
g.gql("MATCH (p:person) OPTIONAL MATCH (p)-[:created]->(s:software {lang: 'none'}) RETURN p.name AS name, upper(s.name) AS u, toString(s) AS t, head(s.tags) AS h ORDER BY name LIMIT 1").to_list()
// #{"h": (), "name": "josh", "t": (), "u": ()}
g.gql("MATCH (n:person {name: 'marko'}) RETURN left(n.name, 2.0) AS x") // error: left() takes an integer length (22G03)
Conversions: strict CAST, lenient toX
GQL's CAST and Cypher's toInteger / toFloat / toBoolean / toString share one set of
conversion rules and differ in what an unconvertible value gives:
| Case | CAST(x AS T) | toX(x) | toXOrNull(x) |
|---|---|---|---|
a text that is no value of the type ('abc' to INTEGER) | error 22018 | NULL | NULL |
a value of a kind the type does not take (true to FLOAT) | error 22G03 | error 22G03 | NULL |
a number out of range (1.0e19 to INTEGER) | error 22003 | error 22003 | NULL |
| a list or a map to STRING | its text | error 22G03 | NULL |
| a vertex, edge or path to STRING | error 22G03 | error 22G03 | NULL |
- To INTEGER: a float is truncated toward zero (
1.9is 1,-1.9is -1); a text is a decimal number, also with a fraction or an exponent, truncated the same way ('1.9'is 1,'1e3'is 1000, computed exactly, without a float in between).toIntegeralso reads Cypher integer literals ('0x1F'is 31,'0o11'is 9,'1_000'is 1000) andtrue/falseas 1 / 0;CASTreads decimal text only, and a boolean is22G03. - To FLOAT: a decimal number,
'NaN','Infinity','-Infinity';toFloatalso reads Cypher literals. Java-only forms ('1.5f','1d', hexadecimal floats) are not numbers. - To BOOLEAN:
'true'and'false'in any letter case;CASTreads'unknown'asNULL;toBooleantakes an integer too (0 is FALSE, every other integer TRUE),CASTdoes not. - To STRING: an integer, UUID or date gives the same text as Gremlin's
as_string(); a float always shows a decimal point or an exponent, in Neo4j's (Java's) form:'1.0','3.2','1.0E7','1.0E-4','1.5E20'(plain decimal from 0.001 up to 10 000 000); booleans give'true'/'false';CASTof a list or map gives Neo4j's text ('[1, a, true, null]','{a: 1, b: hi}', strings unquoted), whiletoStringtakes scalars only, as in Neo4j (a list or map is22G03,toStringOrNullgivesNULL; stage 3f, the openCypher TCKTypeConversion4). - Surrounding blanks:
CASTskips spaces around the text; the Cypher forms skip every character up to U+0020 (spaces, tabs, line breaks), as Java'sString.trim()does, andtoBooleanany Java whitespace.
g.gql("MATCH (n:person {name: 'marko'}) RETURN toString(1.0) AS a, toString(1.5e20) AS b, toString(0.0001) AS c, toString(10000000.0) AS d").to_list()
// #{"a": "1.0", "b": "1.5E20", "c": "1.0E-4", "d": "1.0E7"}
g.inject(1.0).as_string().to_list() // "1": Gremlin's text of a float
g.gql("MATCH (n:person {name: 'marko'}) RETURN toInteger('1.9') AS a, toInteger('0x1F') AS b, toIntegerOrNull('0x1F') AS c").to_list() // #{"a": 1, "b": 31, "c": 31}
g.inject("1.9").as_number(GType.LONG).to_list() // error: Gremlin's as_number(GType.LONG) does not read a fraction in text
g.gql("MATCH (n:person {name: 'marko'}) RETURN CAST('0x1F' AS INTEGER) AS x") // error: invalid character value for cast (22018)
g.gql("MATCH (n:person {name: 'marko'}) RETURN toInteger(1.0e19) AS x") // error: out of range for an INTEGER (22003)
Rounding
round(x [, precision [, mode]]) follows Neo4j and always returns a float:
- One argument, or
precision0 without a mode: Java'sMath.round, a half rounds toward positive infinity (round(2.5)is 3.0,round(-2.5)andround(-2.5, 0)are -2.0). - With a positive
precision, or with amode: the shortest decimal text ofxis rounded (Java'sBigDecimal.valueOf), soround(2.675, 2)is 2.68 although the float 2.675 is a bit below it. The default mode is'HALF_UP'(a half away from zero:round(-1.55, 1)is -1.6). modeis one of'UP','DOWN','CEILING','FLOOR','HALF_UP','HALF_DOWN','HALF_EVEN', written in upper case (as Neo4j, which uses Java's mode names); any other text, Java's'UNNECESSARY'included, is22023. A negativeprecisionis22023too, and a floatprecision22G03.NaNand the infinities pass through. A number beyond the integer range is rounded as it is, never clamped:round(1.0e20)is1.0e20.
Gremlin's math() has no round; the logarithm of math("log(_)") is the natural one, the same
as ln(x) and the one-argument log(x). log(2, x) and log(10, x) are exact for powers of
the base (log(2, 1024) is 10.0).
g.gql("MATCH (n:person {name: 'marko'}) RETURN round(-1.5) AS a, round(-1.5, 0) AS b, round(-1.5, 0, 'HALF_UP') AS c, round(1.25, 1, 'HALF_EVEN') AS d").to_list()
// #{"a": -1.0, "b": -1.0, "c": -2.0, "d": 1.2}
g.gql("MATCH (n:person {name: 'marko'}) RETURN log(2, 1024) AS a, log(10, 0.001) AS b").to_list() // #{"a": 10.0, "b": -3.0}
g.gql("MATCH (n:person {name: 'marko'}) RETURN round(1.5, 0, 'half_up') AS x") // error: round() has no rounding mode "half_up" (22023)
Strings: trim, split, replace
- Trim. Every GQL trim form removes the space (U+0020) when no character is given:
TRIM(s),TRIM(LEADING FROM s),trim(s),btrim(s),ltrim(s),rtrim(s). A tab or a line break stays.TRIM(c FROM s)takes exactly one characterc(22027otherwise);btrim(s, chars),ltrim(s, chars)andrtrim(s, chars)remove any character of the setchars(''removes nothing).trim(s, x)with a stringsis an error that namesbtrim(s, chars);trim(list, n)is GQL's list trim. - Split. Empty parts are kept:
split('a,,b,', ',')is['a', '', 'b', ''], andsplit('', ',')is['']. An empty delimiter splits into characters (split('', '')is['']). With a list of delimiters the text is cut at the leftmost match, and when two delimiters match at the same place the one listed first wins (split('a--b', ['-', '--'])is['a', '', 'b'],split('a--b', ['--', '-'])is['a', 'b']); a list containing''splits into characters, aNULLin the list givesNULL, and[]gives[s]. - Replace. Every non-overlapping occurrence, left to right. An empty
searchinserts the replacement before every character and at the end (replace('abc', '', '-')is'-a-b-c-', Java's rule). The optional fourth argumentlimit(Neo4j 2025.06) replaces at most that many occurrences, from the left (0changes nothing, a negative limit is22023).
Gremlin's string steps differ in these places, so the same-named Gremlin step is a twin only for plain inputs:
g.gql("MATCH (n:person {name: 'marko'}) RETURN split('a,,b', ',') AS s").to_list() // #{"s": ["a", "", "b"]}
g.inject("a,,b").split(",").to_list() // ["a", "b"]: Gremlin drops the empty part
g.gql("MATCH (n:person {name: 'marko'}) RETURN replace('abc', '', '-') AS r").to_list() // #{"r": "-a-b-c-"}
g.inject("abc").replace("", "-").to_list() // "abc": Gremlin leaves the text unchanged
g.gql("MATCH (n:person {name: 'marko'}) RETURN trim(' \t a \t ') AS t").to_list() // the tabs stay: "\t a \t"
g.inject(" \t a \t ").trim().to_list() // "a": Gremlin's trim() removes all whitespace
Regular-expression match
s =~ pattern (a compatibility form) matches the whole
string: the pattern is compiled as ^(?:pattern)$, so 'marko' =~ 'ar' is FALSE and
'josh' =~ 'mark|jos' is FALSE (the alternation is anchored as a whole). Three-valued: a NULL
operand gives NULL, and so does an operand that is no string (1 =~ '1', '1' =~ 1), as for
STARTS WITH. A literal or $parameter pattern is compiled once, when the query is compiled,
so an invalid one is error 2201B before any row; a pattern computed per row (n.name =~ m.pattern) is compiled when a row reaches it, the last one compiled kept for the next rows, and
an invalid one fails that row's evaluation (2201B).
g.gql("MATCH (n:person) WHERE n.name =~ 'ar' RETURN n.name AS name").to_list() // no rows: the whole string must match
g.gql("MATCH (n:person) WHERE n.name =~ '.*ar.*' RETURN n.name AS name").to_list() // #{"name": "marko"}
g.gql("RETURN NULL =~ 'a' AS a, 'a' =~ NULL AS b, 1 =~ '1' AS c").to_list() // #{"a": (), "b": (), "c": ()}
Elements and paths
startNode(e)/endNode(e)are the edge's stored source and target, independent of the direction the pattern was written in:MATCH (a)-[e]-(b)bindsato either end, whilestartNode(e)is always the source.n IS SOURCE OF eandn IS DESTINATION OF etest the same (TRUE, FALSE, orNULLwhen an operand isNULL).keys(x)/properties(x)of a vertex or edge list its properties in stored order; a property stored asnullis no key, asx.keyreads it asNULLanyway. Labels never appear (labels(x)lists them).keys()of a map keeps a key whose value isNULL(keys({a: 1, b: NULL})is['a', 'b'], as in Neo4j);properties()of a map is the map.nodes(p)/relationships(p)follow the path in the written order of the pattern; an edge matched against its direction (-[e]-) keeps its stored direction. A zero-length path gives one vertex and[]; theNULLpath of an unmatchedOPTIONAL MATCHgivesNULL.
g.gql("MATCH (a)<-[e:knows]-(b) RETURN a.name AS a, startNode(e).name AS s, endNode(e).name AS t ORDER BY a").to_list()
// #{"a": "josh", "s": "marko", "t": "josh"}, #{"a": "vadas", "s": "marko", "t": "vadas"}
g.gql("MATCH p = (a {name: 'marko'})-[*0..1]->(b) RETURN b.name AS b, size(nodes(p)) AS n, relationships(p) AS r ORDER BY n, b LIMIT 2").to_list()
// #{"b": "marko", "n": 1, "r": []}, #{"b": "josh", "n": 2, "r": [e[2][1-knows->4]]}
Where functions differ from Neo4j
The compatibility functions keep Neo4j's meaning, and the GQL functions follow GQL. These are the places where a query gives a different answer than in Neo4j, each deliberate:
| Neo4j | gql() | Why |
|---|---|---|
toInteger(1.0e19) saturates to the largest integer; toInteger(NaN) is 0 | error 22003 | a silent wrong value is worse than an error; toIntegerOrNull gives NULL |
toFloat('1e400') is Infinity | error 22003 (toFloatOrNull: NULL) | the same rule for both targets |
toString(n) of a node, relationship or path gives vendor text ('(:A:B)') | error 22G03 naming element_id(x), properties(x) or RETURN p | Neo4j's element text is vendor-specific |
CAST does not exist (toInteger and friends are the alternatives) | GQL's strict CAST; it reads decimal text only, no Cypher literals ('0x1F') | GQL is normative; the Cypher literals are toInteger's |
trim(s), ltrim(s), rtrim(s) remove any whitespace | spaces only | GQL's TRIM removes spaces (to be checked against the ISO text) |
floor(3), ceil(3) are floats | integers (they compare equal) | GQL keeps the argument's type |
round(x, p) takes a float p; range() takes floats | error 22G03 | an integer argument must be an integer |
a rounding mode outside Java's names fails with 22000; 'UNNECESSARY' is a mode | error 22023; 'UNNECESSARY' refused | the code is ours; 'UNNECESSARY' fails whenever rounding is needed |
round() of a number beyond the integer range | the number itself, never saturated | |
left('a', null) raises an error (per the manual) | NULL | GQL's NULL rule |
sqrt(-1) is NaN, ln(0) is -Infinity, exp(1000) is Infinity | errors 2201F, 2201E, 22003 | no NaN or infinity from finite numbers (GQL requires the logarithm error too) |
split(s, '') splits UTF-16 code units | splits characters | an emoji stays one item |
range step 0, negative round precision: 22N38 / 22N03 | 22023 | the ISO / SQL-heritage code |
=~ takes a Java regular expression (look-around, back-references, possessive quantifiers) | the Rust regex crate's syntax: no look-around, no back-references; an invalid pattern is 2201B | matching stays linear in the text, as for TextP.regex (Regular-expression match) |
log(2, x) and log(10, x) are exact for powers of the base, and keys(map) keeps keys whose
value is NULL, both as in Neo4j.
Dates and times
The temporal functions are openCypher's, with Neo4j's meaning, on
the engine's date and time values (Cypher's time is the engine's
offsettime, its localtime the time). Three properties of those values shape the answers:
- Nanoseconds. A value holds nanoseconds, as Neo4j's: text, a map field and a computed duration keep up to nine fraction digits; a duration scaled by a number keeps whole nanoseconds, truncated toward zero, as Neo4j does.
- Zone names are kept. A
datetimekeeps its UTC offset and, when it was built with one, its zone name (in text,[Europe/London], or astimezone: 'Europe/Stockholm'), as Neo4j does:d.timezonegives the name,toStringwrites it (..+01:00[Europe/London]), and later arithmetic, truncation andduration.betweenfollow the zone's daylight saving rules (Time Zones). Atimekeeps an offset only: a time of day has no date, so a named zone (time({hour: 12, timezone: 'Europe/Berlin'})) gives the zone's offset at the query's instant, the instant every clock of the query reads (+01:00in a Berlin winter,+02:00in summer). ISO GQL's zoned time carries a UTC offset only, and Neo4j takes the zone's offset at the current time too; reading it at the query's one instant keeps the query repeatable within itself. - One clock per query. The constructors without an argument,
X.statement()andX.transaction()all read one instant, taken when the query starts (in nanoseconds, with the digits the platform clock has: Clock precision);X.realtime()is refused, asrand()is.
The Gremlin steps and the Rhai operators keep their own (PostgreSQL-style) rules: every
difference below lives in gql() only. Dates in GQL sets each function
beside the Gremlin step that does the same.
g.gql("RETURN datetime('2015-07-21T21:40:32.142[Europe/London]') AS d, datetime({year: 1984, month: 10, day: 11, hour: 12, timezone: 'Europe/Stockholm'}).timezone AS z").to_list()
// #{"d": datetime("2015-07-21T21:40:32.142+01:00[Europe/London]"), "z": "Europe/Stockholm"}
g.gql("RETURN time({hour: 12, timezone: '+01:00'}) AS t").to_list() // #{"t": offsettime("12:00:00+01:00")}
g.gql("RETURN datetime('2015-03-29T01:30+01:00') + duration('PT1H') AS d").to_list() // #{"d": datetime("2015-03-29T02:30:00+01:00")}
g.gql("RETURN localtime({hour: 12, minute: 0, second: 0, nanosecond: 5}) AS t").to_list() // [#{"t": time("12:00:00.000000005")}]
Where dates and times differ from Neo4j
| Neo4j | gql() | Why |
|---|---|---|
time(): a time of day with an offset ('12:00+01:00') | the same (the engine's offsettime, ISO zoned_time()); equal when the UTC times of day are equal | Neo4j's = compares the offset too; no TCK case tells the two apart |
a datetime keeps its zone name: '..+01:00[Europe/London]', d.timezone is 'Europe/London', arithmetic and duration.between follow the zone's daylight saving rules | the same; equality and order use the instant only | as for any datetime (Comparing and Sorting) |
X.truncate('millennium' | 'century', ..): 2017 gives 2000, 1984 gives 1900 | the same | Neo4j's rule in gql(); Gremlin's date_trunc(DT.millennium) keeps PostgreSQL's (2001, 1901) |
date + duration takes months, days and the whole days of the time part; localtime + duration only the time part, wrapping | the same | Neo4j's rule in gql(); Gremlin's date_add refuses a duration with a time part on a date |
toString(temporal) writes '12:00', 'PT-1M-0.001S' | the same; CAST(x AS STRING) writes the engine's ISO text ('12:00:00', '-PT1M0.001S') | toString is the Cypher compatibility function, CAST the ISO one |
< / > on two durations is NULL | the same; ORDER BY sorts durations by length (a month of 30 days) | a month has no fixed length; a sort needs a total order |
X.realtime() reads the live clock | refused (0A000); the other clocks read one instant per query | a clock that moves during the query makes it non-deterministic, as rand() |
| a duration: 64-bit months, days and seconds, plus the nanoseconds of one second | the same time part (a 64-bit count of seconds plus nanoseconds, -PT9223372036854775808S to PT9223372036854775807.999999999S); months and days are 32-bit, beyond is 22008 | the size of a stored value; a result leaving the years -9999 to 9999 is 22008 in both |
| years -999 999 999 to 999 999 999 | years -9999 to 9999; beyond is 22008 | the engine's range (Dates and Times); 2 TCK cases are excluded (year-range) |
duration.between of two different types aligns them (a missing date: times only, a missing time: midnight, a missing zone: the other side's) | the same; the zone lent is the other side's zone name when it has one, else its offset | Neo4j's rule |
time() with a named zone takes the zone's current offset | the zone's offset at the query's instant (one instant per query) | a time of day has no date to place in the zone; one instant per query keeps every clock and constructor of a query consistent |
| no temporal literals | the ISO GQL literals DATE '..', TIME '..', DATETIME '..' / TIMESTAMP '..', DURATION '..' (and ZONED / LOCAL spelled out) and the clocks CURRENT_DATE, CURRENT_TIME, CURRENT_TIMESTAMP, LOCAL_TIME, LOCAL_TIMESTAMP (Literals) | ISO GQL forms; a type word followed by a string means nothing in Neo4j's language |
Ids are strings
element_id(x) is the element's id as a string, the same value as Gremlin's id(). Compare
it with a string: element_id(n) = '1', never = 1 (a number never equals a string). Vertex and
edge ids are separate spaces in Graphersal, so a vertex and an edge may have the same id; Cypher
id(x), an integer in Neo4j, is refused (Refused Constructs).
g.gql("MATCH (n) WHERE element_id(n) = '1' RETURN n.name AS n").to_list() // #{"n": "marko"}
g.gql("MATCH (n) WHERE element_id(n) = 1 RETURN n.name AS n").to_list() // no rows
Labels and properties
:Aon a vertex tests whetherAis in its label set;labels(v)lists the set, primary label first. An edge has exactly one label,type(e).n.kreads a property. A jpath key or a map-valued property reads as a map, andm.keyreads into it.NULLfrom a missing property and a storednullare the same value.
Differences from Neo4j
gql() accepts Neo4j's Cypher spellings as compatibility forms with Neo4j's
meaning, measured by the openCypher TCK (TCK Compliance: no supported
construct behaves differently from the kit, except where a case is excluded below). Each of
these differences is a deliberate decision, described in its section:
| Neo4j | gql() | Where |
|---|---|---|
a negative or non-integer $parameter of SKIP / LIMIT is a SyntaxError at runtime | refused when the query is compiled (22G02 / 22G03), before any row: parameters are bound then (TCK cases excluded) | Parameters are bound when the query is compiled |
| more type errors at compile time, from its static type system | refused before the run only where the kind is certain from the query text; any other wrong kind fails when a row reaches it | Static kind checks, Type errors surface at evaluation time |
| a property value is a scalar or a list of scalars of one kind | maps, mixed and nested lists are stored as given (a TCK case excluded) | Writes |
SET n = NULL removes every property | an error (22004), also for SET n += NULL | Writes |
a plain DELETE of a node with edges is checked when the transaction commits | checked per clause (G1001) | Writes, DELETE |
RETURN n of a node deleted earlier in the query returns it | an error naming properties(n) | DELETE |
a required (existence-constrained) property may come from ON CREATE SET | checked when MERGE creates the element | MERGE |
the RETURN of a COUNT { } body is evaluated | not evaluated when it only projects | COUNT and COLLECT |
| a comprehension variable may reuse any name that is not in scope | not a name that a LET / WITH value in scope still reads | Map projections and comprehensions |
'a' + 1 is 'a1' | an error (22G03): convert explicitly | Comparisons across types |
some functions saturate, give NaN / infinity or take floats | errors or GQL's types | Where functions differ from Neo4j |
=~ takes a Java regular expression | the Rust regex crate's syntax (no look-around, no back-references) | Regular-expression match |
id(x) is an integer; rand() | ids are strings (element_id(x)); id() and rand() are refused | Ids are strings, Refused Constructs |
| years up to ±999 999 999 | years -9999 to 9999 (TCK cases excluded) | Where dates and times differ from Neo4j |
X.realtime() | refused; one clock per query | Dates and times |
a procedure argument may read the row; procedures are vendor or user code (db.*, apoc.*, Java) | CALL folder.name(..) calls a saved GQL query with constant or $parameter arguments only, positional in the order its body first reads them; a saved query in Rhai, or a call inside EXISTS / COUNT / COLLECT, is 0A000; db, dbms, apoc, gds are refused | Calls of saved queries |
| procedure names are qualified, one per namespace | saved-query names are unique across all folders: a name in another folder is a "did you mean" error | Calls of saved queries |
a NUMBER argument keeps an integer (42) | coerced by the parameter's schema: a number parameter gets 42.0 | Calls of saved queries |
| a procedure call is a commit of its own in some drivers | the call is part of the calling query's one unit, whose commit names no saved query (unlike g.query(..)) | Calls of saved queries |
Where ISO GQL and Neo4j disagree, gql() follows Neo4j in a few places (comparisons across
kinds, list ordering, float division by zero, NaN): Differences from the
standard.