diff --git a/CHANGELOG.md b/CHANGELOG.md index f651b0b..ae17c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 99dd122..9a15c13 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`,...) | 🥺 | diff --git a/docs/Architecture.md b/docs/Architecture.md new file mode 100644 index 0000000..911283c --- /dev/null +++ b/docs/Architecture.md @@ -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. diff --git a/docs/Expressions.md b/docs/Expressions.md new file mode 100644 index 0000000..9f50395 --- /dev/null +++ b/docs/Expressions.md @@ -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. diff --git a/grandcypher/__init__.py b/grandcypher/__init__.py index e2e71de..8d19768 100644 --- a/grandcypher/__init__.py +++ b/grandcypher/__init__.py @@ -203,7 +203,7 @@ start="start", ) -__version__ = "1.0.1" +__version__ = "1.2.0" _ALPHABET = string.ascii_lowercase + string.digits diff --git a/grandcypher/test_version.py b/grandcypher/test_version.py new file mode 100644 index 0000000..e126d6b --- /dev/null +++ b/grandcypher/test_version.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 7947703..0bf3fe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "grand-cypher" -version = "1.1.0" +version = "1.2.0" authors = [ { name = "Jordan Matelsky", email = "opensource@matelsky.com" } ] diff --git a/uv.lock b/uv.lock index c1d2140..8ebe27a 100644 --- a/uv.lock +++ b/uv.lock @@ -34,7 +34,7 @@ wheels = [ [[package]] name = "grand-cypher" -version = "1.0.1" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "cachetools" },