diff --git a/README.md b/README.md index 374bd42..c31188b 100644 --- a/README.md +++ b/README.md @@ -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 248. 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: