diff --git a/flowquery-py/src/parsing/data_structures/lookup.py b/flowquery-py/src/parsing/data_structures/lookup.py index 957dfd8..64b47c5 100644 --- a/flowquery-py/src/parsing/data_structures/lookup.py +++ b/flowquery-py/src/parsing/data_structures/lookup.py @@ -38,6 +38,8 @@ def is_operand(self) -> bool: def value(self) -> Any: obj = self.variable.value() + if obj is None: + return None key = self.index.value() # Try dict-like access first, then fall back to attribute access for objects try: diff --git a/flowquery-py/src/parsing/functions/__init__.py b/flowquery-py/src/parsing/functions/__init__.py index 99ad1c3..608683d 100644 --- a/flowquery-py/src/parsing/functions/__init__.py +++ b/flowquery-py/src/parsing/functions/__init__.py @@ -3,8 +3,13 @@ from .aggregate_function import AggregateFunction from .async_function import AsyncFunction from .avg import Avg +from .coalesce import Coalesce from .collect import Collect from .count import Count +from .date_ import DateFunction +from .datetime_ import Datetime +from .duration import Duration +from .element_id import ElementId from .function import Function from .function_factory import FunctionFactory from .function_metadata import ( @@ -19,13 +24,23 @@ get_registered_function_metadata, ) from .functions import Functions +from .head import Head +from .id_ import Id from .join import Join from .keys import Keys +from .last import Last +from .localdatetime import LocalDatetime +from .localtime import LocalTime +from .max_ import Max +from .min_ import Min +from .nodes import Nodes from .predicate_function import PredicateFunction from .predicate_sum import PredicateSum +from .properties import Properties from .rand import Rand from .range_ import Range from .reducer_element import ReducerElement +from .relationships import Relationships from .replace import Replace from .round_ import Round from .schema import Schema @@ -33,9 +48,12 @@ from .split import Split from .string_distance import StringDistance from .stringify import Stringify - -# Built-in functions from .sum import Sum +from .tail import Tail +from .time_ import Time +from .timestamp import Timestamp +from .to_float import ToFloat +from .to_integer import ToInteger from .to_json import ToJson from .to_lower import ToLower from .to_string import ToString @@ -64,10 +82,23 @@ # Built-in functions "Sum", "Avg", + "DateFunction", + "Datetime", + "Coalesce", "Collect", "Count", + "Duration", + "ElementId", + "Head", + "Id", "Join", + "Last", "Keys", + "Max", + "Min", + "Nodes", + "Properties", + "Relationships", "Rand", "Range", "Replace", @@ -76,11 +107,18 @@ "Split", "StringDistance", "Stringify", + "Tail", + "Time", + "Timestamp", + "ToFloat", + "ToInteger", "ToJson", "ToLower", "ToString", "Trim", "Type", + "LocalDatetime", + "LocalTime", "Functions", "Schema", "PredicateSum", diff --git a/flowquery-py/src/parsing/functions/coalesce.py b/flowquery-py/src/parsing/functions/coalesce.py new file mode 100644 index 0000000..650c0db --- /dev/null +++ b/flowquery-py/src/parsing/functions/coalesce.py @@ -0,0 +1,44 @@ +"""Coalesce function.""" + +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef + + +@FunctionDef({ + "description": "Returns the first non-null value from a list of expressions", + "category": "scalar", + "parameters": [ + {"name": "expressions", "description": "Two or more expressions to evaluate", "type": "any"} + ], + "output": {"description": "The first non-null value, or null if all values are null", "type": "any"}, + "examples": [ + "RETURN coalesce(null, 'hello', 'world')", + "MATCH (n) RETURN coalesce(n.nickname, n.name) AS displayName" + ] +}) +class Coalesce(Function): + """Coalesce function. + + Returns the first non-null value from a list of expressions. + Equivalent to Neo4j's coalesce() function. + """ + + def __init__(self) -> None: + super().__init__("coalesce") + self._expected_parameter_count = None # variable number of parameters + + def value(self) -> Any: + children = self.get_children() + if len(children) == 0: + raise ValueError("coalesce() requires at least one argument") + for child in children: + try: + val = child.value() + except (KeyError, AttributeError): + # Treat missing properties/keys as null, matching Neo4j behavior + val = None + if val is not None: + return val + return None diff --git a/flowquery-py/src/parsing/functions/date_.py b/flowquery-py/src/parsing/functions/date_.py new file mode 100644 index 0000000..8954cec --- /dev/null +++ b/flowquery-py/src/parsing/functions/date_.py @@ -0,0 +1,63 @@ +"""Date function.""" + +from datetime import datetime +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef +from .temporal_utils import build_date_object, parse_temporal_arg + + +@FunctionDef({ + "description": ( + "Returns a date value. With no arguments returns the current date. " + "Accepts an ISO 8601 date string or a map of components (year, month, day)." + ), + "category": "scalar", + "parameters": [ + { + "name": "input", + "description": "Optional. An ISO 8601 date string (YYYY-MM-DD) or a map of components.", + "type": "string", + "required": False, + }, + ], + "output": { + "description": ( + "A date object with properties: year, month, day, " + "epochMillis, dayOfWeek, dayOfYear, quarter, formatted" + ), + "type": "object", + }, + "examples": [ + "RETURN date() AS today", + "RETURN date('2025-06-15') AS d", + "RETURN date({year: 2025, month: 6, day: 15}) AS d", + "WITH date() AS d RETURN d.year, d.month, d.dayOfWeek", + ], +}) +class DateFunction(Function): + """Date function. + + Returns a date value (no time component). + When called with no arguments, returns the current date. + When called with a string argument, parses it as an ISO 8601 date. + + Equivalent to Neo4j's date() function. + """ + + def __init__(self) -> None: + super().__init__("date") + self._expected_parameter_count = None + + def value(self) -> Any: + children = self.get_children() + if len(children) > 1: + raise ValueError("date() accepts at most one argument") + + if len(children) == 1: + d = parse_temporal_arg(children[0].value(), "date") + else: + d = datetime.now() + + return build_date_object(d) diff --git a/flowquery-py/src/parsing/functions/datetime_.py b/flowquery-py/src/parsing/functions/datetime_.py new file mode 100644 index 0000000..bbbe1ab --- /dev/null +++ b/flowquery-py/src/parsing/functions/datetime_.py @@ -0,0 +1,64 @@ +"""Datetime function.""" + +from datetime import datetime, timezone +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef +from .temporal_utils import build_datetime_object, parse_temporal_arg + + +@FunctionDef({ + "description": ( + "Returns a datetime value. With no arguments returns the current UTC datetime. " + "Accepts an ISO 8601 string or a map of components (year, month, day, hour, minute, second, millisecond)." + ), + "category": "scalar", + "parameters": [ + { + "name": "input", + "description": "Optional. An ISO 8601 datetime string or a map of components.", + "type": "string", + "required": False, + }, + ], + "output": { + "description": ( + "A datetime object with properties: year, month, day, hour, minute, second, millisecond, " + "epochMillis, epochSeconds, dayOfWeek, dayOfYear, quarter, formatted" + ), + "type": "object", + }, + "examples": [ + "RETURN datetime() AS now", + "RETURN datetime('2025-06-15T12:30:00Z') AS dt", + "RETURN datetime({year: 2025, month: 6, day: 15, hour: 12}) AS dt", + "WITH datetime() AS dt RETURN dt.year, dt.month, dt.day", + ], +}) +class Datetime(Function): + """Datetime function. + + Returns a datetime value (date + time + timezone offset). + When called with no arguments, returns the current UTC datetime. + When called with a string argument, parses it as an ISO 8601 datetime. + When called with a map argument, constructs a datetime from components. + + Equivalent to Neo4j's datetime() function. + """ + + def __init__(self) -> None: + super().__init__("datetime") + self._expected_parameter_count = None + + def value(self) -> Any: + children = self.get_children() + if len(children) > 1: + raise ValueError("datetime() accepts at most one argument") + + if len(children) == 1: + d = parse_temporal_arg(children[0].value(), "datetime") + else: + d = datetime.now(timezone.utc) + + return build_datetime_object(d, utc=True) diff --git a/flowquery-py/src/parsing/functions/duration.py b/flowquery-py/src/parsing/functions/duration.py new file mode 100644 index 0000000..45de506 --- /dev/null +++ b/flowquery-py/src/parsing/functions/duration.py @@ -0,0 +1,159 @@ +"""Duration function.""" + +import re +from typing import Any, Dict + +from .function import Function +from .function_metadata import FunctionDef + +ISO_DURATION_REGEX = re.compile( + r"^P(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?" + r"(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?" + r"(?:(\d+(?:\.\d+)?)S)?)?$" +) + + +def _parse_duration_string(s: str) -> Dict[str, float]: + """Parse an ISO 8601 duration string into components.""" + match = ISO_DURATION_REGEX.match(s) + if not match: + raise ValueError(f"duration(): Invalid ISO 8601 duration string: '{s}'") + return { + "years": float(match.group(1)) if match.group(1) else 0, + "months": float(match.group(2)) if match.group(2) else 0, + "weeks": float(match.group(3)) if match.group(3) else 0, + "days": float(match.group(4)) if match.group(4) else 0, + "hours": float(match.group(5)) if match.group(5) else 0, + "minutes": float(match.group(6)) if match.group(6) else 0, + "seconds": float(match.group(7)) if match.group(7) else 0, + } + + +def _build_duration_object(components: Dict[str, Any]) -> Dict[str, Any]: + """Build a duration result object from components.""" + years = components.get("years", 0) or 0 + months = components.get("months", 0) or 0 + weeks = components.get("weeks", 0) or 0 + days = components.get("days", 0) or 0 + hours = components.get("hours", 0) or 0 + minutes = components.get("minutes", 0) or 0 + raw_seconds = components.get("seconds", 0) or 0 + seconds = int(raw_seconds) + fractional_seconds = raw_seconds - seconds + + if "milliseconds" in components and components["milliseconds"]: + milliseconds = int(components["milliseconds"]) + else: + milliseconds = round(fractional_seconds * 1000) + + if "nanoseconds" in components and components["nanoseconds"]: + nanoseconds = int(components["nanoseconds"]) + else: + nanoseconds = round(fractional_seconds * 1_000_000_000) % 1_000_000 + + # Total days including weeks + total_days = int(days + weeks * 7) + + # Total seconds for the time portion + total_seconds = int(hours * 3600 + minutes * 60 + seconds) + + # Total months + total_months = int(years * 12 + months) + + # Build ISO 8601 formatted string + formatted = "P" + if years: + formatted += f"{int(years)}Y" + if months: + formatted += f"{int(months)}M" + if weeks: + formatted += f"{int(weeks)}W" + raw_days = int(total_days - weeks * 7) + if raw_days: + formatted += f"{raw_days}D" + has_time = hours or minutes or seconds or milliseconds + if has_time: + formatted += "T" + if hours: + formatted += f"{int(hours)}H" + if minutes: + formatted += f"{int(minutes)}M" + if seconds or milliseconds: + if milliseconds: + formatted += f"{seconds}.{milliseconds:03d}S" + else: + formatted += f"{seconds}S" + if formatted == "P": + formatted = "PT0S" + + return { + "years": int(years), + "months": int(months), + "weeks": int(weeks), + "days": total_days, + "hours": int(hours), + "minutes": int(minutes), + "seconds": seconds, + "milliseconds": milliseconds, + "nanoseconds": nanoseconds, + "totalMonths": total_months, + "totalDays": total_days, + "totalSeconds": total_seconds, + "formatted": formatted, + } + + +@FunctionDef({ + "description": ( + "Creates a duration value representing a span of time. " + "Accepts an ISO 8601 duration string (e.g., 'P1Y2M3DT4H5M6S') or a map of components " + "(years, months, weeks, days, hours, minutes, seconds, milliseconds, nanoseconds)." + ), + "category": "scalar", + "parameters": [ + { + "name": "input", + "description": ( + "An ISO 8601 duration string or a map of components " + "(years, months, weeks, days, hours, minutes, seconds, milliseconds, nanoseconds)" + ), + "type": "any", + } + ], + "output": { + "description": ( + "A duration object with properties: years, months, weeks, days, hours, minutes, seconds, " + "milliseconds, nanoseconds, totalMonths, totalDays, totalSeconds, formatted" + ), + "type": "object", + }, + "examples": [ + "RETURN duration('P1Y2M3D') AS d", + "RETURN duration('PT2H30M') AS d", + "RETURN duration({days: 14, hours: 16}) AS d", + "RETURN duration({months: 5, days: 1, hours: 12}) AS d", + ], +}) +class Duration(Function): + """Duration function. + + Creates a duration value representing a span of time. + """ + + def __init__(self) -> None: + super().__init__("duration") + self._expected_parameter_count = 1 + + def value(self) -> Any: + arg = self.get_children()[0].value() + if arg is None: + return None + + if isinstance(arg, str): + components = _parse_duration_string(arg) + return _build_duration_object(components) + + if isinstance(arg, dict): + return _build_duration_object(arg) + + raise ValueError("duration() expects a string or map argument") diff --git a/flowquery-py/src/parsing/functions/element_id.py b/flowquery-py/src/parsing/functions/element_id.py new file mode 100644 index 0000000..8962228 --- /dev/null +++ b/flowquery-py/src/parsing/functions/element_id.py @@ -0,0 +1,50 @@ +"""ElementId function.""" + +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef + + +@FunctionDef({ + "description": ( + "Returns the element id of a node or relationship as a string. " + "For nodes, returns the string representation of the id property. " + "For relationships, returns the type." + ), + "category": "scalar", + "parameters": [ + {"name": "entity", "description": "A node or relationship to get the element id from", "type": "object"} + ], + "output": {"description": "The element id of the entity as a string", "type": "string", "example": "\"1\""}, + "examples": [ + "MATCH (n:Person) RETURN elementId(n)", + "MATCH (a)-[r]->(b) RETURN elementId(r)" + ] +}) +class ElementId(Function): + """ElementId function. + + Returns the element id of a node or relationship as a string. + """ + + def __init__(self) -> None: + super().__init__("elementid") + self._expected_parameter_count = 1 + + def value(self) -> Any: + obj = self.get_children()[0].value() + if obj is None: + return None + if not isinstance(obj, dict): + raise ValueError("elementId() expects a node or relationship") + + # If it's a RelationshipMatchRecord (has type, startNode, endNode, properties) + if all(k in obj for k in ("type", "startNode", "endNode", "properties")): + return str(obj["type"]) + + # If it's a node record (has id field) + if "id" in obj: + return str(obj["id"]) + + raise ValueError("elementId() expects a node or relationship") diff --git a/flowquery-py/src/parsing/functions/head.py b/flowquery-py/src/parsing/functions/head.py new file mode 100644 index 0000000..899b967 --- /dev/null +++ b/flowquery-py/src/parsing/functions/head.py @@ -0,0 +1,39 @@ +"""Head function.""" + +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef + + +@FunctionDef({ + "description": "Returns the first element of a list", + "category": "scalar", + "parameters": [ + {"name": "list", "description": "The list to get the first element from", "type": "array"} + ], + "output": {"description": "The first element of the list", "type": "any", "example": "1"}, + "examples": [ + "RETURN head([1, 2, 3])", + "WITH ['a', 'b', 'c'] AS items RETURN head(items)" + ] +}) +class Head(Function): + """Head function. + + Returns the first element of a list. + """ + + def __init__(self) -> None: + super().__init__("head") + self._expected_parameter_count = 1 + + def value(self) -> Any: + val = self.get_children()[0].value() + if val is None: + return None + if not isinstance(val, list): + raise ValueError("head() expects a list") + if len(val) == 0: + return None + return val[0] diff --git a/flowquery-py/src/parsing/functions/id_.py b/flowquery-py/src/parsing/functions/id_.py new file mode 100644 index 0000000..dec4f90 --- /dev/null +++ b/flowquery-py/src/parsing/functions/id_.py @@ -0,0 +1,49 @@ +"""Id function.""" + +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef + + +@FunctionDef({ + "description": ( + "Returns the id of a node or relationship. " + "For nodes, returns the id property. For relationships, returns the type." + ), + "category": "scalar", + "parameters": [ + {"name": "entity", "description": "A node or relationship to get the id from", "type": "object"} + ], + "output": {"description": "The id of the entity", "type": "any", "example": "1"}, + "examples": [ + "MATCH (n:Person) RETURN id(n)", + "MATCH (a)-[r]->(b) RETURN id(r)" + ] +}) +class Id(Function): + """Id function. + + Returns the id of a node or relationship. + """ + + def __init__(self) -> None: + super().__init__("id") + self._expected_parameter_count = 1 + + def value(self) -> Any: + obj = self.get_children()[0].value() + if obj is None: + return None + if not isinstance(obj, dict): + raise ValueError("id() expects a node or relationship") + + # If it's a RelationshipMatchRecord (has type, startNode, endNode, properties) + if all(k in obj for k in ("type", "startNode", "endNode", "properties")): + return obj["type"] + + # If it's a node record (has id field) + if "id" in obj: + return obj["id"] + + raise ValueError("id() expects a node or relationship") diff --git a/flowquery-py/src/parsing/functions/last.py b/flowquery-py/src/parsing/functions/last.py new file mode 100644 index 0000000..681c13d --- /dev/null +++ b/flowquery-py/src/parsing/functions/last.py @@ -0,0 +1,39 @@ +"""Last function.""" + +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef + + +@FunctionDef({ + "description": "Returns the last element of a list", + "category": "scalar", + "parameters": [ + {"name": "list", "description": "The list to get the last element from", "type": "array"} + ], + "output": {"description": "The last element of the list", "type": "any", "example": "3"}, + "examples": [ + "RETURN last([1, 2, 3])", + "WITH ['a', 'b', 'c'] AS items RETURN last(items)" + ] +}) +class Last(Function): + """Last function. + + Returns the last element of a list. + """ + + def __init__(self) -> None: + super().__init__("last") + self._expected_parameter_count = 1 + + def value(self) -> Any: + val = self.get_children()[0].value() + if val is None: + return None + if not isinstance(val, list): + raise ValueError("last() expects a list") + if len(val) == 0: + return None + return val[-1] diff --git a/flowquery-py/src/parsing/functions/localdatetime.py b/flowquery-py/src/parsing/functions/localdatetime.py new file mode 100644 index 0000000..9a860e5 --- /dev/null +++ b/flowquery-py/src/parsing/functions/localdatetime.py @@ -0,0 +1,62 @@ +"""Local datetime function.""" + +from datetime import datetime +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef +from .temporal_utils import build_datetime_object, parse_temporal_arg + + +@FunctionDef({ + "description": ( + "Returns a local datetime value (no timezone). With no arguments returns the current local datetime. " + "Accepts an ISO 8601 string or a map of components." + ), + "category": "scalar", + "parameters": [ + { + "name": "input", + "description": "Optional. An ISO 8601 datetime string or a map of components.", + "type": "string", + "required": False, + }, + ], + "output": { + "description": ( + "A datetime object with properties: year, month, day, hour, minute, second, millisecond, " + "epochMillis, epochSeconds, dayOfWeek, dayOfYear, quarter, formatted" + ), + "type": "object", + }, + "examples": [ + "RETURN localdatetime() AS now", + "RETURN localdatetime('2025-06-15T12:30:00') AS dt", + "WITH localdatetime() AS dt RETURN dt.hour, dt.minute", + ], +}) +class LocalDatetime(Function): + """Local datetime function. + + Returns a local datetime value (date + time, no timezone offset). + When called with no arguments, returns the current local datetime. + When called with a string argument, parses it as an ISO 8601 datetime. + + Equivalent to Neo4j's localdatetime() function. + """ + + def __init__(self) -> None: + super().__init__("localdatetime") + self._expected_parameter_count = None + + def value(self) -> Any: + children = self.get_children() + if len(children) > 1: + raise ValueError("localdatetime() accepts at most one argument") + + if len(children) == 1: + d = parse_temporal_arg(children[0].value(), "localdatetime") + else: + d = datetime.now() + + return build_datetime_object(d, utc=False) diff --git a/flowquery-py/src/parsing/functions/localtime.py b/flowquery-py/src/parsing/functions/localtime.py new file mode 100644 index 0000000..67f8281 --- /dev/null +++ b/flowquery-py/src/parsing/functions/localtime.py @@ -0,0 +1,59 @@ +"""Local time function.""" + +from datetime import datetime +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef +from .temporal_utils import build_time_object, parse_temporal_arg + + +@FunctionDef({ + "description": ( + "Returns a local time value (no timezone). With no arguments returns the current local time. " + "Accepts an ISO 8601 time string or a map of components." + ), + "category": "scalar", + "parameters": [ + { + "name": "input", + "description": "Optional. An ISO 8601 time string (HH:MM:SS) or a map of components.", + "type": "string", + "required": False, + }, + ], + "output": { + "description": "A time object with properties: hour, minute, second, millisecond, formatted", + "type": "object", + }, + "examples": [ + "RETURN localtime() AS now", + "RETURN localtime('14:30:00') AS t", + "WITH localtime() AS t RETURN t.hour, t.minute", + ], +}) +class LocalTime(Function): + """Local time function. + + Returns a local time value (no timezone offset). + When called with no arguments, returns the current local time. + When called with a string argument, parses it. + + Equivalent to Neo4j's localtime() function. + """ + + def __init__(self) -> None: + super().__init__("localtime") + self._expected_parameter_count = None + + def value(self) -> Any: + children = self.get_children() + if len(children) > 1: + raise ValueError("localtime() accepts at most one argument") + + if len(children) == 1: + d = parse_temporal_arg(children[0].value(), "localtime") + else: + d = datetime.now() + + return build_time_object(d, utc=False) diff --git a/flowquery-py/src/parsing/functions/max_.py b/flowquery-py/src/parsing/functions/max_.py new file mode 100644 index 0000000..75f53ca --- /dev/null +++ b/flowquery-py/src/parsing/functions/max_.py @@ -0,0 +1,49 @@ +"""Max aggregate function.""" + +from typing import Any + +from .aggregate_function import AggregateFunction +from .function_metadata import FunctionDef +from .reducer_element import ReducerElement + + +class MaxReducerElement(ReducerElement): + """Reducer element for Max aggregate function.""" + + def __init__(self) -> None: + self._value: Any = None + + @property + def value(self) -> Any: + return self._value + + @value.setter + def value(self, val: Any) -> None: + if self._value is None or val > self._value: + self._value = val + + +@FunctionDef({ + "description": "Returns the maximum value across grouped rows", + "category": "aggregate", + "parameters": [ + {"name": "value", "description": "Value to compare", "type": "number"} + ], + "output": {"description": "Maximum value", "type": "number", "example": 10}, + "examples": ["WITH [3, 1, 2] AS nums UNWIND nums AS n RETURN max(n)"] +}) +class Max(AggregateFunction): + """Max aggregate function. + + Returns the maximum value across grouped rows. + """ + + def __init__(self) -> None: + super().__init__("max") + self._expected_parameter_count = 1 + + def reduce(self, element: MaxReducerElement) -> None: + element.value = self.first_child().value() + + def element(self) -> MaxReducerElement: + return MaxReducerElement() diff --git a/flowquery-py/src/parsing/functions/min_.py b/flowquery-py/src/parsing/functions/min_.py new file mode 100644 index 0000000..66b8216 --- /dev/null +++ b/flowquery-py/src/parsing/functions/min_.py @@ -0,0 +1,49 @@ +"""Min aggregate function.""" + +from typing import Any + +from .aggregate_function import AggregateFunction +from .function_metadata import FunctionDef +from .reducer_element import ReducerElement + + +class MinReducerElement(ReducerElement): + """Reducer element for Min aggregate function.""" + + def __init__(self) -> None: + self._value: Any = None + + @property + def value(self) -> Any: + return self._value + + @value.setter + def value(self, val: Any) -> None: + if self._value is None or val < self._value: + self._value = val + + +@FunctionDef({ + "description": "Returns the minimum value across grouped rows", + "category": "aggregate", + "parameters": [ + {"name": "value", "description": "Value to compare", "type": "number"} + ], + "output": {"description": "Minimum value", "type": "number", "example": 1}, + "examples": ["WITH [3, 1, 2] AS nums UNWIND nums AS n RETURN min(n)"] +}) +class Min(AggregateFunction): + """Min aggregate function. + + Returns the minimum value across grouped rows. + """ + + def __init__(self) -> None: + super().__init__("min") + self._expected_parameter_count = 1 + + def reduce(self, element: MinReducerElement) -> None: + element.value = self.first_child().value() + + def element(self) -> MinReducerElement: + return MinReducerElement() diff --git a/flowquery-py/src/parsing/functions/nodes.py b/flowquery-py/src/parsing/functions/nodes.py new file mode 100644 index 0000000..bc2be47 --- /dev/null +++ b/flowquery-py/src/parsing/functions/nodes.py @@ -0,0 +1,48 @@ +"""Nodes function.""" + +from typing import Any, List + +from .function import Function +from .function_metadata import FunctionDef + + +@FunctionDef({ + "description": "Returns all nodes in a path as an array", + "category": "scalar", + "parameters": [ + {"name": "path", "description": "A path value returned from a graph pattern match", "type": "array"} + ], + "output": { + "description": "Array of node records", "type": "array", + "example": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}] + }, + "examples": ["MATCH p=(:Person)-[:KNOWS]-(:Person) RETURN nodes(p)"] +}) +class Nodes(Function): + """Nodes function. + + Returns all nodes in a path as an array. + """ + + def __init__(self) -> None: + super().__init__("nodes") + self._expected_parameter_count = 1 + + def value(self) -> Any: + path = self.get_children()[0].value() + if path is None: + return [] + if not isinstance(path, list): + raise ValueError("nodes() expects a path (array)") + # A path is an array of alternating node and relationship objects: + # [node, rel, node, rel, node, ...] + # Nodes are plain dicts (have 'id' but not all of 'type'/'startNode'/'endNode'/'properties') + # Relationships are RelationshipMatchRecords (have 'type', 'startNode', 'endNode', 'properties') + result: List[Any] = [] + for element in path: + if element is None or not isinstance(element, dict): + continue + # A RelationshipMatchRecord has type, startNode, endNode, properties + if not all(k in element for k in ("type", "startNode", "endNode", "properties")): + result.append(element) + return result diff --git a/flowquery-py/src/parsing/functions/properties.py b/flowquery-py/src/parsing/functions/properties.py new file mode 100644 index 0000000..cdd7970 --- /dev/null +++ b/flowquery-py/src/parsing/functions/properties.py @@ -0,0 +1,50 @@ +"""Properties function.""" + +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef + + +@FunctionDef({ + "description": ( + "Returns a map containing all the properties of a node, relationship, or map. " + "For nodes and relationships, internal identifiers are excluded." + ), + "category": "scalar", + "parameters": [ + {"name": "entity", "description": "A node, relationship, or map to extract properties from", "type": "object"} + ], + "output": {"description": "Map of properties", "type": "object", "example": {"name": "Alice", "age": 30}}, + "examples": [ + "MATCH (n:Person) RETURN properties(n)", + "WITH { name: 'Alice', age: 30 } AS obj RETURN properties(obj)" + ] +}) +class Properties(Function): + """Properties function. + + Returns a map containing all the properties of a node, relationship, or map. + """ + + def __init__(self) -> None: + super().__init__("properties") + self._expected_parameter_count = 1 + + def value(self) -> Any: + obj = self.get_children()[0].value() + if obj is None: + return None + if not isinstance(obj, dict): + raise ValueError("properties() expects a node, relationship, or map") + + # If it's a RelationshipMatchRecord (has type, startNode, endNode, properties) + if all(k in obj for k in ("type", "startNode", "endNode", "properties")): + return obj["properties"] + + # If it's a node record (has id field), exclude id + if "id" in obj: + return {k: v for k, v in obj.items() if k != "id"} + + # Otherwise, treat as a plain map and return a copy + return dict(obj) diff --git a/flowquery-py/src/parsing/functions/relationships.py b/flowquery-py/src/parsing/functions/relationships.py new file mode 100644 index 0000000..8c35bee --- /dev/null +++ b/flowquery-py/src/parsing/functions/relationships.py @@ -0,0 +1,46 @@ +"""Relationships function.""" + +from typing import Any, List + +from .function import Function +from .function_metadata import FunctionDef + + +@FunctionDef({ + "description": "Returns all relationships in a path as an array", + "category": "scalar", + "parameters": [ + {"name": "path", "description": "A path value returned from a graph pattern match", "type": "array"} + ], + "output": { + "description": "Array of relationship records", "type": "array", + "example": [{"type": "KNOWS", "properties": {"since": "2020"}}] + }, + "examples": ["MATCH p=(:Person)-[:KNOWS]-(:Person) RETURN relationships(p)"] +}) +class Relationships(Function): + """Relationships function. + + Returns all relationships in a path as an array. + """ + + def __init__(self) -> None: + super().__init__("relationships") + self._expected_parameter_count = 1 + + def value(self) -> Any: + path = self.get_children()[0].value() + if path is None: + return [] + if not isinstance(path, list): + raise ValueError("relationships() expects a path (array)") + # A path is an array of alternating node and relationship objects: + # [node, rel, node, rel, node, ...] + # Relationships are RelationshipMatchRecords (have 'type', 'startNode', 'endNode', 'properties') + result: List[Any] = [] + for element in path: + if element is None or not isinstance(element, dict): + continue + if all(k in element for k in ("type", "startNode", "endNode", "properties")): + result.append(element) + return result diff --git a/flowquery-py/src/parsing/functions/tail.py b/flowquery-py/src/parsing/functions/tail.py new file mode 100644 index 0000000..67fa064 --- /dev/null +++ b/flowquery-py/src/parsing/functions/tail.py @@ -0,0 +1,37 @@ +"""Tail function.""" + +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef + + +@FunctionDef({ + "description": "Returns all elements of a list except the first", + "category": "scalar", + "parameters": [ + {"name": "list", "description": "The list to get all but the first element from", "type": "array"} + ], + "output": {"description": "All elements except the first", "type": "array", "example": [2, 3]}, + "examples": [ + "RETURN tail([1, 2, 3])", + "WITH ['a', 'b', 'c'] AS items RETURN tail(items)" + ] +}) +class Tail(Function): + """Tail function. + + Returns all elements of a list except the first. + """ + + def __init__(self) -> None: + super().__init__("tail") + self._expected_parameter_count = 1 + + def value(self) -> Any: + val = self.get_children()[0].value() + if val is None: + return None + if not isinstance(val, list): + raise ValueError("tail() expects a list") + return val[1:] diff --git a/flowquery-py/src/parsing/functions/temporal_utils.py b/flowquery-py/src/parsing/functions/temporal_utils.py new file mode 100644 index 0000000..93b5f98 --- /dev/null +++ b/flowquery-py/src/parsing/functions/temporal_utils.py @@ -0,0 +1,186 @@ +"""Shared utility functions for temporal (date/time) operations. + +These helpers are used by the datetime_, date_, time_, localdatetime, +localtime, and timestamp functions. +""" + +from datetime import date, datetime, timezone +from typing import Any, Dict + + +def iso_day_of_week(d: date) -> int: + """Computes the ISO day of the week (1 = Monday, 7 = Sunday) matching Neo4j convention.""" + return d.isoweekday() + + +def day_of_year(d: date) -> int: + """Computes the day of the year (1-based).""" + return d.timetuple().tm_yday + + +def quarter(month: int) -> int: + """Computes the quarter (1-4) from a month (1-12).""" + return (month - 1) // 3 + 1 + + +def parse_temporal_arg(arg: Any, fn_name: str) -> datetime: + """Parses a temporal argument (string, number, or map) into a datetime object. + + Args: + arg: The argument to parse (string, number, or dict with components) + fn_name: The calling function name for error messages + + Returns: + A datetime object + """ + if isinstance(arg, str): + try: + return datetime.fromisoformat(arg.replace("Z", "+00:00")) + except ValueError: + raise ValueError(f"{fn_name}(): Invalid temporal string: '{arg}'") + + if isinstance(arg, (int, float)): + # Treat as epoch milliseconds + return datetime.fromtimestamp(arg / 1000, tz=timezone.utc) + + if isinstance(arg, dict): + # Map-style construction: {year, month, day, hour, minute, second, millisecond} + now = datetime.now() + year = arg.get("year", now.year) + month = arg.get("month", 1) + day = arg.get("day", 1) + hour = arg.get("hour", 0) + minute = arg.get("minute", 0) + second = arg.get("second", 0) + millisecond = arg.get("millisecond", 0) + return datetime(year, month, day, hour, minute, second, millisecond * 1000) + + raise ValueError( + f"{fn_name}(): Expected a string, number (epoch millis), or map argument, " + f"got {type(arg).__name__}" + ) + + +def build_datetime_object(d: datetime, utc: bool) -> Dict[str, Any]: + """Builds a datetime result object with full temporal properties. + + Args: + d: The datetime object + utc: If True, use UTC values; if False, use local values + + Returns: + A dict with year, month, day, hour, minute, second, millisecond, + epochMillis, epochSeconds, dayOfWeek, dayOfYear, quarter, formatted + """ + if utc: + if d.tzinfo is None: + d = d.replace(tzinfo=timezone.utc) + d_utc = d.astimezone(timezone.utc) + year = d_utc.year + month = d_utc.month + day = d_utc.day + hour = d_utc.hour + minute = d_utc.minute + second = d_utc.second + millisecond = d_utc.microsecond // 1000 + formatted = d_utc.strftime("%Y-%m-%dT%H:%M:%S.") + f"{millisecond:03d}Z" + else: + if d.tzinfo is not None: + d = d.astimezone(tz=None).replace(tzinfo=None) + year = d.year + month = d.month + day = d.day + hour = d.hour + minute = d.minute + second = d.second + millisecond = d.microsecond // 1000 + formatted = d.strftime("%Y-%m-%dT%H:%M:%S.") + f"{millisecond:03d}" + + date_part = date(year, month, day) + epoch_millis = int(d.timestamp() * 1000) if d.tzinfo else int( + datetime(year, month, day, hour, minute, second, millisecond * 1000).timestamp() * 1000 + ) + + return { + "year": year, + "month": month, + "day": day, + "hour": hour, + "minute": minute, + "second": second, + "millisecond": millisecond, + "epochMillis": epoch_millis, + "epochSeconds": epoch_millis // 1000, + "dayOfWeek": iso_day_of_week(date_part), + "dayOfYear": day_of_year(date_part), + "quarter": quarter(month), + "formatted": formatted, + } + + +def build_date_object(d: datetime) -> Dict[str, Any]: + """Builds a date result object (no time component). + + Args: + d: The datetime object + + Returns: + A dict with year, month, day, epochMillis, dayOfWeek, dayOfYear, quarter, formatted + """ + year = d.year + month = d.month + day_val = d.day + + date_only = datetime(year, month, day_val) + epoch_millis = int(date_only.timestamp() * 1000) + + date_part = date(year, month, day_val) + + return { + "year": year, + "month": month, + "day": day_val, + "epochMillis": epoch_millis, + "dayOfWeek": iso_day_of_week(date_part), + "dayOfYear": day_of_year(date_part), + "quarter": quarter(month), + "formatted": f"{year}-{month:02d}-{day_val:02d}", + } + + +def build_time_object(d: datetime, utc: bool) -> Dict[str, Any]: + """Builds a time result object (no date component). + + Args: + d: The datetime object + utc: If True, use UTC values; if False, use local values + + Returns: + A dict with hour, minute, second, millisecond, formatted + """ + if utc: + if d.tzinfo is None: + d = d.replace(tzinfo=timezone.utc) + d_utc = d.astimezone(timezone.utc) + hour = d_utc.hour + minute = d_utc.minute + second = d_utc.second + millisecond = d_utc.microsecond // 1000 + else: + if d.tzinfo is not None: + d = d.astimezone(tz=None).replace(tzinfo=None) + hour = d.hour + minute = d.minute + second = d.second + millisecond = d.microsecond // 1000 + + time_part = f"{hour:02d}:{minute:02d}:{second:02d}.{millisecond:03d}" + formatted = f"{time_part}Z" if utc else time_part + + return { + "hour": hour, + "minute": minute, + "second": second, + "millisecond": millisecond, + "formatted": formatted, + } diff --git a/flowquery-py/src/parsing/functions/time_.py b/flowquery-py/src/parsing/functions/time_.py new file mode 100644 index 0000000..09b2195 --- /dev/null +++ b/flowquery-py/src/parsing/functions/time_.py @@ -0,0 +1,59 @@ +"""Time function.""" + +from datetime import datetime, timezone +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef +from .temporal_utils import build_time_object, parse_temporal_arg + + +@FunctionDef({ + "description": ( + "Returns a time value. With no arguments returns the current UTC time. " + "Accepts an ISO 8601 time string or a map of components (hour, minute, second, millisecond)." + ), + "category": "scalar", + "parameters": [ + { + "name": "input", + "description": "Optional. An ISO 8601 time string (HH:MM:SS) or a map of components.", + "type": "string", + "required": False, + }, + ], + "output": { + "description": "A time object with properties: hour, minute, second, millisecond, formatted", + "type": "object", + }, + "examples": [ + "RETURN time() AS now", + "RETURN time('12:30:00') AS t", + "WITH time() AS t RETURN t.hour, t.minute", + ], +}) +class Time(Function): + """Time function. + + Returns a time value (with timezone offset awareness). + When called with no arguments, returns the current UTC time. + When called with a string argument, parses it. + + Equivalent to Neo4j's time() function. + """ + + def __init__(self) -> None: + super().__init__("time") + self._expected_parameter_count = None + + def value(self) -> Any: + children = self.get_children() + if len(children) > 1: + raise ValueError("time() accepts at most one argument") + + if len(children) == 1: + d = parse_temporal_arg(children[0].value(), "time") + else: + d = datetime.now(timezone.utc) + + return build_time_object(d, utc=True) diff --git a/flowquery-py/src/parsing/functions/timestamp.py b/flowquery-py/src/parsing/functions/timestamp.py new file mode 100644 index 0000000..c334e60 --- /dev/null +++ b/flowquery-py/src/parsing/functions/timestamp.py @@ -0,0 +1,39 @@ +"""Timestamp function.""" + +import time +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef + + +@FunctionDef({ + "description": ( + "Returns the number of milliseconds since the Unix epoch (1970-01-01T00:00:00Z). " + "Equivalent to Neo4j's timestamp() function." + ), + "category": "scalar", + "parameters": [], + "output": { + "description": "Milliseconds since Unix epoch", + "type": "number", + "example": 1718450000000, + }, + "examples": [ + "RETURN timestamp() AS ts", + "WITH timestamp() AS before, timestamp() AS after RETURN after - before", + ], +}) +class Timestamp(Function): + """Timestamp function. + + Returns the number of milliseconds since the Unix epoch (1970-01-01T00:00:00Z). + Equivalent to Neo4j's timestamp() function. + """ + + def __init__(self) -> None: + super().__init__("timestamp") + self._expected_parameter_count = 0 + + def value(self) -> Any: + return int(time.time() * 1000) diff --git a/flowquery-py/src/parsing/functions/to_float.py b/flowquery-py/src/parsing/functions/to_float.py new file mode 100644 index 0000000..9ecd670 --- /dev/null +++ b/flowquery-py/src/parsing/functions/to_float.py @@ -0,0 +1,46 @@ +"""ToFloat function.""" + +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef + + +@FunctionDef({ + "description": "Converts a value to a floating point number", + "category": "scalar", + "parameters": [ + {"name": "value", "description": "The value to convert to a float", "type": "any"} + ], + "output": {"description": "The floating point representation of the value", "type": "number", "example": 3.14}, + "examples": [ + 'RETURN toFloat("3.14")', + "RETURN toFloat(42)", + "RETURN toFloat(true)" + ] +}) +class ToFloat(Function): + """ToFloat function. + + Converts a value to a floating point number. + """ + + def __init__(self) -> None: + super().__init__("tofloat") + self._expected_parameter_count = 1 + + def value(self) -> Any: + val = self.get_children()[0].value() + if val is None: + return None + if isinstance(val, bool): + return 1.0 if val else 0.0 + if isinstance(val, (int, float)): + return float(val) + if isinstance(val, str): + trimmed = val.strip() + try: + return float(trimmed) + except (ValueError, OverflowError): + raise ValueError(f'Cannot convert string "{val}" to float') + raise ValueError("toFloat() expects a number, string, or boolean") diff --git a/flowquery-py/src/parsing/functions/to_integer.py b/flowquery-py/src/parsing/functions/to_integer.py new file mode 100644 index 0000000..818aea7 --- /dev/null +++ b/flowquery-py/src/parsing/functions/to_integer.py @@ -0,0 +1,46 @@ +"""ToInteger function.""" + +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef + + +@FunctionDef({ + "description": "Converts a value to an integer", + "category": "scalar", + "parameters": [ + {"name": "value", "description": "The value to convert to an integer", "type": "any"} + ], + "output": {"description": "The integer representation of the value", "type": "number", "example": 42}, + "examples": [ + 'RETURN toInteger("42")', + "RETURN toInteger(3.14)", + "RETURN toInteger(true)" + ] +}) +class ToInteger(Function): + """ToInteger function. + + Converts a value to an integer. + """ + + def __init__(self) -> None: + super().__init__("tointeger") + self._expected_parameter_count = 1 + + def value(self) -> Any: + val = self.get_children()[0].value() + if val is None: + return None + if isinstance(val, bool): + return 1 if val else 0 + if isinstance(val, (int, float)): + return int(val) + if isinstance(val, str): + trimmed = val.strip() + try: + return int(float(trimmed)) + except (ValueError, OverflowError): + raise ValueError(f'Cannot convert string "{val}" to integer') + raise ValueError("toInteger() expects a number, string, or boolean") diff --git a/flowquery-py/tests/compute/test_runner.py b/flowquery-py/tests/compute/test_runner.py index 57bb774..5ea44eb 100644 --- a/flowquery-py/tests/compute/test_runner.py +++ b/flowquery-py/tests/compute/test_runner.py @@ -249,6 +249,99 @@ async def test_avg_with_one_value(self): assert len(results) == 1 assert results[0] == {"avg": 1} + @pytest.mark.asyncio + async def test_min(self): + """Test min aggregate function.""" + runner = Runner("unwind [3, 1, 4, 1, 5, 9] as n return min(n) as minimum") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"minimum": 1} + + @pytest.mark.asyncio + async def test_max(self): + """Test max aggregate function.""" + runner = Runner("unwind [3, 1, 4, 1, 5, 9] as n return max(n) as maximum") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"maximum": 9} + + @pytest.mark.asyncio + async def test_min_with_grouped_values(self): + """Test min with grouped values.""" + runner = Runner( + "unwind [1, 1, 2, 2] as i unwind [10, 20, 30, 40] as j return i, min(j) as minimum" + ) + await runner.run() + results = runner.results + assert len(results) == 2 + assert results[0] == {"i": 1, "minimum": 10} + assert results[1] == {"i": 2, "minimum": 10} + + @pytest.mark.asyncio + async def test_max_with_grouped_values(self): + """Test max with grouped values.""" + runner = Runner( + "unwind [1, 1, 2, 2] as i unwind [10, 20, 30, 40] as j return i, max(j) as maximum" + ) + await runner.run() + results = runner.results + assert len(results) == 2 + assert results[0] == {"i": 1, "maximum": 40} + assert results[1] == {"i": 2, "maximum": 40} + + @pytest.mark.asyncio + async def test_min_with_null(self): + """Test min with null.""" + runner = Runner("return min(null) as minimum") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"minimum": None} + + @pytest.mark.asyncio + async def test_max_with_null(self): + """Test max with null.""" + runner = Runner("return max(null) as maximum") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"maximum": None} + + @pytest.mark.asyncio + async def test_min_with_strings(self): + """Test min with string values.""" + runner = Runner( + 'unwind ["cherry", "apple", "banana"] as s return min(s) as minimum' + ) + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"minimum": "apple"} + + @pytest.mark.asyncio + async def test_max_with_strings(self): + """Test max with string values.""" + runner = Runner( + 'unwind ["cherry", "apple", "banana"] as s return max(s) as maximum' + ) + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"maximum": "cherry"} + + @pytest.mark.asyncio + async def test_min_and_max_together(self): + """Test min and max together.""" + runner = Runner( + "unwind [3, 1, 4, 1, 5, 9] as n return min(n) as minimum, max(n) as maximum" + ) + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"minimum": 1, "maximum": 9} + @pytest.mark.asyncio async def test_with_and_return(self): """Test with and return.""" @@ -901,6 +994,144 @@ async def test_keys_function(self): assert len(results) == 1 assert results[0] == {"keys": ["name", "age"]} + @pytest.mark.asyncio + async def test_properties_function_with_map(self): + """Test properties function with a plain map.""" + runner = Runner('RETURN properties({name: "Alice", age: 30}) as props') + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"props": {"name": "Alice", "age": 30}} + + @pytest.mark.asyncio + async def test_properties_function_with_node(self): + """Test properties function with a graph node.""" + await Runner( + """ + CREATE VIRTUAL (:Animal) AS { + UNWIND [ + {id: 1, name: 'Dog', legs: 4}, + {id: 2, name: 'Cat', legs: 4} + ] AS record + RETURN record.id AS id, record.name AS name, record.legs AS legs + } + """ + ).run() + match = Runner( + """ + MATCH (a:Animal) + RETURN properties(a) AS props + """ + ) + await match.run() + results = match.results + assert len(results) == 2 + assert results[0] == {"props": {"name": "Dog", "legs": 4}} + assert results[1] == {"props": {"name": "Cat", "legs": 4}} + + @pytest.mark.asyncio + async def test_properties_function_with_null(self): + """Test properties function with null.""" + runner = Runner("RETURN properties(null) as props") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"props": None} + + @pytest.mark.asyncio + async def test_nodes_function(self): + """Test nodes function with a graph path.""" + await Runner( + """ + CREATE VIRTUAL (:City) AS { + UNWIND [ + {id: 1, name: 'New York'}, + {id: 2, name: 'Boston'} + ] AS record + RETURN record.id AS id, record.name AS name + } + """ + ).run() + await Runner( + """ + CREATE VIRTUAL (:City)-[:CONNECTED_TO]-(:City) AS { + UNWIND [ + {left_id: 1, right_id: 2} + ] AS record + RETURN record.left_id AS left_id, record.right_id AS right_id + } + """ + ).run() + match = Runner( + """ + MATCH p=(:City)-[:CONNECTED_TO]-(:City) + RETURN nodes(p) AS cities + """ + ) + await match.run() + results = match.results + assert len(results) == 1 + assert len(results[0]["cities"]) == 2 + assert results[0]["cities"][0]["id"] == 1 + assert results[0]["cities"][0]["name"] == "New York" + assert results[0]["cities"][1]["id"] == 2 + assert results[0]["cities"][1]["name"] == "Boston" + + @pytest.mark.asyncio + async def test_relationships_function(self): + """Test relationships function with a graph path.""" + await Runner( + """ + CREATE VIRTUAL (:City) AS { + UNWIND [ + {id: 1, name: 'New York'}, + {id: 2, name: 'Boston'} + ] AS record + RETURN record.id AS id, record.name AS name + } + """ + ).run() + await Runner( + """ + CREATE VIRTUAL (:City)-[:CONNECTED_TO]-(:City) AS { + UNWIND [ + {left_id: 1, right_id: 2, distance: 190} + ] AS record + RETURN record.left_id AS left_id, record.right_id AS right_id, record.distance AS distance + } + """ + ).run() + match = Runner( + """ + MATCH p=(:City)-[:CONNECTED_TO]-(:City) + RETURN relationships(p) AS rels + """ + ) + await match.run() + results = match.results + assert len(results) == 1 + assert len(results[0]["rels"]) == 1 + assert results[0]["rels"][0]["type"] == "CONNECTED_TO" + assert results[0]["rels"][0]["properties"]["distance"] == 190 + + @pytest.mark.asyncio + async def test_nodes_function_with_null(self): + """Test nodes function with null.""" + runner = Runner("RETURN nodes(null) as n") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"n": []} + + @pytest.mark.asyncio + async def test_relationships_function_with_null(self): + """Test relationships function with null.""" + runner = Runner("RETURN relationships(null) as r") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"r": []} + @pytest.mark.asyncio async def test_type_function(self): """Test type function.""" @@ -1976,6 +2207,46 @@ async def test_optional_match_with_no_matching_relationship(self): assert results[2]["name"] == "Person 3" assert results[2]["friend"] is None + @pytest.mark.asyncio + async def test_optional_match_property_access_on_null_node_returns_null(self): + """Test that accessing a property on a null node from optional match returns null.""" + await Runner( + """ + CREATE VIRTUAL (:OptPropPerson) AS { + unwind [ + {id: 1, name: 'Person 1'}, + {id: 2, name: 'Person 2'}, + {id: 3, name: 'Person 3'} + ] as record + RETURN record.id as id, record.name as name + } + """ + ).run() + await Runner( + """ + CREATE VIRTUAL (:OptPropPerson)-[:KNOWS]-(:OptPropPerson) AS { + unwind [ + {left_id: 1, right_id: 2} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id + } + """ + ).run() + # When accessing b.name and b is null (no match), should return null like Neo4j + match = Runner( + """ + MATCH (a:OptPropPerson) + OPTIONAL MATCH (a)-[:KNOWS]->(b:OptPropPerson) + RETURN a.name AS name, b.name AS friend_name + """ + ) + await match.run() + results = match.results + assert len(results) == 3 + assert results[0] == {"name": "Person 1", "friend_name": "Person 2"} + assert results[1] == {"name": "Person 2", "friend_name": None} + assert results[2] == {"name": "Person 3", "friend_name": None} + @pytest.mark.asyncio async def test_optional_match_where_all_nodes_match(self): """Test optional match where all nodes have matching relationships.""" @@ -3099,4 +3370,566 @@ async def test_sum_over_empty_array_returns_0(self): await runner.run() results = runner.results assert len(results) == 1 - assert results[0] == {"sum": 0} \ No newline at end of file + assert results[0] == {"sum": 0} + + @pytest.mark.asyncio + async def test_coalesce_returns_first_non_null(self): + """Test coalesce returns first non-null value.""" + runner = Runner("RETURN coalesce(null, null, 'hello', 'world') as result") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"result": "hello"} + + @pytest.mark.asyncio + async def test_coalesce_returns_first_argument_when_not_null(self): + """Test coalesce returns first argument when not null.""" + runner = Runner("RETURN coalesce('first', 'second') as result") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"result": "first"} + + @pytest.mark.asyncio + async def test_coalesce_returns_null_when_all_null(self): + """Test coalesce returns null when all arguments are null.""" + runner = Runner("RETURN coalesce(null, null, null) as result") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"result": None} + + @pytest.mark.asyncio + async def test_coalesce_with_single_non_null_argument(self): + """Test coalesce with single non-null argument.""" + runner = Runner("RETURN coalesce(42) as result") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"result": 42} + + @pytest.mark.asyncio + async def test_coalesce_with_mixed_types(self): + """Test coalesce with mixed types.""" + runner = Runner("RETURN coalesce(null, 42, 'hello') as result") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"result": 42} + + @pytest.mark.asyncio + async def test_coalesce_with_property_access(self): + """Test coalesce with property access.""" + runner = Runner("WITH {name: 'Alice'} AS person RETURN coalesce(person.nickname, person.name) as result") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"result": "Alice"} + + # ============================================================ + # Temporal / Time Functions (Neo4j-style) + # ============================================================ + + @pytest.mark.asyncio + async def test_datetime_returns_current_datetime_object(self): + """Test datetime() returns current datetime object.""" + import time + before = int(time.time() * 1000) + runner = Runner("RETURN datetime() AS dt") + await runner.run() + after = int(time.time() * 1000) + results = runner.results + assert len(results) == 1 + dt = results[0]["dt"] + assert dt is not None + assert isinstance(dt["year"], int) + assert isinstance(dt["month"], int) + assert isinstance(dt["day"], int) + assert isinstance(dt["hour"], int) + assert isinstance(dt["minute"], int) + assert isinstance(dt["second"], int) + assert isinstance(dt["millisecond"], int) + assert isinstance(dt["epochMillis"], int) + assert isinstance(dt["epochSeconds"], int) + assert isinstance(dt["dayOfWeek"], int) + assert isinstance(dt["dayOfYear"], int) + assert isinstance(dt["quarter"], int) + assert isinstance(dt["formatted"], str) + # epochMillis should be between before and after + assert dt["epochMillis"] >= before + assert dt["epochMillis"] <= after + + @pytest.mark.asyncio + async def test_datetime_with_iso_string_argument(self): + """Test datetime() with ISO string argument.""" + runner = Runner("RETURN datetime('2025-06-15T12:30:45.123Z') AS dt") + await runner.run() + results = runner.results + assert len(results) == 1 + dt = results[0]["dt"] + assert dt["year"] == 2025 + assert dt["month"] == 6 + assert dt["day"] == 15 + assert dt["hour"] == 12 + assert dt["minute"] == 30 + assert dt["second"] == 45 + assert dt["millisecond"] == 123 + assert dt["formatted"] == "2025-06-15T12:30:45.123Z" + + @pytest.mark.asyncio + async def test_datetime_property_access(self): + """Test datetime() property access.""" + runner = Runner( + "WITH datetime('2025-06-15T12:30:45.123Z') AS dt RETURN dt.year AS year, dt.month AS month, dt.day AS day" + ) + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"year": 2025, "month": 6, "day": 15} + + @pytest.mark.asyncio + async def test_date_returns_current_date_object(self): + """Test date() returns current date object.""" + runner = Runner("RETURN date() AS d") + await runner.run() + results = runner.results + assert len(results) == 1 + d = results[0]["d"] + assert d is not None + assert isinstance(d["year"], int) + assert isinstance(d["month"], int) + assert isinstance(d["day"], int) + assert isinstance(d["epochMillis"], int) + assert isinstance(d["dayOfWeek"], int) + assert isinstance(d["dayOfYear"], int) + assert isinstance(d["quarter"], int) + assert isinstance(d["formatted"], str) + # Should not have time fields + assert "hour" not in d + assert "minute" not in d + + @pytest.mark.asyncio + async def test_date_with_iso_date_string(self): + """Test date() with ISO date string.""" + runner = Runner("RETURN date('2025-06-15') AS d") + await runner.run() + results = runner.results + assert len(results) == 1 + d = results[0]["d"] + assert d["year"] == 2025 + assert d["month"] == 6 + assert d["day"] == 15 + assert d["formatted"] == "2025-06-15" + + @pytest.mark.asyncio + async def test_date_dayofweek_and_quarter(self): + """Test date() dayOfWeek and quarter.""" + # 2025-06-15 is a Sunday + runner = Runner("RETURN date('2025-06-15') AS d") + await runner.run() + d = runner.results[0]["d"] + assert d["dayOfWeek"] == 7 # Sunday = 7 in ISO + assert d["quarter"] == 2 # June = Q2 + + @pytest.mark.asyncio + async def test_time_returns_current_utc_time(self): + """Test time() returns current UTC time.""" + runner = Runner("RETURN time() AS t") + await runner.run() + results = runner.results + assert len(results) == 1 + t = results[0]["t"] + assert isinstance(t["hour"], int) + assert isinstance(t["minute"], int) + assert isinstance(t["second"], int) + assert isinstance(t["millisecond"], int) + assert isinstance(t["formatted"], str) + assert t["formatted"].endswith("Z") # UTC time ends in Z + + @pytest.mark.asyncio + async def test_localtime_returns_current_local_time(self): + """Test localtime() returns current local time.""" + runner = Runner("RETURN localtime() AS t") + await runner.run() + results = runner.results + assert len(results) == 1 + t = results[0]["t"] + assert isinstance(t["hour"], int) + assert isinstance(t["minute"], int) + assert isinstance(t["second"], int) + assert isinstance(t["millisecond"], int) + assert isinstance(t["formatted"], str) + assert not t["formatted"].endswith("Z") # Local time does not end in Z + + @pytest.mark.asyncio + async def test_localdatetime_returns_current_local_datetime(self): + """Test localdatetime() returns current local datetime.""" + runner = Runner("RETURN localdatetime() AS dt") + await runner.run() + results = runner.results + assert len(results) == 1 + dt = results[0]["dt"] + assert isinstance(dt["year"], int) + assert isinstance(dt["month"], int) + assert isinstance(dt["day"], int) + assert isinstance(dt["hour"], int) + assert isinstance(dt["minute"], int) + assert isinstance(dt["second"], int) + assert isinstance(dt["millisecond"], int) + assert isinstance(dt["epochMillis"], int) + assert isinstance(dt["formatted"], str) + assert not dt["formatted"].endswith("Z") # Local datetime does not end in Z + + @pytest.mark.asyncio + async def test_localdatetime_with_string_argument(self): + """Test localdatetime() with string argument.""" + runner = Runner("RETURN localdatetime('2025-01-20T08:15:30.500') AS dt") + await runner.run() + dt = runner.results[0]["dt"] + assert isinstance(dt["year"], int) + assert isinstance(dt["hour"], int) + assert dt["epochMillis"] is not None + + @pytest.mark.asyncio + async def test_timestamp_returns_epoch_millis(self): + """Test timestamp() returns epoch millis.""" + import time + before = int(time.time() * 1000) + runner = Runner("RETURN timestamp() AS ts") + await runner.run() + after = int(time.time() * 1000) + results = runner.results + assert len(results) == 1 + ts = results[0]["ts"] + assert isinstance(ts, int) + assert ts >= before + assert ts <= after + + @pytest.mark.asyncio + async def test_datetime_epochmillis_matches_timestamp(self): + """Test datetime() epochMillis matches timestamp().""" + runner = Runner( + "WITH datetime() AS dt, timestamp() AS ts RETURN dt.epochMillis AS dtMillis, ts AS tsMillis" + ) + await runner.run() + results = runner.results + assert len(results) == 1 + # They should be very close (within a few ms) + assert abs(results[0]["dtMillis"] - results[0]["tsMillis"]) < 100 + + @pytest.mark.asyncio + async def test_date_with_property_access_in_where(self): + """Test date() with property access in WHERE.""" + runner = Runner( + "UNWIND [1, 2, 3] AS x WITH x, date('2025-06-15') AS d WHERE d.quarter = 2 RETURN x" + ) + await runner.run() + results = runner.results + assert len(results) == 3 # All 3 pass through since Q2 = 2 + + @pytest.mark.asyncio + async def test_datetime_with_map_argument(self): + """Test datetime() with map argument.""" + runner = Runner( + "RETURN datetime({year: 2024, month: 12, day: 25, hour: 10, minute: 30}) AS dt" + ) + await runner.run() + dt = runner.results[0]["dt"] + assert dt["year"] == 2024 + assert dt["month"] == 12 + assert dt["day"] == 25 + assert dt["quarter"] == 4 # December = Q4 + + @pytest.mark.asyncio + async def test_date_with_map_argument(self): + """Test date() with map argument.""" + runner = Runner( + "RETURN date({year: 2025, month: 3, day: 1}) AS d" + ) + await runner.run() + d = runner.results[0]["d"] + assert d["year"] == 2025 + assert d["month"] == 3 + assert d["day"] == 1 + assert d["quarter"] == 1 # March = Q1 + + @pytest.mark.asyncio + async def test_id_function_with_node(self): + """Test id() function with a graph node.""" + await Runner( + """ + CREATE VIRTUAL (:Person) AS { + UNWIND [ + {id: 1, name: 'Alice'}, + {id: 2, name: 'Bob'} + ] AS record + RETURN record.id AS id, record.name AS name + } + """ + ).run() + match = Runner( + """ + MATCH (n:Person) + RETURN id(n) AS nodeId + """ + ) + await match.run() + results = match.results + assert len(results) == 2 + assert results[0] == {"nodeId": 1} + assert results[1] == {"nodeId": 2} + + @pytest.mark.asyncio + async def test_id_function_with_null(self): + """Test id() function with null.""" + runner = Runner("RETURN id(null) AS nodeId") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"nodeId": None} + + @pytest.mark.asyncio + async def test_id_function_with_relationship(self): + """Test id() function with a relationship.""" + await Runner( + """ + CREATE VIRTUAL (:City) AS { + UNWIND [ + {id: 1, name: 'New York'}, + {id: 2, name: 'Boston'} + ] AS record + RETURN record.id AS id, record.name AS name + } + """ + ).run() + await Runner( + """ + CREATE VIRTUAL (:City)-[:CONNECTED_TO]-(:City) AS { + UNWIND [ + {left_id: 1, right_id: 2} + ] AS record + RETURN record.left_id AS left_id, record.right_id AS right_id + } + """ + ).run() + match = Runner( + """ + MATCH (a:City)-[r:CONNECTED_TO]->(b:City) + RETURN id(r) AS relId + """ + ) + await match.run() + results = match.results + assert len(results) == 1 + assert results[0] == {"relId": "CONNECTED_TO"} + + @pytest.mark.asyncio + async def test_element_id_function_with_node(self): + """Test elementId() function with a graph node.""" + await Runner( + """ + CREATE VIRTUAL (:Person) AS { + UNWIND [ + {id: 1, name: 'Alice'}, + {id: 2, name: 'Bob'} + ] AS record + RETURN record.id AS id, record.name AS name + } + """ + ).run() + match = Runner( + """ + MATCH (n:Person) + RETURN elementId(n) AS eid + """ + ) + await match.run() + results = match.results + assert len(results) == 2 + assert results[0] == {"eid": "1"} + assert results[1] == {"eid": "2"} + + @pytest.mark.asyncio + async def test_element_id_function_with_null(self): + """Test elementId() function with null.""" + runner = Runner("RETURN elementId(null) AS eid") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"eid": None} + + @pytest.mark.asyncio + async def test_head_function(self): + """Test head() function.""" + runner = Runner("RETURN head([1, 2, 3]) AS h") + await runner.run() + assert len(runner.results) == 1 + assert runner.results[0] == {"h": 1} + + @pytest.mark.asyncio + async def test_head_function_empty_list(self): + """Test head() function with empty list.""" + runner = Runner("RETURN head([]) AS h") + await runner.run() + assert runner.results[0] == {"h": None} + + @pytest.mark.asyncio + async def test_head_function_null(self): + """Test head() function with null.""" + runner = Runner("RETURN head(null) AS h") + await runner.run() + assert runner.results[0] == {"h": None} + + @pytest.mark.asyncio + async def test_tail_function(self): + """Test tail() function.""" + runner = Runner("RETURN tail([1, 2, 3]) AS t") + await runner.run() + assert len(runner.results) == 1 + assert runner.results[0] == {"t": [2, 3]} + + @pytest.mark.asyncio + async def test_tail_function_single_element(self): + """Test tail() function with single element.""" + runner = Runner("RETURN tail([1]) AS t") + await runner.run() + assert runner.results[0] == {"t": []} + + @pytest.mark.asyncio + async def test_tail_function_null(self): + """Test tail() function with null.""" + runner = Runner("RETURN tail(null) AS t") + await runner.run() + assert runner.results[0] == {"t": None} + + @pytest.mark.asyncio + async def test_last_function(self): + """Test last() function.""" + runner = Runner("RETURN last([1, 2, 3]) AS l") + await runner.run() + assert len(runner.results) == 1 + assert runner.results[0] == {"l": 3} + + @pytest.mark.asyncio + async def test_last_function_empty_list(self): + """Test last() function with empty list.""" + runner = Runner("RETURN last([]) AS l") + await runner.run() + assert runner.results[0] == {"l": None} + + @pytest.mark.asyncio + async def test_last_function_null(self): + """Test last() function with null.""" + runner = Runner("RETURN last(null) AS l") + await runner.run() + assert runner.results[0] == {"l": None} + + @pytest.mark.asyncio + async def test_to_integer_function_string(self): + """Test toInteger() function with string.""" + runner = Runner('RETURN toInteger("42") AS i') + await runner.run() + assert runner.results[0] == {"i": 42} + + @pytest.mark.asyncio + async def test_to_integer_function_float(self): + """Test toInteger() function with float.""" + runner = Runner("RETURN toInteger(3.14) AS i") + await runner.run() + assert runner.results[0] == {"i": 3} + + @pytest.mark.asyncio + async def test_to_integer_function_boolean(self): + """Test toInteger() function with boolean.""" + runner = Runner("RETURN toInteger(true) AS i") + await runner.run() + assert runner.results[0] == {"i": 1} + + @pytest.mark.asyncio + async def test_to_integer_function_null(self): + """Test toInteger() function with null.""" + runner = Runner("RETURN toInteger(null) AS i") + await runner.run() + assert runner.results[0] == {"i": None} + + @pytest.mark.asyncio + async def test_to_float_function_string(self): + """Test toFloat() function with string.""" + runner = Runner('RETURN toFloat("3.14") AS f') + await runner.run() + assert runner.results[0] == {"f": 3.14} + + @pytest.mark.asyncio + async def test_to_float_function_integer(self): + """Test toFloat() function with integer.""" + runner = Runner("RETURN toFloat(42) AS f") + await runner.run() + assert runner.results[0] == {"f": 42} + + @pytest.mark.asyncio + async def test_to_float_function_boolean(self): + """Test toFloat() function with boolean.""" + runner = Runner("RETURN toFloat(true) AS f") + await runner.run() + assert runner.results[0] == {"f": 1.0} + + @pytest.mark.asyncio + async def test_to_float_function_null(self): + """Test toFloat() function with null.""" + runner = Runner("RETURN toFloat(null) AS f") + await runner.run() + assert runner.results[0] == {"f": None} + + @pytest.mark.asyncio + async def test_duration_iso_string(self): + """Test duration() with ISO 8601 string.""" + runner = Runner("RETURN duration('P1Y2M3DT4H5M6S') AS d") + await runner.run() + d = runner.results[0]["d"] + assert d["years"] == 1 + assert d["months"] == 2 + assert d["days"] == 3 + assert d["hours"] == 4 + assert d["minutes"] == 5 + assert d["seconds"] == 6 + assert d["totalMonths"] == 14 + assert d["formatted"] == "P1Y2M3DT4H5M6S" + + @pytest.mark.asyncio + async def test_duration_map_argument(self): + """Test duration() with map argument.""" + runner = Runner("RETURN duration({days: 14, hours: 16}) AS d") + await runner.run() + d = runner.results[0]["d"] + assert d["days"] == 14 + assert d["hours"] == 16 + assert d["totalDays"] == 14 + assert d["totalSeconds"] == 57600 + + @pytest.mark.asyncio + async def test_duration_weeks(self): + """Test duration() with weeks.""" + runner = Runner("RETURN duration('P2W') AS d") + await runner.run() + d = runner.results[0]["d"] + assert d["weeks"] == 2 + assert d["days"] == 14 + assert d["totalDays"] == 14 + + @pytest.mark.asyncio + async def test_duration_null(self): + """Test duration() with null.""" + runner = Runner("RETURN duration(null) AS d") + await runner.run() + assert runner.results[0] == {"d": None} + + @pytest.mark.asyncio + async def test_duration_time_only(self): + """Test duration() with time-only string.""" + runner = Runner("RETURN duration('PT2H30M') AS d") + await runner.run() + d = runner.results[0]["d"] + assert d["hours"] == 2 + assert d["minutes"] == 30 + assert d["totalSeconds"] == 9000 + assert d["formatted"] == "PT2H30M" \ No newline at end of file diff --git a/src/parsing/data_structures/lookup.ts b/src/parsing/data_structures/lookup.ts index d3dd870..8305846 100644 --- a/src/parsing/data_structures/lookup.ts +++ b/src/parsing/data_structures/lookup.ts @@ -2,9 +2,9 @@ import ASTNode from "../ast_node"; /** * Represents a lookup operation (array/object indexing) in the AST. - * + * * Lookups access elements from arrays or properties from objects using an index or key. - * + * * @example * ```typescript * // For array[0] or obj.property or obj["key"] @@ -33,8 +33,12 @@ class Lookup extends ASTNode { return true; } public value(): any { - return this.variable.value()[this.index.value()]; + const obj = this.variable.value(); + if (obj === null || obj === undefined) { + return null; + } + return obj[this.index.value()]; } } -export default Lookup; \ No newline at end of file +export default Lookup; diff --git a/src/parsing/functions/coalesce.ts b/src/parsing/functions/coalesce.ts new file mode 100644 index 0000000..5952cb1 --- /dev/null +++ b/src/parsing/functions/coalesce.ts @@ -0,0 +1,50 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; + +/** + * Returns the first non-null value from a list of expressions. + * Equivalent to Neo4j's coalesce() function. + * + * @example + * ``` + * RETURN coalesce(null, null, 'hello', 'world') // returns 'hello' + * MATCH (n) RETURN coalesce(n.nickname, n.name) AS displayName + * ``` + */ +@FunctionDef({ + description: "Returns the first non-null value from a list of expressions", + category: "scalar", + parameters: [ + { name: "expressions", description: "Two or more expressions to evaluate", type: "any" }, + ], + output: { + description: "The first non-null value, or null if all values are null", + type: "any", + }, + examples: [ + "RETURN coalesce(null, 'hello', 'world')", + "MATCH (n) RETURN coalesce(n.nickname, n.name) AS displayName", + ], +}) +class Coalesce extends Function { + constructor() { + super("coalesce"); + this._expectedParameterCount = null; // variable number of parameters + } + + public value(): any { + const children = this.getChildren(); + if (children.length === 0) { + throw new Error("coalesce() requires at least one argument"); + } + for (const child of children) { + const val = child.value(); + if (val !== null && val !== undefined) { + return val; + } + } + return null; + } +} + +export default Coalesce; diff --git a/src/parsing/functions/date.ts b/src/parsing/functions/date.ts new file mode 100644 index 0000000..4fdd66a --- /dev/null +++ b/src/parsing/functions/date.ts @@ -0,0 +1,65 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; +import { buildDateObject, parseTemporalArg } from "./temporal_utils"; + +/** + * Returns a date value (no time component). + * When called with no arguments, returns the current date. + * When called with a string argument, parses it as an ISO 8601 date. + * When called with a map argument, constructs a date from components. + * + * Equivalent to Neo4j's date() function. + * + * @example + * ``` + * RETURN date() AS today + * RETURN date('2025-06-15') AS d + * RETURN date({year: 2025, month: 6, day: 15}) AS d + * ``` + */ +@FunctionDef({ + description: + "Returns a date value. With no arguments returns the current date. " + + "Accepts an ISO 8601 date string or a map of components (year, month, day).", + category: "scalar", + parameters: [ + { + name: "input", + description: "Optional. An ISO 8601 date string (YYYY-MM-DD) or a map of components.", + type: "string", + required: false, + }, + ], + output: { + description: + "A date object with properties: year, month, day, " + + "epochMillis, dayOfWeek, dayOfYear, quarter, formatted", + type: "object", + }, + examples: [ + "RETURN date() AS today", + "RETURN date('2025-06-15') AS d", + "RETURN date({year: 2025, month: 6, day: 15}) AS d", + "WITH date() AS d RETURN d.year, d.month, d.dayOfWeek", + ], +}) +class DateFunction extends Function { + constructor() { + super("date"); + this._expectedParameterCount = null; + } + + public value(): any { + const children = this.getChildren(); + if (children.length > 1) { + throw new Error("date() accepts at most one argument"); + } + + const d: Date = + children.length === 1 ? parseTemporalArg(children[0].value(), "date") : new Date(); + + return buildDateObject(d); + } +} + +export default DateFunction; diff --git a/src/parsing/functions/datetime.ts b/src/parsing/functions/datetime.ts new file mode 100644 index 0000000..693b826 --- /dev/null +++ b/src/parsing/functions/datetime.ts @@ -0,0 +1,65 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; +import { buildDatetimeObject, parseTemporalArg } from "./temporal_utils"; + +/** + * Returns a datetime value (date + time + timezone offset). + * When called with no arguments, returns the current UTC datetime. + * When called with a string argument, parses it as an ISO 8601 datetime. + * When called with a map argument, constructs a datetime from components. + * + * Equivalent to Neo4j's datetime() function. + * + * @example + * ``` + * RETURN datetime() AS now + * RETURN datetime('2025-06-15T12:30:00Z') AS dt + * RETURN datetime({year: 2025, month: 6, day: 15}) AS dt + * ``` + */ +@FunctionDef({ + description: + "Returns a datetime value. With no arguments returns the current UTC datetime. " + + "Accepts an ISO 8601 string or a map of components (year, month, day, hour, minute, second, millisecond).", + category: "scalar", + parameters: [ + { + name: "input", + description: "Optional. An ISO 8601 datetime string or a map of components.", + type: "string", + required: false, + }, + ], + output: { + description: + "A datetime object with properties: year, month, day, hour, minute, second, millisecond, " + + "epochMillis, epochSeconds, dayOfWeek, dayOfYear, quarter, formatted", + type: "object", + }, + examples: [ + "RETURN datetime() AS now", + "RETURN datetime('2025-06-15T12:30:00Z') AS dt", + "RETURN datetime({year: 2025, month: 6, day: 15, hour: 12}) AS dt", + "WITH datetime() AS dt RETURN dt.year, dt.month, dt.day", + ], +}) +class Datetime extends Function { + constructor() { + super("datetime"); + this._expectedParameterCount = null; + } + + public value(): any { + const children = this.getChildren(); + if (children.length > 1) { + throw new Error("datetime() accepts at most one argument"); + } + + const d: Date = + children.length === 1 ? parseTemporalArg(children[0].value(), "datetime") : new Date(); + + return buildDatetimeObject(d, true); + } +} + +export default Datetime; diff --git a/src/parsing/functions/duration.ts b/src/parsing/functions/duration.ts new file mode 100644 index 0000000..28f40a8 --- /dev/null +++ b/src/parsing/functions/duration.ts @@ -0,0 +1,143 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; + +/** + * Regex for ISO 8601 duration strings: P[nY][nM][nW][nD][T[nH][nM][nS]] + */ +const ISO_DURATION_REGEX = + /^P(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/; + +function parseDurationString(s: string): Record { + const match = s.match(ISO_DURATION_REGEX); + if (!match) { + throw new Error(`duration(): Invalid ISO 8601 duration string: '${s}'`); + } + return { + years: match[1] ? parseFloat(match[1]) : 0, + months: match[2] ? parseFloat(match[2]) : 0, + weeks: match[3] ? parseFloat(match[3]) : 0, + days: match[4] ? parseFloat(match[4]) : 0, + hours: match[5] ? parseFloat(match[5]) : 0, + minutes: match[6] ? parseFloat(match[6]) : 0, + seconds: match[7] ? parseFloat(match[7]) : 0, + }; +} + +function buildDurationObject(components: Record): Record { + const years = components.years || 0; + const months = components.months || 0; + const weeks = components.weeks || 0; + const days = components.days || 0; + const hours = components.hours || 0; + const minutes = components.minutes || 0; + const seconds = Math.floor(components.seconds || 0); + const fractionalSeconds = (components.seconds || 0) - seconds; + + const milliseconds = components.milliseconds + ? Math.floor(components.milliseconds) + : Math.round(fractionalSeconds * 1000); + + const nanoseconds = components.nanoseconds + ? Math.floor(components.nanoseconds) + : Math.round(fractionalSeconds * 1_000_000_000) % 1_000_000; + + // Total days including weeks + const totalDays = days + weeks * 7; + + // Total seconds for the time portion + const totalSeconds = hours * 3600 + minutes * 60 + seconds; + + // Approximate total in various units (months approximated at 30 days) + const totalMonths = years * 12 + months; + + // Build ISO 8601 formatted string + let formatted = "P"; + if (years) formatted += `${years}Y`; + if (months) formatted += `${months}M`; + if (weeks) formatted += `${weeks}W`; + if (totalDays - weeks * 7) formatted += `${totalDays - weeks * 7}D`; + const hasTime = hours || minutes || seconds || milliseconds; + if (hasTime) { + formatted += "T"; + if (hours) formatted += `${hours}H`; + if (minutes) formatted += `${minutes}M`; + if (seconds || milliseconds) { + if (milliseconds) { + formatted += `${seconds}.${String(milliseconds).padStart(3, "0")}S`; + } else { + formatted += `${seconds}S`; + } + } + } + if (formatted === "P") formatted = "PT0S"; + + return { + years, + months, + weeks, + days: totalDays, + hours, + minutes, + seconds, + milliseconds, + nanoseconds, + totalMonths, + totalDays, + totalSeconds, + formatted, + }; +} + +@FunctionDef({ + description: + "Creates a duration value representing a span of time. " + + "Accepts an ISO 8601 duration string (e.g., 'P1Y2M3DT4H5M6S') or a map of components " + + "(years, months, weeks, days, hours, minutes, seconds, milliseconds, nanoseconds).", + category: "scalar", + parameters: [ + { + name: "input", + description: + "An ISO 8601 duration string or a map of components (years, months, weeks, days, hours, minutes, seconds, milliseconds, nanoseconds)", + type: "any", + }, + ], + output: { + description: + "A duration object with properties: years, months, weeks, days, hours, minutes, seconds, " + + "milliseconds, nanoseconds, totalMonths, totalDays, totalSeconds, formatted", + type: "object", + }, + examples: [ + "RETURN duration('P1Y2M3D') AS d", + "RETURN duration('PT2H30M') AS d", + "RETURN duration({days: 14, hours: 16}) AS d", + "RETURN duration({months: 5, days: 1, hours: 12}) AS d", + ], +}) +class Duration extends Function { + constructor() { + super("duration"); + this._expectedParameterCount = 1; + } + + public value(): any { + const arg = this.getChildren()[0].value(); + if (arg === null || arg === undefined) { + return null; + } + + if (typeof arg === "string") { + const components = parseDurationString(arg); + return buildDurationObject(components); + } + + if (typeof arg === "object" && !Array.isArray(arg)) { + return buildDurationObject(arg); + } + + throw new Error("duration() expects a string or map argument"); + } +} + +export default Duration; diff --git a/src/parsing/functions/element_id.ts b/src/parsing/functions/element_id.ts new file mode 100644 index 0000000..fbb70e2 --- /dev/null +++ b/src/parsing/functions/element_id.ts @@ -0,0 +1,51 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; + +@FunctionDef({ + description: + "Returns the element id of a node or relationship as a string. For nodes, returns the string representation of the id property. For relationships, returns the type.", + category: "scalar", + parameters: [ + { + name: "entity", + description: "A node or relationship to get the element id from", + type: "object", + }, + ], + output: { + description: "The element id of the entity as a string", + type: "string", + example: '"1"', + }, + examples: ["MATCH (n:Person) RETURN elementId(n)", "MATCH (a)-[r]->(b) RETURN elementId(r)"], +}) +class ElementId extends Function { + constructor() { + super("elementid"); + this._expectedParameterCount = 1; + } + + public value(): any { + const obj = this.getChildren()[0].value(); + if (obj === null || obj === undefined) { + return null; + } + if (typeof obj !== "object" || Array.isArray(obj)) { + throw new Error("elementId() expects a node or relationship"); + } + + // If it's a RelationshipMatchRecord (has type, startNode, endNode, properties) + if ("type" in obj && "startNode" in obj && "endNode" in obj && "properties" in obj) { + return String(obj.type); + } + + // If it's a node record (has id field) + if ("id" in obj) { + return String(obj.id); + } + + throw new Error("elementId() expects a node or relationship"); + } +} + +export default ElementId; diff --git a/src/parsing/functions/function_factory.ts b/src/parsing/functions/function_factory.ts index cf9618c..e1a55e4 100644 --- a/src/parsing/functions/function_factory.ts +++ b/src/parsing/functions/function_factory.ts @@ -1,7 +1,12 @@ import AsyncFunction from "./async_function"; import "./avg"; +import "./coalesce"; import "./collect"; import "./count"; +import "./date"; +import "./datetime"; +import "./duration"; +import "./element_id"; import Function from "./function"; import { AsyncDataProvider, @@ -11,12 +16,22 @@ import { getRegisteredFunctionMetadata, } from "./function_metadata"; import "./functions"; +import "./head"; +import "./id"; import "./join"; import "./keys"; +import "./last"; +import "./localdatetime"; +import "./localtime"; +import "./max"; +import "./min"; +import "./nodes"; import PredicateFunction from "./predicate_function"; import "./predicate_sum"; +import "./properties"; import "./rand"; import "./range"; +import "./relationships"; import "./replace"; import "./round"; import "./schema"; @@ -26,6 +41,11 @@ import "./string_distance"; import "./stringify"; // Import built-in functions to ensure their @FunctionDef decorators run import "./sum"; +import "./tail"; +import "./time"; +import "./timestamp"; +import "./to_float"; +import "./to_integer"; import "./to_json"; import "./to_lower"; import "./to_string"; diff --git a/src/parsing/functions/head.ts b/src/parsing/functions/head.ts new file mode 100644 index 0000000..b2c197c --- /dev/null +++ b/src/parsing/functions/head.ts @@ -0,0 +1,42 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; + +@FunctionDef({ + description: "Returns the first element of a list", + category: "scalar", + parameters: [ + { + name: "list", + description: "The list to get the first element from", + type: "array", + }, + ], + output: { + description: "The first element of the list", + type: "any", + example: "1", + }, + examples: ["RETURN head([1, 2, 3])", "WITH ['a', 'b', 'c'] AS items RETURN head(items)"], +}) +class Head extends Function { + constructor() { + super("head"); + this._expectedParameterCount = 1; + } + + public value(): any { + const val = this.getChildren()[0].value(); + if (val === null || val === undefined) { + return null; + } + if (!Array.isArray(val)) { + throw new Error("head() expects a list"); + } + if (val.length === 0) { + return null; + } + return val[0]; + } +} + +export default Head; diff --git a/src/parsing/functions/id.ts b/src/parsing/functions/id.ts new file mode 100644 index 0000000..8b69431 --- /dev/null +++ b/src/parsing/functions/id.ts @@ -0,0 +1,51 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; + +@FunctionDef({ + description: + "Returns the id of a node or relationship. For nodes, returns the id property. For relationships, returns the type.", + category: "scalar", + parameters: [ + { + name: "entity", + description: "A node or relationship to get the id from", + type: "object", + }, + ], + output: { + description: "The id of the entity", + type: "any", + example: "1", + }, + examples: ["MATCH (n:Person) RETURN id(n)", "MATCH (a)-[r]->(b) RETURN id(r)"], +}) +class Id extends Function { + constructor() { + super("id"); + this._expectedParameterCount = 1; + } + + public value(): any { + const obj = this.getChildren()[0].value(); + if (obj === null || obj === undefined) { + return null; + } + if (typeof obj !== "object" || Array.isArray(obj)) { + throw new Error("id() expects a node or relationship"); + } + + // If it's a RelationshipMatchRecord (has type, startNode, endNode, properties) + if ("type" in obj && "startNode" in obj && "endNode" in obj && "properties" in obj) { + return obj.type; + } + + // If it's a node record (has id field) + if ("id" in obj) { + return obj.id; + } + + throw new Error("id() expects a node or relationship"); + } +} + +export default Id; diff --git a/src/parsing/functions/last.ts b/src/parsing/functions/last.ts new file mode 100644 index 0000000..235de3b --- /dev/null +++ b/src/parsing/functions/last.ts @@ -0,0 +1,42 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; + +@FunctionDef({ + description: "Returns the last element of a list", + category: "scalar", + parameters: [ + { + name: "list", + description: "The list to get the last element from", + type: "array", + }, + ], + output: { + description: "The last element of the list", + type: "any", + example: "3", + }, + examples: ["RETURN last([1, 2, 3])", "WITH ['a', 'b', 'c'] AS items RETURN last(items)"], +}) +class Last extends Function { + constructor() { + super("last"); + this._expectedParameterCount = 1; + } + + public value(): any { + const val = this.getChildren()[0].value(); + if (val === null || val === undefined) { + return null; + } + if (!Array.isArray(val)) { + throw new Error("last() expects a list"); + } + if (val.length === 0) { + return null; + } + return val[val.length - 1]; + } +} + +export default Last; diff --git a/src/parsing/functions/localdatetime.ts b/src/parsing/functions/localdatetime.ts new file mode 100644 index 0000000..3d0f42c --- /dev/null +++ b/src/parsing/functions/localdatetime.ts @@ -0,0 +1,65 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; +import { buildDatetimeObject, parseTemporalArg } from "./temporal_utils"; + +/** + * Returns a local datetime value (date + time, no timezone offset). + * When called with no arguments, returns the current local datetime. + * When called with a string argument, parses it as an ISO 8601 datetime. + * When called with a map argument, constructs a datetime from components. + * + * Equivalent to Neo4j's localdatetime() function. + * + * @example + * ``` + * RETURN localdatetime() AS now + * RETURN localdatetime('2025-06-15T12:30:00') AS dt + * ``` + */ +@FunctionDef({ + description: + "Returns a local datetime value (no timezone). With no arguments returns the current local datetime. " + + "Accepts an ISO 8601 string or a map of components.", + category: "scalar", + parameters: [ + { + name: "input", + description: "Optional. An ISO 8601 datetime string or a map of components.", + type: "string", + required: false, + }, + ], + output: { + description: + "A datetime object with properties: year, month, day, hour, minute, second, millisecond, " + + "epochMillis, epochSeconds, dayOfWeek, dayOfYear, quarter, formatted", + type: "object", + }, + examples: [ + "RETURN localdatetime() AS now", + "RETURN localdatetime('2025-06-15T12:30:00') AS dt", + "WITH localdatetime() AS dt RETURN dt.hour, dt.minute", + ], +}) +class LocalDatetime extends Function { + constructor() { + super("localdatetime"); + this._expectedParameterCount = null; + } + + public value(): any { + const children = this.getChildren(); + if (children.length > 1) { + throw new Error("localdatetime() accepts at most one argument"); + } + + const d: Date = + children.length === 1 + ? parseTemporalArg(children[0].value(), "localdatetime") + : new Date(); + + return buildDatetimeObject(d, false); + } +} + +export default LocalDatetime; diff --git a/src/parsing/functions/localtime.ts b/src/parsing/functions/localtime.ts new file mode 100644 index 0000000..abdc2ee --- /dev/null +++ b/src/parsing/functions/localtime.ts @@ -0,0 +1,60 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; +import { buildTimeObject, parseTemporalArg } from "./temporal_utils"; + +/** + * Returns a local time value (no timezone offset). + * When called with no arguments, returns the current local time. + * When called with a string argument, parses it. + * + * Equivalent to Neo4j's localtime() function. + * + * @example + * ``` + * RETURN localtime() AS now + * RETURN localtime('14:30:00') AS t + * ``` + */ +@FunctionDef({ + description: + "Returns a local time value (no timezone). With no arguments returns the current local time. " + + "Accepts an ISO 8601 time string or a map of components.", + category: "scalar", + parameters: [ + { + name: "input", + description: "Optional. An ISO 8601 time string (HH:MM:SS) or a map of components.", + type: "string", + required: false, + }, + ], + output: { + description: "A time object with properties: hour, minute, second, millisecond, formatted", + type: "object", + }, + examples: [ + "RETURN localtime() AS now", + "RETURN localtime('14:30:00') AS t", + "WITH localtime() AS t RETURN t.hour, t.minute", + ], +}) +class LocalTime extends Function { + constructor() { + super("localtime"); + this._expectedParameterCount = null; + } + + public value(): any { + const children = this.getChildren(); + if (children.length > 1) { + throw new Error("localtime() accepts at most one argument"); + } + + const d: Date = + children.length === 1 ? parseTemporalArg(children[0].value(), "localtime") : new Date(); + + return buildTimeObject(d, false); + } +} + +export default LocalTime; diff --git a/src/parsing/functions/max.ts b/src/parsing/functions/max.ts new file mode 100644 index 0000000..15463ea --- /dev/null +++ b/src/parsing/functions/max.ts @@ -0,0 +1,37 @@ +import AggregateFunction from "./aggregate_function"; +import { FunctionDef } from "./function_metadata"; +import ReducerElement from "./reducer_element"; + +class MaxReducerElement extends ReducerElement { + private _value: any = null; + public get value(): any { + return this._value; + } + public set value(value: any) { + if (this._value === null || value > this._value) { + this._value = value; + } + } +} + +@FunctionDef({ + description: "Returns the maximum value across grouped rows", + category: "aggregate", + parameters: [{ name: "value", description: "Value to compare", type: "number" }], + output: { description: "Maximum value", type: "number", example: 10 }, + examples: ["WITH [3, 1, 2] AS nums UNWIND nums AS n RETURN max(n)"], +}) +class Max extends AggregateFunction { + constructor() { + super("max"); + this._expectedParameterCount = 1; + } + public reduce(element: MaxReducerElement): void { + element.value = this.firstChild().value(); + } + public element(): MaxReducerElement { + return new MaxReducerElement(); + } +} + +export default Max; diff --git a/src/parsing/functions/min.ts b/src/parsing/functions/min.ts new file mode 100644 index 0000000..a1d2565 --- /dev/null +++ b/src/parsing/functions/min.ts @@ -0,0 +1,37 @@ +import AggregateFunction from "./aggregate_function"; +import { FunctionDef } from "./function_metadata"; +import ReducerElement from "./reducer_element"; + +class MinReducerElement extends ReducerElement { + private _value: any = null; + public get value(): any { + return this._value; + } + public set value(value: any) { + if (this._value === null || value < this._value) { + this._value = value; + } + } +} + +@FunctionDef({ + description: "Returns the minimum value across grouped rows", + category: "aggregate", + parameters: [{ name: "value", description: "Value to compare", type: "number" }], + output: { description: "Minimum value", type: "number", example: 1 }, + examples: ["WITH [3, 1, 2] AS nums UNWIND nums AS n RETURN min(n)"], +}) +class Min extends AggregateFunction { + constructor() { + super("min"); + this._expectedParameterCount = 1; + } + public reduce(element: MinReducerElement): void { + element.value = this.firstChild().value(); + } + public element(): MinReducerElement { + return new MinReducerElement(); + } +} + +export default Min; diff --git a/src/parsing/functions/nodes.ts b/src/parsing/functions/nodes.ts new file mode 100644 index 0000000..6d17acb --- /dev/null +++ b/src/parsing/functions/nodes.ts @@ -0,0 +1,54 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; + +@FunctionDef({ + description: "Returns all nodes in a path as an array", + category: "scalar", + parameters: [ + { + name: "path", + description: "A path value returned from a graph pattern match", + type: "array", + }, + ], + output: { + description: "Array of node records", + type: "array", + example: "[{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]", + }, + examples: ["MATCH p=(:Person)-[:KNOWS]-(:Person) RETURN nodes(p)"], +}) +class Nodes extends Function { + constructor() { + super("nodes"); + this._expectedParameterCount = 1; + } + + public value(): any { + const path = this.getChildren()[0].value(); + if (path === null || path === undefined) { + return []; + } + if (!Array.isArray(path)) { + throw new Error("nodes() expects a path (array)"); + } + // A path is an array of alternating node and relationship objects: + // [node, rel, node, rel, node, ...] + // Nodes are plain NodeRecords (have 'id' but not 'type'/'startNode'/'endNode') + // Relationships are RelationshipMatchRecords (have 'type', 'startNode', 'endNode', 'properties') + return path.filter((element: any) => { + if (element === null || element === undefined || typeof element !== "object") { + return false; + } + // A RelationshipMatchRecord has type, startNode, endNode, properties + return !( + "type" in element && + "startNode" in element && + "endNode" in element && + "properties" in element + ); + }); + } +} + +export default Nodes; diff --git a/src/parsing/functions/properties.ts b/src/parsing/functions/properties.ts new file mode 100644 index 0000000..47d8f9e --- /dev/null +++ b/src/parsing/functions/properties.ts @@ -0,0 +1,56 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; + +@FunctionDef({ + description: + "Returns a map containing all the properties of a node, relationship, or map. For nodes and relationships, internal identifiers are excluded.", + category: "scalar", + parameters: [ + { + name: "entity", + description: "A node, relationship, or map to extract properties from", + type: "object", + }, + ], + output: { + description: "Map of properties", + type: "object", + example: "{ name: 'Alice', age: 30 }", + }, + examples: [ + "MATCH (n:Person) RETURN properties(n)", + "WITH { name: 'Alice', age: 30 } AS obj RETURN properties(obj)", + ], +}) +class Properties extends Function { + constructor() { + super("properties"); + this._expectedParameterCount = 1; + } + + public value(): any { + const obj = this.getChildren()[0].value(); + if (obj === null || obj === undefined) { + return null; + } + if (typeof obj !== "object" || Array.isArray(obj)) { + throw new Error("properties() expects a node, relationship, or map"); + } + + // If it's a RelationshipMatchRecord (has type, startNode, endNode, properties) + if ("type" in obj && "startNode" in obj && "endNode" in obj && "properties" in obj) { + return obj.properties; + } + + // If it's a node record (has id field), exclude id + if ("id" in obj) { + const { id, ...props } = obj; + return props; + } + + // Otherwise, treat as a plain map and return a copy + return { ...obj }; + } +} + +export default Properties; diff --git a/src/parsing/functions/relationships.ts b/src/parsing/functions/relationships.ts new file mode 100644 index 0000000..ce14f9e --- /dev/null +++ b/src/parsing/functions/relationships.ts @@ -0,0 +1,52 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; + +@FunctionDef({ + description: "Returns all relationships in a path as an array", + category: "scalar", + parameters: [ + { + name: "path", + description: "A path value returned from a graph pattern match", + type: "array", + }, + ], + output: { + description: "Array of relationship records", + type: "array", + example: "[{ type: 'KNOWS', properties: { since: '2020' } }]", + }, + examples: ["MATCH p=(:Person)-[:KNOWS]-(:Person) RETURN relationships(p)"], +}) +class Relationships extends Function { + constructor() { + super("relationships"); + this._expectedParameterCount = 1; + } + + public value(): any { + const path = this.getChildren()[0].value(); + if (path === null || path === undefined) { + return []; + } + if (!Array.isArray(path)) { + throw new Error("relationships() expects a path (array)"); + } + // A path is an array of alternating node and relationship objects: + // [node, rel, node, rel, node, ...] + // Relationships are RelationshipMatchRecords (have 'type', 'startNode', 'endNode', 'properties') + return path.filter((element: any) => { + if (element === null || element === undefined || typeof element !== "object") { + return false; + } + return ( + "type" in element && + "startNode" in element && + "endNode" in element && + "properties" in element + ); + }); + } +} + +export default Relationships; diff --git a/src/parsing/functions/tail.ts b/src/parsing/functions/tail.ts new file mode 100644 index 0000000..5441864 --- /dev/null +++ b/src/parsing/functions/tail.ts @@ -0,0 +1,39 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; + +@FunctionDef({ + description: "Returns all elements of a list except the first", + category: "scalar", + parameters: [ + { + name: "list", + description: "The list to get all but the first element from", + type: "array", + }, + ], + output: { + description: "All elements except the first", + type: "array", + example: "[2, 3]", + }, + examples: ["RETURN tail([1, 2, 3])", "WITH ['a', 'b', 'c'] AS items RETURN tail(items)"], +}) +class Tail extends Function { + constructor() { + super("tail"); + this._expectedParameterCount = 1; + } + + public value(): any { + const val = this.getChildren()[0].value(); + if (val === null || val === undefined) { + return null; + } + if (!Array.isArray(val)) { + throw new Error("tail() expects a list"); + } + return val.slice(1); + } +} + +export default Tail; diff --git a/src/parsing/functions/temporal_utils.ts b/src/parsing/functions/temporal_utils.ts new file mode 100644 index 0000000..7e9a79b --- /dev/null +++ b/src/parsing/functions/temporal_utils.ts @@ -0,0 +1,180 @@ +/** + * Shared utility functions for temporal (date/time) operations. + * + * These helpers are used by the datetime, date, time, localdatetime, + * localtime, and timestamp functions. + */ + +/** + * Computes the ISO day of the week (1 = Monday, 7 = Sunday) matching Neo4j convention. + */ +function isoDayOfWeek(d: Date): number { + const jsDay = d.getDay(); // 0 = Sunday, 6 = Saturday + return jsDay === 0 ? 7 : jsDay; +} + +/** + * Computes the day of the year (1-based). + */ +function dayOfYear(d: Date): number { + const start = new Date(d.getFullYear(), 0, 0); + const diff = d.getTime() - start.getTime(); + const oneDay = 1000 * 60 * 60 * 24; + return Math.floor(diff / oneDay); +} + +/** + * Computes the quarter (1-4) from a month (0-11). + */ +function quarter(month: number): number { + return Math.floor(month / 3) + 1; +} + +/** + * Pads a number to a given width with leading zeros. + */ +function pad(n: number, width: number = 2): string { + return String(n).padStart(width, "0"); +} + +/** + * Formats a timezone offset in ±HH:MM format. + */ +function formatTimezoneOffset(d: Date): string { + const offset = -d.getTimezoneOffset(); + if (offset === 0) return "Z"; + const sign = offset > 0 ? "+" : "-"; + const absOffset = Math.abs(offset); + const hours = Math.floor(absOffset / 60); + const minutes = absOffset % 60; + return `${sign}${pad(hours)}:${pad(minutes)}`; +} + +/** + * Parses a temporal argument (string or map) into a Date object. + * + * @param arg - The argument to parse (string or object with year/month/day/hour/minute/second/millisecond) + * @param fnName - The calling function name for error messages + * @returns A Date object + */ +export function parseTemporalArg(arg: any, fnName: string): Date { + if (typeof arg === "string") { + const d = new Date(arg); + if (isNaN(d.getTime())) { + throw new Error(`${fnName}(): Invalid temporal string: '${arg}'`); + } + return d; + } + + if (typeof arg === "number") { + // Treat as epoch milliseconds + return new Date(arg); + } + + if (typeof arg === "object" && arg !== null && !Array.isArray(arg)) { + // Map-style construction: {year, month, day, hour, minute, second, millisecond} + const year = arg.year ?? new Date().getFullYear(); + const month = (arg.month ?? 1) - 1; // JS months are 0-based + const day = arg.day ?? 1; + const hour = arg.hour ?? 0; + const minute = arg.minute ?? 0; + const second = arg.second ?? 0; + const millisecond = arg.millisecond ?? 0; + return new Date(year, month, day, hour, minute, second, millisecond); + } + + throw new Error( + `${fnName}(): Expected a string, number (epoch millis), or map argument, got ${typeof arg}` + ); +} + +/** + * Builds a datetime result object with full temporal properties. + * + * @param d - The Date object + * @param utc - If true, use UTC values; if false, use local values + * @returns An object with year, month, day, hour, minute, second, millisecond, + * epochMillis, epochSeconds, dayOfWeek, dayOfYear, quarter, formatted + */ +export function buildDatetimeObject(d: Date, utc: boolean): Record { + const year = utc ? d.getUTCFullYear() : d.getFullYear(); + const month = utc ? d.getUTCMonth() + 1 : d.getMonth() + 1; + const day = utc ? d.getUTCDate() : d.getDate(); + const hour = utc ? d.getUTCHours() : d.getHours(); + const minute = utc ? d.getUTCMinutes() : d.getMinutes(); + const second = utc ? d.getUTCSeconds() : d.getSeconds(); + const millisecond = utc ? d.getUTCMilliseconds() : d.getMilliseconds(); + + const datePart = new Date(year, month - 1, day); + + const formatted = utc + ? d.toISOString() + : `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}:${pad(second)}.${pad(millisecond, 3)}`; + + return { + year, + month, + day, + hour, + minute, + second, + millisecond, + epochMillis: d.getTime(), + epochSeconds: Math.floor(d.getTime() / 1000), + dayOfWeek: isoDayOfWeek(datePart), + dayOfYear: dayOfYear(datePart), + quarter: quarter(month - 1), + formatted, + }; +} + +/** + * Builds a date result object (no time component). + * + * @param d - The Date object + * @returns An object with year, month, day, epochMillis, dayOfWeek, dayOfYear, quarter, formatted + */ +export function buildDateObject(d: Date): Record { + const year = d.getFullYear(); + const month = d.getMonth() + 1; + const day = d.getDate(); + + // Strip time component for epoch calculation + const dateOnly = new Date(year, month - 1, day); + + return { + year, + month, + day, + epochMillis: dateOnly.getTime(), + dayOfWeek: isoDayOfWeek(dateOnly), + dayOfYear: dayOfYear(dateOnly), + quarter: quarter(month - 1), + formatted: `${year}-${pad(month)}-${pad(day)}`, + }; +} + +/** + * Builds a time result object (no date component). + * + * @param d - The Date object + * @param utc - If true, use UTC values; if false, use local values + * @returns An object with hour, minute, second, millisecond, formatted + */ +export function buildTimeObject(d: Date, utc: boolean): Record { + const hour = utc ? d.getUTCHours() : d.getHours(); + const minute = utc ? d.getUTCMinutes() : d.getMinutes(); + const second = utc ? d.getUTCSeconds() : d.getSeconds(); + const millisecond = utc ? d.getUTCMilliseconds() : d.getMilliseconds(); + + const timePart = `${pad(hour)}:${pad(minute)}:${pad(second)}.${pad(millisecond, 3)}`; + const formatted = utc ? `${timePart}Z` : timePart; + + return { + hour, + minute, + second, + millisecond, + formatted, + }; +} diff --git a/src/parsing/functions/time.ts b/src/parsing/functions/time.ts new file mode 100644 index 0000000..1763b09 --- /dev/null +++ b/src/parsing/functions/time.ts @@ -0,0 +1,60 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; +import { buildTimeObject, parseTemporalArg } from "./temporal_utils"; + +/** + * Returns a time value (with timezone offset awareness). + * When called with no arguments, returns the current UTC time. + * When called with a string argument, parses it as an ISO 8601 time. + * + * Equivalent to Neo4j's time() function. + * + * @example + * ``` + * RETURN time() AS now + * RETURN time('12:30:00') AS t + * ``` + */ +@FunctionDef({ + description: + "Returns a time value. With no arguments returns the current UTC time. " + + "Accepts an ISO 8601 time string or a map of components (hour, minute, second, millisecond).", + category: "scalar", + parameters: [ + { + name: "input", + description: "Optional. An ISO 8601 time string (HH:MM:SS) or a map of components.", + type: "string", + required: false, + }, + ], + output: { + description: "A time object with properties: hour, minute, second, millisecond, formatted", + type: "object", + }, + examples: [ + "RETURN time() AS now", + "RETURN time('12:30:00') AS t", + "WITH time() AS t RETURN t.hour, t.minute", + ], +}) +class Time extends Function { + constructor() { + super("time"); + this._expectedParameterCount = null; + } + + public value(): any { + const children = this.getChildren(); + if (children.length > 1) { + throw new Error("time() accepts at most one argument"); + } + + const d: Date = + children.length === 1 ? parseTemporalArg(children[0].value(), "time") : new Date(); + + return buildTimeObject(d, true); + } +} + +export default Time; diff --git a/src/parsing/functions/timestamp.ts b/src/parsing/functions/timestamp.ts new file mode 100644 index 0000000..30762ae --- /dev/null +++ b/src/parsing/functions/timestamp.ts @@ -0,0 +1,41 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; + +/** + * Returns the number of milliseconds since the Unix epoch (1970-01-01T00:00:00Z). + * + * Equivalent to Neo4j's timestamp() function. + * + * @example + * ``` + * RETURN timestamp() AS ts + * ``` + */ +@FunctionDef({ + description: + "Returns the number of milliseconds since the Unix epoch (1970-01-01T00:00:00Z). " + + "Equivalent to Neo4j's timestamp() function.", + category: "scalar", + parameters: [], + output: { + description: "Milliseconds since Unix epoch", + type: "number", + example: 1718450000000, + }, + examples: [ + "RETURN timestamp() AS ts", + "WITH timestamp() AS before, timestamp() AS after RETURN after - before", + ], +}) +class Timestamp extends Function { + constructor() { + super("timestamp"); + this._expectedParameterCount = 0; + } + + public value(): any { + return Date.now(); + } +} + +export default Timestamp; diff --git a/src/parsing/functions/to_float.ts b/src/parsing/functions/to_float.ts new file mode 100644 index 0000000..c28cc28 --- /dev/null +++ b/src/parsing/functions/to_float.ts @@ -0,0 +1,50 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; + +@FunctionDef({ + description: "Converts a value to a floating point number", + category: "scalar", + parameters: [ + { + name: "value", + description: "The value to convert to a float", + type: "any", + }, + ], + output: { + description: "The floating point representation of the value", + type: "number", + example: "3.14", + }, + examples: ['RETURN toFloat("3.14")', "RETURN toFloat(42)", "RETURN toFloat(true)"], +}) +class ToFloat extends Function { + constructor() { + super("tofloat"); + this._expectedParameterCount = 1; + } + + public value(): any { + const val = this.getChildren()[0].value(); + if (val === null || val === undefined) { + return null; + } + if (typeof val === "boolean") { + return val ? 1.0 : 0.0; + } + if (typeof val === "number") { + return val; + } + if (typeof val === "string") { + const trimmed = val.trim(); + const parsed = Number(trimmed); + if (isNaN(parsed)) { + throw new Error(`Cannot convert string "${val}" to float`); + } + return parsed; + } + throw new Error("toFloat() expects a number, string, or boolean"); + } +} + +export default ToFloat; diff --git a/src/parsing/functions/to_integer.ts b/src/parsing/functions/to_integer.ts new file mode 100644 index 0000000..379bb7f --- /dev/null +++ b/src/parsing/functions/to_integer.ts @@ -0,0 +1,50 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; + +@FunctionDef({ + description: "Converts a value to an integer", + category: "scalar", + parameters: [ + { + name: "value", + description: "The value to convert to an integer", + type: "any", + }, + ], + output: { + description: "The integer representation of the value", + type: "number", + example: "42", + }, + examples: ['RETURN toInteger("42")', "RETURN toInteger(3.14)", "RETURN toInteger(true)"], +}) +class ToInteger extends Function { + constructor() { + super("tointeger"); + this._expectedParameterCount = 1; + } + + public value(): any { + const val = this.getChildren()[0].value(); + if (val === null || val === undefined) { + return null; + } + if (typeof val === "boolean") { + return val ? 1 : 0; + } + if (typeof val === "number") { + return Math.trunc(val); + } + if (typeof val === "string") { + const trimmed = val.trim(); + const parsed = Number(trimmed); + if (isNaN(parsed)) { + throw new Error(`Cannot convert string "${val}" to integer`); + } + return Math.trunc(parsed); + } + throw new Error("toInteger() expects a number, string, or boolean"); + } +} + +export default ToInteger; diff --git a/tests/compute/runner.test.ts b/tests/compute/runner.test.ts index ff7d1fa..e21aa79 100644 --- a/tests/compute/runner.test.ts +++ b/tests/compute/runner.test.ts @@ -216,6 +216,86 @@ test("Test avg with one value", async () => { expect(results[0]).toEqual({ avg: 1 }); }); +test("Test min", async () => { + const runner = new Runner("unwind [3, 1, 4, 1, 5, 9] as n return min(n) as minimum"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ minimum: 1 }); +}); + +test("Test max", async () => { + const runner = new Runner("unwind [3, 1, 4, 1, 5, 9] as n return max(n) as maximum"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ maximum: 9 }); +}); + +test("Test min with grouped values", async () => { + const runner = new Runner( + "unwind [1, 1, 2, 2] as i unwind [10, 20, 30, 40] as j return i, min(j) as minimum" + ); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(2); + expect(results[0]).toEqual({ i: 1, minimum: 10 }); + expect(results[1]).toEqual({ i: 2, minimum: 10 }); +}); + +test("Test max with grouped values", async () => { + const runner = new Runner( + "unwind [1, 1, 2, 2] as i unwind [10, 20, 30, 40] as j return i, max(j) as maximum" + ); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(2); + expect(results[0]).toEqual({ i: 1, maximum: 40 }); + expect(results[1]).toEqual({ i: 2, maximum: 40 }); +}); + +test("Test min with null", async () => { + const runner = new Runner("return min(null) as minimum"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ minimum: null }); +}); + +test("Test max with null", async () => { + const runner = new Runner("return max(null) as maximum"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ maximum: null }); +}); + +test("Test min with strings", async () => { + const runner = new Runner('unwind ["cherry", "apple", "banana"] as s return min(s) as minimum'); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ minimum: "apple" }); +}); + +test("Test max with strings", async () => { + const runner = new Runner('unwind ["cherry", "apple", "banana"] as s return max(s) as maximum'); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ maximum: "cherry" }); +}); + +test("Test min and max together", async () => { + const runner = new Runner( + "unwind [3, 1, 4, 1, 5, 9] as n return min(n) as minimum, max(n) as maximum" + ); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ minimum: 1, maximum: 9 }); +}); + test("Test with and return", async () => { const runner = new Runner("with 1 as a return a"); await runner.run(); @@ -822,6 +902,121 @@ test("Test keys function", async () => { expect(results[0]).toEqual({ keys: ["name", "age"] }); }); +test("Test properties function with map", async () => { + const runner = new Runner('RETURN properties({name: "Alice", age: 30}) as props'); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ props: { name: "Alice", age: 30 } }); +}); + +test("Test properties function with node", async () => { + await new Runner(` + CREATE VIRTUAL (:Animal) AS { + UNWIND [ + {id: 1, name: 'Dog', legs: 4}, + {id: 2, name: 'Cat', legs: 4} + ] AS record + RETURN record.id AS id, record.name AS name, record.legs AS legs + } + `).run(); + const match = new Runner(` + MATCH (a:Animal) + RETURN properties(a) AS props + `); + await match.run(); + const results = match.results; + expect(results.length).toBe(2); + expect(results[0]).toEqual({ props: { name: "Dog", legs: 4 } }); + expect(results[1]).toEqual({ props: { name: "Cat", legs: 4 } }); +}); + +test("Test properties function with null", async () => { + const runner = new Runner("RETURN properties(null) as props"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ props: null }); +}); + +test("Test nodes function", async () => { + await new Runner(` + CREATE VIRTUAL (:City) AS { + UNWIND [ + {id: 1, name: 'New York'}, + {id: 2, name: 'Boston'} + ] AS record + RETURN record.id AS id, record.name AS name + } + `).run(); + await new Runner(` + CREATE VIRTUAL (:City)-[:CONNECTED_TO]-(:City) AS { + UNWIND [ + {left_id: 1, right_id: 2} + ] AS record + RETURN record.left_id AS left_id, record.right_id AS right_id + } + `).run(); + const match = new Runner(` + MATCH p=(:City)-[:CONNECTED_TO]-(:City) + RETURN nodes(p) AS cities + `); + await match.run(); + const results = match.results; + expect(results.length).toBe(1); + expect(results[0].cities.length).toBe(2); + expect(results[0].cities[0].id).toBe(1); + expect(results[0].cities[0].name).toBe("New York"); + expect(results[0].cities[1].id).toBe(2); + expect(results[0].cities[1].name).toBe("Boston"); +}); + +test("Test relationships function", async () => { + await new Runner(` + CREATE VIRTUAL (:City) AS { + UNWIND [ + {id: 1, name: 'New York'}, + {id: 2, name: 'Boston'} + ] AS record + RETURN record.id AS id, record.name AS name + } + `).run(); + await new Runner(` + CREATE VIRTUAL (:City)-[:CONNECTED_TO]-(:City) AS { + UNWIND [ + {left_id: 1, right_id: 2, distance: 190} + ] AS record + RETURN record.left_id AS left_id, record.right_id AS right_id, record.distance AS distance + } + `).run(); + const match = new Runner(` + MATCH p=(:City)-[:CONNECTED_TO]-(:City) + RETURN relationships(p) AS rels + `); + await match.run(); + const results = match.results; + expect(results.length).toBe(1); + expect(results[0].rels.length).toBe(1); + expect(results[0].rels[0].type).toBe("CONNECTED_TO"); + expect(results[0].rels[0].properties.distance).toBe(190); +}); + +test("Test nodes function with null", async () => { + const runner = new Runner("RETURN nodes(null) as n"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ n: [] }); +}); + +test("Test relationships function with null", async () => { + const runner = new Runner("RETURN relationships(null) as r"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ r: [] }); +}); + test("Test type function", async () => { const runner = new Runner(` RETURN type(123) as type1, @@ -1825,6 +2020,39 @@ test("Test optional match with no matching relationship", async () => { expect(results[2].friend).toBeNull(); }); +test("Test optional match property access on null node returns null", async () => { + await new Runner(` + CREATE VIRTUAL (:Person) AS { + unwind [ + {id: 1, name: 'Person 1'}, + {id: 2, name: 'Person 2'}, + {id: 3, name: 'Person 3'} + ] as record + RETURN record.id as id, record.name as name + } + `).run(); + await new Runner(` + CREATE VIRTUAL (:Person)-[:KNOWS]-(:Person) AS { + unwind [ + {left_id: 1, right_id: 2} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id + } + `).run(); + // When accessing b.name and b is null (no match), should return null like Neo4j + const match = new Runner(` + MATCH (a:Person) + OPTIONAL MATCH (a)-[:KNOWS]->(b:Person) + RETURN a.name AS name, b.name AS friend_name + `); + await match.run(); + const results = match.results; + expect(results.length).toBe(3); + expect(results[0]).toEqual({ name: "Person 1", friend_name: "Person 2" }); + expect(results[1]).toEqual({ name: "Person 2", friend_name: null }); + expect(results[2]).toEqual({ name: "Person 3", friend_name: null }); +}); + test("Test optional match where all nodes match", async () => { await new Runner(` CREATE VIRTUAL (:Person) AS { @@ -2966,3 +3194,501 @@ test("Test match with ORed relationship types returns correct type in relationsh expect(results[0]).toEqual({ from: "NYC", to: "LA", type: "FLIGHT" }); expect(results[1]).toEqual({ from: "NYC", to: "Chicago", type: "TRAIN" }); }); + +test("Test coalesce returns first non-null value", async () => { + const runner = new Runner("RETURN coalesce(null, null, 'hello', 'world') as result"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ result: "hello" }); +}); + +test("Test coalesce returns first argument when not null", async () => { + const runner = new Runner("RETURN coalesce('first', 'second') as result"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ result: "first" }); +}); + +test("Test coalesce returns null when all arguments are null", async () => { + const runner = new Runner("RETURN coalesce(null, null, null) as result"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ result: null }); +}); + +test("Test coalesce with single non-null argument", async () => { + const runner = new Runner("RETURN coalesce(42) as result"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ result: 42 }); +}); + +test("Test coalesce with mixed types", async () => { + const runner = new Runner("RETURN coalesce(null, 42, 'hello') as result"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ result: 42 }); +}); + +test("Test coalesce with property access", async () => { + const runner = new Runner( + "WITH {name: 'Alice'} AS person RETURN coalesce(person.nickname, person.name) as result" + ); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ result: "Alice" }); +}); + +// ============================================================ +// Temporal / Time Functions (Neo4j-style) +// ============================================================ + +test("Test datetime() returns current datetime object", async () => { + const before = Date.now(); + const runner = new Runner("RETURN datetime() AS dt"); + await runner.run(); + const after = Date.now(); + const results = runner.results; + expect(results.length).toBe(1); + const dt = results[0].dt; + expect(dt).toBeDefined(); + expect(typeof dt.year).toBe("number"); + expect(typeof dt.month).toBe("number"); + expect(typeof dt.day).toBe("number"); + expect(typeof dt.hour).toBe("number"); + expect(typeof dt.minute).toBe("number"); + expect(typeof dt.second).toBe("number"); + expect(typeof dt.millisecond).toBe("number"); + expect(typeof dt.epochMillis).toBe("number"); + expect(typeof dt.epochSeconds).toBe("number"); + expect(typeof dt.dayOfWeek).toBe("number"); + expect(typeof dt.dayOfYear).toBe("number"); + expect(typeof dt.quarter).toBe("number"); + expect(typeof dt.formatted).toBe("string"); + // epochMillis should be between before and after + expect(dt.epochMillis).toBeGreaterThanOrEqual(before); + expect(dt.epochMillis).toBeLessThanOrEqual(after); +}); + +test("Test datetime() with ISO string argument", async () => { + const runner = new Runner("RETURN datetime('2025-06-15T12:30:45.123Z') AS dt"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + const dt = results[0].dt; + expect(dt.year).toBe(2025); + expect(dt.month).toBe(6); + expect(dt.day).toBe(15); + expect(dt.hour).toBe(12); + expect(dt.minute).toBe(30); + expect(dt.second).toBe(45); + expect(dt.millisecond).toBe(123); + expect(dt.formatted).toBe("2025-06-15T12:30:45.123Z"); +}); + +test("Test datetime() property access", async () => { + const runner = new Runner( + "WITH datetime('2025-06-15T12:30:45.123Z') AS dt RETURN dt.year AS year, dt.month AS month, dt.day AS day" + ); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ year: 2025, month: 6, day: 15 }); +}); + +test("Test date() returns current date object", async () => { + const runner = new Runner("RETURN date() AS d"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + const d = results[0].d; + expect(d).toBeDefined(); + expect(typeof d.year).toBe("number"); + expect(typeof d.month).toBe("number"); + expect(typeof d.day).toBe("number"); + expect(typeof d.epochMillis).toBe("number"); + expect(typeof d.dayOfWeek).toBe("number"); + expect(typeof d.dayOfYear).toBe("number"); + expect(typeof d.quarter).toBe("number"); + expect(typeof d.formatted).toBe("string"); + // Should not have time fields + expect(d.hour).toBeUndefined(); + expect(d.minute).toBeUndefined(); +}); + +test("Test date() with ISO date string", async () => { + const runner = new Runner("RETURN date('2025-06-15') AS d"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + const d = results[0].d; + expect(d.year).toBe(2025); + expect(d.month).toBe(6); + expect(d.day).toBe(15); + expect(d.formatted).toBe("2025-06-15"); +}); + +test("Test date() dayOfWeek and quarter", async () => { + // 2025-06-15 is a Sunday + const runner = new Runner("RETURN date('2025-06-15') AS d"); + await runner.run(); + const d = runner.results[0].d; + expect(d.dayOfWeek).toBe(7); // Sunday = 7 in ISO + expect(d.quarter).toBe(2); // June = Q2 +}); + +test("Test time() returns current UTC time", async () => { + const runner = new Runner("RETURN time() AS t"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + const t = results[0].t; + expect(typeof t.hour).toBe("number"); + expect(typeof t.minute).toBe("number"); + expect(typeof t.second).toBe("number"); + expect(typeof t.millisecond).toBe("number"); + expect(typeof t.formatted).toBe("string"); + expect(t.formatted).toMatch(/Z$/); // UTC time ends in Z +}); + +test("Test localtime() returns current local time", async () => { + const runner = new Runner("RETURN localtime() AS t"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + const t = results[0].t; + expect(typeof t.hour).toBe("number"); + expect(typeof t.minute).toBe("number"); + expect(typeof t.second).toBe("number"); + expect(typeof t.millisecond).toBe("number"); + expect(typeof t.formatted).toBe("string"); + expect(t.formatted).not.toMatch(/Z$/); // Local time does not end in Z +}); + +test("Test localdatetime() returns current local datetime", async () => { + const runner = new Runner("RETURN localdatetime() AS dt"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + const dt = results[0].dt; + expect(typeof dt.year).toBe("number"); + expect(typeof dt.month).toBe("number"); + expect(typeof dt.day).toBe("number"); + expect(typeof dt.hour).toBe("number"); + expect(typeof dt.minute).toBe("number"); + expect(typeof dt.second).toBe("number"); + expect(typeof dt.millisecond).toBe("number"); + expect(typeof dt.epochMillis).toBe("number"); + expect(typeof dt.formatted).toBe("string"); + expect(dt.formatted).not.toMatch(/Z$/); // Local datetime does not end in Z +}); + +test("Test localdatetime() with string argument", async () => { + const runner = new Runner("RETURN localdatetime('2025-01-20T08:15:30.500Z') AS dt"); + await runner.run(); + const dt = runner.results[0].dt; + expect(typeof dt.year).toBe("number"); + expect(typeof dt.hour).toBe("number"); + expect(dt.epochMillis).toBeDefined(); +}); + +test("Test timestamp() returns epoch millis", async () => { + const before = Date.now(); + const runner = new Runner("RETURN timestamp() AS ts"); + await runner.run(); + const after = Date.now(); + const results = runner.results; + expect(results.length).toBe(1); + const ts = results[0].ts; + expect(typeof ts).toBe("number"); + expect(ts).toBeGreaterThanOrEqual(before); + expect(ts).toBeLessThanOrEqual(after); +}); + +test("Test datetime() epochMillis matches timestamp()", async () => { + const runner = new Runner( + "WITH datetime() AS dt, timestamp() AS ts RETURN dt.epochMillis AS dtMillis, ts AS tsMillis" + ); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + // They should be very close (within a few ms) + expect(Math.abs(results[0].dtMillis - results[0].tsMillis)).toBeLessThan(100); +}); + +test("Test date() with property access in WHERE", async () => { + const runner = new Runner( + "UNWIND [1, 2, 3] AS x WITH x, date('2025-06-15') AS d WHERE d.quarter = 2 RETURN x" + ); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(3); // All 3 pass through since Q2 = 2 +}); + +test("Test datetime() with map argument", async () => { + const runner = new Runner( + "RETURN datetime({year: 2024, month: 12, day: 25, hour: 10, minute: 30}) AS dt" + ); + await runner.run(); + const dt = runner.results[0].dt; + expect(dt.year).toBe(2024); + expect(dt.month).toBe(12); + expect(dt.day).toBe(25); + expect(dt.quarter).toBe(4); // December = Q4 +}); + +test("Test date() with map argument", async () => { + const runner = new Runner("RETURN date({year: 2025, month: 3, day: 1}) AS d"); + await runner.run(); + const d = runner.results[0].d; + expect(d.year).toBe(2025); + expect(d.month).toBe(3); + expect(d.day).toBe(1); + expect(d.quarter).toBe(1); // March = Q1 +}); + +test("Test id() function with node", async () => { + await new Runner(` + CREATE VIRTUAL (:Person) AS { + UNWIND [ + {id: 1, name: 'Alice'}, + {id: 2, name: 'Bob'} + ] AS record + RETURN record.id AS id, record.name AS name + } + `).run(); + const match = new Runner(` + MATCH (n:Person) + RETURN id(n) AS nodeId + `); + await match.run(); + const results = match.results; + expect(results.length).toBe(2); + expect(results[0]).toEqual({ nodeId: 1 }); + expect(results[1]).toEqual({ nodeId: 2 }); +}); + +test("Test id() function with null", async () => { + const runner = new Runner("RETURN id(null) AS nodeId"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ nodeId: null }); +}); + +test("Test id() function with relationship", async () => { + await new Runner(` + CREATE VIRTUAL (:City) AS { + UNWIND [ + {id: 1, name: 'New York'}, + {id: 2, name: 'Boston'} + ] AS record + RETURN record.id AS id, record.name AS name + } + `).run(); + await new Runner(` + CREATE VIRTUAL (:City)-[:CONNECTED_TO]-(:City) AS { + UNWIND [ + {left_id: 1, right_id: 2} + ] AS record + RETURN record.left_id AS left_id, record.right_id AS right_id + } + `).run(); + const match = new Runner(` + MATCH (a:City)-[r:CONNECTED_TO]->(b:City) + RETURN id(r) AS relId + `); + await match.run(); + const results = match.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ relId: "CONNECTED_TO" }); +}); + +test("Test elementId() function with node", async () => { + await new Runner(` + CREATE VIRTUAL (:Person) AS { + UNWIND [ + {id: 1, name: 'Alice'}, + {id: 2, name: 'Bob'} + ] AS record + RETURN record.id AS id, record.name AS name + } + `).run(); + const match = new Runner(` + MATCH (n:Person) + RETURN elementId(n) AS eid + `); + await match.run(); + const results = match.results; + expect(results.length).toBe(2); + expect(results[0]).toEqual({ eid: "1" }); + expect(results[1]).toEqual({ eid: "2" }); +}); + +test("Test elementId() function with null", async () => { + const runner = new Runner("RETURN elementId(null) AS eid"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ eid: null }); +}); + +test("Test head() function", async () => { + const runner = new Runner("RETURN head([1, 2, 3]) AS h"); + await runner.run(); + expect(runner.results.length).toBe(1); + expect(runner.results[0]).toEqual({ h: 1 }); +}); + +test("Test head() function with empty list", async () => { + const runner = new Runner("RETURN head([]) AS h"); + await runner.run(); + expect(runner.results[0]).toEqual({ h: null }); +}); + +test("Test head() function with null", async () => { + const runner = new Runner("RETURN head(null) AS h"); + await runner.run(); + expect(runner.results[0]).toEqual({ h: null }); +}); + +test("Test tail() function", async () => { + const runner = new Runner("RETURN tail([1, 2, 3]) AS t"); + await runner.run(); + expect(runner.results.length).toBe(1); + expect(runner.results[0]).toEqual({ t: [2, 3] }); +}); + +test("Test tail() function with single element", async () => { + const runner = new Runner("RETURN tail([1]) AS t"); + await runner.run(); + expect(runner.results[0]).toEqual({ t: [] }); +}); + +test("Test tail() function with null", async () => { + const runner = new Runner("RETURN tail(null) AS t"); + await runner.run(); + expect(runner.results[0]).toEqual({ t: null }); +}); + +test("Test last() function", async () => { + const runner = new Runner("RETURN last([1, 2, 3]) AS l"); + await runner.run(); + expect(runner.results.length).toBe(1); + expect(runner.results[0]).toEqual({ l: 3 }); +}); + +test("Test last() function with empty list", async () => { + const runner = new Runner("RETURN last([]) AS l"); + await runner.run(); + expect(runner.results[0]).toEqual({ l: null }); +}); + +test("Test last() function with null", async () => { + const runner = new Runner("RETURN last(null) AS l"); + await runner.run(); + expect(runner.results[0]).toEqual({ l: null }); +}); + +test("Test toInteger() function with string", async () => { + const runner = new Runner('RETURN toInteger("42") AS i'); + await runner.run(); + expect(runner.results[0]).toEqual({ i: 42 }); +}); + +test("Test toInteger() function with float", async () => { + const runner = new Runner("RETURN toInteger(3.14) AS i"); + await runner.run(); + expect(runner.results[0]).toEqual({ i: 3 }); +}); + +test("Test toInteger() function with boolean", async () => { + const runner = new Runner("RETURN toInteger(true) AS i"); + await runner.run(); + expect(runner.results[0]).toEqual({ i: 1 }); +}); + +test("Test toInteger() function with null", async () => { + const runner = new Runner("RETURN toInteger(null) AS i"); + await runner.run(); + expect(runner.results[0]).toEqual({ i: null }); +}); + +test("Test toFloat() function with string", async () => { + const runner = new Runner('RETURN toFloat("3.14") AS f'); + await runner.run(); + expect(runner.results[0]).toEqual({ f: 3.14 }); +}); + +test("Test toFloat() function with integer", async () => { + const runner = new Runner("RETURN toFloat(42) AS f"); + await runner.run(); + expect(runner.results[0]).toEqual({ f: 42 }); +}); + +test("Test toFloat() function with boolean", async () => { + const runner = new Runner("RETURN toFloat(true) AS f"); + await runner.run(); + expect(runner.results[0]).toEqual({ f: 1.0 }); +}); + +test("Test toFloat() function with null", async () => { + const runner = new Runner("RETURN toFloat(null) AS f"); + await runner.run(); + expect(runner.results[0]).toEqual({ f: null }); +}); + +test("Test duration() with ISO 8601 string", async () => { + const runner = new Runner("RETURN duration('P1Y2M3DT4H5M6S') AS d"); + await runner.run(); + const d = runner.results[0].d; + expect(d.years).toBe(1); + expect(d.months).toBe(2); + expect(d.days).toBe(3); + expect(d.hours).toBe(4); + expect(d.minutes).toBe(5); + expect(d.seconds).toBe(6); + expect(d.totalMonths).toBe(14); + expect(d.formatted).toBe("P1Y2M3DT4H5M6S"); +}); + +test("Test duration() with map argument", async () => { + const runner = new Runner("RETURN duration({days: 14, hours: 16}) AS d"); + await runner.run(); + const d = runner.results[0].d; + expect(d.days).toBe(14); + expect(d.hours).toBe(16); + expect(d.totalDays).toBe(14); + expect(d.totalSeconds).toBe(57600); +}); + +test("Test duration() with weeks", async () => { + const runner = new Runner("RETURN duration('P2W') AS d"); + await runner.run(); + const d = runner.results[0].d; + expect(d.weeks).toBe(2); + expect(d.days).toBe(14); + expect(d.totalDays).toBe(14); +}); + +test("Test duration() with null", async () => { + const runner = new Runner("RETURN duration(null) AS d"); + await runner.run(); + expect(runner.results[0]).toEqual({ d: null }); +}); + +test("Test duration() with time only", async () => { + const runner = new Runner("RETURN duration('PT2H30M') AS d"); + await runner.run(); + const d = runner.results[0].d; + expect(d.hours).toBe(2); + expect(d.minutes).toBe(30); + expect(d.totalSeconds).toBe(9000); + expect(d.formatted).toBe("PT2H30M"); +});