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
2 changes: 1 addition & 1 deletion docs/03-cypher-on-a-relational-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ FalkorDB.

**What compiles.** What each statement gives, on each store, is the
[Cypher spec](https://github.com/liberation-data/drivine-cypher-spec), shared by the three Drivine libraries;
drivine-python passes it at v0.6.5 (pinned in [`tests/cypher/VERSION`](../tests/cypher/VERSION)). Reads, writes,
drivine-python passes it at v0.10.0 (pinned in [`tests/cypher/VERSION`](../tests/cypher/VERSION)). Reads, writes,
`UNION`, `CALL { … }`, `EXISTS { … }`, variable-length relationships, `shortestPath` and quantified path patterns all
compile. Cypher the compiler doesn't take yet is refused by name, with a `DrivineError`, before anything is sent to the
database.
Expand Down
14 changes: 14 additions & 0 deletions docs/04-statements.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ people = await pm.query(spec.bind({"city": "Sydney"}))
is named, and that walks no variable-length relationship or quantified path pattern of its own; on Postgres they may.
What isn't compiled yet (a path returned whole, `SKIP` of an expression…) is
refused by name before anything is sent, and what isn't Cypher with where it went wrong.
- A value whose kind the statement fixes (a literal, a list or a map written, or a variable a `WITH` binds to one) is
refused where an operator or a function takes another kind, as Neo4j refuses it when it plans the statement:
`RETURN 123 AND true` fails with `AND takes a truth value, not an integer, as in Neo4j.`, and so do
`WITH 123 AS x RETURN x.num` and `RETURN 1 IN 'foo'`. A step of 0 in `range()`, written or given, a parameter given
as the index of a list or a map written that isn't indexed by its kind, and a map, or a list of maps or of lists,
written in `SET`, are refused too, though Neo4j fails on them only when a row reaches them. A value whose kind is
known only when the row is read, such as a property or a parameter, is compiled: where it is of the wrong kind, the
statement may give null where Neo4j fails. A variable used as two kinds is refused, as Neo4j refuses it:
`MATCH (a)-[a]->()` fails with `a is a node, and is used as a relationship: a variable is of one kind, as in Neo4j.`
So is one a pattern makes where it is bound already: `MATCH (a) CREATE (a)`, a path variable bound before its
`MATCH`, or a relationship variable bound as something else.
- `truncate` takes each unit Neo4j does, from `millennium` to `microsecond`, and a map of fields to set on the value
cut short (`date.truncate('month', d, {day: 2})`), but not yet a nanosecond, a week's or a quarter's field, or a
named zone.
- `CALL db.labels()` and `CALL db.relationshipTypes()` compile, alone or within a query with `YIELD`, and give each
label and type something has now, read from the nodes and relationships; any other procedure is refused. A parameter
may be named by a keyword (`$limit`), as in Neo4j. Functions of numbers (`exp`, `log`, `sin`, `atan2`, `round` by a
Expand Down
70 changes: 70 additions & 0 deletions drivine/cypher/sql/cypher_syntax.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""
What the compiler and `check_variable_kinds` both read from a statement's syntax alone, as Drivine for TypeScript's
CypherSyntax: kept apart from the compiler, which imports `check_variable_kinds`, so neither imports the other.
"""

from __future__ import annotations

from collections.abc import Iterable, Sequence

from drivine.cypher.ast.clause import Call, Clause, With
from drivine.cypher.ast.expression import Variable
from drivine.cypher.ast.projection import ProjectionItem
from drivine.drivine_error import DrivineError


def imports(call: Call, clauses: Sequence[Clause], names: Iterable[str]) -> set[str]:
"""
The variables of the row, of `names`, that the query `clauses` of the call imports, as Neo4j has it: those its
brackets name, `CALL (a, b) { … }`, or each with `CALL (*) { … }`; with no brackets, those its leading `WITH` names
as they are, or each with `WITH *`; none where it has neither, `CALL () { … }` or `CALL { MATCH … }`. In a `UNION`,
each query's own leading `WITH`. Refuses a bracketed name not of the row, and, as Neo4j does (its 42I28), a leading
`WITH` that names a variable of the row, or `*`, with anything more: such a `WITH` imports, and one that names none
is any `WITH`, `CALL { WITH 1 AS one WHERE … }`.
"""
all_ = set(names)
if call.imports_all:
return all_
if call.imports is not None:
missing = next((it for it in call.imports if it not in all_), None)
if missing is not None:
raise DrivineError(
f"{missing} is not defined: a variable is bound by a pattern, a WITH or an UNWIND before it is used."
)
return set(call.imports)
first = clauses[0] if clauses else None
if not isinstance(first, With):
return set()
leading = first.projection

def named(item: ProjectionItem) -> str | None:
return item.expression.name if isinstance(item.expression, Variable) else None

if not leading.star and not any(named(it) in all_ for it in leading.items):
return set()
if not all(named(it) == it.name for it in leading.items) or not plain_with(first):
raise DrivineError(
"The importing WITH of a CALL { … } names variables of the row alone, as in Cypher: no alias, expression, "
"DISTINCT, WHERE, ORDER BY, SKIP or LIMIT. Import them with CALL (a, b) { … }, and work on them in a WITH "
"after."
)
return all_ if leading.star else {it.name for it in leading.items if it.name in all_}


def plain_with(clause: With) -> bool:
"""Whether the `WITH` has no `WHERE`, `DISTINCT`, `ORDER BY`, `SKIP` or `LIMIT`: one that only names what it passes
on."""
projection = clause.projection
return (
clause.where is None
and not projection.distinct
and not projection.order_by
and projection.skip is None
and projection.limit is None
)


def bracketed(call: Call) -> bool:
"""Whether the call imports by its brackets, `CALL (a) { … }` or `CALL (*) { … }`: what it imports so it sees
throughout."""
return call.imports is not None or call.imports_all
29 changes: 27 additions & 2 deletions drivine/cypher/sql/oracle_temporal_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,25 @@
from collections.abc import Callable

from drivine.cypher.sql.temporal import Temporal
from drivine.cypher.sql.temporal_sql import Shared, TemporalSql
from drivine.cypher.sql.temporal_sql import Shared, TemporalSql, years_of

CLOCK = "CAST(SYS_EXTRACT_UTC(SYSTIMESTAMP) AS TIMESTAMP)"
"""The clock as the query that reads it starts, in UTC."""

NOW = "cy_now"
"""The variable a block reads CLOCK into as it starts."""

_FORMATS = {"year": "YYYY", "quarter": "Q", "month": "MM", "week": "IW", "day": "DD", "hour": "HH24", "minute": "MI"}
_FORMATS = {
"year": "YYYY",
"weekYear": "IYYY",
"quarter": "Q",
"month": "MM",
"week": "IW",
"day": "DD",
"hour": "HH24",
"minute": "MI",
}
"""`TRUNC`'s format model of each unit: `IYYY` is the first day of the ISO week-based year."""


class OracleTemporalSql(TemporalSql):
Expand Down Expand Up @@ -129,13 +139,28 @@ def between(values: list[str]) -> str:
return self.shared([from_, to], between)

def truncate(self, unit: str, timestamp: str) -> str | None:
"""
`TRUNC` of a date to a format model. A millennium, a century and a decade are the year less its years into the
span (see `years_of`), as months before the year's first day.
"""
format_ = _FORMATS.get(unit)
if format_ is not None:
return f"CAST(TRUNC(CAST({timestamp} AS DATE), '{format_}') AS TIMESTAMP)"
if unit == "second":
return f"CAST(CAST({timestamp} AS DATE) AS TIMESTAMP)"
if unit == "millisecond":
return f"TO_TIMESTAMP(TO_CHAR({timestamp}, 'YYYY-MM-DD HH24:MI:SS.FF3'), 'YYYY-MM-DD HH24:MI:SS.FF3')"
if unit == "microsecond":
return timestamp
years = years_of(unit)
if years is not None:
return self.shared(
[timestamp],
lambda t: (
f"CAST(ADD_MONTHS(TRUNC(CAST({t[0]} AS DATE), 'YYYY'), "
f"-12 * MOD(EXTRACT(YEAR FROM {t[0]}), {years})) AS TIMESTAMP)"
),
)
return None


Expand Down
29 changes: 27 additions & 2 deletions drivine/cypher/sql/postgres_temporal_sql.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from drivine.cypher.sql.temporal import Temporal
from drivine.cypher.sql.temporal_sql import TemporalSql
from drivine.cypher.sql.temporal_sql import TemporalSql, years_of

_TRUNCATED = {"year", "quarter", "month", "week", "day", "hour", "minute", "second", "millisecond"}

Expand Down Expand Up @@ -70,4 +70,29 @@ def seconds_between(self, from_: str, to: str) -> str:
return f"EXTRACT(EPOCH FROM ({to} - {from_}))"

def truncate(self, unit: str, timestamp: str) -> str | None:
return f"date_trunc('{unit}', {timestamp})" if unit in _TRUNCATED else None
"""
`date_trunc`, but for the units it counts otherwise than Neo4j: its millennium starts in 2001 and its century in
1901, so those and the decade are the year less its years into the span (see `years_of`). The week-based year
is so many weeks before the timestamp's week, its ISO week less one.
"""
if unit in _TRUNCATED:
return f"date_trunc('{unit}', {timestamp})"
if unit == "microsecond":
return f"date_trunc('microseconds', {timestamp})"
years = years_of(unit)
if years is not None:
return self.shared(
[timestamp],
lambda t: (
f"(date_trunc('year', {t[0]}) - make_interval(years => MOD(CAST(EXTRACT(YEAR FROM {t[0]}) AS int), "
f"{years})))"
),
)
if unit == "weekYear":
return self.shared(
[timestamp],
lambda t: (
f"(date_trunc('week', {t[0]}) - make_interval(weeks => CAST(EXTRACT(WEEK FROM {t[0]}) AS int) - 1))"
),
)
return None
Loading
Loading