Guide
HelixDB accepts SQL statements alongside native queries and Cypher. It translates
each statement into its Cypher profile
and runs it on the same graph, transactions, and indexes, so data written in one
language is visible to the others. A table is a node label, a column is a
property, and a join is a relationship. The translation reads no schema: it
follows fixed naming rules, which you can adjust with mappings.
The translation is a port of the SQL-to-Cypher translator in the
Neo4j JDBC driver. It covers SELECT,
INSERT, UPDATE, DELETE, and TRUNCATE. Everything in
What is supported runs, and everything it cannot translate
faithfully is rejected with a specific error before the statement reads or writes
anything.
SQL is served by Helix Cloud gateways, and by local servers, embedded databases,
and SDKs built from the repository checkout.
The CLI does not expose SQL yet.
Run a statement
Send one statement, with optional parameters, mappings, and a query name, toPOST /v2/sql. The server translates it and works out whether it reads or
writes. Parameters given as a list bind each ? in order.
columns and rectangular rows, with one
value per column in column order, as for
Cypher:
query is required; parameters, query_name, and mappings are optional. A
result column keeps its SQL name: an unqualified column is named after the
column, an alias after the alias, and any other entry after its text as written,
such as p.name or COUNT(*). Repeated names are numbered: SELECT 1, 1
returns the columns 1 and 1_2. Duplicate rows are kept unless the statement
uses DISTINCT or GROUP BY, row order is only defined by ORDER BY, and a
write without RETURNING succeeds with empty columns and rows.
Over HTTP
A local server has no authentication:403 for a statement that writes. The
HTTP API reference describes the
request, status codes, and errors in full. A local server’s gRPC API serves the
same bodies through ExecuteSql and ExplainSql.
Request options
The native request options apply to SQL too:
Rust and TypeScript set options on
request_builder() / requestBuilder() before
.sql(...); Go and Python pass them to SQL and sql. Embedded clients reject
request options.
Explain a statement
POST /v2/sql/explain takes the same body, translates the statement, and plans
the translated Cypher without running it, exactly like
explaining Cypher.
Explaining a write changes nothing. Planning checks parameters, so supply every
parameter the statement uses.
What is supported
Create and read a small graph:
Tables and columns
Labels and properties are case-sensitive and keep the case you write; table
names, aliases, and mapping keys are not. Schema prefixes such as
public.Person are ignored. With more than one table, every column needs a
table qualifier. Every table is a label, so a node without a label has no SQL
form.
A table named Start_TYPE_End, such as Person_ACTED_IN_Movie, is a
relationship table. On its own it matches
(_start:Person)-[person_acted_in_movie:ACTED_IN]->(_end:Movie). Its
unqualified columns are relationship properties, Person.name or _start.name
is a property of the start node, Movie.title or _end.title of the end node,
and person_name is shorthand for _start.name when only the start label is
Person, in reads, UPDATE, and INSERT alike; qualify a relationship property
whose name looks like shorthand with the type, as in ACTED_IN.person_count.
The start and end each need a
lowercase letter, so upper-case table names such as ORDER_LINE_ITEMS stay node
labels. When a FROM names several relationship tables, each one’s nodes are
<alias>_start and <alias>_end instead.
Joins
Joins become relationship patterns by these rules:- In a direct join, the second column of the
=names the relationship type, uppercased, and the relationship points away from the table it belongs to. Its variable is the lowercased type, numbered when a table already uses it, and columns can read it, as indirected.since. - In a chain of three tables, the middle one is the relationship; longer chains
continue in pairs, and the last
ONof each pair sets the direction. - Every
ONmust compare two columns with=. Put other conditions inWHERE. The compared columns only name the relationship and its direction; they are not read.
Mappings
mappings renames tables and join columns. Keys match case-insensitively:
(p:Person)-[directed:DIRECTED]->(m:Movie). A join_columns key
names the join’s left table and the column that names the type.
The SDKs take the same mappings: helix_db::SqlMappings in Rust,
{ tables, joinColumns } in TypeScript, helix.SQLMappings in Go, and the
mappings= dictionary in Python.
Statements
Grouping follows SQL: a selected column must be grouped or aggregated,
GROUP BY
without an aggregate returns each group once, an aggregate without GROUP BY
returns one row even for an empty table, SUM of no values is null, and
ORDER BY 1 or GROUP BY 1 refer to select-list positions. An alias in
GROUP BY, HAVING, or ORDER BY names its select-list entry.
INSERT into a relationship table connects existing nodes. Columns of the
start and end nodes find them, and the other columns become relationship
properties:
_start.v$id and
_end.v$id, or v$person_id when the label tells the ends apart.
Expressions and functions
Comparisons,AND, OR, NOT, IS [NOT] NULL, IS [NOT] TRUE,
IS [NOT] DISTINCT FROM, IN lists, BETWEEN, arithmetic, CASE, CAST, and
row values such as (a, b) < (1, 2) translate with SQL semantics. || and
CONCAT join their operands as strings; || returns null when an operand is
null, and CONCAT skips nulls, as in PostgreSQL. CAST uses Cypher’s
conversions: to an integer type it truncates toward zero, and text that does not
convert becomes null rather than an error.
LIKE and ILIKE translate when the pattern is a literal whose only wildcards
are a leading or trailing %: '%x%' becomes CONTAINS, 'x%' STARTS WITH,
and '%x' ENDS WITH.
Functions with a Cypher equivalent are renamed: lower and upper become
toLower and toUpper, length and char_length become size, nvl and
ifnull become coalesce, array_agg becomes collect (which, unlike SQL’s
array_agg, leaves out nulls), and nullif and
nvl2 become CASE. SUBSTRING counts from 1, as in SQL; a start below 1
must be a literal, since a computed one fails. TRIM, LTRIM, and RTRIM
remove all surrounding whitespace, as Cypher’s do, not only spaces. Other names
pass through, so the Cypher profile
decides which functions run.
What is not supported
These are rejected before anything runs, mostly with the error categoryunsupported_feature and the detail sql_construct:
Some fail as ordinary compile errors instead:
- An unqualified column in a statement with several tables is a
syntax_error(ambiguous_column), as is a qualifier that names several tables. - A selected column that is neither grouped nor aggregated is a
syntax_error(invalid_aggregation). - A function outside the Cypher profile,
such as
sqrt, reports that profile’s error.
Values and parameters
Values in parameters and results use the Cypher encoding: ordinary JSON for most values, tagged values for large integers, non-finite floats, nodes, relationships, and paths.
A statement that uses
? cannot also use numbered placeholders, since ? takes
those numbers. IN (?) binds a list parameter. Labels, relationship types, and
column names are part of the statement, so keep values in parameters.
Writes and transactions
Each statement that writes runs in one transaction, including its index changes, as a Cypher write does. If any part fails, nothing the statement wrote is kept.- A multi-row
INSERTcreates every row or none. Inserting into a relationship table that matches no node pair creates nothing and succeeds. UPDATEcomputes every new value from the row before the update, soSET a = b, b = aswaps. Setting a column toNULLremoves the property.DELETEorTRUNCATEof a node that still has relationships fails, like a foreign key. Delete the relationships first, or useTRUNCATE .. CASCADE, which removes them.- Stored properties follow the Cypher rules: scalars or lists of one scalar type, and values an index can store.
- One request is one statement, so a write followed by a read is two requests and two transactions.
- Do not retry a write automatically when its commit outcome is uncertain; see error handling.
Indexes and performance
SQL runs as the translated Cypher, so it uses existing native indexes the same way, without naming them. The Cypher rules apply to the translated statement:- An equality on a table’s column, such as
WHERE u.email = ?, can read an equality index, and so canINlists andORed equalities on one column. - A range such as
WHERE u.age >= 21 AND u.age < ?can read a range index. WHERE id(u) = ?, orWHERE u.v$id IN (1, 2)with a list of literal ids, reads those nodes directly. A list parameter, as inIN (?), scans the label.- A join reads only the relationship type it names, so name the relationship table or join column rather than joining every relationship.
- Without
DISTINCTor grouping,ORDER BY,OFFSET, andLIMITapply to the matched tables before the select list reads columns, soSELECT u.name FROM User u LIMIT 10stops after ten nodes when itsWHEREcannot fail. ORDER BYa range-indexed column in the index’s direction, with a range bound on it and aLIMIT, reads the index in order and stops after the window, as inSELECT u.name FROM User u WHERE u.rank >= 0 ORDER BY u.rank DESC LIMIT 10with a descending index onrank.
AND, OR, XOR, and
NOT combinations over literals, parameters, and columns qualify. Arithmetic and
function calls keep the label scan.
Limits
A SQL statement has the Cypher limits, plus 1,024 binary operators and about 500,000 tokens, where every word, number, symbol, and whitespace character counts, including those inside quoted text. Forms that repeat an operand, such asBETWEEN or an alias in GROUP BY, may
copy about 16 MB of it in total. Exceeding any of these fails with
resource_limit (HTTP 429). Send large values as parameters, which do not
count.
Errors
A failed statement returns the Cypher error body. Translation errors and the errors of the translated statement both report aspan, the byte
range of the SQL text that failed:
Next steps
Cypher
The Cypher profile that SQL translates to, and how it plans indexes.
HTTP API
Request headers, status codes, and the OpenAPI contract for
/v2/sql.Error handling
Error categories, HTTP statuses, and when to retry.
Secondary indexes
Create the indexes that SQL and Cypher plans read.