Embedder: pgGraph — a PostgreSQL extension that exposes graph queries over ordinary registered tables. It is adding a
graph.cypher(text, jsonb)SQL function that parses openCypher v9 through cyrs and dispatches the resulting plan to pgGraph's existing in-memory engine (reads) and SPI-issued DML (writes).Companion document: pgGraph's integration spec lives in
pgGraph/docs/contributor_guide/cypher-frontend/(000–080). Its080-open-questions.mdQ-UP-*items each cite a section number below; the§N.Mnumbering here is a stable contract — pgGraph docs link to it. Retitle freely, but do not renumber.
This is distinct from docs/embedder-issues/ (0001–0010), which is
feedback from a different embedder migrating off a bespoke parser.
A few asks here overlap those; cross-links are noted inline.
Every request in this document landed in cyrs 0.1.0 (19 crates published to crates.io, 2026-05-10; embedder PRs #56 and #58). Two items — §1.3 and §4.2 — were over-delivered beyond the original ask.
The per-section Status lines and the summary table record what
shipped; the Problem / Proposed shape bodies are kept as the
historical record of the ask. pgGraph's 080-open-questions.md
Upstream section is updated to match — no Q-UP-* item blocks
pgGraph any longer.
Each request carries:
- Blocks — the pgGraph milestone (M0–M5) that needs it.
- Severity — from pgGraph's perspective; cyrs may prioritise differently.
- Status — what shipped (all resolved as of cyrs 0.1.0).
- Blocks: pgGraph M5. Severity: medium (deferrable).
- Status: ✅ Resolved (cyrs 0.1.0) —
cyrs_plan::ReadOpgained aShortestPath { input, from, to, rel, kind, bind_path }variant; sema lowersshortestPath/allShortestPathsto it.
Problem. cyrs_plan::ReadOp has Source, Expand, Filter,
Project, Aggregate, OrderBy, Skip, Limit, Distinct,
Unwind, Union, With, OptionalJoin — but no shortest-path
operator. MATCH p = shortestPath((a)-[*]-(b)) either fails to lower
or degrades to a generic var-length Expand, losing the
shortest-path semantics. pgGraph has a native path_finder module
and wants a dedicated op to dispatch to it instead of post-filtering
an exhaustive expansion.
Proposed shape. A new ReadOp variant:
ShortestPath {
input: OpId,
from: VarId,
to: VarId,
rel: RelSpec, // the var-length pattern between the endpoints
kind: ShortestPathKind, // Single | All
bind_path: VarId, // receives the path value (see §1.2)
}sema validates the shortestPath / allShortestPaths call shape and
lowers to it.
- Blocks: pgGraph M2 (
RETURN pfor a matched path). - Severity: medium.
- Status: ✅ Resolved — documented contract: the plan IR has
no
Pathtype; the embedder owns path materialisation;cyrs_hir::VarKind::Pathcarries the contract andShortestPathis the only path-producing read operator.
Problem. For MATCH p = (a)-[*1..3]->(b) RETURN p, the embedder
must materialise p as a value. It is unclear from the IR docs how a
path-bound variable is represented: is p a VarId carrying a
Path type, and what is the structural contract — an ordered
node/relationship element sequence, or an opaque value the embedder
shapes itself?
Proposed shape. Either:
- document a
PlanType::Pathand state that a path-bound variable yields a value withnodes()/relationships()/length()accessor semantics, or - state explicitly that cyrs guarantees only the ordered element sequence and the embedder owns materialisation.
Either is fine — pgGraph needs the contract pinned, not a specific representation. (cyrs took option 2.)
- Blocks: pgGraph M2 (
RETURNwith function calls). - Severity: low.
- Status: ✅ Resolved, exceeded — beyond the existing
builtin_names()/builtin_count(), cyrs 0.1.0 addedStandardLibrary::builtin_signature()exposing the fullFunctionSignatureper name, includingdeterministicandnull_propagatingflags. Builtin enumeration is now normative and embedder-facing.
Context. pgGraph dispatches each Cypher function to either a SQL fragment or a Rust evaluator, and wants a CI test that fails when cyrs adds a function pgGraph does not handle.
cyrs_schema::StandardLibrary already exposed
builtin_names() -> Vec<&'static str> (documented "stable across
releases") and builtin_count() -> usize. That was already enough
for the drift test; the request was to confirm it and, if cheap,
expose per-function arity / signature metadata.
- Blocks: pgGraph M4. Severity: high.
- Status: ✅ Resolved —
WriteOp::MergeNodeandMergeRelgainedkey_props: Vec<SmolStr>, populated by HIR→Plan lowering whenever the pattern'spropsis a literalExpr::Map. When it is not,key_propsis empty and the embedder falls back to inspectingprops. - Related:
docs/embedder-issues/0007(write-side coverage).
Problem. WriteOp::MergeNode { labels, props: Expr, on_create, on_match, bind } exposes the merge pattern's properties as a single
opaque props map expression. pgGraph compiles MERGE to
INSERT ... ON CONFLICT (<key columns>) DO UPDATE ..., which needs
the list of key property names as structured data. Today pgGraph
must crack open the props Expr, assume it is a literal map, and
re-derive the keys — duplicating analysis lowering already did.
Proposed shape. Surface the pattern key properties as structured data, e.g.:
MergeNode {
labels: Vec<SmolStr>,
key_props: Vec<(SmolStr, Expr)>, // the {k: ...} in the MERGE pattern
on_create: Vec<WriteOp>,
on_match: Vec<WriteOp>,
bind: Option<VarId>,
}If keeping props: Expr is preferred, instead guarantee it is
always a literal map expression and document that the embedder may
destructure it. (cyrs kept props and added a key_props: Vec<SmolStr> mirror.)
- Blocks: pgGraph M4. Severity: high.
- Status: ✅ Resolved — both methods added to the
SchemaProvidertrait (default-empty);cyrs-sema's schema-aware pass now proves MERGE determinism upstream. - Related:
docs/embedder-issues/0004(SchemaProvider adapter).
Problem. SchemaProvider today has labels,
relationship_types, node_properties, relationship_properties,
relationship_endpoints, inverse_of, function, procedure,
schema_digest — but nothing exposing uniqueness constraints.
sema therefore cannot prove a MERGE key is backed by a declared
uniqueness; the determinism check falls to the embedder at execution
time.
Proposed shape. Add to the trait:
/// Ordered property tuples that are guaranteed unique for this label.
fn label_unique_props(&self, label: &str) -> Vec<Vec<SmolStr>> { Vec::new() }
fn rel_type_unique_props(&self, rel_type: &str) -> Vec<Vec<SmolStr>> { Vec::new() }Default-empty so existing impls compile unchanged. sema uses them to diagnose a MERGE whose key is not a registered uniqueness tuple. (Shipped as proposed.)
- Blocks: pgGraph M3 (multi-label
CREATE). Severity: high. - Status: ✅ Resolved — added as
labels_compatible(&self, labels: &[SmolStr]) -> Option<bool>. TheOption(vs the proposed barebool) lets a provider answer "no opinion" withNone. - Related:
docs/embedder-issues/0004.
Problem. CREATE (n:A:B) requires knowing whether labels A and
B can co-exist on one node. For a relational embedder that is
whether they map to the same (or a compatible) table. No
SchemaProvider method asks this.
Proposed shape.
fn labels_compatible(&self, labels: &[SmolStr]) -> bool { labels.len() <= 1 }Default rejects multi-label so existing impls keep current behaviour.
sema rejects incompatible label sets with a schema-aware diagnostic.
(Shipped with an Option<bool> return — None = no opinion.)
- Blocks: pgGraph M1. Severity: high.
- Status: ✅ Resolved (cy-7it) —
lower::PlanStatementcarries aparamslist ofParamRef, each with a best-effortParamType(with anUnknownvariant for unconstrained parameters).
Problem. Cypher $param references must be bound by the embedder
from its own value domain (for pgGraph: JSONB / Postgres scalar
types). pgGraph needs (a) a way to enumerate every parameter a
statement references and (b), ideally, a declared or inferred type
per parameter so the binding can be type-checked before execution.
Proposed shape. A parameters() accessor on the lowered
statement / plan returning:
pub struct ParamRef {
pub name: SmolStr,
pub inferred_type: Option<PlanType>, // None when unconstrained
}plus a param-map input to the execution-facing API so substitution is
consistent across the read and write phases. (Shipped as
PlanStatement::params with ParamRef + ParamType::Unknown.)
- Blocks: pgGraph M1 (any schema-rejection diagnostic).
- Severity: medium.
- Status: ✅ Resolved —
E4500..=E4999is now formally reserved as an embedder-owned range. ADiagCode::ALLtest asserts no cyrs-defined code lands in it; cyrs deliberately defines noE45xx–E49xxvariant of its own. - Related:
docs/embedder-issues/0003(typed diagnostic codes).
Problem. cyrs's cyrs-diag code ranges (spec §10.2) are E0xxx
syntax, E1xxx name resolution, E2xxx schema-free sema, E3xxx
schema-aware sema, E4xxx dialect/compat, E5xxx type system,
W6xxx/W7xxx lints, N8xxx notes. pgGraph mints its own codes for
rejections cyrs cannot diagnose because they concern pgGraph's
storage model (label not registered, MERGE key not backed by a
constraint, label-set arithmetic unsupported, etc.). It currently
uses the E45xx block for these.
Ask. Formally reserve E45xx (or a clearly-named host/embedder
sub-range) as embedder-owned, and guarantee cyrs-diag's own
DiagCode enum never mints codes there. Without the guarantee a
future cyrs release could collide with pgGraph's E4500–E4560.
Proposed shape. A note in spec §10.2 and a DiagCode test
asserting the range stays empty. Pairs well with embedder-issue 0003
(typed DiagCode enum) — a reserved range is easier to police with
a typed enum than with u16 discriminants. (Shipped: the reserved
range is E4500..=E4999, comfortably covering pgGraph's
E4500–E4560.)
- Blocks: pgGraph M1. Severity: high.
- Status: ✅ Resolved — both
lower_statement(&str)andlower_parse(&Parse)now returnResult<Statement, HirLowerError>; the newcyrs-hirerrormodule definesHirLowerErrorwithParseFailedandInvariantvariants. No panic boundary needed. - Related:
docs/embedder-issues/0001,0010.
Problem. cyrs_hir::lower::lower_statement(&str) -> Statement and
lower_parse(&Parse) -> Statement both return Statement
unconditionally. (Good news: lower_parse already exists, so the
re-parse cost in embedder-issue 0001/0010 is addressed for pgGraph.)
If lowering can fail — input that parsed but cannot lower, or an
internal invariant violation — the embedder has no typed failure
channel. pgGraph would have to wrap the call in catch_unwind; a
panic inside a Postgres backend caught and re-raised is fragile and
yields a generic 42601 instead of a precise diagnostic.
Proposed shape.
pub fn lower_parse(parse: &Parse) -> Result<Statement, LowerError>;
pub fn lower_statement(src: &str) -> Result<Statement, LowerError>;A best-effort partial Statement alongside the error is welcome but
not required — pgGraph only needs a non-panicking failure path.
(Shipped as proposed; the error type is named HirLowerError.)
- Blocks: pgGraph M1 (
errposition()carets in diagnostics). - Severity: low.
- Status: ✅ Resolved, exceeded —
Statement::span_of(HirId)returnsOption<Range<usize>>, i.e. a byte range directly, rather than theTextRangeoriginally asked for. This folds in embedder-issue 0008 (theTextRange → Range<usize>conversion): pgGraph gets a Postgres-ready byte offset in one call. - Related:
docs/embedder-issues/0008(span ↔ byte-range).
Context. Postgres errposition() takes a byte offset into the
query string; pgGraph wants to render carets under offending tokens.
The capability already existed: the HIR statement carries a
node_map: IndexMap<HirId, SyntaxNode> and a
syntax_for(HirId) -> Option<&SyntaxNode> accessor, and a
SyntaxNode yields its TextRange. So
stmt.syntax_for(id).map(|n| n.text_range()) already gave the span.
Ask. Add a one-line convenience and document it as the supported path:
impl Statement {
pub fn span(&self, id: HirId) -> Option<TextRange> {
self.syntax_for(id).map(|n| n.text_range())
}
}Pairs with embedder-issue 0008's TextRange → Range<usize> helper so
the embedder gets a byte offset in one hop. (cyrs shipped
span_of(HirId) -> Option<Range<usize>> — the byte range directly.)
These were not API changes — they asked cyrs to treat already-documented behaviour as a stable contract pgGraph can build on. Both contracts are documented and held stable.
- Blocks: pgGraph M1. Severity: low.
- Status: ✅ Resolved —
ReadOp::Filter's rustdoc contract is documented and treated as stable behaviour.
ReadOp::Filter's rustdoc states rows where the predicate evaluates
to false or null are dropped. pgGraph relies on this to align
Cypher 3-valued logic with SQL WHERE (which also drops null) when
it pushes a predicate to SQL.
- Blocks: pgGraph M2. Severity: low.
- Status: ✅ Resolved —
ReadOp::Aggregate's empty-keyscontract is documented and treated as stable behaviour.
ReadOp::Aggregate's rustdoc states an empty keys vec aggregates
the whole input into a single row. Cypher requires that single row to
appear even when the input is empty (e.g. MATCH (n) RETURN count(n) returns 0, not zero rows). pgGraph's row-evaluator and
its SQL GROUP BY emission diverge on this, so it special-cases
empty-key aggregates.
- Blocks: pgGraph release (not development). Severity: medium.
- Status: ✅ Resolved — cyrs 0.1.0 published 19 crates to
crates.io (2026-05-10). pgGraph can pin a crates.io version for
release; it keeps the
../../cyrspath dependency for M1–M5 co-development and flips to0.1.0before shipping. - Related:
docs/embedder-issues/0005(crate naming).
Problem. pgGraph depends on cyrs via a path dependency
(../../cyrs). That is fine for co-development but cannot ship: a
released pgGraph needs a reproducible, pinnable cyrs version. cyrs is
not on crates.io and has no stable release tags.
Proposed shape. Either:
- publish the consumed crates (
cyrs-hir,cyrs-plan,cyrs-schema,cyrs-sema,cyrs-diag) to crates.io with a SemVer minor, or - cut signed git tags (
v0.1.0, …) pgGraph can pin a rev against.
Resolve embedder-issue 0005 (the cyrs-* package / cypher_* lib
naming split) first if publishing. cyrs already has release.toml
and docs/release-playbook.md, so the tooling exists. (cyrs took
option 1 — full crates.io publication at 0.1.0.)
All 13 items resolved in cyrs 0.1.0 (PRs #56, #58).
| § | Ask | pgGraph milestone | Severity | Status |
|---|---|---|---|---|
| 1.1 | ShortestPath ReadOp |
M5 | medium | ✅ ReadOp::ShortestPath |
| 1.2 | Path-variable contract | M2 | medium | ✅ documented (embedder-owned) |
| 1.3 | Builtin function enumeration | M2 | low | ✅ builtin_signature() (exceeded) |
| 2.1 | MERGE key surface on WriteOp |
M4 | high | ✅ MergeNode.key_props |
| 2.2 | *_unique_props on SchemaProvider |
M4 | high | ✅ shipped on the trait |
| 2.3 | labels_compatible on SchemaProvider |
M3 | high | ✅ -> Option<bool> |
| 2.4 | Typed parameter surface | M1 | high | ✅ PlanStatement::params |
| 3.1 | Reserve E45xx embedder code range |
M1 | medium | ✅ E4500..=E4999 reserved |
| 4.1 | lower_* return Result |
M1 | high | ✅ Result<_, HirLowerError> |
| 4.2 | HirId → span convenience |
M1 | low | ✅ span_of() -> Range<usize> (exceeded) |
| 5.1 | Filter 3VL semantics stable |
M1 | low | ✅ documented contract |
| 5.2 | Empty-key Aggregate emits one row |
M2 | low | ✅ documented contract |
| 6.1 | Stable release channel | release | medium | ✅ crates.io 0.1.0 |
Outcome. cyrs cleared every pgGraph blocker in two PRs. §1.3 and
§4.2 were over-delivered (full signature metadata; a byte-range span
accessor). pgGraph's M1–M5 plan now builds straight against the
shipped 0.1.0 API with no embedder-side workarounds — see
pgGraph/docs/contributor_guide/cypher-frontend/080-open-questions.md.