From 0075a4e4b6ae730eefb6e4b546f8f6328a8845fe Mon Sep 17 00:00:00 2001 From: jasper blues Date: Sun, 1 Nov 2026 07:11:07 +1000 Subject: [PATCH 1/2] Make a date, a time or a moment from another temporal value, with the map's fields laid over its own, as drivine4j does Ports drivine4j's #508 (merged as liberation-data/drivine4j#509), with the spec's liberation-data/drivine-cypher-spec#44, which v0.19.0 already holds. The compiler follows Drivine for TypeScript's port (liberation-data/drivine#503). - date(), localtime(), time(), localdatetime() and datetime() take a map with date, time or datetime, another temporal value, and lay the map's other fields over its own (SqlCypherCompiler._from_temporal, Temporals.over). A year, a month and a day replace the value's together, the date read as a date first so the store fails where they make none; a week, an ordinal day or a day of the quarter go through of_calendar; a quarter or a day of the week alone moves the date; a fraction given makes the whole fraction. A zoned value keeps the offset of what gives its time; a timezone given moves a zoned value there and names a local one's offset. - A value whose kind the statement doesn't tell is read by a CASE over the kinds that can give it; a known wrong kind is refused when compiling. A null or wrong-kind value or zone given by an expression fails as the statement runs, on the day 0, as Neo4j fails. - Refused as Neo4j refuses them: a field the kind hasn't, a datetime beside a date or a time, a value that is no temporal value, two calendars, a date that isn't whole beside a value that gives the time alone, a field of the time without the one above it, a number not whole or past its range, a time zone that is null or no offset. A zone by name (UTC among them) is refused as not compiled, now in any temporal map. - The whole-number check is shared with truncation (_whole_field), and the zone check (_zone_text) and the calendars (_calendars_of) with the calendar fields. The #317 holds come out of HELD. Fixes #317. --- drivine/cypher/sql/sql_cypher_compiler.py | 271 ++++++++++++++++++++-- drivine/cypher/sql/temporal.py | 5 + drivine/cypher/sql/temporals.py | 243 ++++++++++++++++++- tests/cypher/cypher_spec.py | 10 - tests/unit/test_cypher_refusals.py | 79 ++++++- 5 files changed, 557 insertions(+), 51 deletions(-) diff --git a/drivine/cypher/sql/sql_cypher_compiler.py b/drivine/cypher/sql/sql_cypher_compiler.py index 3e9c3a3..54041c0 100644 --- a/drivine/cypher/sql/sql_cypher_compiler.py +++ b/drivine/cypher/sql/sql_cypher_compiler.py @@ -78,7 +78,16 @@ 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 CALENDAR_FIELDS, FIELD_NAMES, NoNumber, Overrides, Temporals, canonical +from drivine.cypher.sql.temporals import ( + CALENDAR_FIELDS, + FIELD_NAMES, + SELECTORS, + NoNumber, + Overrides, + TemporalBase, + Temporals, + canonical, +) from drivine.cypher.sql.value import ( Bool, Entities, @@ -4626,6 +4635,8 @@ def of(text: str) -> Json: raise DrivineError(f"'{text}' is no {function.name}.") return Json(self.literal_json(worked), temporal=kind) if isinstance(written, MapLiteral): + if kind is not Temporal.DURATION and any(key in SELECTORS for key, _ in written.entries): + return of(self._from_temporal(kind, function, written, scope)) fields = {key: self._expression(value, scope) for key, value in written.entries} _map_fields(kind, function.name, list(fields)) numbers = { @@ -4637,11 +4648,13 @@ 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 + # A zone by name, which no store's text holds yet, is refused rather than given as null: so a value made + # from such a moment, `time({time: other})`, is refused too. + _map_zone(written.entries) 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)) @@ -4658,6 +4671,104 @@ def of(text: str) -> Json: return of(self.temporals.convert(text, given.temporal, kind)) return of(self.temporals.convert_any(text, kind)) + def _from_temporal(self, kind: Temporal, function: Function, written: MapLiteral, scope: Scope) -> str: + """ + The SQL of a temporal value of the kind that the function makes of the map `written` with a `date`, a `time` or + a `datetime`, another temporal value whose fields it takes, with the map's others laid over them: see + `Temporals.over`. Fails as Neo4j fails: on a field the kind has none of, a `datetime` beside a `date` or a + `time`, a value written that is no temporal value or is of a kind that hasn't what it is to give, fields of two + calendars, a date of its fields that is not whole (a year, a month and a day; a week and a day of the week; an + ordinal day; a quarter and a day of the quarter) beside a value that gives the time, a field of the time without + the one above it where no value gives the time, and a number written that is not whole or past its field's + range. A zone by name is refused. As drivine4j's. + """ + name = function.name + entries = dict(written.entries) + keys = entries.keys() + calendar = _over_keys(kind, name, keys) + + def base(key: str, kinds: tuple[Temporal, ...]) -> TemporalBase | None: + value = entries.get(key) + return self._over_base(name, key, kinds, value, scope) if value is not None else None + + datetime_ = base("datetime", (Temporal.DATE_TIME, Temporal.LOCAL_DATE_TIME)) + date_ = datetime_ or base("date", (Temporal.DATE_TIME, Temporal.LOCAL_DATE_TIME, Temporal.DATE)) + time = datetime_ or base( + "time", (Temporal.DATE_TIME, Temporal.LOCAL_DATE_TIME, Temporal.TIME, Temporal.LOCAL_TIME) + ) + fields = { + key: self._over_field(name, key, value, keys, scope) + for key, value in written.entries + if key not in SELECTORS and key != "timezone" + } + zone = entries.get("timezone") + timezone = self._over_zone(name, zone, scope) if zone is not None else None + # A date of fields written as numbers alone is worked out here, as a map of them is (see _calendar_date). + if date_ is None and calendar is not None and calendar != "a month": + day = _calendar_date( + name, [(key, value) for key, value in written.entries if key == "year" or key in CALENDAR_FIELDS] + ) + if day is not None: + fields = {key: value for key, value in fields.items() if key not in CALENDAR_FIELDS} + fields["year"], fields["month"], fields["day"] = (str(it) for it in day) + return self.temporals.over(date_, time, fields, timezone, kind) + + def _over_field(self, name: str, key: str, value: Expression, keys: AbstractSet[str], scope: Scope) -> str: + """ + The SQL number of the field `key` of a map given `name` that is laid over another temporal value, written as + `value`: a field written as null is its least (OVER_LEAST), as is one an expression gives as null. A number + written that is not whole, or past its field's range (OVER_RANGES; beside a millisecond or a microsecond given, + a microsecond or a nanosecond runs to the next one's), fails. `keys` are the map's fields. + """ + least = OVER_LEAST.get(key, 0) + written = _written_number(value) + if written is None: + return f"COALESCE({self.number(self.comparable(self.json(self._expression(value, scope))))}, {least})" + if written.value is None: + return f"{least}" + range_, beside = _over_range(key, keys) + return f"{_whole_field(name, key, written.value, range_, beside)}" + + def _over_base( + self, name: str, key: str, kinds: tuple[Temporal, ...], value: Expression, scope: Scope + ) -> TemporalBase: + """ + The value that `value`, written as the `date`, `time` or `datetime` field `key` of a map given `name`, gives + `Temporals.over`, where it is of one of `kinds`. A value written that is no temporal value, or one whose kind + the statement tells that is none of `kinds`, fails, as Neo4j fails on it. + """ + nouns = [f"a {it.function}" for it in reversed(kinds)] + + def refused(given: str) -> NoReturn: + raise DrivineError(f"{name}() takes {', '.join(nouns[:-1])} or {nouns[-1]} for {key}, not {given}.") + + if isinstance(value, Literal): + refused(_shown_literal(value.value)) + given = self._expression(value, scope) + if not isinstance(given, Json): + refused("a truth value" if isinstance(given, Bool) else "a node, a relationship or a path") + if given.known is not None: + refused(noun_of(given.known)) + if given.temporal is not None and given.temporal not in kinds: + refused(f"a {given.temporal.function}") + return TemporalBase( + self.text(self.comparable(given)), (given.temporal,) if given.temporal is not None else kinds + ) + + def _over_zone(self, name: str, zone: Expression, scope: Scope) -> str: + """ + The offset's SQL text of `zone`, the `timezone` of a map given `name` that is laid over another temporal value: + one written is refused where it is null or no offset (see `_zone_text`); one an expression gives is the store's + to check. + """ + if zone == Literal(None): + raise DrivineError(f"{name}() takes a time zone, not null.") + if not isinstance(zone, Literal): + return self.text(self.comparable(self.json(self._expression(zone, scope)))) + if not isinstance(zone.value, str): + raise DrivineError(f"{name}() takes a time zone, not {_written(zone.value)}.") + return _zone_text(zone.value) + def _map_projection(self, projection: MapProjection, scope: Scope) -> Value: if projection.variable not in scope: raise DrivineError(f"{projection.variable} is not defined.") @@ -7202,8 +7313,41 @@ def _is_number(value: Any) -> bool: TRUNCATION_LEAST = {"month": 1, "day": 1, "dayOfWeek": 1} """The least of each field a truncation's map gives that is not 0, which a field given as null is.""" -TRUNCATION_FIELD_NAMES = {"dayOfWeek": "a day of the week", "hour": "an hour"} -"""A truncation's fields as a message names them, where it is not `a `.""" +FIELD_NOUNS = { + "dayOfWeek": "a day of the week", + "hour": "an hour", + "ordinalDay": "an ordinal day", + "dayOfQuarter": "a day of the quarter", +} +"""A temporal value's fields as a message names them, where it is not `a `.""" + +OVER_RANGES: dict[str, tuple[int, int]] = { + **TRUNCATION_RANGES, + "week": (1, 53), + "ordinalDay": (1, 366), + "quarter": (1, 4), + "dayOfQuarter": (1, 92), + "nanosecond": (0, 999_999_999), +} +"""The values each field a map laid over another temporal value gives may take, as Neo4j takes them, but for the year: +see `_from_temporal`.""" + +OVER_LEAST = {"month": 1, "day": 1, "week": 1, "dayOfWeek": 1, "ordinalDay": 1, "quarter": 1, "dayOfQuarter": 1} +"""The least of each field a map laid over another temporal value gives that is not 0, which a field given as null +is.""" + +WHOLE_DATES = { + "a month": ("year", "month", "day"), + "a week": ("year", "week", "dayOfWeek"), + "an ordinal day": ("year", "ordinalDay"), + "a quarter": ("year", "quarter", "dayOfQuarter"), +} +"""The date's fields that make a whole date by each calendar, where no value gives the date: see `_from_temporal`.""" + +TIME_ABOVE = (("minute", "hour"), ("second", "minute"), ("millisecond", "second"), ("microsecond", "second"), + ("nanosecond", "second")) # fmt: skip +"""Each field of the time that a map without a value that gives the time takes only beside another: see +`_from_temporal`.""" def _truncation_keys(function: Function, kind: Temporal, entries: Sequence[tuple[str, Expression]]) -> set[str]: @@ -7235,23 +7379,28 @@ def _truncation_whole(function: Function, key: str, written: Any, millis: bool) range (see TRUNCATION_RANGES); with `millis`, a millisecond is given or kept, beside which a microsecond runs to 999. """ - if not isinstance(written, int) or isinstance(written, bool): - shown = f"'{written}'" if isinstance(written, str) else _shown(written) - raise DrivineError(f"{function.name}() takes a whole number for {key}, not {shown}.") beside = key == "microsecond" and millis range_ = (0, 999) if beside else TRUNCATION_RANGES.get(key) + return _whole_field(function.name, key, written, range_, "millisecond" if beside else None) + + +def _whole_field(name: str, key: str, written: Any, range_: tuple[int, int] | None, beside: str | None) -> int: + """ + The value written as the field `key` of a temporal map given `name`, where it is a whole number in `range_`, the + range it has `beside` another field given where one is named, as Neo4j takes it: fails on any other. + """ + if not isinstance(written, int) or isinstance(written, bool): + raise DrivineError(f"{name}() takes a whole number for {key}, not {_shown_literal(written)}.") if range_ is not None and not range_[0] <= written <= range_[1]: - name = TRUNCATION_FIELD_NAMES.get(key, f"a {key}") - also = " beside a millisecond" if beside else "" - raise DrivineError(f"{function.name}() takes {name} from {range_[0]} to {range_[1]}{also}, not {written}.") + noun = FIELD_NOUNS.get(key, f"a {key}") + also = f" beside a {beside}" if beside is not None else "" + raise DrivineError(f"{name}() takes {noun} from {range_[0]} to {range_[1]}{also}, not {written}.") return written -def _shown(value: Any) -> str: - """A value written as a message shows it, as Cypher writes it: `true`, `1.5`.""" - if isinstance(value, bool): - return "true" if value else "false" - return str(value) +def _shown_literal(value: Any) -> str: + """The value a statement writes, as a message shows it: a text in quotes, `'3'`, else as written.""" + return f"'{value}'" if isinstance(value, str) else _written(value) @dataclass(frozen=True) @@ -7294,10 +7443,10 @@ def _map_fields(kind: Temporal, name: str, keys: list[str]) -> None: unsupported(f"{name}() of a map with {unknown}") -def _calendar_zone(entries: Sequence[tuple[str, Expression]]) -> None: +def _map_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. + Refuses the `timezone` of `entries`, a temporal map'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): @@ -7375,9 +7524,7 @@ 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.") + calendars = _calendars_of(name, keys) 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: @@ -7390,6 +7537,74 @@ def _calendar_of(name: str, keys: set[str]) -> str: return calendars[0] +def _calendars_of(name: str, keys: AbstractSet[str]) -> list[str]: + """ + The calendars (see CALENDARS) that `keys`, the fields of a map given `name`, give a date by: a month, a week, an + ordinal day or a quarter. Fails where it is more than one, as Neo4j does. + """ + 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.") + return calendars + + +def _over_keys(kind: Temporal, name: str, keys: AbstractSet[str]) -> str | None: + """ + The calendar (see CALENDARS) that `keys`, the fields of a map that `name` makes a value of the kind of with a + `date`, a `time` or a `datetime`, give a date by, where they give one. Fails as Neo4j fails: on a field the kind + has none of, a `datetime` beside a `date` or a `time`, fields of two calendars, a date of its fields that is not + whole (see WHOLE_DATES) where no value gives the date, and a field of the time without the one above it (see + TIME_ABOVE) where no value gives the time. + """ + dated = kind in (Temporal.DATE, Temporal.LOCAL_DATE_TIME, Temporal.DATE_TIME) + takes = _over_takes(kind, dated) + unknown = next((it for it in keys if it not in takes), None) + if unknown is not None: + raise DrivineError(f"{name}() takes no field {unknown}.") + if "datetime" in keys and ("date" in keys or "time" in keys): + raise DrivineError(f"{name}() takes a datetime, or a date and a time, not both.") + calendars = _calendars_of(name, keys) + calendar = calendars[0] if len(calendars) == 1 else None + if dated and "date" not in keys and "datetime" not in keys: + if calendar is None or not all(it in keys for it in WHOLE_DATES[calendar]): + raise DrivineError( + f"{name}() 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." + ) + if kind is not Temporal.DATE and "time" not in keys and "datetime" not in keys: + for field, above in TIME_ABOVE: + if field in keys and above not in keys: + raise DrivineError(f"{name}() takes a {field} beside {FIELD_NOUNS.get(above, f'a {above}')}.") + return calendar + + +def _over_takes(kind: Temporal, dated: bool) -> set[str]: + """ + The fields of a map with a `date`, a `time` or a `datetime` that makes a value of the kind, which is `dated` where + it has a date: which of those it takes, the fields of its date and its time, and a zoned value's time zone. + """ + selectors: tuple[str, ...] = ("date",) if kind is Temporal.DATE else SELECTORS if dated else ("time",) + return { + *selectors, + *(("year", "month", "day", *CALENDAR_FIELDS) if dated else ()), + *(TRUNCATION_TIME_FIELDS if kind is not Temporal.DATE else ()), + *(("timezone",) if kind.zoned else ()), + } + + +def _over_range(key: str, keys: AbstractSet[str]) -> tuple[tuple[int, int] | None, str | None]: + """ + The range of the field `key` of a map with the fields `keys` that is laid over another temporal value (see + OVER_RANGES), and the field beside which it runs only to the next one's: a microsecond or a nanosecond beside a + millisecond, a nanosecond beside a microsecond. + """ + if key == "nanosecond" and "microsecond" in keys: + return (0, 999), "microsecond" + if key in ("microsecond", "nanosecond") and "millisecond" in keys: + return ((0, 999_999) if key == "nanosecond" else (0, 999)), "millisecond" + return OVER_RANGES.get(key), None + + 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 @@ -7448,15 +7663,21 @@ def _iso_day_of_week(days: int) -> int: def _truncation_zone(function: Function, value: Expression) -> str: """ - The offset a truncation's `timezone` field, the value, gives the function, as SQL text. Neo4j takes an offset - written as `Z` or `+01:00` alone, as Java's ZoneOffset does (up to 18 hours, and 0 is UTC's, `Z`), and a zone by - name, which no store's text holds yet, so it is refused as not compiled yet. + The offset a truncation's `timezone` field, the value, gives the function, as SQL text: see `_zone_text`. """ if value == Literal(None): raise DrivineError(f"{function.name}() takes a time zone, not null.") if not isinstance(value, Literal) or not isinstance(value.value, str): unsupported("A truncation to a time zone an expression gives") - zone: str = value.value + return _zone_text(value.value) + + +def _zone_text(zone: str) -> str: + """ + The offset's SQL text the time zone `zone` written gives a value. Neo4j takes an offset written as `Z` or `+01:00` + alone, as Java's ZoneOffset does (up to 18 hours, and 0 is UTC's, `Z`), and a zone by name, which no store's text + holds yet, so it is refused as not compiled yet; any other text fails. + """ offset = _offset_text(zone) if offset is not None: return offset diff --git a/drivine/cypher/sql/temporal.py b/drivine/cypher/sql/temporal.py index e721e86..8ee775d 100644 --- a/drivine/cypher/sql/temporal.py +++ b/drivine/cypher/sql/temporal.py @@ -31,6 +31,11 @@ def __init__(self, pattern: str, zoned: bool) -> None: self.pattern = pattern self.zoned = zoned + @property + def function(self) -> str: + """The name of the function that makes a value of this kind: `date`, `localdatetime`…""" + return self.name.lower().replace("_", "") + @property def instant(self) -> bool: """Whether a value of this kind is a moment or a time of day, which a duration is added to.""" diff --git a/drivine/cypher/sql/temporals.py b/drivine/cypher/sql/temporals.py index 4408989..f0ca0bc 100644 --- a/drivine/cypher/sql/temporals.py +++ b/drivine/cypher/sql/temporals.py @@ -68,9 +68,31 @@ class Field: """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`.""" +SELECTORS = ("date", "time", "datetime") +"""The fields of a map that give another temporal value to make one of: see `Temporals.over`.""" + +_DAY = ("year", "month", "day") +"""The fields of a date by its month: see `Temporals.over`.""" + +_FRACTION = ("millisecond", "microsecond", "nanosecond") +"""The fields of a fraction of a second, which make the whole fraction where any is given: see `Temporals.over`.""" + _TIMES = (Temporal.TIME, Temporal.LOCAL_TIME) """The kinds of a time of day, which no months are between.""" + +@dataclass(frozen=True, eq=False) +class TemporalBase: + """ + A temporal value that a map's `date`, `time` or `datetime` gives `Temporals.over`: its `text`, and the `kinds` it + may be, one where the statement tells its kind, else those of the kind that give what it is to give (a date, a time + of day, both), its text telling which, as a value read from the store does. + """ + + text: str + kinds: tuple[Temporal, ...] + + _Body = Callable[[Temporal, str], str] """What a value of a kind is, of the kind and the value's text.""" @@ -283,21 +305,218 @@ def dated(d: list[str]) -> str: "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) + return self._dated(fields, time, timezone, kind) + + return self.shared([date_], dated) + + def _dated(self, date_: dict[str, str], time: dict[str, str], timezone: str | None, kind: Temporal) -> str: + """ + `of` the date of the fields `date_` (`year`, `month`, `day`) and the fields of the time `time`, the date read as + a date first where the kind has a time too, so a store fails on a day its month has none of: see + `_calendar_date`. + """ + if kind is Temporal.DATE: + return self.of(date_, None, kind) + return self.shared( + [self.sql.local(self.of(date_, None, Temporal.DATE), Temporal.DATE)], + lambda c: self.of({**time, **self._date_of(c[0])}, timezone, 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) + @staticmethod + def _date_of(timestamp: str) -> dict[str, str]: + """The year, the month and the day of the month of the timestamp, as `of` takes them.""" + return { + "year": f"EXTRACT(YEAR FROM {timestamp})", + "month": f"EXTRACT(MONTH FROM {timestamp})", + "day": f"EXTRACT(DAY FROM {timestamp})", + } - return self.shared([self.sql.local(self.of(fields, None, Temporal.DATE), Temporal.DATE)], timed) + # ----- Values made from another ----- - return self.shared([date_], dated) + def over( + self, + date_: TemporalBase | None, + time: TemporalBase | None, + fields: dict[str, str], + timezone: str | None, + kind: Temporal, + ) -> str: + """ + The text of the kind made of the value `date_` gives its date and the value `time` gives its time of day (one + value where a map's `datetime` gives both), with the fields `fields` laid over theirs, SQL numbers each by + Cypher's names, and the offset's text `timezone`, as Neo4j lays them: + + - A `week` is of the base's week-based year or the `year` given, and keeps its day of the week unless one is + given; an `ordinalDay` is of its year or the one given; a `dayOfQuarter` of its quarter or the one given. + Each is worked out as `of_calendar` works it out. + - Else a `year`, a `month` and a `day` given make a date with the base's others, which fails where it is none + (February 29th of another year); then a `quarter` alone moves it so many quarters, to the month's last day + where it has fewer, and a `dayOfWeek` alone to that day of its week. Beside a `dayOfWeek` the `year` moves + the date so many years, to the month's last day where it has fewer, and doesn't fail. + - A millisecond, a microsecond or a nanosecond given makes the whole fraction of the second. + - A time of day or a moment that gives the time keeps its offset, else UTC's; with a `timezone` it is moved + there, the same moment, where it has an offset, and given that offset where it has none. + + Without `date_`, the fields give the date as a map's fields do; without `time`, the time. Fails as the + statement runs where a value given is null, or of a kind that hasn't what it is to give, or the `timezone` is + null or no offset, as Neo4j fails. As drivine4j's. + """ + bases = [date_] if date_ is not None and date_ is time else [it for it in (date_, time) if it is not None] + + def index(base: TemporalBase) -> int: + return next(i for i, it in enumerate(bases) if it is base) + + def of_texts(texts: list[str]) -> str: + def of_clocks(bound: list[str]) -> str: + day = bound[index(date_)] if date_ is not None else None + clock = bound[index(time)] if time is not None else None + own = self._own_offset(texts[index(time)], time.kinds) if time is not None and kind.zoned else None + # Where what gives the time may be local, as its text tells. + open_ = own is not None and time is not None and any(not it.zoned for it in time.kinds) + if not kind.zoned: + made = self._over_fields(day, clock, fields, None, kind) + elif timezone is None: + offset = f"COALESCE({own}, 'Z')" if open_ else own + made = self._over_fields(day, clock, fields, offset, kind) + elif own is None: + made = self._over_fields(day, clock, fields, timezone, kind) + else: + offset = f"COALESCE({own}, {timezone})" if open_ else own + made = self._moved_to(self._over_fields(day, clock, fields, offset, kind), kind, timezone) + + # A value given that is null, or of a kind that hasn't what it is to give, or a time zone an expression + # gives that is null or none, fails as on Neo4j: its date is the day 0, which every store fails on, + # written of the condition so that a store can't fail on it where it isn't reached. + def checked(v: list[str]) -> str: + # The time zone too, which Oracle's `||` would drop where it is null, not make the text null. + given = " AND ".join( + f"{it} IS NOT NULL" for it in [*bound, *([timezone] if timezone is not None else []), v[0]] + ) + none = self.of({"day": f"CASE WHEN {given} THEN 1 ELSE 0 END"}, None, Temporal.DATE) + return f"CASE WHEN {given} THEN {v[0]} ELSE {none} END" + + return self.shared([made], checked) + + return self.shared([self._clock(texts[i], it.kinds) for i, it in enumerate(bases)], of_clocks) + + return self.shared([it.text for it in bases], of_texts) + + def _own_offset(self, text: str, kinds: tuple[Temporal, ...]) -> str | None: + """ + The offset of the text of one of `kinds`, which gives a value made `over` its time: None where none of them has + one, and null where its text tells it is local. + """ + zoned = [it for it in kinds if it.zoned] + if not zoned: + return None + if len(zoned) == len(kinds): + return self.offset(text) + return f"CASE WHEN {' OR '.join(self.is_kind(text, it) for it in zoned)} THEN {self.offset(text)} END" + + def _clock(self, text: str, kinds: tuple[Temporal, ...]) -> str: + """The wall clock the text reads, of the one of `kinds` it is: null where it is none.""" + if len(kinds) == 1: + return self.sql.local(text, kinds[0]) + whens = " ".join( + f"WHEN {self.is_kind(text, it)} THEN {self.sql.local(text, it)}" for it in INSTANTS if it in kinds + ) + return f"CASE {whens} END" + + def _over_fields( + self, day: str | None, clock: str | None, fields: dict[str, str], offset: str | None, kind: Temporal + ) -> str: + """`over`'s value of the wall clocks `day` and `clock` (None where none is given), laid over by `fields`, with + the offset.""" + time = self._over_time(clock, fields) + if kind in _TIMES: + return self.of(time, offset, kind) + if day is None: + calendar = {name: value for name, value in fields.items() if name == "year" or name in CALENDAR_FIELDS} + if any(it in CALENDAR_FIELDS for it in calendar): + return self.of_calendar({**calendar, **time}, offset, kind) + return self.of({**{name: value for name, value in fields.items() if name in _DAY}, **time}, offset, kind) + return self._over_day(day, fields, time, offset, kind) + + def _over_time(self, clock: str | None, fields: dict[str, str]) -> dict[str, str]: + """`over`'s fields of the time of the wall clock `clock` (None where none is given), laid over by `fields`.""" + time: dict[str, str] = {} + for name, part in (("hour", "HOUR"), ("minute", "MINUTE")): + value = fields.get(name, f"EXTRACT({part} FROM {clock})" if clock is not None else None) + if value is not None: + time[name] = value + second = fields.get("second", f"TRUNC(EXTRACT(SECOND FROM {clock}))" if clock is not None else None) + if second is not None: + time["second"] = second + if any(it in fields for it in _FRACTION): + time.update({name: value for name, value in fields.items() if name in _FRACTION}) + elif clock is not None: + time["microsecond"] = self.sql.number(self.sql.micros(clock)) + return time + + def _over_day( + self, day: str, fields: dict[str, str], time: dict[str, str], offset: str | None, kind: Temporal + ) -> str: + """`over`'s value of the wall clock `day` that gives the date, laid over by `fields`, and the time `time`.""" + + def own(name: str) -> str: + return fields[name] if name in fields else self._clock_field(day, name) + + if "week" in fields: + year = fields["year"] if "year" in fields else self._clock_field(day, "weekYear") + week = {"year": year, "week": own("week"), "dayOfWeek": own("dayOfWeek")} + return self.of_calendar({**week, **time}, offset, kind) + if "ordinalDay" in fields: + return self.of_calendar({"year": own("year"), "ordinalDay": own("ordinalDay"), **time}, offset, kind) + if "dayOfQuarter" in fields: + quarter = {"year": own("year"), "quarter": own("quarter"), "dayOfQuarter": own("dayOfQuarter")} + return self.of_calendar({**quarter, **time}, offset, kind) + if not any(it in fields for it in _DAY): + return self._move_day(day, fields, time, offset, kind) + if "dayOfWeek" in fields: + # Beside a day of the week (no month or day can be), a year moves the date so many years, to February 28th + # from the 29th of a common year, as Neo4j moves it, where alone it fails there. + years = f"({own('year')} - {self._clock_field(day, 'year')}) * 12" + return self._move_day(self.sql.add_months(day, years), fields, time, offset, kind) + date_ = {it: own(it) for it in _DAY} + return self.shared( + [self.sql.local(self.of(date_, None, Temporal.DATE), Temporal.DATE)], + lambda c: self._move_day(c[0], fields, time, offset, kind), + ) + + def _move_day( + self, day: str, fields: dict[str, str], time: dict[str, str], offset: str | None, kind: Temporal + ) -> str: + """`over`'s value of the date `day`, moved by a `quarter` or a `dayOfWeek` among `fields` where one is.""" + + def whole(value: str, last: int) -> str: + return f"{value} = TRUNC({value}) AND {value} BETWEEN 1 AND {last}" + + if "quarter" in fields: + + def by_quarter(q: list[str]) -> str: + moved = self.sql.add_months(day, f"({q[0]} - {self._clock_field(day, 'quarter')}) * 3") + return self._calendar_date(moved, time, offset, kind, lambda _: whole(q[0], 4)) + + return self.shared([fields["quarter"]], by_quarter) + if "dayOfWeek" in fields: + + def by_day_of_week(d: list[str]) -> str: + moved = self.sql.add_time(day, f"{d[0]} - {self.sql.day_of_week(day)}", "0") + return self._calendar_date(moved, time, offset, kind, lambda _: whole(d[0], 7)) + + return self.shared([fields["dayOfWeek"]], by_day_of_week) + return self.of({**self._date_of(day), **time}, offset, kind) + + def _moved_to(self, text: str, kind: Temporal, timezone: str) -> str: + """The text of the kind, zoned, the same moment as the text of it at the offset's text `timezone`.""" + return self.shared( + [text, timezone], + lambda v: self.format( + self.sql.add_time(self.sql.utc(v[0], kind), "0", self._zone_field(v[1], "offsetSeconds").sql), + kind, + self.offset(v[1]), + ), + ) def field(self, text: str, kind: Temporal, name: str) -> Field | None: """ diff --git a/tests/cypher/cypher_spec.py b/tests/cypher/cypher_spec.py index 6ccdc9d..63eee7a 100644 --- a/tests/cypher/cypher_spec.py +++ b/tests/cypher/cypher_spec.py @@ -200,16 +200,6 @@ def _steps(first: int, last: int) -> tuple[int, ...]: "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 diff --git a/tests/unit/test_cypher_refusals.py b/tests/unit/test_cypher_refusals.py index 8f44786..2dda83c 100644 --- a/tests/unit/test_cypher_refusals.py +++ b/tests/unit/test_cypher_refusals.py @@ -4,6 +4,7 @@ tests/cypher/refusals.yaml. """ +from datetime import datetime from decimal import Decimal from typing import Any @@ -649,10 +650,6 @@ def test_a_truncations_fields_given_as_an_expression_a_negated_number_utc_or_non "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.", @@ -686,6 +683,80 @@ def test_a_temporal_map_of_the_calendars_fields_that_runs_on_or_that_an_expressi compiler("g").compile(cypher, {"y": 2023}) +_NAMED_ZONE = "The time zone 'Europe/Stockholm' is Cypher, and is not compiled to SQL yet." + + +@pytest.mark.parametrize("compiler", COMPILERS.values(), ids=COMPILERS.keys()) +@pytest.mark.parametrize( + ("cypher", "message"), + [ + # A zone by name in the constructor of the value given, which Postgres and Oracle gave as null. + ( + "WITH datetime({year: 1984, month: 10, day: 11, hour: 12, timezone: 'Europe/Stockholm'}) AS other " + "RETURN time({time: other})", + _NAMED_ZONE, + ), + ("RETURN datetime({year: 1984, month: 10, day: 11, timezone: 'Europe/Stockholm'})", _NAMED_ZONE), + ( + "RETURN datetime({datetime: datetime('2024-03-15T10:00Z'), timezone: 'UTC'})", + _NAMED_ZONE.replace("Europe/Stockholm", "UTC"), + ), + ( + "MATCH (n) RETURN date({date: n})", + "date() takes a date, a localdatetime or a datetime for date, not a node, a relationship or a path.", + ), + ( + "RETURN date({date: 1 = 1})", + "date() takes a date, a localdatetime or a datetime for date, not a truth value.", + ), + ( + "RETURN time({time: duration('PT1H')})", + "time() takes a localtime, a time, a localdatetime or a datetime for time, not a duration.", + ), + ( + "RETURN localtime({time: localtime('10:00'), microsecond: 6, nanosecond: 1000})", + "localtime() takes a nanosecond from 0 to 999 beside a microsecond, not 1000.", + ), + ( + "RETURN localtime({time: localtime('10:00'), millisecond: 5, nanosecond: 1000000})", + "localtime() takes a nanosecond from 0 to 999999 beside a millisecond, not 1000000.", + ), + # -1 is written as much as 1; and the fields of a whole date written beside a value that gives the time are + # worked out as a map of them is, past Java's years refused. + ("RETURN date({date: date('2024-03-15'), month: -(2)})", "date() takes a month from 1 to 12, not -2."), + ( + "RETURN localdatetime({year: 4294969319, week: 1, dayOfWeek: 1, time: localtime('10:00')})", + "localdatetime() takes no year 4294969319: Invalid value for Year (valid values -999999999 - 999999999): " + "4294969319", + ), + ], +) +def test_a_temporal_map_from_another_temporal_value_past_what_the_spec_holds_is_refused_as_neo4j_refuses_it( + compiler: type[SqlCypherCompiler], cypher: str, message: str +) -> None: + # #317, drivine4j's #508: the spec's "Temporal map from another temporal value Neo4j does not take" holds the rest. + 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_from_another_temporal_value_that_runs_on_or_that_an_expression_gives_is_compiled( + compiler: type[SqlCypherCompiler], +) -> None: + # #317, drivine4j's #508: a day of the week or of the quarter alone, beside a value that gives the date, and a + # number, a value or a time zone an expression or a parameter gives, are the store's to check. + for cypher in ( + "RETURN date({date: date('2024-03-15'), dayOfWeek: 2}), date({date: date('2024-03-15'), dayOfQuarter: 92}), " + "date({date: date('2024-03-15'), ordinalDay: 366})", + "WITH 13 AS m RETURN date({date: date('2024-03-15'), month: m}), localtime({time: $t, hour: 3}), " + "datetime({datetime: $t, timezone: $z})", + "RETURN date({date: date('2024-03-15'), month: 2 - 4}), " + "localtime({time: localtime('10:00'), microsecond: 6, nanosecond: 5})", + ): + compiler("g").compile(cypher, {"t": datetime(2024, 3, 15, 10, 0), "z": "+01:00"}) + + def _in_where(aggregate: str) -> str: return ( f"{aggregate} aggregates, and a WHERE holds no aggregate unless its WITH aggregates, as in Neo4j: aggregate in " From 9b4dcb1df055bda8067eb4e6aad8b039f3fe3fe3 Mon Sep 17 00:00:00 2001 From: jasper blues Date: Sun, 1 Nov 2026 08:00:34 +1000 Subject: [PATCH 2/2] Share the number field check of a truncation and a temporal from another, and test calendar fields beside a time and Python temporal parameters --- drivine/cypher/sql/sql_cypher_compiler.py | 41 ++++++++++--------- drivine/cypher/sql/temporals.py | 3 +- tests/integration/test_cypher_compiled.py | 37 +++++++++++++++++ .../sync_integration/test_cypher_compiled.py | 37 +++++++++++++++++ 4 files changed, 98 insertions(+), 20 deletions(-) diff --git a/drivine/cypher/sql/sql_cypher_compiler.py b/drivine/cypher/sql/sql_cypher_compiler.py index 54041c0..67b6ed8 100644 --- a/drivine/cypher/sql/sql_cypher_compiler.py +++ b/drivine/cypher/sql/sql_cypher_compiler.py @@ -4720,13 +4720,30 @@ def _over_field(self, name: str, key: str, value: Expression, keys: AbstractSet[ written that is not whole, or past its field's range (OVER_RANGES; beside a millisecond or a microsecond given, a microsecond or a nanosecond runs to the next one's), fails. `keys` are the map's fields. """ - least = OVER_LEAST.get(key, 0) + range_, beside = _over_range(key, keys) + return self._map_number(name, key, value, OVER_LEAST.get(key, 0), range_, beside, scope) + + def _map_number( + self, + name: str, + key: str, + value: Expression, + least: int, + range_: tuple[int, int] | None, + beside: str | None, + scope: Scope, + ) -> str: + """ + The SQL number of the field `key` of a temporal map given `name` (a truncation's, or one laid over another + temporal value), written as `value`: `least` where it is written as null or an expression gives null, else a + number written that `_whole_field` takes in `range_`, the range it has `beside` another field where one is + named. + """ written = _written_number(value) if written is None: return f"COALESCE({self.number(self.comparable(self.json(self._expression(value, scope))))}, {least})" if written.value is None: return f"{least}" - range_, beside = _over_range(key, keys) return f"{_whole_field(name, key, written.value, range_, beside)}" def _over_base( @@ -6047,13 +6064,10 @@ def _truncation_number(self, function: Function, key: str, value: Expression, mi The SQL number of the field `key` of a truncation of the function, which the value gives: see `_truncation_fields`. With `millis`, a millisecond is given or kept, beside which a microsecond runs to 999. """ + beside = key == "microsecond" and millis + range_ = (0, 999) if beside else TRUNCATION_RANGES.get(key) least = TRUNCATION_LEAST.get(key, 0) - written = _written_number(value) - if written is None: - return f"COALESCE({self.number(self.comparable(self.json(self._expression(value, scope))))}, {least})" - if written.value is None: - return f"{least}" - return f"{_truncation_whole(function, key, written.value, millis)}" + return self._map_number(function.name, key, value, least, range_, "millisecond" if beside else None, scope) def _between(self, function: Function, arguments: list[Value], name: str) -> Value: """`duration.between(a, b)` and `duration.inMonths`, `inDays` and `inSeconds`: the duration from one value to @@ -7373,17 +7387,6 @@ def _truncation_keys(function: Function, kind: Temporal, entries: Sequence[tuple return keys -def _truncation_whole(function: Function, key: str, written: Any, millis: bool) -> int: - """ - The value written as the field `key` of a truncation of the function, where it is a whole number in the field's - range (see TRUNCATION_RANGES); with `millis`, a millisecond is given or kept, beside which a microsecond runs to - 999. - """ - beside = key == "microsecond" and millis - range_ = (0, 999) if beside else TRUNCATION_RANGES.get(key) - return _whole_field(function.name, key, written, range_, "millisecond" if beside else None) - - def _whole_field(name: str, key: str, written: Any, range_: tuple[int, int] | None, beside: str | None) -> int: """ The value written as the field `key` of a temporal map given `name`, where it is a whole number in `range_`, the diff --git a/drivine/cypher/sql/temporals.py b/drivine/cypher/sql/temporals.py index f0ca0bc..3096124 100644 --- a/drivine/cypher/sql/temporals.py +++ b/drivine/cypher/sql/temporals.py @@ -86,7 +86,8 @@ class TemporalBase: """ A temporal value that a map's `date`, `time` or `datetime` gives `Temporals.over`: its `text`, and the `kinds` it may be, one where the statement tells its kind, else those of the kind that give what it is to give (a date, a time - of day, both), its text telling which, as a value read from the store does. + of day, both), its text telling which, as a value read from the store does. Told apart by identity (`eq=False`), as + drivine4j's `Base` is, so `over` binds the one a map's `datetime` gives as both date and time once. """ text: str diff --git a/tests/integration/test_cypher_compiled.py b/tests/integration/test_cypher_compiled.py index 2d9162f..c0bd575 100644 --- a/tests/integration/test_cypher_compiled.py +++ b/tests/integration/test_cypher_compiled.py @@ -195,6 +195,24 @@ async def test_a_parameter_of_a_time_a_local_or_zoned_datetime_or_a_timedelta_is assert found == {"minute": 30, "hour": 10, "zoned": "+02:00", "later": 16, "days": 1, "hours": 12} +async def test_a_temporal_value_is_made_from_a_parameter_of_pythons_temporal_types(cypher_pm: PersistenceManager): + # #317: a parameter's kind is told by its Python type, so `Temporals.over` reads it as that kind alone. + found = await cypher_pm.get_one( + spec( + "WITH date({date: $day, day: 1}) AS d, localtime({time: $z, hour: 3}) AS l, " + "datetime({datetime: $ldt, timezone: '+01:00'}) AS m, datetime({date: $day, time: $time}) AS t " + "RETURN [[d.month, d.day], [l.hour, l.minute], [m.day, m.hour, m.timezone], [t.day, t.minute, t.timezone]]", + { + "day": date(2024, 3, 15), + "time": time(10, 30), + "ldt": datetime(2024, 3, 15, 10, 0), + "z": datetime(2024, 3, 15, 10, 5, tzinfo=timezone(timedelta(hours=2))), + }, + ) + ) + assert found == [[3, 1], [3, 5], [15, 10, "+01:00"], [15, 30, "Z"]] + + async def test_a_zoned_parameter_keeps_its_kind_in_reduce(cypher_pm: PersistenceManager): found = await cypher_pm.query( spec( @@ -1271,6 +1289,25 @@ async def test_a_date_or_a_moment_from_calendar_fields_an_expression_gives_is_ne ] +async def test_a_moment_from_calendar_fields_an_expression_gives_beside_a_time_is_worked_out_by_the_store( + cypher_pm: PersistenceManager, +): + # #317: a week, an ordinal day or a quarter that an expression gives can't be worked out as the statement is + # compiled, so `Temporals.over` gives it the store's `of_calendar`, with the time of the value given. + rows = await cypher_pm.query( + spec( + "UNWIND [[2020, 53, 7, 2024, 60, 4]] AS p " + "WITH localdatetime({year: p[0], week: p[1], dayOfWeek: p[2], time: localtime('10:30')}) AS w, " + "datetime({year: p[3], ordinalDay: p[4], time: time('10:00+02:00')}) AS o, " + "datetime({year: 2024, quarter: p[5], dayOfQuarter: 92, time: localtime('01:00'), timezone: '-05:00'}) " + "AS q " + "RETURN [[w.year, w.month, w.day, w.hour, w.minute], [o.year, o.month, o.day, o.hour, o.timezone], " + "[q.year, q.month, q.day, q.hour, q.timezone]]" + ) + ) + assert rows == [[[2021, 1, 3, 10, 30], [2024, 2, 29, 10, "+02:00"], [2024, 12, 31, 1, "-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/sync_integration/test_cypher_compiled.py b/tests/sync_integration/test_cypher_compiled.py index 64e4c59..4292b0e 100644 --- a/tests/sync_integration/test_cypher_compiled.py +++ b/tests/sync_integration/test_cypher_compiled.py @@ -197,6 +197,24 @@ def test_a_parameter_of_a_time_a_local_or_zoned_datetime_or_a_timedelta_is_a_tem assert found == {"minute": 30, "hour": 10, "zoned": "+02:00", "later": 16, "days": 1, "hours": 12} +def test_a_temporal_value_is_made_from_a_parameter_of_pythons_temporal_types(cypher_pm: PersistenceManager): + # #317: a parameter's kind is told by its Python type, so `Temporals.over` reads it as that kind alone. + found = cypher_pm.get_one( + spec( + "WITH date({date: $day, day: 1}) AS d, localtime({time: $z, hour: 3}) AS l, " + "datetime({datetime: $ldt, timezone: '+01:00'}) AS m, datetime({date: $day, time: $time}) AS t " + "RETURN [[d.month, d.day], [l.hour, l.minute], [m.day, m.hour, m.timezone], [t.day, t.minute, t.timezone]]", + { + "day": date(2024, 3, 15), + "time": time(10, 30), + "ldt": datetime(2024, 3, 15, 10, 0), + "z": datetime(2024, 3, 15, 10, 5, tzinfo=timezone(timedelta(hours=2))), + }, + ) + ) + assert found == [[3, 1], [3, 5], [15, 10, "+01:00"], [15, 30, "Z"]] + + def test_a_zoned_parameter_keeps_its_kind_in_reduce(cypher_pm: PersistenceManager): found = cypher_pm.query( spec( @@ -1265,6 +1283,25 @@ def test_a_date_or_a_moment_from_calendar_fields_an_expression_gives_is_neo4js_a ] +def test_a_moment_from_calendar_fields_an_expression_gives_beside_a_time_is_worked_out_by_the_store( + cypher_pm: PersistenceManager, +): + # #317: a week, an ordinal day or a quarter that an expression gives can't be worked out as the statement is + # compiled, so `Temporals.over` gives it the store's `of_calendar`, with the time of the value given. + rows = cypher_pm.query( + spec( + "UNWIND [[2020, 53, 7, 2024, 60, 4]] AS p " + "WITH localdatetime({year: p[0], week: p[1], dayOfWeek: p[2], time: localtime('10:30')}) AS w, " + "datetime({year: p[3], ordinalDay: p[4], time: time('10:00+02:00')}) AS o, " + "datetime({year: 2024, quarter: p[5], dayOfQuarter: 92, time: localtime('01:00'), timezone: '-05:00'}) " + "AS q " + "RETURN [[w.year, w.month, w.day, w.hour, w.minute], [o.year, o.month, o.day, o.hour, o.timezone], " + "[q.year, q.month, q.day, q.hour, q.timezone]]" + ) + ) + assert rows == [[[2021, 1, 3, 10, 30], [2024, 2, 29, 10, "+02:00"], [2024, 12, 31, 1, "-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, ):