Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# CHANGELOG

### **1.2.0** (July 24, 2026)

### Features

- Add composable scalar functions in `WHERE` and `RETURN`: `toLower`, `toUpper`, `trim`, `coalesce`, `size`, and relationship `type`.
- Add `COLLECT` aggregation and support scalar expressions inside aggregations.
- Add `relationships()` for selected relationship sequences.
- Add scoped `ALL`, `ANY`, `NONE`, and `SINGLE` relationship-list predicates with null-aware semantics.

### Performance

- Add continuous benchmarks for scalar filtering, grouped collection, relationship-list predicates, and nested scalar aggregation.

### Internals

- Introduce typed expression evaluation and concrete aggregation classes.

### **1.1.0** (June 30, 2026)

### Features
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ RETURN A.club, B.club
""")
```

See [examples.md](docs/examples.md) for more!
See [examples.md](docs/examples.md) for more, or read the
[expressions and functions guide](docs/Expressions.md).
Implementation details are documented in [Architecture.md](docs/Architecture.md).

### Example Usage with SQL

Expand Down Expand Up @@ -80,9 +82,11 @@ RETURN
| `DISTINCT` | ✅ Thanks @jackboyla! |
| `ORDER BY` | ✅ Thanks @jackboyla! |
| `IN` | ✅ Thanks @davidmezzetti! |
| Aggregation functions (`COUNT`, `SUM`, `MIN`, `MAX`, `AVG`) | ✅ Thanks @jackboyla! |
| Aggregation functions (`COUNT`, `SUM`, `MIN`, `MAX`, `AVG`, `COLLECT`) | ✅ Thanks @jackboyla! |
| Aliasing of returned entities (`return X as Y`) | ✅ Thanks @jackboyla! |
| `WHERE` clause arithmetic support for math / numbers | ✅ Thanks @q-rosiebloxsom! |
| Scalar functions (`toLower`, `toUpper`, `trim`, `coalesce`, `size`, `type`) | ✅ |
| Relationship lists and predicates (`ALL`, `ANY`, `NONE`, `SINGLE`) | ✅ |
| Negated edges (`where not (a)-->(b)`) | 🥺 |
| `OPTIONAL MATCH` | 🥺 |
| Graph mutations (e.g. `DELETE`, `SET`,...) | 🥺 |
Expand Down
68 changes: 68 additions & 0 deletions docs/Architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Query expression architecture

Grand Cypher parses a query with Lark and transforms the parse tree into typed
runtime objects. Matching and expression evaluation are intentionally separate:

1. `GrandCypherTransformer` builds the motif and expression objects.
2. `GrandCypherExecutor` finds graph matches.
3. Expressions evaluate against one `Match`, the host graph, relationship
bindings, and an optional temporary scope.
4. Aggregation expressions group per-match values after lookup.

## Expression protocol

Runtime expressions implement:

```python
evaluate(match, host, return_edges, scope=None)
```

Built-in typed references and arithmetic expressions use the same contract.
`ExpressionBase` provides inexpensive runtime dispatch; the structural
`Expression` protocol remains available for typing.

Typed references have distinct behavior:

- `EntityRef` represents a bare node or relationship name.
- `AttributeRef` resolves node and relationship attributes.
- `IDRef` resolves a matched node identifier.

## Scalar composition

Scalar expression objects contain argument expressions. Evaluation recursively
resolves their arguments, allowing function nesting, scalar-to-scalar
comparisons, and arithmetic composition without adding function-specific paths
to the executor.

## Scoped evaluation

The optional `scope` maps temporary names to values. Scoped names take priority
over graph bindings. List predicates create a child scope for each relationship
item, binding the predicate variable to that relationship's attribute mapping.

Scope propagates through scalar functions, arithmetic expressions, boolean
conditions, and comparisons.

## Aggregation

`AggregationExpression` owns shared grouping mechanics. Concrete classes
implement `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, and `COLLECT`.

Simple node and relationship references use the existing lookup columns.
Supported computed arguments, such as scalar functions and relationship-list
expressions, evaluate once per match before grouping. Direct arithmetic
aggregation arguments are not currently part of the grammar. Aggregations in
the same query must produce identical group keys.

## Relationship lists

`RelationshipsExpression` materializes the concrete edge path selected for a
relationship binding. It respects multigraph edge keys, so predicates evaluate
the specific matched relationship rather than another parallel edge.

## Index hints

The indexer optimizes supported simple attribute comparisons. Computed scalar,
arithmetic, relationship-list, and list-predicate expressions fall back to the
normal full evaluation path when they cannot be represented safely as an index
condition.
100 changes: 100 additions & 0 deletions docs/Expressions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Expressions and functions

Grand Cypher supports composable expressions in `WHERE` and `RETURN` clauses.

## Scalar functions

The following scalar functions are supported:

| Function | Result |
| --- | --- |
| `ID(n)` | Graph identifier for a matched node |
| `toLower(value)` | Lowercase string |
| `toUpper(value)` | Uppercase string |
| `trim(value)` | String with surrounding whitespace removed |
| `coalesce(a, b, ...)` | First non-null argument |
| `size(value)` | Length of a string or relationship list |
| `type(r)` | One relationship label for a matched relationship |

Functions can be nested and used on either side of a comparison:

```cypher
MATCH (n)
WHERE toLower(trim(n.name)) = toLower(n.canonical_name)
RETURN ID(n), toUpper(n.name)
```

Scalar functions also compose with arithmetic expressions:

```cypher
MATCH (n)
WHERE size(n.name) + 1 > 8
RETURN n.name
```

## Aggregations

Supported aggregation functions are `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, and
`COLLECT`. Aggregations can group by other returned values:

```cypher
MATCH (n)
RETURN n.team, COLLECT(n.name), AVG(n.score)
ORDER BY AVG(n.score) DESC
```

Aggregation arguments can be scalar expressions:

```cypher
MATCH (n)
RETURN n.team, AVG(size(n.name)) AS average_name_length
ORDER BY AVG(size(n.name)) DESC
```

Direct arithmetic expressions inside aggregation arguments are not currently
supported.

`COLLECT` excludes null values.

## Relationship lists

For a named relationship binding, `relationships(r)` returns the selected
relationship sequence for that match. This is especially useful with
variable-length paths:

```cypher
MATCH (a)-[r*1..3]->(b)
WHERE size(relationships(r)) = 2
RETURN ID(a), ID(b)
```

Each item in the relationship list is an attribute dictionary for one selected
relationship.

If a relationship has multiple labels, `type(r)` returns one of them; label
selection order is not defined.

## List predicates

`ALL`, `ANY`, `NONE`, and `SINGLE` evaluate a condition for each item in a
relationship list:

```cypher
MATCH (a)-[r*2]->(b)
WHERE ALL(edge IN relationships(r) WHERE edge.weight > 5)
RETURN ID(a), ID(b)
```

The loop variable is scoped to the predicate and supports compound conditions:

```cypher
MATCH (a)-[r*2]->(b)
WHERE ALL(
edge IN relationships(r)
WHERE edge.weight > 5 AND edge.kind = "friend"
)
RETURN ID(a), ID(b)
```

List predicates use Cypher's three-valued null behavior. A null predicate
result propagates when the known values do not otherwise determine the answer.
2 changes: 1 addition & 1 deletion grandcypher/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@
start="start",
)

__version__ = "1.0.1"
__version__ = "1.2.0"


_ALPHABET = string.ascii_lowercase + string.digits
Expand Down
13 changes: 13 additions & 0 deletions grandcypher/test_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import re
from pathlib import Path

from . import __version__


def test_package_version_matches_project_metadata():
pyproject = (Path(__file__).parents[1] / "pyproject.toml").read_text()
project = pyproject.split("[project]", 1)[1].split("[", 1)[0]
version_match = re.search(r'^version\s*=\s*"([^"]+)"', project, re.MULTILINE)

assert version_match is not None
assert __version__ == version_match.group(1)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "grand-cypher"
version = "1.1.0"
version = "1.2.0"
authors = [
{ name = "Jordan Matelsky", email = "opensource@matelsky.com" }
]
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading