Reverse scenario: exposing relational data in a graph using triggers
This commit is contained in:
1 parent
ede432db74
commit
d705811c99
1 file changed
+106
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user