Skip to content

Support pattern expressions as boolean expressions#2360

Merged
jrgemignani merged 5 commits into
apache:masterfrom
gregfelice:feature_pattern_in_where
Jun 22, 2026
Merged

Support pattern expressions as boolean expressions#2360
jrgemignani merged 5 commits into
apache:masterfrom
gregfelice:feature_pattern_in_where

Conversation

@gregfelice

@gregfelice gregfelice commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds support for openCypher pattern expressions as general boolean expressions. Pattern expressions are now accepted anywhere a regular expression is valid — in WHERE, RETURN, WITH, SET, CASE, and as operands of boolean combinators — not just in WHERE.

Note on scope: An earlier version of this PR was titled "Support pattern expressions in WHERE clause via GLR parser." While implementing, Copilot correctly pointed out that adding anonymous_path to expr_atom actually enables pattern expressions in every expression context, not only WHERE. Rather than restrict the grammar with a WHERE-specific nonterminal, this PR now explicitly embraces the broader surface area — matching what openCypher defines and what Neo4j users expect — and covers the new contexts with regression tests. The PR title and description have been updated accordingly.

// WHERE (original motivation — still works)
MATCH (a:Person), (b:Person)
WHERE (a)-[:KNOWS]->(b)
RETURN a.name, b.name

// NOT pattern expression in WHERE
MATCH (a:Person)
WHERE NOT (a)-[:KNOWS]->(:Person)
RETURN a.name

// RETURN — project a boolean
MATCH (a:Person)
RETURN a.name, (a)-[:KNOWS]->(:Person) AS knows_someone

// CASE WHEN
MATCH (a:Person)
RETURN a.name,
       CASE WHEN (a)-[:KNOWS]->(:Person) THEN 'social' ELSE 'loner' END

// Combined with AND / OR
MATCH (a:Person)
RETURN a.name,
       (a)-[:KNOWS]->(:Person) AND (a)-[:WORKS_WITH]->(:Person) AS has_both

// SET — persist a computed boolean as a property
MATCH (a:Person)
SET a.is_social = (a)-[:KNOWS]->(:Person)
RETURN a.name, a.is_social

// WITH pipeline
MATCH (a:Person)
WITH a.name AS name, (a)-[:KNOWS]->(:Person) AS knows
WHERE knows
RETURN name

Implementation

Grammar (cypher_gram.y)

  • Switched the parser to GLR mode (%glr-parser) to handle the inherent ambiguity between parenthesized expressions and graph patterns. Both start with (, so a single-token lookahead cannot decide which production applies until several tokens later. GLR forks at the conflict point and discards the failing alternative.
  • Added anonymous_path to expr_atom with a %dprec 1 annotation. A bare (a) prefers the expression-variable interpretation (%dprec 2 on '(' expr ')'), so single-node pattern expressions still resolve as plain variable references.
  • Added make_exists_pattern_sublink() to wrap the pattern in an EXISTS subquery — a bare pattern (a)-[:KNOWS]->(b) in expression context is semantically equivalent to EXISTS((a)-[:KNOWS]->(b)).
  • Added a helper make_explain_stmt() and placed it immediately above its docstring comment so the grammar file stays readable.
  • Replaced (rhs)[1] with YYRHSLOC(rhs, 1) in the YYLLOC_DEFAULT macro — a required GLR-correctness fix, not stylistic. GLR's rhs stack has a different layout than LALR(1)'s; the raw index lands on the wrong slot under GLR and produces incorrect source locations.
  • Conflict budget pinned with %expect/%expect-rr. GLR produces 7 shift/reduce and 3 reduce/reduce conflicts (all arising from the ( ambiguity between path_node and parenthesized expr, plus expr_var/var_name_opt overlap on )/}/=). These are handled correctly at runtime by GLR + %dprec. The grammar declares %expect 7 and %expect-rr 3 so any deviation up or down fails the build and forces an audit of the new conflicts. The Makefile keeps -Werror and scopes the relaxation to -Wno-error=conflicts-sr -Wno-error=conflicts-rr, so other Bison warning categories (unused rules, undeclared types, etc.) still fail the build. Earlier rounds of this PR tried %expect-less variants for "Bison version portability," but Bison's %expect is exact-match — so the right answer is to pin the totals and update them deliberately if a future Bison reports different counts, not to remove the alarm.

Parser (cypher_analyze.c, cypher_clause.c)

  • Existing EXISTS sublink handling already covers the pattern case — no new transform code needed on the analyze/clause side.

Files changed

File Change
src/backend/parser/cypher_gram.y %glr-parser, %dprec annotations, anonymous_path in expr_atom, make_exists_pattern_sublink(), GLR comment block explaining why conflicts are expected
Makefile BISONFLAGS += -Werror -Wno-error=conflicts-sr -Wno-error=conflicts-rr — keep -Werror for other warning categories, scope relaxation to the two known-noise conflict categories; register pattern_expression regression test
regress/sql/pattern_expression.sql New regression suite: WHERE, NOT, nested, multi-hop, RETURN projection, mixed projections, CASE WHEN, AND/OR combinators, SET, WITH pipeline, regression guards for parenthesized arithmetic
regress/expected/pattern_expression.out Expected output for above

Test plan

  • Basic pattern in WHERE: WHERE (a)-[:KNOWS]->(b)
  • NOT pattern in WHERE: WHERE NOT (a)-[:KNOWS]->(:Person)
  • Pattern in WHERE with label: WHERE (a)-[:KNOWS]->(:Person)
  • Pattern via EXISTS subquery (original syntax still works)
  • Pattern in RETURN with AS alias → boolean column
  • Pattern in RETURN without alias (positional column)
  • Multiple pattern expressions in same RETURN
  • Pattern in CASE WHEN ... THEN ... ELSE ... END
  • Pattern combined with AND and OR in RETURN
  • Pattern in SET a.flag = (a)-[:R]->(:L)
  • Pattern in WITH projection + subsequent WHERE filter
  • Regression guards: RETURN (1 + 2), RETURN (n.name) — parenthesized expressions still work
  • All 32 regression tests pass (31 existing + pattern_expression)
  • Build is clean under -Werror with %expect 7 %expect-rr 3 pinned (Bison 3.8.2); any conflict-count drift now fails the build deliberately

Implementation notes (added during review)

  • YYRHSLOC macro change is a required GLR correctness fix, not cosmetic. GLR uses a different RHS stack layout than LALR(1); the previous (rhs)[1] indexing reads the wrong slot under GLR. The macro update is load-bearing for correct error locations under %glr-parser.
  • Pattern expressions in projection are EXISTS-sublink-per-row. A bare pattern in a RETURN/WITH/SET projection (e.g. RETURN a.name, (a)-[:KNOWS]->(:Person)) desugars to a correlated EXISTS sublink evaluated once per outer row — O(rows × subquery cost). This is the correct openCypher semantics, but distinct from a pattern in WHERE, where the planner may pull the sublink up into a semi-join. Regression tests use tiny datasets so this is invisible; on large data, projection-context pattern expressions cost more than WHERE-context ones.

Known limitation (pre-existing, tracked separately)

  • Single-node labeled pattern expressions (a:Label) do not filter by label. Because this PR lets a bare pattern appear as an expression anywhere, (a:Label) is now syntactically accepted as a boolean (e.g. WHERE (a:Person) or RETURN (a:Person)). It does not behave as an openCypher label predicate: the single-vertex pattern desugars to an EXISTS subquery with no label constraint and so is trivially true. Root cause is pre-existing in the transform layer — make_path_join_quals() (src/backend/parser/cypher_clause.c:5454) early-returns for vertex-only patterns (list_length(entities) < 3), skipping the label-filter quals that are only emitted for relationship patterns. This is not introduced by this PR (the code is byte-identical on master, and this PR touches no transform code); the grammar change merely makes the limitation reachable via a new surface. Relationship patterns — the feature this PR targets — correlate and filter correctly. Tracked as a follow-up; use a relationship pattern or label(a) = 'Label' for label checks in the meantime.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds openCypher-compatible “pattern expressions” as boolean predicates in WHERE by resolving the (a) ambiguity (parenthesized expression vs node pattern) using Bison GLR parsing.

Changes:

  • Switch cypher_gram.y to %glr-parser, add %dprec annotations, and introduce make_exists_pattern_sublink() to share EXISTS(pattern) wrapping logic.
  • Allow anonymous_path to appear as an expr_atom, translating bare patterns into an EXISTS sublink boolean.
  • Add regression coverage for pattern expressions (regress/sql + regress/expected) and register the new test in REGRESS.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
src/backend/parser/cypher_gram.y Enables bare pattern parsing in expression context via GLR + %dprec, refactors EXISTS wrapping into a helper.
regress/sql/pattern_expression.sql New regression test cases covering pattern expressions in WHERE and boolean combinations.
regress/expected/pattern_expression.out Expected output for the new regression test.
Makefile Adds pattern_expression to the regression test run list.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/backend/parser/cypher_gram.y Outdated
Comment thread src/backend/parser/cypher_gram.y
Comment thread regress/sql/pattern_expression.sql
@jrgemignani

Copy link
Copy Markdown
Contributor

@gregfelice Please see the above comments by Copilot

@gregfelice

Copy link
Copy Markdown
Contributor Author

Addressed all 3 Copilot suggestions:

  1. Misplaced comment — Moved "Helper function to create an ExplainStmt node" from above make_exists_pattern_sublink() to directly above make_explain_stmt() where it belongs.

  2. %expect/%expect-rr documentation — Added block comment explaining the conflict budget: 7 shift/reduce from path extension vs arithmetic operators on -/<, 3 reduce/reduce from expr_var vs var_name_opt on )/}/=. All resolved by GLR forking + %dprec annotations. Noted to update counts if grammar rules change.

  3. Misleading test comment — Changed "Regular expressions still work" to "Regular (non-pattern) expressions still work" to avoid confusion with regex.

Regression test passes (pattern_expression: ok).

@jrgemignani

Copy link
Copy Markdown
Contributor

@gregfelice Did you push them?

@gregfelice

Copy link
Copy Markdown
Contributor Author

@jrgemignani Pushed now — sorry about that! All 3 Copilot suggestions addressed in commit 5d11080.

And noted on the workflow — I'll reply directly to Copilot's comments going forward instead of posting standalone. Thanks for the heads up.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/backend/parser/cypher_gram.y
Comment thread src/backend/parser/cypher_gram.y
@jrgemignani

Copy link
Copy Markdown
Contributor

@gregfelice Please see Copilot above

@gregfelice

gregfelice commented Apr 6, 2026 via email

Copy link
Copy Markdown
Contributor Author

@gregfelice gregfelice changed the title Support pattern expressions in WHERE clause via GLR parser Support pattern expressions as boolean expressions Apr 15, 2026
@gregfelice

Copy link
Copy Markdown
Contributor Author

@jrgemignani — both Copilot items addressed and threads resolved. The %expect concern is moot (we use -Wno-conflicts-sr -Wno-conflicts-rr via BISONFLAGS instead), and the broader pattern-expression surface area is intentional, documented in the PR description, and covered by regression tests for WHERE, RETURN, CASE WHEN, SET, WITH, and boolean combinators. Ready for re-review. Thanks!

@gregfelice gregfelice force-pushed the feature_pattern_in_where branch from d05092d to 908adc6 Compare April 20, 2026 14:50
@gregfelice

Copy link
Copy Markdown
Contributor Author

Rebased onto current master (new HEAD 908adc61) and force-pushed. Conflicts were all alphabetical-merge reconciliation with #2359: Makefile regression-test list, helper function declarations in cypher_gram.y, and the predicate-function body placement. Dropped one duplicate /* Helper function to create an ExplainStmt node */ comment that came along with the HEAD side of the conflict. No logic changes from the rebase.

Local PG18-cassert installcheck is green on all 34 AGE tests including the new pattern_expression and upstream's new predicate_functions (only pgvector fails, environmental — extension not installed on my cassert PG).

CI on the new SHA is in action_required pending maintainer approval. @MuhammadTahaNaveed / @jrgemignani — would appreciate a kick when convenient. Thanks!

@gregfelice gregfelice force-pushed the feature_pattern_in_where branch from 908adc6 to 61426cf Compare April 23, 2026 17:59
@gregfelice

Copy link
Copy Markdown
Contributor Author

Rebased onto current master (new HEAD 61426cf0) and force-pushed to clear mergeability drift. Three commits replay on top of master (84e29542). Only conflict was the Makefile bison-flags line: master still carries -Werror, this branch replaces it with -Wno-conflicts-sr -Wno-conflicts-rr plus the explanatory comment block (the whole reason for the PR, since pattern-expression grammar introduces inherent GLR conflicts). No logic changes from the rebase.

Local PG18-cassert installcheck green on all 34 AGE tests including pattern_expression (51 ms).

CI on the new SHA will sit in action_required pending maintainer approval — would you mind kicking it when you have a moment? Thanks.

@jrgemignani

Copy link
Copy Markdown
Contributor

@gregfelice

From Opus 4.7 -

Deep review: PR #2360 — Pattern expressions as boolean expressions

I read the PR description and the actual diff (cypher_gram.y, Makefile, regression test). Status: 3 commits, force-pushed onto current master (84e29542), Copilot's 3+2+0 rounds resolved, jrgemignani approved. Here's my analysis.

What it does (correctly)

The change is fundamentally sound:

  1. Switches cypher_gram.y to %glr-parser. Right call — the (a) ambiguity (parenthesized expr vs. node pattern) genuinely is unresolvable at one-token lookahead. LALR(1) can't see far enough to know whether ( introduces (1+2) or (a)-[:R]->(b). GLR is the textbook tool.
  2. Adds anonymous_path %dprec 1 to expr_atom, alongside the existing '(' expr ')' %dprec 2. Bare (a) tied to both productions — %dprec 2 > %dprec 1 correctly disambiguates to expression-variable.
  3. Refactors EXISTS-wrapping into make_exists_pattern_sublink() — and re-uses it for both the existing EXISTS(pattern) production and the new bare-pattern production. The refactor is byte-identical to what the inline code did (same cypher_sub_pattern, CSP_EXISTS, EXISTS_SUBLINK, node_to_agtype(..., "boolean", ...)). Zero semantic change for the existing EXISTS path. ✓
  4. YYRHSLOC(rhs, 1) macro fix — silently correct and necessary. GLR has a different rhs stack layout than LALR(1); (rhs)[1] indexes the wrong slot under GLR. This isn't mentioned in the description but it's a real correctness fix the PR depends on.
  5. Tests cover meaningful surface area — WHERE, NOT, AND/OR, RETURN (alias + positional), mixed projections, CASE WHEN, SET, WITH pipeline, backward-compat with EXISTS(...). Good coverage of the new contexts.

Concerns worth raising

1. Removing -Werror is heavier than needed

The Makefile change is:

- src/backend/parser/cypher_gram.c: BISONFLAGS += --defines=... -Werror
+ src/backend/parser/cypher_gram.c: BISONFLAGS += --defines=... -Wno-conflicts-sr -Wno-conflicts-rr

This drops -Werror entirely, not just the two conflict categories. Any other Bison warning (deprecated %name-prefix=, undeclared types, unused rules, etc.) will silently be ignored going forward.

Suggested: keep -Werror, add only the two no-error opts:

BISONFLAGS += --defines=... -Werror -Wno-error=conflicts-sr -Wno-error=conflicts-rr

Bison 3.4+ supports -Wno-error=<category>. Keeps the safety net on everything else.

2. No %expect budget at all is fragile

The description's argument — "exact counts can drift across Bison versions" — is correct. But going to "any number of conflicts is fine" loses the alarm bell that catches new, unintentional conflicts a future grammar change might introduce. A new rule that adds 12 more conflicts would silently slip in.

Suggested: %expect 7 / %expect-rr 3 with a code comment "expected to drift on Bison upgrades; update as needed." This is the standard PostgreSQL approach in gram.y (which has %expect 0 and updates when needed). The point of %expect is not to pin a version, it's to fail loudly when unexpected conflicts appear. If the worry is distro drift, the right answer is a wider %expect value (e.g., %expect 12 %expect-rr 5) — generous enough to absorb Bison-version churn, tight enough to flag a new grammar bug.

3. GLR runtime cost on the parse hot path

GLR forks parse stacks at every conflict point. On ambiguous prefixes it does several full reductions before discarding losers. AGE parses Cypher on every single cypher() call, often inside tight per-row contexts (UDF arguments, prepared statements re-planned, etc.). I don't see any parse-time benchmark in the PR.

Concretely: a MATCH (a) query enters the ( ambiguity at character 7, and GLR will explore both '(' expr ')' and anonymous_path until it sees the closing ) and what follows. For short read-only queries, parser time is non-trivial relative to plan+execute.

Recommended diligence before merge:

  • Microbench: pgbench -f script.sql -T 30 -c 4 running 1000× of typical Cypher query (MATCH (a:Person {id: $1}) RETURN a) on LALR build vs. GLR build. Look for backend CPU% in parse.
  • Spot-check EXPLAIN (ANALYZE, BUFFERS) SELECT cypher(...) planning time, before/after.

If parse time regresses meaningfully (say >5%) on simple queries, this is worth knowing and documenting, even if it's accepted as the cost of the feature. If it's noise (<1%), it's a non-issue.

4. Pattern expression semantics — coverage gaps

The grammar change makes anonymous_path legal anywhere an expr_atom is. That opens the door wider than the tests probe:

  • Single-node labeled patterns in expression position: RETURN (a:Person) is, in openCypher, the pattern "does a have label Person?" — a boolean. Under this PR, (a:Person) would be parsed by GLR — does it pick the (expr) branch (which would fail because a:Person isn't an expression) or the anonymous_path branch (which would succeed)? Per %dprec, both fork; the failing fork is dropped; we get the pattern interpretation. The test suite doesn't include this case. Worth adding.
  • In list/map literals: RETURN [(a)-[:R]->(b), (a)-[:S]->(c)] — list of booleans. RETURN {flag: (a)-[:R]->(b)} — map containing a boolean. The grammar now allows these. Untested.
  • In function arguments: RETURN count((a)-[:R]->(b)). Should work (boolean is countable). Untested.
  • In OPTIONAL MATCH WHERE: OPTIONAL MATCH (a) WHERE (a)-[:R]->(:L). Untested.
  • Nested EXISTS: WHERE EXISTS((a)-[:R]->(b)) AND (a)-[:S]->(c). The PR's "mixed" test doesn't combine the two syntaxes in one predicate. Untested.

None of these is a blocker — they're follow-up coverage. But the PR description claims "matching what openCypher defines and what Neo4j users expect," and these are exactly the cases Neo4j users will try.

5. Performance of the resulting plan

Bare pattern → EXISTS sublink → planner builds a correlated subquery. In a RETURN a.name, (a)-[:KNOWS]->(:Person) AS knows over a million rows, the planner has to either:

  • (a) Evaluate the EXISTS sublink per row (O(N) sublink evals), or
  • (b) Convert it to a semi-join (PG's pull_up_sublinks).

For Cypher, the pattern is a SRF (cypher's own scan), not a SQL subquery the planner can semi-join in the usual way. So path (a) is what will happen, and on large data it will be slow.

That's the correct semantics. But it's worth documenting in the PR or a follow-up that pattern-expressions in projection contexts are O(rows × subquery cost), distinct from pattern-expressions in WHERE (where the planner might pull it up). Tests use 4 rows — invisible. Real workloads would notice.

6. The expr_func_subexpr: EXISTS '(' anonymous_path ')' production stays

It's now redundant with anonymous_path %dprec 1 in expr_atom. Both produce the same make_exists_pattern_sublink(...) output. The grammar will accept both spellings. Keeping the explicit form is fine for backward compat (users may have written WHERE EXISTS((...)) for years), but it does add to the conflict count. Could be noted in the description as "intentionally redundant for backward compatibility."

7. var_name_opt %dprec 1 on both alternatives

var_name_opt:
    /* empty */ %dprec 1
        { $$ = NULL; }
    | var_name %dprec 1
    ;

Equal priorities on both alternatives is a no-op for %dprec. Bison will treat this as "no preference" and discard the failing fork. That's fine, but the annotation is misleading — the priorities don't disambiguate, they're inert. Either remove them or set unequal values. Minor.

Verdict

Direction: correct. GLR is the right tool, the refactor preserves semantics exactly, the test coverage spans the new contexts, and Copilot's review rounds caught the small stuff.

Items I'd want addressed before merge:

  1. Keep -Werror, scope down to -Wno-error=conflicts-{sr,rr} rather than nuking -Werror.
  2. Re-introduce some %expect budget (generous, e.g. %expect 10 %expect-rr 5) so future grammar changes that add unintended conflicts get flagged.
  3. Quick parse-time microbench (LALR vs. GLR) on a couple of representative read-only Cypher queries. Even a one-line "no measurable regression on MATCH (a:L {p: $1}) RETURN a" in the PR description would satisfy.

Items I'd address as a follow-up (not blocking):

  1. Test coverage gaps: RETURN (a:Label), pattern in list/map literals, pattern in function args, OPTIONAL MATCH WHERE.
  2. Document the EXISTS-sublink-per-row cost in projection contexts so users aren't surprised on large data.
  3. Note the YYRHSLOC fix in the PR body — it's not just a stylistic change, it's required for GLR correctness.

Items that are fine as-is:

  1. The make_exists_pattern_sublink refactor (semantics preserved byte-for-byte).
  2. The conflict explanation block comment (genuinely helpful for future grammar maintainers).
  3. The decision to broaden scope from "WHERE only" to "all expression contexts" — Copilot was right, the grammar change inherently does this and a WHERE-only restriction would have required a parallel grammar branch.

Overall, this is a high-quality feature PR that solves a real openCypher-compat gap, and the maintainer approval is well-earned. The Makefile -Werror removal and absent %expect budget are the two items I'd push back on; everything else is polish.

@jrgemignani jrgemignani self-requested a review May 13, 2026 15:11

@jrgemignani jrgemignani left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see the Opus 4.7 review

@jrgemignani

Copy link
Copy Markdown
Contributor

@gregfelice I'm being extra careful due to this modifying the grammar.

@gregfelice

Copy link
Copy Markdown
Contributor Author

@jrgemignani — thanks for the careful review. Addressed all three blocking asks in 553e9761, with one factual correction to the underlying reasoning that I want to surface explicitly:

1. Makefile -Werror scope — kept -Werror, downgraded only the two conflict categories via -Wno-error=conflicts-sr -Wno-error=conflicts-rr. pgxs auto-adds -Wno-deprecated so the existing %name-prefix= / %pure-parser directives still build clean. Any other Bison warning (unused rules, undeclared types, etc.) now fails the build again — same coverage as master, minus the two known-noise categories.

2. %expect / %expect-rr budget — added %expect 7 and %expect-rr 3 matching the Bison 3.8.2 totals. Caveat: Bison treats %expect as exact-match, not as a ceiling, so the "wider budget to absorb Bison drift" framing in the review doesn't actually work — %expect 12 on a 7-conflict grammar fails the build with found 7, expected 12 (verified empirically). Exact pinning is actually a stronger signal than a generous bound: it catches both new conflicts AND silent disappearance of expected ones (either is a surprise worth auditing). The grammar comment documents that future Bison versions reporting different counts should bump these numbers explicitly with a version note in the commit message.

3. Parse-time microbench — Phase 3.5 release-build perf preflight — built a user-owned PG18 release cluster (-O2, no cassert) and ran a pgbench equivalent of the workload-mapping-prescribed smoke (W01 + W04 for parser-only changes), 5 iterations × 15 s × 1 client per side:

LALR (master) mean GLR (this PR) mean delta
W04-style: MATCH (a:L {p: 42}) RETURN a (1000-node graph) 4944 TPS (4904–4976, ±1.5%) 4979 TPS (4961–4999, ±0.8%) +0.7%
W01-style: CREATE (:L {p: 99}) (in rolled-back tx) 11064 TPS (10749–11353, ±5.6%) 11066 TPS (10920–11176, ±2.3%) +0.02%

Both deltas well below the 5% gate, and W01's LALR-side range (5.6%) is itself wider than the LALR-vs-GLR delta. No measurable parse-time regression on representative read-only or write Cypher.

Cassert installcheck: 34/34 AGE tests green (pgvector skipped — vector extension not installed in my local cassert PG; unrelated to this PR).

I also updated the PR description to (a) note the YYRHSLOC(rhs, 1) replacement is a GLR-stack correctness fix, not stylistic, and (b) reflect the restored -Werror + %expect 7/3 approach.

Non-blocking follow-ups from the review — your call on bundling:

  • Test coverage for RETURN (a:Label), list/map literals, function args, OPTIONAL MATCH WHERE — happy to fold into this PR's pattern_expression regression suite as a second commit if you want them here, otherwise I'll open a follow-up.
  • Doc note on EXISTS-sublink-per-row cost in projection contexts — better as a standalone docs commit so it's discoverable from the Cypher reference, not just this PR's history.

@jrgemignani

Copy link
Copy Markdown
Contributor

@gregfelice Ty. Just me - I prefer the builds to fail on anything, warnings included :) And grammars are tricky.

@jrgemignani

jrgemignani commented May 14, 2026

Copy link
Copy Markdown
Contributor

@gregfelice I would add the non-blocking follow ups that you listed. Follow ups sometimes get forgotten. Otherwise, everything else looks good.

gregfelice added a commit to gregfelice/age that referenced this pull request Jun 1, 2026
Addresses the non-blocking test-coverage follow-ups from the review:
pattern expressions in additional contexts opened up by allowing
anonymous_path as an expr_atom.

New cases (all verified against a PG18 build):
- Single-node pattern on a bound variable (a:Label). Documented as an
  EXISTS existence check, NOT an openCypher label predicate: a matching
  label is always true, and a non-matching label hits AGE's pre-existing
  "multiple labels for variable" restriction (captured as expected error).
- Pattern expressions inside list and map literals.
- Pattern expressions as function arguments: collect() shows correct
  per-row booleans; count() counts all rows (non-null bool) -- documented
  so the value is not mistaken for a bug.
- Pattern expression in OPTIONAL MATCH ... WHERE (null-preserving).
- EXISTS() and a bare pattern combined in one predicate.

make installcheck: 33/33 green.
@gregfelice

Copy link
Copy Markdown
Contributor Author

@jrgemignani — folded in the test-coverage follow-up (commit d372551a). New cases, all verified against a PG18 build (33/33 installcheck): pattern exprs in list literals, map literals, function args (collect()/count()), OPTIONAL MATCH … WHERE (null-preserving), and EXISTS() + bare pattern combined in one predicate.

One thing I want to flag rather than bury — the RETURN (a:Label) case from the review doesn't behave as openCypher's label predicate. On an already-bound variable it's a degenerate EXISTS existence check (a matching label is always true), and a non-matching label hits AGE's pre-existing "multiple labels for variable" restriction (errors rather than evaluating false). I captured both behaviours in the suite (including the error as expected output) so the current semantics are pinned — but if you'd rather reject single-node patterns in expression position than document the degenerate form, say the word and I'll add that instead.

Also documented that count() over a pattern boolean counts every row (boolean false is non-null) — expected SQL aggregate semantics, just noted so the value isn't mistaken for a bug.

The EXISTS-sublink-per-row cost note I'll do as a separate apache/age-website PR so it's discoverable from the Cypher reference, as discussed.

@gregfelice

Copy link
Copy Markdown
Contributor Author

The EXISTS-sublink-per-row cost note is up as a separate docs PR: apache/age-website#364. It documents pattern expressions in RETURN/WITH projections with a verified example plus the per-row EXISTS-subquery cost note, and is marked as depending on this PR (shouldn't merge before it).

@jrgemignani

Copy link
Copy Markdown
Contributor

@gregfelice We merged some big PRs and another 2 will hopefully be happening tomorrow. You may need to rebase this one too.

@gregfelice

gregfelice commented Jun 2, 2026 via email

Copy link
Copy Markdown
Contributor Author

jrgemignani pushed a commit to apache/age-website that referenced this pull request Jun 11, 2026
…#364)

Pattern expressions (path patterns such as (a)-[:KNOWS]->(:Person)) can be
used directly in a RETURN/WITH projection, not only as WHERE predicates.
Adds a worked example to the RETURN reference with verified output, plus a
performance note: a pattern expression is evaluated as an EXISTS subquery
once per result row, so its cost scales with the number of rows projected.

Documents the projection capability added in apache/age#2360.
gregfelice added a commit to gregfelice/age that referenced this pull request Jun 11, 2026
Addresses the non-blocking test-coverage follow-ups from the review:
pattern expressions in additional contexts opened up by allowing
anonymous_path as an expr_atom.

New cases (all verified against a PG18 build):
- Single-node pattern on a bound variable (a:Label). Documented as an
  EXISTS existence check, NOT an openCypher label predicate: a matching
  label is always true, and a non-matching label hits AGE's pre-existing
  "multiple labels for variable" restriction (captured as expected error).
- Pattern expressions inside list and map literals.
- Pattern expressions as function arguments: collect() shows correct
  per-row booleans; count() counts all rows (non-null bool) -- documented
  so the value is not mistaken for a bug.
- Pattern expression in OPTIONAL MATCH ... WHERE (null-preserving).
- EXISTS() and a bare pattern combined in one predicate.

make installcheck: 33/33 green.
@gregfelice gregfelice force-pushed the feature_pattern_in_where branch from d372551 to bf65c48 Compare June 11, 2026 18:04
@gregfelice

Copy link
Copy Markdown
Contributor Author

Rebased onto current master (c59cba71) — clean replay, no logic changes (the only intervening master commit, the cassert Dockerfile, doesn't touch these files). Full cassert installcheck is green: 36/36 including pattern_expression, via the CI-exact EXTRA_TESTS="pgvector fuzzystrmatch pg_trgm". Notably the %expect 7/3 pin held under a different Bison version than the original 3.8.2, so the grammar-gen contract looks robust across distros.

All prior review items are addressed (-Werror/%expect rework in 553e9761, follow-up coverage in the latest commit). @jrgemignani — when you have a moment, could you kick the action_required CI and re-review / clear the changes-requested? (Docs companion age-website#364 is ready, gated to merge after this.)

gregfelice added a commit to gregfelice/age that referenced this pull request Jun 11, 2026
Addresses the non-blocking test-coverage follow-ups from the review:
pattern expressions in additional contexts opened up by allowing
anonymous_path as an expr_atom.

New cases (all verified against a PG18 build):
- Single-node pattern on a bound variable (a:Label). Documented as an
  EXISTS existence check, NOT an openCypher label predicate: a matching
  label is always true, and a non-matching label hits AGE's pre-existing
  "multiple labels for variable" restriction (captured as expected error).
- Pattern expressions inside list and map literals.
- Pattern expressions as function arguments: collect() shows correct
  per-row booleans; count() counts all rows (non-null bool) -- documented
  so the value is not mistaken for a bug.
- Pattern expression in OPTIONAL MATCH ... WHERE (null-preserving).
- EXISTS() and a bare pattern combined in one predicate.

make installcheck: 33/33 green.
@gregfelice gregfelice force-pushed the feature_pattern_in_where branch from bf65c48 to a8d67c6 Compare June 11, 2026 19:25
@gregfelice

Copy link
Copy Markdown
Contributor Author

Correction to my previous comment — that rebase was onto a stale fork base, which pulled an unrelated docker/Dockerfile.cassert into the diff and left the branch behind apache master. Re-rebased cleanly onto current master (12e2a31c): the PR is now just the grammar + test files (4 files, no stray Docker change), re-validated cassert-green 40/40 including pattern_expression via the CI-exact EXTRA_TESTS="pgvector fuzzystrmatch pg_trgm". @jrgemignani — ready for the action_required CI kick + re-review when you have a moment.

gregfelice and others added 5 commits June 21, 2026 17:29
…che#1577)

Enable bare graph patterns as boolean expressions in WHERE clauses:

  MATCH (a:Person), (b:Person)
  WHERE (a)-[:KNOWS]->(b)        -- now valid, equivalent to EXISTS(...)
  RETURN a.name, b.name

Previously, this required wrapping in EXISTS():
  WHERE EXISTS((a)-[:KNOWS]->(b))

The bare pattern syntax is standard openCypher and is used extensively
in Neo4j.  Its absence was the most frequently cited migration blocker.

Implementation approach:
- Switch the Cypher parser from LALR(1) to Bison GLR mode.  GLR handles
  the inherent ambiguity between parenthesized expressions '(' expr ')'
  and graph path nodes '(' var_name label_opt props ')' by forking the
  parse stack and discarding the failing path.
- Add anonymous_path as an expr_atom alternative with %dprec 1 (lower
  priority than expression path at %dprec 2).  The action wraps the
  pattern in a cypher_sub_pattern + EXISTS SubLink, reusing the same
  transform_cypher_sub_pattern() machinery as explicit EXISTS().
- Extract make_exists_pattern_sublink() helper shared by both
  EXISTS(pattern) and bare pattern rules.
- Fix YYLLOC_DEFAULT to use YYRHSLOC() for GLR compatibility.
- %dprec annotations on expr_var/var_name_opt resolve the reduce/reduce
  conflict between expression variables and pattern node variables.

Conflict budget: 7 shift/reduce (path extension vs arithmetic on -/<),
3 reduce/reduce (expr_var vs var_name_opt on )/}/=).  All are expected
and handled correctly by GLR forking + %dprec disambiguation.

All 32 regression tests pass (31 existing + 1 new).  New
pattern_expression test covers: bare patterns, NOT patterns, labeled
nodes, AND/OR combinations, left-directed patterns, anonymous nodes,
multi-hop patterns, EXISTS() backward compatibility, and non-pattern
expression regression checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Move "Helper function to create an ExplainStmt node" comment from
   above make_exists_pattern_sublink() to above make_explain_stmt()
   where it belongs.

2. Add block comment documenting the %expect/%expect-rr conflict
   budget: 7 S/R from path vs arithmetic on - and <, 3 R/R from
   expr_var vs var_name_opt on ) } =.

3. Clarify test comment: "Regular expressions" -> "Regular (non-pattern)
   expressions" to avoid confusion with regex.

Regression test: pattern_expression OK.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Pattern expressions are now accepted anywhere an expr is valid
  (RETURN, WITH, SET, CASE, boolean combinations), not only WHERE.
  This matches openCypher semantics and documents the broader surface
  area that was already implicitly enabled by adding anonymous_path
  to expr_atom.  Added regression tests for each new context:
  RETURN projection (bare and AS-aliased), mixed with other
  projections, CASE WHEN, boolean AND/OR combinators, SET to
  persist a computed boolean property, and WITH ... WHERE pipeline.

- Remove the hardcoded `%expect 7` / `%expect-rr 3` conflict budget
  from cypher_gram.y.  The exact conflict counts can drift across
  Bison versions and distros, which would break builds even though
  the grammar is correct (GLR handles the conflicts at runtime via
  fork + %dprec).  Instead, pass -Wno-conflicts-sr / -Wno-conflicts-rr
  via BISONFLAGS in the Makefile so the build stays clean without
  binding us to a specific Bison release.  Kept a block comment in
  the grammar explaining why GLR conflicts are expected and how
  they resolve.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reverts the broad `-Werror` drop and the no-%expect approach from the
prior round on jrgemignani's request.  The earlier framing — that
conflict counts drift across Bison versions, so %expect is fragile —
overcorrected: it removed the only build-time alarm bell for unintended
new conflicts.

Makefile: keep -Werror so any unexpected Bison warning (unused rules,
undeclared types, etc.) still fails the build; downgrade only the two
conflict categories to plain warnings via -Wno-error=conflicts-sr
-Wno-error=conflicts-rr.  pgxs auto-adds -Wno-deprecated, so existing
%name-prefix= / %pure-parser directives remain non-erroring.

cypher_gram.y: add `%expect 7` and `%expect-rr 3` matching the
Bison 3.8.2 totals.  Bison treats %expect as exact-match, not as a
ceiling — any deviation fails the build and forces an audit of the new
conflicts.  Comment updated to reflect that future Bison versions
reporting different counts should bump the numbers explicitly with a
version note in the commit message, rather than removing the directive.

No grammar or runtime change.  Cassert installcheck 34/34 AGE tests
green.
Addresses the non-blocking test-coverage follow-ups from the review:
pattern expressions in additional contexts opened up by allowing
anonymous_path as an expr_atom.

New cases (all verified against a PG18 build):
- Single-node pattern on a bound variable (a:Label). Documented as an
  EXISTS existence check, NOT an openCypher label predicate: a matching
  label is always true, and a non-matching label hits AGE's pre-existing
  "multiple labels for variable" restriction (captured as expected error).
- Pattern expressions inside list and map literals.
- Pattern expressions as function arguments: collect() shows correct
  per-row booleans; count() counts all rows (non-null bool) -- documented
  so the value is not mistaken for a bug.
- Pattern expression in OPTIONAL MATCH ... WHERE (null-preserving).
- EXISTS() and a bare pattern combined in one predicate.

make installcheck: 33/33 green.
@gregfelice gregfelice force-pushed the feature_pattern_in_where branch from a8d67c6 to a553dc8 Compare June 21, 2026 21:38
@gregfelice

Copy link
Copy Markdown
Contributor Author

@jrgemignani — rebased onto current master (581d236e) and ready for re-review. The changes-requested items from your 05-13 review are all in:

  • -Werror scope — kept -Werror, downgraded only the two conflict categories via -Wno-error=conflicts-{sr,rr} (553e9761). Any other Bison warning still fails the build.
  • %expect budget%expect 7 / %expect-rr 3, exact-match pins (553e9761).
  • Parse-time microbench — LALR vs GLR, +0.7% / +0.02%, both well under the 5% gate.
  • Follow-up coverage (your 06-01 ask) — list/map literals, function args, OPTIONAL MATCH … WHERE, EXISTS+bare-pattern, and the documented (a:Label) degenerate case (d372551a).

The rebase was a clean replay (no logic changes; the only overlap, #2437's Makefile rework, auto-merged cleanly — the REGRESS entry and BISONFLAGS are intact). Re-validated cassert PG18 green: 42/42 including pattern_expression, CI-exact EXTRA_TESTS="pgvector fuzzystrmatch pg_trgm", no regression diffs.

When you have a moment, could you kick the action_required CI and clear the stale changes-requested? Docs companion age-website#364 is ready and gated to merge after this.

@jrgemignani

Copy link
Copy Markdown
Contributor

@gregfelice Going to set off Copilot and Opus 4.8 for one last check. Otherwise, everything looks good. I'm just extra careful with anything grammar related. That's not you, that's me.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/backend/parser/cypher_gram.y
@jrgemignani jrgemignani merged commit 2bc8e95 into apache:master Jun 22, 2026
6 checks passed
@jrgemignani

Copy link
Copy Markdown
Contributor

@gregfelice Looks good

@gregfelice

Copy link
Copy Markdown
Contributor Author

@jrgemignani — closing out the Opus 4.7 review items.

1. -Werror scope — done (c6270db0): kept -Werror, downgraded only -Wno-error=conflicts-{sr,rr}. Any other Bison warning still fails the build.

2. %expect budget — one correction, because the suggested fix can't compile, then how I solved the real goal. Bison treats %expect / %expect-rr as exact-match, not a ceiling: %expect 10 against a real count of 7 fails with "expected 10 shift/reduce conflicts, found 7." So widening to a generous 10/5 reds the build immediately on our Bison (3.8.2 → 7 s/r, 3 r/r).

The underlying goal — don't let distro Bison drift silently break CI, but still catch genuinely new conflicts — is right, so I pinned the toolchain instead of loosening the alarm (just pushed):

  • installcheck.yaml: runs-on: ubuntu-latestubuntu-24.04. ubuntu-latest floating to a newer release (and a newer Bison) was the actual drift source; freezing the image freezes Bison at 3.8.x.
  • Added a "Verify Bison version" step that fails loudly with a pointer to cypher_gram.y if Bison ever leaves 3.8.x — so the next person gets "re-run bison, update %expect, bump the runner together," not a cryptic count mismatch deep in the build.

Net: %expect 7 / %expect-rr 3 stays exact (tightest alarm for a real new conflict), reproducibility comes from pinning the variable. Same posture as PostgreSQL's gram.y, which keeps an exact %expect and bumps it on intentional changes.

3. Parse-time microbench (GLR vs LALR) — ran it. Disposable PG18 container, identical AGE build per arm except the grammar, pgbench -M simple (simple protocol = full re-parse every txn — the regime GLR taxes), -c4, 1000-node graph, Bison 3.8.2.

query LALR tps GLR tps Δ
RETURN 1 (parse floor) 123,849 123,815 −0.03%
MATCH (a:Person {id:$1}) RETURN a 25,170 25,463 +1.2%
RETURN (a)-[:KNOWS]->(:Person) (pattern expr) parse error (LALR) 17,156 GLR-only

No measurable parse-time regression — the parse floor is identical and the point lookup is within run-to-run noise (GLR came out marginally ahead), well under the >5% bar. The pattern-expression query is GLR-only (new syntax); its lower tps is EXISTS-sublink execution cost, not parse overhead.

4. Follow-up test coverage — already added: pattern expressions in list literal, map literal, count(), OPTIONAL MATCH … WHERE (null-preserving), and mixed EXISTS((..)) AND (..).

Still open from the review — want me to fold these in, or take as a fast follow?

  • One coverage gap remains: RETURN (a:Label) (single-node labeled pattern as a boolean). Quick to add.
  • PR-body note that the YYRHSLOC change is a required GLR correctness fix, not cosmetic.
  • PR-body note that pattern expressions in projection are EXISTS-sublink-per-row (O(rows × subquery)), distinct from WHERE where the planner may pull it up.

@gregfelice

Copy link
Copy Markdown
Contributor Author

Follow-up on the remaining review items — PR body updated, plus one finding worth flagging.

Test coverage — RETURN (a:Label): while adding this case I found it isn't a missing test, it's a latent bug. A single-node labeled pattern as a boolean (WHERE (a:Person) / RETURN (a:Person)) is accepted but doesn't filter by label — it's trivially true. Root cause is pre-existing in the transform layer: make_path_join_quals() (cypher_clause.c:5454) early-returns for vertex-only patterns (list_length(entities) < 3), so the label-filter quals — only emitted for relationship patterns — are skipped. It's byte-identical on master and this PR touches no transform code; the grammar change just makes it reachable via the new (a:Label) surface. Relationship patterns (this PR's actual feature) correlate and filter correctly. I opened #2443 to track the transform fix rather than scope-creep it into the grammar PR, and documented it as a known limitation in the PR body. (Verified by building this branch in a container and running the repros.)

Doc notes added to the PR body:

  • YYRHSLOC change is a required GLR correctness fix (different RHS stack layout under GLR), not cosmetic.
  • Pattern expressions in projection are EXISTS-sublink-per-row (O(rows × subquery)), distinct from WHERE where the planner may pull them up.

So the review items net out as: #1 (-Werror) done, #2 (%expect) solved via toolchain pinning, #3 (parse-time) measured — no regression, #4 (coverage) done for relationship patterns with the single-node label case tracked in #2443. Ready for another look whenever.

@jrgemignani jrgemignani mentioned this pull request Jul 2, 2026
jrgemignani added a commit that referenced this pull request Jul 2, 2026
* Make age extension usable from shared_preload_libraries (#2438)

When AGE is loaded via shared_preload_libraries, its hooks
(post_parse_analyze, set_rel_pathlist, object_access) are active
before CREATE EXTENSION age is run. This causes errors when
non-Cypher queries trigger those hooks and ag_catalog does not
yet exist.

Changes:

- Add is_age_extension_exist() with a relcache callback cache so that
  checking pg_extension is not repeated on every hook invocation.
- Guard post_parse_analyze, set_rel_pathlist, and object_access hooks
  with is_age_extension_exist() so they become no-ops when the
  extension is not installed.
- Refactor ag_ProcessUtility_hook to detect CREATE/DROP EXTENSION age
  and broadcast a relcache invalidation via
  CacheInvalidateRelcacheByRelid(ExtensionRelationId) so other
  backends update their cached extension state.
- Wrap DROP EXTENSION processing in PG_TRY/PG_CATCH to restore
  object_access_hook if the drop fails (e.g. dependent objects).
- Skip _PG_init during pg_upgrade (IsBinaryUpgrade) to avoid hook
  registration when the binary-upgrade machinery is running.
- Add regression tests that verify hooks do not error when ag_catalog
  schema is absent.

* feature: add create_subgraph() (#2441)

Add the feature create_subgraph() for materialized induced-subgraph
extraction.

Add ag_catalog.create_subgraph(new_graph, from_graph, node_filter,
relationship_filter) which materializes a new, persistent, fully
Cypher-queryable AGE graph as the induced subgraph of an existing graph.

Selection follows the graph-theory induced-subgraph definition as
operationalized by Neo4j GDS gds.graph.filter():
  * a vertex is kept iff node_filter holds ('*' keeps all);
  * an edge is kept iff relationship_filter holds AND both of its
    endpoints were kept (no dangling edges).

Filters are arbitrary Cypher predicates bound to `n` (nodes) and `r`
(relationships) and are evaluated by AGE's own Cypher engine against the
source graph, so the full predicate language is available; label
selection uses label(n)/label(r) since the match pattern is fixed.

Implementation notes:
  * Result is a real, ACID, registered graph (create_graph + create_v/
    elabel), not a virtual view; it composes with cypher() and itself.
  * Entity graphids are reassigned from the destination labels' own
    sequences (graphid encodes a per-graph label id), and edge endpoints
    are remapped through an old->new vertex map, enforcing the induced
    rule via inner joins.
  * Source label tables are read with FROM ONLY to avoid double-copying
    children under PostgreSQL table inheritance.
  * Properties of any agtype are preserved; self-loops and parallel
    edges (multigraph structure) are retained.
  * SECURITY INVOKER: reads respect the caller's table privileges and
    RLS; the new graph is owned by the caller.
  * Validates NULL/identical graph names, missing source, pre-existing
    destination, and a reserved dollar-quote token in predicates.

Wire-up:
  * sql/age_subgraph.sql (new) registered in sql/sql_files after
    age_pg_upgrade; identical body added to age--1.7.0--y.y.y.sql so the
    upgrade-path catalog comparison matches.
  * regress/sql/subgraph.sql + expected output (new), added to REGRESS.
    Covers full copy, vertex-induced, node+rel, label-only edge drop,
    bipartite, empty result, composability, self-loops/parallel edges,
    property fidelity, and error cases over a ~4500-vertex / 2000-edge
    source graph.

All 38 regression tests pass against PostgreSQL 18.

Co-authored-by: GitHub Copilot (Claude Opus 4.8) <[email protected]>

modified:   Makefile
modified:   age--1.7.0--y.y.y.sql
new file:   regress/expected/subgraph.out
new file:   regress/sql/subgraph.sql
new file:   sql/age_subgraph.sql
modified:   sql/sql_files

* cypher_vle: add ORDER BY to non-deterministic RETURN queries (#2434)

Several VLE regression queries RETURN multiple rows without an ORDER BY,
so their row order depends on traversal/scan order and can vary between
runs and platforms. Add ORDER BY ASC to those queries (on the path,
edge-list, or graphid as appropriate) so the expected output is stable.
The queries are pinned by path (p), edge list (e), or graphid
(id(u)/id(v)/id(e[n])) depending on what each RETURN projects.

Full audit of cypher_vle: all 38 multi-row result blocks were checked.
After this change, every multi-row RETURN is deterministically ordered
except the two SELECT * FROM show_list_use_vle('list01') calls, which are
already deterministic because the function body orders its results with
RETURN v ORDER BY id(v) (added in #2417); their result blocks are
unchanged by this commit.

This is a test-only change (regress/sql/cypher_vle.sql and
regress/expected/cypher_vle.out); no extension C code or SQL is modified.
Row counts are unchanged (pure reordering).

All 37 regression tests pass (installcheck) on PostgreSQL 18.3.

Co-authored-by: GitHub Copilot <noreply@github.com>

modified:   regress/expected/cypher_vle.out
modified:   regress/sql/cypher_vle.sql

* age_global_graph: stabilize regression tests (#2431)

age_global_graph: stabilize regression tests under concurrent xid load

Wrap both vertex_stats() context-building phases in a single
BEGIN ISOLATION LEVEL REPEATABLE READ; ... COMMIT; transaction so the
three calls share one snapshot. This prevents the snapshot-fallback path
in is_ggctx_invalid() from purging an already-built graph context when
concurrent xid activity (autovacuum, parallel installcheck, replication,
shared CI) advances the snapshot between calls, which would otherwise
make the targeted delete_global_graphs(name) checks return false instead
of the expected true. Read Committed is insufficient because it acquires
a fresh snapshot per statement; REPEATABLE READ pins one snapshot for the
whole transaction.

Also add explicit ORDER BY id to the three direct-SQL label-table SELECTs
(_ag_label_vertex x2, _ag_label_edge) that return multiple rows, so their
output no longer depends on heap scan order.

This is a test-only change (regress/sql/age_global_graph.sql and
regress/expected/age_global_graph.out); no extension C code or SQL is
modified.

All 37 regression tests pass (installcheck) on PostgreSQL 18.3.

Co-authored-by: GitHub Copilot <noreply@github.com>

modified:   regress/expected/age_global_graph.out
modified:   regress/sql/age_global_graph.sql

* Makefile: add installcheck-existing target and improve readability (#2437)

Add an "installcheck-existing" target that runs the regression suite
against an already-running PostgreSQL server, complementing the default
"installcheck" (which builds a private temp instance). It points
pg_regress at the server via the standard libpq variables
(PGHOST/PGPORT/PGUSER; PGDATABASE defaults to contrib_regression) and
lets pg_regress create the database and load the extension through
--load-extension=age. It deliberately avoids --use-existing -- that
option skips database creation and disables --load-extension -- so no
manual CREATE EXTENSION step is required. The upgrade test (age_upgrade)
is excluded because it stages synthetic extension files into the local
sharedir that a running server would not see.

Readability and maintainability improvements (no behavior change):
- Derive age_sql from AGE_CURR_VER (read from age.control) so the
  version number is defined in exactly one place.
- Add section banners and a top-of-file layout/target index; group the
  scattered upgrade-test pieces and move the ag_scanner flex rule in
  with the other parser-generation rules.
- Wrap the long REGRESS_OPTS and EXTRA_CLEAN assignments across lines.
- Fix the DATA filter-out pattern to use a double dash
  (age--%--y.y.y.sql) matching the actual template filename; the prior
  single-dash pattern only matched via greedy '%' expansion.
- Anchor the age.control version regex (/^default_version/).
- Replace the hardcoded "31 tests" comment with generic wording and add
  a "help" target listing the common targets.

Verified on PostgreSQL 18: make installcheck (temp instance) and
make installcheck-existing both pass; clean rebuild and make clean
unaffected.

Co-authored-by: GitHub Copilot <noreply@github.com>

modified:   Makefile

* Make ag_catalog ownership and built-in resolution explicit (#2440)

AGE places all of its objects in the ag_catalog schema. Make the
assumptions around that schema explicit so installs and upgrades behave
predictably regardless of how a database is provisioned:

- Ownership-checked install: CREATE EXTENSION age installs into
  ag_catalog only when that schema does not already exist under a
  different owner, keeping ownership of AGE's catalog well-defined.
- Deterministic name resolution: the pg_upgrade helper functions resolve
  built-ins from pg_catalog first and schema-qualify their
  format()/hashtext() calls, so their behavior does not depend on what
  else is defined in ag_catalog.
- README note describing ag_catalog ownership and the install-time check.

The upgrade script applies the same helper changes so existing
installations get them on ALTER EXTENSION UPDATE. Adds an
extension_security regression test covering the ownership check and the
qualified-call / search_path properties.

Assisted-by: GitHub Copilot (Claude Opus 4.8)

modified:   Makefile
modified:   README.md
modified:   age--1.7.0--y.y.y.sql
new file:   regress/expected/extension_security.out
new file:   regress/sql/extension_security.sql
modified:   sql/age_main.sql
modified:   sql/age_pg_upgrade.sql

Resolved Conflicts: Makefile

* cypher_with: add ORDER BY to non-deterministic RETURN queries (#2436)

Several cypher_with regression queries RETURN multiple rows without an
ORDER BY, so their row order depends on heap/scan order and can vary
between runs, build types, and platforms. Add ORDER BY ASC to those
queries so the expected output is stable. Ordering keys use id() (a
single int64 that bypasses the locale-sensitive string comparison path
and is reproducible from the test's deterministic setup order), or the
projected path/scalar where that is what the query returns. Where the
underlying vertex/edge was dropped by a WITH projection, its id is
threaded through as an alias rather than reordering the projection.

Full audit of cypher_with: all 23 multi-row result blocks were checked.
After this change, every multi-row, non-EXPLAIN RETURN is deterministically
ordered. The two remaining unordered multi-row blocks are left as-is:
- "RETURN lbl" returns two identical "Person" rows, so order cannot drift;
- the 13 EXPLAIN (VERBOSE, COSTS OFF) plan blocks emit a fixed serial plan
  (no parallel/gather nodes), so their row order is already deterministic.

This is a test-only change (regress/sql/cypher_with.sql and
regress/expected/cypher_with.out); no extension C code or SQL is modified.
Row counts are unchanged (pure reordering).

All 37 regression tests pass (installcheck) on PostgreSQL 18.3.

Co-authored-by: GitHub Copilot <noreply@github.com>

modified:   regress/expected/cypher_with.out
modified:   regress/sql/cypher_with.sql

* Add shortest_path / all_shortest_paths SRFs (#2430)

Add two C set-returning functions that compute unweighted (hop-count)
shortest paths over the cached global graph adjacency via BFS, callable
both at the SQL top level and inside a cypher() RETURN:

  - age_shortest_path(...)       -> the single shortest path (0 or 1 rows)
  - age_all_shortest_paths(...)  -> every shortest path, one per row

The signature follows the natural Cypher argument order
(graph, start, end, edge_types, direction, min_hops, max_hops), registered
in sql/agtype_typecast.sql (install) and age--1.7.0--y.y.y.sql (upgrade).
Unimplemented parameters fail loudly: multiple relationship types and a
non-zero min_hops raise ERRCODE_FEATURE_NOT_SUPPORTED. A single edge type
(string or one-element array) is honored, and a NULL endpoint yields no
rows per Cypher null semantics (wrong-typed endpoints / NULL graph still
error).

To call the SRFs inside a cypher() RETURN, transform_cypher_return now sets
query->hasTargetSRFs (it was the only results-producing clause that didn't,
so the planner never added a ProjectSet node), and transform_FuncCall
auto-prepends the graph name for snake_case shortest_path /
all_shortest_paths. camelCase names are reserved for the future native
grammar.

Robustness:
  - BFS guards against non-existent endpoints (returns 0 rows instead of
    crashing) and honors CHECK_FOR_INTERRUPTS.
  - An unknown edge label now matches no edges instead of silently
    traversing all of them (get_label_relation returns InvalidOid).

Adds the age_shortest_path regression test (directed/undirected, label
filtering, parallel edges, self-loops, max_hops, the not-supported stubs,
NULL and non-existent endpoint/graph guards).

38/38 installcheck pass.

Co-authored-by: Copilot <copilot@github.com>

modified:   Makefile
modified:   age--1.7.0--y.y.y.sql
modified:   sql/agtype_typecast.sql
modified:   src/backend/parser/cypher_clause.c
modified:   src/backend/parser/cypher_expr.c
modified:   src/backend/utils/adt/age_vle.c
new file:   regress/expected/age_shortest_path.out
new file:   regress/sql/age_shortest_path.sql

* Support pattern expressions as boolean expressions (#2360)

* Support pattern expressions in WHERE clause via GLR parser (issue #1577)

Enable bare graph patterns as boolean expressions in WHERE clauses:

  MATCH (a:Person), (b:Person)
  WHERE (a)-[:KNOWS]->(b)        -- now valid, equivalent to EXISTS(...)
  RETURN a.name, b.name

Previously, this required wrapping in EXISTS():
  WHERE EXISTS((a)-[:KNOWS]->(b))

The bare pattern syntax is standard openCypher and is used extensively
in Neo4j.  Its absence was the most frequently cited migration blocker.

Implementation approach:
- Switch the Cypher parser from LALR(1) to Bison GLR mode.  GLR handles
  the inherent ambiguity between parenthesized expressions '(' expr ')'
  and graph path nodes '(' var_name label_opt props ')' by forking the
  parse stack and discarding the failing path.
- Add anonymous_path as an expr_atom alternative with %dprec 1 (lower
  priority than expression path at %dprec 2).  The action wraps the
  pattern in a cypher_sub_pattern + EXISTS SubLink, reusing the same
  transform_cypher_sub_pattern() machinery as explicit EXISTS().
- Extract make_exists_pattern_sublink() helper shared by both
  EXISTS(pattern) and bare pattern rules.
- Fix YYLLOC_DEFAULT to use YYRHSLOC() for GLR compatibility.
- %dprec annotations on expr_var/var_name_opt resolve the reduce/reduce
  conflict between expression variables and pattern node variables.

Conflict budget: 7 shift/reduce (path extension vs arithmetic on -/<),
3 reduce/reduce (expr_var vs var_name_opt on )/}/=).  All are expected
and handled correctly by GLR forking + %dprec disambiguation.

All 32 regression tests pass (31 existing + 1 new).  New
pattern_expression test covers: bare patterns, NOT patterns, labeled
nodes, AND/OR combinations, left-directed patterns, anonymous nodes,
multi-hop patterns, EXISTS() backward compatibility, and non-pattern
expression regression checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address Copilot review: comment placement, %expect docs, test wording

1. Move "Helper function to create an ExplainStmt node" comment from
   above make_exists_pattern_sublink() to above make_explain_stmt()
   where it belongs.

2. Add block comment documenting the %expect/%expect-rr conflict
   budget: 7 S/R from path vs arithmetic on - and <, 3 R/R from
   expr_var vs var_name_opt on ) } =.

3. Clarify test comment: "Regular expressions" -> "Regular (non-pattern)
   expressions" to avoid confusion with regex.

Regression test: pattern_expression OK.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address Copilot round 3: broaden scope, remove %expect fragility

- Pattern expressions are now accepted anywhere an expr is valid
  (RETURN, WITH, SET, CASE, boolean combinations), not only WHERE.
  This matches openCypher semantics and documents the broader surface
  area that was already implicitly enabled by adding anonymous_path
  to expr_atom.  Added regression tests for each new context:
  RETURN projection (bare and AS-aliased), mixed with other
  projections, CASE WHEN, boolean AND/OR combinators, SET to
  persist a computed boolean property, and WITH ... WHERE pipeline.

- Remove the hardcoded `%expect 7` / `%expect-rr 3` conflict budget
  from cypher_gram.y.  The exact conflict counts can drift across
  Bison versions and distros, which would break builds even though
  the grammar is correct (GLR handles the conflicts at runtime via
  fork + %dprec).  Instead, pass -Wno-conflicts-sr / -Wno-conflicts-rr
  via BISONFLAGS in the Makefile so the build stays clean without
  binding us to a specific Bison release.  Kept a block comment in
  the grammar explaining why GLR conflicts are expected and how
  they resolve.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address jrgemignani review: keep -Werror, restore %expect budget

Reverts the broad `-Werror` drop and the no-%expect approach from the
prior round on jrgemignani's request.  The earlier framing — that
conflict counts drift across Bison versions, so %expect is fragile —
overcorrected: it removed the only build-time alarm bell for unintended
new conflicts.

Makefile: keep -Werror so any unexpected Bison warning (unused rules,
undeclared types, etc.) still fails the build; downgrade only the two
conflict categories to plain warnings via -Wno-error=conflicts-sr
-Wno-error=conflicts-rr.  pgxs auto-adds -Wno-deprecated, so existing
%name-prefix= / %pure-parser directives remain non-erroring.

cypher_gram.y: add `%expect 7` and `%expect-rr 3` matching the
Bison 3.8.2 totals.  Bison treats %expect as exact-match, not as a
ceiling — any deviation fails the build and forces an audit of the new
conflicts.  Comment updated to reflect that future Bison versions
reporting different counts should bump the numbers explicitly with a
version note in the commit message, rather than removing the directive.

No grammar or runtime change.  Cassert installcheck 34/34 AGE tests
green.

* Add follow-up regression coverage for pattern expressions (#2360)

Addresses the non-blocking test-coverage follow-ups from the review:
pattern expressions in additional contexts opened up by allowing
anonymous_path as an expr_atom.

New cases (all verified against a PG18 build):
- Single-node pattern on a bound variable (a:Label). Documented as an
  EXISTS existence check, NOT an openCypher label predicate: a matching
  label is always true, and a non-matching label hits AGE's pre-existing
  "multiple labels for variable" restriction (captured as expected error).
- Pattern expressions inside list and map literals.
- Pattern expressions as function arguments: collect() shows correct
  per-row booleans; count() counts all rows (non-null bool) -- documented
  so the value is not mistaken for a bug.
- Pattern expression in OPTIONAL MATCH ... WHERE (null-preserving).
- EXISTS() and a bare pattern combined in one predicate.

make installcheck: 33/33 green.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add reduce() list folding function (#2435)

Implement the openCypher reduce(acc = init, var IN list | body) expression,
which folds an arbitrary expression over a list, threading an accumulator
across the elements in list order. This closes a long-standing gap (reduce()
was previously unsupported) and works both at the SQL top level and inside a
cypher() RETURN/WHERE.

Implementation
--------------
reduce() is desugared, at transform time, into a correlated scalar subquery
over a new ordered aggregate rather than a new executor node, so no PostgreSQL
core changes are required:

    CASE WHEN list IS NULL THEN NULL
         ELSE COALESCE((SELECT ag_catalog.age_reduce(
                            <init>, '<serialized-body>'::text,
                            r.elem ORDER BY r.ord)
                        FROM unnest(<list>) WITH ORDINALITY AS r(elem, ord)),
                       <init>)
    END

  - A new cypher_reduce extensible node carries the accumulator/element names
    and the init/list/body expressions (grammar production, keyword, and the
    copy/out/read serialization plumbing).
  - The fold body is transformed against a throwaway two-column agtype
    namespace, its accumulator and element Var references are rewritten to
    PARAM_EXEC params 0 and 1, and it is serialized with nodeToString() into a
    text argument.
  - age_reduce_transfn (a custom agtype aggregate transition function)
    deserializes and compiles the body once per group with ExecInitExpr, then
    evaluates it per element with ExecEvalExpr, rebinding the two params. The
    body is normalized to agtype at transform time so a boolean or other
    non-agtype result cannot be misread as a by-reference Datum.

Semantics
---------
  - List order is preserved (unnest WITH ORDINALITY + aggregate ORDER BY).
  - An empty list yields the initial value; a NULL list yields NULL.
  - The list and initial value may reference outer-query variables (e.g.
    reduce(total = 0, n IN nodes(p) | total + n.age)); the body may reference
    only the accumulator and element.
  - Arithmetic, string, list-building, boolean/comparison (AND/OR/=/>), CASE,
    and element property-access bodies are all supported.
  - Outer-variable, query-parameter, nested-reduce, and aggregate references
    inside the body raise a clean ERRCODE_FEATURE_NOT_SUPPORTED error.
  - reduce is registered as a safe keyword so it remains usable as a property
    or map key, preserving backward compatibility.

Tests
-----
Adds the age_reduce regression test (registered in the install SQL and the
upgrade template so age_upgrade passes), covering: arithmetic/product/string
folds; order sensitivity; empty/NULL list; NULL element and NULL init;
list-building and CASE bodies; boolean and comparison bodies; element property
access; multiple and nested (in list/init) reduce(); reduce() in boolean
expressions, WHERE, and list comprehensions; folds over collected nodes and
node list properties; the not-supported rejections; and reduce as a map key.

Following reviewer feedback, three further semantics-coverage gaps are
pinned directly so the mechanisms that make the aggregate desugaring
correct are exercised by tests rather than only correct by inspection:
  - A fold body that produces null mid-fold and then recovers: the
    agtype 'null' running state is a readable value, so a later element
    folds back out of it (distinct from "null propagates to a null
    result", which was already covered).
  - An empty list with a NULL initial value: COALESCE(<no rows>, init)
    yields NULL, kept distinct from a body that legitimately folds to
    agtype 'null', which must not be resurrected to the initial value.
  - A type error and a runtime division-by-zero error in the body: both
    abort cleanly out of the standalone per-element evaluator rather
    than corrupting the running aggregate state.

All multi-row results are ordered. 42/42 installcheck pass.

Future work
-----------
The body restriction (accumulator and element only) is a property of the
standalone expression evaluation and can be relaxed without core changes:
  - Allow loop-invariant outer-variable and cypher $parameter references in
    the body by capturing them as additional eager aggregate arguments bound
    to extra param slots.
  - Support a nested reduce() inside the body via an SPI-based evaluation
    fallback for subquery-bearing bodies.
Aggregates inside the body remain intentionally unsupported, matching the
openCypher specification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

modified:   Makefile
modified:   age--1.7.0--y.y.y.sql
new file:   regress/expected/age_reduce.out
new file:   regress/sql/age_reduce.sql
modified:   sql/age_aggregate.sql
modified:   src/backend/nodes/ag_nodes.c
modified:   src/backend/nodes/cypher_copyfuncs.c
modified:   src/backend/nodes/cypher_outfuncs.c
modified:   src/backend/nodes/cypher_readfuncs.c
modified:   src/backend/parser/cypher_analyze.c
modified:   src/backend/parser/cypher_clause.c
modified:   src/backend/parser/cypher_gram.y
modified:   src/backend/utils/adt/agtype.c
modified:   src/include/nodes/ag_nodes.h
modified:   src/include/nodes/cypher_copyfuncs.h
modified:   src/include/nodes/cypher_nodes.h
modified:   src/include/nodes/cypher_outfuncs.h
modified:   src/include/nodes/cypher_readfuncs.h
modified:   src/include/parser/cypher_kwlist.h

* resolve subgraph staging sequences via regclass (#2446)

The vertex/edge staging copies in create_subgraph() generated new
graphids with nextval(%L), which binds the sequence as a string literal
and invokes the nextval(text) overload. That re-resolves the
schema-qualified sequence name on each call.

Cast the literal to regclass (nextval(%L::regclass)) so the sequence is
resolved once to its OID, matching how AGE defines its label id defaults
(nextval('schema.seq'::regclass)). Applied to both the vertex and edge
staging queries, in sql/age_subgraph.sql and the identical body in the
age--1.7.0--y.y.y.sql upgrade template so the upgrade-path catalog
comparison still matches.

Behavior is unchanged; all 38 regression tests pass against PostgreSQL 18.

Addresses Copilot review feedback on #2441.

Co-authored-by: GitHub Copilot (Claude Opus 4.8) <[email protected]>

modified:   age--1.7.0--y.y.y.sql
modified:   sql/age_subgraph.sql

* Support relationship-type filters and a minimum hop count (#2442)

Support relationship-type filters and a minimum hop count in shortest_path SRFs

age_shortest_path / age_all_shortest_paths gain two related capabilities,
both following openCypher / Neo4j semantics.

Relationship-type filtering: the edge_types argument now accepts an array
of types; an edge matches when its label is any one of the requested
types. A bare string or a one-element array keeps the single-type
behaviour, an empty string/array or NULL means no filter, and an unknown
type matches nothing. sp_run_bfs takes an Oid set rather than a single
oid, and sp_compute_paths resolves the argument into that set.

Minimum hop count: the new min_hops argument is a lower bound on the path
length. When it does not exceed the true shortest distance it imposes no
constraint, so the normal BFS shortest-path result is returned. When it
exceeds the shortest distance, BFS cannot produce a qualifying path, so
the search falls back to the variable-length-edge depth-first engine
(sp_minhops_fallback), which enumerates edge-distinct paths
(relationship-uniqueness / trail semantics) and returns the shortest
path(s) whose length is at least min_hops. This regime permits revisiting
a vertex and closed walks back to the start, but never reusing an edge. A
private memory context bounds the search and a cost guard caps the number
of examined paths, raising PROGRAM_LIMIT_EXCEEDED (with a hint to bound the
search with a maximum hop count) when the cap is exceeded. The hard regime
combined with multiple relationship types is unsupported, because the VLE
engine matches a single label; that case raises FEATURE_NOT_SUPPORTED.

Regression coverage spans single- and multi-type filters, directed and
undirected reachability, multiplicity of equal-length paths, max_hops
bounds, NULL and non-existent endpoints, and both min_hops regimes,
including a vertex-revisiting longer path (sp_revisit) and a closed-walk
cycle back to the start (sp_tri). The in-cypher() Tier 1 call forms are
exercised as well.

Review feedback addressed:

1. Error messages now report the function actually called. age_shortest_path
   and age_all_shortest_paths share their argument-resolution helpers, which
   hard-coded an "age_shortest_path" prefix regardless of the caller; the
   caller's name is now threaded through so each function reports its own
   (this also corrects a mislabeled multi-type min_hops error). A new
   regression case (sp_errname) pins the behaviour for both functions.

2. age_all_shortest_paths now bounds the number of materialized result paths.
   The shortest-path DAG can contain exponentially many equal-length paths,
   all built up front before the first row streams; enumeration is capped at
   SP_MAX_RESULT_PATHS (1,000,000), raising PROGRAM_LIMIT_EXCEEDED with a hint
   to narrow the search, mirroring the existing min-hops candidate cap.

3. The BFS search state (visited table, frontier queue, predecessor multiset,
   and intermediate path arrays) now lives in a private scratch memory context
   that is deleted once the surviving result Datums are built in the SRF
   context, rather than persisting in multi_call_memory_ctx for the life of
   the SRF. This bounds peak memory to the result set plus one search and
   matches the pattern sp_minhops_fallback already used.

4. A second review round tightened memory hygiene and reporting: the
   pnstrdup'd relationship-type name is freed once resolved to an oid (it was
   retained for the life of the SRF) in both the array and scalar cases; the
   invalid-direction error now carries the called function's name like the
   other argument errors; the min-hops fallback's private context is renamed
   to a caller-neutral "age shortest path minhops" (it is shared by both
   SRFs); and the multi-type label-filter comment is corrected to note that an
   unknown type merely contributes no matches -- known types in the same set
   still match, and only an all-unknown set leaves just the zero-length path.

41/41 installcheck.

Co-authored-by: Copilot <copilot@github.com>

modified:   regress/expected/age_shortest_path.out
modified:   regress/sql/age_shortest_path.sql
modified:   src/backend/utils/adt/age_vle.c

* Fix single-node labeled pattern expressions not filtering by label (#2443) (#2444)

A single-node labeled pattern used as a boolean expression -- e.g.
`WHERE (a:Person)`, `WHERE EXISTS((a:Person))` -- was accepted but did not
test the bound vertex's label. It desugars to an EXISTS sub-pattern, and
make_path_join_quals() returned early for vertex-only patterns
(list_length(entities) < 3), emitting no quals. With no edge to carry a
correlation, the sub-pattern referenced nothing from the enclosing query,
so the planner produced an uncorrelated one-time InitPlan that was trivially
true whenever any vertex of that label existed -- the predicate matched every
outer row.

Emit an explicit label-id filter for a vertex-only pattern whose vertex
carries a non-default label and whose variable is declared in an ancestor
parse state (i.e. a correlated reference). make_qual() builds a name-based id
reference that resolves to the outer variable, so the filter both correlates
the sub-pattern to that variable and enforces the label. Freshly scanned,
non-correlated vertices (no ancestor binding) are untouched, so plain
MATCH (a:Person) and "does any X exist" EXISTS checks are unaffected.

Add regression coverage in pattern_expression: WHERE (a:Person),
WHERE NOT (a:Person), and EXISTS((a:Company)) against a graph with a
non-Person vertex. All 41 regression tests pass.

* Preserve null-valued keys in map literals (#2391) (#2412)

* Preserve null-valued keys in map literals (#2391)

Map literals such as RETURN {a: null} previously dropped keys whose
values were null, producing {} instead of {"a": null}. This
diverged from the openCypher / Neo4j semantics where map literals
preserve every key the user wrote, including those bound to null.

Root cause: cypher_map.keep_null defaulted to false (zero-initialised),
so the grammar-produced node fed agtype_build_map_nonull, which strips
null entries. Call sites that legitimately need strip-null semantics
(CREATE node/edge property maps and SET = assignments) already set
keep_null=false explicitly, and the MATCH pattern path sets it to true
explicitly. Flipping the grammar default to true therefore only affects
the cases that were buggy (bare map expressions and nested map values),
and leaves CREATE/SET behaviour unchanged.

Two preexisting tests encoded the old buggy output and are updated:
expr.out (bare RETURN maps now keep the null value) and agtype.out
(a nested map inside an orderability test no longer drops its null
entry, shifting one row in the ORDER BY result). Dedicated regression
coverage for #2391 is added to regress/sql/expr.sql.

* Address review: move 'End of tests' marker and drop order claim

- Move 'End of tests' to after the Issue 2391 test block so it marks
  the actual end of the file.
- Remove 'in order' from the mixed-values comment since map key
  ordering is not guaranteed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update accessor EXPLAIN expected output for null-preserving map literals

Map literals now build with agtype_build_map (keep_null) instead of
agtype_build_map_nonull, so the accessor-optimization EXPLAIN plan in
expr.out must reflect the new function name. Also strip a stray trailing
CRLF on the last line of expr.sql/expr.out that leaked a carriage return
into the regression output.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add nested-map write coverage and clarify top-level strip wording

A reviewer noted the keep_null=true default reaches further than the
PR summary stated: CREATE / SET = only override keep_null=false on the
top-level property map, not recursively. A nested map value is its own
cypher_map node, so it now inherits the new default and preserves its
null-valued keys (e.g. CREATE (n {a: {b: null}}) stores a -> {"b": null}).

- regress/sql/expr.sql, regress/expected/expr.out: pin the nested case
  under a write with CREATE (n:Nested {a: {b: null}}), MATCH ... SET
  n = {a: {b: null}}, and a MATCH verify.
- src/backend/parser/cypher_gram.y: clarify the map: rule comment to
  state the CREATE / SET override is top-level only and nested map
  values keep the null-preserving default.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Node.js driver CI build broken by @types/node drift (#2452)

The Node.js driver CI (npm install -> npm run build -> tsc) failed with
parser errors in node_modules/@types/node/ffi.d.ts (TS1139/TS1005/TS1109/
TS1128). package-lock.json is gitignored, so CI resolves dependencies
purely from package.json. @types/node was only pulled transitively via a
wildcard range (@types/pg and jest depend on @types/node@*), so a fresh
install grabbed the latest (26.x). That version uses `const` type
parameters (a TypeScript 5.0 feature) in ffi.d.ts, which typescript@4.9
cannot parse. skipLibCheck does not suppress these parser-level errors.

The runtime Node version is unrelated: @types/node is resolved from the
npm dependency graph, not the Node.js runtime.

Fix:
- Add a bounded direct devDependency "@types/node": "^20.19.0" so a fresh
  install constrains the typings to the Node 20 LTS line, which is
  compatible with typescript@4.9 and keeps the toolchain consistent
  (eslint 7 / typescript-eslint 4 / TS 4.9 / Node 20 typings).
- Pin CI to Node 20 (setup-node@v4, node-version: 20) for reproducibility
  and to match the pinned typings; replaces the deprecated setup-node@v3
  and floating node-version: latest.

Verified: a clean, no-lockfile install (matching CI) now resolves
@types/node@20.19.43 and tsc builds successfully.

Co-authored-by: Copilot <copilot@github.com>

modified:   .github/workflows/nodejs-driver.yaml
modified:   drivers/nodejs/package.json

* Fix stack overflow and precision loss in toFloatList() conversion (#2451)

toFloatList()'s AGTV_FLOAT branch formatted each element with
sprintf(buffer, "%f", ...) into a fixed 64-byte stack buffer and then
re-parsed the string back into a float. This had two defects:

  1. Stack overflow. "%f" prints the full integer part with no width
     limit, so a large magnitude overflows the 64-byte buffer. The value
     is query-reachable: RETURN toFloatList([1.0e308]) needs ~317 bytes
     (309 integer digits + ".000000") and smashes the stack. This is the
     issue reported in #2410.

  2. Precision loss. "%f" emits only 6 fractional digits, so the
     format-and-reparse round trip was lossy -- toFloatList([0.123456789])
     returned 0.123457.

The element is already a float8, so the whole format/reparse step is
unnecessary. Assign elem->val.float_value directly. This removes the
stack buffer entirely (no magic buffer size to justify) and fixes both
the overflow and the precision loss at once.

Also harden toStringList(): its "%.*g"/"%ld" conversions use bounded
formats and were never overflow-prone, but switch them from sprintf to
snprintf as defensive depth.

Add regression coverage to regress/sql/expr.sql for both the large
magnitude case (no overflow) and precision preservation.

This reimplements the fix originally proposed by David Christensen in
#2410, whose report identified the sprintf overflow.

Co-authored-by: David Christensen <david.christensen@snowflake.com>

* Support outer references in reduce() fold bodies (#2448)

Allow a reduce(acc = init, var IN list | body) fold body to reference
loop-invariant values from the enclosing query -- outer-query variables and
cypher() parameters -- in addition to the accumulator and element. These were
previously rejected with ERRCODE_FEATURE_NOT_SUPPORTED.

How it works
------------
The fold body is still compiled to a standalone expression evaluated by
age_reduce_transfn, so an outer reference (which cannot be evaluated there)
is captured at transform time and supplied as a value:

  - After the accumulator and element are rewritten to PARAM_EXEC params 0 and
    1, transform_cypher_reduce() walks the body and replaces each loop-invariant
    outer reference -- one that references an outer Var or a cypher() $parameter
    but not the accumulator/element -- with a new PARAM_EXEC param 2, 3, ... in
    body order. Capture is at leaf granularity: only the bare outer value is
    hoisted out of the body, while the operators, function calls and
    CASE/AND/OR/coalesce branches around it stay in the serialized body.
    Because a captured value becomes an aggregate argument that the executor
    evaluates eagerly and unconditionally, hoisting a whole computed subtree
    (for example "1/z" under a never-taken CASE branch) would defeat the fold's
    short-circuiting; capturing only the leaf keeps evaluation under the body's
    own control flow. The one exception is an outer reference that is not itself
    agtype-typed -- most commonly the graphid inside a graph vertex/edge
    variable -- whose smallest enclosing agtype-typed subtree is captured whole,
    since it cannot stand alone as an agtype[] extra.
  - The captured expressions are passed to the aggregate as a trailing
    agtype[] argument; age_reduce(agtype, text, agtype, agtype[]) and its
    transition function gain this argument.
  - age_reduce_transfn sizes its param array to 2 + the number of captures and
    binds the captured values to params 2.. on every row. Because the captures
    are evaluated in the outer query context as ordinary aggregate arguments, a
    correlated capture is re-evaluated per group, so an outer value that varies
    per row (for example under UNWIND) is folded with the correct value.
    Each capture slot is rebound on every row, and the trailing extras
    argument is read only when the aggregate actually passes it (PG_NARGS),
    keeping the transition safe under direct age_reduce() SQL calls and an
    older 4-argument signature.

This keeps the no-core-patch design: the body is still a serialized standalone
expression, and the only new machinery is the captured-value plumbing.

Still rejected
--------------
Subqueries in the body (including a nested reduce()) and aggregate functions
remain unsupported and raise a clean ERRCODE_FEATURE_NOT_SUPPORTED error: a
subquery cannot be planned as a plain aggregate argument, and an aggregate in a
per-element fold is undefined per the openCypher specification.

Tests
-----
age_reduce gains an "Outer references in the fold body" section covering a
plain outer variable, an outer variable used as a multiplier, two distinct
outer variables, a property of an outer graph variable, the same outer variable
referenced more than once, a property of an outer map, a subexpression that
mixes an outer reference with the element (only the loop-invariant part is
captured), an outer reference inside a CASE branch of the body, a NULL outer
value propagating through the fold, multiple captures mixing a NULL and a
non-NULL outer value, an outer variable that changes per row (captured per
group), and a cypher() parameter supplied via a prepared statement.

A "Short-circuit evaluation is preserved for outer references in the body"
section verifies that a guarded outer sub-expression is not evaluated on a
branch that is not taken: a never-taken CASE THEN branch, a never-taken CASE
ELSE branch, an OR and an AND that short-circuit, and a coalesce -- each of
which would divide by zero if the outer "1/w" were hoisted into an eagerly
evaluated aggregate argument -- plus a guarded branch that is taken and
evaluates its outer division normally.

The previously-rejected outer-variable case is moved out of the not-supported
section, which now covers a nested reduce() (any subquery in the body is
unsupported) and an aggregate in the body.

The same change also broadens the base reduce() coverage with value-type folds
(a float accumulator, negative numbers, a map accumulator passed through
unchanged, and list elements indexed in the body), function calls in the fold
body (a scalar function over the element and the list itself produced by a
function), reduce() composed with surrounding expressions (consumed by another
function and used in a comparison), and syntax-error checks for each required
piece of the form -- the "= init", ", var IN list", and "| body" clauses, plus
a rejected qualified iterator variable. 42/42 installcheck pass.

Co-authored-by: Copilot <copilot@github.com>

modified:   age--1.7.0--y.y.y.sql
modified:   regress/expected/age_reduce.out
modified:   regress/sql/age_reduce.sql
modified:   sql/age_aggregate.sql
modified:   src/backend/parser/cypher_clause.c
modified:   src/backend/utils/adt/agtype.c

* Fix segfault and out-of-bounds reads in file loaders on malformed rows (#2453)

* Fix segfault and out-of-bounds reads in file loaders on malformed rows

load_edges_from_file() and load_labels_from_file() build their COPY
parser with only format=csv and header=false, so COPY uses its default
comma delimiter. A file delimited by anything else (or a malformed row)
then parses with an unexpected column count, and the loaders indexed the
parsed fields without validating that count:

- process_edge_row() reads the four fixed fields fields[0..3]
  unconditionally. A non-comma-delimited edge file parses as a single
  column, so fields[1..3] are out of bounds -> segfault (issue #2449).
- create_agtype_from_list()/_i() pair header[i] with fields[i] for all
  i < nfields, so a row with more fields than the header reads header[i]
  out of bounds.

Add bounds validation that turns these into clear errors:

- Edge header must have >= 4 columns; a smaller count almost always
  means the wrong delimiter, so the error carries a hint.
- Each edge row must have >= 4 columns and no more than the header's.
- Each label row must have no more than the header's column count.

Rows with fewer trailing columns than the header remain allowed, matching
existing behavior (exercised by the existing conversion tests).

This closes the segfault and out-of-bounds reads. The silent mis-parsing
of a non-comma file whose header and rows share the same (wrong) column
count is not detectable here; adding a delimiter option to the load
functions is a separate follow-up.

Adds a regression test in age_load using a pipe-delimited edge file.

Addresses #2449.

* loader guards: clarify error wording and add per-row regression coverage

Address review feedback on the nfields guards:

- Error messages now say "the header's %d columns" (was "the header's %d"),
  making the count's unit explicit.
- Add regression cases exercising the per-row guards, which previously only
  had coverage for the mis-delimited-header path:
    * an edge row with fewer than 4 columns
    * an edge row with more columns than the header
    * a label row with more columns than the header
  Each asserts a clean ERROR (these were the out-of-bounds reads the guards
  now catch).

* ci: pin Build/Regression runner to ubuntu-24.04 and guard Bison version for GLR grammar (#2445)

* ci: pin runner to ubuntu-24.04 + guard Bison version for GLR grammar

The Cypher GLR grammar pins exact shift/reduce and reduce/reduce conflict
counts via %expect / %expect-rr in cypher_gram.y, and Bison treats %expect
as exact-match: a different Bison version can report different counts and
break the build. ubuntu-latest floats to new Ubuntu releases (and new Bison
versions), which would silently shift those counts.

Pin runs-on to ubuntu-24.04 to freeze Bison at 3.8.x, and add a guard step
that fails loudly with a pointer to cypher_gram.y if Bison ever drifts off
3.8.x. Reproducibility comes from pinning the variable rather than widening
the conflict-count tolerance, keeping the exact-match alarm for genuinely
new grammar conflicts intact.

* ci: make Bison version parse robust (awk + explicit empty guard)

Address Copilot review on #2445: the previous grep-based parse could
silently yield an empty version and fail with a confusing
'Bison  != 3.8.x' message. Parse the version field with awk and error
explicitly when it can't be determined.

Resolved conflict: .github/workflows/installcheck.yaml

* Add automatic header-dependency tracking to the Makefile (#2454)

AGE lists OBJS explicitly and relies on PGXS, whose built-in dependency
tracking only runs when the server was built with --enable-depend (often
disabled). Consequently, editing a header did not recompile the .c files
that include it, leaving stale .o files. This is especially dangerous for
node/struct headers: a stale ag_nodes.o keeps an outdated node_size, so
_readExtensibleNode under-allocates and readNode corrupts the heap
("unrecognized node type").

Emit a .d file beside each object via -MMD -MP and -include them, deriving
DEPFILES from OBJS. The mechanism is self-contained (independent of
--enable-depend): -MMD skips system headers and -MP tolerates deleted
headers. On servers built with --enable-depend, PGXS appends its own -MF
after CFLAGS (last -MF wins), so this degrades cleanly to PGXS's tracking.
Add DEPFILES to EXTRA_CLEAN and *.d to .gitignore.

Co-authored-by: Copilot <copilot@github.com>

modified:   .gitignore
modified:   Makefile

---------

Co-authored-by: serdarmumcu <serdar.mumcu@udemy.com>
Co-authored-by: Greg Felice <gregfelice@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Prashant Chinnam <5108573+crprashant@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: David Christensen <david.christensen@snowflake.com>
jrgemignani pushed a commit that referenced this pull request Jul 2, 2026
* Support pattern expressions in WHERE clause via GLR parser (issue #1577)

Enable bare graph patterns as boolean expressions in WHERE clauses:

  MATCH (a:Person), (b:Person)
  WHERE (a)-[:KNOWS]->(b)        -- now valid, equivalent to EXISTS(...)
  RETURN a.name, b.name

Previously, this required wrapping in EXISTS():
  WHERE EXISTS((a)-[:KNOWS]->(b))

The bare pattern syntax is standard openCypher and is used extensively
in Neo4j.  Its absence was the most frequently cited migration blocker.

Implementation approach:
- Switch the Cypher parser from LALR(1) to Bison GLR mode.  GLR handles
  the inherent ambiguity between parenthesized expressions '(' expr ')'
  and graph path nodes '(' var_name label_opt props ')' by forking the
  parse stack and discarding the failing path.
- Add anonymous_path as an expr_atom alternative with %dprec 1 (lower
  priority than expression path at %dprec 2).  The action wraps the
  pattern in a cypher_sub_pattern + EXISTS SubLink, reusing the same
  transform_cypher_sub_pattern() machinery as explicit EXISTS().
- Extract make_exists_pattern_sublink() helper shared by both
  EXISTS(pattern) and bare pattern rules.
- Fix YYLLOC_DEFAULT to use YYRHSLOC() for GLR compatibility.
- %dprec annotations on expr_var/var_name_opt resolve the reduce/reduce
  conflict between expression variables and pattern node variables.

Conflict budget: 7 shift/reduce (path extension vs arithmetic on -/<),
3 reduce/reduce (expr_var vs var_name_opt on )/}/=).  All are expected
and handled correctly by GLR forking + %dprec disambiguation.

All 32 regression tests pass (31 existing + 1 new).  New
pattern_expression test covers: bare patterns, NOT patterns, labeled
nodes, AND/OR combinations, left-directed patterns, anonymous nodes,
multi-hop patterns, EXISTS() backward compatibility, and non-pattern
expression regression checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address Copilot review: comment placement, %expect docs, test wording

1. Move "Helper function to create an ExplainStmt node" comment from
   above make_exists_pattern_sublink() to above make_explain_stmt()
   where it belongs.

2. Add block comment documenting the %expect/%expect-rr conflict
   budget: 7 S/R from path vs arithmetic on - and <, 3 R/R from
   expr_var vs var_name_opt on ) } =.

3. Clarify test comment: "Regular expressions" -> "Regular (non-pattern)
   expressions" to avoid confusion with regex.

Regression test: pattern_expression OK.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address Copilot round 3: broaden scope, remove %expect fragility

- Pattern expressions are now accepted anywhere an expr is valid
  (RETURN, WITH, SET, CASE, boolean combinations), not only WHERE.
  This matches openCypher semantics and documents the broader surface
  area that was already implicitly enabled by adding anonymous_path
  to expr_atom.  Added regression tests for each new context:
  RETURN projection (bare and AS-aliased), mixed with other
  projections, CASE WHEN, boolean AND/OR combinators, SET to
  persist a computed boolean property, and WITH ... WHERE pipeline.

- Remove the hardcoded `%expect 7` / `%expect-rr 3` conflict budget
  from cypher_gram.y.  The exact conflict counts can drift across
  Bison versions and distros, which would break builds even though
  the grammar is correct (GLR handles the conflicts at runtime via
  fork + %dprec).  Instead, pass -Wno-conflicts-sr / -Wno-conflicts-rr
  via BISONFLAGS in the Makefile so the build stays clean without
  binding us to a specific Bison release.  Kept a block comment in
  the grammar explaining why GLR conflicts are expected and how
  they resolve.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address jrgemignani review: keep -Werror, restore %expect budget

Reverts the broad `-Werror` drop and the no-%expect approach from the
prior round on jrgemignani's request.  The earlier framing — that
conflict counts drift across Bison versions, so %expect is fragile —
overcorrected: it removed the only build-time alarm bell for unintended
new conflicts.

Makefile: keep -Werror so any unexpected Bison warning (unused rules,
undeclared types, etc.) still fails the build; downgrade only the two
conflict categories to plain warnings via -Wno-error=conflicts-sr
-Wno-error=conflicts-rr.  pgxs auto-adds -Wno-deprecated, so existing
%name-prefix= / %pure-parser directives remain non-erroring.

cypher_gram.y: add `%expect 7` and `%expect-rr 3` matching the
Bison 3.8.2 totals.  Bison treats %expect as exact-match, not as a
ceiling — any deviation fails the build and forces an audit of the new
conflicts.  Comment updated to reflect that future Bison versions
reporting different counts should bump the numbers explicitly with a
version note in the commit message, rather than removing the directive.

No grammar or runtime change.  Cassert installcheck 34/34 AGE tests
green.

* Add follow-up regression coverage for pattern expressions (#2360)

Addresses the non-blocking test-coverage follow-ups from the review:
pattern expressions in additional contexts opened up by allowing
anonymous_path as an expr_atom.

New cases (all verified against a PG18 build):
- Single-node pattern on a bound variable (a:Label). Documented as an
  EXISTS existence check, NOT an openCypher label predicate: a matching
  label is always true, and a non-matching label hits AGE's pre-existing
  "multiple labels for variable" restriction (captured as expected error).
- Pattern expressions inside list and map literals.
- Pattern expressions as function arguments: collect() shows correct
  per-row booleans; count() counts all rows (non-null bool) -- documented
  so the value is not mistaken for a bug.
- Pattern expression in OPTIONAL MATCH ... WHERE (null-preserving).
- EXISTS() and a bare pattern combined in one predicate.

make installcheck: 33/33 green.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants