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 <noreply@anthropic.com>
This commit is contained in:
Bart BroereandClaude Opus 5.5 committed 2026-10-21 14:27:11 +02:00
1 parent ad2dde4d2f
commit ede432db74
1 file changed
+64 -26
+64 -26
View File
@@ -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