Add generated SQL columns for graph properties

AGE keeps all properties in one agtype column and its Cypher writes fire
no triggers, so the bridge periodically calls a stored procedure that adds
a stored generated text column for every new property key. Only label
tables with inserts or updates since the previous run are scanned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Bart BroereandClaude Opus 5.5 committed 2026-10-14 14:26:49 +02:00
1 parent 04e1c32223
commit 7bd044ddb9
7 files changed
+225 -2

No files matched your search

+33 -1
View File
@@ -42,7 +42,8 @@ graph as a schema of the same name, with one table per label.
- **Databases are AGE graphs.** Every AGE graph shows up as a Neo4j database, and
`:use <name>` switches between them. A graph named `neo4j` is created on first start
(`PEO4C_DEFAULT_GRAPH`). `CREATE DATABASE x` and `DROP DATABASE x` on the `system`
database call `create_graph` / `drop_graph`; AGE requires names of at least 3 characters.
database call `create_graph` / `drop_graph`; AGE requires names of at least 3 characters,
and `peo4c` is reserved for the schema holding peo4c's SQL helpers.
- **Queries** are sent to AGE as `SELECT * FROM cypher('<graph>', $$ <query> $$) AS (...)`.
The bridge works out the result columns from the final `RETURN` clause and inlines
query parameters as Cypher literals.
@@ -56,6 +57,35 @@ graph as a schema of the same name, with one table per label.
- **Authentication** is passed straight through: every Bolt connection opens its own
PostgreSQL connection with the credentials you log in with.
## Querying the graph from SQL
AGE stores each node label and relationship type as a table in the graph's schema,
with all properties in one `agtype` column, `properties`. peo4c adds a generated
column for every property key it finds, so you can use plain SQL:
```sql
SELECT name, age FROM neo4j."Person" WHERE name LIKE 'A%';
CREATE INDEX ON neo4j."Person" (name);
```
AGE's Cypher writes do not fire PostgreSQL triggers, so new keys cannot be caught
the moment they are written. Instead the bridge calls
`CALL peo4c.sync_property_columns()` every few seconds
(`PEO4C_PROPERTY_COLUMNS_INTERVAL`), which you can also run yourself. It only scans
tables that had inserts or updates since its previous run, and adds all new keys of a
table with a single `ALTER TABLE`. Once a column exists, PostgreSQL keeps it in step
with every `CREATE`, `MERGE`, `SET` and `REMOVE`.
- Columns are `text`, the same as `properties->>'key'`: one key can hold values of
different types in different rows, and a failing cast would make writes fail. Cast
in your query, for example `age::int`. Lists and maps come out as JSON text.
- Keys that clash with an existing column (`id`, `properties`, `start_id`, `end_id`)
get no column. Use `properties->>'id'` for those.
- Adding a column rewrites the table, which locks it for the duration. On a large
table this blocks writes to that label for a while, but only once per new key.
- AGE does not allow dropping columns from label tables, so a column stays after its
key disappears from the data.
## Configuration
| Variable | Default | Purpose |
@@ -66,6 +96,8 @@ graph as a schema of the same name, with one table per label.
| `PEO4C_ADVERTISED_BOLT_PORT` | `7687` | Bolt port advertised to the browser; set it when you publish 7687 on another host port. |
| `PEO4C_ADVERTISED_HOST` | `localhost` | Host reported in `SHOW DATABASES` and to clients that do not send a routing address. |
| `PEO4C_LOG_LEVEL` | `INFO` | `DEBUG` logs every generated SQL statement. |
| `PEO4C_PROPERTY_COLUMNS` | `true` | Set to `false` to stop adding generated property columns. |
| `PEO4C_PROPERTY_COLUMNS_INTERVAL` | `5` | Seconds between checks for new property keys. |
| `PGADMIN_DEFAULT_EMAIL` | `admin@example.com` | pgAdmin login, created on first start. |
| `PGADMIN_DEFAULT_PASSWORD` | `$POSTGRES_PASSWORD` | pgAdmin login password, set on first start. |
| `PEO4C_PGADMIN` | `true` | Set to `false` to not start pgAdmin. |