13 KiB
Features AGE does not support
Problem
The README lists "anything AGE does not support", with examples: multiple labels per node, pattern comprehensions, temporal and spatial types, indexes and constraints through Cypher. These are very different in size, so this plan treats each as a separate part that can be done (or dropped) on its own. In order of value per effort:
- D. Indexes and constraints: mostly translation to SQL DDL; high value.
- B. Pattern comprehensions: a generalisation of an existing rewrite.
- C. Temporal types: useful, but needs a convention for storing values.
- A. Multiple labels: invasive; needs a real parser.
- E. Spatial types: least demand; do only on request.
For all parts, boltage should keep failing clearly (FeatureNotSupported with a
message that names the feature) for whatever is still unsupported, rather than
passing AGE's sometimes cryptic errors through.
Where an approach depends on AGE behaviour that is not yet verified, the plan says
"spike": check it against the AGE version in the image (apache/age:release_PG18_1.8.0)
before building on it. Also check AGE's issue tracker for each part: if AGE adds the
feature itself, boltage only has to pass it through.
D. Indexes and constraints through Cypher
Today SHOW INDEXES and SHOW CONSTRAINTS return empty results
(boltage/backend.py _run_system), and CREATE INDEX reaches AGE, which rejects it.
Label tables are ordinary PostgreSQL tables, so indexes and constraints can be real
PostgreSQL indexes and constraints on them.
Commands to support
| Cypher | SQL |
|---|---|
CREATE INDEX [name] [IF NOT EXISTS] FOR (n:L) ON (n.p [, n.q]) |
CREATE INDEX name ON g."L" (<expr p> [, <expr q>]) |
CREATE INDEX ... FOR ()-[r:T]-() ON (r.p) |
same, on the edge table g."T" |
CREATE CONSTRAINT [name] FOR (n:L) REQUIRE n.p IS UNIQUE |
CREATE UNIQUE INDEX name ON g."L" (<expr p>) |
... REQUIRE (n.p, n.q) IS NODE KEY |
unique index on both, plus a CHECK that both exist |
... REQUIRE n.p IS NOT NULL |
ALTER TABLE g."L" ADD CONSTRAINT name CHECK (<p exists>) |
DROP INDEX name [IF EXISTS], DROP CONSTRAINT name [IF EXISTS] |
DROP INDEX / ALTER TABLE ... DROP CONSTRAINT |
SHOW INDEXES, SHOW CONSTRAINTS |
read pg_index / pg_constraint for the graph's schema |
Full-text, vector, point and TEXT indexes, and property type constraints, stay
unsupported with a clear error. RANGE indexes map to the default b-tree.
Steps
- Spike: the index expression. An index only helps if it matches the expression
AGE generates for
n.pin aWHERE. UseEXPLAINonSELECT * FROM cypher('g', $$ MATCH (n:L) WHERE n.p = 1 RETURN n $$) AS (n agtype)to find it (believed to beagtype_access_operator(VARIADIC ARRAY[properties, '"p"'::agtype])), and check that an index on it is used for=, range comparisons, and forMATCH (n:L {p: 1})(which may need a GIN index onpropertiesinstead). Record the findings in the code, since they are AGE implementation details. - Label creation.
CREATE INDEXon a label that does not exist yet creates it first withcreate_vlabel/create_elabel, as Neo4j allows indexes on labels with no nodes. - Names. Use the Cypher name as the PostgreSQL index/constraint name; generate
one like Neo4j does (
index_<hash>,constraint_<hash>) when none is given. Store the Cypher-level kind (RANGEindex,UNIQUENESSconstraint, ...) in aCOMMENT ON INDEXsoSHOWcan report it without guessing. SHOW INDEXES/SHOW CONSTRAINTS. Query the catalog for indexes and constraints on the graph's label tables, map table → label throughag_label, and parse the property name back out of the indexed expression (or the comment). Indexes AGE creates itself (onid,start_id,end_id) are shown as Neo4j's built-inLOOKUPindexes or hidden; decide after looking at what neo4j-browser does with them. Also fillindexes/constraintsindb.schema.visualization.- Errors. A unique violation from AGE's
CREATE/MERGEalready maps toConstraintValidationFailed(sqlstate 23,failure_from_pg); make the message name the constraint and property like Neo4j does. Creating a unique constraint on data that has duplicates fails in PostgreSQL; map it toNeo.ClientError.Schema.ConstraintCreationFailed. - Privileges. Only the table owner can create indexes; map the permission
error to
Neo.ClientError.Security.Forbidden. - Parsing: these commands have a fixed shape, so regexes in
_run_system(like the existingCREATE DATABASEhandling) are enough.
Tests
Smoke test: create an index and see it used (via EXPLAIN once plan 01 lands, or
via SHOW INDEXES); a unique constraint rejects a duplicate CREATE with
ConstraintError; IF NOT EXISTS / IF EXISTS; drop both.
B. Pattern comprehensions
[(a)-[r:T]->(b) WHERE b.x > 1 | b.name] is a list built from a sub-pattern.
boltage rewrites one specific neo4j-browser query (rewrite_for_age,
boltage/cypher.py:208); everything else fails in AGE.
Approach
Rewrite a pattern comprehension in clause C into an OPTIONAL MATCH plus
collect() before C, carrying the variables in scope:
MATCH (a) RETURN a.name, [(a)-->(b) | b.name] AS friends
becomes
MATCH (a)
OPTIONAL MATCH (a)-->(boltage_b1) WITH a, collect(boltage_b1.name) AS boltage_pc1
RETURN a.name, boltage_pc1 AS friends
- Find pattern comprehensions: a
[whose contents start with a pattern ((…)followed by a relationship) and contain a top-level|. List comprehensions ([x IN list | ...]) must not match. - Rename the comprehension's own new variables (here
b) so they cannot clash with outer variables. - Use the scope tracker from the shared groundwork to
list the variables to carry in the
WITH. - Each comprehension gets its own
OPTIONAL MATCH ... WITH ... collect()step, one after the other, so several comprehensions in one clause do not multiply rows. - A
WHEREinside the comprehension becomes theWHEREof theOPTIONAL MATCH. - Replace the existing neo4j-browser-specific regex with the general rewrite, and keep its smoke test.
Semantic differences to handle or document
collect()drops nulls, a comprehension keeps them:[(a)-->(b) | b.missing]gives[null, null]in Neo4j. Collect a wrapper (collect([b.missing])) and unwrap it with a list comprehension, if AGE supports list comprehensions (spike); otherwise document the difference.OPTIONAL MATCHwith no match yields one row with nulls, whichcollect()turns into[]: correct.- Comprehensions inside aggregations,
WHEREclauses,ORDER BY, or nested in another comprehension: support theWITH/RETURN/WHEREprojection positions first; reject the rest withFeatureNotSupported. - A
WITHthat carries the variables also ends the scope for aggregation in the original clause; whenCitself aggregates (RETURN a, count(*), [(a)-->(b)|b]), the rewrite must keep grouping keys intact. Reject that case at first.
Pattern expressions used as predicates (WHERE (a)-->(b)) and EXISTS { } /
COUNT { } subqueries are related; check which ones AGE accepts and extend the
same rewrite where it does not.
Tests
Unit tests for detection (pattern vs list comprehension, strings) and the rewrite
text; smoke tests comparing results with hand-written OPTIONAL MATCH equivalents.
C. Temporal types
AGE has no date, time or duration values. Neo4j clients send and receive them as
Bolt structures (Date, Time, LocalTime, DateTime, LocalDateTime,
Duration), which boltage's packstream does not handle: a temporal parameter
reaches literal() (boltage/cypher.py) as an unknown Structure and fails.
The README already recommends storing points in time as Unix timestamps or ISO 8601 strings with a UTC offset. The plan builds on that convention.
Phase 1: accept temporal parameters
- Unpack Bolt temporal structures in
packstream.pyinto Pythondate/time/datetimevalues and a small duration type:D(Date),T(Time),t(LocalTime),d(LocalDateTime),I/i(DateTime with offset / zone id, Bolt 5 and 4.4 with theutcpatch),F/f(the legacy DateTime variants of Bolt 4.4 without the patch) andE(Duration). literal()turns them into ISO 8601 strings (with offset for zoned values, the way the README recommends).Durationbecomes an ISO 8601 duration string (P1DT2H).- Document that the values come back as strings.
This alone makes drivers able to write temporal values, and the README's views and generated columns work on them.
Phase 2: temporal functions in Cypher
- Spike: AGE can call PostgreSQL functions from Cypher when they are
schema-qualified and take/return
agtype. Check this works, and its performance. - Ship a small SQL extension script (a
boltageschema withboltage.datetime(agtype),boltage.date(agtype),boltage.duration(agtype),boltage.datetime_truncate, ...) that returns normalised ISO 8601 strings, implemented with PostgreSQL'stimestamptzarithmetic. Install it from the container's init script; for other setups documentpsql -f. - Rewrite calls to
date(),datetime(),localdatetime(),time(),duration()andduration.between()in the Cypher text to the qualified functions (outside strings, usingscan()). Leavetimestamp(), which AGE has. - Property access such as
d.yearon a temporal value cannot be rewritten reliably, because boltage does not know which values are temporal. Support the function forms (date.truncate, ...) and document that accessors are not available.
ISO strings normalised to UTC compare and sort correctly as strings, so ORDER BY
and range filters keep working.
Phase 3 (optional): typed output
Returning strings means drivers give users str instead of datetime. To return
real temporal values, the type has to be stored with the value, for example as a
map {"__boltage_type": "datetime", "value": "..."}. That breaks string comparison
and the README's SQL views, so only do this behind a setting, if users ask for it.
Tests
Smoke test: write a datetime and a date parameter and read them back as ISO
strings; RETURN datetime('2024-01-02T03:04:05+01:00') returns the normalised UTC
string; ordering by a datetime property.
A. Multiple labels per node
In AGE a label is a table and a vertex lives in exactly one. Supporting
CREATE (:A:B), MATCH (n:B) where B is a second label, SET n:B and
REMOVE n:B needs boltage to keep extra labels elsewhere and rewrite queries.
Approach
- Keep the first label as the AGE label (the table), and extra labels in a reserved
property,
__labels(a list). - Rewrite:
CREATE (n:A:B {..})→CREATE (n:A {.., __labels: ['B']})MATCH (n:B)→MATCH (n) WHERE (label(n) = 'B' OR 'B' IN n.__labels)MATCH (n:A:B)→MATCH (n:A) WHERE 'B' IN n.__labelsn:Bas a predicate → the same expressionSET n:B→SET n.__labels = coalesce(n.__labels, []) + 'B'(whenBis not the table label);REMOVE n:Bsimilarly.
- Output:
agtype.parsemerges__labelsinto the node's labels and hides the property;keys(n),properties(n)andn {.*}must hide it too (rewrite to filter it out). - Catalog:
db.labels,SHOWand counts must include labels that only exist in__labels(needs a scan, or a GIN index onproperties).
Why it is last
Every rewrite needs to understand patterns, predicates and projections exactly;
regexes on masked text will not be correct enough. This needs a real Cypher parser
(for example generated from the openCypher grammar), which is a larger change than
everything else in these plans together. MATCH (n:B) for a secondary label also
turns into a scan of all vertices. Do this only if there is real demand, and look
at AGE's own plans for multiple labels first.
E. Spatial types
Same shape as temporal types: AGE has no point type, and Bolt has Point2D (X)
and Point3D (Y) structures.
- Unpack Bolt points and store them as maps
{srid, x, y[, z]}vialiteral(). - Optionally, behind a setting, pack maps with exactly those keys back into Bolt points, so neo4j-browser and drivers show points.
- Provide
point()andpoint.distance()asboltageschema functions (as in part C), using plain Euclidean distance for Cartesian SRIDs and haversine for WGS-84 (4326/4979).
Spatial indexes would need PostGIS and are out of scope.
README changes
Per part as it lands: replace the item in the Limitations bullet with a short section under "How it works" (indexes and constraints, pattern comprehension rewriting, how temporal values are stored).