diff --git a/docs/03-cypher-on-a-relational-database.md b/docs/03-cypher-on-a-relational-database.md index 8383cd6..0c4c84a 100644 --- a/docs/03-cypher-on-a-relational-database.md +++ b/docs/03-cypher-on-a-relational-database.md @@ -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. diff --git a/docs/04-statements.md b/docs/04-statements.md index 56fabae..95bb15a 100644 --- a/docs/04-statements.md +++ b/docs/04-statements.md @@ -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 diff --git a/drivine/cypher/sql/cypher_syntax.py b/drivine/cypher/sql/cypher_syntax.py new file mode 100644 index 0000000..b74e244 --- /dev/null +++ b/drivine/cypher/sql/cypher_syntax.py @@ -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 diff --git a/drivine/cypher/sql/oracle_temporal_sql.py b/drivine/cypher/sql/oracle_temporal_sql.py index fd9448a..df5349a 100644 --- a/drivine/cypher/sql/oracle_temporal_sql.py +++ b/drivine/cypher/sql/oracle_temporal_sql.py @@ -3,7 +3,7 @@ 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.""" @@ -11,7 +11,17 @@ 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): @@ -129,6 +139,10 @@ 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)" @@ -136,6 +150,17 @@ def truncate(self, unit: str, timestamp: str) -> str | None: 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 diff --git a/drivine/cypher/sql/postgres_temporal_sql.py b/drivine/cypher/sql/postgres_temporal_sql.py index 664a75d..b1b0fa2 100644 --- a/drivine/cypher/sql/postgres_temporal_sql.py +++ b/drivine/cypher/sql/postgres_temporal_sql.py @@ -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"} @@ -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 diff --git a/drivine/cypher/sql/sql_cypher_compiler.py b/drivine/cypher/sql/sql_cypher_compiler.py index 1982fc3..3c73c38 100644 --- a/drivine/cypher/sql/sql_cypher_compiler.py +++ b/drivine/cypher/sql/sql_cypher_compiler.py @@ -4,6 +4,7 @@ import functools import logging import math +import re from abc import ABC, abstractmethod from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass @@ -11,6 +12,7 @@ from enum import Enum from functools import cached_property from typing import Any, NoReturn, TypeVar, cast +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from drivine.backend.sql.sql_graph import sql_text as literal from drivine.cypher.ast.clause import ( @@ -69,11 +71,25 @@ from drivine.cypher.ast.set_item import SetAdd, SetItem, SetProperty, SetReplace from drivine.cypher.cypher_reader import read_cypher from drivine.cypher.sql.compiled_cypher import CompiledCypher +from drivine.cypher.sql.cypher_syntax import bracketed, imports, plain_with from drivine.cypher.sql.parameters import Parameters, cypher_json, jsonable from drivine.cypher.sql.temporal import Temporal -from drivine.cypher.sql.temporal_sql import TemporalSql -from drivine.cypher.sql.temporals import FIELD_NAMES, NoNumber, Temporals, canonical -from drivine.cypher.sql.value import Bool, Entities, Json, Node, Path, Relationship, Value +from drivine.cypher.sql.temporal_sql import TRUNCATION_UNITS, TemporalSql +from drivine.cypher.sql.temporals import FIELD_NAMES, NoNumber, Overrides, Temporals, canonical +from drivine.cypher.sql.value import ( + Bool, + Entities, + Json, + Known, + Node, + Path, + Relationship, + Value, + capital_noun_of, + known_of, + noun_of, +) +from drivine.cypher.sql.variable_kinds import check_variable_kinds from drivine.drivine_error import DrivineError from drivine.model import stamps @@ -481,6 +497,9 @@ def compile(self, cypher: str, parameters: Mapping[str, Any] | None = None) -> C def compile_query(self, query: Query, parameters: Mapping[str, Any]) -> CompiledCypher: """The statement, read already, with its parameters' values, as SQL: a query, or a procedure where it writes.""" + # A variable used as two kinds, or made where it is bound already, is refused for that, before what is not + # compiled yet. + check_variable_kinds(query) if query.unions: return self._with_functions(self._union(query, parameters)) if not any(writes(it) for it in query.clauses): @@ -1753,6 +1772,7 @@ def _called_union(self, select: _Select, call: Call) -> _Projected: items = {it.items if isinstance(it, Json) else None for it in all_} item_items = {it.item_items if isinstance(it, Json) else None for it in all_} row_keys = {it.row_keys if isinstance(it, Json) else None for it in all_} + known = {it.known if isinstance(it, Json) else None for it in all_} scope[variable] = dataclasses.replace( value, row_keys=next(iter(row_keys)) if len(row_keys) == 1 else None, @@ -1763,6 +1783,8 @@ def _called_union(self, select: _Select, call: Call) -> _Projected: temporals=any(isinstance(it, Json) and it.temporals for it in all_), text=all(_is_text(it) for it in all_), texts=all(_holds_texts(it) for it in all_), + # Its kind is known only where each query writes it as a value of that one. + known=next(iter(known)) if len(known) == 1 else None, ) return _Projected(sql, scope, []) @@ -2416,6 +2438,7 @@ def _bind_row( flat=carried.flat if carried else False, row_keys=(key,) if written else None, from_list=not written, + known=carried.known if carried else None, ), ) elif kind in (_Kind.NODES, _Kind.RELATIONSHIPS): @@ -2775,6 +2798,7 @@ def _target(self, variable: str, writing: str) -> tuple[str, str, str]: def _set(self, item: SetItem) -> None: if isinstance(item, SetProperty): + _property_of_kind(item.value) table, id_, row = self._target(item.variable, "SET") self._evaluate( VALUE, [item.value], lambda scope: self.comparable(self.json(self._expression(item.value, scope))) @@ -3581,7 +3605,7 @@ def _condition(self, where: Expression, scope: Scope) -> str: def _expression(self, expression: Expression, scope: Scope) -> Value: """ The value the expression gives in the scope: see `_value_of`. Marked Json.flat where it is known to hold no - null in a list or a map. + null in a list or a map, and with its Json.known kind where the statement writes it as a value of one. """ outer = self._filtering # Only a condition joined by AND or OR is read for its truth alone: NOT, a CASE or a function reads null apart @@ -3594,6 +3618,10 @@ def _expression(self, expression: Expression, scope: Scope) -> Value: value = self._value_of(expression, scope) finally: self._filtering = outer + if isinstance(value, Json) and value.known is None: + known = self._known_kind(expression, scope) + if known is not None: + value = dataclasses.replace(value, known=known) if isinstance(value, (Json, Entities)) and not value.flat and self._flat(expression, scope): return dataclasses.replace(value, flat=True) return value @@ -3703,6 +3731,77 @@ def ids(relationships: bool) -> Entities: texts=all(_is_text(it) for it in items), ) + def _known_kind(self, expression: Expression, scope: Scope) -> Known | None: + """ + The kind of value the expression is in the scope, where the statement writes it as one (Json.known): a literal, + a negated number, a list or a map written, or a variable bound to one. Read from the statement alone, compiling + nothing, so a check may run before its operand is compiled. None where the kind is left open, as it is for a + property, a parameter or a function's value: Neo4j checks those when the row is read. + """ + if isinstance(expression, Literal): + return known_of(expression.value) + if isinstance(expression, Unary): + operand = self._known_kind(expression.operand, scope) + negated = expression.operator is UnaryOperator.NEGATE and operand in ("INTEGER", "FLOAT") + return operand if negated else None + if isinstance(expression, ListLiteral): + return "LIST" + if isinstance(expression, MapLiteral): + return "MAP" + if isinstance(expression, Variable): + value = scope.get(expression.name) + return value.known if isinstance(value, Json) else None + return None + + def _index_of_kind(self, index: Index, scope: Scope) -> None: + """ + Refuses `subject[index]` where the statement writes its subject as a value that is no list or map, or its index + as a value of a kind its subject is not indexed by (`_known_kind`): a list by an integer, a map by a text. As + Neo4j refuses it; where the subject's kind is left open, it is compiled as before. An index given as a parameter + is of its value's kind: the statement is compiled for each call, and its SQL is already made by that value, a + text read as a property and a number as an item, so one of the other kind would be read as neither. + """ + subject = self._known_kind(index.subject, scope) + if subject is None: + return + if subject not in ("LIST", "MAP"): + raise DrivineError( + f"{capital_noun_of(subject)} is not a list or a map, and is indexed as one, as in Neo4j." + ) + by: Known = "INTEGER" if subject == "LIST" else "TEXT" + kind = self._known_kind(index.index, scope) + if kind is None and isinstance(index.index, Parameter): + kind = known_of(self._known(index.index)) + if kind is not None and kind != by: + raise DrivineError( + f"{capital_noun_of(subject)} is indexed by {noun_of(by)}, not {noun_of(kind)}, as in Neo4j." + ) + + def _in_of_kind(self, binary: Binary, scope: Scope) -> None: + """Refuses the binary where it is `IN` a value the statement writes as one of a kind other than a list + (`_known_kind`), as Neo4j refuses it.""" + kind = self._known_kind(binary.right, scope) if binary.operator is BinaryOperator.IN else None + if kind is not None and kind != "LIST": + raise DrivineError(f"IN takes a list, not {noun_of(kind)}, as in Neo4j.") + + def _require_condition(self, expression: Expression, scope: Scope, operator: str) -> None: + """Refuses the expression, an operand of `operator`, where its kind is known (`_known_kind`) to be other than a + truth value, as Neo4j refuses it.""" + kind = self._known_kind(expression, scope) + if kind is not None and kind != "BOOLEAN": + raise DrivineError(f"{operator} takes a truth value, not {noun_of(kind)}, as in Neo4j.") + + @staticmethod + def _require_kind(value: Value, allowed: Known, message: Callable[[str], str]) -> None: + """ + Refuses the value, an argument, where the statement writes it as a value of a kind (Json.known) other than + `allowed`, with the message of that kind's noun, as Neo4j refuses it when it plans the statement. A value whose + kind the statement leaves open is compiled as before. + """ + kind = value.known if isinstance(value, Json) else None + if kind is not None and kind != allowed: + raise DrivineError(message(noun_of(kind))) + def _known(self, index: Expression) -> Any: """An index written, or bound to a parameter, is known: a text is the property of that name, a number an item.""" @@ -3744,6 +3843,7 @@ def _in_parameter(self, value: Property | Index) -> str | None: return self._parameters.json_at(subject.name, keys) def _index(self, expression: Index, scope: Scope) -> Value: + self._index_of_kind(expression, scope) index = expression.index listed: list[Value] = [] @@ -3960,6 +4060,7 @@ def context_step(accumulator: str, item: str, map_: str) -> str: self.property_of(map_, it), temporal=was_json.temporal if was_json else None, items=was_json.items if was_json else None, + known=was_json.known if was_json else None, ) inner[expression.accumulator] = Json(accumulator) inner[expression.variable] = Json(item, temporal=_items_of(listed), temporals=_item_temporals_of(listed)) @@ -4172,6 +4273,8 @@ def _item(self, expression: Expression, select: _Select) -> Value: and subject.temporal is None and subject.row_keys is None and expression.key not in FIELD_NAMES + # One of a kind known other than a map is left to _property, which refuses it. + and not _known_no_map(subject) ): return Json(self.item_value(self.comparable(subject), expression.key), temporals=subject.temporals) return self._expression(expression, select.scope) @@ -4184,6 +4287,10 @@ def _property(self, subject: Value, key: str) -> Value: if isinstance(subject, Relationship): return Json(self.property_of(subject.props, key)) if isinstance(subject, Json): + # A value the statement writes as one of a kind other than a map has none, as Neo4j says when it plans the + # statement. + if _known_no_map(subject): + raise DrivineError(f"{capital_noun_of(cast(Known, subject.known))} has no properties.") field_ = self._temporal_field(subject, key) if field_ is not None: return field_ @@ -4450,11 +4557,14 @@ def identity(value: Value) -> Json: def _binary(self, binary: Binary, scope: Scope) -> Value: filter_ = self._filtered operator = binary.operator + self._in_of_kind(binary, scope) if operator in (BinaryOperator.EQUALS, BinaryOperator.NOT_EQUALS, BinaryOperator.IN): itemwise = self._itemwise(binary, scope) if itemwise is not None: return Bool(f"(NOT {itemwise})" if operator is BinaryOperator.NOT_EQUALS else itemwise) if operator in (BinaryOperator.AND, BinaryOperator.OR, BinaryOperator.XOR): + for it in (binary.left, binary.right): + self._require_condition(it, scope, operator.value) left = self.bool(self._expression(binary.left, scope)) right = self.bool(self._expression(binary.right, scope)) if operator is BinaryOperator.AND: @@ -5102,6 +5212,8 @@ def _given(self, expression: Expression) -> Any: return None def _unary(self, unary: Unary, scope: Scope) -> Value: + if unary.operator is UnaryOperator.NOT: + self._require_condition(unary.operand, scope, "NOT") operand = self._expression(unary.operand, scope) if unary.operator is UnaryOperator.NOT: return Bool(f"(NOT ({self.bool(operand)}))") @@ -5192,7 +5304,14 @@ def one() -> Value: name = function.name.lower() if name == "properties": subject = one() - return self.json(subject) if isinstance(subject, (Node, Relationship)) else subject + if isinstance(subject, (Node, Relationship)): + return self.json(subject) + self._require_kind( + subject, + "MAP", + lambda kind: f"properties() takes a node, a relationship or a map, not {kind}, as in Neo4j.", + ) + return subject if name == "labels": subject = one() if isinstance(subject, Node): @@ -5336,6 +5455,12 @@ def one() -> Value: if name == "range": if len(arguments) not in (2, 3): raise DrivineError("range() takes a start, an end and a step.") + for it in arguments: + self._require_kind(it, "INTEGER", lambda kind: f"range() takes integers, not {kind}, as in Neo4j.") + # A step written or given as 0 would never reach the end: a Decimal is given as the number it is. + step_given = jsonable(self._known(function.arguments[2])) if len(arguments) == 3 else None + if _is_number(step_given) and step_given == 0: + raise DrivineError("range() takes a step other than 0, as in Neo4j.") # Each argument read as a number first, as drivine4j reads them, so a statement's SQL is the same as its. numbers = [self.number(self.comparable(self.json(it))) for it in arguments] from_, to = numbers[:2] @@ -5527,7 +5652,7 @@ def counted(text: str) -> str: now_kind = _temporal_of(name.split(".")[0]) return Json(self.scalar(self.temporals.now(now_kind)), temporal=now_kind) if name in TEMPORAL_TRUNCATE: - return self._truncate(function, arguments, _temporal_of(name.split(".")[0])) + return self._truncate(function, arguments, _temporal_of(name.split(".")[0]), scope) if name in ("duration.between", "duration.inmonths", "duration.indays", "duration.inseconds"): return self._between(function, arguments, name) # A function the store has no SQL for is named before its arguments are counted. @@ -5554,10 +5679,15 @@ def applied(list_: str) -> str: ) return Json(result, text=name in TEXT_FUNCTIONS) - def _truncate(self, function: Function, arguments: list[Value], to: Temporal) -> Value: - """`date.truncate(unit, value)` and its like: the value cut short to the unit, as a value of `to`.""" - if len(arguments) != 2: + def _truncate(self, function: Function, arguments: list[Value], to: Temporal, scope: Scope) -> Value: + """ + `date.truncate(unit, value)` and its like: the value cut short to the unit, as a value of `to`, and with a map + of fields, `date.truncate(unit, value, {day: 2})`, those fields laid over it. + """ + if len(arguments) < 2: raise DrivineError(f"{function.name}() takes a unit and a value.") + if len(arguments) > 3: + raise DrivineError(f"{function.name}() takes a unit, a value and a map of fields.") written = function.arguments[0] if not isinstance(written, Literal) or not isinstance(written.value, str): unsupported("A truncation to a unit an expression gives") @@ -5565,14 +5695,15 @@ def _truncate(self, function: Function, arguments: list[Value], to: Temporal) -> given = arguments[1] if not isinstance(given, Json): raise DrivineError(f"{function.name}() takes a unit and a value.") + fields = ( + self._truncation_fields(function, unit, to, function.arguments[2], scope) if len(arguments) == 3 else None + ) def cut(text: str, from_: Temporal) -> str: - truncated = self.temporals.truncate(unit, text, from_, to) + truncated = self.temporals.truncate(unit, text, from_, to, fields) if truncated is None: - raise DrivineError( - f"{function.name}() truncates to year, quarter, month, week, day, hour, minute, second or " - f"millisecond, not {unit}." - ) + units = ", ".join(TRUNCATION_UNITS[:-1]) + raise DrivineError(f"{function.name}() truncates to {units} or {TRUNCATION_UNITS[-1]}, not {unit}.") return truncated text = self.text(self.comparable(given)) @@ -5582,6 +5713,48 @@ def cut(text: str, from_: Temporal) -> str: truncated = self.temporals.by_kind(text, lambda from_, t: cut(t, from_)) return Json(self.scalar(truncated), temporal=to) + def _truncation_fields( + self, function: Function, unit: str, kind: Temporal, written: Expression, scope: Scope + ) -> Overrides: + """ + The fields the map `written`, the third argument of the function, gives the value of the kind it truncates, in + place of its own, as Neo4j takes them. A field of no such value, a day or a month beside a day of the week, a + number that is not whole, or a number written past its field's range (see TRUNCATION_RANGES; a microsecond + beside a millisecond given or kept by a cut to the unit runs to 999), fails, where a store would carry it into + the next field or fail on it at its own time. A field that is null is given as its least, as a map that leaves + it out makes a value: the year 0, the first month, day or day of the week, and no time; a time zone that is + null fails. Fields the stores have no SQL for yet (nanoseconds, the fields of a week or a quarter, a time zone + by name) are refused. As drivine4j's #468. + """ + # Null gives no fields, as Neo4j has it. + if written == Literal(None): + return Overrides({}) + if not isinstance(written, MapLiteral): + unsupported("A truncation's fields that an expression gives") + keys = _truncation_keys(function, kind, written.entries) + millis = "millisecond" in keys or unit == "millisecond" + timezone: str | None = None + fields: dict[str, str] = {} + for key, value in written.entries: + if key == "timezone": + timezone = _truncation_zone(function, value) + else: + fields[key] = self._truncation_number(function, key, value, millis, scope) + return Overrides(fields, timezone) + + def _truncation_number(self, function: Function, key: str, value: Expression, millis: bool, scope: Scope) -> str: + """ + The SQL number of the field `key` of a truncation of the function, which the value gives: see + `_truncation_fields`. With `millis`, a millisecond is given or kept, beside which a microsecond runs to 999. + """ + least = TRUNCATION_LEAST.get(key, 0) + written = _written_number(value) + if written is None: + return f"COALESCE({self.number(self.comparable(self.json(self._expression(value, scope))))}, {least})" + if written.value is None: + return f"{least}" + return f"{_truncation_whole(function, key, written.value, millis)}" + def _between(self, function: Function, arguments: list[Value], name: str) -> Value: """`duration.between(a, b)` and `duration.inMonths`, `inDays` and `inSeconds`: the duration from one value to the other.""" @@ -5790,10 +5963,16 @@ def json(self, value: Value) -> Json: ) def bool(self, value: Value) -> str: - """A value as a condition: a boolean as itself, JSON's `true` and `false` as theirs, anything else as null.""" + """ + A value as a condition: a boolean as itself, JSON's `true` and `false` as theirs, anything else as null. One the + statement writes as a value of another kind (Json.known) is refused, as Neo4j refuses it when it plans the + statement. + """ if isinstance(value, Bool): return value.sql if isinstance(value, Json): + if value.known is not None and value.known != "BOOLEAN": + raise DrivineError(f"{capital_noun_of(value.known)} is not a condition, as in Neo4j.") return self.bool_of(self.comparable(value)) raise DrivineError("A node, a relationship or a path is not a condition.") @@ -6031,63 +6210,6 @@ def is_seeded(expression: Expression, made: Iterable[str]) -> bool: ) -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 - - def _texts_in(value: Any, texts: set[str]) -> None: """ Adds each text the value, a statement as read or any part of it, holds anywhere, as far as its first space: each of @@ -6659,6 +6781,177 @@ def _is_number(value: Any) -> bool: return isinstance(value, (int, float)) and not isinstance(value, bool) +TRUNCATION_DATE_FIELDS = ( + "year", + "month", + "day", + "dayOfWeek", + "week", + "weekYear", + "quarter", + "dayOfQuarter", + "ordinalDay", +) +"""The fields of a date a truncation's map may give, as Neo4j takes them.""" + +TRUNCATION_TIME_FIELDS = ("hour", "minute", "second", "millisecond", "microsecond", "nanosecond") +"""The fields of a time of day a truncation's map may give, as Neo4j takes them.""" + +TRUNCATION_FIELDS_NOT_COMPILED = frozenset({"nanosecond", "week", "weekYear", "quarter", "dayOfQuarter", "ordinalDay"}) +"""The fields a truncation's map may give that the stores have no SQL for yet: nanoseconds, and the fields of a week or +a quarter.""" + +TRUNCATION_RANGES: dict[str, tuple[int, int]] = { + "month": (1, 12), + "day": (1, 31), + "dayOfWeek": (1, 7), + "hour": (0, 23), + "minute": (0, 59), + "second": (0, 59), + "millisecond": (0, 999), + "microsecond": (0, 999999), +} +"""The values each field a truncation's map gives may take, as Neo4j takes them, but for the year, which runs past what +a store's text holds.""" + +TRUNCATION_LEAST = {"month": 1, "day": 1, "dayOfWeek": 1} +"""The least of each field a truncation's map gives that is not 0, which a field given as null is.""" + +TRUNCATION_FIELD_NAMES = {"dayOfWeek": "a day of the week", "hour": "an hour"} +"""A truncation's fields as a message names them, where it is not `a `.""" + + +def _truncation_keys(function: Function, kind: Temporal, entries: Sequence[tuple[str, Expression]]) -> set[str]: + """ + The keys of `entries`, a truncation's map of fields given the function, which truncates to the kind: refused where + Neo4j refuses one, as a field of no such value or a day or a month beside a day of the week, or where no store has + SQL for it yet. + """ + takes = { + *(TRUNCATION_DATE_FIELDS if kind not in (Temporal.LOCAL_TIME, Temporal.TIME) else ()), + *(TRUNCATION_TIME_FIELDS if kind is not Temporal.DATE else ()), + *(("timezone",) if kind.zoned else ()), + } + for key, _ in entries: + if key not in takes: + raise DrivineError(f"{function.name}() takes no field {key}.") + if key in TRUNCATION_FIELDS_NOT_COMPILED: + unsupported(f"{function.name}() of a map with {key}") + keys = {key for key, _ in entries} + for it in ("day", "month"): + if it in keys and "dayOfWeek" in keys: + raise DrivineError(f"{function.name}() takes a {it} or a day of the week, not both.") + return keys + + +def _truncation_whole(function: Function, key: str, written: Any, millis: bool) -> int: + """ + The value written as the field `key` of a truncation of the function, where it is a whole number in the field's + range (see TRUNCATION_RANGES); with `millis`, a millisecond is given or kept, beside which a microsecond runs to + 999. + """ + if not isinstance(written, int) or isinstance(written, bool): + shown = f"'{written}'" if isinstance(written, str) else _shown(written) + raise DrivineError(f"{function.name}() takes a whole number for {key}, not {shown}.") + beside = key == "microsecond" and millis + range_ = (0, 999) if beside else TRUNCATION_RANGES.get(key) + if range_ is not None and not range_[0] <= written <= range_[1]: + name = TRUNCATION_FIELD_NAMES.get(key, f"a {key}") + also = " beside a millisecond" if beside else "" + raise DrivineError(f"{function.name}() takes {name} from {range_[0]} to {range_[1]}{also}, not {written}.") + return written + + +def _shown(value: Any) -> str: + """A value written as a message shows it, as Cypher writes it: `true`, `1.5`.""" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +@dataclass(frozen=True) +class _Written: + """A value a statement writes: see `_written_number`.""" + + value: Any + + +def _written_number(value: Expression) -> _Written | None: + """ + The value the expression is written as, where it is a literal or a negated whole number, `-1`, which is written as + much as `1` is: None for any other expression, a negated float among them, as drivine4j reads it. + """ + if isinstance(value, Literal): + return _Written(value.value) + negated = value.operand if isinstance(value, Unary) and value.operator is UnaryOperator.NEGATE else None + if isinstance(negated, Literal) and _is_number(negated.value) and not isinstance(negated.value, float): + return _Written(-negated.value) + return None + + +def _truncation_zone(function: Function, value: Expression) -> str: + """ + The offset a truncation's `timezone` field, the value, gives the function, as SQL text. Neo4j takes an offset + written as `Z` or `+01:00` alone, as Java's ZoneOffset does (up to 18 hours, and 0 is UTC's, `Z`), and a zone by + name, which no store's text holds yet, so it is refused as not compiled yet. + """ + if value == Literal(None): + raise DrivineError(f"{function.name}() takes a time zone, not null.") + if not isinstance(value, Literal) or not isinstance(value.value, str): + unsupported("A truncation to a time zone an expression gives") + zone: str = value.value + offset = _offset_text(zone) + if offset is not None: + return offset + if not zone.startswith(("+", "-")) and _named_zone(zone): + unsupported(f"The time zone '{zone}'") + raise DrivineError(f"'{zone}' is no time zone.") + + +def _offset_text(zone: str) -> str | None: + """The SQL text of the offset the zone writes, `Z` or `+01:00`, as Java's ZoneOffset takes it: None where it is + none.""" + if zone == "Z": + return "'Z'" + offset = re.fullmatch(r"[+-]([0-9]{2}):([0-9]{2})", zone) + if offset is None: + return None + hours, minutes = int(offset.group(1)), int(offset.group(2)) + if minutes >= 60 or hours > 18 or (hours == 18 and minutes > 0): + return None + return "'Z'" if hours == 0 and minutes == 0 else f"'{zone}'" + + +def _named_zone(zone: str) -> bool: + """Whether the text names a time zone, as `Europe/Stockholm` does.""" + try: + ZoneInfo(zone) + except (ValueError, ZoneInfoNotFoundError): + return False + return True + + +def _known_no_map(value: Json) -> bool: + """Whether the statement writes the value as a value of a kind other than a map (Json.known), which has no + properties.""" + return value.known is not None and value.known != "MAP" + + +def _property_of_kind(written: Expression) -> None: + """ + Refuses `written`, the value a `SET` gives a property, where it is a map written, or a list written that holds one + or a list: no property, as Neo4j refuses it, and as Drivine's saves do. + """ + if isinstance(written, MapLiteral) or ( + isinstance(written, ListLiteral) and any(isinstance(it, (MapLiteral, ListLiteral)) for it in written.items) + ): + what = "a map" if isinstance(written, MapLiteral) else "a list of maps or lists" + raise DrivineError( + f"A property is a number, a text, a truth value, a temporal value or a list of them, not {what}, as in " + "Neo4j." + ) + + def _cut(value: float) -> int: """ A number cut toward zero, as Java casts a double to a long and Neo4j so reads a start or length: NaN is 0, and a diff --git a/drivine/cypher/sql/temporal_sql.py b/drivine/cypher/sql/temporal_sql.py index 0c4b6d1..08a5143 100644 --- a/drivine/cypher/sql/temporal_sql.py +++ b/drivine/cypher/sql/temporal_sql.py @@ -9,6 +9,39 @@ """The compiler's `shared`: a value it is given is written once where it is read more than once.""" +TRUNCATION_UNITS = ( + "millennium", + "century", + "decade", + "year", + "weekYear", + "quarter", + "month", + "week", + "day", + "hour", + "minute", + "second", + "millisecond", + "microsecond", +) +""" +The units a timestamp truncates to, as Cypher names them, from the longest. The week-based year starts on the Monday of +its first ISO week. A timestamp keeps microseconds alone, so cut to them it is the same. +""" + +_YEARS_OF = {"millennium": 1000, "century": 100, "decade": 10} +""" +The units of many years, and how many: as Neo4j counts them, the year less its years into the span, counted toward zero +as Java divides. So 2017's millennium starts in 2000, 1984's century in 1900, and -1234's millennium in -1000. +""" + + +def years_of(unit: str) -> int | None: + """How many years the unit is, where it is a unit of many: see _YEARS_OF.""" + return _YEARS_OF.get(unit) + + class TemporalSql(ABC): """ A store's SQL for temporal values, which Temporals works openCypher's out of. Its texts and timestamps are SQL: a @@ -76,8 +109,7 @@ def seconds_between(self, from_: str, to: str) -> str: @abstractmethod def truncate(self, unit: str, timestamp: str) -> str | None: - """The timestamp truncated to the unit (`year`, `quarter`, `month`, `week`, `day`, `hour`, `minute`, `second`, - `millisecond`): None for another unit.""" + """The timestamp truncated to the unit, one of TRUNCATION_UNITS, as Neo4j truncates: None for another unit.""" def replace(self, text: str, pattern: str, replacement: str) -> str: """The text with every match of the pattern, a SQL text, replaced by the replacement.""" diff --git a/drivine/cypher/sql/temporals.py b/drivine/cypher/sql/temporals.py index ddcc1ba..7a58507 100644 --- a/drivine/cypher/sql/temporals.py +++ b/drivine/cypher/sql/temporals.py @@ -24,6 +24,18 @@ class Parts: seconds: str +@dataclass(frozen=True) +class Overrides: + """ + The fields the third argument of a truncation gives the value cut short, in place of its own: SQL numbers by + Cypher's names (`year`, `month`, `day`, `hour`, `minute`, `second`, `millisecond`, `microsecond`), `dayOfWeek`, and + `timezone`, an offset's text. + """ + + fields: dict[str, str] + timezone: str | None = None + + @dataclass(frozen=True) class Field: """A field of a value: its SQL, and whether it is a text, where it is not a number.""" @@ -211,38 +223,38 @@ def field(self, text: str, kind: Temporal, name: str) -> Field | None: if name not in INSTANT_FIELDS: return None - def number(values: list[str]) -> str: - t = values[0] - second = f"EXTRACT(SECOND FROM {t})" - if name == "year": - return f"EXTRACT(YEAR FROM {t})" - if name == "quarter": - return f"TRUNC((EXTRACT(MONTH FROM {t}) + 2) / 3)" - if name == "month": - return f"EXTRACT(MONTH FROM {t})" - if name == "week": - return self.sql.number(f"to_char({t}, 'IW')") - if name == "weekYear": - return self.sql.number(f"to_char({t}, 'IYYY')") - if name == "day": - return f"EXTRACT(DAY FROM {t})" - if name == "ordinalDay": - return self.sql.number(f"to_char({t}, 'DDD')") - if name in ("dayOfWeek", "weekDay"): - return self.sql.day_of_week(t) - if name == "hour": - return f"EXTRACT(HOUR FROM {t})" - if name == "minute": - return f"EXTRACT(MINUTE FROM {t})" - if name == "second": - return f"TRUNC({second})" - if name == "millisecond": - return f"(TRUNC({second} * 1000) - TRUNC({second}) * 1000)" - if name == "microsecond": - return f"(TRUNC({second} * 1000000) - TRUNC({second}) * 1000000)" - return f"((TRUNC({second} * 1000000) - TRUNC({second}) * 1000000) * 1000)" - - return Field(self.shared([self.sql.local(text, kind)], number), text=False) + return Field(self.shared([self.sql.local(text, kind)], lambda t: self._clock_field(t[0], name)), text=False) + + def _clock_field(self, t: str, name: str) -> str: + """A field of the wall clock `t`, a timestamp, by Cypher's name for it.""" + second = f"EXTRACT(SECOND FROM {t})" + if name == "year": + return f"EXTRACT(YEAR FROM {t})" + if name == "quarter": + return f"TRUNC((EXTRACT(MONTH FROM {t}) + 2) / 3)" + if name == "month": + return f"EXTRACT(MONTH FROM {t})" + if name == "week": + return self.sql.number(f"to_char({t}, 'IW')") + if name == "weekYear": + return self.sql.number(f"to_char({t}, 'IYYY')") + if name == "day": + return f"EXTRACT(DAY FROM {t})" + if name == "ordinalDay": + return self.sql.number(f"to_char({t}, 'DDD')") + if name in ("dayOfWeek", "weekDay"): + return self.sql.day_of_week(t) + if name == "hour": + return f"EXTRACT(HOUR FROM {t})" + if name == "minute": + return f"EXTRACT(MINUTE FROM {t})" + if name == "second": + return f"TRUNC({second})" + if name == "millisecond": + return f"(TRUNC({second} * 1000) - TRUNC({second}) * 1000)" + if name == "microsecond": + return f"(TRUNC({second} * 1000000) - TRUNC({second}) * 1000000)" + return f"((TRUNC({second} * 1000000) - TRUNC({second}) * 1000000) * 1000)" def _zone_field(self, text: str, name: str) -> Field: def zone(values: list[str]) -> str: @@ -313,15 +325,73 @@ def _moved(self, timestamp: str, duration: str, sign: int, date_: str | None = N self.sql.add_months(timestamp, f"{s}({parts.months})"), f"{s}({parts.days})", f"{s}({seconds})" ) - def truncate(self, unit: str, text: str, from_: Temporal, to: Temporal) -> str | None: - """The text of `to` the text of `from_` is, cut short to the unit: None where the store truncates to no such - unit.""" + def truncate( + self, unit: str, text: str, from_: Temporal, to: Temporal, overrides: Overrides | None = None + ) -> str | None: + """ + The text of `to` the text of `from_` is, cut short to the unit, with `overrides` in place of its fields where it + has any: None where the store truncates to no such unit. + """ if self.sql.truncate(unit, "NULL") is None: return None + if overrides is None or (not overrides.fields and overrides.timezone is None): + return self.shared( + [text], + lambda t: self.format( + self.sql.truncate(unit, self.sql.local(t[0], from_)) or "", to, self._offset_of(t[0], from_, to) + ), + ) + given = overrides return self.shared( [text], - lambda t: self.format( - self.sql.truncate(unit, self.sql.local(t[0], from_)) or "", to, self._offset_of(t[0], from_, to) + lambda t: self.shared( + [self.sql.truncate(unit, self.sql.local(t[0], from_)) or ""], + lambda cut: self._overridden( + cut[0], + unit, + to, + given, + given.timezone if given.timezone is not None else self._offset_of(t[0], from_, to), + ), + ), + ) + + def _overridden(self, cut: str, unit: str, kind: Temporal, overrides: Overrides, offset: str | None) -> str: + """ + The text of the kind of the timestamp `cut`, cut short to the unit, with the fields `overrides` gives in place + of its own, and the offset, as Neo4j lays a truncation's fields over the value: the year, month and day, then + the time, then the day of the week, which moves the value within its ISO week. A millisecond or a microsecond + given makes the whole fraction of the second, but a microsecond given a value cut to the millisecond is added + to the millisecond kept. + """ + given = overrides.fields + own = { + it: given[it] if it in given else self._clock_field(cut, it) + for it in ("year", "month", "day", "hour", "minute", "second") + } + micros = self.sql.number(self.sql.micros(cut)) + carried = unit == "millisecond" and "microsecond" in given + if "millisecond" in given: + fraction = f"{given['millisecond']} * 1000" + if not carried and "microsecond" in given: + fraction += f" + {given['microsecond']}" + elif "microsecond" in given: + fraction = f"({micros} + {given['microsecond']})" if carried else given["microsecond"] + else: + fraction = micros + made = self.of({**own, "microsecond": fraction}, offset, kind) + day = given.get("dayOfWeek") + if day is None: + return made + return self.shared( + [made], + lambda m: self.shared( + [self.sql.local(m[0], kind)], + lambda clock: self.format( + self.sql.add_time(clock[0], f"{day} - {self.sql.day_of_week(clock[0])}", "0"), + kind, + self.offset(m[0]) if kind.zoned else None, + ), ), ) diff --git a/drivine/cypher/sql/value.py b/drivine/cypher/sql/value.py index eb3eded..c032996 100644 --- a/drivine/cypher/sql/value.py +++ b/drivine/cypher/sql/value.py @@ -1,9 +1,47 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any, Literal from drivine.cypher.sql.temporal import Temporal +Known = Literal["INTEGER", "FLOAT", "TEXT", "BOOLEAN", "LIST", "MAP"] +"""A kind of value the statement writes: see Json.known.""" + +_KNOWN_NOUNS: dict[Known, str] = { + "INTEGER": "an integer", + "FLOAT": "a float", + "TEXT": "a text", + "BOOLEAN": "a truth value", + "LIST": "a list", + "MAP": "a map", +} + + +def noun_of(known: Known) -> str: + """A value of the kind, as a message names it: `an integer`, `a truth value`.""" + return _KNOWN_NOUNS[known] + + +def capital_noun_of(known: Known) -> str: + """`noun_of`, as a sentence begins with it: `An integer`.""" + noun = noun_of(known) + return noun[0].upper() + noun[1:] + + +def known_of(value: Any) -> Known | None: + """ + The kind of the value, a literal the reader gives or a parameter's value: an int is an integer, a float a float. + None for null, and for any other, a Decimal among them, which is written as a whole number where it is one. + """ + if isinstance(value, bool): + return "BOOLEAN" + if isinstance(value, int): + return "INTEGER" + if isinstance(value, float): + return "FLOAT" + return "TEXT" if isinstance(value, str) else None + @dataclass(frozen=True) class Json: @@ -59,6 +97,12 @@ class Json: passed on as it is: it reads no node's or relationship's properties, so a property of it a `RETURN` or a `WITH` gives is read as `SqlCypherCompiler.item_value` reads it. """ + known: Known | None = None + """ + Where the statement writes it as a value of one kind, so that its kind is known when compiling, as Neo4j knows it + when it plans the statement: a number, a text or a truth value written, a list or a map written, or a variable a + `WITH` binds to one. None where the statement leaves its kind open, as for a property or a parameter. + """ @dataclass(frozen=True) diff --git a/drivine/cypher/sql/variable_kinds.py b/drivine/cypher/sql/variable_kinds.py new file mode 100644 index 0000000..fcd9387 --- /dev/null +++ b/drivine/cypher/sql/variable_kinds.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Literal as Typed +from typing import cast + +from drivine.cypher.ast.clause import ( + Call, + Clause, + Create, + Delete, + Foreach, + Match, + Merge, + Return, + SetClause, + Unwind, + With, +) +from drivine.cypher.ast.expression import ( + Binary, + Case, + Entry, + Exists, + Expression, + Function, + HasLabels, + Index, + ListComprehension, + ListLiteral, + Literal, + MapLiteral, + MapProjection, + PatternComprehension, + PatternPredicate, + Property, + Quantifier, + Reduce, + Slice, + Subquery, + Unary, + Variable, +) +from drivine.cypher.ast.pattern import Pattern +from drivine.cypher.ast.projection import Projection +from drivine.cypher.ast.query import Query +from drivine.cypher.ast.relationship_pattern import Group +from drivine.cypher.ast.set_item import SetItem, SetLabels +from drivine.cypher.sql.cypher_syntax import bracketed, imports +from drivine.cypher.sql.value import Known, known_of, noun_of +from drivine.drivine_error import DrivineError + +Kind = Typed["NODE", "RELATIONSHIP", "RELATIONSHIPS", "PATH", "ANY"] | Known +"""A kind of variable: see `check_variable_kinds`. A value's kinds are Known's; `ANY` is any kind at all.""" + +_NOUNS: dict[str, str] = { + "NODE": "a node", + "RELATIONSHIP": "a relationship", + "RELATIONSHIPS": "a list of relationships", + "PATH": "a path", + "ANY": "a value", +} +"""A variable of each kind that is no value's, as a message names it: a value's is `noun_of`'s.""" + +Scope = dict[str, Kind] +"""The variables in scope, by name, each of its kind.""" + + +def check_variable_kinds(query: Query) -> None: + """ + Refuses the statement where a variable of it is used as two kinds, `MATCH (a)-[a]->()` (Neo4j's + VariableTypeConflict), or is bound already where a pattern makes it, `MATCH (a) CREATE (a)` (VariableAlreadyBound), + as Neo4j refuses it when it plans the statement. Read from the statement alone, before it is compiled, so a + statement that compiles is compiled as it was, and one refused here is refused for what is wrong with it, not for + what the compiler does not take yet. As drivine4j's VariableKinds and Drivine for TypeScript's. + + Only a kind the statement fixes is known: a pattern's node, relationship, variable-length relationship or path, a + value written, and a variable a `WITH` passes on as it is. Any other value (a property, a parameter, an unwound + item, a function's result) may be of any kind, and is never refused here as being of another: what it is used as is + left to the compiler, as before. + """ + _query(query, {}) + + +def _noun(kind: Kind) -> str: + """A variable of the kind, as a message names it.""" + noun = _NOUNS.get(kind) + return noun if noun is not None else noun_of(cast(Known, kind)) + + +def _query(query: Query, outer: Scope) -> list[Scope]: + """The query's variables, from those of `outer` it reads: what each part of it returns.""" + return [_clauses(it, dict(outer)) for it in (query.clauses, *(union.clauses for union in query.unions))] + + +def _clauses(clauses: Sequence[Clause], scope: Scope, foreach: bool = False, kept: Scope | None = None) -> Scope: + """ + The clauses, in turn, over `scope`: what their `RETURN` returns, of the kinds it returns. Within a `FOREACH`, + `foreach`, a `CREATE` or a `MERGE` may name a variable bound already, as Neo4j takes it there. `kept` stay in scope + after each `WITH` that does not name them, as what a `CALL`'s brackets import does (drivine4j#464). + """ + current = scope + returned: Scope = {} + for clause in clauses: + if isinstance(clause, With): + following = _projected(clause.projection, current, clause.where) + for name, kind in (kept or {}).items(): + following.setdefault(name, kind) + current = following + elif isinstance(clause, Return): + returned = _projected(clause.projection, current, None) + else: + _other(clause, current, foreach) + return returned + + +def _other(clause: Clause, scope: Scope, foreach: bool) -> None: + """A clause other than a `WITH` or a `RETURN`, over `scope`, which it binds what it names in: see `_clauses`.""" + if isinstance(clause, Match): + for pattern in clause.patterns: + _matched(pattern, scope) + _each([clause.where], scope) + elif isinstance(clause, Unwind): + _expression(clause.list, scope) + scope[clause.variable] = "ANY" + elif isinstance(clause, Create): + made: set[str] = set() + for pattern in clause.patterns: + _made(pattern, scope, made, False, foreach) + elif isinstance(clause, Merge): + _made(clause.pattern, scope, set(), True, foreach) + for item in (*clause.on_create, *clause.on_match): + _set(item, scope) + elif isinstance(clause, SetClause): + for item in clause.items: + _set(item, scope) + elif isinstance(clause, Delete): + _each(list(clause.expressions), scope) + elif isinstance(clause, Foreach): + _expression(clause.list, scope) + _clauses(clause.clauses, {**scope, clause.variable: "ANY"}, True) + elif isinstance(clause, Call): + scope.update(_called(clause, scope)) + # A REMOVE names nothing. + + +def _called(call: Call, scope: Scope) -> Scope: + """ + What the call returns, of the kinds each of its queries returns, read from the row `scope` holds: only a variable + its brackets import, `CALL (a) { … }` or `CALL (*) { … }`, or where it has none its leading `WITH` imports as it is, + or with `*`, of the kind it is; any other of the row is a new variable there, as in Neo4j. + """ + parts: list[Scope] = [] + for clauses in (call.query.clauses, *(it.clauses for it in call.query.unions)): + imported = imports(call, clauses, scope) + inner: Scope = {name: kind for name, kind in scope.items() if name in imported} + # What its brackets import stays in scope after a WITH that does not name it. + parts.append(_clauses(clauses, dict(inner), False, inner if bracketed(call) else None)) + called: Scope = {} + for name, kind in parts[0].items(): + called[name] = kind if all(it.get(name) == kind for it in parts) else "ANY" + return called + + +def _projected(projection: Projection, scope: Scope, where: Expression | None) -> Scope: + """ + The scope after a `WITH` or a `RETURN` of the projection over `scope`: each item of the kind its expression is, + and with `*` each variable in scope. Its `WHERE`, `where`, and its order read the items, and a variable before it + as of any kind. + """ + for item in projection.items: + _expression(item.expression, scope) + following: Scope = dict(scope) if projection.star else {} + for item in projection.items: + following[item.name] = _kind_of(item.expression, scope) + reading: Scope = {**dict.fromkeys(scope, "ANY"), **following} + _each([where, *(it.expression for it in projection.order_by)], reading) + _each([projection.skip, projection.limit], scope) + return following + + +def _kind_of(value: Expression, scope: Scope) -> Kind: + """The kind of the value, where the statement fixes it: see `check_variable_kinds`.""" + if isinstance(value, Variable): + return scope.get(value.name, "ANY") + if isinstance(value, Literal): + return known_of(value.value) or "ANY" + if isinstance(value, ListLiteral): + return "LIST" + if isinstance(value, MapLiteral): + return "MAP" + return "ANY" + + +def _matched(pattern: Pattern, scope: Scope, within: bool = False) -> None: + """ + A pattern a `MATCH` finds, over `scope`: what it names, bound as it names it, or used as it is bound. Its path + variable is new, but in a pattern within an expression, `within`, where it is the pattern's own. + """ + # In the order written, so the first of two kinds is the one the variable is bound as. + for i, node in enumerate(pattern.nodes): + _each([node.properties], scope) + if node.variable is not None: + _use(node.variable, "NODE", scope) + if i == len(pattern.relationships): + break + relationship = pattern.relationships[i] + _each([relationship.properties], scope) + if relationship.group is not None: + _grouped(relationship.group, scope) + if relationship.variable is not None: + _use(relationship.variable, "RELATIONSHIPS" if relationship.range else "RELATIONSHIP", scope) + variable = pattern.variable + if variable is not None: + if not within and variable in scope: + raise DrivineError(f"{variable} is bound already, so a pattern binds no path to it, as in Neo4j.") + scope[variable] = "PATH" + + +def _grouped(group: Group, scope: Scope) -> None: + """A quantified path pattern's group: what it names is a list outside it, of whatever it matched.""" + for it in (*group.pattern.nodes, *group.pattern.relationships): + if it.variable is not None and it.variable not in scope: + scope[it.variable] = "ANY" + + +def _use(variable: str, kind: Kind, scope: Scope) -> None: + """The variable where a pattern binds it as the kind: bound so, or used so where it is bound already as that + kind.""" + bound = scope.get(variable) + if bound is None: + scope[variable] = kind + elif not _fits(bound, kind): + raise _conflict(variable, bound, kind) + + +def _fits(bound: Kind, kind: Kind) -> bool: + """Whether a variable bound as `bound` may be used where a pattern takes `kind`.""" + return bound == kind or bound == "ANY" or (kind == "RELATIONSHIPS" and bound == "LIST") + + +def _conflict(variable: str, bound: Kind, used: Kind) -> DrivineError: + """The refusal of the variable, bound as `bound`, where a pattern uses it as `used`.""" + return DrivineError( + f"{variable} is {_noun(bound)}, and is used as {_noun(used)}: a variable is of one kind, as in Neo4j." + ) + + +def _made(pattern: Pattern, scope: Scope, made: set[str], merging: bool, foreach: bool) -> None: + """ + A pattern a `CREATE` or a `MERGE` makes, over `scope`: each node of it new, or one bound already that it joins up, + and each relationship new: a node a pattern of the same `CREATE` made before it is bound already. `made` holds the + relationships the clause made before. A relationship bound before the clause, as one, is left to the compiler, + which refuses it as not compiled yet, as the spec has it. Within a `FOREACH`, `foreach`, Neo4j takes a variable + bound already in each of these places, and only one of another kind is refused. + """ + clause = "MERGE" if merging else "CREATE" + for node in pattern.nodes: + _each([node.properties], scope) + variable = node.variable + if variable is None: + continue + bound = scope.get(variable) + if bound is None: + scope[variable] = "NODE" + continue + if not foreach and (node.labels or node.properties is not None): + raise DrivineError( + f"{variable} is bound already, so a CREATE or MERGE makes no node of it with labels or properties." + ) + if not pattern.relationships: + if not foreach: + raise DrivineError(f"{variable} is bound already, so a {clause} makes no node of it, as in Neo4j.") + continue + if not _fits(bound, "NODE"): + raise _conflict(variable, bound, "NODE") + for relationship in pattern.relationships: + _each([relationship.properties], scope) + variable = relationship.variable + if variable is None: + continue + if foreach: + scope.setdefault(variable, "RELATIONSHIP") + continue + bound = scope.get(variable) + if bound is not None and (bound != "RELATIONSHIP" or variable in made): + raise DrivineError(f"{variable} is bound already, so a {clause} makes no relationship of it, as in Neo4j.") + scope[variable] = "RELATIONSHIP" + made.add(variable) + if pattern.variable is not None: + scope.setdefault(pattern.variable, "PATH") + + +def _set(item: SetItem, scope: Scope) -> None: + """A `SET` item, over `scope`: the patterns within the value it sets, which a label it sets has none of.""" + if not isinstance(item, SetLabels): + _expression(item.value, scope) + + +def _each(values: Sequence[Expression | None], scope: Scope) -> None: + """Each of the values that is there, read over `scope`: see `_expression`.""" + for it in values: + if it is not None: + _expression(it, scope) + + +def _expression(value: Expression, scope: Scope) -> None: + """ + The patterns within the value, each over `scope` and what it names only within it, and the functions of a variable + whose kind they do not take. + """ + if isinstance(value, (Property, HasLabels, Unary)): + _expression(value.subject if not isinstance(value, Unary) else value.operand, scope) + elif isinstance(value, ListLiteral): + _each(value.items, scope) + elif isinstance(value, MapLiteral): + _each([it for _, it in value.entries], scope) + elif isinstance(value, MapProjection): + _each([it.value for it in value.elements if isinstance(it, Entry)], scope) + elif isinstance(value, Binary): + _each([value.left, value.right], scope) + elif isinstance(value, Index): + _each([value.subject, value.index], scope) + elif isinstance(value, Slice): + _each([value.subject, value.from_, value.to], scope) + elif isinstance(value, Function): + _each(value.arguments, scope) + _function_of(value, scope) + elif isinstance(value, Case): + _each([value.subject, value.otherwise, *(it for pair in value.alternatives for it in pair)], scope) + elif isinstance(value, ListComprehension): + _expression(value.list, scope) + _each([value.where, value.projection], {**scope, value.variable: "ANY"}) + elif isinstance(value, Quantifier): + _expression(value.list, scope) + _each([value.where], {**scope, value.variable: "ANY"}) + elif isinstance(value, Reduce): + _each([value.initial, value.list], scope) + _expression(value.expression, {**scope, value.accumulator: "ANY", value.variable: "ANY"}) + elif isinstance(value, PatternComprehension): + inner = dict(scope) + _matched(value.pattern, inner, True) + _each([value.where, value.projection], inner) + elif isinstance(value, PatternPredicate): + _matched(value.pattern, dict(scope), True) + elif isinstance(value, Exists): + inner = dict(scope) + for pattern in value.patterns: + _matched(pattern, inner, True) + _each([value.where], inner) + elif isinstance(value, Subquery): + _query(value.query, scope) + # A literal, a parameter, a variable and count(*) hold no other expression. + + +def _function_of(function: Function, scope: Scope) -> None: + """A function of a variable whose kind it does not take, as Neo4j refuses it: `size()` of a path.""" + argument = function.arguments[0] if len(function.arguments) == 1 else None + kind = scope.get(argument.name) if isinstance(argument, Variable) else None + if function.name.lower() == "size" and kind == "PATH": + raise DrivineError("size() takes a list or a text, not a path, as in Neo4j: length() takes a path.") diff --git a/tests/cypher/VERSION b/tests/cypher/VERSION index 6139a00..bf057db 100644 --- a/tests/cypher/VERSION +++ b/tests/cypher/VERSION @@ -1 +1 @@ -v0.6.19 +v0.10.0 diff --git a/tests/cypher/queries.yaml b/tests/cypher/queries.yaml index ac8314b..7a48f7d 100644 --- a/tests/cypher/queries.yaml +++ b/tests/cypher/queries.yaml @@ -953,6 +953,66 @@ cases: - cypher: "MATCH (p:Person {name: 'Hugo'}) RETURN COUNT { OPTIONAL MATCH (p)-[:KNOWS]->(f) RETURN f }" rows: [1] + - name: "CALL imports only what it names" + description: "A CALL sees only what its brackets or its leading WITH import: any other name in it is a new variable." + steps: + - note: "No brackets and no leading WITH: nothing is imported, so p in the CALL is any movie." + only: [neo4j, sql] + cypher: "MATCH (p:Person) CALL { MATCH (p:Movie) RETURN p.title AS t } RETURN count(*) AS n, count(DISTINCT t) AS d" + rows: [[12, 3]] + - cypher: "MATCH (p:Person) CALL { MATCH (p:Movie) RETURN count(p) AS n } RETURN p.name, n ORDER BY p.name" + only: [neo4j, sql] + rows: [["Carrie", 3], ["Hugo", 3], ["Keanu", 3], ["Laurence", 3]] + - note: "CALL () imports nothing." + only: [neo4j, sql] + cypher: "MATCH (p:Person {name: 'Keanu'}) CALL () { MATCH (p:Movie) RETURN p.title AS t } RETURN p.name, t ORDER BY t" + rows: [["Keanu", "Johnny Mnemonic"], ["Keanu", "The Matrix"], ["Keanu", "The Matrix Reloaded"]] + - note: "CALL (a) imports a alone, and so does a leading WITH a: b in it is new." + only: [neo4j, sql] + cypher: "MATCH (a:Person {name: 'Keanu'}), (b:Person {name: 'Carrie'}) CALL (a) { MATCH (a)-[:ACTED_IN]->(b) RETURN b.title AS t } RETURN b.name, t ORDER BY t" + rows: [["Carrie", "Johnny Mnemonic"], ["Carrie", "The Matrix"], ["Carrie", "The Matrix Reloaded"]] + - cypher: "MATCH (a:Person {name: 'Keanu'}), (b:Person {name: 'Carrie'}) CALL { WITH a MATCH (a)-[:ACTED_IN]->(b) RETURN b.title AS t } RETURN b.name, t ORDER BY t" + only: [neo4j, sql] + rows: [["Carrie", "Johnny Mnemonic"], ["Carrie", "The Matrix"], ["Carrie", "The Matrix Reloaded"]] + - note: "A node's name the CALL does not import may be a relationship's in it." + only: [neo4j, sql] + cypher: "MATCH (a:Person {name: 'Keanu'}) CALL { MATCH ()-[a:KNOWS]->() RETURN count(a) AS n } RETURN a.name, n" + rows: [["Keanu", 2]] + - cypher: "MATCH (a:Person {name: 'Laurence'}) CALL () { MATCH ()-[a:KNOWS]->(b) RETURN b.name AS k } RETURN a.name, k ORDER BY k" + only: [neo4j, sql] + rows: [["Laurence", "Carrie"], ["Laurence", "Keanu"]] + - cypher: "MATCH (a:Person {name: 'Keanu'}), (b:Movie {title: 'The Matrix'}) CALL (a) { MATCH (a)-[b]->() RETURN count(b) AS n } RETURN b.title, n" + only: [neo4j, sql] + rows: [["The Matrix", 4]] + - note: "What a leading WITH imports is gone after a WITH that does not pass it on." + only: [neo4j, sql] + cypher: "MATCH (a:Person {name: 'Keanu'}) CALL { WITH a WITH 1 AS one MATCH (a:Movie) RETURN count(a) AS n } RETURN n" + rows: [3] + - note: "What brackets import is seen throughout the CALL, after a WITH that does not pass it on too." + only: [neo4j, sql] + cypher: "MATCH (a:Person {name: 'Keanu'}), (b:Person {name: 'Carrie'}) CALL (a, b) { WITH 1 AS one RETURN b.name AS n } RETURN n" + rows: ["Carrie"] + - cypher: "MATCH (a:Person {name: 'Keanu'}) CALL (a) { MATCH (a)-[:ACTED_IN]->(m) WITH m MATCH (a)-[:ACTED_IN]->(m) RETURN count(m) AS n } RETURN n" + only: [neo4j, sql] + rows: [3] + - cypher: "MATCH (a:Person) CALL (a) { UNWIND [1, 2] AS i WITH i RETURN a.name AS n, sum(i) AS s } RETURN n, s ORDER BY n" + only: [neo4j, sql] + rows: [["Carrie", 3], ["Hugo", 3], ["Keanu", 3], ["Laurence", 3]] + - note: "CALL (*), and a leading WITH *, import every variable." + only: [neo4j, sql] + cypher: "MATCH (a:Person {name: 'Keanu'}) CALL (*) { MATCH (a)-[:ACTED_IN]->(m) RETURN count(m) AS n } RETURN n" + rows: [3] + - cypher: "MATCH (a:Person {name: 'Keanu'}) CALL (*) { MATCH (a)-[:ACTED_IN]->(m) WITH m MATCH (a)-[:ACTED_IN]->(m) RETURN count(m) AS n } RETURN n" + only: [neo4j, sql] + rows: [3] + - cypher: "MATCH (a:Person {name: 'Keanu'}) CALL { WITH * MATCH (a)-[:ACTED_IN]->(m) RETURN count(m) AS n } RETURN n" + only: [neo4j, sql] + rows: [3] + - note: "In a UNION, each query imports what its own leading WITH names." + only: [neo4j, sql] + cypher: "MATCH (a:Person {name: 'Keanu'}) CALL { WITH a MATCH (a)-[:ACTED_IN]->(m) RETURN count(m) AS n UNION ALL MATCH (a:Movie) RETURN count(a) AS n } RETURN a.name, n" + rows: [["Keanu", 3], ["Keanu", 3]] + - name: "UNION inside subqueries" description: "A UNION works inside CALL, COUNT and EXISTS, evaluated per row." steps: diff --git a/tests/cypher/refusals.yaml b/tests/cypher/refusals.yaml index 9c57f27..585e7ba 100644 --- a/tests/cypher/refusals.yaml +++ b/tests/cypher/refusals.yaml @@ -49,6 +49,61 @@ cases: error: "date() of a map with week is Cypher, and is not compiled to SQL yet." - cypher: "RETURN duration({day: 1})" error: "duration() of a map with day is Cypher, and is not compiled to SQL yet." + - cypher: "RETURN datetime.truncate('day', date('2024-03-15'), {nanosecond: 2})" + error: "datetime.truncate() of a map with nanosecond is Cypher, and is not compiled to SQL yet." + - cypher: "RETURN date.truncate('month', date('2024-03-15'), {week: 2})" + error: "date.truncate() of a map with week is Cypher, and is not compiled to SQL yet." + - cypher: "RETURN datetime.truncate('day', date('2024-03-15'), {timezone: 'Europe/Stockholm'})" + error: "The time zone 'Europe/Stockholm' is Cypher, and is not compiled to SQL yet." + - cypher: "WITH {day: 2} AS fields RETURN date.truncate('month', date('2024-03-15'), fields)" + error: "A truncation's fields that an expression gives is Cypher, and is not compiled to SQL yet." + - cypher: "WITH '+01:00' AS zone RETURN datetime.truncate('day', date('2024-03-15'), {timezone: zone})" + error: "A truncation to a time zone an expression gives is Cypher, and is not compiled to SQL yet." + + - name: "Truncation given fields Neo4j does not take" + description: "A truncation's map of fields that Neo4j fails on is refused: a field of no such value, a day or a month beside a day of the week, a number that is not whole or is past its field's range, or a time zone it does not read." + steps: + - cypher: "RETURN date.truncate('day', date('2024-03-15'), {hour: 5})" + error: "date.truncate() takes no field hour." + - cypher: "RETURN localdatetime.truncate('day', localdatetime('2024-03-15T10:00'), {timezone: '+05:00'})" + error: "localdatetime.truncate() takes no field timezone." + - cypher: "RETURN localtime.truncate('hour', localtime('10:30'), {day: 2})" + error: "localtime.truncate() takes no field day." + - cypher: "RETURN date.truncate('day', date('2024-03-15'), {foo: 1})" + error: "date.truncate() takes no field foo." + - cypher: "RETURN date.truncate('month', date('2024-03-15'), {day: 2, dayOfWeek: 3})" + error: "date.truncate() takes a day or a day of the week, not both." + - note: "A day of the week goes with a year alone, not with a month, though it is null." + cypher: "RETURN date.truncate('month', date('2024-03-15'), {month: 2, dayOfWeek: 3})" + error: "date.truncate() takes a month or a day of the week, not both." + - cypher: "RETURN date.truncate('weekYear', date('2024-03-15'), {dayOfWeek: 2, year: 2000, month: null})" + error: "date.truncate() takes a month or a day of the week, not both." + - cypher: "RETURN date.truncate('week', date('2024-03-15'), {dayOfWeek: 9})" + error: "date.truncate() takes a day of the week from 1 to 7, not 9." + - note: "A number written past its field's range, which a store would carry into the next field." + cypher: "RETURN date.truncate('year', date('2017-02-11'), {month: -1})" + error: "date.truncate() takes a month from 1 to 12, not -1." + - cypher: "RETURN localtime.truncate('hour', localtime('12:00'), {hour: 24})" + error: "localtime.truncate() takes an hour from 0 to 23, not 24." + - cypher: "RETURN localtime.truncate('second', localtime('12:00'), {millisecond: 1000})" + error: "localtime.truncate() takes a millisecond from 0 to 999, not 1000." + - cypher: "RETURN localtime.truncate('second', localtime('12:00'), {microsecond: 1000000})" + error: "localtime.truncate() takes a microsecond from 0 to 999999, not 1000000." + - note: "Beside a millisecond given, or kept by a cut to the millisecond, a microsecond runs to 999." + cypher: "RETURN localtime.truncate('second', localtime('12:00'), {millisecond: 5, microsecond: 1000})" + error: "localtime.truncate() takes a microsecond from 0 to 999 beside a millisecond, not 1000." + - cypher: "RETURN localtime.truncate('millisecond', localtime('12:00'), {microsecond: 1000})" + error: "localtime.truncate() takes a microsecond from 0 to 999 beside a millisecond, not 1000." + - cypher: "RETURN date.truncate('month', date('2024-03-15'), {day: 2.7})" + error: "date.truncate() takes a whole number for day, not 2.7." + - cypher: "RETURN date.truncate('month', date('2024-03-15'), {year: '2000'})" + error: "date.truncate() takes a whole number for year, not '2000'." + - cypher: "RETURN datetime.truncate('day', date('2024-03-15'), {timezone: '+0500'})" + error: "'+0500' is no time zone." + - cypher: "RETURN datetime.truncate('day', date('2024-03-15'), {timezone: 'bogus'})" + error: "'bogus' is no time zone." + - cypher: "RETURN datetime.truncate('day', date('2024-03-15'), {timezone: null})" + error: "datetime.truncate() takes a time zone, not null." - name: "Temporal function given the wrong arguments" description: "A temporal function given arguments it doesn't take is refused." @@ -59,8 +114,10 @@ cases: error: "date() takes a text or a map of its fields." - cypher: "RETURN date.truncate('month')" error: "date.truncate() takes a unit and a value." - - cypher: "RETURN date.truncate('decade', date('2024-03-15'))" - error: "date.truncate() truncates to year, quarter, month, week, day, hour, minute, second or millisecond, not decade." + - cypher: "RETURN date.truncate('month', date('2024-03-15'), {}, {})" + error: "date.truncate() takes a unit, a value and a map of fields." + - cypher: "RETURN date.truncate('fortnight', date('2024-03-15'))" + error: "date.truncate() truncates to millennium, century, decade, year, weekYear, quarter, month, week, day, hour, minute, second, millisecond or microsecond, not fortnight." - cypher: "WITH 'month' AS u RETURN date.truncate(u, date('2024-03-15'))" error: "A truncation to a unit an expression gives is Cypher, and is not compiled to SQL yet." - cypher: "RETURN duration.between(date('2024-03-15'))" @@ -312,3 +369,240 @@ cases: error: "toString() takes a number, a text, a truth value or a temporal value, not a list, as in Neo4j." - cypher: "MATCH (n) RETURN toString(n {.name})" error: "toString() takes a number, a text, a truth value or a temporal value, not a map, as in Neo4j." + + - name: "Operand of AND, OR, XOR or NOT of another kind" + description: "AND, OR, XOR and NOT of a value the statement writes as a number, a text, a list or a map, or binds with WITH to one, are refused, as Neo4j refuses them when it plans the statement." + steps: + - cypher: "RETURN 123 AND true" + error: "AND takes a truth value, not an integer, as in Neo4j." + - cypher: "RETURN false OR 'foo'" + error: "OR takes a truth value, not a text, as in Neo4j." + - cypher: "RETURN [true] XOR false" + error: "XOR takes a truth value, not a list, as in Neo4j." + - cypher: "RETURN null AND {}" + error: "AND takes a truth value, not a map, as in Neo4j." + - cypher: "RETURN NOT -1.5" + error: "NOT takes a truth value, not a float, as in Neo4j." + - cypher: "WITH 123 AS x WITH x AS y RETURN NOT y" + error: "NOT takes a truth value, not an integer, as in Neo4j." + - cypher: "WITH 'a' AS x RETURN reduce(a = true, y IN [1] | a AND x)" + error: "AND takes a truth value, not a text, as in Neo4j." + + - name: "Condition of another kind" + description: "A WHERE, a CASE's WHEN or a list's filter that the statement writes as a value other than a truth value, or binds with WITH to one, is refused, as Neo4j refuses it." + steps: + - cypher: "MATCH (n) WHERE 1 RETURN n" + error: "An integer is not a condition, as in Neo4j." + - cypher: "WITH 'a' AS x MATCH (n) WHERE x RETURN n" + error: "A text is not a condition, as in Neo4j." + - cypher: "RETURN CASE WHEN [] THEN 'a' END" + error: "A list is not a condition, as in Neo4j." + - cypher: "RETURN [x IN [1, 2] WHERE 'a']" + error: "A text is not a condition, as in Neo4j." + - cypher: "RETURN any(x IN [1] WHERE 1.5)" + error: "A float is not a condition, as in Neo4j." + + - name: "Property of a value that is no map" + description: "A property of a value the statement writes as a number, a text, a truth value or a list, or binds with WITH to one, is refused, as Neo4j refuses it." + steps: + - cypher: "WITH 123 AS x RETURN x.num" + error: "An integer has no properties." + - cypher: "WITH 'abc' AS s MATCH (n) RETURN s.size" + error: "A text has no properties." + - cypher: "WITH [1] AS l RETURN l.num" + error: "A list has no properties." + - cypher: "WITH true AS b RETURN b.num" + error: "A truth value has no properties." + - cypher: "WITH 1 AS x RETURN reduce(a = 0, y IN [1] | a + x.num)" + error: "An integer has no properties." + - cypher: "CALL { RETURN 1 AS x UNION RETURN 2 AS x } RETURN x.num" + error: "An integer has no properties." + - cypher: "WITH 1 AS x CREATE (n) RETURN x.num" + error: "An integer has no properties." + + - name: "Index of another kind" + description: "An item of a value the statement writes as no list or map, or a list's item or a map's entry by an index of a kind it isn't indexed by, is refused, as Neo4j refuses it. An index given as a parameter is of its value's kind, where Neo4j fails when a row reaches it." + steps: + - cypher: "WITH true AS list, 0 AS idx RETURN list[idx]" + error: "A truth value is not a list or a map, and is indexed as one, as in Neo4j." + - cypher: "RETURN 'abc'[0]" + error: "A text is not a list or a map, and is indexed as one, as in Neo4j." + - cypher: "WITH [1, 2, 3] AS list, 4.7 AS idx RETURN list[idx]" + error: "A list is indexed by an integer, not a float, as in Neo4j." + - cypher: "RETURN [1, 2, 3]['1']" + error: "A list is indexed by an integer, not a text, as in Neo4j." + - cypher: "RETURN {a: 1}[1]" + error: "A map is indexed by a text, not an integer, as in Neo4j." + - cypher: "WITH [1] AS l RETURN l[$k]" + parameters: {k: "a"} + error: "A list is indexed by an integer, not a text, as in Neo4j." + + - name: "IN a value that is no list" + description: "IN a value the statement writes as one other than a list, or binds with WITH to one, is refused, as Neo4j refuses it." + steps: + - cypher: "RETURN 1 IN 123" + error: "IN takes a list, not an integer, as in Neo4j." + - cypher: "WITH {x: []} AS m RETURN 1 IN m" + error: "IN takes a list, not a map, as in Neo4j." + + - name: "range() of another kind or by a step of 0" + description: "range() of a value the statement writes as one other than an integer is refused, as Neo4j refuses it when it plans the statement. range() by a step written or given as 0 is refused too, where Neo4j fails when a row reaches it." + steps: + - cypher: "RETURN range(0, 1, 1.1)" + error: "range() takes integers, not a float, as in Neo4j." + - cypher: "RETURN range(true, 1)" + error: "range() takes integers, not a truth value, as in Neo4j." + - cypher: "RETURN range(2, 8, 0)" + error: "range() takes a step other than 0, as in Neo4j." + - cypher: "RETURN range(2, 8, -0)" + error: "range() takes a step other than 0, as in Neo4j." + - cypher: "RETURN range(2, 8, $s)" + parameters: {s: 0} + error: "range() takes a step other than 0, as in Neo4j." + + - name: "properties() of another kind" + description: "properties() of a value the statement writes as a number, a text, a truth value or a list, or binds with WITH to one, is refused, as Neo4j refuses it." + steps: + - cypher: "RETURN properties(1)" + error: "properties() takes a node, a relationship or a map, not an integer, as in Neo4j." + - cypher: "WITH 'Cypher' AS t RETURN properties(t)" + error: "properties() takes a node, a relationship or a map, not a text, as in Neo4j." + - cypher: "RETURN properties([true, false])" + error: "properties() takes a node, a relationship or a map, not a list, as in Neo4j." + + - name: "Property set to a map" + description: "A property set to a map written, or to a list written that holds a map or a list, is refused, where Neo4j fails when a row reaches the SET." + steps: + - cypher: "CREATE (a) SET a.maplist = [{num: 1}]" + error: "A property is a number, a text, a truth value, a temporal value or a list of them, not a list of maps or lists, as in Neo4j." + - cypher: "MATCH (a) SET a.m = {num: 1}" + error: "A property is a number, a text, a truth value, a temporal value or a list of them, not a map, as in Neo4j." + + - name: "Variable used as two kinds" + description: "A variable a pattern binds as a node, a relationship, a variable-length relationship's list or a path, or a WITH binds to a value written, is refused where a pattern uses it as another kind, as Neo4j refuses it. A value whose kind the statement leaves open (a property, a parameter, an unwound item) isn't refused for it." + steps: + - cypher: "MATCH (a)-[a]->() RETURN a" + error: "a is a node, and is used as a relationship: a variable is of one kind, as in Neo4j." + - cypher: "MATCH ()-[r]-(r) RETURN r" + error: "r is a relationship, and is used as a node: a variable is of one kind, as in Neo4j." + - cypher: "MATCH (r) MATCH ()-[r]-() RETURN r" + error: "r is a node, and is used as a relationship: a variable is of one kind, as in Neo4j." + - cypher: "MATCH (r)-[]-()-[r*]-() RETURN r" + error: "r is a node, and is used as a list of relationships: a variable is of one kind, as in Neo4j." + - cypher: "MATCH ()-[r*]-() MATCH ()-[r]-() RETURN r" + error: "r is a list of relationships, and is used as a relationship: a variable is of one kind, as in Neo4j." + - cypher: "MATCH p = ()-->() MATCH (p) RETURN p" + error: "p is a path, and is used as a node: a variable is of one kind, as in Neo4j." + - cypher: "WITH true AS n MATCH (n) RETURN n" + error: "n is a truth value, and is used as a node: a variable is of one kind, as in Neo4j." + - cypher: "WITH [] AS r MATCH ()-[r]-() RETURN r" + error: "r is a list, and is used as a relationship: a variable is of one kind, as in Neo4j." + - cypher: "WITH {x: 1} AS r MATCH ()-[r*]-() RETURN r" + error: "r is a map, and is used as a list of relationships: a variable is of one kind, as in Neo4j." + - note: A WITH passes a variable on as the kind it is, under its own name or another, or with *. + cypher: "MATCH (a) WITH a AS r MATCH ()-[r]-() RETURN r" + error: "r is a node, and is used as a relationship: a variable is of one kind, as in Neo4j." + - cypher: "MATCH (a) WITH * MATCH ()-[a]->() RETURN a" + error: "a is a node, and is used as a relationship: a variable is of one kind, as in Neo4j." + - cypher: "MATCH (a) WITH 1 AS a MATCH ()-[a]->() RETURN a" + error: "a is an integer, and is used as a relationship: a variable is of one kind, as in Neo4j." + - note: A pattern in an expression or a subquery reads the variables around it. + cypher: "MATCH (a) RETURN [(a)-[a]->() | 1] AS l" + error: "a is a node, and is used as a relationship: a variable is of one kind, as in Neo4j." + - cypher: "MATCH (a) WHERE (a)-[a]->() RETURN a" + error: "a is a node, and is used as a relationship: a variable is of one kind, as in Neo4j." + - cypher: "MATCH (a) WHERE EXISTS { MATCH ()-[a]->() } RETURN a" + error: "a is a node, and is used as a relationship: a variable is of one kind, as in Neo4j." + - cypher: "MATCH (a) RETURN COUNT { (a)-[a]->() } AS c" + error: "a is a node, and is used as a relationship: a variable is of one kind, as in Neo4j." + - note: A CALL { … } reads a variable its WITH or its brackets import as the kind it is, and returns one as the kind it is. + cypher: "MATCH (a) CALL { WITH a MATCH ()-[a]->() RETURN 1 AS x } RETURN x" + error: "a is a node, and is used as a relationship: a variable is of one kind, as in Neo4j." + - cypher: "MATCH (a) CALL (a) { MATCH ()-[a]->() RETURN 1 AS x } RETURN x" + error: "a is a node, and is used as a relationship: a variable is of one kind, as in Neo4j." + - cypher: "CALL { MATCH (a) RETURN a } MATCH ()-[a]->() RETURN a" + error: "a is a node, and is used as a relationship: a variable is of one kind, as in Neo4j." + - cypher: "MATCH ()-[r]->() CREATE (a)-[:R]->(r)" + error: "r is a relationship, and is used as a node: a variable is of one kind, as in Neo4j." + - note: In a FOREACH too, where a CREATE may name a variable bound already. + cypher: "MATCH ()-[r]->() FOREACH (x IN [1] | CREATE (r)-[:T]->())" + error: "r is a relationship, and is used as a node: a variable is of one kind, as in Neo4j." + + - name: "Variable bound already where a pattern makes it" + description: "A path variable bound already, a node a CREATE or a MERGE would make alone that is bound already, and a relationship one makes whose variable is bound already as anything but a relationship, or by the same CREATE, are refused, as Neo4j refuses them. A relationship bound as one before the CREATE or MERGE stays refused as not compiled yet (see \"Write not compiled yet\"). In a FOREACH, Neo4j takes a CREATE or a MERGE of a variable bound already." + steps: + - cypher: "MATCH p = (p)-[]-() RETURN p" + error: "p is bound already, so a pattern binds no path to it, as in Neo4j." + - cypher: "MATCH ()-[p]->() MATCH p = ()-->() RETURN p" + error: "p is bound already, so a pattern binds no path to it, as in Neo4j." + - cypher: "UNWIND [1] AS p MATCH p = ()-->() RETURN p" + error: "p is bound already, so a pattern binds no path to it, as in Neo4j." + - cypher: "MATCH (a) CREATE (a)" + error: "a is bound already, so a CREATE makes no node of it, as in Neo4j." + - cypher: "MATCH (a) MERGE (a)" + error: "a is bound already, so a MERGE makes no node of it, as in Neo4j." + - cypher: "CREATE (n), (n)" + error: "n is bound already, so a CREATE makes no node of it, as in Neo4j." + - cypher: "MATCH (a) CREATE (a)-[:R]->(b), (b)" + error: "b is bound already, so a CREATE makes no node of it, as in Neo4j." + - cypher: "MATCH ()-[r]->() CREATE (r)" + error: "r is bound already, so a CREATE makes no node of it, as in Neo4j." + - cypher: "MATCH (a) CALL { WITH a CREATE (a) } RETURN 1" + error: "a is bound already, so a CREATE makes no node of it, as in Neo4j." + - cypher: "MATCH (a) CALL (a) { CREATE (a) } RETURN 1" + error: "a is bound already, so a CREATE makes no node of it, as in Neo4j." + - cypher: "CREATE (n:Foo)-[:T1]->(), (n:Bar)-[:T2]->()" + error: "n is bound already, so a CREATE or MERGE makes no node of it with labels or properties." + - cypher: "CREATE (n)-[r:T]->(), ()-[r:T]->()" + error: "r is bound already, so a CREATE makes no relationship of it, as in Neo4j." + - cypher: "MATCH (a) CREATE (b)-[a:R]->()" + error: "a is bound already, so a CREATE makes no relationship of it, as in Neo4j." + - cypher: "WITH 1 AS r MERGE ()-[r:R]->()" + error: "r is bound already, so a MERGE makes no relationship of it, as in Neo4j." + + - name: "CALL imports variables of the row" + description: "A CALL's brackets name only variables of its row, as Neo4j has it." + steps: + - cypher: "MATCH (a) CALL (x) { RETURN 1 AS n } RETURN n" + error: "x is not defined: a variable is bound by a pattern, a WITH or an UNWIND before it is used." + - note: "An inner CALL imports from the outer CALL's scope, which holds only what the outer one imports." + cypher: "MATCH (a), (b) CALL (a) { CALL (b) { MATCH (b)-->(d) RETURN d } RETURN d.x AS x } RETURN x" + error: "b is not defined: a variable is bound by a pattern, a WITH or an UNWIND before it is used." + - note: "What brackets import is in scope after a WITH that does not name it." + cypher: "MATCH (a) CALL (a) { WITH 1 AS one CREATE (a) RETURN one } RETURN one" + error: "a is bound already, so a CREATE makes no node of it, as in Neo4j." + - cypher: "MATCH (a) CALL (*) { WITH 1 AS one MATCH ()-[a]->() RETURN one } RETURN one" + error: "a is a node, and is used as a relationship: a variable is of one kind, as in Neo4j." + - note: "A procedure's call imports each variable of the row, so after a WITH that drops one its WHERE does not read it." + cypher: "MATCH (a) WITH 1 AS x CALL db.labels() YIELD label WHERE label = a.kind RETURN label" + error: "a is not defined: a variable is bound by a pattern, a WITH or an UNWIND before it is used." + + - name: "Importing WITH of variables alone" + description: "A CALL's leading WITH that names a variable of the row, or *, names nothing more, as Neo4j has it (its 42I28)." + steps: + - cypher: "MATCH (a), (b) CALL { WITH a, b AS c MATCH (a)-->(c) RETURN c.x AS x } RETURN x" + error: "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." + - cypher: "MATCH (a) CALL { WITH a AS c MATCH (c)-->(d) RETURN d.x AS x } RETURN x" + error: "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." + - cypher: "MATCH (a) CALL { WITH a WHERE a.x > 1 RETURN count(*) AS n } RETURN a, n" + error: "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." + - cypher: "MATCH (a) CALL { WITH a ORDER BY a.x LIMIT 1 MATCH (a)-->(d) RETURN d.x AS x } RETURN x" + error: "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." + - cypher: "MATCH (a) CALL { WITH a SKIP 0 RETURN 1 AS x } RETURN x" + error: "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." + - cypher: "MATCH (a) CALL { WITH DISTINCT a MATCH (a)-->(d) RETURN d.x AS x } RETURN x" + error: "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." + - cypher: "MATCH (a) CALL { WITH a, 1 AS one MATCH (a)-->(d) RETURN d.x + one AS x } RETURN x" + error: "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." + - cypher: "MATCH (a) CALL { WITH * WHERE true RETURN 1 AS x } RETURN x" + error: "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." + - cypher: "MATCH (a) CALL { UNWIND [1] AS x RETURN x UNION WITH a WHERE true RETURN 1 AS x } RETURN x" + error: "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." + - cypher: "MATCH (a) CALL { WITH a WHERE a.x > 1 SET a.y = 1 } RETURN a" + error: "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." + + - name: "size() of a path" + description: "size() of a path is refused, as Neo4j refuses it: length() gives a path's length." + steps: + - cypher: "MATCH p = (a)-[*]->(b) RETURN size(p)" + error: "size() takes a list or a text, not a path, as in Neo4j: length() takes a path." diff --git a/tests/cypher/temporal.yaml b/tests/cypher/temporal.yaml index 146b30f..a35cbf3 100644 --- a/tests/cypher/temporal.yaml +++ b/tests/cypher/temporal.yaml @@ -242,6 +242,89 @@ cases: localMidnight: localdatetime.truncate('day', datetime('2024-05-15T10:30:00Z')).hour} rows: [{localHour: 0, timeZone: "+02:00", timeSecond: 0, monday: 13, firstOfMonth: 1, localMidnight: 0}] + - name: "Truncating to many years, a week-based year or a microsecond" + description: "A millennium starts in 2000 and a century in 1900, a week-based year on the Monday of its first ISO week, and a microsecond keeps the microseconds." + steps: + - cypher: | + WITH date.truncate('weekYear', date('1984-01-01')) AS w, date.truncate('weekYear', date('2020-12-31')) AS w2, + datetime.truncate('decade', datetime('1984-10-11T12:31:14+01:00')) AS d + RETURN {millennium: date.truncate('millennium', date('2017-10-11')).year, lastMillennium: date.truncate('millennium', date('1999-12-31')).year, + century: date.truncate('century', date('2017-05-05')).year, decade: date.truncate('decade', date('2019-05-05')).year, + decadeStart: [d.year, d.month, d.day, d.hour, d.timezone], + weekYear: [w.year, w.month, w.day], weekYearBefore: [w2.year, w2.month, w2.day], + weekYearZone: datetime.truncate('weekYear', datetime('2021-01-03T10:00+02:00')).timezone, + localMicros: localtime.truncate('microsecond', localtime('12:31:14.645876')).microsecond, + micros: datetime.truncate('microsecond', datetime('2017-10-11T12:31:14.645876-01:00')).microsecond} + only: [neo4j, sql] + note: "Memgraph and FalkorDB have no date.truncate()." + rows: + - millennium: 2000 + lastMillennium: 1000 + century: 2000 + decade: 2010 + decadeStart: [1980, 1, 1, 0, "+01:00"] + weekYear: [1983, 1, 3] + weekYearBefore: [2019, 12, 30] + weekYearZone: "+02:00" + localMicros: 645876 + micros: 645876 + - cypher: "RETURN [date.truncate('millennium', date('2017-10-11')), localdatetime.truncate('decade', date('1984-10-11')), time.truncate('microsecond', time('12:31:14.645876+01:00'))]" + only: [sql] + rows: [["2000-01-01", "1980-01-01T00:00:00", "12:31:14.645876+01:00"]] + + - name: "Truncating with fields" + description: "A truncation's third argument, a map, gives the value cut short fields in place of its own, as Neo4j lays them over it." + steps: + - cypher: | + WITH date.truncate('month', date('1984-10-11'), {year: 2000, dayOfWeek: 3}) AS moved, date.truncate('weekYear', date('1984-01-01'), {day: 5}) AS w + RETURN {day: date.truncate('month', date('1984-10-11'), {day: 2}).day, + dayOfWeek: date.truncate('month', date('1984-10-11'), {dayOfWeek: 2}).day, + sunday: date.truncate('week', date('1984-10-11'), {dayOfWeek: 7}).day, + month: date.truncate('year', date('1984-10-11'), {month: 3}).month, + moved: [moved.year, moved.month, moved.day], weekYear: [w.year, w.month, w.day], + none: date.truncate('millennium', date('2017-10-11'), {}).year, + noMap: date.truncate('month', date('2017-10-11'), null).day} + only: [neo4j, sql] + note: "The year and month first, then the day of the week, which moves the value within its ISO week." + rows: [{day: 2, dayOfWeek: 2, sunday: 14, month: 3, moved: [2000, 9, 27], weekYear: [1983, 1, 5], none: 2000, noMap: 1}] + - cypher: | + WITH datetime('2017-10-11T12:31:14.645876+01:00') AS t + WITH t, datetime.truncate('millisecond', t, {second: 3}) AS second, localtime.truncate('hour', t, {minute: 6}) AS hour + RETURN {second: [second.second, second.microsecond], hour: [hour.hour, hour.minute], + millisecond: datetime.truncate('microsecond', t, {millisecond: 5}).microsecond, + carried: datetime.truncate('millisecond', t, {microsecond: 7}).microsecond, + microsecond: datetime.truncate('second', t, {microsecond: 7}).microsecond, + both: datetime.truncate('millisecond', t, {millisecond: 7, microsecond: 8}).microsecond, + bothFromSecond: datetime.truncate('second', t, {millisecond: 7, microsecond: 8}).microsecond} + only: [neo4j, sql] + note: "A millisecond or a microsecond makes the whole fraction, but a microsecond given a value cut to the millisecond is added to it." + rows: [{second: [3, 645000], hour: [12, 6], millisecond: 5000, carried: 645007, microsecond: 7, both: 7000, bothFromSecond: 7008}] + - cypher: | + RETURN {hour: datetime.truncate('second', datetime('2017-10-11T12:31:14+01:00'), {hour: null}).hour, + month: date.truncate('day', date('1984-10-11'), {month: null}).month, + monday: date.truncate('day', date('1984-10-11'), {dayOfWeek: null}).day, + fraction: datetime.truncate('millisecond', datetime('2017-10-11T12:31:14.645876+01:00'), {millisecond: null, microsecond: 8}).microsecond} + only: [neo4j, sql] + note: "A field that is null is given as its least: no hour, the first month, Monday." + rows: [{hour: 0, month: 1, monday: 8, fraction: 0}] + - cypher: | + WITH datetime.truncate('day', datetime('2017-10-11T12:31:14+01:00'), {timezone: '+05:00'}) AS zoned, + time.truncate('hour', time('12:31:14-01:00'), {timezone: '+05:00'}) AS time + RETURN {zoned: [zoned.day, zoned.hour, zoned.timezone], time: [time.hour, time.timezone], + fromLocal: datetime.truncate('day', localdatetime('2017-10-11T12:31:14'), {timezone: '+05:00'}).timezone, + utc: datetime.truncate('day', datetime('2017-10-11T12:31:14+01:00'), {timezone: '+00:00'}).timezone, + fromDate: datetime.truncate('day', date('2017-10-11'), {}).timezone} + only: [neo4j, sql] + note: "A time zone keeps the wall clock, and a value of none is at UTC." + rows: [{zoned: [11, 0, "+05:00"], time: [12, "+05:00"], fromLocal: "+05:00", utc: "Z", fromDate: "Z"}] + - cypher: "RETURN [date.truncate('millennium', date('2017-10-11'), {day: 2}), datetime.truncate('weekYear', datetime('1984-01-01T12:31:14+01:00'), {day: 5}), localtime.truncate('millisecond', localtime('12:31:14.645876'), {microsecond: 7})]" + only: [sql] + rows: [["2000-01-02", "1983-01-05T00:00:00+01:00", "12:31:14.645007"]] + - cypher: "RETURN date.truncate('month', date('1984-02-11'), {day: 31})" + only: [neo4j, sql] + note: "A day the month does not have." + error: true + - name: "Moments from maps with fractions and offsets" description: "A moment or time made from a map takes its fraction of a second and its offset." steps: diff --git a/tests/cypher/writes.yaml b/tests/cypher/writes.yaml index ce588d7..32cc45a 100644 --- a/tests/cypher/writes.yaml +++ b/tests/cypher/writes.yaml @@ -386,6 +386,53 @@ cases: - cypher: "WITH 'outer' AS a CALL (a) { WITH 'inner' AS b CREATE (:CyWrite {name: b}) RETURN b + '!' AS c } RETURN [a, c]" rows: [["outer", "inner!"]] + - name: "CALL that writes imports only what it names" + description: "A name a writing CALL does not import is a new variable in it: a CREATE of it makes a node." + steps: + - cypher: "CREATE (:CyWrite {id: 1}), (:CyWrite {id: 2})" + only: [neo4j, sql] + - cypher: "MATCH (a:CyWrite) CALL () { CREATE (a:CyWrite {made: true}) RETURN a.made AS m } RETURN a.id, m ORDER BY a.id" + only: [neo4j, sql] + rows: [[1, true], [2, true]] + - cypher: "MATCH (a:CyWrite {id: 1}) CALL { CREATE (a:CyWrite {made: false}) RETURN 1 AS x } RETURN [a.id, x]" + only: [neo4j, sql] + rows: [[1, 1]] + - cypher: "MATCH (n:CyWrite) RETURN [count(n.id), count(n.made)]" + only: [neo4j, sql] + rows: [[2, 3]] + - note: "CALL (a) imports a alone: b in it is new, and the row's b is as it was after it." + only: [neo4j, sql] + cypher: "MATCH (a:CyWrite {id: 1}), (b:CyWrite {id: 2}) CALL (a) { CREATE (a)-[:R]->(b:CyWrite {id: 3}) } RETURN 1" + - cypher: "MATCH (a:CyWrite)-[:R]->(b) RETURN [a.id, b.id]" + only: [neo4j, sql] + rows: [[1, 3]] + - cypher: "MATCH (a:CyWrite {id: 1}), (b:CyWrite {id: 2}) CALL (a) { CREATE (b:CyWrite {id: 4}) RETURN b.id AS made } RETURN [b.id, made]" + only: [neo4j, sql] + rows: [[2, 4]] + - note: "What brackets import is seen after a leading WITH that does not pass it on." + only: [neo4j, sql] + cypher: "MATCH (a:CyWrite {id: 1}), (b:CyWrite {id: 2}) CALL (a, b) { WITH a SET b.seen = true } RETURN b.seen" + rows: [true] + - note: "And after any WITH: a CREATE of it joins the row's node, and a SET writes it." + only: [neo4j, sql] + cypher: "MATCH (a:CyWrite {id: 1}) CALL (a) { WITH 1 AS one CREATE (a)-[:S]->(:CyWrite {id: 5}) } RETURN 1" + - cypher: "MATCH (a:CyWrite)-[:S]->(b) RETURN [a.id, b.id]" + only: [neo4j, sql] + rows: [[1, 5]] + - cypher: "MATCH (a:CyWrite {id: 1}) CALL (a) { MATCH (c:CyWrite {id: 5}) WITH c SET a.next = c.id RETURN a.next AS n } RETURN n" + only: [neo4j, sql] + rows: [5] + - cypher: "MATCH (a:CyWrite {id: 2}) CALL (*) { WITH 1 AS one SET a.one = one } RETURN a.one" + only: [neo4j, sql] + rows: [1] + - note: "An aggregate over no rows still gives its row, and the WITH's WHERE reads what brackets import." + only: [neo4j, sql] + cypher: "MATCH (a:CyWrite {id: 1}) CALL (a) { MATCH (c:CyNone) WITH count(c) AS n SET a.none = n RETURN a.none AS v } RETURN v" + rows: [0] + - cypher: "MATCH (a:CyWrite {id: 1}) CALL (a) { MATCH (c:CyWrite) WITH c WHERE c.id = a.next SET c.prev = a.id RETURN c.prev AS p, c.id AS i } RETURN [p, i]" + only: [neo4j, sql] + rows: [[1, 5]] + - name: "Writing through lists" description: "A list of nodes or relationships refers to them, so writing to an item writes to the node." steps: diff --git a/tests/unit/test_cypher_refusals.py b/tests/unit/test_cypher_refusals.py index 424dfc5..31a3243 100644 --- a/tests/unit/test_cypher_refusals.py +++ b/tests/unit/test_cypher_refusals.py @@ -376,3 +376,234 @@ def test_a_compiler_compiling_again_keeps_none_of_the_names_it_made_for_the_stat again = compiler("g") again.compile(first) assert again.compile(second).sql == compiler("g").compile(second).sql + + +@pytest.mark.parametrize("compiler", COMPILERS.values(), ids=COMPILERS.keys()) +@pytest.mark.parametrize( + ("cypher", "parameters", "message"), + [ + # A parameter's value is known as the statement is compiled, once for each call: a step of 0 would never end, + # and an index given of another kind is refused as one written is. + ("RETURN range(2, 8, $s)", {"s": 0}, "range() takes a step other than 0, as in Neo4j."), + ("RETURN range(2, 8, $s)", {"s": 0.0}, "range() takes a step other than 0, as in Neo4j."), + # A Decimal is given as the number it is, as a bigint is in Drivine for TypeScript. + ("RETURN range(2, 8, $s)", {"s": Decimal(0)}, "range() takes a step other than 0, as in Neo4j."), + ("WITH [1] AS l RETURN l[$k]", {"k": "a"}, "A list is indexed by an integer, not a text, as in Neo4j."), + ("WITH {a: 1} AS m RETURN m[$k]", {"k": 1}, "A map is indexed by a text, not an integer, as in Neo4j."), + ("WITH [1] AS l RETURN l[$k]", {"k": True}, "A list is indexed by an integer, not a truth value, as in Neo4j."), + # A value carried across a write is of the kind the statement made it. + ("WITH 1 AS x CREATE (n) RETURN x.num", {}, "An integer has no properties."), + # A variable each query of a UNION in a CALL makes of the same kind is of that kind. + ("CALL { RETURN 1 AS x UNION ALL RETURN 2 AS x } RETURN x.num", {}, "An integer has no properties."), + ], +) +def test_a_value_the_statement_writes_as_one_of_a_kind_an_operator_or_a_function_does_not_take_is_refused( + compiler: type[SqlCypherCompiler], cypher: str, parameters: dict[str, Any], message: str +) -> None: + # drivine4j's #420 (#448 there), and Drivine for TypeScript's #482: the spec's refusals hold the statements written; + # these are beside them. + with pytest.raises(DrivineError) as refused: + compiler("g").compile(cypher, parameters) + assert str(refused.value) == message + + +@pytest.mark.parametrize("compiler", COMPILERS.values(), ids=COMPILERS.keys()) +@pytest.mark.parametrize( + ("cypher", "parameters"), + [ + ("MATCH (n) WHERE n.flag AND NOT n.other RETURN n", {}), + ("RETURN $p AND true", {"p": 1}), + ("MATCH (n) RETURN n.x.y, n.x[0], 1 IN n.x, range(0, n.x), properties(n.x)", {}), + ("UNWIND [{a: 1}] AS m RETURN m.a, m['a']", {}), + ("RETURN NOT null, null XOR true, null.num, null[0], [1, 2, 3][null], 1 IN null, properties(null)", {}), + ("WITH {a: 1} AS m, 'a' AS k RETURN m.a, m[k], properties(m), [1, 2][-1], range(0, 10, -2)", {}), + ("MATCH (a) SET a.tags = ['x', 'y'], a.n = -1", {}), + # A variable each query of a UNION makes of another kind is of none known. + ("CALL { RETURN 1 AS x UNION RETURN {a: 1} AS x } RETURN x.a", {}), + # A variable a later WITH, a comprehension, a quantifier or a reduce() binds again is of the kind that one + # makes it. + ("WITH 1 AS x WITH {a: 1} AS x RETURN x.a", {}), + ( + "WITH 1 AS x RETURN [x IN [{a: 1}] | x.a], [x IN [true] WHERE x], any(x IN [true] WHERE x), " + "reduce(x = true, y IN [true] | x AND y)", + {}, + ), + ("WITH 1 AS x FOREACH (x IN [{a: 1}] | CREATE ({v: x.a}))", {}), + # A CASE, a coalesce(), a list joined, an item of a list or a map written, or a parameter, is of no kind known. + ( + "WITH CASE WHEN true THEN {a: 1} ELSE 1 END AS x, coalesce(null, {a: 1}) AS y, [1] + [{a: 1}] AS z " + "RETURN x.a, y.a, z[1]", + {}, + ), + ("RETURN [{a: 1}][0].a, [true][0] AND true, {a: true}.a AND true, {a: [1]}['a'][0]", {}), + ("WITH $p AS x RETURN x.a, x AND true, x[0], 1 IN x, properties(x)", {"p": {"a": 1}}), + ("WITH [1] AS l, {a: 1} AS m RETURN l[$i], m[$k]", {"i": 0, "k": "a"}), + ("WITH {a: 1} AS m CREATE (n) RETURN m.a", {}), + # A name a WITH or an UNWIND binds again after a write is of the kind that one makes it. + ("WITH 1 AS x CREATE (n {v: x}) WITH n AS x RETURN x.v", {}), + ("WITH 1 AS x CREATE (n {v: x}) WITH n.v AS x RETURN x.a", {}), + ("WITH 1 AS x CREATE (n) WITH {a: 1} AS x CREATE (m) RETURN x.a", {}), + ("WITH 1 AS x CREATE (n) WITH 1 AS y UNWIND [{a: 1}] AS x RETURN x.a", {}), + ], +) +def test_a_value_whose_kind_is_known_only_when_the_row_is_read_is_compiled_as_neo4j_plans_it( + compiler: type[SqlCypherCompiler], cypher: str, parameters: dict[str, Any] +) -> None: + # A property, a parameter or an unwound item, and null and the kinds each takes. + compiler("g").compile(cypher, parameters) + + +@pytest.mark.parametrize("compiler", COMPILERS.values(), ids=COMPILERS.keys()) +@pytest.mark.parametrize( + "cypher", + [ + # Neo4j takes each of these: a node bound before an OPTIONAL MATCH or a MERGE that joins it up, a name a WITH, + # a UNION, a pattern comprehension, an EXISTS or a CALL that imports nothing leaves to a new variable, a list a + # variable-length relationship matches, a value whose kind the statement leaves open, and a variable bound + # already that a CREATE or a MERGE in a FOREACH names. + "MATCH (a) OPTIONAL MATCH (a)-->(b) RETURN b", + "MATCH (a) OPTIONAL MATCH (a) RETURN a", + "OPTIONAL MATCH (a) MATCH (a)-->() RETURN a", + "MATCH (a) MERGE (a)-[:R]->(b)", + "MATCH (a) MERGE (b)-[:R]->(a)", + "MATCH (a) MERGE (a)-[:R]->(a)", + "CREATE (a) MERGE (a)-[:R]->(b)", + "MATCH (a)-[r]->(b) MERGE (a)-[s:T]->(b)", + "MATCH (n) CREATE (n)-[:R]->(n)", + "CREATE (n)-[:T1]->(), (n)-[:T2]->()", + "MATCH (a) CREATE (b), (a)-[:R]->(b)", + "MATCH (n) FOREACH (x IN [1] | CREATE (n)-[:R]->())", + "MATCH (n) FOREACH (x IN [1] | CREATE (n))", + "FOREACH (x IN [1] | CREATE (n), (n))", + "MATCH (a) FOREACH (x IN [1] | MERGE (a)-[:R]->())", + "MATCH (a) WITH a SKIP 0 MERGE (a)-[:R]->()", + "MATCH (a) CALL { CREATE (a) RETURN 1 AS x } RETURN x", + "MATCH (a)-[r*]->() WITH r MATCH ()-[r*]->() RETURN r", + "MATCH (a) RETURN [(a)-->(a) | 1] AS l", + "WITH 1 AS p RETURN [p = ()-->() | length(p)] AS l", + "MATCH (a), (a)-->() RETURN a", + "MATCH (n) WITH n MATCH (n:L) RETURN n", + "MATCH (a) WITH a AS b, a AS c MATCH (b)-->(c) RETURN b", + "MATCH ()-[a]->() WITH 1 AS x MATCH (a) RETURN a", + "MATCH ()-[r]->() RETURN r UNION ALL MATCH (r) RETURN r", + "MATCH (a) RETURN [a IN [1] | a] AS x", + "MATCH (n) RETURN [p = (n)-->() | length(p)] AS x", + "MATCH (a) WITH [(a)-[r]->() | r] AS rs MATCH ()-[r]->() RETURN r", + "MATCH (a) WHERE EXISTS { MATCH (a)-[r]->() } MATCH ()-[r]->() RETURN r", + "MATCH (a) CALL { WITH a MATCH (a)-->(b) RETURN b } MATCH (b)-->() RETURN b", + "MATCH (a) CALL (a) { MATCH (a)-->(b) RETURN b } MATCH (b)-->() RETURN b", + "MATCH (a) CALL (a) { CREATE (a)-[:R]->(b) RETURN b } RETURN b", + "MATCH ()-[n]->(m) FOREACH (n IN [m] | CREATE (n)-[:R]->())", + "MATCH (a) WITH a, 1 AS b ORDER BY size([(a)-->(x) | x]) RETURN b", + "MATCH (a) MATCH p = (a)-->() RETURN nodes(p)", + "MATCH ()-[r*]->() RETURN size(r)", + "WITH [] AS r MATCH ()-[r*]-() RETURN r", + "MATCH (a) WITH collect(a) AS ns UNWIND ns AS n MATCH (n)-->(m) RETURN m", + # What a CALL's brackets import stays in scope, of its kind, after a WITH inside it (drivine4j#464). + "MATCH (a) CALL (a) { WITH 1 AS one MATCH (a)-->(b) RETURN b } RETURN b", + ], +) +def test_a_variable_bound_already_is_compiled_where_neo4j_takes_it( + compiler: type[SqlCypherCompiler], cypher: str +) -> None: + # drivine4j's #451: the spec's refusals hold the statements Neo4j refuses; these are beside them. + compiler("g").compile(cypher) + + +_BOUND_BEFORE = "A relationship variable bound before its pattern is Cypher, and is not compiled to SQL yet." + + +@pytest.mark.parametrize("compiler", COMPILERS.values(), ids=COMPILERS.keys()) +@pytest.mark.parametrize( + ("cypher", "message"), + [ + ( + "MATCH (a) CALL (*) { CREATE (a) } RETURN 1", + "a is bound already, so a CREATE makes no node of it, as in Neo4j.", + ), + # Neo4j takes these, and the compiler refuses them for what it does not compile yet, as before. So it refuses a + # relationship bound before a CREATE or a MERGE that makes it again, as the spec has it, which Neo4j refuses as + # bound already. + ("MATCH ()-[r]->() WITH r MATCH ()-[r]->() RETURN r", _BOUND_BEFORE), + ("MATCH (a)-[r]->() RETURN EXISTS { (a)-[r]->() } AS e", _BOUND_BEFORE), + ("MATCH (a)-[r]->(b) MERGE (a)-[r]->(b)", _BOUND_BEFORE), + ("MATCH ()-[r]->() FOREACH (x IN [1] | CREATE ()-[r:T]->())", _BOUND_BEFORE), + ( + "MATCH (a) CALL { WITH a RETURN a AS b UNION WITH a MATCH ()-[b]->() RETURN b } RETURN b", + "A UNION in CALL { … } whose queries return values of different kinds is Cypher, and is not compiled to " + "SQL yet.", + ), + ( + "MATCH p = ()-->() RETURN [p = ()-->() | p] AS x", + "A path is not a value drivine gives the same on each store: use nodes(p), relationships(p) and length(p).", + ), + ], +) +def test_a_variable_used_as_two_kinds_or_made_where_it_is_bound_already_is_refused_as_neo4j_refuses_it( + compiler: type[SqlCypherCompiler], cypher: str, message: str +) -> None: + with pytest.raises(DrivineError) as refused: + compiler("g").compile(cypher) + assert str(refused.value) == message + + +@pytest.mark.parametrize("compiler", COMPILERS.values(), ids=COMPILERS.keys()) +@pytest.mark.parametrize("unit", ["fortnight", "__class__", "keys"]) +def test_a_truncation_to_a_unit_it_has_no_sql_for_names_each_unit_it_takes( + compiler: type[SqlCypherCompiler], unit: str +) -> None: + units = ( + "millennium, century, decade, year, weekYear, quarter, month, week, day, hour, minute, second, millisecond or " + "microsecond" + ) + with pytest.raises(DrivineError) as refused: + compiler("g").compile(f"RETURN date.truncate('{unit}', date('2024-03-15'))") + assert str(refused.value) == f"date.truncate() truncates to {units}, not {unit}." + + +@pytest.mark.parametrize("compiler", COMPILERS.values(), ids=COMPILERS.keys()) +@pytest.mark.parametrize( + ("cypher", "message"), + [ + ("RETURN datetime.truncate('day', datetime(), {timezone: '+19:00'})", "'+19:00' is no time zone."), + ("RETURN datetime.truncate('day', datetime(), {timezone: '+05:60'})", "'+05:60' is no time zone."), + ( + "RETURN datetime.truncate('day', datetime(), {timezone: 'UTC'})", + "The time zone 'UTC' is Cypher, and is not compiled to SQL yet.", + ), + ( + "RETURN date.truncate('year', date(), {quarter: 2})", + "date.truncate() of a map with quarter is Cypher, and is not compiled to SQL yet.", + ), + ("RETURN time.truncate('hour', time(), {year: 2000})", "time.truncate() takes no field year."), + ("RETURN date.truncate('month', date(), {day: -1})", "date.truncate() takes a day from 1 to 31, not -1."), + ( + "RETURN date.truncate('month', date(), {day: true})", + "date.truncate() takes a whole number for day, not true.", + ), + ], +) +def test_a_truncations_fields_past_what_the_spec_holds_are_refused_as_neo4j_refuses_them( + compiler: type[SqlCypherCompiler], cypher: str, message: str +) -> None: + # drivine4j's #468, and Drivine for TypeScript's #482: Neo4j takes a time zone as an offset alone or by name, which + # no store's text holds yet, and the fields of a week or a quarter, which no store has SQL for yet. + with pytest.raises(DrivineError) as refused: + compiler("g").compile(cypher) + assert str(refused.value) == message + + +@pytest.mark.parametrize("compiler", COMPILERS.values(), ids=COMPILERS.keys()) +def test_a_truncations_fields_given_as_an_expression_a_negated_number_utc_or_none_are_compiled( + compiler: type[SqlCypherCompiler], +) -> None: + for cypher in ( + "MATCH (n) RETURN date.truncate('month', date(), {day: n.day, year: -1})", + "RETURN datetime.truncate('day', datetime(), {timezone: '-00:00'}), " + "datetime.truncate('day', datetime(), {timezone: '+18:00'})", + "RETURN localdatetime.truncate('day', localdatetime(), {}), localtime.truncate('hour', localtime(), null)", + # A float negated is an expression, as drivine4j and Drivine for TypeScript read it: only a whole number + # negated is written. + "RETURN date.truncate('month', date(), {day: -2.0})", + ): + compiler("g").compile(cypher)