From ad2dde4d2f64e9dfc29509e6400d90e155c4dd03 Mon Sep 17 00:00:00 2001 From: Bart Broere Date: Wed, 21 Oct 2026 14:12:07 +0200 Subject: [PATCH] Replace the age example in the README and document datetime columns Use the release year from Neo4j's Movies dataset for the typed column example, and show how to turn a Unix timestamp and an ISO 8601 string into timestamp and timestamptz generated columns. Co-Authored-By: Claude Opus 5.5 --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 125a972..df18e4c 100644 --- a/README.md +++ b/README.md @@ -89,25 +89,63 @@ returns a property as text. For typed columns, add them yourself, either as a vi as a generated column: ```sql -CREATE VIEW neo4j.people AS - SELECT id, properties ->> 'name'::text AS name, (properties ->> 'age'::text)::bigint AS age - FROM neo4j."Person"; +CREATE VIEW neo4j.movies AS + SELECT id, properties ->> 'title'::text AS title, (properties ->> 'released'::text)::integer AS released + FROM neo4j."Movie"; -ALTER TABLE neo4j."Person" - ADD COLUMN age bigint GENERATED ALWAYS AS ((properties ->> 'age'::text)::bigint) STORED; +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 value that does not fit the column's type makes the Cypher write fail: with the - column above, `CREATE (:Person {age: 'old'})` is rejected. A view only fails when it + 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. -- AGE has no date or time types, so store those as ISO 8601 strings and cast them in - SQL. + +### 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: + +```sql +ALTER TABLE neo4j."Movie" + ADD COLUMN updated timestamp GENERATED ALWAYS AS + (to_timestamp((properties ->> 'updated'::text)::bigint) AT TIME ZONE 'UTC') 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: + +```sql +CREATE FUNCTION public.iso8601_timestamptz(value text) RETURNS timestamptz + LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE + SET TimeZone = 'UTC' +AS $$ +BEGIN + IF value !~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)$' THEN + RAISE EXCEPTION 'not an ISO 8601 timestamp with a UTC offset: %', value; + END IF; + RETURN value::timestamptz; +END +$$; + +ALTER TABLE neo4j."Movie" + ADD COLUMN premiere timestamptz GENERATED ALWAYS AS + (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. ## Container images