From 9a4ef4f9692d48ea4509527b281237677f48b424 Mon Sep 17 00:00:00 2001 From: jasper blues Date: Sun, 1 Nov 2026 03:47:22 +1000 Subject: [PATCH 1/2] Make a date or a moment from a week, an ordinal day or a quarter, and filter a grouping WITH by an aggregate in its WHERE, as drivine4j does, at Cypher spec v0.19.0 Ports drivine4j's #495 (merged as liberation-data/drivine4j#498) and #497 (merged as liberation-data/drivine4j#501), with the spec's liberation-data/drivine-cypher-spec#40 and #41. Calendar fields (#312): - date, localdatetime and datetime take {year, week[, dayOfWeek]}, {year, ordinalDay} and {year, quarter[, dayOfQuarter]}, beside a time and a timezone; localtime and time refuse them ("localtime() takes no field week."). - Written as numbers, the date is worked out when compiling (_calendar_date, proleptic Gregorian at any year) and made as {year, month, day} is, so it fails where Neo4j fails: two calendars, a day without its week or quarter, no year, a number past its range or not whole, a year past +-999,999,999. Week 53 of a 52-week year and a day of the quarter up to 92 run on into the next; ordinal day 366 of a common year fails. - Given by an expression, Temporals.of_calendar works it out in SQL; a field past its range gives the day 0, which the store fails on. A moment's date is read as a date first, as Oracle's JSON_VALUE reads a moment with day 0 as null. - dayOfQuarter is read off a temporal value. HAVING (#313): - check_aggregates takes the WHERE of a WITH that aggregates, or is DISTINCT, as filtering its groups; it refuses there only an aggregate in a comprehension's, a quantifier's, a reduce's or a pattern's own expressions. The message now says "unless its WITH aggregates". - SqlCypherCompiler._having makes each aggregate in that WHERE an item of the WITH (#havingN, named by _hidden as no text of the statement is, or the item that is that aggregate already), and reads a key written as its item is before its name as that item, compared as grouping compares them (a function's name in any case, a property's name as written, count(DISTINCT i) apart from count(i)). The made items are no variables after the WITH. The WHERE is still met after ORDER BY, SKIP and LIMIT; a CALL that imports the row reads it after the WITH too. The spec moves to v0.19.0. HELD in tests/cypher/cypher_spec.py holds the steps later ports own, each checked to fail until its port lands: #314 (null grouping, v0.17.0), #316 (Implicit grouping key's nested-body, order-term and any/none steps, v0.18.0), #317 (temporal from another, v0.19.0). drivine4j's CompilerRefusalTest, CypherQueryContract and CypherTemporalContract cases not in the spec are ported. Fixes #312 Fixes #313 --- drivine/cypher/sql/aggregates.py | 67 +++- drivine/cypher/sql/sql_cypher_compiler.py | 288 +++++++++++++- drivine/cypher/sql/temporals.py | 117 +++++- tests/cypher/VERSION | 2 +- tests/cypher/cypher_spec.py | 72 ++++ tests/cypher/queries.yaml | 316 ++++++++++++++- tests/cypher/refusals.yaml | 217 ++++++++++- tests/cypher/temporal.yaml | 365 ++++++++++++++++++ tests/cypher/writes.yaml | 14 + tests/integration/test_cypher_compiled.py | 112 ++++++ tests/integration/test_cypher_spec.py | 53 ++- .../sync_integration/test_cypher_compiled.py | 112 ++++++ tests/sync_integration/test_cypher_spec.py | 53 ++- tests/unit/test_cypher_refusals.py | 157 +++++++- 14 files changed, 1864 insertions(+), 81 deletions(-) diff --git a/drivine/cypher/sql/aggregates.py b/drivine/cypher/sql/aggregates.py index 30b39c4..95b08b9 100644 --- a/drivine/cypher/sql/aggregates.py +++ b/drivine/cypher/sql/aggregates.py @@ -6,6 +6,7 @@ from __future__ import annotations from collections.abc import Callable, Sequence +from typing import NoReturn from drivine.cypher.ast.clause import ( Call, @@ -49,7 +50,8 @@ def check_aggregates( InvalidAggregation), of a `MATCH`, a `WITH`, a comprehension, a quantifier, a pattern's or `EXISTS { … }`'s, in a `CALL { … }` or a subquery too. Read from the statement alone, before it is compiled, so a statement that compiles is compiled as it was. A subquery's own query, `COUNT { MATCH … RETURN count(*) }`, aggregates in its own - projections. + projections. The `WHERE` of a `WITH` that aggregates, or is `DISTINCT`, filters its groups, and holds an aggregate + as Neo4j has it (#313). `is_aggregate` and `children` are the compiler's, passed in so that neither module imports the other. """ @@ -79,7 +81,12 @@ def _clauses(self, clauses: Sequence[Clause]) -> None: self._expression(clause.list) elif isinstance(clause, With): self._projection(clause.projection) - self._where(clause.where) + # One that is DISTINCT groups its rows by its items, as one that aggregates does by those that do not. + projection = clause.projection + if projection.distinct or any(self._grouped(it.expression) is not None for it in projection.items): + self._having(clause.where) + else: + self._where(clause.where) elif isinstance(clause, Create): for pattern in clause.patterns: self._pattern(pattern) @@ -129,12 +136,52 @@ def _where(self, where: Expression | None) -> None: return aggregate = self._aggregate_in(where) if aggregate is not None: - raise DrivineError( - f"{_named(aggregate)} aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH " - "first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - ) + _refuse(aggregate) + self._expression(where) + + def _having(self, where: Expression | None) -> None: + """ + The `WHERE` of a `WITH` that aggregates: it filters the groups, as SQL's HAVING does, so an aggregate is read in + it as Neo4j reads it (#313), but not in a comprehension's, a quantifier's, a reduce's or a pattern's own + expressions. + """ + if where is None: + return + aggregate = self._beyond_groups(where) + if aggregate is not None: + _refuse(aggregate) self._expression(where) + def _grouped(self, expression: Expression) -> Expression | None: + """ + The first aggregate the expression holds outside a comprehension's, a quantifier's, a reduce's and a pattern's + own expressions and a subquery's own query, where `children` finds its parts: one the compiler reads over a + WITH's groups. + """ + if self._is_aggregate(expression): + return expression + for part in self._children(expression): + found = self._grouped(part) + if found is not None: + return found + return None + + def _beyond_groups(self, expression: Expression) -> Expression | None: + """The first aggregate the expression holds that `_grouped` does not find, but `_aggregate_in` does.""" + if self._is_aggregate(expression): + return None + near = self._children(expression) + for part in near: + found = self._beyond_groups(part) + if found is not None: + return found + for part in self._parts(expression): + if part not in near: + found = self._aggregate_in(part) + if found is not None: + return found + return None + def _aggregate_in(self, expression: Expression) -> Expression | None: """The first aggregate the expression holds, a comprehension's, a pattern's or `EXISTS { … }`'s among them, but not one a subquery's own query holds.""" @@ -196,6 +243,14 @@ def _parts(self, expression: Expression) -> list[Expression]: return self._children(expression) +def _refuse(aggregate: Expression) -> NoReturn: + raise DrivineError( + f"{_named(aggregate)} aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: " + "aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE " + "c > 1." + ) + + def _named(aggregate: Expression) -> str: """An aggregate's name, as a message gives it.""" if isinstance(aggregate, CountStar): diff --git a/drivine/cypher/sql/sql_cypher_compiler.py b/drivine/cypher/sql/sql_cypher_compiler.py index 4567062..985d1d5 100644 --- a/drivine/cypher/sql/sql_cypher_compiler.py +++ b/drivine/cypher/sql/sql_cypher_compiler.py @@ -77,7 +77,7 @@ from drivine.cypher.sql.parameters import Parameters, cypher_json, jsonable from drivine.cypher.sql.temporal import Temporal 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.temporals import CALENDAR_FIELDS, FIELD_NAMES, NoNumber, Overrides, Temporals, canonical from drivine.cypher.sql.value import ( Bool, Entities, @@ -1415,13 +1415,21 @@ def _anew_variable(self) -> str: A variable of `_made_anew`'s, new: named as no variable of the statement's is, nor begins, so one quoted alike, `` `#anew2` `` or `` `#anew2 row` ``, is not taken for it, hidden from a star or read as its UUID. """ + name = self._hidden(ANEW) + self._made_anew.add(name) + return name + + def _hidden(self, prefix: str) -> str: + """ + A name new to the statement, `prefix` and a number: named as no text of the queries given to compile is, nor + begins, so a variable quoted alike, `` `#having2` ``, stays the statement's. + """ for query in self._queries: _texts_in(query, self._query_texts) self._queries.clear() - name = self._alias(ANEW) + name = self._alias(prefix) while name in self._query_texts: - name = self._alias(ANEW) - self._made_anew.add(name) + name = self._alias(prefix) return name def _query(self, query: Query) -> str: @@ -1464,6 +1472,66 @@ def _read(self, select: _Select, clause: Clause, outer: Scope | None = None) -> def _with(self, select: _Select, with_: With, outer: Scope | None = None, apart: bool = False) -> _Select: """A `WITH`: a query of its own, which the rest of the statement reads from, as it reads `outer`.""" + read, hidden = self._having(with_) + next_ = self._with_having(select, read, outer, apart) + for it in hidden: + next_.bound.pop(it, None) + return next_ + + def _having(self, with_: With) -> tuple[With, list[str]]: + """ + A `WITH` that groups its rows, as one that aggregates or is `DISTINCT` does, read as Neo4j reads its `WHERE`, + which filters the groups, as SQL's HAVING does (#313), and the names of the items made for it. Each aggregate + the `WHERE` holds, outside a comprehension's or a quantifier's own expressions, is made an item of the `WITH`, + of its own where no item is that aggregate, named as no variable is, and the `WHERE` reads that item. A key the + `WITH` groups by, written there as the item is, before its name, is read as that item, as in `WITH i AS j, + count(*) AS c WHERE i > 1`; a variable an item is named after is that item, in a larger expression too. Keys and + aggregates are compared as grouping compares them (`_grouping_key`): `TOUPPER(x)` is the key `toUpper(x)`. The + items made are no variables of the statement's: the `WITH` is read, then they are left out of what it binds. + The `WITH` as it is where it groups nothing, or has no `WHERE`. As drivine4j's. + """ + where = with_.where + items = with_.projection.items + if where is None or ( + not with_.projection.distinct and not any(contains(it.expression, is_aggregate) for it in items) + ): + return with_, [] + names = {it.name for it in items} + keys = { + _grouping_key(it.expression): it.name + for it in items + if not contains(it.expression, is_aggregate) and variables_in(it.expression) + } + # An aggregate an item is already is read by that item's name. + aggregates = {_grouping_key(it.expression): it.name for it in items if is_aggregate(it.expression)} + lifted: dict[Expression, ProjectionItem] = {} + + def read(part: Expression) -> Expression | None: + key = _grouping_key(part) + if is_aggregate(part): + name = aggregates.get(key) + if name is None: + if key not in lifted: + # Named as no text of the statement's is, so an item quoted alike stays the statement's. + lifted[key] = ProjectionItem(part, self._hidden(HAVING)) + name = lifted[key].name + return Variable(name) + # `WITH i + 10 AS i … WHERE i + 10 > 20` reads the item i, as Neo4j does: no key is read where an item is + # named. + if any(it in names for it in variables_in(part)): + return part if isinstance(part, Variable) else None + name = keys.get(key) + return Variable(name) if name is not None else None + + condition = replaced(where, read) + if condition == where: + return with_, [] + made = list(lifted.values()) + projection = dataclasses.replace(with_.projection, items=(*items, *made)) + return dataclasses.replace(with_, projection=projection, where=condition), [it.name for it in made] + + def _with_having(self, select: _Select, with_: With, outer: Scope | None = None, apart: bool = False) -> _Select: + """A `WITH` `_having` has read: see `_with`.""" projected = self._project(select, with_.projection, final=False) name = self._alias("w") next_ = _Select(outer or {}) @@ -2608,11 +2676,15 @@ def each() -> None: elif isinstance(clause, With) and rebound: # What brackets import is seen throughout the call, as Neo4j has it, after a WITH that does not pass # it on too: read again from the row the call is written for, so `CALL (a) { WITH 1 AS one CREATE - # (a)-[:R]->() }` joins the row's a, not a new node. Its WHERE reads them too, so it is met after. - w = self._with(select, dataclasses.replace(clause, where=None)) + # (a)-[:R]->() }` joins the row's a, not a new node. Its WHERE reads them too, so it is met after; + # one that filters groups reads the aggregates `_having` made items of the WITH. + read, hidden = self._having(clause) + w = self._with_having(select, dataclasses.replace(read, where=None)) dropped = {it for it in imported if it not in w.scope} self._bind_row(w, caller, dropped, kinds=before, values=carried_before) - select = self._filtered_rows(w, clause.where) if clause.where is not None else w + select = self._filtered_rows(w, read.where) if read.where is not None else w + for it in hidden: + select.bound.pop(it, None) else: select = self._next(select, clause) if returned is not None: @@ -4554,10 +4626,7 @@ def of(text: str) -> Json: return Json(self.literal_json(worked), temporal=kind) if isinstance(written, MapLiteral): fields = {key: self._expression(value, scope) for key, value in written.entries} - known = DURATION_MAP_FIELDS if kind is Temporal.DURATION else INSTANT_MAP_FIELDS - unknown = next((it for it in fields if it not in known), None) - if unknown is not None: - unsupported(f"{function.name}() of a map with {unknown}") + _map_fields(kind, function.name, list(fields)) numbers = { key: self.number(self.comparable(self.json(value))) for key, value in fields.items() @@ -4567,7 +4636,17 @@ def of(text: str) -> Json: return of(self.temporals.duration(numbers)) timezone = fields.get("timezone") zone = self.text(self.comparable(self.json(timezone))) if timezone is not None else None - return of(self.temporals.of(numbers, zone, kind)) + if not any(key in CALENDAR_FIELDS for key, _ in written.entries): + return of(self.temporals.of(numbers, zone, kind)) + # A date or a moment takes a week, an ordinal day or a quarter in place of a month and a day: worked out + # here where the statement writes them, as `{year, month, day}` is made, else by the store. + _calendar_zone(written.entries) + date_ = _calendar_date(function.name, written.entries) + if date_ is None: + return of(self.temporals.of_calendar(numbers, zone, kind)) + day = {key: value for key, value in numbers.items() if key not in CALENDAR_FIELDS} + day["year"], day["month"], day["day"] = (str(it) for it in date_) + return of(self.temporals.of(day, zone, kind)) given = arguments[0] if not isinstance(given, Json): raise DrivineError(f"{function.name}() takes a text or a map of its fields.") @@ -6178,6 +6257,12 @@ def is_anew(expression: Expression) -> bool: `SqlCypherCompiler._anew_first`. Named as no variable is, unless quoted so: see `SqlCypherCompiler._anew_variable`. """ +HAVING = "#having" +""" +What the name of an item begins with that a `WITH` that groups is given for an aggregate of its `WHERE`: see +`SqlCypherCompiler._having`. Named as no variable of the statement is: see `SqlCypherCompiler._hidden`. +""" + RANDOM_UUID = Function("randomUUID", False, ()) """A call of `randomUUID()`, as a statement writes one.""" @@ -6323,7 +6408,7 @@ 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 its variables' names, and more (a label, a key, a text it gives), which is as much as a name made for it, with no - space, needs to keep clear of: see `SqlCypherCompiler._anew_variable`. + space, needs to keep clear of: see `SqlCypherCompiler._hidden`. """ if isinstance(value, str): texts.add(value.split(" ", 1)[0]) @@ -7188,6 +7273,183 @@ def _written_number(value: Expression) -> _Written | None: return None +def _map_fields(kind: Temporal, name: str, keys: list[str]) -> None: + """ + Refuses `keys`, the fields of a map that the function `name` makes a value of `kind` of, where it makes none of + them: a time of day has no date to give, as Neo4j has it, and a field the stores have no SQL for yet is refused as + not compiled yet. + """ + dated = kind in (Temporal.DATE, Temporal.LOCAL_DATE_TIME, Temporal.DATE_TIME) + if kind.instant and not dated: + calendar = next((it for it in keys if it in CALENDAR_FIELDS), None) + if calendar is not None: + raise DrivineError(f"{name}() takes no field {calendar}.") + if kind is Temporal.DURATION: + known = DURATION_MAP_FIELDS + else: + known = INSTANT_MAP_FIELDS | CALENDAR_FIELDS if dated else INSTANT_MAP_FIELDS + unknown = next((it for it in keys if it not in known), None) + if unknown is not None: + unsupported(f"{name}() of a map with {unknown}") + + +def _calendar_zone(entries: Sequence[tuple[str, Expression]]) -> None: + """ + Refuses the `timezone` of `entries`, a map of the calendar's fields, where it is a zone by name, which no store's + text holds yet, rather than give null. + """ + zone = next((value for key, value in entries if key == "timezone"), None) + if not isinstance(zone, Literal) or not isinstance(zone.value, str): + return + if _offset_text(zone.value) is None and not zone.value.startswith(("+", "-")) and _named_zone(zone.value): + unsupported(f"The time zone '{zone.value}'") + + +CALENDAR_LEAST = {"year": 0, "week": 1, "dayOfWeek": 1, "ordinalDay": 1, "quarter": 1, "dayOfQuarter": 1} +"""The least of the year and each field of a date by the calendar's other fields, which a field given as null is.""" + +CALENDARS = ( + ("a month", ("month", "day")), + ("a week", ("week", "dayOfWeek")), + ("an ordinal day", ("ordinalDay",)), + ("a quarter", ("quarter", "dayOfQuarter")), +) +"""The calendars a map's fields may give a date by, beside a year, and the fields of each.""" + +YEARS = 999_999_999 +"""The greatest year and the least, less its sign, that Java's dates reach, as Neo4j's do.""" + +EPOCH_DAYS = (-365_243_219_162, 365_241_780_471) +"""The days from 1970-01-01 to Java's least date, -999999999-01-01, and to its greatest, +999999999-12-31.""" + + +def _calendar_date(name: str, entries: Sequence[tuple[str, Expression]]) -> tuple[int, int, int] | None: + """ + The date, `(year, month, day)`, that `entries`, the fields of a map given the function `name`, give by the + calendar's other fields beside the year (see `Temporals.of_calendar`), where it writes the year and each of them as + a whole number or null: None where an expression gives any of them, for the store to work out. As Neo4j has them, a + field that is null is given as its least (the year 0, the first week, day or quarter), a week runs to 53 and a day + of the quarter to 92 in any year, carried into the next where it has fewer, and an ordinal day runs to the year's + last. Fails, as Neo4j does, on fields of two calendars (a month beside a week), a day of the week or of the quarter + without its week or quarter, a week or a quarter without a year, and a number written that is not whole or is past + its field's range, the year's among them (Java's, as Neo4j's: ±999,999,999). As drivine4j's. + """ + keys = {key for key, _ in entries} + calendar = _calendar_of(name, keys) + numbers = _calendar_numbers(name, entries) + if numbers is None: + return None + year = numbers["year"] + if not -YEARS <= year <= YEARS: + raise DrivineError( + f"{name}() takes no year {year}: Invalid value for Year (valid values -{YEARS} - {YEARS}): {year}" + ) + + def field(key: str, label: str, last: int, of: str = "") -> int: + given = numbers.get(key, 1) + if not 1 <= given <= last: + raise DrivineError(f"{name}() takes {label} from 1 to {last}{of}, not {given}.") + return given + + if calendar == "a week": + january4 = _days_from_civil(year, 1, 4) + monday = january4 - (_iso_day_of_week(january4) - 1) + days = monday + (field("week", "a week", 53) - 1) * 7 + field("dayOfWeek", "a day of the week", 7) - 1 + elif calendar == "an ordinal day": + last = 366 if _leap_year(year) else 365 + days = _days_from_civil(year, 1, 1) + field("ordinalDay", "an ordinal day", last, f" in {year}") - 1 + else: + first = _days_from_civil(year, (field("quarter", "a quarter", 4) - 1) * 3 + 1, 1) + days = first + field("dayOfQuarter", "a day of the quarter", 92) - 1 + least, greatest = EPOCH_DAYS + if not least <= days <= greatest: + raise DrivineError( + f"{name}() takes no year {year}: Invalid value for EpochDay (valid values {least} - {greatest}): {days}" + ) + return _civil_from_days(days) + + +def _calendar_of(name: str, keys: set[str]) -> str: + """ + The calendar, beside the year, that `keys`, the fields of a map given `name`, give a date by: refused where they + are of two, where a day of the week or of the quarter is without its week or quarter, or where they have no year. + """ + calendars = [label for label, fields in CALENDARS if any(it in keys for it in fields)] + if len(calendars) > 1: + raise DrivineError(f"{name}() takes {calendars[0]} or {calendars[1]}, not both.") + if "dayOfWeek" in keys and "week" not in keys: + raise DrivineError(f"{name}() takes a day of the week beside a week.") + if "dayOfQuarter" in keys and "quarter" not in keys: + raise DrivineError(f"{name}() takes a day of the quarter beside a quarter.") + if "year" not in keys: + # Neo4j makes an ordinal day of no year one of the year 0, which no store's date reaches. + if "ordinalDay" in keys: + unsupported(f"{name}() of an ordinal day without a year") + raise DrivineError(f"{name}() takes a year beside {calendars[0]}.") + return calendars[0] + + +def _calendar_numbers(name: str, entries: Sequence[tuple[str, Expression]]) -> dict[str, int] | None: + """ + The year and the calendar's fields of `entries`, the fields of a map given `name`, where each is written as a whole + number or null, its least: None where an expression gives any. A number that is not whole is refused. + """ + numbers: dict[str, int] = {} + for key, value in entries: + if key != "year" and key not in CALENDAR_FIELDS: + continue + written = _written_number(value) + if written is None: + return None + literal = written.value + if literal is None: + numbers[key] = CALENDAR_LEAST[key] + elif isinstance(literal, int) and not isinstance(literal, bool): + numbers[key] = literal + else: + shown = f"'{literal}'" if isinstance(literal, str) else _written_text(literal) + raise DrivineError(f"{name}() takes a whole number for {key}, not {shown}.") + return numbers + + +def _written_text(value: Any) -> str: + """The value as Cypher writes it: `true` for a truth value.""" + return str(value).lower() if isinstance(value, bool) else str(value) + + +def _leap_year(year: int) -> bool: + """Whether the year of the proleptic Gregorian calendar is a leap year.""" + return (year % 4 == 0 and year % 100 != 0) or year % 400 == 0 + + +def _days_from_civil(year: int, month: int, day: int) -> int: + """The days from 1970-01-01 to the date `year`-`month`-`day` of the proleptic Gregorian calendar, at any year.""" + y = year - 1 if month <= 2 else year + era = y // 400 + year_of_era = y - era * 400 + day_of_year = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1 + day_of_era = year_of_era * 365 + year_of_era // 4 - year_of_era // 100 + day_of_year + return era * 146097 + day_of_era - 719468 + + +def _civil_from_days(days: int) -> tuple[int, int, int]: + """The date, `(year, month, day)`, `days` after 1970-01-01, of the proleptic Gregorian calendar.""" + z = days + 719468 + era = z // 146097 + day_of_era = z - era * 146097 + year_of_era = (day_of_era - day_of_era // 1460 + day_of_era // 36524 - day_of_era // 146096) // 365 + day_of_year = day_of_era - (365 * year_of_era + year_of_era // 4 - year_of_era // 100) + mp = (5 * day_of_year + 2) // 153 + day = day_of_year - (153 * mp + 2) // 5 + 1 + month = mp + 3 if mp < 10 else mp - 9 + return year_of_era + era * 400 + (1 if month <= 2 else 0), month, day + + +def _iso_day_of_week(days: int) -> int: + """The ISO day of the week, from 1 for Monday to 7, of the day `days` after 1970-01-01, a Thursday.""" + return (days + 3) % 7 + 1 + + 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 diff --git a/drivine/cypher/sql/temporals.py b/drivine/cypher/sql/temporals.py index 7a58507..4408989 100644 --- a/drivine/cypher/sql/temporals.py +++ b/drivine/cypher/sql/temporals.py @@ -46,24 +46,28 @@ class Field: FIELD_NAMES = frozenset( { - "year", "quarter", "month", "week", "weekYear", "day", "ordinalDay", "dayOfWeek", "weekDay", "hour", "minute", - "second", "millisecond", "microsecond", "nanosecond", "epochSeconds", "epochMillis", "timezone", "offset", - "offsetMinutes", "offsetSeconds", "years", "quarters", "months", "weeks", "days", "hours", "minutes", "seconds", - "milliseconds", "microseconds", "nanoseconds", "quartersOfYear", "monthsOfQuarter", "monthsOfYear", - "daysOfWeek", "minutesOfHour", "secondsOfMinute", "millisecondsOfSecond", "microsecondsOfSecond", - "nanosecondsOfSecond", + "year", "quarter", "month", "week", "weekYear", "day", "ordinalDay", "dayOfQuarter", "dayOfWeek", "weekDay", + "hour", "minute", "second", "millisecond", "microsecond", "nanosecond", "epochSeconds", "epochMillis", + "timezone", "offset", "offsetMinutes", "offsetSeconds", "years", "quarters", "months", "weeks", "days", "hours", + "minutes", "seconds", "milliseconds", "microseconds", "nanoseconds", "quartersOfYear", "monthsOfQuarter", + "monthsOfYear", "daysOfWeek", "minutesOfHour", "secondsOfMinute", "millisecondsOfSecond", + "microsecondsOfSecond", "nanosecondsOfSecond", } ) # fmt: skip """The names of the fields a temporal value may have, of a moment's or a duration's.""" INSTANT_FIELDS = frozenset( { - "year", "quarter", "month", "week", "weekYear", "day", "ordinalDay", "dayOfWeek", "weekDay", "hour", "minute", - "second", "millisecond", "microsecond", "nanosecond", + "year", "quarter", "month", "week", "weekYear", "day", "ordinalDay", "dayOfQuarter", "dayOfWeek", "weekDay", + "hour", "minute", "second", "millisecond", "microsecond", "nanosecond", } ) # fmt: skip """The fields of a moment or a time of day read off its wall clock.""" +CALENDAR_FIELDS = frozenset({"week", "dayOfWeek", "ordinalDay", "quarter", "dayOfQuarter"}) +"""The fields that give a date by the calendar's other fields, beside the year, in place of a month and a day: see +`Temporals.of_calendar`.""" + _TIMES = (Temporal.TIME, Temporal.LOCAL_TIME) """The kinds of a time of day, which no months are between.""" @@ -204,6 +208,97 @@ def field(name: str, default: str, digits: int) -> str: raise ValueError("a duration is made of its own fields") return self.convert(text, kind, kind) + def of_calendar(self, fields: dict[str, str], timezone: str | None, kind: Temporal) -> str: + """ + `of` for fields that give the date by the calendar's other fields beside the `year`, SQL numbers each: a `week` + of the ISO week-based year and its `dayOfWeek`, an `ordinalDay`, or a `quarter` and its `dayOfQuarter`. The + date is so many days after the first of the year, of the quarter, or of the year's first ISO week, the week of + January 4th. As Neo4j has them, a field that is null is given as its least (the year 0, the first week, day or + quarter), and a week runs to 53 and a day of the quarter to 92 in any year, carried into the next where it has + fewer. A field that is not whole or is past its range gives the day 0, which the store fails on as it fails on + `{day: 0}`. + """ + time = {name: value for name, value in fields.items() if name != "year" and name not in CALENDAR_FIELDS} + + def given(name: str, least: int) -> str: + return f"COALESCE({fields[name]}, {least})" if name in fields else str(least) + + def january(y: str, day: int) -> str: + return self.sql.local(f"to_char({y}, 'FM0000') || '-01-0{day}'", Temporal.DATE) + + def whole(*values: str) -> str: + return " AND ".join(f"{it} = TRUNC({it})" for it in values) + + year = given("year", 0) + if "week" in fields: + + def week(v: list[str]) -> str: + y, w, d = v + + def of_january4(j: list[str]) -> str: + days = f"({w} - 1) * 7 + {d} - {self.sql.day_of_week(j[0])}" + valid = f"{whole(y, w, d)} AND {w} BETWEEN 1 AND 53 AND {d} BETWEEN 1 AND 7" + return self._calendar_date( + self.sql.add_time(j[0], days, "0"), time, timezone, kind, lambda _: valid + ) + + return self.shared([january(y, 4)], of_january4) + + return self.shared([year, given("week", 1), given("dayOfWeek", 1)], week) + if "ordinalDay" in fields: + + def ordinal(v: list[str]) -> str: + y, n = v + return self._calendar_date( + self.sql.add_time(january(y, 1), f"{n} - 1", "0"), + time, + timezone, + kind, + lambda day: f"{whole(y, n)} AND {n} >= 1 AND EXTRACT(YEAR FROM {day}) = {y}", + ) + + return self.shared([year, given("ordinalDay", 1)], ordinal) + + def quarter(v: list[str]) -> str: + y, q, n = v + first = self.sql.add_months(january(y, 1), f"({q} - 1) * 3") + valid = f"{whole(y, q, n)} AND {q} BETWEEN 1 AND 4 AND {n} BETWEEN 1 AND 92" + return self._calendar_date(self.sql.add_time(first, f"{n} - 1", "0"), time, timezone, kind, lambda _: valid) + + return self.shared([year, given("quarter", 1), given("dayOfQuarter", 1)], quarter) + + def _calendar_date( + self, date_: str, time: dict[str, str], timezone: str | None, kind: Temporal, valid: Callable[[str], str] + ) -> str: + """ + `of` the date the timestamp `date_` reads where `valid` holds of it, else of the day 0, and the fields of the + time `time`. A moment's date is read as a date first: every store fails on a date's day 0, where Oracle reads a + moment's text as a JSON value's, null where it is no moment. + """ + + def dated(d: list[str]) -> str: + day = d[0] + fields = { + "year": f"EXTRACT(YEAR FROM {day})", + "month": f"EXTRACT(MONTH FROM {day})", + "day": f"CASE WHEN NOT ({valid(day)}) THEN 0 ELSE EXTRACT(DAY FROM {day}) END", + } + if kind is Temporal.DATE: + return self.of(fields, None, kind) + + def timed(c: list[str]) -> str: + checked = c[0] + day_of = { + "year": f"EXTRACT(YEAR FROM {checked})", + "month": f"EXTRACT(MONTH FROM {checked})", + "day": f"EXTRACT(DAY FROM {checked})", + } + return self.of({**time, **day_of}, timezone, kind) + + return self.shared([self.sql.local(self.of(fields, None, Temporal.DATE), Temporal.DATE)], timed) + + return self.shared([date_], dated) + def field(self, text: str, kind: Temporal, name: str) -> Field | None: """ A field of the text of the kind, by Cypher's name for it: a number, or for `timezone` and `offset` a text; None @@ -242,6 +337,12 @@ def _clock_field(self, t: str, name: str) -> str: return f"EXTRACT(DAY FROM {t})" if name == "ordinalDay": return self.sql.number(f"to_char({t}, 'DDD')") + if name == "dayOfQuarter": + quarter = self.sql.truncate("quarter", t) + assert quarter is not None + day = self.sql.number(f"to_char({t}, 'DDD')") + first = self.sql.number(f"to_char({quarter}, 'DDD')") + return f"({day} - {first} + 1)" if name in ("dayOfWeek", "weekDay"): return self.sql.day_of_week(t) if name == "hour": diff --git a/tests/cypher/VERSION b/tests/cypher/VERSION index 4a29f93..96fb87f 100644 --- a/tests/cypher/VERSION +++ b/tests/cypher/VERSION @@ -1 +1 @@ -v0.14.0 +v0.19.0 diff --git a/tests/cypher/cypher_spec.py b/tests/cypher/cypher_spec.py index 9955bd3..6ccdc9d 100644 --- a/tests/cypher/cypher_spec.py +++ b/tests/cypher/cypher_spec.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import Any +import pytest import yaml SPEC = Path(__file__).parent @@ -167,6 +168,77 @@ def failure_matches(step: Step, failure: BaseException) -> bool: return True +@dataclass(frozen=True) +class Hold: + """Steps of a case held on some stores until the issue whose port makes them pass lands: see `HELD`.""" + + issue: int + """The issue of drivine-python's whose port makes the steps pass.""" + on: tuple[str, ...] + """The stores or groups the steps are held on.""" + steps: tuple[int, ...] | None = None + """The steps, from 1; every step of the case where None.""" + + +def _steps(first: int, last: int) -> tuple[int, ...]: + """The steps from `first` to `last`.""" + return tuple(range(first, last + 1)) + + +HELD: dict[str, dict[str, list[Hold]]] = { + "queries.yaml": { + # v0.17.0, liberation-data/drivine-cypher-spec#42: JSON's null and a missing key group, de-duplicate and sort + # apart. + "A null in a map and a missing key are one null": [ + Hold(314, ("postgres",), (*_steps(1, 23), 25, 26)), + Hold(314, ("oracle",), (*_steps(1, 27), 29)), + ], + # v0.18.0, liberation-data/drivine-cypher-spec#43: a key read in a body nested in a list comprehension's WHERE, + # in an order term's body, and any(y IN … WHERE y = e). + "Implicit grouping key": [Hold(316, ("sql",), (29, 30, 31, 33, 34, 35, 37, 38, 39, 40, 42, 43, 44))], + }, + "writes.yaml": { + "A null in a written row and a missing key are one null": [Hold(314, ("sql",), (1, 2))], + }, + "temporal.yaml": { + # v0.19.0, liberation-data/drivine-cypher-spec#44: a temporal value made from another. + "Dates, times and moments from another temporal value": [Hold(317, ("sql",))], + "Zoned times and moments from another temporal value": [Hold(317, ("sql",))], + "Temporal values from another an expression gives": [Hold(317, ("sql",), (2, 3, 4))], + }, + "refusals.yaml": { + "Temporal map not compiled yet": [Hold(317, ("sql",), (1, 4))], + "Temporal map from another temporal value Neo4j does not take": [Hold(317, ("sql",))], + }, +} +""" +The steps of a spec tag past what drivine-python runs that a port here has yet to make pass, by file and case. Each is +run, and must fail; one that passes fails, so that its hold is taken out when its port lands. As Drivine for +TypeScript's. +""" + + +def held_for(file: str, case: Case, index: int, store: str) -> int | None: + """The issue the step at `index` (from 0) of the case of `file` is held for on `store`, if it is: see `HELD`.""" + for hold in HELD.get(file, {}).get(case.name, []): + named = any(it == store or store in GROUPS.get(it, set()) for it in hold.on) + if named and (hold.steps is None or index + 1 in hold.steps): + return hold.issue + return None + + +HELD_FAILURES = (Exception, pytest.fail.Exception) +"""What a held step fails with: the store's or the compiler's failure, or the check's.""" + + +def held_passes(file: str, case: Case, index: int, store: str, issue: int) -> str: + """What is said of a held step that passes.""" + return ( + f"{file}, {case.name}, step {index + 1} is held on {store} for liberation-data/drivine-python#{issue}, and " + "passes: take it out of HELD." + ) + + def check_rows(step: Step, rows: list[Any], where: str) -> None: """That the rows are those the step expects, compared as it says.""" if step.rows is None: diff --git a/tests/cypher/queries.yaml b/tests/cypher/queries.yaml index 1149563..5a23783 100644 --- a/tests/cypher/queries.yaml +++ b/tests/cypher/queries.yaml @@ -1436,6 +1436,119 @@ cases: - cypher: "RETURN CASE null WHEN null THEN 'null' ELSE 'other' END" rows: ["other"] + - name: "A null in a map and a missing key are one null" + description: "A map's or a list's null, written or given, and a key or an item that is missing are the same null: grouped as one, kept once by DISTINCT and UNION, and tied in an order." + steps: + - note: "On the SQL stores a map's null is JSON's null, and a missing key SQL's: they are grouped, kept distinct and sorted as one." + cypher: "UNWIND [{a: null}, {b: 1}] AS x RETURN x.a, count(*)" + only: [neo4j, sql] + rows: [[null, 2]] + - cypher: "UNWIND [{a: null}, {b: 1}, {a: 1}] AS x RETURN x.a AS a, count(*) AS c ORDER BY a" + only: [neo4j, sql] + rows: [[1, 1], [null, 2]] + - cypher: "UNWIND [{a: null}, {b: 1}] AS x WITH x.a AS a, count(*) AS c RETURN a, c" + only: [neo4j, sql] + rows: [[null, 2]] + - cypher: "UNWIND [{a: null}, {b: 1}] AS x WITH x.a AS a RETURN a, count(*) AS c" + only: [neo4j, sql] + rows: [[null, 2]] + - cypher: "UNWIND [{a: null}, {b: 1}] AS x RETURN x['a'] AS a, count(*) AS c" + only: [neo4j, sql] + rows: [[null, 2]] + - cypher: "UNWIND [{a: {b: null}}, {a: {}}] AS x RETURN x.a.b AS v, count(*) AS c" + only: [neo4j, sql] + rows: [[null, 2]] + - cypher: "UNWIND [[null], []] AS l RETURN l[0] AS v, count(*) AS c" + only: [neo4j, sql] + rows: [[null, 2]] + - cypher: "UNWIND [{a: null}, {b: 1}, {c: 2}] AS x RETURN CASE WHEN x.c IS NULL THEN x.a ELSE null END AS v, count(*) AS c" + only: [neo4j, sql] + rows: [[null, 3]] + - cypher: "UNWIND [{a: null}, {b: 1}] AS x WITH collect(x) AS xs UNWIND xs AS y RETURN y.a AS a, count(*) AS c" + only: [neo4j, sql] + rows: [[null, 2]] + - cypher: "UNWIND $p AS x RETURN x.a AS a, count(*) AS c" + only: [neo4j, sql] + parameters: {p: [{a: null}, {b: 1}]} + rows: [[null, 2]] + - cypher: "UNWIND [{b: 1}, $p] AS x RETURN x.a AS a, count(*) AS c" + only: [neo4j, sql] + parameters: {p: {a: null}} + rows: [[null, 2]] + - cypher: "UNWIND [{a: null}, {b: 1}] AS x RETURN x.a = $p AS e, x.a AS a, count(*) AS c" + only: [neo4j, sql] + parameters: {p: null} + rows: [[null, null, 2]] + - note: "DISTINCT keeps the one null once." + cypher: "UNWIND [{a: null}, {b: 1}] AS x RETURN DISTINCT x.a" + only: [neo4j, sql] + rows: [null] + - cypher: "UNWIND [{a: null}, {b: 1}] AS x WITH DISTINCT x.a AS a RETURN count(*)" + only: [neo4j, sql] + rows: [1] + - cypher: "UNWIND $p AS x RETURN DISTINCT x.a AS a" + only: [neo4j, sql] + parameters: {p: [{a: null}, {b: 1}]} + rows: [null] + - note: "So does UNION." + cypher: "UNWIND [{a: null}] AS x RETURN x.a AS v UNION UNWIND [{b: 1}] AS x RETURN x.a AS v" + only: [neo4j, sql] + rows: [null] + - cypher: "UNWIND [{b: 1}] AS x RETURN x.a AS v UNION RETURN $p.a AS v" + only: [neo4j, sql] + parameters: {p: {a: null}} + rows: [null] + - cypher: "UNWIND [{b: 1}] AS x RETURN x.a AS v UNION RETURN $p[0] AS v" + only: [neo4j, sql] + parameters: {p: [null, 1]} + rows: [null] + - cypher: "UNWIND [{b: 1}] AS x RETURN x.a AS v UNION RETURN null AS v" + only: [neo4j, sql] + rows: [null] + - cypher: "UNWIND [null] AS v RETURN v UNION MATCH (p:Person {name: 'Keanu'}) RETURN p.nickname AS v" + only: [neo4j, sql] + rows: [null] + - cypher: "RETURN $p.a AS a UNION RETURN $q.a AS a" + only: [neo4j, sql] + parameters: {p: {a: null}, q: {}} + rows: [null] + - note: "A null written is the same null as a property a node has not." + cypher: "MATCH (p:Person) RETURN CASE WHEN p.born > 1960 THEN p.nickname ELSE null END AS v, count(*) AS c" + only: [neo4j, sql] + rows: [[null, 4]] + - cypher: "MATCH (p:Person) RETURN DISTINCT CASE WHEN p.born > 1960 THEN null END AS v" + only: [neo4j, sql] + rows: [null] + - cypher: "MATCH (p:Person) WITH p ORDER BY CASE WHEN p.born > 1964 THEN null ELSE p.nickname END, p.name DESC RETURN collect(p.name) AS l" + only: [neo4j, sql] + rows: [["Laurence", "Keanu", "Hugo", "Carrie"]] + - cypher: "UNWIND [1, null] AS v WITH v RETURN CASE WHEN v = 1 THEN {}.a ELSE v END AS w, count(*) AS c" + only: [neo4j, sql] + rows: [[null, 2]] + - cypher: "UNWIND [{a: null}, {b: 1}] AS x RETURN reduce(s = null, t IN [1] | x.a) AS v, count(*) AS c" + only: [neo4j, sql] + rows: [[null, 2]] + - note: "An order ties them, so the next term decides." + cypher: "UNWIND [{a: null, i: 1}, {b: 1, i: 2}, {a: 0, i: 3}] AS x RETURN x.i AS i ORDER BY x.a, x.i DESC" + only: [neo4j, sql] + rows: [3, 2, 1] + - cypher: "UNWIND [{a: null, i: 1}, {b: 1, i: 2}, {a: 0, i: 3}] AS x RETURN x.i AS i ORDER BY x.a DESC, x.i DESC" + only: [neo4j, sql] + rows: [2, 1, 3] + - cypher: "UNWIND [{a: null, i: 1}, {b: 1, i: 2}, {a: 0, i: 3}] AS x WITH x ORDER BY x.a, x.i DESC RETURN collect(x.i) AS l" + only: [neo4j, sql] + rows: [[3, 2, 1]] + - note: "count(DISTINCT), collect(DISTINCT), = and IN read the one null as null already." + cypher: "UNWIND [{a: null, k: 1}, {b: 1, k: 1}] AS x RETURN x.k AS k, collect(DISTINCT x.a) AS l, count(DISTINCT x.a) AS n" + only: [neo4j, sql] + rows: [[1, [], 0]] + - cypher: "UNWIND [{a: null}, {b: 1}] AS x UNWIND [{a: null}, {b: 1}] AS y RETURN x.a = y.a AS e, x.a <> y.a AS ne, count(*) AS n" + only: [neo4j, sql] + rows: [[null, null, 4]] + - cypher: "UNWIND [{a: null}, {a: 2}] AS x RETURN x.a IN [1] AS i, x.a IN [null] AS j, x.a IN [2, null] AS k, x.a = 1 AS e ORDER BY e" + only: [neo4j, sql] + rows: [[false, null, true, false], [null, null, null, null]] + - name: "Relationships in lists compare by identity" description: "Two relationships with the same properties are different, as items of a list too." steps: @@ -1581,6 +1694,60 @@ cases: - cypher: "MATCH (:Person)-[r:ACTED_IN]->(m) RETURN r.role AS role, any(t IN collect(m.title) WHERE t CONTAINS r.role) AS n ORDER BY role" only: [neo4j, sql] rows: [["Johnny", true], ["Morpheus", false], ["Neo", false], ["Neo", false], ["Trinity", false], ["Trinity", false]] + - note: "Inside a list comprehension's WHERE, the key all through: in a quantifier's WHERE, a reduce()'s step or a comprehension's projection there too. Two x share a = 1, so a row for each x would give a = 1 twice." + cypher: "MATCH (:Person)-[r:ACTED_IN]->(m) RETURN r.role AS role, size([t IN collect(m.title) WHERE any(u IN [t] WHERE u CONTAINS r.role)]) AS n ORDER BY role" + only: [neo4j, sql] + rows: [["Johnny", 1], ["Morpheus", 0], ["Neo", 0], ["Trinity", 0]] + - cypher: "UNWIND [{a: 1, b: 1}, {a: 1, b: 2}, {a: 2, b: 1}] AS x RETURN x.a AS a, size([t IN collect(x.b) WHERE reduce(s = false, u IN [t] | s OR u > x.a)]) AS n ORDER BY a" + only: [neo4j, sql] + rows: [[1, 1], [2, 0]] + - cypher: "UNWIND [{a: 1, b: 1}, {a: 1, b: 2}, {a: 2, b: 1}] AS x RETURN x.a AS a, size([t IN collect(x.b) WHERE [u IN [t] | u > x.a][0]]) AS n ORDER BY a" + only: [neo4j, sql] + rows: [[1, 1], [2, 0]] + - note: "Not the other way about: a list comprehension's WHERE inside a quantifier's WHERE over an aggregate is grouped by x." + cypher: "UNWIND [{a: 1, b: 1}, {a: 1, b: 2}, {a: 2, b: 1}] AS x RETURN x.a AS a, any(t IN collect(x.b) WHERE size([u IN [t] WHERE u > x.a]) > 0) AS n ORDER BY a, n" + only: [neo4j, sql] + rows: [[1, false], [1, true], [2, false]] + - note: "A comprehension, a quantifier or a reduce() that holds no aggregate reads the key in each of its bodies." + cypher: "UNWIND [{a: 1, b: 1}, {a: 1, b: 2}, {a: 2, b: 1}] AS x RETURN x.a AS a, count(*) + [y IN [1] | x.a][0] AS n ORDER BY a" + only: [neo4j, sql] + rows: [[1, 3], [2, 3]] + - cypher: "UNWIND [{a: 1, b: 1}, {a: 1, b: 2}, {a: 2, b: 1}] AS x RETURN x.a AS a, count(*) + reduce(s = 0, y IN [1] | s + x.a) AS n ORDER BY a" + only: [neo4j, sql] + rows: [[1, 3], [2, 3]] + - cypher: "UNWIND [{a: 1, b: 1}, {a: 1, b: 2}, {a: 2, b: 1}] AS x RETURN x.a AS a, [count(*), any(y IN [0, 1] WHERE y > x.a)] AS n ORDER BY a" + only: [neo4j, sql] + rows: [[1, [2, false]], [2, [1, false]]] + - note: "But in the projection of a comprehension over an aggregate, x, whatever the projection holds." + cypher: "UNWIND [{a: 1, b: 1}, {a: 1, b: 2}, {a: 2, b: 1}] AS x RETURN x.a AS a, size([t IN collect(x.b) WHERE t >= x.a | [y IN [1] | x.a][0]]) AS n ORDER BY a, n" + only: [neo4j, sql] + rows: [[1, 1], [1, 1], [2, 0]] + - note: "Neo4j reads any(y IN list WHERE y = e) as e IN list, and none(…) as NOT e IN list, where e reads no y, so these read the key; y IN [e] is y = e. all() and single() are not read so." + cypher: "UNWIND [{a: 1, b: 1}, {a: 1, b: 2}, {a: 2, b: 1}] AS x RETURN x.a AS a, any(y IN collect(x.b) WHERE y = x.a) AS n ORDER BY a" + only: [neo4j, sql] + rows: [[1, true], [2, false]] + - cypher: "UNWIND [{a: 1, b: 1}, {a: 1, b: 2}, {a: 2, b: 1}] AS x RETURN x.a AS a, none(y IN collect(x.b) WHERE x.a = y) AS n ORDER BY a" + only: [neo4j, sql] + rows: [[1, false], [2, true]] + - cypher: "UNWIND [{a: 1, b: 1}, {a: 1, b: 2}, {a: 2, b: 1}] AS x RETURN x.a AS a, any(y IN collect(x.b) WHERE y IN [x.a]) AS n ORDER BY a" + only: [neo4j, sql] + rows: [[1, true], [2, false]] + - cypher: "MATCH (:Person)-[r:ACTED_IN]->(m) RETURN r.role AS role, any(t IN collect(m.title) WHERE t = r.role) AS n ORDER BY role" + only: [neo4j, sql] + rows: [["Johnny", false], ["Morpheus", false], ["Neo", false], ["Trinity", false]] + - cypher: "UNWIND [{a: 1, b: 1}, {a: 1, b: 2}, {a: 2, b: 1}] AS x RETURN x.a AS a, all(y IN collect(x.b) WHERE y = x.a) AS n ORDER BY a, n" + only: [neo4j, sql] + rows: [[1, false], [1, true], [2, false]] + - note: "An order term that aggregates reads the key as an item does." + cypher: "UNWIND [{a: 1, b: 1}, {a: 1, b: 2}, {a: 2, b: 1}] AS x RETURN x.a AS a, count(*) AS n ORDER BY size([y IN collect(x.b) WHERE y > x.a])" + only: [neo4j, sql] + rows: [[2, 1], [1, 2]] + - cypher: "MATCH (:Person)-[r:ACTED_IN]->(m) RETURN r.role AS role, count(*) AS n ORDER BY size([t IN collect(m.title) WHERE t CONTAINS r.role]) DESC, role" + only: [neo4j, sql] + rows: [["Johnny", 1], ["Morpheus", 1], ["Neo", 2], ["Trinity", 2]] + - cypher: "UNWIND [{a: 1, b: 1}, {a: 1, b: 2}, {a: 2, b: 1}] AS x RETURN x.a AS a, count(*) AS n ORDER BY size([y IN collect(x.b) | x.a]), a" + only: [neo4j, sql] + rows: [[1, 1], [1, 1], [2, 1]] - note: "Unnamed, a map projection is an item named after its variable, which an order term or the next clause reads." cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p {.name}, count(*) AS c ORDER BY p.name" only: [neo4j, sql] @@ -1603,7 +1770,7 @@ cases: rows: [["X", 2], ["Y", 1]] - name: "Aggregate beside a WHERE" - description: "A WHERE over what an aggregate gave, and one in a subquery's or a CALL's own query, which aggregates in its projection, filters as Neo4j does. An aggregate in a WHERE is refused (see refusals.yaml, \"Aggregate in a WHERE\")." + description: "A WHERE over what an aggregate gave, and one in a subquery's or a CALL's own query, which aggregates in its projection, filters as Neo4j does. An aggregate in the WHERE of a WITH that groups is in \"Aggregate in the WHERE of a WITH that groups\"; in any other WHERE it is refused (see refusals.yaml, \"Aggregate in a WHERE\")." steps: - cypher: "UNWIND [1, 2] AS i WITH count(*) AS c WHERE c > 1 RETURN c" rows: [2] @@ -1616,6 +1783,153 @@ cases: - cypher: "MATCH (p:Person) CALL (p) { MATCH (p)-[:ACTED_IN]->(m) WITH count(m) AS c WHERE c > 1 RETURN c } RETURN p.name, c ORDER BY p.name" rows: [["Carrie", 2], ["Keanu", 3]] + - name: "Aggregate in the WHERE of a WITH that groups" + description: "The WHERE of a WITH that aggregates, or is DISTINCT, filters its groups, as SQL's HAVING does, and holds an aggregate, as Neo4j has it: one that is no item is worked out over each group's rows too. There a variable from before the WITH is read where it is a key the WITH groups by, written as its item is, and inside an aggregate; a name an item has is that item, in a larger expression too. The WHERE is met after the WITH's ORDER BY, SKIP and LIMIT. An aggregate in the WHERE of a WITH that groups nothing, or in a comprehension's or a pattern's own WHERE, is refused, and so is a variable no key is (see refusals.yaml, \"Aggregate in a WHERE\" and \"Variable before a WITH that groups, in its WHERE\")." + steps: + - cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE count(*) > 1 RETURN i" + only: [neo4j, sql] + rows: [1] + - cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE sum(i) > 1 RETURN i" + only: [neo4j, sql] + unordered: true + rows: [1, 2] + - cypher: "UNWIND [1, 2] AS i WITH i AS j, count(*) AS c WHERE j > count(*) RETURN j" + only: [neo4j, sql] + rows: [2] + - note: "An aggregate no item is, over each group's rows." + cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE max(i) > 1 RETURN i, c" + only: [neo4j, sql] + rows: [[2, 1]] + - cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE avg(i) >= 1.5 RETURN i, c" + only: [neo4j, sql] + rows: [[2, 1]] + - cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE size(collect(i)) > 1 RETURN i, c" + only: [neo4j, sql] + rows: [[1, 2]] + - cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE count(DISTINCT i) = 1 RETURN i, c" + only: [neo4j, sql] + unordered: true + rows: [[1, 2], [2, 1]] + - cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE size(collect(DISTINCT i)) = 1 AND count(*) > 1 RETURN i, c" + only: [neo4j, sql] + rows: [[1, 2]] + - cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE max(i) + count(*) > 2 RETURN i, c ORDER BY i" + only: [neo4j, sql] + rows: [[1, 2], [2, 1]] + - cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) WITH p, count(*) AS c WHERE max(m.released) > p.born + 37 RETURN p.name, c ORDER BY p.name" + only: [neo4j, sql] + rows: [["Keanu", 3], ["Laurence", 1]] + - note: "An aggregate an item is, by the item's name or written again." + cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE c = count(*) RETURN i, c ORDER BY i" + only: [neo4j, sql] + rows: [[1, 2], [2, 1]] + - cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) WITH p, collect(m.title) AS ts WHERE size(collect(m.title)) > 1 RETURN p.name ORDER BY p.name" + only: [neo4j, sql] + rows: ["Carrie", "Keanu"] + - note: "Beside a condition on a key, in a CASE, and as the list a quantifier reads." + cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE i > 1 OR count(*) > 1 RETURN i, c ORDER BY i" + only: [neo4j, sql] + rows: [[1, 2], [2, 1]] + - cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE i > 1 AND count(*) > 1 RETURN i, c" + only: [neo4j, sql] + rows: [] + - cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE CASE WHEN count(*) > 1 THEN true ELSE false END RETURN i, c" + only: [neo4j, sql] + rows: [[1, 2]] + - cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE all(x IN collect(i) WHERE x > 1) RETURN i, c" + only: [neo4j, sql] + rows: [[2, 1]] + - note: "With no key, the rows are one group, an empty one where there are none." + cypher: "UNWIND [1, 2, 3] AS i WITH count(*) AS c WHERE sum(i) > 2 RETURN c" + only: [neo4j, sql] + rows: [3] + - cypher: "UNWIND [1, 2, 3] AS i WITH count(*) AS c WHERE count(*) > 5 RETURN c" + only: [neo4j, sql] + rows: [] + - cypher: "UNWIND [] AS i WITH count(*) AS c WHERE count(*) = 0 RETURN c" + only: [neo4j, sql] + rows: [0] + - note: "A key, written as its item is before its name; its variable inside an aggregate, over each group's rows." + cypher: "UNWIND [1, 1, 2] AS i WITH i AS j, count(*) AS c WHERE i > 1 RETURN j, c" + only: [neo4j, sql] + rows: [[2, 1]] + - cypher: "UNWIND [1, 1, 2] AS i WITH i AS j, count(*) AS c WHERE sum(i) > 1 RETURN j, c" + only: [neo4j, sql] + unordered: true + rows: [[1, 2], [2, 1]] + - cypher: "UNWIND [1, 2, 3] AS i WITH i % 2 AS k, count(*) AS c WHERE i % 2 = 1 RETURN k, c" + only: [neo4j, sql] + rows: [[1, 2]] + - cypher: "UNWIND [1, 2, 3] AS i WITH i % 2 AS k, count(*) AS c WHERE sum(i) > 2 RETURN k, c" + only: [neo4j, sql] + rows: [[1, 2]] + - cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) WITH p.name AS n, count(*) AS c WHERE p.name STARTS WITH 'K' AND count(*) > 1 RETURN n, c" + only: [neo4j, sql] + rows: [["Keanu", 3]] + - note: "A name an item has is that item, in a larger expression too: i + 10 is the item i, plus 10." + cypher: "UNWIND [1, 1, 2] AS i WITH i + 10 AS i, count(*) AS c WHERE i + 10 > 20 RETURN i, c ORDER BY i" + only: [neo4j, sql] + rows: [[11, 2], [12, 1]] + - cypher: "UNWIND [1, 1, 2] AS i WITH i AS j, i + 1 AS i, count(*) AS c WHERE i > 2 RETURN j, i, c" + only: [neo4j, sql] + rows: [[2, 3, 1]] + - note: "A DISTINCT WITH groups by its items." + cypher: "UNWIND [1, 1, 2] AS i WITH DISTINCT i WHERE count(*) > 1 RETURN i" + only: [neo4j, sql] + rows: [1] + - cypher: "UNWIND [1, 1, 2] AS i WITH DISTINCT i, count(*) AS c WHERE count(*) > 1 RETURN i, c" + only: [neo4j, sql] + rows: [[1, 2]] + - cypher: "UNWIND [1, 1, 2] AS i WITH DISTINCT * WHERE count(*) > 1 RETURN i" + only: [neo4j, sql] + rows: [1] + - cypher: "UNWIND [1, 1, 2] AS i WITH DISTINCT i AS j WHERE i > 1 RETURN j" + only: [neo4j, sql] + rows: [2] + - cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) WITH DISTINCT p WHERE count(m) > 1 RETURN p.name ORDER BY p.name" + only: [neo4j, sql] + rows: ["Carrie", "Keanu"] + - cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) WITH DISTINCT p.name AS n WHERE p.name STARTS WITH 'K' OR count(*) = 1 RETURN n ORDER BY n" + only: [neo4j, sql] + rows: ["Keanu", "Laurence"] + - note: "Met after the WITH's ORDER BY, SKIP and LIMIT, in their order." + cypher: "UNWIND [1, 1, 2, 3, 3, 3] AS i WITH i, count(*) AS c ORDER BY i LIMIT 2 WHERE count(*) > 1 RETURN i, c" + only: [neo4j, sql] + rows: [[1, 2]] + - cypher: "UNWIND [1, 1, 2, 3, 3, 3] AS i WITH i, count(*) AS c ORDER BY i SKIP 1 WHERE count(*) > 1 RETURN i, c" + only: [neo4j, sql] + rows: [[3, 3]] + - cypher: "UNWIND [1, 1, 2, 3, 3, 3] AS i WITH i, count(*) AS c ORDER BY count(*) DESC WHERE count(*) > 1 RETURN i, c" + only: [neo4j, sql] + rows: [[3, 3], [1, 2]] + - cypher: "UNWIND [1, 1, 2, 3, 3, 3] AS i WITH i, count(*) AS c ORDER BY sum(i) DESC LIMIT 2 WHERE sum(i) > 2 RETURN i, c" + only: [neo4j, sql] + rows: [[3, 3]] + - cypher: "UNWIND [1, 1, 2] AS i WITH DISTINCT i ORDER BY i DESC LIMIT 1 WHERE count(*) > 1 RETURN i" + only: [neo4j, sql] + rows: [] + - note: "An aggregate no item is, is no variable after the WITH." + cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE sum(i) > 1 RETURN *" + only: [neo4j, sql] + unordered: true + rows: [[2, 1], [1, 2]] + - cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE count(*) > 1 WITH i, c, count(*) AS d WHERE count(*) = 1 RETURN i, c, d" + only: [neo4j, sql] + rows: [[1, 2, 1]] + - note: "In a subquery's and a CALL's own query." + cypher: "MATCH (p:Person) CALL (p) { MATCH (p)-[:ACTED_IN]->(m) WITH p, count(*) AS k WHERE count(m) > 1 RETURN k } RETURN p.name, k ORDER BY p.name" + only: [neo4j, sql] + rows: [["Carrie", 2], ["Keanu", 3]] + - cypher: "UNWIND [1, 2] AS i CALL (i) { UNWIND [i, i, 3] AS x WITH x, count(*) AS c WHERE count(*) > 1 AND x = i RETURN c } RETURN i, c" + only: [neo4j, sql] + rows: [[1, 2], [2, 2]] + - cypher: "MATCH (p:Person) RETURN p.name, COUNT { MATCH (p)-[:ACTED_IN]->(m) WITH m, count(*) AS k WHERE count(*) > 0 RETURN m } AS n ORDER BY p.name" + only: [neo4j, sql] + rows: [["Carrie", 2], ["Hugo", 0], ["Keanu", 3], ["Laurence", 1]] + - cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) WITH p, count(*) AS c WHERE EXISTS { MATCH (p)-[:KNOWS]->(q) WITH q, count(*) AS k WHERE count(*) > 0 RETURN q } RETURN p.name ORDER BY p.name" + only: [neo4j, sql] + rows: ["Keanu", "Laurence"] + - name: "Variable-length ranges" description: "A variable-length relationship matches each length in its range." steps: diff --git a/tests/cypher/refusals.yaml b/tests/cypher/refusals.yaml index 47d40ef..bbce229 100644 --- a/tests/cypher/refusals.yaml +++ b/tests/cypher/refusals.yaml @@ -45,8 +45,18 @@ cases: - name: "Temporal map not compiled yet" description: "A temporal value made from a map of fields the compiler doesn't handle yet is refused, naming what it is." steps: - - cypher: "RETURN date({year: 2024, week: 10, dayOfWeek: 3})" - error: "date() of a map with week is Cypher, and is not compiled to SQL yet." + - note: "A zone by name beside another temporal value is refused, not given as null." + cypher: "RETURN datetime({datetime: localdatetime('2024-03-15T10:00'), timezone: 'Europe/Stockholm'})" + error: "The time zone 'Europe/Stockholm' is Cypher, and is not compiled to SQL yet." + - note: "Neo4j makes an ordinal day of no year one of the year 0, which no SQL store's date reaches." + cypher: "RETURN date({ordinalDay: 2})" + error: "date() of an ordinal day without a year is Cypher, and is not compiled to SQL yet." + - note: "A zone by name beside a week, an ordinal day or a quarter is refused, not given as null." + cypher: "RETURN datetime({year: 1984, week: 10, timezone: 'Europe/Stockholm'})" + error: "The time zone 'Europe/Stockholm' is Cypher, and is not compiled to SQL yet." + - note: "And in a map of no calendar fields, so a value made from such a moment, time({time: other}), is refused too." + cypher: "RETURN datetime({year: 1984, month: 10, day: 11, hour: 12, timezone: 'Europe/Stockholm'})" + error: "The time zone 'Europe/Stockholm' 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})" @@ -60,6 +70,139 @@ cases: - 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: "Temporal map of calendar fields Neo4j does not take" + description: "A date or a moment made from a map of a week, an ordinal day or a quarter that Neo4j fails on is refused: fields of two calendars, a day of the week or of the quarter without its week or quarter, no year, a number that is not whole or is past its field's range, or such a field of a time of day." + steps: + - cypher: "RETURN date({year: 2023, month: 2, week: 3})" + error: "date() takes a month or a week, not both." + - cypher: "RETURN date({year: 2023, quarter: 2, day: 5})" + error: "date() takes a month or a quarter, not both." + - cypher: "RETURN datetime({year: 2023, week: 2, ordinalDay: 5})" + error: "datetime() takes a week or an ordinal day, not both." + - cypher: "RETURN date({year: 2023, ordinalDay: 5, quarter: 1})" + error: "date() takes an ordinal day or a quarter, not both." + - cypher: "RETURN date({year: 2023, dayOfWeek: 3})" + error: "date() takes a day of the week beside a week." + - cypher: "RETURN localdatetime({year: 2023, dayOfQuarter: 3})" + error: "localdatetime() takes a day of the quarter beside a quarter." + - cypher: "RETURN date({week: 2})" + error: "date() takes a year beside a week." + - cypher: "RETURN date({quarter: 2})" + error: "date() takes a year beside a quarter." + - cypher: "RETURN date({year: 2020, week: 54})" + error: "date() takes a week from 1 to 53, not 54." + - cypher: "RETURN date({year: 2020, week: 0})" + error: "date() takes a week from 1 to 53, not 0." + - cypher: "RETURN date({year: 2023, week: 2, dayOfWeek: 8})" + error: "date() takes a day of the week from 1 to 7, not 8." + - note: "A day of the year runs to the year's last." + cypher: "RETURN date({year: 2023, ordinalDay: 366})" + error: "date() takes an ordinal day from 1 to 365 in 2023, not 366." + - cypher: "RETURN date({year: 2023, ordinalDay: -1})" + error: "date() takes an ordinal day from 1 to 365 in 2023, not -1." + - cypher: "RETURN date({year: 2023, quarter: 5})" + error: "date() takes a quarter from 1 to 4, not 5." + - note: "A day of the quarter runs to 92 in any quarter." + cypher: "RETURN date({year: 2024, quarter: 4, dayOfQuarter: 93})" + error: "date() takes a day of the quarter from 1 to 92, not 93." + - cypher: "RETURN date({year: 2023, week: 2.5})" + error: "date() takes a whole number for week, not 2.5." + - cypher: "RETURN date({year: '2023', quarter: 2})" + error: "date() takes a whole number for year, not '2023'." + - cypher: "RETURN localtime({hour: 12, week: 2})" + error: "localtime() takes no field week." + - cypher: "RETURN time({hour: 12, ordinalDay: 2})" + error: "time() takes no field ordinalDay." + + - name: "Temporal map from another temporal value Neo4j does not take" + description: "A temporal value made from a map with a date, a time or a datetime that Neo4j fails on is refused: a field the kind has none of, a datetime beside a date or a time, a value that is no temporal value or hasn't what it is to give, fields of two calendars, a date that isn't whole beside a value that gives the time, a field of the time without the one above it, a number that isn't whole or is past its field's range, or a time zone it doesn't read." + steps: + - cypher: "RETURN date({date: date('2024-03-15'), hour: 3})" + error: "date() takes no field hour." + - cypher: "RETURN date({date: date('2024-03-15'), timezone: '+01:00'})" + error: "date() takes no field timezone." + - cypher: "RETURN date({datetime: datetime('2024-03-15T10:00Z')})" + error: "date() takes no field datetime." + - cypher: "RETURN localtime({date: date('2024-03-15')})" + error: "localtime() takes no field date." + - cypher: "RETURN time({datetime: datetime('2024-03-15T10:00Z')})" + error: "time() takes no field datetime." + - cypher: "RETURN localtime({time: localtime('10:00'), timezone: '+01:00'})" + error: "localtime() takes no field timezone." + - cypher: "RETURN time({time: time('10:00Z'), day: 2})" + error: "time() takes no field day." + - cypher: "RETURN localdatetime({datetime: localdatetime('2024-03-15T10:00'), timezone: '+01:00'})" + error: "localdatetime() takes no field timezone." + - cypher: "RETURN datetime({date: date('2024-03-15'), foo: 1})" + error: "datetime() takes no field foo." + - cypher: "RETURN datetime({date: date('2024-03-15'), datetime: datetime('2020-01-01T10:00Z')})" + error: "datetime() takes a datetime, or a date and a time, not both." + - cypher: "RETURN localdatetime({time: localtime('11:00'), datetime: localdatetime('2020-01-01T10:00')})" + error: "localdatetime() takes a datetime, or a date and a time, not both." + - cypher: "RETURN date({date: localtime('10:00')})" + error: "date() takes a date, a localdatetime or a datetime for date, not a localtime." + - cypher: "RETURN localtime({time: date('2024-03-15')})" + error: "localtime() takes a localtime, a time, a localdatetime or a datetime for time, not a date." + - cypher: "RETURN datetime({datetime: date('2024-03-15')})" + error: "datetime() takes a localdatetime or a datetime for datetime, not a date." + - cypher: "RETURN date({date: null})" + error: "date() takes a date, a localdatetime or a datetime for date, not null." + - cypher: "RETURN date({date: '2024-03-15'})" + error: "date() takes a date, a localdatetime or a datetime for date, not '2024-03-15'." + - cypher: "WITH 5 AS x RETURN localtime({time: x})" + error: "localtime() takes a localtime, a time, a localdatetime or a datetime for time, not an integer." + - cypher: "RETURN date({date: date('2024-03-15'), month: 2, week: 3})" + error: "date() takes a month or a week, not both." + - note: "A day of the week goes alone beside a value that gives the date, but not with a day." + cypher: "RETURN date({date: date('2024-03-15'), dayOfWeek: 2, day: 3})" + error: "date() takes a month or a week, not both." + - note: "Beside a value that gives the time alone, the fields give the whole date." + cypher: "RETURN localdatetime({year: 2024, month: 3, time: localtime('10:00')})" + error: "localdatetime() takes a date beside a time: a date, or a year with a month and a day, a week and a day of the week, an ordinal day, or a quarter and a day of the quarter." + - cypher: "RETURN datetime({year: 2024, week: 3, time: localtime('10:00')})" + error: "datetime() takes a date beside a time: a date, or a year with a month and a day, a week and a day of the week, an ordinal day, or a quarter and a day of the quarter." + - cypher: "RETURN datetime({time: localtime('10:00')})" + error: "datetime() takes a date beside a time: a date, or a year with a month and a day, a week and a day of the week, an ordinal day, or a quarter and a day of the quarter." + - note: "Beside a value that gives the date alone, a field of the time takes the one above it." + cypher: "RETURN localdatetime({date: date('2024-03-15'), second: 3})" + error: "localdatetime() takes a second beside a minute." + - cypher: "RETURN localdatetime({date: date('2024-03-15'), minute: 3})" + error: "localdatetime() takes a minute beside an hour." + - cypher: "RETURN datetime({date: date('2024-03-15'), hour: 5, minute: 3, microsecond: 1})" + error: "datetime() takes a microsecond beside a second." + - cypher: "RETURN date({date: date('2024-03-15'), day: 32})" + error: "date() takes a day from 1 to 31, not 32." + - cypher: "RETURN date({date: date('2024-03-15'), month: 13})" + error: "date() takes a month from 1 to 12, not 13." + - cypher: "RETURN date({date: date('2024-03-15'), week: 54})" + error: "date() takes a week from 1 to 53, not 54." + - cypher: "RETURN date({date: date('2024-03-15'), dayOfWeek: 8})" + error: "date() takes a day of the week from 1 to 7, not 8." + - cypher: "RETURN date({date: date('2024-03-15'), quarter: 5})" + error: "date() takes a quarter from 1 to 4, not 5." + - cypher: "RETURN date({date: date('2024-03-15'), dayOfQuarter: 93})" + error: "date() takes a day of the quarter from 1 to 92, not 93." + - cypher: "RETURN localtime({time: localtime('10:00'), hour: 24})" + error: "localtime() takes an hour from 0 to 23, not 24." + - cypher: "RETURN localtime({time: localtime('10:00'), second: 60})" + error: "localtime() takes a second from 0 to 59, not 60." + - cypher: "RETURN localtime({time: localtime('10:00'), millisecond: 1000})" + error: "localtime() takes a millisecond from 0 to 999, not 1000." + - cypher: "RETURN localtime({time: localtime('10:00'), millisecond: 5, microsecond: 1000})" + error: "localtime() takes a microsecond from 0 to 999 beside a millisecond, not 1000." + - cypher: "RETURN localtime({time: localtime('10:00'), nanosecond: 1000000000})" + error: "localtime() takes a nanosecond from 0 to 999999999, not 1000000000." + - cypher: "RETURN date({date: date('2024-03-15'), year: 2024.5})" + error: "date() takes a whole number for year, not 2024.5." + - cypher: "RETURN date({date: date('2024-03-15'), day: '3'})" + error: "date() takes a whole number for day, not '3'." + - cypher: "RETURN time({time: time('10:00+01:00'), timezone: null})" + error: "time() takes a time zone, not null." + - cypher: "RETURN datetime({date: date('2024-03-15'), time: localtime('10:00'), timezone: 5})" + error: "datetime() takes a time zone, not 5." + - cypher: "RETURN time({time: time('10:00+01:00'), timezone: '+0500'})" + error: "'+0500' is no time zone." + - 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: @@ -608,48 +751,84 @@ cases: error: "size() takes a list or a text, not a path, as in Neo4j: length() takes a path." - name: "Aggregate in a WHERE" - description: "An aggregate in a WHERE is refused, as Neo4j refuses it (its 42I24, \"invalid use of aggregate function\"): of a MATCH or a WITH, a pattern comprehension, a list comprehension, a quantifier, a quantified path pattern, EXISTS { … } and COUNT { … }, in a subquery's or a CALL { … }'s query too. A subquery or a CALL that aggregates in its own projection is filtered as any WITH is (see queries.yaml, \"Aggregate beside a WHERE\")." + description: "An aggregate in a WHERE is refused, as Neo4j refuses it (its 42I24, \"invalid use of aggregate function\"): of a MATCH or a WITH that groups nothing, a pattern comprehension, a list comprehension, a quantifier, a quantified path pattern, EXISTS { … } and COUNT { … }, in a subquery's or a CALL { … }'s query too, and in a comprehension's, a quantifier's or a pattern's own WHERE or body within the WHERE of a WITH that groups. The WHERE of a WITH that aggregates, or is DISTINCT, holds an aggregate, and so does one after a subquery or a CALL that aggregates in its own projection (see queries.yaml, \"Aggregate in the WHERE of a WITH that groups\" and \"Aggregate beside a WHERE\")." steps: - cypher: "UNWIND [1, 2] AS i WITH i WHERE sum(i) > 1 RETURN i" - error: "sum() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "sum() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - note: "Within a condition of several, too." cypher: "UNWIND [1, 2] AS i WITH i WHERE i > 1 AND count(*) > 0 RETURN i" - error: "count(*) aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "count(*) aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - cypher: "MATCH (n) WHERE count(*) > 1 RETURN n" - error: "count(*) aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "count(*) aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - cypher: "MATCH (n) WHERE sum(n.x) > 1 RETURN n" - error: "sum() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "sum() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - cypher: "MATCH (a) WHERE a.x IN collect(a.y) RETURN a" - error: "collect() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "collect() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - note: "A pattern comprehension's WHERE." cypher: "MATCH (n) RETURN [(n)-->(m) WHERE count(m) > 1 | m] AS ms" - error: "count() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "count() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - note: "A quantifier's WHERE." cypher: "UNWIND [[1, 2]] AS l RETURN all(x IN l WHERE sum(x) > 1) AS a" - error: "sum() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "sum() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - note: "A list comprehension's WHERE." cypher: "UNWIND [[1, 2]] AS l RETURN [x IN l WHERE count(x) > 1 | x] AS a" - error: "count() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "count() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - note: "A quantified path pattern's WHERE." cypher: "MATCH (a) ((x)-[:R]->(y) WHERE count(x) > 1)+ (b) RETURN a" - error: "count() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "count() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - note: "The WHERE of EXISTS { … } and COUNT { … } of a pattern." cypher: "MATCH (n) WHERE EXISTS { (n)-->(m) WHERE count(m) > 1 } RETURN n" - error: "count() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "count() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - cypher: "MATCH (n) WHERE COUNT { (n)-->(m) WHERE count(m) > 1 } > 0 RETURN n" - error: "count() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "count() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - note: "A WHERE in a subquery's own query." cypher: "MATCH (n) WHERE EXISTS { MATCH (n)-->(m) WHERE count(m) > 1 RETURN m } RETURN n" - error: "count() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "count() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - cypher: "MATCH (n) RETURN COUNT { MATCH (n)-->(m) WITH m WHERE count(m) > 1 RETURN m } AS c" - error: "count() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "count() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - note: "A WHERE in a CALL { … }." cypher: "UNWIND [1, 2] AS i CALL (i) { WITH i WHERE sum(i) > 1 RETURN i AS j } RETURN j" - error: "sum() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "sum() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - cypher: "UNWIND [1, 2] AS i CALL (i) { MATCH (n) WHERE count(n) > i RETURN n } RETURN n" - error: "count() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "count() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." - cypher: "UNWIND [1, 2] AS i CALL { WITH i MATCH (n) WHERE max(n.x) > i RETURN n } RETURN n" - error: "max() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + error: "max() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + - note: "A WITH whose only aggregate is in its WHERE groups nothing." + cypher: "UNWIND [1, 1, 2] AS i WITH i WHERE count(*) > 1 RETURN i" + error: "count(*) aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + - cypher: "UNWIND [1, 1, 2] AS i WITH * WHERE count(*) > 1 RETURN i" + error: "count(*) aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + - note: "In the WHERE of a WITH that groups, a comprehension's, a quantifier's or a pattern's own WHERE or body holds none either." + cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE all(x IN [1] WHERE count(*) > x) RETURN i, c" + error: "count(*) aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + - cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE size([x IN [1] WHERE count(*) > x | x]) > 0 RETURN i, c" + error: "count(*) aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + - cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE [x IN [1] | x + count(*)][0] > 2 RETURN i, c" + error: "count(*) aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + - cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) WITH p, count(*) AS c WHERE size([(p)-[:ACTED_IN]->(x) WHERE count(x) > 1 | x]) > 0 RETURN p.name" + error: "count() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + - cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) WITH p, count(*) AS c WHERE EXISTS { (p)-[:KNOWS]->(q) WHERE count(q) > 0 } RETURN p.name" + error: "count() aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + + - name: "Variable before a WITH that groups, in its WHERE" + description: "The WHERE of a WITH that aggregates, or is DISTINCT, reads a variable from before the WITH only inside an aggregate, or where it is a key the WITH groups by, written as its item is (see queries.yaml, \"Aggregate in the WHERE of a WITH that groups\"). Any other is refused, as Neo4j refuses it (its 42N44, \"inaccessible variable\", or beside an aggregate its 42I18). So is an aggregate of a variable the WITH declares (Neo4j's 42I79), and of a key's name or of a key read in a quantifier, which Neo4j fails within (its 50N00)." + steps: + - cypher: "UNWIND [1, 2, 3] AS i WITH i % 2 AS k, count(*) AS c WHERE i > 0 RETURN k, c" + error: "i is not defined: a variable is bound by a pattern, a WITH or an UNWIND before it is used." + - cypher: "UNWIND [1, 2, 3] AS i WITH i % 2 AS k, count(*) AS c WHERE sum(i) > 2 AND i > 0 RETURN k, c" + error: "i is not defined: a variable is bound by a pattern, a WITH or an UNWIND before it is used." + - cypher: "UNWIND [1, 2, 3] AS i WITH DISTINCT i % 2 AS k WHERE i > 0 RETURN k" + error: "i is not defined: a variable is bound by a pattern, a WITH or an UNWIND before it is used." + - cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) WITH p.name AS n, count(*) AS c WHERE p.born > 1965 RETURN n, c" + error: "p is not defined: a variable is bound by a pattern, a WITH or an UNWIND before it is used." + - cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) WITH p, count(*) AS c WHERE m.released > 1996 RETURN p.name" + error: "m is not defined: a variable is bound by a pattern, a WITH or an UNWIND before it is used." + - cypher: "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE sum(c) > 1 RETURN i, c" + error: "c is not defined: a variable is bound by a pattern, a WITH or an UNWIND before it is used." + - cypher: "UNWIND [1, 1, 2] AS i WITH i AS j, count(*) AS c WHERE sum(j) > 1 RETURN j, c" + error: "j is not defined: a variable is bound by a pattern, a WITH or an UNWIND before it is used." + - cypher: "UNWIND [1, 1, 2] AS i WITH i AS j, count(*) AS c WHERE any(x IN [1] WHERE x = i) RETURN j, c" + error: "i is not defined: a variable is bound by a pattern, a WITH or an UNWIND before it is used." - name: "Implicit grouping key in a body or an ORDER BY" description: "An item that aggregates, and names, beside its aggregates or in a comprehension's, a quantifier's, a reduce()'s or a pattern's body, a variable outside each item that does not aggregate, is refused, as Neo4j refuses it (its 42I18, \"reference to non-grouping sub-expression\"); there, a key is read only where it is a variable or a variable's property, and in a pattern's parts only where it is a variable. So is a term of an ORDER BY that aggregates, after items that aggregate, and names a variable outside the items. What Neo4j takes beside them is in queries.yaml, \"Implicit grouping key\"." diff --git a/tests/cypher/temporal.yaml b/tests/cypher/temporal.yaml index a35cbf3..890ff53 100644 --- a/tests/cypher/temporal.yaml +++ b/tests/cypher/temporal.yaml @@ -336,6 +336,371 @@ cases: nano: localtime({hour: 1, minute: 0, second: 0, nanosecond: 5000}).microsecond} rows: [{millis: 1710491445123, zone: "+02:00", offset: 120, micro: 7, nano: 5}] + - name: "Dates from a week, an ordinal day or a quarter" + description: "A date or a moment made from a map takes a week of the week-based year and a day of the week, a day of the year, or a quarter and a day of the quarter, in place of a month and a day." + steps: + - cypher: | + RETURN {week: date({year: 1984, week: 10}), weekDay: date({year: 1984, week: 10, dayOfWeek: 3}), ordinal: date({year: 1984, ordinalDay: 202}), + quarter: date({year: 1984, quarter: 3}), quarterDay: date({year: 1984, quarter: 3, dayOfQuarter: 45})} + only: [sql] + rows: [{week: "1984-03-05", weekDay: "1984-03-07", ordinal: "1984-07-20", quarter: "1984-07-01", quarterDay: "1984-08-14"}] + - cypher: | + WITH date({year: 2020, week: 53}) AS long, date({year: 2020, week: 53, dayOfWeek: 7}) AS sunday, date({year: 2019, week: 1}) AS before, + date({year: 2021, week: 1}) AS after, date({year: 2015, week: 53, dayOfWeek: 5}) AS friday + RETURN [[long.year, long.month, long.day], [sunday.year, sunday.month, sunday.day], [before.year, before.month, before.day], + [after.year, after.month, after.day], [friday.year, friday.month, friday.day]] + only: [neo4j, sql] + note: "The year is the week-based year: its first week is the one with January 4th, which may start in the year before. 2020 and 2015 have 53 weeks." + rows: [[[2020, 12, 28], [2021, 1, 3], [2018, 12, 31], [2021, 1, 4], [2016, 1, 1]]] + - cypher: | + WITH date({year: 2019, week: 53}) AS w, date({year: 2023, quarter: 1, dayOfQuarter: 91}) AS q1, date({year: 2023, quarter: 2, dayOfQuarter: 92}) AS q2 + RETURN [[w.year, w.month, w.day], [q1.year, q1.month, q1.day], [q2.year, q2.month, q2.day]] + only: [neo4j, sql] + note: "Week 53 of a year of 52 weeks is the first week of the next, and a day of the quarter past its last runs into the next quarter, as Neo4j has them." + rows: [[[2019, 12, 30], [2023, 4, 1], [2023, 7, 1]]] + - cypher: | + WITH date({year: 2024, ordinalDay: 366}) AS leap, date({year: 2023, ordinalDay: 365}) AS common, date({year: 2024, ordinalDay: 60}) AS feb29, + date({year: 2023, ordinalDay: 60}) AS mar1, date({year: 2024, quarter: 4, dayOfQuarter: 92}) AS q4, date({year: 2024, quarter: 1, dayOfQuarter: 91}) AS q1 + RETURN [[leap.month, leap.day], [common.month, common.day], [feb29.month, feb29.day], [mar1.month, mar1.day], [q4.month, q4.day], [q1.month, q1.day]] + only: [neo4j, sql] + rows: [[[12, 31], [12, 31], [2, 29], [3, 1], [12, 31], [3, 31]]] + - cypher: | + WITH date({year: 2023, week: null}) AS w, date({year: 2023, week: 2, dayOfWeek: null}) AS d, date({year: 2023, ordinalDay: null}) AS o, + date({year: 2023, quarter: 2, dayOfQuarter: null}) AS q + RETURN [[w.month, w.day], [d.month, d.day], [o.month, o.day], [q.month, q.day]] + only: [neo4j, sql] + note: "A field that is null is given as its least: the first week, day of the week, day of the year or day of the quarter." + rows: [[[1, 2], [1, 9], [1, 1], [4, 1]]] + - cypher: | + WITH localdatetime({year: 1984, week: 10, dayOfWeek: 3, hour: 12, minute: 31, second: 14, millisecond: 645}) AS l, + datetime({year: 2020, week: 53, dayOfWeek: 7, hour: 23, timezone: '-05:00'}) AS w, + datetime({year: 1984, ordinalDay: 202, hour: 12, timezone: '+01:00'}) AS o, + localdatetime({year: 2024, quarter: 4, dayOfQuarter: 92, hour: 23, minute: 59}) AS q + RETURN [[l.day, l.hour, l.millisecond], [w.year, w.day, w.hour, w.timezone], [o.month, o.day, o.hour, o.timezone], [q.month, q.day, q.minute]] + only: [neo4j, sql] + note: "A moment takes them beside its time and its offset." + rows: [[[7, 12, 645], [2021, 3, 23, "-05:00"], [7, 20, 12, "+01:00"], [12, 31, 59]]] + - cypher: | + UNWIND [[2020, 53, 7], [2019, 53, 1], [1984, 10, null], [2024, null, null]] AS p + WITH date({year: p[0], week: p[1], dayOfWeek: p[2]}) AS d + RETURN [d.year, d.month, d.day] + only: [neo4j, sql] + note: "Fields an expression gives, worked out as the statement runs." + rows: [[2021, 1, 3], [2019, 12, 30], [1984, 3, 5], [2024, 1, 1]] + - cypher: | + UNWIND [[2024, 366, 4, 92], [2023, 1, 1, 91], [2023, null, null, null]] AS p + WITH date({year: p[0], ordinalDay: p[1]}) AS o, date({year: p[0], quarter: p[2], dayOfQuarter: p[3]}) AS q + RETURN [o.month, o.day, q.year, q.month, q.day] + only: [neo4j, sql] + rows: [[12, 31, 2024, 12, 31], [1, 1, 2023, 4, 1], [1, 1, 2023, 1, 1]] + - cypher: "RETURN date({year: 2020, week: 54})" + only: [neo4j, sql] + note: "Neo4j: 22007: data exception - invalid date, time, or datetime format. A week runs to 53, a day of the week to 7, a quarter to 4." + error: true + - cypher: "RETURN date({year: 2023, ordinalDay: 366})" + only: [neo4j, sql] + note: "Neo4j: 22007. A day of the year runs to the year's last, 365 in a common year." + error: true + - cypher: "RETURN date({year: 2024, quarter: 4, dayOfQuarter: 93})" + only: [neo4j, sql] + note: "Neo4j: 22007. A day of the quarter runs to 92." + error: true + - cypher: "UNWIND [[2023, 366]] AS p RETURN date({year: p[0], ordinalDay: p[1]})" + only: [neo4j, sql] + note: "Neo4j: 22007. A field past its range that an expression gives fails as the statement runs." + error: true + - cypher: "UNWIND [[2024, 5]] AS p RETURN date({year: p[0], quarter: p[1]})" + only: [neo4j, sql] + error: true + - cypher: "UNWIND [[2020, 54]] AS p RETURN date({year: p[0], week: p[1]})" + only: [neo4j, sql] + error: true + + - name: "Day of the quarter and the fields of the week at the year's turn" + description: "A date or a moment gives the day of its quarter, and the week, week-based year and day of the year where the week-based year is not the year." + steps: + - cypher: | + RETURN [date('1984-10-11').dayOfQuarter, date('2024-12-31').dayOfQuarter, date('2023-03-31').dayOfQuarter, date('2024-03-31').dayOfQuarter, + date('2024-01-01').dayOfQuarter, datetime('2024-05-20T10:00+02:00').dayOfQuarter, localdatetime('2024-08-01T23:00').dayOfQuarter] + only: [neo4j, sql] + rows: [[11, 92, 90, 91, 1, 50, 32]] + - cypher: | + RETURN [date('2021-01-03').week, date('2021-01-03').weekYear, date('2021-01-03').dayOfWeek, date('2018-12-31').week, date('2018-12-31').weekYear, + date('2020-12-31').week, date('2024-12-31').ordinalDay, date('2021-01-03').quarter] + only: [neo4j, sql] + rows: [[53, 2020, 7, 1, 2019, 53, 366, 1]] + - cypher: "CREATE (:CyTemporal {at: date('1984-10-11')}), (:CyTemporal {at: localdatetime('1984-11-11T12:31:14')})" + only: [neo4j, sql] + - note: "A value read from the store, its kind told by its text." + cypher: "MATCH (n:CyTemporal) RETURN [n.at.quarter, n.at.dayOfQuarter, n.at.week, n.at.weekYear, n.at.ordinalDay, n.at.weekDay] ORDER BY n.at.month" + only: [neo4j, sql] + rows: [[4, 11, 41, 1984, 285, 4], [4, 42, 45, 1984, 316, 7]] + + - name: "Dates, times and moments from another temporal value" + description: "A map's date, time or datetime gives the value made the fields of another temporal value, and the map's other fields are laid over them." + steps: + - cypher: | + WITH date('1984-11-11') AS d, localdatetime('1984-11-11T12:31:14.645') AS l, datetime('1984-11-11T23:30-05:00') AS z + WITH date({date: d}) AS a, date({date: l}) AS b, date({date: z}) AS c + RETURN [[a.year, a.month, a.day], [b.year, b.month, b.day], [c.year, c.month, c.day]] + only: [neo4j, sql] + note: "A date from a date, a local moment or a zoned one: the date its wall clock reads." + rows: [[[1984, 11, 11], [1984, 11, 11], [1984, 11, 11]]] + - cypher: | + WITH localtime('12:31:14.645') AS lt, time('12:31:14.645+01:00') AS t, localdatetime('1984-03-07T12:31:14') AS l, datetime('1984-10-11T23:00-05:00') AS z + WITH localtime({time: lt}) AS a, localtime({time: t}) AS b, localtime({time: l}) AS c, localtime({time: z}) AS e + RETURN [[a.hour, a.minute, a.second, a.millisecond], [b.hour, b.minute, b.second, b.millisecond], [c.hour, c.minute, c.second], [e.hour, e.minute]] + only: [neo4j, sql] + note: "A time of day from a time of day or a moment, local or zoned: the time its wall clock reads." + rows: [[[12, 31, 14, 645], [12, 31, 14, 645], [12, 31, 14], [23, 0]]] + - cypher: | + WITH localtime('12:31:14.645') AS lt, time('12:31:14.645+01:00') AS t, localdatetime('1984-03-07T12:31:14') AS l, datetime('1984-10-11T23:00-05:00') AS z + WITH time({time: lt}) AS a, time({time: t}) AS b, time({time: l}) AS c, time({time: z}) AS e + RETURN [[a.hour, a.millisecond, a.timezone], [b.hour, b.millisecond, b.timezone], [c.hour, c.second, c.timezone], [e.hour, e.timezone]] + only: [neo4j, sql] + note: "A zoned time keeps the offset of a zoned value, and is at UTC from a local one." + rows: [[[12, 645, "Z"], [12, 645, "+01:00"], [12, 14, "Z"], [23, "-05:00"]]] + - cypher: | + WITH date('1984-10-11') AS d, localtime('12:31:14.645') AS lt, time('12:31:14+01:00') AS t, localdatetime('1984-03-07T12:31:14') AS l, datetime('1984-10-11T12:00-05:00') AS z + WITH localdatetime({date: d}) AS a, localdatetime({date: z, time: lt}) AS b, localdatetime({date: l, time: t}) AS c, localdatetime({datetime: z}) AS e, localdatetime({datetime: l}) AS f + RETURN [[a.year, a.month, a.day, a.hour, a.minute], [b.month, b.day, b.hour, b.minute, b.millisecond], [c.month, c.day, c.hour, c.second], [e.day, e.hour], [f.month, f.day, f.second]] + only: [neo4j, sql] + note: "A local moment takes its date from one value and its time from another, or both from a moment; with no time, midnight." + rows: [[[1984, 10, 11, 0, 0], [10, 11, 12, 31, 645], [3, 7, 12, 14], [11, 12], [3, 7, 14]]] + - cypher: | + WITH date('1984-10-11') AS d, localtime('12:31:14.645') AS lt, time('12:31:14+01:00') AS t, localdatetime('1984-03-07T12:31:14') AS l, datetime('1984-10-11T12:00-05:00') AS z + WITH datetime({date: d}) AS a, datetime({date: z}) AS b, datetime({date: d, time: t}) AS c, datetime({date: d, time: lt}) AS e, datetime({datetime: z}) AS f, + datetime({datetime: l}) AS g, datetime({date: l, time: z}) AS h + RETURN [[a.day, a.hour, a.timezone], [b.day, b.hour, b.timezone], [c.day, c.hour, c.minute, c.timezone], [e.hour, e.millisecond, e.timezone], + [f.day, f.hour, f.timezone], [g.month, g.day, g.second, g.timezone], [h.month, h.day, h.hour, h.timezone]] + only: [neo4j, sql] + note: "A zoned moment keeps the offset of what gives its time, and is at UTC where that is local or nothing gives it: the offset of what gives the date alone is dropped." + rows: [[[11, 0, "Z"], [11, 0, "Z"], [11, 12, 31, "+01:00"], [12, 645, "Z"], [11, 12, "-05:00"], [3, 7, 14, "Z"], [3, 7, 12, "-05:00"]]] + - cypher: | + WITH date('1984-11-11') AS d + WITH date({date: d, year: 2023}) AS a, date({date: d, month: 2}) AS b, date({date: d, day: 28}) AS c, date({date: date('2024-03-15'), day: 30, month: 4}) AS e, + date({date: date('2024-01-31'), day: 15, month: 2}) AS f + RETURN [[a.year, a.month, a.day], [b.year, b.month, b.day], [c.year, c.month, c.day], [e.year, e.month, e.day], [f.year, f.month, f.day]] + only: [neo4j, sql] + note: "A year, a month and a day given replace the value's, together: January 31st with the month 2 and the day 15 is February 15th." + rows: [[[2023, 11, 11], [1984, 2, 11], [1984, 11, 28], [2024, 4, 30], [2024, 2, 15]]] + - cypher: | + WITH date('1984-11-11') AS d + WITH date({date: d, week: 1}) AS a, date({date: d, dayOfWeek: 3}) AS b, date({date: d, ordinalDay: 28}) AS c, date({date: d, quarter: 3}) AS e, date({date: d, dayOfQuarter: 5}) AS f + RETURN [[a.year, a.month, a.day], [b.year, b.month, b.day], [c.year, c.month, c.day], [e.year, e.month, e.day], [f.year, f.month, f.day]] + only: [neo4j, sql] + note: "A week keeps the day of the week, a day of the week the week, a quarter the month of the quarter and the day, a day of the quarter the quarter, and an ordinal day the year." + rows: [[[1984, 1, 8], [1984, 11, 7], [1984, 1, 28], [1984, 8, 11], [1984, 10, 5]]] + - cypher: | + WITH date({date: date('1816-12-30'), week: 2, dayOfWeek: 3}) AS a, date({date: date('1816-12-31'), week: 2}) AS b, date({date: date('1816-12-31'), year: 1817, week: 2}) AS c, + date({date: date('2020-12-30'), year: 2021, week: 2}) AS e, date({date: date('2020-12-30'), year: 2021}) AS f + RETURN [[a.year, a.month, a.day], [b.year, b.month, b.day], [c.year, c.month, c.day], [e.year, e.month, e.day], [f.year, f.month, f.day]] + only: [neo4j, sql] + note: "Beside a week the year is the week-based year: 1816-12-31 is in the first week of 1817, and 2020-12-30 in the 53rd of 2020. Without one, it is the year." + rows: [[[1817, 1, 8], [1817, 1, 7], [1817, 1, 7], [2021, 1, 13], [2021, 12, 30]]] + - cypher: | + WITH date({date: date('2019-12-30'), week: 53}) AS a, date({date: date('2020-12-30'), year: 2019, week: 53}) AS b, date({date: date('2024-03-15'), dayOfQuarter: 92}) AS c, + date({date: date('2023-03-15'), dayOfQuarter: 92}) AS e, date({date: date('2024-03-31'), quarter: 2}) AS f, date({date: date('2024-01-31'), quarter: 2}) AS g, + date({date: date('2024-12-31'), ordinalDay: 366}) AS h + RETURN [[a.year, a.month, a.day], [b.year, b.month, b.day], [c.month, c.day], [e.month, e.day], [f.month, f.day], [g.month, g.day], [h.month, h.day]] + only: [neo4j, sql] + note: "As a map of them has it, week 53 of a year of 52 runs into the next, as does a day of the quarter past its last; a quarter moves to the month's last day where it has fewer." + rows: [[[2020, 12, 28], [2020, 1, 1], [4, 1], [4, 2], [6, 30], [4, 30], [12, 31]]] + - cypher: | + WITH date('2024-03-15') AS d, localtime('12:31:14.645') AS lt + WITH date({date: d, day: null}) AS a, date({date: d, month: null}) AS b, date({date: d, week: null}) AS c, date({date: d, ordinalDay: null}) AS e, date({date: d, quarter: null}) AS f, + localtime({time: lt, hour: null}) AS g, localtime({time: lt, millisecond: null}) AS h + RETURN [[a.month, a.day], [b.month, b.day], [c.month, c.day], [e.month, e.day], [f.month, f.day], [g.hour, g.minute, g.millisecond], [h.second, h.millisecond]] + only: [neo4j, sql] + note: "A field given as null is its least, not the value's: the first day, month, week or quarter, and no hour or fraction." + rows: [[[3, 1], [1, 15], [1, 5], [1, 1], [3, 15], [0, 31, 645], [14, 0]]] + - cypher: | + WITH localtime('12:31:14.645876') AS lt + WITH localtime({time: lt, second: 42}) AS a, localtime({time: lt, millisecond: 5}) AS b, localtime({time: lt, microsecond: 7}) AS c, localtime({time: lt, millisecond: 5, microsecond: 6}) AS e, + localtime({time: localtime('10:11:12'), hour: 3, minute: null}) AS f + RETURN [[a.second, a.microsecond], [b.second, b.microsecond], [c.microsecond], [e.microsecond], [f.hour, f.minute, f.second]] + only: [neo4j, sql] + note: "A second given keeps the fraction; a millisecond or a microsecond given makes the whole fraction." + rows: [[[42, 645876], [14, 5000], [7], [5006], [3, 0, 12]]] + - cypher: | + WITH time({time: time('12:31:14.645+01:00'), minute: 5}) AS a, localdatetime({datetime: localdatetime('2024-03-15T10:11:12.123456'), millisecond: 7}) AS b, + localdatetime({datetime: localdatetime('2024-03-15T10:11:12.123456'), minute: 7}) AS c, datetime({datetime: datetime('2024-03-15T10:00+03:00'), hour: null}) AS e + RETURN [[a.hour, a.minute, a.millisecond, a.timezone], [b.second, b.microsecond], [c.minute, c.microsecond], [e.day, e.hour, e.timezone]] + only: [neo4j, sql] + rows: [[[12, 5, 645, "+01:00"], [12, 7000], [7, 123456], [15, 0, "+03:00"]]] + - cypher: | + WITH date('1984-10-11') AS d, localtime('12:31:14.645') AS lt, time('12:31:14+01:00') AS t + WITH localdatetime({date: d, hour: 10, minute: 10, second: 10}) AS a, datetime({date: datetime('1984-10-11T12:00-05:00'), hour: 10}) AS b, + localdatetime({date: d, hour: 5, minute: 3, second: 2, nanosecond: 1000}) AS c, localdatetime({year: 1984, month: 10, day: 11, time: lt}) AS e, + datetime({year: 2024, week: 3, dayOfWeek: 2, time: t}) AS f, localdatetime({year: 2024, ordinalDay: 3, time: lt}) AS g, + localdatetime({year: 2024, quarter: 3, dayOfQuarter: 2, time: lt}) AS h + RETURN [[a.day, a.hour, a.minute, a.second], [b.day, b.hour, b.timezone], [c.second, c.microsecond], [e.month, e.day, e.minute, e.millisecond], + [f.month, f.day, f.hour, f.timezone], [g.month, g.day, g.hour], [h.month, h.day, h.hour]] + only: [neo4j, sql] + note: "A value that gives the date beside fields of the time, and one that gives the time beside fields of the date." + rows: [[[11, 10, 10, 10], [11, 10, "Z"], [2, 1], [10, 11, 31, 645], [1, 16, 12, "+01:00"], [1, 3, 12], [7, 2, 12]]] + - cypher: | + WITH date('1984-10-11') AS d, localtime('12:31:14.645') AS lt + WITH localdatetime({date: d, time: lt, day: 28, second: 42}) AS a, localdatetime({date: date('2024-03-15'), time: localtime('10:11:12'), week: 1}) AS b, + datetime({date: date('2024-03-15'), hour: 10, timezone: '+01:00', week: 2}) AS c, localdatetime({date: date('2024-03-15'), time: localtime('10:11:12'), year: 2020, hour: 1}) AS e + RETURN [[a.day, a.second, a.millisecond], [b.month, b.day, b.hour], [c.month, c.day, c.hour, c.timezone], [e.year, e.hour, e.minute]] + only: [neo4j, sql] + rows: [[[28, 42, 645], [1, 5, 10], [1, 12, 10, "+01:00"], [2020, 1, 11]]] + - cypher: | + WITH date({date: date('2024-02-29'), year: 2023, dayOfWeek: 4}) AS a, date({date: date('2024-02-29'), year: 2020, dayOfWeek: 4}) AS b, + date({date: date('2021-01-02'), year: 2021, dayOfWeek: 1}) AS c, date({date: date('2020-12-31'), year: 2021, dayOfWeek: 7}) AS e, + date({date: date('2026-01-01'), year: 2019, dayOfWeek: 5}) AS f, datetime({datetime: datetime('2024-02-29T23:30+05:00'), year: 2023, dayOfWeek: 3, timezone: '-05:00'}) AS g + RETURN [[a.year, a.month, a.day], [b.year, b.month, b.day], [c.year, c.month, c.day], [e.year, e.month, e.day], [f.year, f.month, f.day], [g.year, g.month, g.day, g.hour, g.timezone]] + only: [neo4j, sql] + note: "Beside a day of the week the year moves the date so many years, to February 28th from the 29th of a common year, where alone it fails; then the day of the week moves it within its week, which may be in another year." + rows: [[[2023, 3, 2], [2020, 2, 27], [2020, 12, 28], [2022, 1, 2], [2019, 1, 4], [2023, 3, 1, 13, "-05:00"]]] + - cypher: | + UNWIND [[date('2024-02-29'), 2023, 1], [date('2024-03-15'), 1999, 2]] AS p + WITH date({date: p[0], year: p[1], dayOfWeek: p[2]}) AS a + RETURN [a.year, a.month, a.day] + only: [neo4j, sql] + note: "The same, with the value, the year and the day of the week given by expressions." + rows: [[2023, 2, 27], [1999, 3, 16]] + - cypher: "RETURN [date({date: date('1984-11-11'), week: 1}), date({date: localdatetime('1984-11-11T12:31:14'), quarter: 3}), date({date: datetime('1984-11-11T23:30-05:00'), ordinalDay: 28})]" + only: [sql] + rows: [["1984-01-08", "1984-08-11", "1984-01-28"]] + + - name: "Zoned times and moments from another temporal value" + description: "A time zone given beside a zoned value moves it there, the same moment; beside a local value, or none, it names the value's offset." + steps: + - cypher: | + WITH time({time: time('23:00+01:00'), timezone: '+05:00'}) AS a, time({time: time('01:00+01:00'), timezone: '-05:00'}) AS b, time({time: time('10:00Z'), timezone: '+05:30'}) AS c, + time({time: localtime('10:00'), timezone: '-02:00'}) AS e, time({time: datetime('2024-03-15T23:00+01:00'), timezone: '+05:00'}) AS f, time({time: localdatetime('2024-03-15T23:00')}) AS g, + time({time: time('10:00+01:00'), timezone: 'Z'}) AS h, time({time: time('12:31:14+01:00'), second: 42, timezone: '+05:00'}) AS i + RETURN [[a.hour, a.timezone], [b.hour, b.timezone], [c.hour, c.minute, c.timezone], [e.hour, e.timezone], [f.hour, f.timezone], [g.hour, g.timezone], [h.hour, h.timezone], + [i.hour, i.minute, i.second, i.timezone]] + only: [neo4j, sql] + note: "A time of day moved past midnight wraps round; the fields given are laid over it before it moves." + rows: [[[3, "+05:00"], [19, "-05:00"], [15, 30, "+05:30"], [10, "-02:00"], [3, "+05:00"], [23, "Z"], [9, "Z"], [16, 31, 42, "+05:00"]]] + - cypher: | + WITH datetime({datetime: datetime('2024-03-15T23:00-05:00'), timezone: '+05:00'}) AS a, datetime({datetime: datetime('2024-03-15T23:00-05:00'), hour: 1, timezone: '+05:00'}) AS b, + datetime({datetime: localdatetime('1984-03-07T12:31:14'), timezone: '+05:00'}) AS c, datetime({datetime: datetime('2024-03-15T10:30:20.5+03:00'), minute: 0, timezone: 'Z'}) AS e, + datetime({datetime: datetime('2024-03-15T10:00+01:00'), timezone: '-12:00'}) AS f + RETURN [[a.day, a.hour, a.timezone], [b.day, b.hour, b.timezone], [c.day, c.hour, c.minute, c.timezone], [e.hour, e.minute, e.second, e.millisecond, e.timezone], [f.day, f.hour, f.timezone]] + only: [neo4j, sql] + note: "A moment moved to another offset may move to another day." + rows: [[[16, 9, "+05:00"], [15, 11, "+05:00"], [7, 12, 31, "+05:00"], [7, 0, 20, 500, "Z"], [14, 21, "-12:00"]]] + - cypher: | + WITH datetime({date: date('2024-03-15'), time: time('23:00-05:00'), timezone: '+05:00'}) AS a, datetime({date: date('2024-03-15'), time: time('23:11:12-05:00'), day: 31, timezone: '+05:00'}) AS b, + datetime({date: date('2024-03-15'), time: time('10:11:12+01:00'), hour: 22, timezone: '-01:00'}) AS c, datetime({date: date('2024-03-15'), time: localtime('10:11:12'), timezone: '+01:00', hour: 22}) AS e, + datetime({date: date('2024-03-15'), timezone: '+01:00'}) AS f, datetime({date: datetime('2024-03-15T10:00+03:00'), timezone: '+01:00'}) AS g, + datetime({date: date('2024-03-15'), time: datetime('2020-01-01T23:30+02:00'), timezone: '-03:00'}) AS h + RETURN [[a.month, a.day, a.hour, a.timezone], [b.month, b.day, b.hour, b.minute, b.timezone], [c.day, c.hour, c.timezone], [e.hour, e.minute, e.timezone], [f.day, f.hour, f.timezone], + [g.day, g.hour, g.timezone], [h.year, h.day, h.hour, h.minute, h.timezone]] + only: [neo4j, sql] + note: "The date and the time are put together at the offset of what gives the time, then moved: March 31st 23:11:12-05:00 is April 1st at +05:00." + rows: [[[3, 16, 9, "+05:00"], [4, 1, 9, 11, "+05:00"], [15, 20, "-01:00"], [22, 11, "+01:00"], [15, 0, "+01:00"], [15, 0, "+01:00"], [2024, 15, 18, 30, "-03:00"]]] + + - name: "Temporal values from another an expression gives" + description: "A value read from the store or unwound, its kind told by its text, gives a date, a time or a moment." + steps: + - cypher: "CREATE (:CyTemporal {id: 'a', at: date('1984-10-11')}), (:CyTemporal {id: 'b', at: localdatetime('1984-11-11T12:31:14')}), (:CyTemporal {id: 'c', at: datetime('1984-12-11T23:00-05:00')})" + only: [neo4j, sql] + - cypher: | + MATCH (n:CyTemporal) WITH n ORDER BY n.id + WITH date({date: n.at, day: 1}) AS d, datetime({date: n.at, hour: 10, timezone: '+01:00'}) AS z + RETURN [d.month, d.day, z.month, z.day, z.hour, z.timezone] + only: [neo4j, sql] + rows: [[10, 1, 10, 11, 10, "+01:00"], [11, 1, 11, 11, 10, "+01:00"], [12, 1, 12, 11, 10, "+01:00"]] + - cypher: | + MATCH (n:CyTemporal) WHERE n.id <> 'a' WITH n ORDER BY n.id + WITH localtime({time: n.at, minute: 1}) AS l, time({time: n.at, timezone: '+05:00'}) AS t, datetime({datetime: n.at, day: 2}) AS z + RETURN [l.hour, l.minute, t.hour, t.minute, t.timezone, z.month, z.day, z.hour, z.timezone] + only: [neo4j, sql] + note: "A local moment read is given the offset, and a zoned one moved to it." + rows: [[12, 1, 12, 31, "+05:00", 11, 2, 12, "Z"], [23, 1, 9, 0, "+05:00", 12, 2, 23, "-05:00"]] + - cypher: | + UNWIND [date('2024-03-15'), localdatetime('2023-07-01T10:00'), datetime('2022-05-04T23:00-05:00')] AS d + WITH date({date: d, day: 2}) AS a + RETURN [a.year, a.month, a.day] + only: [neo4j, sql] + rows: [[2024, 3, 2], [2023, 7, 2], [2022, 5, 2]] + + - name: "Temporal values from another that make no date" + description: "Fields laid over another temporal value that make a day its month has none of fail, as the statement runs." + steps: + - cypher: "RETURN date({date: date('2024-01-31'), month: 2})" + only: [neo4j, sql] + note: "Neo4j: 22007: data exception - invalid date, time, or datetime format. February 31st: the month isn't moved to its last day." + error: true + - cypher: "RETURN date({date: date('2024-02-29'), year: 2023})" + only: [neo4j, sql] + note: "Neo4j: 22007. February 29th of a common year." + error: true + - cypher: "RETURN date({date: date('2023-12-31'), ordinalDay: 366})" + only: [neo4j, sql] + note: "Neo4j: 22007." + error: true + - cypher: "RETURN date({date: date('2024-02-29'), year: 2023, quarter: 1})" + only: [neo4j, sql] + note: "Neo4j: 22007. The year is laid over the date before the quarter moves it." + error: true + - cypher: "UNWIND [date('2024-01-31')] AS d RETURN date({date: d, month: 2})" + only: [neo4j, sql] + note: "Neo4j: 22007." + error: true + - cypher: "RETURN localdatetime({date: date('2024-01-31'), month: 2, hour: 1})" + only: [neo4j, sql] + note: "Neo4j: 22007. A moment's too, which Oracle reads as a JSON value's, null where it is no moment." + error: true + - cypher: "RETURN datetime({datetime: datetime('2024-01-31T10:00Z'), month: 2})" + only: [neo4j, sql] + note: "Neo4j: 22007." + error: true + - cypher: "UNWIND [datetime('2023-12-31T10:00Z')] AS d RETURN datetime({date: d, ordinalDay: 366, hour: 1})" + only: [neo4j, sql] + note: "Neo4j: 22007." + error: true + - cypher: "UNWIND [[date('2024-03-15'), 5]] AS p RETURN date({date: p[0], quarter: p[1]})" + only: [neo4j, sql] + note: "Neo4j: 22007. A field past its range that an expression gives." + error: true + + - name: "Temporal values from another that an expression gives as none" + description: "Another temporal value or a time zone an expression gives that is null, or of a kind that hasn't what it is to give, fails as the statement runs, not given as null." + steps: + - cypher: "UNWIND [null] AS d RETURN date({date: d, day: 1})" + only: [neo4j, sql] + note: "Neo4j: 22007." + error: true + - cypher: "OPTIONAL MATCH (n:CyNothing) RETURN date({date: n.at})" + only: [neo4j, sql] + note: "Neo4j: 22007. A property of a node that isn't there." + error: true + - cypher: "UNWIND [null] AS t RETURN datetime({date: date('2024-03-15'), time: t})" + only: [neo4j, sql] + note: "Neo4j: 22007." + error: true + - cypher: "UNWIND [localtime('10:00')] AS d RETURN date({date: d})" + only: [neo4j, sql] + note: "Neo4j: 22G05. A time of day gives no date." + error: true + - cypher: "UNWIND [date('2024-03-15')] AS t RETURN time({time: t, timezone: '+01:00'})" + only: [neo4j, sql] + note: "Neo4j: 22G05. A date gives no time of day." + error: true + - cypher: "UNWIND ['hello'] AS t RETURN localtime({time: t})" + only: [neo4j, sql] + note: "Neo4j: 22007. A text that is no temporal value." + error: true + - cypher: "UNWIND [null] AS z RETURN datetime({datetime: datetime('2024-03-15T10:00Z'), timezone: z})" + only: [neo4j, sql] + note: "Neo4j: 22G06. A time zone that is null." + error: true + - cypher: "UNWIND [null] AS z RETURN time({time: localtime('10:00'), timezone: z})" + only: [neo4j, sql] + note: "Neo4j: 22G06." + error: true + - cypher: "UNWIND ['hello'] AS z RETURN datetime({date: date('2024-03-15'), timezone: z})" + only: [neo4j, sql] + note: "Neo4j: 22000. A text that is no time zone." + error: true + - name: "Converting between temporal kinds" description: "A temporal value of one kind is made from a value of another." steps: diff --git a/tests/cypher/writes.yaml b/tests/cypher/writes.yaml index 349c27e..34015ea 100644 --- a/tests/cypher/writes.yaml +++ b/tests/cypher/writes.yaml @@ -27,6 +27,20 @@ cases: - cypher: "MATCH (n:CyWrite) RETURN properties(n)" rows: [{name: "Ada", age: 36}] + - name: "A null in a written row and a missing key are one null" + description: "A write's rows, read after it, group a map's null and a missing key as one null, and keep it once by DISTINCT." + steps: + - cypher: "UNWIND $rows AS r CREATE (:CyWrite {b: 9}) RETURN r.a AS a, count(*) AS c" + only: [neo4j, sql] + parameters: {rows: [{a: null}, {b: 1}]} + rows: [[null, 2]] + - cypher: "UNWIND [{a: null}, {b: 1}] AS r CREATE (:CyWrite {b: 9}) WITH r RETURN DISTINCT r.a AS a" + only: [neo4j, sql] + rows: [null] + - cypher: "MATCH (n:CyWrite) RETURN n.b AS b, count(*) AS c" + only: [neo4j, sql] + rows: [[9, 4]] + - name: "MERGE" description: "MERGE finds what exists, or what an earlier row made, and makes it once." steps: diff --git a/tests/integration/test_cypher_compiled.py b/tests/integration/test_cypher_compiled.py index 18afde0..fe9455a 100644 --- a/tests/integration/test_cypher_compiled.py +++ b/tests/integration/test_cypher_compiled.py @@ -1155,6 +1155,118 @@ async def query(cypher: str) -> list[Any]: ) == [["X", 2], ["Y", 1]] +async def test_an_aggregate_in_the_where_of_a_with_that_groups_compares_keys_as_grouping_does_and_makes_no_variable( + cypher_pm: PersistenceManager, +): + # #313: the WHERE of a WITH that aggregates, or is DISTINCT, filters its groups, as SQL's HAVING does. drivine4j's + # CypherQueryContract of liberation-data/drivine4j#497, less the statements the spec holds since v0.16.0; the rows + # are Neo4j's (2026.10.1). + await seeded(cypher_pm) + + async def query(cypher: str) -> list[Any]: + return await cypher_pm.query(spec(cypher)) + + ordered: list[tuple[str, list[Any]]] = [ + # A key's property, read through the item it is renamed to, of a map and of a node. + ( + "UNWIND [{a: 1}, {a: 1}, {a: 2}] AS x WITH x AS y, count(*) AS c WHERE x.a > 1 AND count(*) = 1 " + "RETURN y.a, c", + [[2, 1]], + ), + ( + "MATCH (p:Person)-[:ACTED_IN]->(m) WITH p AS q, count(*) AS c WHERE p.born > 1965 RETURN q.name, c " + "ORDER BY q.name", + [["Carrie", 2]], + ), + # A key and an aggregate compared as grouping compares them, a function's name in any case; count(DISTINCT i) + # is not count(i). + ( + "UNWIND ['a', 'A', 'b'] AS x WITH toUpper(x) AS u, count(*) AS c WHERE TOUPPER(x) = 'A' RETURN u, c", + [["A", 2]], + ), + ("UNWIND [1, 1, 2] AS i WITH i, Sum(i) AS s WHERE SUM(i) > 1 RETURN i, s ORDER BY i", [[1, 2], [2, 2]]), + ( + "UNWIND [1, 1, 2] AS i WITH i, count(i) AS c WHERE count(DISTINCT i) = 1 AND count(i) > 1 RETURN i, c", + [[1, 2]], + ), + # A property's name keeps its case: sum(x.A) is not sum(x.a). + ( + "UNWIND [{a: 1, A: 10}, {a: 2, A: 20}] AS x WITH count(*) AS c, sum(x.a) AS s WHERE sum(x.A) > 25 " + "RETURN c, s", + [[2, 3]], + ), + # An item quoted as a made item would be named is the statement's own. + *( + ( + f"UNWIND [1, 1, 2] AS i WITH i, count(*) AS `#having{n}` WHERE sum(i) > 1 RETURN i, `#having{n}` " + "ORDER BY i", + [[1, 2], [2, 1]], + ) + for n in range(1, 7) + ), + ] + for cypher, rows in ordered: + assert (cypher, await query(cypher)) == (cypher, rows) + unordered: list[tuple[str, list[Any]]] = [ + ("UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE sum(i) > 1 WITH * RETURN *", [[1, 2], [2, 1]]), + ( + "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE sum(i) > 1 RETURN i UNION UNWIND [5] AS `#having2` " + "RETURN `#having2` AS i", + [1, 2, 5], + ), + ("UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE count(DISTINCT i) = 1 RETURN i, c", [[1, 2], [2, 1]]), + ] + for cypher, rows in unordered: + given = sorted(json.dumps(it) for it in await query(cypher)) + assert (cypher, given) == (cypher, sorted(json.dumps(it) for it in rows)) + + +async def test_a_date_or_a_moment_from_calendar_fields_an_expression_gives_fails_past_their_range_as_it_runs( + cypher_pm: PersistenceManager, +): + # #312: worked out by the store, which fails on a field past its range as Neo4j does. drivine4j's + # CypherTemporalContract of liberation-data/drivine4j#495, less the statements the spec holds since v0.15.0. + differing: list[str] = [] + for cypher in ( + "UNWIND [[2023, 2, 0]] AS p RETURN date({year: p[0], week: p[1], dayOfWeek: p[2]})", + "UNWIND [[2024, 4, 93]] AS p RETURN date({year: p[0], quarter: p[1], dayOfQuarter: p[2]})", + "UNWIND [[2023, 2.5]] AS p RETURN date({year: p[0], week: p[1]})", + # A moment's too, which Oracle reads as a JSON value's, null where it is no moment. + "UNWIND [[2023, 366]] AS p RETURN localdatetime({year: p[0], ordinalDay: p[1], hour: 3})", + "UNWIND [[2020, 54]] AS p RETURN datetime({year: p[0], week: p[1], timezone: '+01:00'})", + "UNWIND [[2024, 4, 93]] AS p RETURN localdatetime({year: p[0], quarter: p[1], dayOfQuarter: p[2]})", + ): + try: + differing.append(f"{cypher} gave {await cypher_pm.query(spec(cypher))}") + except Exception: # noqa: BLE001, S112 + # Fails, as on Neo4j. + continue + assert differing == [] + + +async def test_a_date_or_a_moment_from_calendar_fields_an_expression_gives_is_neo4js_at_the_turn_of_a_year( + cypher_pm: PersistenceManager, +): + # #312: the least a null gives, and a moment beside its time. Neo4j's rows (2026.10.1). + rows = await cypher_pm.query( + spec( + "UNWIND [[2020, 53, 7, 2024, 366, 4, 92], [2019, 53, 1, 2023, 365, 1, 91], " + "[2015, 53, 5, 2024, 60, null, null], [2026, 1, 1, 2025, null, 2, 1]] AS p " + "WITH date({year: p[0], week: p[1], dayOfWeek: p[2]}) AS w, localdatetime({year: p[3], ordinalDay: p[4], " + "hour: 12}) AS o, datetime({year: p[0], quarter: p[5], dayOfQuarter: p[6], hour: 0, minute: 5, " + "timezone: '-05:00'}) AS q " + "RETURN [[w.year, w.month, w.day], [o.year, o.month, o.day, o.hour], [q.year, q.month, q.day, q.minute, " + "q.timezone]]" + ) + ) + assert rows == [ + [[2021, 1, 3], [2024, 12, 31, 12], [2020, 12, 31, 5, "-05:00"]], + [[2019, 12, 30], [2023, 12, 31, 12], [2019, 4, 1, 5, "-05:00"]], + [[2016, 1, 1], [2024, 2, 29, 12], [2015, 1, 1, 5, "-05:00"]], + [[2025, 12, 29], [2025, 1, 1, 12], [2026, 4, 1, 5, "-05:00"]], + ] + + async def test_an_optional_call_in_a_writes_subquery_counts_what_it_finds_and_the_row_it_finds_nothing_for( cypher_pm: PersistenceManager, ): diff --git a/tests/integration/test_cypher_spec.py b/tests/integration/test_cypher_spec.py index fc30226..4ece97a 100644 --- a/tests/integration/test_cypher_spec.py +++ b/tests/integration/test_cypher_spec.py @@ -13,7 +13,16 @@ from drivine import DatabaseType, PersistenceManager, QuerySpecification from drivine.backend.cypher import quote_name from drivine.query.statement import cypher_statement -from tests.cypher.cypher_spec import Case, check_rows, expanded, failure_matches, load +from tests.cypher.cypher_spec import ( + HELD_FAILURES, + Case, + check_rows, + expanded, + failure_matches, + held_for, + held_passes, + load, +) NAMES = {DatabaseType.NEO4J: "neo4j", DatabaseType.POSTGRES: "postgres", DatabaseType.ORACLE: "oracle"} """The name a step gives each store by.""" @@ -55,36 +64,54 @@ async def clear(pm: PersistenceManager) -> None: await pm.execute(QuerySpecification("DELETE FROM drivine_node")) -async def run(pm: PersistenceManager, case: Case) -> None: - """Each step of the case that runs on the store, in turn: its rows checked, or its failure.""" +async def run(pm: PersistenceManager, file: str, case: Case) -> None: + """ + Each step of the case of the spec file that runs on the store, in turn: its rows checked, or its failure. A step + held on the store (`HELD`) must fail, until its port lands. + """ store = NAMES[pm.type] for index, step in enumerate(case.steps): if not step.runs_on(store): continue - where = f"{case.name}, step {index + 1}, on {store}: {step.cypher.strip()}" - parameters = expanded(step.parameters) - if step.error is not None: - with pytest.raises(Exception) as failed: # noqa: PT011 - await query(pm, step.cypher, parameters) - assert failure_matches(step, failed.value), f"{where}\nexpected: {step.error}\nactual: {failed.value}" + issue = held_for(file, case, index, store) + if issue is None: + await check(pm, case, index, store) + continue + try: + await check(pm, case, index, store) + except HELD_FAILURES: + # Held, as liberation-data/drivine-python# has it. continue - check_rows(step, await query(pm, step.cypher, parameters), where) + pytest.fail(held_passes(file, case, index, store, issue)) + + +async def check(pm: PersistenceManager, case: Case, index: int, store: str) -> None: + """The step at `index` of the case run on the store: its rows checked, or its failure.""" + step = case.steps[index] + where = f"{case.name}, step {index + 1}, on {store}: {step.cypher.strip()}" + parameters = expanded(step.parameters) + if step.error is not None: + with pytest.raises(Exception) as failed: # noqa: PT011 + await query(pm, step.cypher, parameters) + assert failure_matches(step, failed.value), f"{where}\nexpected: {step.error}\nactual: {failed.value}" + return + check_rows(step, await query(pm, step.cypher, parameters), where) @pytest.mark.parametrize("case", QUERIES.cases, ids=[it.name for it in QUERIES.cases]) async def test_queries(cypher_pm: PersistenceManager, case: Case): await clear(cypher_pm) await query(cypher_pm, QUERIES.graph or "") - await run(cypher_pm, case) + await run(cypher_pm, "queries.yaml", case) @pytest.mark.parametrize("case", WRITES.cases, ids=[it.name for it in WRITES.cases]) async def test_writes(cypher_pm: PersistenceManager, case: Case): await clear(cypher_pm) - await run(cypher_pm, case) + await run(cypher_pm, "writes.yaml", case) @pytest.mark.parametrize("case", TEMPORAL.cases, ids=[it.name for it in TEMPORAL.cases]) async def test_temporal(cypher_pm: PersistenceManager, case: Case): await clear(cypher_pm) - await run(cypher_pm, case) + await run(cypher_pm, "temporal.yaml", case) diff --git a/tests/sync_integration/test_cypher_compiled.py b/tests/sync_integration/test_cypher_compiled.py index 0642a57..9c89663 100644 --- a/tests/sync_integration/test_cypher_compiled.py +++ b/tests/sync_integration/test_cypher_compiled.py @@ -1149,6 +1149,118 @@ def query(cypher: str) -> list[Any]: ) == [["X", 2], ["Y", 1]] +def test_an_aggregate_in_the_where_of_a_with_that_groups_compares_keys_as_grouping_does_and_makes_no_variable( + cypher_pm: PersistenceManager, +): + # #313: the WHERE of a WITH that aggregates, or is DISTINCT, filters its groups, as SQL's HAVING does. drivine4j's + # CypherQueryContract of liberation-data/drivine4j#497, less the statements the spec holds since v0.16.0; the rows + # are Neo4j's (2026.10.1). + seeded(cypher_pm) + + def query(cypher: str) -> list[Any]: + return cypher_pm.query(spec(cypher)) + + ordered: list[tuple[str, list[Any]]] = [ + # A key's property, read through the item it is renamed to, of a map and of a node. + ( + "UNWIND [{a: 1}, {a: 1}, {a: 2}] AS x WITH x AS y, count(*) AS c WHERE x.a > 1 AND count(*) = 1 " + "RETURN y.a, c", + [[2, 1]], + ), + ( + "MATCH (p:Person)-[:ACTED_IN]->(m) WITH p AS q, count(*) AS c WHERE p.born > 1965 RETURN q.name, c " + "ORDER BY q.name", + [["Carrie", 2]], + ), + # A key and an aggregate compared as grouping compares them, a function's name in any case; count(DISTINCT i) + # is not count(i). + ( + "UNWIND ['a', 'A', 'b'] AS x WITH toUpper(x) AS u, count(*) AS c WHERE TOUPPER(x) = 'A' RETURN u, c", + [["A", 2]], + ), + ("UNWIND [1, 1, 2] AS i WITH i, Sum(i) AS s WHERE SUM(i) > 1 RETURN i, s ORDER BY i", [[1, 2], [2, 2]]), + ( + "UNWIND [1, 1, 2] AS i WITH i, count(i) AS c WHERE count(DISTINCT i) = 1 AND count(i) > 1 RETURN i, c", + [[1, 2]], + ), + # A property's name keeps its case: sum(x.A) is not sum(x.a). + ( + "UNWIND [{a: 1, A: 10}, {a: 2, A: 20}] AS x WITH count(*) AS c, sum(x.a) AS s WHERE sum(x.A) > 25 " + "RETURN c, s", + [[2, 3]], + ), + # An item quoted as a made item would be named is the statement's own. + *( + ( + f"UNWIND [1, 1, 2] AS i WITH i, count(*) AS `#having{n}` WHERE sum(i) > 1 RETURN i, `#having{n}` " + "ORDER BY i", + [[1, 2], [2, 1]], + ) + for n in range(1, 7) + ), + ] + for cypher, rows in ordered: + assert (cypher, query(cypher)) == (cypher, rows) + unordered: list[tuple[str, list[Any]]] = [ + ("UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE sum(i) > 1 WITH * RETURN *", [[1, 2], [2, 1]]), + ( + "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE sum(i) > 1 RETURN i UNION UNWIND [5] AS `#having2` " + "RETURN `#having2` AS i", + [1, 2, 5], + ), + ("UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE count(DISTINCT i) = 1 RETURN i, c", [[1, 2], [2, 1]]), + ] + for cypher, rows in unordered: + given = sorted(json.dumps(it) for it in query(cypher)) + assert (cypher, given) == (cypher, sorted(json.dumps(it) for it in rows)) + + +def test_a_date_or_a_moment_from_calendar_fields_an_expression_gives_fails_past_their_range_as_it_runs( + cypher_pm: PersistenceManager, +): + # #312: worked out by the store, which fails on a field past its range as Neo4j does. drivine4j's + # CypherTemporalContract of liberation-data/drivine4j#495, less the statements the spec holds since v0.15.0. + differing: list[str] = [] + for cypher in ( + "UNWIND [[2023, 2, 0]] AS p RETURN date({year: p[0], week: p[1], dayOfWeek: p[2]})", + "UNWIND [[2024, 4, 93]] AS p RETURN date({year: p[0], quarter: p[1], dayOfQuarter: p[2]})", + "UNWIND [[2023, 2.5]] AS p RETURN date({year: p[0], week: p[1]})", + # A moment's too, which Oracle reads as a JSON value's, null where it is no moment. + "UNWIND [[2023, 366]] AS p RETURN localdatetime({year: p[0], ordinalDay: p[1], hour: 3})", + "UNWIND [[2020, 54]] AS p RETURN datetime({year: p[0], week: p[1], timezone: '+01:00'})", + "UNWIND [[2024, 4, 93]] AS p RETURN localdatetime({year: p[0], quarter: p[1], dayOfQuarter: p[2]})", + ): + try: + differing.append(f"{cypher} gave {cypher_pm.query(spec(cypher))}") + except Exception: # noqa: BLE001, S112 + # Fails, as on Neo4j. + continue + assert differing == [] + + +def test_a_date_or_a_moment_from_calendar_fields_an_expression_gives_is_neo4js_at_the_turn_of_a_year( + cypher_pm: PersistenceManager, +): + # #312: the least a null gives, and a moment beside its time. Neo4j's rows (2026.10.1). + rows = cypher_pm.query( + spec( + "UNWIND [[2020, 53, 7, 2024, 366, 4, 92], [2019, 53, 1, 2023, 365, 1, 91], " + "[2015, 53, 5, 2024, 60, null, null], [2026, 1, 1, 2025, null, 2, 1]] AS p " + "WITH date({year: p[0], week: p[1], dayOfWeek: p[2]}) AS w, localdatetime({year: p[3], ordinalDay: p[4], " + "hour: 12}) AS o, datetime({year: p[0], quarter: p[5], dayOfQuarter: p[6], hour: 0, minute: 5, " + "timezone: '-05:00'}) AS q " + "RETURN [[w.year, w.month, w.day], [o.year, o.month, o.day, o.hour], [q.year, q.month, q.day, q.minute, " + "q.timezone]]" + ) + ) + assert rows == [ + [[2021, 1, 3], [2024, 12, 31, 12], [2020, 12, 31, 5, "-05:00"]], + [[2019, 12, 30], [2023, 12, 31, 12], [2019, 4, 1, 5, "-05:00"]], + [[2016, 1, 1], [2024, 2, 29, 12], [2015, 1, 1, 5, "-05:00"]], + [[2025, 12, 29], [2025, 1, 1, 12], [2026, 4, 1, 5, "-05:00"]], + ] + + def test_an_optional_call_in_a_writes_subquery_counts_what_it_finds_and_the_row_it_finds_nothing_for( cypher_pm: PersistenceManager, ): diff --git a/tests/sync_integration/test_cypher_spec.py b/tests/sync_integration/test_cypher_spec.py index c6ae679..888cdff 100644 --- a/tests/sync_integration/test_cypher_spec.py +++ b/tests/sync_integration/test_cypher_spec.py @@ -15,7 +15,16 @@ from drivine.backend.cypher import quote_name from drivine.query.statement import cypher_statement from drivine.sync import DatabaseType, PersistenceManager, QuerySpecification -from tests.cypher.cypher_spec import Case, check_rows, expanded, failure_matches, load +from tests.cypher.cypher_spec import ( + HELD_FAILURES, + Case, + check_rows, + expanded, + failure_matches, + held_for, + held_passes, + load, +) NAMES = {DatabaseType.NEO4J: "neo4j", DatabaseType.POSTGRES: "postgres", DatabaseType.ORACLE: "oracle"} """The name a step gives each store by.""" @@ -57,36 +66,54 @@ def clear(pm: PersistenceManager) -> None: pm.execute(QuerySpecification("DELETE FROM drivine_node")) -def run(pm: PersistenceManager, case: Case) -> None: - """Each step of the case that runs on the store, in turn: its rows checked, or its failure.""" +def run(pm: PersistenceManager, file: str, case: Case) -> None: + """ + Each step of the case of the spec file that runs on the store, in turn: its rows checked, or its failure. A step + held on the store (`HELD`) must fail, until its port lands. + """ store = NAMES[pm.type] for index, step in enumerate(case.steps): if not step.runs_on(store): continue - where = f"{case.name}, step {index + 1}, on {store}: {step.cypher.strip()}" - parameters = expanded(step.parameters) - if step.error is not None: - with pytest.raises(Exception) as failed: # noqa: PT011 - query(pm, step.cypher, parameters) - assert failure_matches(step, failed.value), f"{where}\nexpected: {step.error}\nactual: {failed.value}" + issue = held_for(file, case, index, store) + if issue is None: + check(pm, case, index, store) + continue + try: + check(pm, case, index, store) + except HELD_FAILURES: + # Held, as liberation-data/drivine-python# has it. continue - check_rows(step, query(pm, step.cypher, parameters), where) + pytest.fail(held_passes(file, case, index, store, issue)) + + +def check(pm: PersistenceManager, case: Case, index: int, store: str) -> None: + """The step at `index` of the case run on the store: its rows checked, or its failure.""" + step = case.steps[index] + where = f"{case.name}, step {index + 1}, on {store}: {step.cypher.strip()}" + parameters = expanded(step.parameters) + if step.error is not None: + with pytest.raises(Exception) as failed: # noqa: PT011 + query(pm, step.cypher, parameters) + assert failure_matches(step, failed.value), f"{where}\nexpected: {step.error}\nactual: {failed.value}" + return + check_rows(step, query(pm, step.cypher, parameters), where) @pytest.mark.parametrize("case", QUERIES.cases, ids=[it.name for it in QUERIES.cases]) def test_queries(cypher_pm: PersistenceManager, case: Case): clear(cypher_pm) query(cypher_pm, QUERIES.graph or "") - run(cypher_pm, case) + run(cypher_pm, "queries.yaml", case) @pytest.mark.parametrize("case", WRITES.cases, ids=[it.name for it in WRITES.cases]) def test_writes(cypher_pm: PersistenceManager, case: Case): clear(cypher_pm) - run(cypher_pm, case) + run(cypher_pm, "writes.yaml", case) @pytest.mark.parametrize("case", TEMPORAL.cases, ids=[it.name for it in TEMPORAL.cases]) def test_temporal(cypher_pm: PersistenceManager, case: Case): clear(cypher_pm) - run(cypher_pm, case) + run(cypher_pm, "temporal.yaml", case) diff --git a/tests/unit/test_cypher_refusals.py b/tests/unit/test_cypher_refusals.py index 55d0366..8f44786 100644 --- a/tests/unit/test_cypher_refusals.py +++ b/tests/unit/test_cypher_refusals.py @@ -16,7 +16,7 @@ from drivine.cypher.sql.oracle_cypher_compiler import OracleCypherCompiler from drivine.cypher.sql.postgres_cypher_compiler import PostgresCypherCompiler from drivine.cypher.sql.sql_cypher_compiler import SqlCypherCompiler, holds_subquery -from tests.cypher.cypher_spec import Case, expanded, failure_matches, load +from tests.cypher.cypher_spec import HELD_FAILURES, Case, expanded, failure_matches, held_for, held_passes, load SPEC = load("refusals.yaml") @@ -29,10 +29,25 @@ def test_refusals(case: Case, store: str) -> None: for index, step in enumerate(case.steps): if not step.runs_on(store): continue - where = f"{case.name}, step {index + 1}, on {store}: {step.cypher.strip()}" - with pytest.raises(Exception) as refused: # noqa: PT011 - COMPILERS[store]("g").compile(step.cypher, expanded(step.parameters)) - assert failure_matches(step, refused.value), f"{where}\nexpected: {step.error}\nactual: {refused.value}" + issue = held_for("refusals.yaml", case, index, store) + if issue is None: + refuses(case, index, store) + continue + try: + refuses(case, index, store) + except HELD_FAILURES: + # Held, as liberation-data/drivine-python# has it. + continue + pytest.fail(held_passes("refusals.yaml", case, index, store, issue)) + + +def refuses(case: Case, index: int, store: str) -> None: + """That the step at `index` of the case is refused on the store, as it says.""" + step = case.steps[index] + where = f"{case.name}, step {index + 1}, on {store}: {step.cypher.strip()}" + with pytest.raises(Exception) as refused: # noqa: PT011 + COMPILERS[store]("g").compile(step.cypher, expanded(step.parameters)) + assert failure_matches(step, refused.value), f"{where}\nexpected: {step.error}\nactual: {refused.value}" @pytest.mark.parametrize("compiler", COMPILERS.values(), ids=COMPILERS.keys()) @@ -609,13 +624,79 @@ def test_a_truncations_fields_given_as_an_expression_a_negated_number_utc_or_non compiler("g").compile(cypher) +@pytest.mark.parametrize("compiler", COMPILERS.values(), ids=COMPILERS.keys()) +@pytest.mark.parametrize( + ("cypher", "message"), + [ + # A year past Java's, as Neo4j's, and one whose date would be past Java's last. + ( + "RETURN date({year: 4294969319, week: 1})", + "date() takes no year 4294969319: Invalid value for Year (valid values -999999999 - 999999999): 4294969319", + ), + ( + "RETURN date({year: -1000000000, ordinalDay: 1})", + "date() takes no year -1000000000: Invalid value for Year (valid values -999999999 - 999999999): " + "-1000000000", + ), + ( + "RETURN date({year: 999999999, week: 53, dayOfWeek: 7})", + "date() takes no year 999999999: Invalid value for EpochDay (valid values -365243219162 - 365241780471): " + "365241780480", + ), + ("RETURN date({year: 2023, week: true})", "date() takes a whole number for week, not true."), + ("RETURN date({year: 2023, quarter: 2.0})", "date() takes a whole number for quarter, not 2.0."), + ( + "RETURN date({ordinalDay: 2})", + "date() of an ordinal day without a year is Cypher, and is not compiled to SQL yet.", + ), + ( + "RETURN date({date: date('2024-03-15'), week: 2})", + "date() of a map with date is Cypher, and is not compiled to SQL yet.", + ), + ( + "RETURN datetime({year: 1984, week: 10, timezone: 'Europe/Stockholm'})", + "The time zone 'Europe/Stockholm' is Cypher, and is not compiled to SQL yet.", + ), + ("RETURN localtime({hour: 12, dayOfQuarter: 2})", "localtime() takes no field dayOfQuarter."), + ], +) +def test_a_temporal_map_of_the_calendars_fields_past_what_the_spec_holds_is_refused_as_neo4j_refuses_it( + compiler: type[SqlCypherCompiler], cypher: str, message: str +) -> None: + # #312, drivine4j's #495: the spec's "Temporal map of calendar fields Neo4j does not take" holds the rest. Neo4j + # makes an ordinal day of no year one of the year 0, which no store's date reaches, and a zone by name no store's + # text holds 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_temporal_map_of_the_calendars_fields_that_runs_on_or_that_an_expression_gives_is_compiled( + compiler: type[SqlCypherCompiler], +) -> None: + # #312, drivine4j's #495: week 53 of a year of 52 weeks and a day of the quarter past its last run on, and what an + # expression gives is the store's to check. + for cypher in ( + "RETURN date({year: 2019, week: 53}), date({year: 2023, quarter: 1, dayOfQuarter: 92}), " + "date({year: 2024, ordinalDay: 366})", + "WITH 54 AS w RETURN date({year: 2020, week: w}), datetime({year: $y, ordinalDay: 366, timezone: '+01:00'})", + "RETURN date({year: -1, week: -1 + 2, dayOfWeek: 7}), datetime({year: 1984, week: 10, timezone: 'Z'})", + ): + compiler("g").compile(cypher, {"y": 2023}) + + def _in_where(aggregate: str) -> str: return ( - f"{aggregate} aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter " - "what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." + f"{aggregate} aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in " + "a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1." ) +def _undefined(variable: str) -> str: + return f"{variable} is not defined: a variable is bound by a pattern, a WITH or an UNWIND before it is used." + + @pytest.mark.parametrize("compiler", COMPILERS.values(), ids=COMPILERS.keys()) @pytest.mark.parametrize( ("cypher", "message"), @@ -651,6 +732,56 @@ def _in_where(aggregate: str) -> str: ("UNWIND [1, 2] AS i RETURN i SKIP size([x IN [1] WHERE sum(x) > 0])", _in_where("sum()")), ("CREATE (n:N {v: [x IN [1, 2] WHERE avg(x) > 0 | x]})", _in_where("avg()")), ("FOREACH (i IN [1] | CREATE (:N {v: [x IN [1, 2] WHERE max(x) > 0 | x]}))", _in_where("max()")), + # #313, drivine4j's #497: a WITH whose only aggregate is in its WHERE groups nothing; in the WHERE of one that + # groups, a comprehension's, a quantifier's or a pattern's own WHERE or body holds no aggregate either. + ("UNWIND [1, 1, 2] AS i WITH i WHERE count(*) > 1 RETURN i", _in_where("count(*)")), + ("UNWIND [1, 1, 2] AS i WITH * WHERE count(*) > 1 RETURN i", _in_where("count(*)")), + ( + "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE all(x IN [1] WHERE count(*) > x) RETURN i, c", + _in_where("count(*)"), + ), + ( + "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE size([x IN [1] WHERE count(*) > x | x]) > 0 RETURN i, c", + _in_where("count(*)"), + ), + ( + "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE [x IN [1] | x + count(*)][0] > 2 RETURN i, c", + _in_where("count(*)"), + ), + ( + "UNWIND [1, 1, 2] AS i WITH DISTINCT i WHERE reduce(s = 0, x IN [1] | s + count(*)) > 0 RETURN i", + _in_where("count(*)"), + ), + ( + "MATCH (p)-[:ACTED_IN]->(m) WITH p, count(*) AS c " + "WHERE size([(p)-[:ACTED_IN]->(x) WHERE count(x) > 1 | x]) > 0 RETURN p.name", + _in_where("count()"), + ), + ( + "MATCH (p)-[:ACTED_IN]->(m) WITH p, count(*) AS c WHERE EXISTS { (p)-[:KNOWS]->(q) WHERE count(q) > 0 } " + "RETURN p.name", + _in_where("count()"), + ), + ( + "UNWIND [1, 2] AS i CALL (i) { UNWIND [i, i] AS x WITH x WHERE count(*) > 1 RETURN x } RETURN x", + _in_where("count(*)"), + ), + # #313, drivine4j's #497: there a variable from before the WITH is read only inside an aggregate, or where it is + # a key, written as its item is. + ("UNWIND [1, 2, 3] AS i WITH i % 2 AS k, count(*) AS c WHERE i > 0 RETURN k, c", _undefined("i")), + ( + "UNWIND [1, 2, 3] AS i WITH i % 2 AS k, count(*) AS c WHERE sum(i) > 2 AND i > 0 RETURN k, c", + _undefined("i"), + ), + ("UNWIND [1, 2, 3] AS i WITH DISTINCT i % 2 AS k WHERE i > 0 RETURN k", _undefined("i")), + ("MATCH (p)-[:ACTED_IN]->(m) WITH p.name AS n, count(*) AS c WHERE p.born > 1965 RETURN n, c", _undefined("p")), + ("MATCH (p)-[:ACTED_IN]->(m) WITH p, count(*) AS c WHERE m.released > 1996 RETURN p.name", _undefined("m")), + ("UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE sum(c) > 1 RETURN i, c", _undefined("c")), + ("UNWIND [1, 1, 2] AS i WITH i AS j, count(*) AS c WHERE sum(j) > 1 RETURN j, c", _undefined("j")), + ( + "UNWIND [1, 1, 2] AS i WITH i AS j, count(*) AS c WHERE any(x IN [1] WHERE x = i) RETURN j, c", + _undefined("i"), + ), ], ) def test_an_aggregate_in_a_where_is_refused_as_neo4j_refuses_it( @@ -673,6 +804,18 @@ def test_an_aggregate_in_a_where_is_refused_as_neo4j_refuses_it( "MATCH (n) WHERE EXISTS { MATCH (n)-->(m) WITH count(m) AS c WHERE c > 0 RETURN c } RETURN n.x", "MATCH (n) CALL (n) { MATCH (n)-->(m) WITH count(m) AS c WHERE c > 0 RETURN c } RETURN n.x, c", "MATCH (n) WITH n, count { (n)-->() } AS c WHERE c > 0 RETURN n.x", + # #313, drivine4j's #497: the WHERE of a WITH that aggregates, or is DISTINCT, filters its groups, an aggregate + # in it among them. + "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE count(*) > 1 RETURN i", + "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE sum(i) > 1 RETURN i", + "UNWIND [1, 2] AS i WITH i AS j, count(*) AS c WHERE j > count(*) RETURN j", + "UNWIND [1, 1, 2] AS i WITH DISTINCT i WHERE count(*) > 1 RETURN i", + "UNWIND [1, 1, 2] AS i WITH i AS j, count(*) AS c WHERE i > 1 RETURN j, c", + "UNWIND [1, 1, 2] AS i WITH i, count(*) AS c WHERE all(x IN collect(i) WHERE x > 1) RETURN i, c", + "MATCH (n) WITH n, count(*) AS c " + "WHERE EXISTS { MATCH (n)-->(m) WITH m, count(*) AS k WHERE count(*) > 0 RETURN m } RETURN n.x", + "UNWIND [1, 2] AS i CALL (i) { UNWIND [i, i] AS x WITH x, count(*) AS c WHERE count(*) > 1 RETURN c } " + "RETURN i, c", ], ) def test_a_where_over_what_an_aggregate_gave_or_in_a_query_that_aggregates_in_its_own_projection_is_compiled( From a09b5e255934ea743a9e661fa2f754783fc6acf7 Mon Sep 17 00:00:00 2001 From: jasper blues Date: Sun, 1 Nov 2026 05:20:20 +1000 Subject: [PATCH 2/2] Count a refusal as a difference in the past-range test, use calendar.isleap, and say when an aggregate is refused --- drivine/cypher/sql/aggregates.py | 1 + drivine/cypher/sql/sql_cypher_compiler.py | 8 ++------ tests/integration/test_cypher_compiled.py | 10 +++++++--- tests/sync_integration/test_cypher_compiled.py | 10 +++++++--- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/drivine/cypher/sql/aggregates.py b/drivine/cypher/sql/aggregates.py index 95b08b9..32e30f2 100644 --- a/drivine/cypher/sql/aggregates.py +++ b/drivine/cypher/sql/aggregates.py @@ -244,6 +244,7 @@ def _parts(self, expression: Expression) -> list[Expression]: def _refuse(aggregate: Expression) -> NoReturn: + """Refuses the aggregate, in a WHERE that filters no groups or in a part of one that is read over each row.""" raise DrivineError( f"{_named(aggregate)} aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: " "aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE " diff --git a/drivine/cypher/sql/sql_cypher_compiler.py b/drivine/cypher/sql/sql_cypher_compiler.py index 985d1d5..3e9c3a3 100644 --- a/drivine/cypher/sql/sql_cypher_compiler.py +++ b/drivine/cypher/sql/sql_cypher_compiler.py @@ -6,6 +6,7 @@ import math import re from abc import ABC, abstractmethod +from calendar import isleap from collections.abc import Callable, Iterable, Mapping, Sequence from collections.abc import Set as AbstractSet from dataclasses import dataclass @@ -7356,7 +7357,7 @@ def field(key: str, label: str, last: int, of: str = "") -> int: monday = january4 - (_iso_day_of_week(january4) - 1) days = monday + (field("week", "a week", 53) - 1) * 7 + field("dayOfWeek", "a day of the week", 7) - 1 elif calendar == "an ordinal day": - last = 366 if _leap_year(year) else 365 + last = 366 if isleap(year) else 365 days = _days_from_civil(year, 1, 1) + field("ordinalDay", "an ordinal day", last, f" in {year}") - 1 else: first = _days_from_civil(year, (field("quarter", "a quarter", 4) - 1) * 3 + 1, 1) @@ -7417,11 +7418,6 @@ def _written_text(value: Any) -> str: return str(value).lower() if isinstance(value, bool) else str(value) -def _leap_year(year: int) -> bool: - """Whether the year of the proleptic Gregorian calendar is a leap year.""" - return (year % 4 == 0 and year % 100 != 0) or year % 400 == 0 - - def _days_from_civil(year: int, month: int, day: int) -> int: """The days from 1970-01-01 to the date `year`-`month`-`day` of the proleptic Gregorian calendar, at any year.""" y = year - 1 if month <= 2 else year diff --git a/tests/integration/test_cypher_compiled.py b/tests/integration/test_cypher_compiled.py index fe9455a..2d9162f 100644 --- a/tests/integration/test_cypher_compiled.py +++ b/tests/integration/test_cypher_compiled.py @@ -1238,9 +1238,13 @@ async def test_a_date_or_a_moment_from_calendar_fields_an_expression_gives_fails ): try: differing.append(f"{cypher} gave {await cypher_pm.query(spec(cypher))}") - except Exception: # noqa: BLE001, S112 - # Fails, as on Neo4j. - continue + except Exception as e: # noqa: BLE001 + # Fails as it runs, as on Neo4j: the store's failure, not a refusal, which is a DrivineError at the root. + cause: BaseException = e + while cause.__cause__ is not None: + cause = cause.__cause__ + if isinstance(cause, DrivineError): + differing.append(f"{cypher} was refused: {cause}") assert differing == [] diff --git a/tests/sync_integration/test_cypher_compiled.py b/tests/sync_integration/test_cypher_compiled.py index 9c89663..64e4c59 100644 --- a/tests/sync_integration/test_cypher_compiled.py +++ b/tests/sync_integration/test_cypher_compiled.py @@ -1232,9 +1232,13 @@ def test_a_date_or_a_moment_from_calendar_fields_an_expression_gives_fails_past_ ): try: differing.append(f"{cypher} gave {cypher_pm.query(spec(cypher))}") - except Exception: # noqa: BLE001, S112 - # Fails, as on Neo4j. - continue + except Exception as e: # noqa: BLE001 + # Fails as it runs, as on Neo4j: the store's failure, not a refusal, which is a DrivineError at the root. + cause: BaseException = e + while cause.__cause__ is not None: + cause = cause.__cause__ + if isinstance(cause, DrivineError): + differing.append(f"{cypher} was refused: {cause}") assert differing == []