graph-dsl
v0.1.0
Published
A typed graph query DSL that emits a portable AST and compiles to graph backends.
Maintainers
Readme
graph-dsl
Typed graph query DSL for JavaScript/TypeScript.
graph-dsl lets you describe graph operations with a small TypeScript DSL. The DSL emits a portable AST, and that AST can then be compiled or executed by different backends.
DSL -> AST -> Cypher Compiler -> Neo4j
|
+-> Ladybug Cypher Compiler -> LadybugDB
|
+-> Gremlin Compiler -> JanusGraph
|
+-> Memory Executor -> Mock database/testsThe current package is an MVP. It already supports a neutral AST, a fluent DSL, Cypher compilation, and an in-memory executor useful for tests.
Table of Contents
- Installation
- Quick Start
- Mental Model
- Core Concepts
- Query Operations
- Runtime Schemas
- Bulk Operations
- Path Traversal
- Aggregations
- Predicates
- Returning Data
- Cypher Compiler
- Ladybug Compiler
- Memory Executor
- AST Shape
- Current Limitations
- Roadmap
Installation
npm install graph-dslFor local development in this repository:
npm install
npm test
npm run typecheck
npm run buildQuick Start
import { compileCypher, edge, eq, node, param, prop, query, select } from "graph-dsl";
const user = node("u", "User");
const post = node("p", "Post");
const ast = query()
.match(edge(user, "WROTE", post))
.where(eq(prop(user, "email"), param("email")))
.return(post, select(user, "email", "authorEmail"))
.toAst();
const cypher = compileCypher(ast, {
params: { email: "[email protected]" },
});
console.log(cypher.query);
// MATCH (u:User)-[:WROTE]->(p:Post)
// WHERE u.email = $email
// RETURN p, u.email AS authorEmailMental Model
The DSL does not directly generate Cypher strings. Instead, it builds a query AST:
const ast = query()
.match(node("u", "User"))
.where(eq(prop("u", "email"), param("email")))
.return(select("u", "email", "email"))
.toAst();That AST is the stable middle layer. Compilers and executors consume the AST:
const cypher = compileCypher(ast, {
params: { email: "[email protected]" },
});This keeps the public DSL independent from one database vendor.
Core Concepts
Nodes
const user = node("u", "User");
const post = node("p", "Post");The first argument is the alias used in the query. Remaining arguments are labels.
const user = node("u", "User", "Author");Node properties can be literals or parameters:
const user = node("u", "User").props({
email: param("email"),
name: "Ada",
});Use anyNode(alias) when a scoped/admin query intentionally matches any node label:
query()
.scope({ graphId: param("graphId") })
.match(anyNode("node"));Edges
const wrote = edge(user, "WROTE", post);By default, edges are outgoing:
(user)-[:WROTE]->(post)You can pass a direction explicitly:
edge(user, "FOLLOWS", friend, "out");
edge(user, "FOLLOWS", friend, "in");
edge(user, "FOLLOWS", friend, "both");Edges can also be aliased, which is useful for returning or deleting them:
const relation = edge(user, "FOLLOWS", friend).as("r");For reads, an edge can match any of several relationship types:
edge(user, ["WROTE", "EDITED"], post).as("r");This compiles to:
(user)-[r:WROTE|EDITED]->(post)Use anyEdge(from, to, direction) when a query intentionally matches any relationship type:
query()
.match(anyEdge(anyNode("source"), anyNode("target"), "both").as("relationship"));Paths And Traversals
Use traverse(...) when you want to match a variable-length relationship chain instead of one fixed edge:
const source = node("source", "Person");
const target = node("target", "Person");
const traversal = traverse(source, "KNOWS", target).hops(1, 3);This compiles to a variable-length relationship:
(source:Person)-[:KNOWS*1..3]->(target:Person)Traversals can also match several relationship types:
traverse(source, ["KNOWS", "FOLLOWS"], target).hops(1, 3);Use path(alias, ...) when you want to name and return the whole path:
const p = path("p", source, "KNOWS", target).hops(1, 3);hops(min, max) sets a bounded traversal range. hops(min) means min or more hops in Cypher. The in-memory executor requires a bounded max to avoid open-ended graph walks in tests.
Properties
prop(ref, key) creates a property expression. It does not read a value immediately. It points to a property on a matched or created graph entity.
prop(user, "email");
user.prop("email");
prop("u", "email");All three examples mean the same thing when user.alias === "u":
u.emailUse property expressions in where(...), set(...), and return(...):
query()
.match(node("u", "User"))
.where(eq(prop("u", "email"), param("email")))
.set(prop("u", "name"), "Ada Lovelace")
.return(select("u", "name", "name"));Parameters
param(name) creates a named runtime parameter. Parameters are values supplied when compiling or executing the AST.
where(eq(user.prop("email"), param("email")));For Cypher, named parameters compile to $email:
compileCypher(ast, {
params: { email: "[email protected]" },
});WHERE u.email = $emailUse parameters for values coming from users, requests, forms, API inputs, or test setup.
Literal Values
You can pass literal values directly to predicates and set(...):
where(gte(prop("u", "age"), 18));
set(prop("u", "active"), true);For Cypher, literals are converted to generated parameters:
WHERE u.age >= $p0
SET u.active = $p1You can also wrap a literal explicitly with value(...):
where(eq(prop("u", "role"), value("admin")));Most of the time, direct literals are easier to read.
Scope
scope(props) adds the same properties to every node and edge used by later query clauses.
This is useful for multi-tenant data, workspace isolation, organization boundaries, or any other context that should be present on every node:
const user = node("u", "User");
const post = node("p", "Post");
const ast = query()
.scope({
tenantId: param("tenantId"),
workspaceId: param("workspaceId"),
orgId: param("orgId"),
})
.match(edge(user, "WROTE", post))
.where(eq(user.prop("email"), param("email")))
.return(post)
.toAst();Cypher output:
MATCH (u:User { tenantId: $tenantId, workspaceId: $workspaceId, orgId: $orgId })-[:WROTE { tenantId: $tenantId, workspaceId: $workspaceId, orgId: $orgId }]->(p:Post { tenantId: $tenantId, workspaceId: $workspaceId, orgId: $orgId })
WHERE u.email = $email
RETURN pScope is applied to both nodes and edges:
query()
.scope({ tenantId: param("tenantId") })
.match(edge(node("u", "User"), "WROTE", node("p", "Post")));The generated node patterns and the WROTE relationship all get tenantId. Traversal relationships created with traverse(...) get the same scoped properties too.
When an aliased path is matched with path("p", ...), scope is also enforced against the full returned path:
const ast = query()
.scope({ tenantId: param("tenantId") })
.match(path("p", node("a", "Person"), "KNOWS", node("b", "Person")).hops(1, 3))
.return("p")
.toAst();Cypher output includes guards for every node and relationship inside p:
MATCH p = (a:Person { tenantId: $tenantId })-[:KNOWS*1..3 { tenantId: $tenantId }]->(b:Person { tenantId: $tenantId })
WHERE all(n IN nodes(p) WHERE n.tenantId = $tenantId) AND all(r IN relationships(p) WHERE r.tenantId = $tenantId)
WITH *
RETURN pScope also applies to created nodes:
const ast = query()
.scope({ tenantId: param("tenantId") })
.create(node("u", "User").props({ email: param("email") }))
.toAst();Cypher output:
CREATE (u:User { email: $email, tenantId: $tenantId })If a node or edge explicitly defines a property that also exists in the scope, the builder throws an error:
query()
.scope({ tenantId: param("tenantId") })
.match(node("u", "User").props({ tenantId: param("otherTenantId") }));This is intentional. Scope is commonly used for data isolation, so accidental overrides should be loud.
Query Operations
Read
const user = node("u", "User");
const post = node("p", "Post");
const ast = query()
.match(edge(user, "WROTE", post))
.where(eq(user.prop("email"), param("email")))
.return(select(post, "title", "title"), select(user, "name", "author"))
.toAst();Cypher output:
MATCH (u:User)-[:WROTE]->(p:Post)
WHERE u.email = $email
RETURN p.title AS title, u.name AS authorUse optionalMatch(...) when related data should not filter out the base row:
const ast = query()
.match(user)
.optionalMatch(edge(user, "WROTE", post))
.return(select(user, "email", "email"), select(post, "title", "title"))
.toAst();Cypher output:
MATCH (u:User)
OPTIONAL MATCH (u:User)-[:WROTE]->(p:Post)
RETURN u.email AS email, p.title AS titleoptionalMatch(...) must follow at least one non-optional match(...) clause so the query is anchored before optional expansion.
Use orderBy(...), skip(...), and limit(...) to control result order and pagination:
const ast = query()
.match(user)
.return(select(user, "name", "name"))
.orderBy(order(user.prop("name"), "desc"))
.skip(param("offset"))
.limit(25)
.toAst();Cypher output:
MATCH (u:User)
RETURN u.name AS name
ORDER BY u.name DESC
SKIP $offset
LIMIT 25Use with(...) to project values into the next pipeline stage:
const ast = query()
.match(user)
.optionalMatch(edge(user, "WROTE", post))
.with(user, count(post, "postCount"))
.where(gte(variable("postCount"), 1))
.return(select(user, "email", "email"), "postCount")
.toAst();Cypher output:
MATCH (u:User)
OPTIONAL MATCH (u:User)-[:WROTE]->(p:Post)
WITH u, count(p) AS postCount
WHERE postCount >= $p0
RETURN u.email AS email, postCountUse variable(name) when a later predicate, sort, or expression needs a scalar alias produced by with(...).
Use call(...) for nested subqueries that produce additional columns for each current row:
const user = node("u", "User");
const post = node("p", "Post");
const postCount = query()
.with(user)
.optionalMatch(edge(user, "WROTE", post))
.return(count(post, "postCount"));
const ast = query()
.match(user)
.call(postCount, { import: [user] })
.return(
map("user", {
email: user.prop("email"),
postCount: variable("postCount"),
}),
)
.toAst();Cypher output:
MATCH (u:User)
CALL {
WITH u
OPTIONAL MATCH (u:User)-[:WROTE]->(p:Post)
RETURN count(p) AS postCount
}
RETURN { email: u.email, postCount: postCount } AS userWhen a subquery needs outer aliases, pass them through call(subquery, { import: [...] }). In Cypher this is represented by an initial WITH inside the subquery. For the in-memory executor, the same import list controls which outer bindings are visible to the nested query.
Path Traversal
Use traverse(...) for variable-length graph reads:
const source = node("source", "Person").props({ id: param("sourceId") });
const target = node("target", "Person").props({ id: param("targetId") });
const ast = query()
.match(traverse(source, "KNOWS", target).hops(1, 3))
.return(target)
.toAst();Cypher output:
MATCH (source:Person { id: $sourceId })-[:KNOWS*1..3]->(target:Person { id: $targetId })
RETURN targetUse path(alias, ...) to bind the whole path:
const ast = query()
.match(path("p", source, "KNOWS", target).hops(1, 3))
.return("p", select(target, "id", "targetId"))
.toAst();Cypher output:
MATCH p = (source:Person { id: $sourceId })-[:KNOWS*1..3]->(target:Person { id: $targetId })
RETURN p, target.id AS targetIdTraversals can be undirected, can bind the traversed relationships, and can apply relationship property filters:
const ast = query()
.match(
traverse(source, "KNOWS", target, "both")
.hops(2, 2)
.via("rels")
.props({ active: true }),
)
.return("rels", target)
.toAst();Cypher output:
MATCH (source:Person { id: $sourceId })-[rels:KNOWS*2 { active: $p0 }]-(target:Person { id: $targetId })
RETURN rels, targetCreate
const user = node("u", "User").props({
email: param("email"),
name: param("name"),
});
const ast = query()
.create(user)
.return(select(user, "email", "email"))
.toAst();Cypher output:
CREATE (u:User { email: $email, name: $name })
RETURN u.email AS emailMerge
Use merge(...) when a node pattern should be found or created by identity properties:
const ast = query()
.scope({ tenantId: param("tenantId") })
.merge(node("u", "User").props({ id: param("userId") }))
.toAst();Cypher output:
MERGE (u:User { id: $userId, tenantId: $tenantId })Use mergeEdge(...) when the endpoint nodes are already bound:
const user = node("u", "User").props({ id: param("userId") });
const post = node("p", "Post").props({ id: param("postId") });
const ast = query()
.scope({ tenantId: param("tenantId") })
.merge(user)
.merge(post)
.mergeEdge(edge(user, "WROTE", post).props({ role: "author" }))
.toAst();Cypher output:
MERGE (u:User { id: $userId, tenantId: $tenantId })
MERGE (p:Post { id: $postId, tenantId: $tenantId })
MERGE (u)-[:WROTE { role: $p0, tenantId: $tenantId }]->(p)Properties inside a merge(...) pattern are identity properties. If you want to merge by one field and update other fields, merge the identity pattern first and then use set(...), setProps(...), onCreateSet(...), or onMatchSet(...).
Use onCreateSet(...) for values that should be written only when the merge creates a new node or edge. Use onMatchSet(...) for values that should be written only when the merge finds an existing node or edge:
const ast = query()
.merge(node("p", "Person").props({ id: param("personId") }))
.onCreateSet(prop("p", "createdAt"), param("now"))
.onMatchSet(prop("p", "updatedAt"), param("now"))
.toAst();Cypher output:
MERGE (p:Person { id: $personId })
ON CREATE SET p.createdAt = $now
ON MATCH SET p.updatedAt = $nowSchema patches can be used with merge-specific setters too:
const identity = Person.identity("p", formData, ["id"]);
const createPatch = Person.patch("p", {
name: formData.name,
createdAt: formData.createdAt,
});
const matchPatch = Person.patch("p", {
name: formData.name,
updatedAt: formData.updatedAt,
});
const ast = query()
.merge(identity.node)
.onCreateSetProps(createPatch)
.onMatchSetProps(matchPatch)
.toAst();Update
const ast = query()
.match(node("u", "User"))
.where(eq(prop("u", "email"), param("email")))
.set(prop("u", "name"), "Ada Lovelace")
.return(select("u", "name", "name"))
.toAst();Cypher output:
MATCH (u:User)
WHERE u.email = $email
SET u.name = $p0
RETURN u.name AS nameUse setMap(...) for Cypher map patches such as SET node += row:
const ast = query()
.match(node("u", "User"))
.where(eq(prop("u", "email"), param("email")))
.setMap("u", row("item", "patch"))
.return("u")
.toAst();Cypher output:
MATCH (u:User)
WHERE u.email = $email
SET u += item.patch
RETURN uDelete
const ast = query()
.match(node("u", "User"))
.where(eq(prop("u", "email"), param("email")))
.delete("u")
.toAst();Cypher output:
MATCH (u:User)
WHERE u.email = $email
DELETE uDetach Delete
Use this when the node may still have relationships.
const ast = query()
.match(node("u", "User"))
.where(eq(prop("u", "email"), param("email")))
.detachDelete("u")
.toAst();Cypher output:
MATCH (u:User)
WHERE u.email = $email
DETACH DELETE uRuntime Schemas
You can describe node and edge properties with a serializable JSON schema. This is useful when schemas are stored outside the codebase, for example in MongoDB.
import { compileCypher, defineNodeFromJson, query } from "graph-dsl";
const schemaDoc = {
kind: "node",
label: "Person",
fields: {
id: { type: "string", required: true },
name: { type: "string", required: true },
age: { type: "number" },
active: { type: "boolean" },
},
} as const;
const Person = defineNodeFromJson(schemaDoc);Map a plain JavaScript object, such as form data, to a DSL node and generated params:
const mapped = Person.from("p", {
id: "person-1",
name: "Ada",
age: 36,
active: true,
});
const ast = query()
.create(mapped.node)
.toAst();
const result = compileCypher(ast, {
params: mapped.params,
});Cypher output:
CREATE (p:Person { id: $p_id, name: $p_name, age: $p_age, active: $p_active })Generated params:
{
p_id: "person-1",
p_name: "Ada",
p_age: 36,
p_active: true,
}The mapper validates input at runtime:
- required fields must be present
- field values must match their schema type
- unknown fields throw by default
- optional fields with
undefinedare skipped
Unknown fields can be stripped instead:
const Car = defineNodeFromJson({
kind: "node",
label: "Car",
fields: {
id: { type: "string", required: true },
model: { type: "string", required: true },
},
options: {
unknownFields: "strip",
},
});You can load the same JSON shape from MongoDB:
const schemaDoc = await db.collection("graphSchemas").findOne({
label: "Person",
});
const Person = defineNodeFromJson(schemaDoc);
const mapped = Person.from("p", formData);For merges, use identity(...) to map only the fields that identify the graph entity. Selected identity fields must be present even if the schema marks them as optional, and the rest of the input is ignored for the identity pattern:
const identity = Person.identity("p", formData, ["id"], {
paramPrefix: "person",
});
const ast = query()
.merge(identity.node)
.toAst();Cypher output:
MERGE (p:Person { id: $person_id })For updates, use patch(...). Patches validate only fields that are present, so required fields are not required for partial updates:
const patch = Person.patch("p", {
name: "Ada Lovelace",
age: 37,
});
const ast = query()
.match(node("p", "Person").props({ id: param("personId") }))
.setProps(patch)
.toAst();
const result = compileCypher(ast, {
params: {
personId: "person-1",
...patch.params,
},
});Cypher output:
MATCH (p:Person { id: $personId })
SET p.name = $p_name
SET p.age = $p_ageEdge schemas work the same way:
import { defineEdgeFromJson, node, param } from "graph-dsl";
const Wrote = defineEdgeFromJson({
kind: "edge",
label: "WROTE",
fields: {
role: { type: "string", required: true },
createdAt: { type: "string", required: true },
featured: { type: "boolean" },
},
});
const person = node("p", "Person").props({
id: param("personId"),
});
const post = node("post", "Post").props({
id: param("postId"),
});
const mappedEdge = Wrote.from(
person,
post,
{
role: "author",
createdAt: "2026-06-30",
featured: true,
},
{ paramPrefix: "wrote" },
);
const ast = query()
.match(person, post)
.createEdge(mappedEdge.edge)
.toAst();Cypher output:
MATCH (p:Person { id: $personId }), (post:Post { id: $postId })
CREATE (p)-[:WROTE { role: $wrote_role, createdAt: $wrote_createdAt, featured: $wrote_featured }]->(post)Edge schemas also support patches for already-bound relationship aliases:
const relation = edge(person, "WROTE", post).as("r");
const patch = Wrote.patch("r", {
featured: true,
});
const ast = query()
.match(relation)
.setProps(patch)
.toAst();Cypher output:
MATCH (p:Person)-[r:WROTE]->(post:Post)
SET r.featured = $r_featuredFor relationship identity, use Wrote.identity(...) with mergeEdge(...):
const identityEdge = Wrote.identity(person, post, {
role: "author",
createdAt: "2026-06-30",
}, ["role"]);
const ast = query()
.match(person, post)
.mergeEdge(identityEdge.edge)
.toAst();Cypher output:
MATCH (p:Person { id: $personId }), (post:Post { id: $postId })
MERGE (p)-[:WROTE { role: $p_WROTE_post_role }]->(post)Bulk Operations
Use unwind(...) when you want to create or update many nodes/edges from a JavaScript array.
The DSL compiles this to Cypher UNWIND, so one parameterized query can process many rows:
import { compileCypher, node, param, query, row } from "graph-dsl";
const ast = query()
.scope({ tenantId: param("tenantId") })
.unwind(param("users"), "item")
.create(
node("u", "User").props({
id: row("item", "id"),
email: row("item", "email"),
name: row("item", "name"),
}),
)
.toAst();
const result = compileCypher(ast, {
params: {
tenantId: "tenant-1",
users: [
{ id: "user-1", email: "[email protected]", name: "Ada" },
{ id: "user-2", email: "[email protected]", name: "Grace" },
],
},
});Cypher output:
UNWIND $users AS item
CREATE (u:User { id: item.id, email: item.email, name: item.name, tenantId: $tenantId })row(alias, key) reads a field from the current unwound item:
row("item", "email");which compiles to:
item.emailBulk Edge Creation
When creating edges between existing nodes, first match the nodes from row data, then use createEdge(...).
import { edge, node, param, query, row } from "graph-dsl";
const user = node("u", "User").props({
id: row("item", "userId"),
});
const post = node("p", "Post").props({
id: row("item", "postId"),
});
const ast = query()
.scope({ tenantId: param("tenantId") })
.unwind(param("writes"), "item")
.match(user, post)
.createEdge(
edge(user, "WROTE", post).props({
createdAt: row("item", "createdAt"),
}),
)
.toAst();Cypher output:
UNWIND $writes AS item
MATCH (u:User { id: item.userId, tenantId: $tenantId }), (p:Post { id: item.postId, tenantId: $tenantId })
CREATE (u)-[:WROTE { createdAt: item.createdAt, tenantId: $tenantId }]->(p)create(...) is for creating full node/edge patterns. createEdge(...) is for creating only relationships between aliases that are already bound by earlier clauses.
Batching Bulk Operations
For large inputs, avoid passing the entire array as one parameter. Use runParamBatches(...) to execute the same UNWIND query in smaller chunks.
The helper is driver-neutral: you decide what happens for each batch.
import { compileCypher, runParamBatches } from "graph-dsl";
await runParamBatches({
items: hugeUsersArray,
batchParam: "users",
batchSize: 1000,
params: {
tenantId: "tenant-1",
},
onBatch: async (params, meta) => {
const compiled = compileCypher(ast, { params });
await session.run(compiled.query, compiled.params);
console.log(`Batch ${meta.index + 1}/${meta.totalBatches ?? "?"}`);
},
});The same helper can be used with the memory executor:
import { executeMemory, runParamBatches } from "graph-dsl";
await runParamBatches({
items: hugeUsersArray,
batchParam: "users",
batchSize: 1000,
params: {
tenantId: "tenant-1",
},
onBatch: (params) => executeMemory(ast, graph, { params }),
});For lower-level control, use chunk(...) or runBatches(...):
for (const usersBatch of chunk(hugeUsersArray, { batchSize: 1000 })) {
const compiled = compileCypher(ast, {
params: {
tenantId: "tenant-1",
users: usersBatch,
},
});
await session.run(compiled.query, compiled.params);
}Aggregations
Aggregate helpers are return selections. Use them inside return(...) together with aliases or property selections.
import { collect, count, countAll, edge, node, prop, query, select } from "graph-dsl";
const user = node("u", "User");
const post = node("p", "Post");
const ast = query()
.match(edge(user, "WROTE", post))
.return(
select(user, "role", "role"),
countAll("rows"),
count(post, "postCount"),
collect(prop(post, "title"), "titles", { distinct: true }),
)
.toAst();Cypher output:
MATCH (u:User)-[:WROTE]->(p:Post)
RETURN u.role AS role, count(*) AS rows, count(p) AS postCount, collect(DISTINCT p.title) AS titlesCypher groups by every non-aggregate return selection. In the example above, results are grouped by u.role.
Supported aggregate helpers:
| Helper | Meaning | Example |
| --- | --- | --- |
| countAll(as) | Counts rows with count(*). | countAll("rows") |
| count(target, as) | Counts non-null values for an alias or expression. | count(node("u"), "users") |
| sum(expression, as) | Sums numeric values. | sum(prop("u", "score"), "totalScore") |
| avg(expression, as) | Averages numeric values. | avg(prop("u", "score"), "avgScore") |
| min(expression, as) | Returns the smallest value. | min(prop("u", "age"), "youngest") |
| max(expression, as) | Returns the largest value. | max(prop("u", "age"), "oldest") |
| collect(target, as) | Collects values into a list. | collect(prop("u", "email"), "emails") |
| stDev(expression, as) | Returns sample standard deviation. | stDev(prop("u", "score"), "scoreStdDev") |
| percentileCont(expression, percentile, as) | Returns a continuous percentile with interpolation. | percentileCont(prop("u", "score"), 0.95, "p95") |
| countWhen(predicate, as) | Counts rows where a predicate is true. | countWhen(lt(variable("value"), param("low")), "low") |
Every aggregate helper accepts { distinct: true } as the last argument:
query()
.match(node("u", "User"))
.return(count(prop("u", "role"), "roles", { distinct: true }));Cypher output:
MATCH (u:User)
RETURN count(DISTINCT u.role) AS rolesNeo4j statistical aggregates are also available:
query()
.match(node("u", "User"))
.return(
stDev(prop("u", "score"), "scoreStdDev"),
percentileCont(prop("u", "score"), 0.95, "p95"),
);Cypher output:
MATCH (u:User)
RETURN stDev(u.score) AS scoreStdDev, percentileCont(u.score, $p0) AS p95Aggregate value helpers can be embedded inside larger expressions. This is useful for composed aggregate projections such as variance:
query()
.match(node("u", "User"))
.return(
expr(
mul(stDevValue(prop("u", "score")), stDevValue(prop("u", "score"))),
"variance",
),
);Cypher output:
MATCH (u:User)
RETURN (stDev(u.score) * stDev(u.score)) AS varianceAvailable aggregate value helpers mirror the return-selection helpers: countValue(...), sumValue(...), avgValue(...), minValue(...), maxValue(...), collectValue(...), stDevValue(...), and percentileContValue(...).
For conditional counts, use countWhen(...) or countWhenValue(...):
query()
.with(expr(toFloat(row("item", "age")), "value"))
.return(
countWhen(lt(variable("value"), param("lowThreshold")), "low"),
countWhen(gt(variable("value"), param("highThreshold")), "high"),
);Cypher output:
WITH toFloat(item.age) AS value
RETURN count(CASE WHEN value < $lowThreshold THEN $p0 END) AS low, count(CASE WHEN value > $highThreshold THEN $p1 END) AS highMap values can be collected or passed through expression helpers:
const company = node("company", "Company");
query()
.match(company)
.return(
collect(
mapValue({
nodeId: elementId(company),
labels: labels(company),
properties: properties(company),
}),
"companies",
{ distinct: true },
),
);Dynamic property access and mapped list comprehensions cover runtime-selected identity fields:
const record = node("node", "Person");
query()
.match(record)
.with(
expr(
mapList(
"field",
param("identityFields"),
toString(dynamicProp(record, listItem("field"))),
),
"identityValues",
),
count(record, "recordCount"),
);Cypher output:
MATCH (node:Person)
WITH [field IN $identityFields | toString(node[field])] AS identityValues, count(node) AS recordCountCase-insensitive runtime filters can be represented with toLower(...) and toString(...):
contains(toLower(toString(variable("column"))), param("filterContains"));Cypher output:
toLower(toString(column)) CONTAINS $filterContainsPredicates
Predicates describe boolean conditions, usually passed to where(...).
Each comparison helper accepts a left expression and a right expression or literal:
eq(prop("u", "email"), param("email"));
gte(prop("u", "age"), 18);
contains(prop("u", "email"), "@example.com");Comparison helpers:
| Helper | Meaning | Cypher output example |
| --- | --- | --- |
| eq(left, right) | Checks equality. | u.email = $email |
| neq(left, right) | Checks inequality. | u.status <> $p0 |
| gt(left, right) | Checks that left is greater than right. | u.age > $p0 |
| gte(left, right) | Checks that left is greater than or equal to right. | u.age >= $p0 |
| lt(left, right) | Checks that left is less than right. | u.age < $p0 |
| lte(left, right) | Checks that left is less than or equal to right. | u.age <= $p0 |
| contains(left, right) | Checks that a string contains another string. | u.email CONTAINS $p0 |
| inList(left, right) | Checks that left is in a list expression. | label IN $targetLabels |
| isNull(expression) | Checks Cypher null state. | u.email IS NULL |
| isNotNull(expression) | Checks Cypher non-null state. | u.email IS NOT NULL |
Logical helpers combine other predicates:
| Helper | Meaning | Cypher output example |
| --- | --- | --- |
| and(a, b, ...) | All child predicates must be true. | (u.age >= $p0) AND (u.active = $p1) |
| or(a, b, ...) | At least one child predicate must be true. | (u.role = $p0) OR (u.role = $p1) |
| not(predicate) | Negates a predicate. | NOT (u.deleted = $p0) |
Example with comparison and logical predicates:
import { and, contains, eq, gte, node, not, prop, query, select } from "graph-dsl";
const ast = query()
.match(node("u", "User"))
.where(
and(
eq(prop("u", "active"), true),
gte(prop("u", "age"), 18),
contains(prop("u", "email"), "@example.com"),
not(eq(prop("u", "deleted"), true)),
),
)
.return(select("u", "email", "email"))
.toAst();Cypher output:
MATCH (u:User)
WHERE (u.active = $p0) AND (u.age >= $p1) AND (u.email CONTAINS $p2) AND (NOT (u.deleted = $p3))
RETURN u.email AS emailList predicates are available through anyInList(...) and allInList(...). Use listItem(...) to reference the item bound by the predicate:
query()
.match(node("target"))
.where(
anyInList(
"label",
labels("target"),
inList(listItem("label"), param("targetLabels")),
),
);Cypher output:
MATCH (target)
WHERE any(label IN labels(target) WHERE label IN $targetLabels)List, path, and map expression helpers cover common data-view projections:
query()
.match(path("p", node("source"), "KNOWS", node("target")).hops(1, 3).via("rels"))
.return(
expr(listAt(labels("target"), 0), "targetType"),
expr(type(last(aliasRef("rels"))), "relationshipType"),
expr(length("p"), "depth"),
expr(size(labels("target")), "labelCount"),
);For map-like values, use mapProp(...):
mapProp(variable("value"), "nodeId");Filtered list comprehensions are available through filterList(...):
query()
.with(
expr(
filterList(
"value",
variable("rawValues"),
isNotNull(mapProp(listItem("value"), "nodeId")),
),
"values",
),
);Cypher output:
WITH [value IN rawValues WHERE value.nodeId IS NOT NULL] AS valuesReturning Data
Return a whole node:
query().match(node("u", "User")).return(node("u", "User"));Return a property:
query()
.match(node("u", "User"))
.return(select("u", "email"));Return a property with an alias:
query()
.match(node("u", "User"))
.return(select("u", "email", "email"));Return a scalar expression with an alias:
const user = node("u", "User");
query()
.match(user)
.return(expr(elementId(user), "id"));Cypher output:
MATCH (u:User)
RETURN elementId(u) AS idReturn a map/object projection:
const user = node("u", "User");
query()
.match(user)
.return(
map("user", {
id: elementId(user),
email: user.prop("email"),
source: "neo4j",
}),
);Cypher output:
MATCH (u:User)
RETURN { id: elementId(u), email: u.email, source: $p0 } AS userelementId(...) accepts a node reference, an aliased edge reference, or an alias string. Edge references must be aliased before they can be passed to elementId(...).
Use labels(...), type(...), coalesce(...), and inList(...) for common data-view expressions:
const user = node("u", "User");
const post = node("p", "Post");
const wrote = edge(user, "WROTE", post).as("r");
query()
.match(wrote)
.where(inList(value("Author"), labels(user)))
.return(
expr(type(wrote), "relationshipType"),
map("user", {
id: elementId(user),
labels: labels(user),
displayName: coalesce(user.prop("name"), user.prop("email"), "Unknown"),
}),
);Cypher output:
MATCH (u:User)-[r:WROTE]->(p:Post)
WHERE $p0 IN labels(u)
RETURN type(r) AS relationshipType, { id: elementId(u), labels: labels(u), displayName: coalesce(u.name, u.email, $p1) } AS userUse scalar conversion and math helpers in projections or with(...) stages:
query()
.unwind(param("items"), "item")
.with(expr(toFloat(row("item", "score")), "value"))
.match(user)
.return(
expr(floor(variable("value")), "bucket"),
expr(round(variable("value")), "rounded"),
expr(toInteger(variable("value")), "integerValue"),
expr(toString(user.prop("email")), "emailText"),
expr(properties(user), "props"),
);Cypher output:
UNWIND $items AS item
WITH toFloat(item.score) AS value
MATCH (u:User)
RETURN floor(value) AS bucket, round(value) AS rounded, toInteger(value) AS integerValue, toString(u.email) AS emailText, properties(u) AS propsUse arithmetic helpers and caseWhen(...) for searched CASE expressions:
const rawBucket = variable("rawBucket");
const bucketCount = param("bucketCount");
query()
.unwind(param("items"), "item")
.with(expr(toInteger(row("item", "bucket")), "rawBucket"))
.return(
expr(
caseWhen(
[
{ when: gte(rawBucket, bucketCount), then: sub(bucketCount, 1) },
{ when: lt(rawBucket, 0), then: 0 },
],
rawBucket,
),
"bucketIndex",
),
);Cypher output:
UNWIND $items AS item
WITH toInteger(item.bucket) AS rawBucket
RETURN CASE WHEN rawBucket >= $bucketCount THEN ($bucketCount - $p0) WHEN rawBucket < $p1 THEN $p2 ELSE rawBucket END AS bucketIndexCypher Compiler
import { compileCypher } from "graph-dsl";
const result = compileCypher(ast, {
params: {
email: "[email protected]",
},
});
console.log(result.query);
console.log(result.params);You can also compile standalone expression and predicate fragments. This is useful when migrating a runtime filter system incrementally while keeping parameter handling in the DSL compiler:
import { compileExpression, compilePredicate, contains, param, toLower, toString, variable } from "graph-dsl";
compileExpression(toLower(toString(variable("column"))));
compilePredicate(contains(toLower(toString(variable("column"))), param("filterContains")));Compiler result:
type CompilerOutput = {
query: string;
params: Record<
string,
| string
| number
| boolean
| null
| Record<string, string | number | boolean | null>
| Array<string | number | boolean | null | Record<string, string | number | boolean | null>>
>;
};Ladybug Compiler
compileLadybugCypher(...) emits the Ladybug-compatible subset of Cypher supported by this MVP. Ladybug is close to openCypher, so the emitted query text is usually the same as compileCypher(...); the important difference is that the Ladybug compiler validates the AST against Ladybug's structured property graph model before emitting a query.
import { compileLadybugCypher, node, param, query, select } from "graph-dsl";
const user = node("u", "User").props({
id: param("userId"),
});
const result = compileLadybugCypher(
query()
.match(user)
.return(select(user, "id", "userId"))
.toAst(),
{
params: {
userId: "user-1",
},
terminateStatement: true,
},
);
console.log(result.query);
// MATCH (u:User { id: $userId })
// RETURN u.id AS userId;Use terminateStatement: true when you want a trailing semicolon for Ladybug CLI-style execution. Driver APIs commonly accept statements without the semicolon, so the default is false.
Ladybug vs openCypher
Ladybug follows openCypher where possible, but it is not a drop-in openCypher runtime. The most important differences for this DSL are:
- Ladybug uses a structured property graph model: node and relationship tables must usually be declared before inserting data.
- A Ladybug node or relationship belongs to one table/label; Neo4j-style multi-label nodes are not part of the normal structured model.
- Node tables have primary keys, and relationship tables declare their allowed
FROM/TOnode table pairs. - Variable-length relationships use walk semantics by default, so repeated relationships are allowed unless the query checks otherwise.
- Variable-length relationships need an upper bound for termination; if omitted, Ladybug applies its own default bound.
- Some Neo4j/openCypher clauses and functions are renamed or unsupported, such as
LOAD CSVbecoming Ladybug's broaderLOAD FROM, noFOREACH, noUSE, andlabel()instead oflabels(). - Ladybug's type system is closer to Postgres than Neo4j; list and map values are more strongly typed.
- Bulk loading is usually better expressed with Ladybug's native
COPY FROM/scan flow than many smallCREATEstatements.
Ladybug MVP Limitations
The Ladybug compiler is intentionally conservative. It validates the subset below and throws early for patterns that would be ambiguous or semantically different in Ladybug.
- Schemas are not generated yet. Define Ladybug node and relationship tables separately with
CREATE NODE TABLEandCREATE REL TABLE. - Node patterns may use at most one label. Ladybug's structured model treats labels as tables, while the generic DSL still allows Neo4j-style multi-label nodes.
CREATEandMERGEnode patterns must have an explicit node label. Relationship patterns must have an explicit relationship label.- Variable-length traversals must be bounded with
.hops(min, max). Unbounded traversals such as.hops(1)are rejected because Ladybug uses walk semantics and requires an upper bound for predictable termination. - Path semantics differ from Neo4j: Neo4j
MATCHuses trail semantics for relationships, while Ladybug uses walk semantics by default. The compiler does not rewrite queries to force Neo4j-equivalent trail behavior. - DDL, primary keys, relationship multiplicities, indexes, and constraints are outside this compiler. They should be managed by migration code or a future schema compiler.
REMOVE,FOREACH,CALL { ... }subqueries,USE,LOAD CSV, and Ladybug-specificLOAD FROM/COPY FROMare not represented in the current AST and are not emitted.- Bulk writes can use the existing
UNWINDDSL shape, but large Ladybug imports should prefer Ladybug's nativeCOPY FROMflow outside this MVP compiler.
Memory Executor
The memory executor runs the same AST against an in-memory graph. It is useful for tests, mocks, and checking DSL semantics without a database.
import { executeMemory, type MemoryGraph } from "graph-dsl";
const graph: MemoryGraph = {
nodes: [
{
id: "user-1",
labels: ["User"],
properties: { email: "[email protected]", name: "Ada" },
},
{
id: "post-1",
labels: ["Post"],
properties: { title: "Graph DSLs" },
},
],
edges: [
{
id: "edge-1",
label: "WROTE",
from: "user-1",
to: "post-1",
properties: {},
},
],
};
const rows = executeMemory(ast, graph, {
params: { email: "[email protected]" },
});executeMemory(...) mutates the graph for create, createEdge, merge, mergeEdge, set, setProps, and delete operations.
For traversal tests, executeMemory(...) supports bounded path patterns. Use .hops(min, max) with a finite max.
AST Shape
The AST is intentionally small:
type QueryAst = {
kind: "query";
clauses: Clause[];
};Supported clause kinds:
type Clause =
| UnwindClause
| MatchClause
| CreateClause
| MergeClause
| CreateEdgeClause
| MergeEdgeClause
| WhereClause
| ReturnClause
| SetPropertyClause
| OnCreateSetClause
| OnMatchSetClause
| DeleteClause;Supported pattern kinds:
type Pattern =
| NodePattern
| EdgePattern
| PathPattern;Return selections can project aliases, properties, or aggregates:
type ReturnSelection =
| AliasSelection
| PropertySelection
| AggregateSelection;You can inspect it directly:
console.log(JSON.stringify(ast, null, 2));Current Limitations
- Gremlin compiler is not implemented yet.
- Runtime schemas currently support
string,number, andbooleanfields. - Typed compile-time schema API is not implemented yet.
- Path/traversal patterns are read-only and can be used with
match(...);create(...)andmerge(...)reject them. - The memory executor requires
maxHopsfor traversal patterns. Cypher compilation can emit unbounded traversals such as*1... - Ladybug Cypher compilation requires bounded traversal patterns and rejects multi-label node patterns.
set(...),onCreateSet(...), andonMatchSet(...)update one property at a time; usesetProps(...),onCreateSetProps(...),onMatchSetProps(...), orsetMap(...)for broader patches.- The memory executor is intentionally small and not a full database; it is meant for tests, mocks, and semantic checks.
- Cypher support currently covers the portable MVP plus migration-focused primitives:
UNWIND,MATCH,OPTIONAL MATCH,WITH,CALL, variable-length path traversal,CREATE,MERGE, merge-specificON CREATE SET/ON MATCH SET, relationship-onlyCREATE/MERGEviacreateEdge(...)/mergeEdge(...),WHERE,RETURNwith aggregate projections, result controls,SET, map patchSET +=,DELETE, andDETACH DELETE. - Batch helpers are driver-neutral and sequential by default; there is no built-in Neo4j session/transaction adapter yet.
Roadmap
The next useful layer is a typed schema API:
const schema = defineGraph({
User: {
props: {
id: string(),
email: string(),
},
},
Post: {
props: {
id: string(),
title: string(),
},
},
WROTE: {
from: "User",
to: "Post",
},
});That schema can later make invalid traversals a TypeScript error:
user.out("WROTE", post); // ok
post.out("WROTE", user); // type error