From ede432db74088c9bc0efd806ac937e313b05dfab Mon Sep 17 00:00:00 2001 From: Bart Broere Date: Wed, 21 Oct 2026 14:27:11 +0200 Subject: [PATCH] Recommend (materialized) views for typed columns in the README Views keep the graph schema-free, can be changed or dropped, and accept any SQL expression, including the text to timestamptz cast that generated columns reject as not immutable. Keep generated columns as an alternative and explain why they need the ISO 8601 helper function. Co-Authored-By: Claude Opus 5.5 --- README.md | 90 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 64 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index df18e4c..374bd42 100644 --- a/README.md +++ b/README.md @@ -85,45 +85,84 @@ peo is configured through environment variables: AGE stores each node label and relationship type as a table in the graph's schema, with all properties in one `agtype` column, `properties`; `properties ->> 'key'::text` -returns a property as text. For typed columns, add them yourself, either as a view or -as a generated column: +returns a property as text. For typed columns, define a view on top of the label table: ```sql CREATE VIEW neo4j.movies AS - SELECT id, properties ->> 'title'::text AS title, (properties ->> 'released'::text)::integer AS released + SELECT id, + properties ->> 'title'::text AS title, + (properties ->> 'released'::text)::integer AS released FROM neo4j."Movie"; - -ALTER TABLE neo4j."Movie" - ADD COLUMN released integer GENERATED ALWAYS AS ((properties ->> 'released'::text)::integer) STORED; ``` -PostgreSQL keeps generated columns up to date on every Cypher `CREATE`, `MERGE`, `SET` -and `REMOVE`, and they can be indexed. Some things to keep in mind: +A plain view converts the properties every time it is read. To index the typed +columns, make it a materialized view and refresh it when the graph has changed; a +unique index on `id` lets the refresh run without blocking readers: -- A value that does not fit the column's type makes the Cypher write fail: with the - column above, `CREATE (:Movie {released: 'soon'})` is rejected. A view only fails when it - is read. -- AGE does not allow dropping columns from its label tables, so a generated column - cannot be removed again. -- Adding a stored generated column rewrites the table and locks it while doing so. +```sql +CREATE MATERIALIZED VIEW neo4j.movies AS + SELECT id, + properties ->> 'title'::text AS title, + (properties ->> 'released'::text)::integer AS released + FROM neo4j."Movie"; +CREATE UNIQUE INDEX ON neo4j.movies (id); +CREATE INDEX ON neo4j.movies (released); + +REFRESH MATERIALIZED VIEW CONCURRENTLY neo4j.movies; +``` + +Prefer (materialized) views over adding columns to the label tables themselves: + +- The graph stays schema-free, like a Neo4j graph. A property value that does not fit + the view's column type, such as `CREATE (:Movie {released: 'soon'})`, is still + written; the view fails when it is read, and a refresh fails and keeps the + materialized view's previous contents. +- A view can be changed or dropped at any time, while AGE does not allow dropping + columns from its label tables. +- A view can use any SQL expression. A generated column only accepts immutable + expressions, which rules out some conversions, see [Dates and times](#dates-and-times). +- Creating a view does not rewrite or lock the label table. ### Dates and times AGE has no date or time types, so store a point in time either as a Unix timestamp -(seconds since 1970-01-01 UTC) or as an ISO 8601 string, and convert it in SQL. A Unix -timestamp converts with the built-in `to_timestamp`, here to a `timestamp` in UTC: +(seconds since 1970-01-01 UTC) or as an ISO 8601 string with a UTC offset, and convert +it in the view: + +```sql +CREATE VIEW neo4j.movie_dates AS + SELECT id, + to_timestamp((properties ->> 'updated'::text)::bigint) AT TIME ZONE 'UTC' AS updated, + (properties ->> 'premiere'::text)::timestamptz AS premiere + FROM neo4j."Movie"; +``` + +Here `updated` is a Unix timestamp, such as `1729500000`, that becomes a `timestamp` in +UTC, and `premiere` is a string, such as `'1999-03-24T19:00:00-08:00'`, that becomes a +`timestamptz`. Casting a string without an offset to `timestamptz` reads it in the +`TimeZone` of the session that reads (or refreshes) the view, so always include one. + +### Generated columns + +If a typed column has to be in the label table itself, kept up to date on every Cypher +`CREATE`, `MERGE`, `SET` and `REMOVE`, add it as a stored generated column: ```sql ALTER TABLE neo4j."Movie" - ADD COLUMN updated timestamp GENERATED ALWAYS AS - (to_timestamp((properties ->> 'updated'::text)::bigint) AT TIME ZONE 'UTC') STORED; + ADD COLUMN released integer GENERATED ALWAYS AS ((properties ->> 'released'::text)::integer) STORED; ``` -A generated column only accepts immutable functions, and casting text to `timestamptz` -is not immutable, because the result depends on the `TimeZone` and `DateStyle` -settings. A view can use the cast as is, but a generated column needs a function that -only accepts strings with a UTC offset and parses them the same way whatever the -session settings are: +Unlike a view, this makes Cypher writes fail when a value does not fit the column's +type, the column cannot be removed again, and adding it rewrites and locks the table. + +Generated columns also only accept immutable expressions. The Unix timestamp +conversion above is immutable, but casting text to `timestamptz` is not: its result +depends on the `TimeZone` setting for strings without an offset, on `DateStyle` for +formats like `'01/02/2024'`, and on the clock for strings like `'now'`. So +`GENERATED ALWAYS AS ((properties ->> 'premiere'::text)::timestamptz)` is rejected with +`generation expression is not immutable`. It takes a function that only accepts strings +for which the cast always gives the same result, ISO 8601 with a UTC offset, and +declares itself immutable: ```sql CREATE FUNCTION public.iso8601_timestamptz(value text) RETURNS timestamptz @@ -143,9 +182,8 @@ ALTER TABLE neo4j."Movie" (public.iso8601_timestamptz(properties ->> 'premiere'::text)) STORED; ``` -With these columns, `CREATE (:Movie {title: 'The Matrix', updated: 1729500000, -premiere: '1999-03-24T19:00:00-08:00'})` fills in both, and a `premiere` without an -offset, such as `'1999-03-24T19:00:00'`, makes the write fail. +PostgreSQL trusts the `IMMUTABLE` label without checking it, so the regular expression +is what makes it true. A view needs none of this. ## Container images