Combining SQL and Cypher#
There is no bridge between SQL and Cypher in AgensGraph, because there is nothing to bridge. A graph is a set of PostgreSQL tables in the same database as your ordinary tables, and both languages compile to the same plan tree.
So the two mix by nesting: a Cypher query goes where SQL expects a table, and a SQL query goes where Cypher expects a value.
This page walks the crossing points, in the order you are likely to need them, and then the
one rule that surprises everybody: what a Cypher RETURN hands back.
Everything runs against the example dataset, which
includes an ordinary table, rating, that is not part of the graph.
Reading a label with plain SQL#
A label is a table, so no Cypher is needed to read one:
SELECT id, properties->>'title' AS title, properties->>'year' AS year
FROM moviekb.movie ORDER BY (properties->>'year')::int LIMIT 3;
id | title | year
------+----------------+------
3.8 | Blade Runner | 1982
3.15 | The Terminator | 1984
3.11 | Total Recall | 1990
Nothing needs enabling for that. Writing rows into a label with SQL does; see What is deliberately closed off. Reading is just reading.
Direct SQL is the right tool when the question is relational: counts per label, a join to a reference table on a property value, a window function over a property. It is the wrong tool for anything with a variable number of hops. The rest of this page covers those.
A Cypher query in a SQL FROM#
Put a Cypher query in parentheses where a SQL FROM expects a table, and give it an alias.
Its RETURN list becomes the columns:
SELECT * FROM (MATCH (m:movie) WHERE m.year > 2015 RETURN m.title AS title, m.year AS year) q
ORDER BY year;
title | year
----------------------------+------
"Blade Runner 2049" | 2017
"The Matrix Resurrections" | 2021
The alias is required:
SELECT count(*) FROM (MATCH (m:movie) RETURN m.title AS title);
ERROR: Cypher in FROM must have an alias
HINT: For example, FROM (...) [AS] foo.
Two more rules. Every column needs a name, because the outer query has to be able to refer to
it. So RETURN m.title without AS is refused. And the nested query must be a read: a write
clause inside a FROM is a syntax error.
You can nest more than one, and join them like any two subqueries:
SELECT d.name #>> '{}' AS director, a.name #>> '{}' AS actor
FROM (MATCH (p:person)-[:directed]->(:movie {title: 'The Matrix'}) RETURN p.name AS name) d
CROSS JOIN (MATCH (p:person)-[:acted_in]->(:movie {title: 'The Matrix'}) RETURN p.name AS name) a
ORDER BY director, actor LIMIT 4;
director | actor
-----------------+--------------------
Lana Wachowski | Carrie-Anne Moss
Lana Wachowski | Keanu Reeves
Lana Wachowski | Laurence Fishburne
Lilly Wachowski | Carrie-Anne Moss
Joining a graph to an ordinary table#
This is the reason most people come to this page. Ask the graph a graph question, then join its answer to relational data:
SELECT q.title #>> '{}' AS film, r.score
FROM (MATCH (:person {name: 'Christopher Nolan'})-[:directed]->(m:movie) RETURN m.title AS title) q
JOIN rating r ON r.title = q.title #>> '{}'
WHERE r.source = 'critics'
ORDER BY r.score DESC;
film | score
--------------+-------
Inception | 8.8
Interstellar | 8.7
The #>> '{}' on both sides is the whole trick, and the next
section explains it. Without it you are comparing a jsonb
string against a text column, which does not type-check.
A graph and a table have no foreign keys between them, so the join key is a value: a title, a code, an external id. If you control the model, store that id as a property and index it.
If the join is hot, promote it to a column: a text column joins to a text column with no
casting at all. See Promoting a property to a column.
A Cypher query in a CTE#
A WITH clause takes a Cypher query as easily as a SELECT. That gives you a name you can
reference more than once:
WITH films AS (MATCH (m:movie) WHERE m.year > 2010 RETURN m.title AS title, m.year AS year)
SELECT f.title #>> '{}' AS title, r.score
FROM films f JOIN rating r ON r.title = f.title #>> '{}'
WHERE r.source = 'critics' ORDER BY r.score DESC;
title | score
--------------------------+-------
Interstellar | 8.7
Blade Runner 2049 | 8.0
The Avengers | 8.0
Elysium | 6.6
The Matrix Resurrections | 5.7
Note which language the statement is in. A statement beginning with WITH cte AS (...) is
SQL, and its body has to be SQL. This is not Cypher’s WITH, which is a pipeline divider and
cannot name a subquery. See How they
differ.
The pattern generalises to aggregating a traversal relationally. Here Cypher counts each film’s cast and SQL averages the scores:
WITH cast_size AS (
MATCH (p:person)-[:acted_in]->(m:movie) RETURN m.title AS title, count(p) AS players
)
SELECT c.title #>> '{}' AS film, c.players #>> '{}' AS players, round(avg(r.score), 2) AS mean_score
FROM cast_size c JOIN rating r ON r.title = c.title #>> '{}'
GROUP BY 1, 2 ORDER BY 3 DESC LIMIT 4;
film | players | mean_score
----------------------------+---------+------------
The Matrix | 3 | 8.90
Inception | 1 | 8.90
Interstellar | 1 | 8.65
Terminator 2: Judgment Day | 2 | 8.60
SQL inside a Cypher query#
The nesting works in the other direction too. A SQL scalar subquery is an expression, so it
goes anywhere Cypher wants one: in a WHERE, in a RETURN, in a property value being
written:
MATCH (m:movie)
WHERE m.title = (SELECT title FROM rating WHERE source = 'critics' ORDER BY score DESC LIMIT 1)
RETURN m.title AS title, m.year AS year;
title | year
-------------+------
"Inception" | 2010
The subquery must return one row and one column, as in SQL. No cast was needed here: the
subquery’s text was compared against a jsonb property, and the comparison resolved. The
other direction is the one that needs #>> '{}': a jsonb property into a text column.
A user-defined function is an expression as well, so a SQL function can be called from a
Cypher RETURN:
CREATE FUNCTION decade(y int) RETURNS int AS 'SELECT (y / 10) * 10' LANGUAGE sql IMMUTABLE;
MATCH (m:movie) RETURN decade(m.year::int) AS decade, count(*) AS films ORDER BY decade;
decade | films
--------+-------
1980 | 2
1990 | 6
2000 | 4
2010 | 5
2020 | 1
The ::int on m.year turns the jsonb property into an argument the function’s signature
accepts. Any language works: PL/pgSQL, PL/Python, C. So does any aggregate or window
function, because Cypher’s aggregates are PostgreSQL’s.
Getting a plain value out#
Anything a Cypher RETURN derives from a property comes back as jsonb. That is not a
display quirk; it is the column’s type. Casting or unwrapping inside the query does not
change it, because the projection re-boxes the result.
This is the single most common source of confusion at this boundary, so here it is from every angle.
Not every column is boxed, only the property-derived ones. Checking the outer types of a Cypher subquery’s columns shows the rule exactly:
SELECT pg_typeof(raw) AS raw, pg_typeof(cast_text) AS cast_text,
pg_typeof(unwrapped) AS unwrapped, pg_typeof(is_modern) AS boolean_expr,
pg_typeof(gid) AS gid, pg_typeof(v) AS vertex
FROM (MATCH (m:movie {title: 'The Matrix'})
RETURN m.title AS raw, m.title::text AS cast_text, m.title #>> '{}' AS unwrapped,
m.year > 1990 AS is_modern, id(m) AS gid, m AS v) q;
raw | cast_text | unwrapped | boolean_expr | gid | vertex
-------+-----------+-----------+--------------+---------+--------
jsonb | jsonb | jsonb | boolean | graphid | vertex
So a boolean expression stays boolean, id(m) stays graphid, and a graph element stays
vertex, edge or graphpath. The three property-derived columns arrive as jsonb. That
includes the one that was cast to text and the one that was already unwrapped.
A string property comes back JSON-quoted:
MATCH (m:movie {title: 'The Matrix'}) RETURN m.title AS t;
t
--------------
"The Matrix"
Casting inside the Cypher query does not help, because the outer projection re-boxes the result:
MATCH (m:movie {title: 'The Matrix'}) RETURN m.title::text AS t;
t
--------------
"The Matrix"
SELECT pg_typeof(t) AS type FROM (MATCH (m:movie {title: 'The Matrix'}) RETURN m.title::text AS t) q;
type
-------
jsonb
Neither does extracting the text with #>> '{}' inside Cypher, for the same reason:
MATCH (m:movie {title: 'The Matrix'}) RETURN m.title #>> '{}' AS t;
t
--------------
"The Matrix"
The way out is to do the unwrapping in SQL, one level up. Wrap the Cypher query and apply
#>> '{}' outside it. That operator is jsonb’s “extract the value at the empty path as
text”:
SELECT t #>> '{}' AS t FROM (MATCH (m:movie {title: 'The Matrix'}) RETURN m.title AS t) q;
t
------------
The Matrix
SELECT pg_typeof(t #>> '{}') AS type FROM (MATCH (m:movie {title: 'The Matrix'}) RETURN m.title AS t) q;
type
------
text
From there the value behaves like any text: compare it to a column, pass it to a function,
cast it onwards with (q.year #>> '{}')::int for a number.
Three notes on this rule.
It applies to the outermost projection only. A property read within a Cypher expression
keeps its type all the way through. So decade(m.year::int) above works.
A promoted property is the exception. A property with a typed column behind it comes back
as that column’s type, not as jsonb. So RETURN m.year on a promoted year is already an
integer and needs no unwrapping. See A promoted read has the column’s
type.
A vertex or an edge comes back as its own composite type, not as jsonb, and SQL can take it apart with field notation:
SELECT pg_typeof(m) AS type, (m).id AS id, (m).properties->>'title' AS title
FROM (MATCH (m:movie {title: 'The Matrix'}) RETURN m) q;
type | id | title
--------+-----+------------
vertex | 3.1 | The Matrix
The parentheses around m are SQL’s, required whenever you read a field of a composite
column.
A view over a Cypher query#
A view cannot begin with a Cypher clause:
CREATE VIEW film_rating AS MATCH (m:movie) RETURN m.title AS title;
ERROR: syntax error at or near "MATCH"
Wrap it in a SELECT and it works. So a graph traversal can be published as a relational
view for anything that speaks SQL: a BI tool, an ORM, a foreign data wrapper:
CREATE VIEW film_rating AS
SELECT q.title #>> '{}' AS title, (q.year #>> '{}')::int AS year, r.score
FROM (MATCH (m:movie) RETURN m.title AS title, m.year AS year) q
JOIN rating r ON r.title = q.title #>> '{}'
WHERE r.source = 'critics';
SELECT * FROM film_rating ORDER BY score DESC LIMIT 4;
title | year | score
----------------------------+------+-------
Inception | 2010 | 8.8
Interstellar | 2014 | 8.7
The Matrix | 1999 | 8.7
Terminator 2: Judgment Day | 1991 | 8.6
The view is an ordinary view, so it lands in the SQL search path rather than in the graph.
graph_path says which graph a label name belongs to; it says nothing about where a view goes:
SELECT schemaname, viewname FROM pg_views WHERE viewname = 'film_rating';
schemaname | viewname
------------+-------------
public | film_rating
What the server stored#
A view holds a parsed query tree, not your text. Ask for the definition back and you get the lowered SQL the Cypher compiled to, not the Cypher you wrote:
SELECT pg_get_viewdef('public.film_rating'::regclass, true);
pg_get_viewdef
-----------------------------------------------------------------------
SELECT q.title #>> '{}'::text[] AS title, +
(q.year #>> '{}'::text[])::integer AS year, +
r.score +
FROM ( SELECT (_agens_default_s.m).properties.'title' AS title, +
(_agens_default_s.m).properties.'year' AS year +
FROM ( SELECT ROW(m.id, m.properties, m.ctid)::vertex AS m+
FROM moviekb.movie m) _agens_default_s) q +
JOIN rating r ON r.title = (q.title #>> '{}'::text[]) +
WHERE r.source = 'critics'::text;
That is worth reading once, because it is the clearest possible statement of what a Cypher
query is on this engine. MATCH (m:movie) became a scan of moviekb.movie whose rows are
assembled into a vertex value. m.title became (m).properties.'title'. The names
beginning _agens_default_s are the generated aliases for the pipeline’s stages.
Two consequences follow.
Your Cypher is not recoverable from the catalog. Keep the source of a view under version control, the way you would any other schema object. This is not specific to views. Anything built on a stored query tree records the lowered form: a rule, a materialised view.
It round-trips. pg_dump emits that same lowered SQL, and restoring it rebuilds a working
view. The definition after a dump and restore into a fresh database is identical, character
for character, and the view returns the same rows.
So a view over a Cypher query is safe to rely on operationally, even though it does not read back as you wrote it.
One thing to watch: if the Cypher resolved a property to a promoted column, the view holds a dependency on that column. Dropping the column is refused until the view goes. That is deliberate, because the view would otherwise silently change meaning.
Clean up#
DROP VIEW film_rating;
DROP FUNCTION decade(int);
Where to go next#
How a graph is stored — why any of this works, and the two gates on writing to a label with SQL.
Composing a query — Cypher’s own pipeline, and where its
WITHdiffers from a CTE.Loading data —
LOAD FROM, which reads an ordinary table into a graph.Promoting a property to a column — for a join key you use constantly.