Reverse scenario: exposing relational data in a graph using triggers

This commit is contained in:
Bart Broere committed 2026-10-21 19:05:48 +02:00
1 parent ede432db74
commit d705811c99
1 file changed
+106
+106
View File
@@ -185,6 +185,112 @@ ALTER TABLE neo4j."Movie"
PostgreSQL trusts the `IMMUTABLE` label without checking it, so the regular expression
is what makes it true. A view needs none of this.
## Exposing existing tables as a graph
AGE only queries its own label tables, so to query existing tables with Cypher, mirror
them into a graph with triggers. The graph then changes in the same transaction as the
tables. Take these tables:
```sql
CREATE TABLE public.customer (customer_id bigint PRIMARY KEY, name text, city text);
CREATE TABLE public.product (product_id bigint PRIMARY KEY, title text, price numeric);
CREATE TABLE public.purchase (
customer_id bigint REFERENCES public.customer,
product_id bigint REFERENCES public.product,
bought_at timestamptz,
quantity integer,
PRIMARY KEY (customer_id, product_id)
);
```
Create a graph with a label for each table. AGE needs `ag_catalog` in the `search_path`
to create them:
```sql
SET search_path = ag_catalog, "$user", public;
SELECT ag_catalog.create_graph('shop');
SELECT ag_catalog.create_vlabel('shop', 'Customer');
SELECT ag_catalog.create_vlabel('shop', 'Product');
SELECT ag_catalog.create_elabel('shop', 'BOUGHT');
```
A vertex id is a label id and a 48-bit number, so `_graphid` turns a primary key into a
vertex id. Edges can then be built straight from the foreign keys. Every change to a
row replaces its vertex:
```sql
CREATE FUNCTION public.customer_to_graph() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ag_catalog, pg_catalog
AS $$
BEGIN
IF TG_OP IN ('UPDATE', 'DELETE') THEN
DELETE FROM shop."Customer"
WHERE id = _graphid(_label_id('shop', 'Customer'), OLD.customer_id);
END IF;
IF TG_OP IN ('INSERT', 'UPDATE') THEN
INSERT INTO shop."Customer" (id, properties)
VALUES (_graphid(_label_id('shop', 'Customer'), NEW.customer_id),
agtype_build_map('customer_id', NEW.customer_id, 'name', NEW.name, 'city', NEW.city));
END IF;
RETURN NULL;
END
$$;
CREATE TRIGGER to_graph AFTER INSERT OR UPDATE OR DELETE ON public.customer
FOR EACH ROW EXECUTE FUNCTION public.customer_to_graph();
```
`public.product` gets the same trigger for `Product`. Edges keep the ids AGE gives
them and are found by their endpoints:
```sql
CREATE FUNCTION public.purchase_to_graph() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ag_catalog, pg_catalog
AS $$
BEGIN
IF TG_OP IN ('UPDATE', 'DELETE') THEN
DELETE FROM shop."BOUGHT"
WHERE start_id = _graphid(_label_id('shop', 'Customer'), OLD.customer_id)
AND end_id = _graphid(_label_id('shop', 'Product'), OLD.product_id);
END IF;
IF TG_OP IN ('INSERT', 'UPDATE') THEN
INSERT INTO shop."BOUGHT" (start_id, end_id, properties)
VALUES (_graphid(_label_id('shop', 'Customer'), NEW.customer_id),
_graphid(_label_id('shop', 'Product'), NEW.product_id),
agtype_build_map('bought_at', NEW.bought_at::text, 'quantity', NEW.quantity));
END IF;
RETURN NULL;
END
$$;
CREATE TRIGGER to_graph AFTER INSERT OR UPDATE OR DELETE ON public.purchase
FOR EACH ROW EXECUTE FUNCTION public.purchase_to_graph();
```
To copy the rows that are already there, update each of them once, vertices first:
```sql
UPDATE public.customer SET customer_id = customer_id;
UPDATE public.product SET product_id = product_id;
UPDATE public.purchase SET customer_id = customer_id;
```
This rewrites every row; for large tables, an `INSERT INTO shop."Customer" ... SELECT`
with the same expressions is faster. After that, `MATCH (c:Customer)-[:BOUGHT]->(p:Product)`
works on the `shop` database, including unlabelled and variable-length patterns. Some
things to keep in mind:
- This only works in one direction. AGE writes label tables without firing triggers, so
a Cypher `CREATE`, `SET` or `DELETE` on a mirrored label does not reach the tables,
and the next change to the row overwrites it. AGE does check table privileges, so
grant the users that log in through peo only `SELECT` on the mirrored label tables.
The triggers are `SECURITY DEFINER`, so relational writes still reach the graph.
Superusers bypass privileges.
- Vertex ids need a `bigint` primary key below 2<sup>48</sup>. Other keys, such as
composite keys or UUIDs, need a table that maps each key to a number.
- Updating a row in `public.purchase` gives its edge a new id; vertex ids never change.
- The data is stored twice, and every write to the tables also writes to the graph.
## Container images
The [`Containerfile`](Containerfile) builds two images: