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