From c2ca9333516c0816d7fd766ee3d996ec5a3ababc Mon Sep 17 00:00:00 2001
From: Rabari Mihir <169349389+Mihir-Rabari@users.noreply.github.com>
Date: Sat, 17 Oct 2026 12:10:42 +0000
Subject: [PATCH 1/5] fix(api): fold Cypher temporal constructors into bound
date values
The translator dependency has a fixed function list without temporal
functions, so the server folds date(), datetime() and localdatetime()
calls with foldable arguments into synthetic bound parameters carrying
native date values before translation. Calls that cannot be folded keep
failing with the translator's original unknown-function error.
Signed-off-by: Rabari Mihir <169349389+Mihir-Rabari@users.noreply.github.com>
---
docs/cypher-api.md | 13 +
.../opencypher/CypherOpProcessor.java | 3 +
.../opencypher/CypherTemporalFunctions.java | 338 ++++++++++++++++++
.../apache/hugegraph/api/CypherApiTest.java | 30 +-
.../unit/api/cypher/CypherErrorTest.java | 2 +-
.../cypher/CypherTemporalFunctionTest.java | 161 +++++++++
6 files changed, 541 insertions(+), 6 deletions(-)
create mode 100644 hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
create mode 100644 hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
diff --git a/docs/cypher-api.md b/docs/cypher-api.md
index 66202a2249..3390b6ea80 100644
--- a/docs/cypher-api.md
+++ b/docs/cypher-api.md
@@ -131,6 +131,19 @@ Parameterized POST requests are already provided by merged
The earlier [bound-query fork #238](https://github.com/hugegraph/hugegraph/pull/238)
is a historical reference; use the current request contract above.
+## Temporal functions
+
+The built-in translator has no temporal functions of its own. The Server folds
+the `date()`, `datetime()` and `localdatetime()` constructors into native date
+values before translation, so these calls work wherever a bound value is
+accepted, for example `MATCH (n) WHERE n.birthday = date('2000-01-01') RETURN n`.
+A constructor folds when it has no argument (the current date or date-time), a
+string literal argument, or a parameter bound to a string; `date()` uses the
+`yyyy-MM-dd` form and the other two use `yyyy-MM-ddTHH:mm:ss[.SSS]`. Arguments
+that are not foldable, other temporal forms such as map or duration arguments,
+and the remaining temporal functions (`time()`, `localtime()`, `duration()`)
+keep failing translation with `Unknown function` as before.
+
## Regression checks
Run the mapper, real-parser and envelope tests from the repository root:
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherOpProcessor.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherOpProcessor.java
index 6097dbeed9..9c97c833c6 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherOpProcessor.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherOpProcessor.java
@@ -200,6 +200,9 @@ private void evalCypher(Context context) throws OpProcessorException {
ProcedureContext procedureContext = ProcedureContext.global();
CypherAst ast = CypherAst.parse(cypher, parameters, procedureContext.getSignatures());
validateParameters(ast, parameters);
+ // The translator has no temporal functions; fold the supported
+ // constructors into bound values before it rejects them.
+ parameters = CypherTemporalFunctions.fold(ast, parameters);
String translatorDefinition = getTranslatorDefinition(context);
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
new file mode 100644
index 0000000000..94a151ae07
--- /dev/null
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
@@ -0,0 +1,338 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hugegraph.opencypher;
+
+import static org.slf4j.LoggerFactory.getLogger;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.text.ParsePosition;
+import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.opencypher.gremlin.translation.CypherAst;
+import org.opencypher.v9_0.expressions.FunctionInvocation;
+import org.opencypher.v9_0.expressions.Parameter;
+import org.opencypher.v9_0.expressions.StringLiteral;
+import org.opencypher.v9_0.util.InputPosition;
+import org.opencypher.v9_0.util.symbols.AnyType;
+import org.slf4j.Logger;
+
+import scala.Option;
+import scala.Product;
+import scala.Some;
+import scala.Tuple2;
+import scala.collection.JavaConverters;
+import scala.collection.Seq;
+
+/**
+ * Rewrites the Cypher temporal constructor functions that the cypher-for-gremlin
+ * translator does not implement into parameters bound to native date values.
+ *
+ * The translator's function list is fixed inside its own dependency, so the
+ * supported constructors (date, datetime, localdatetime) are folded into the
+ * parsed statement right before translation: each call with a foldable argument
+ * becomes a synthetic bound parameter and the date value is added to the
+ * bindings. Calls that cannot be folded are left in place and keep failing
+ * with the translator's original error, as do all other temporal functions.
+ */
+public final class CypherTemporalFunctions {
+
+ private static final Logger logger = getLogger(CypherTemporalFunctions.class);
+
+ private static final String DATE = "date";
+ private static final String DATETIME = "datetime";
+ private static final String LOCALDATETIME = "localdatetime";
+
+ private static final String[] DATE_FORMATS = {"yyyy-MM-dd"};
+ private static final String[] DATETIME_FORMATS = {"yyyy-MM-dd'T'HH:mm:ss",
+ "yyyy-MM-dd'T'HH:mm:ss.SSS"};
+
+ // Two leading spaces: parameter names coming from a Cypher query can
+ // never collide with this prefix, like the translator's own reserved names.
+ private static final String SYNTHETIC_PREFIX = " cypher.fold.";
+
+ private CypherTemporalFunctions() {
+ }
+
+ /**
+ * Folds the supported temporal constructors in the parsed statement and
+ * returns the effective request parameters for translation: the same
+ * instance when nothing was folded, a copy carrying the synthetic
+ * bindings otherwise. The parsed statement is only replaced when the
+ * rewrite completed without errors, so a fold failure leaves the query
+ * exactly as it was parsed.
+ */
+ public static Map fold(CypherAst ast, Map parameters) {
+ TemporalFold fold = new TemporalFold(parameters);
+ try {
+ Field statementField = CypherAst.class.getDeclaredField("statement");
+ Field parametersField = CypherAst.class.getDeclaredField("parameters");
+ statementField.setAccessible(true);
+ parametersField.setAccessible(true);
+
+ Object folded = fold.copy(ast.statement());
+ if (fold.bindings.isEmpty()) {
+ return parameters;
+ }
+ Object merged = mergeParameters(parametersField.get(ast), fold.bindings);
+ statementField.set(ast, folded);
+ parametersField.set(ast, merged);
+ } catch (Exception e) {
+ logger.warn("Failed to fold temporal functions, keeping the parsed statement", e);
+ return parameters;
+ }
+ Map effective = new LinkedHashMap<>(parameters);
+ effective.putAll(fold.bindings);
+ return effective;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Object mergeParameters(Object parameters, Map bindings) {
+ scala.collection.immutable.Map merged =
+ (scala.collection.immutable.Map) parameters;
+ for (Map.Entry binding : bindings.entrySet()) {
+ merged = merged.$plus(new Tuple2<>(binding.getKey(), binding.getValue()));
+ }
+ return merged;
+ }
+
+ private static final class TemporalFold {
+
+ private final Map parameters;
+ private final Map bindings = new LinkedHashMap<>();
+ private int counter = 0;
+
+ private TemporalFold(Map parameters) {
+ this.parameters = parameters;
+ }
+
+ /**
+ * Returns a copy of the node with temporal constructors folded in, or
+ * the same instance when the subtree does not change. Subtrees that
+ * cannot be rebuilt are shared with the original statement, so the
+ * copy stays minimal.
+ */
+ private Object copy(Object node) throws Exception {
+ if (node == null || node instanceof String || node instanceof Number ||
+ node instanceof Boolean || node instanceof Character) {
+ return node;
+ }
+ if (node instanceof FunctionInvocation) {
+ Object folded = foldCall((FunctionInvocation) node);
+ if (folded != null) {
+ return folded;
+ }
+ }
+ if (node instanceof Option) {
+ Option> option = (Option>) node;
+ if (!option.isEmpty()) {
+ Object value = option.get();
+ Object copied = copy(value);
+ if (copied != value) {
+ return new Some<>(copied);
+ }
+ }
+ return node;
+ }
+ if (node instanceof scala.collection.IndexedSeq) {
+ return copyIndexedSeq((scala.collection.IndexedSeq>) node);
+ }
+ if (node instanceof Seq) {
+ return copySeq((Seq>) node);
+ }
+ if (node instanceof scala.collection.immutable.Set) {
+ return copySet((scala.collection.immutable.Set>) node);
+ }
+ if (node instanceof Tuple2) {
+ Tuple2, ?> tuple = (Tuple2, ?>) node;
+ Object first = copy(tuple._1());
+ Object second = copy(tuple._2());
+ return first == tuple._1() && second == tuple._2() ? node : new Tuple2<>(first, second);
+ }
+ if (node instanceof Product) {
+ return rebuild((Product) node);
+ }
+ return node;
+ }
+
+ private Object foldCall(FunctionInvocation call) {
+ String name = call.name();
+ if (!DATE.equals(name) && !DATETIME.equals(name) && !LOCALDATETIME.equals(name)) {
+ return null;
+ }
+ if (call.distinct() || !call.namespace().parts().isEmpty()) {
+ return null;
+ }
+ Seq> arguments = call.args();
+ Date value;
+ if (arguments.isEmpty()) {
+ value = currentValue(name);
+ } else if (arguments.size() == 1) {
+ String literal = literalOf(arguments.head());
+ if (literal == null) {
+ return null;
+ }
+ value = parse(name, literal);
+ if (value == null) {
+ return null;
+ }
+ } else {
+ return null;
+ }
+ String binding = SYNTHETIC_PREFIX + name + '.' + counter++;
+ this.bindings.put(binding, value);
+ return new Parameter(binding, AnyType.instance(), call.position());
+ }
+
+ private String literalOf(Object argument) {
+ if (argument instanceof StringLiteral) {
+ return ((StringLiteral) argument).value();
+ }
+ if (argument instanceof Parameter) {
+ Object bound = this.parameters.get(((Parameter) argument).name());
+ if (bound instanceof String) {
+ return (String) bound;
+ }
+ }
+ return null;
+ }
+
+ private static Date currentValue(String name) {
+ if (DATE.equals(name)) {
+ return Date.from(LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant());
+ }
+ return new Date();
+ }
+
+ private static Date parse(String name, String literal) {
+ String[] patterns = DATE.equals(name) ? DATE_FORMATS : DATETIME_FORMATS;
+ for (String pattern : patterns) {
+ SimpleDateFormat format = new SimpleDateFormat(pattern, Locale.ROOT);
+ format.setLenient(false);
+ // Require the literal to match the pattern completely, so a
+ // shorter pattern cannot silently drop a trailing part.
+ ParsePosition position = new ParsePosition(0);
+ Date parsed = format.parse(literal, position);
+ if (parsed != null && position.getIndex() == literal.length()) {
+ return parsed;
+ }
+ }
+ return null;
+ }
+
+ private Object copyIndexedSeq(scala.collection.IndexedSeq> seq) throws Exception {
+ List copies = new ArrayList<>(seq.size());
+ boolean changed = false;
+ scala.collection.Iterator> iterator = seq.iterator();
+ while (iterator.hasNext()) {
+ Object element = iterator.next();
+ Object copied = copy(element);
+ changed |= copied != element;
+ copies.add(copied);
+ }
+ if (!changed) {
+ return seq;
+ }
+ return scala.collection.immutable.IndexedSeq$.MODULE$.apply(JavaConverters.asScalaBuffer(copies));
+ }
+
+ private Object copySeq(Seq> seq) throws Exception {
+ List copies = new ArrayList<>(seq.size());
+ boolean changed = false;
+ scala.collection.Iterator> iterator = seq.iterator();
+ while (iterator.hasNext()) {
+ Object element = iterator.next();
+ Object copied = copy(element);
+ changed |= copied != element;
+ copies.add(copied);
+ }
+ if (!changed) {
+ return seq;
+ }
+ return JavaConverters.asScalaBuffer(copies).toList();
+ }
+
+ private Object copySet(scala.collection.immutable.Set> set) throws Exception {
+ List copies = new ArrayList<>(set.size());
+ boolean changed = false;
+ scala.collection.Iterator> iterator = set.iterator();
+ while (iterator.hasNext()) {
+ Object element = iterator.next();
+ Object copied = copy(element);
+ changed |= copied != element;
+ copies.add(copied);
+ }
+ if (!changed) {
+ return set;
+ }
+ scala.collection.Set result = scala.collection.immutable.Set$.MODULE$.empty();
+ for (Object element : copies) {
+ result = result.$plus(element);
+ }
+ return result;
+ }
+
+ private Object rebuild(Product product) throws Exception {
+ int arity = product.productArity();
+ Object[] elements = new Object[arity];
+ boolean changed = false;
+ for (int i = 0; i < arity; i++) {
+ Object element = product.productElement(i);
+ Object copied = copy(element);
+ changed |= copied != element;
+ elements[i] = copied;
+ }
+ if (!changed) {
+ return product;
+ }
+ Constructor> constructor = constructorOf(product.getClass(), arity);
+ constructor.setAccessible(true);
+ Object[] arguments = new Object[constructor.getParameterCount()];
+ System.arraycopy(elements, 0, arguments, 0, arity);
+ if (constructor.getParameterCount() == arity + 1) {
+ arguments[arity] = product.getClass().getMethod("position").invoke(product);
+ }
+ return constructor.newInstance(arguments);
+ }
+
+ private static Constructor> constructorOf(Class> clazz, int arity) {
+ for (Constructor> constructor : clazz.getDeclaredConstructors()) {
+ if (constructor.getParameterCount() == arity + 1) {
+ Class>[] types = constructor.getParameterTypes();
+ if (InputPosition.class.isAssignableFrom(types[arity])) {
+ return constructor;
+ }
+ }
+ }
+ for (Constructor> constructor : clazz.getDeclaredConstructors()) {
+ if (constructor.getParameterCount() == arity) {
+ return constructor;
+ }
+ }
+ throw new IllegalStateException("No matching constructor in " + clazz.getName());
+ }
+ }
+}
diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java
index cf05c73181..12a61701c5 100644
--- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java
+++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java
@@ -57,15 +57,15 @@ public void prepareSchema() {
String schema = URL_PREFIX + "/schema/";
for (String[] property : new String[][]{{"name", "TEXT"}, {"age", "INT"},
{"city", "TEXT"}, {"active", "BOOLEAN"},
- {"note", "TEXT"}}) {
+ {"note", "TEXT"}, {"birthday", "DATE"}}) {
createAndAssert(schema + "propertykeys", JsonUtil.toJson(ImmutableMap.of(
"name", property[0], "data_type", property[1],
"cardinality", "SINGLE")), 202);
}
createAndAssert(schema + "vertexlabels", "{\"name\":\"cypher_person\", " +
- "\"id_strategy\":\"PRIMARY_KEY\",\"primary_keys\":[\"name\"]," +
- "\"properties\":[\"name\",\"age\",\"city\",\"active\",\"note\"]," +
- "\"nullable_keys\":[\"active\",\"note\"]}");
+ "\"id_strategy\":\"PRIMARY_KEY\",\"primary_keys\":[\"name\"], " +
+ "\"properties\":[\"name\",\"age\",\"city\",\"active\",\"note\"," +
+ "\"birthday\"],\"nullable_keys\":[\"active\",\"note\",\"birthday\"]}");
createAndAssert(schema + "edgelabels", "{\"name\":\"cypher_knows\", " +
"\"source_label\":\"cypher_person\",\"target_label\":\"cypher_person\"," +
"\"frequency\":\"SINGLE\",\"properties\":[],\"nullable_keys\":[]}");
@@ -79,7 +79,7 @@ public void prepareSchema() {
.put("fields", Collections.singletonList(index[0]))
.put("rebuild", false).build()), 202);
}
- addVertex("marko", 20, "Beijing", true);
+ addVertex("marko", 20, "Beijing", true, "2000-01-01");
addVertex("peter", 30, "Beijing", false);
addVertex("alice", 40, "Shanghai", true);
addVertex("dave", 30, "Shanghai", false);
@@ -122,6 +122,19 @@ public void testComputedRegexRequiresWholeStringMatch() {
Assert.assertTrue(assertCypherSuccessData(content).isEmpty());
}
+ @Test
+ public void testDateFunctionMatchesDateProperty() {
+ assertColumn(query("MATCH (n:cypher_person) WHERE n.birthday = date('2000-01-01') " +
+ "RETURN n.name AS name"), "name", "marko");
+ }
+
+ @Test
+ public void testDateFunctionYieldsNoMatchOnTextProperty() {
+ Map, ?> row = assertSingleMapRow(query(
+ "MATCH (n:cypher_person {note: date('2024-02-05')}) RETURN count(n) AS count"));
+ Assert.assertEquals(0L, ((Number) row.get("count")).longValue());
+ }
+
@Test
public void testCreate() {
assertColumn(query("CREATE (n:cypher_person {name:'test', age:20, city:'Hefei'}) " +
@@ -623,6 +636,13 @@ private void addVertex(String name, int age, String city, boolean active) {
"name", name, "age", age, "city", city, "active", active))));
}
+ private void addVertex(String name, int age, String city, boolean active, String birthday) {
+ createAndAssert(URL_PREFIX + "/graph/vertices", JsonUtil.toJson(ImmutableMap.of(
+ "label", "cypher_person", "properties", ImmutableMap.of(
+ "name", name, "age", age, "city", city, "active", active,
+ "birthday", birthday))));
+ }
+
private void addEdge(String source, String target) {
createAndAssert(URL_PREFIX + "/graph/edges", JsonUtil.toJson(
ImmutableMap.builder()
diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherErrorTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherErrorTest.java
index f2d25204c3..f8c59e1427 100644
--- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherErrorTest.java
+++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherErrorTest.java
@@ -99,7 +99,7 @@ public void testRealParserFailures() {
"MATCH (n:person RETURN n",
"MATCH (n:person) RETRUN n",
"MATCH (n) RETURN n; MATCH (m) RETURN m",
- "RETURN date()",
+ "RETURN duration('P1Y')",
"MATCH (n:person) RETURN not_defined_var",
"MATCH (n) RETURN n.age + 'a' * true",
"CREATE (n) CREATE (n) RETURN n",
diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
new file mode 100644
index 0000000000..0397d2a7ac
--- /dev/null
+++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hugegraph.unit.api.cypher;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.hugegraph.opencypher.CypherTemporalFunctions;
+import org.junit.Assert;
+import org.junit.Test;
+import org.opencypher.gremlin.translation.CypherAst;
+import org.opencypher.gremlin.translation.translator.Translator;
+
+public class CypherTemporalFunctionTest {
+
+ @Test
+ public void testDateLiteralFoldsInPropertyMatch() throws Exception {
+ Map.Entry> folded = translate(
+ "MATCH (n {d0: date('2024-02-05')}) RETURN count(*)",
+ Collections.emptyMap());
+
+ Assert.assertTrue(folded.getKey(), folded.getKey().contains("g.V()"));
+ Assert.assertEquals(expectedDate("yyyy-MM-dd", "2024-02-05"),
+ syntheticBinding(folded.getValue(), " cypher.fold.date."));
+ }
+
+ @Test
+ public void testBareDateFoldsToStartOfToday() throws Exception {
+ Map.Entry> folded =
+ translate("RETURN date()", Collections.emptyMap());
+
+ Assert.assertEquals(Date.from(LocalDate.now()
+ .atStartOfDay(ZoneId.systemDefault())
+ .toInstant()),
+ syntheticBinding(folded.getValue(), " cypher.fold.date."));
+ }
+
+ @Test
+ public void testDateWithParameterArgumentFolds() throws Exception {
+ Map parameters = new HashMap<>();
+ parameters.put("d", "2024-02-05");
+ Map.Entry> folded = translate(
+ "MATCH (n) WHERE n.d0 = date($d) RETURN count(*)", parameters);
+
+ Assert.assertEquals(expectedDate("yyyy-MM-dd", "2024-02-05"),
+ syntheticBinding(folded.getValue(), " cypher.fold.date."));
+ Assert.assertEquals("2024-02-05", folded.getValue().get("d"));
+ }
+
+ @Test
+ public void testDatetimeAndLocaldatetimeFold() throws Exception {
+ Map.Entry> datetime = translate(
+ "RETURN datetime('2024-02-05T10:15:30')", Collections.emptyMap());
+ Assert.assertEquals(expectedDate("yyyy-MM-dd'T'HH:mm:ss", "2024-02-05T10:15:30"),
+ syntheticBinding(datetime.getValue(), " cypher.fold.datetime."));
+
+ Map.Entry> localdatetime = translate(
+ "RETURN localdatetime('2024-02-05T10:15:30.123')", Collections.emptyMap());
+ Assert.assertEquals(expectedDate("yyyy-MM-dd'T'HH:mm:ss.SSS", "2024-02-05T10:15:30.123"),
+ syntheticBinding(localdatetime.getValue(), " cypher.fold.localdatetime."));
+ }
+
+ @Test
+ public void testDateInsideAnotherCallFolds() throws Exception {
+ Map.Entry> folded = translate(
+ "RETURN coalesce(date('2024-02-05'), 'x')", Collections.emptyMap());
+
+ Assert.assertNotNull(folded.getKey());
+ Assert.assertEquals(expectedDate("yyyy-MM-dd", "2024-02-05"),
+ syntheticBinding(folded.getValue(), " cypher.fold.date."));
+ }
+
+ @Test
+ public void testDateInCreatePropertyFolds() throws Exception {
+ Map.Entry> folded = translate(
+ "CREATE (n:person {birthday: date('2000-01-01')}) RETURN n",
+ Collections.emptyMap());
+
+ Assert.assertNotNull(folded.getKey());
+ Assert.assertEquals(expectedDate("yyyy-MM-dd", "2000-01-01"),
+ syntheticBinding(folded.getValue(), " cypher.fold.date."));
+ }
+
+ @Test
+ public void testUnfoldableDateStaysUnknownFunction() {
+ for (String query : new String[]{"MATCH (n) RETURN date(n.x)",
+ "RETURN date('tomorrow')"}) {
+ Exception failure = Assert.assertThrows(Exception.class,
+ () -> translate(query, Collections.emptyMap()));
+ Assert.assertTrue(query, failure.getMessage().contains("Unknown function 'date'"));
+ }
+ }
+
+ @Test
+ public void testDurationStaysUnknownFunction() {
+ Exception failure = Assert.assertThrows(Exception.class,
+ () -> translate("RETURN duration('P1Y')", Collections.emptyMap()));
+ Assert.assertTrue(failure.getMessage(), failure.getMessage()
+ .contains("Unknown function 'duration'"));
+ }
+
+ @Test
+ public void testQueryWithoutTemporalFunctionsKeepsParameters() {
+ Map parameters = new HashMap<>();
+ parameters.put("name", "marko");
+ Map.Entry> folded = translate(
+ "MATCH (n) WHERE n.name = $name RETURN n", parameters);
+
+ Assert.assertSame(parameters, folded.getValue());
+ Assert.assertTrue(folded.getKey(), folded.getKey().contains("marko"));
+ }
+
+ private static Map.Entry> translate(String query,
+ Map parameters) {
+ CypherAst ast = CypherAst.parse(query, parameters);
+ Map effective = CypherTemporalFunctions.fold(ast, parameters);
+ String gremlin = ast.buildTranslation(Translator.builder().gremlinGroovy()
+ .build("gremlin+cfog_server_extensions" +
+ "+inline_parameters"));
+ return new java.util.AbstractMap.SimpleImmutableEntry<>(gremlin, effective);
+ }
+
+ private static Date expectedDate(String pattern, String literal) throws ParseException {
+ SimpleDateFormat format = new SimpleDateFormat(pattern, Locale.ROOT);
+ format.setLenient(false);
+ return format.parse(literal);
+ }
+
+ private static Date syntheticBinding(Map parameters, String prefix) {
+ for (Map.Entry entry : parameters.entrySet()) {
+ if (entry.getKey().startsWith(prefix)) {
+ Assert.assertTrue(entry.getKey(), entry.getValue() instanceof Date);
+ return (Date) entry.getValue();
+ }
+ }
+ Assert.fail("Missing synthetic binding with prefix " + prefix);
+ return null;
+ }
+}
From fe0c1bb5d1ceb9349830c7be90b80a16d368a4c3 Mon Sep 17 00:00:00 2001
From: Rabari Mihir <169349389+Mihir-Rabari@users.noreply.github.com>
Date: Sat, 17 Oct 2026 18:49:52 +0000
Subject: [PATCH 2/5] fix(api): fold temporal calls under Ands via
JavaConverters
scala.collection.Set.$plus compiles against the generic SetLike
signature, which scala-library does not match at runtime, so any fold
under an Ands (labelled MATCH with WHERE, inline property map, or AND)
failed with NoSuchMethodError instead of degrading to the unknown
function error. Rebuild the set with JavaConverters.asScalaBuffer like
the Seq paths, catch LinkageError with the existing Exception fallback
so a link failure keeps the parsed statement, and register
CypherTemporalFunctionTest in UnitTestSuite so the fold tests run in
CI.
Signed-off-by: Rabari Mihir <169349389+Mihir-Rabari@users.noreply.github.com>
---
.../opencypher/CypherTemporalFunctions.java | 8 +---
.../apache/hugegraph/unit/UnitTestSuite.java | 2 +
.../cypher/CypherTemporalFunctionTest.java | 41 +++++++++++++++++++
3 files changed, 45 insertions(+), 6 deletions(-)
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
index 94a151ae07..529c239d76 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
@@ -100,7 +100,7 @@ public static Map fold(CypherAst ast, Map parame
Object merged = mergeParameters(parametersField.get(ast), fold.bindings);
statementField.set(ast, folded);
parametersField.set(ast, merged);
- } catch (Exception e) {
+ } catch (Exception | LinkageError e) {
logger.warn("Failed to fold temporal functions, keeping the parsed statement", e);
return parameters;
}
@@ -288,11 +288,7 @@ private Object copySet(scala.collection.immutable.Set> set) throws Exception {
if (!changed) {
return set;
}
- scala.collection.Set result = scala.collection.immutable.Set$.MODULE$.empty();
- for (Object element : copies) {
- result = result.$plus(element);
- }
- return result;
+ return JavaConverters.asScalaBuffer(copies).toSet();
}
private Object rebuild(Product product) throws Exception {
diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
index 7219372239..0f94c50f72 100644
--- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
+++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
@@ -45,6 +45,7 @@
import org.apache.hugegraph.traversal.optimize.TraversalUtilOptimizeTest;
import org.apache.hugegraph.unit.api.auth.LoginAPITest;
import org.apache.hugegraph.unit.api.cypher.CypherErrorTest;
+import org.apache.hugegraph.unit.api.cypher.CypherTemporalFunctionTest;
import org.apache.hugegraph.unit.api.filter.AccessLogFilterTest;
import org.apache.hugegraph.unit.api.filter.CompressInterceptorTest;
import org.apache.hugegraph.unit.api.filter.LoadDetectFilterTest;
@@ -155,6 +156,7 @@
/* api cypher */
CypherErrorTest.class,
+ CypherTemporalFunctionTest.class,
/* api gremlin */
GremlinQueryAPITest.class,
diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
index 0397d2a7ac..2e154603af 100644
--- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
+++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
@@ -21,9 +21,11 @@
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.ZoneId;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
+import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -103,6 +105,45 @@ public void testDateInCreatePropertyFolds() throws Exception {
syntheticBinding(folded.getValue(), " cypher.fold.date."));
}
+ @Test
+ public void testDateInLabelledMatchWhereFolds() throws Exception {
+ Map.Entry> folded = translate(
+ "MATCH (n:cypher_person) WHERE n.birthday = date('2000-01-01') RETURN n.name AS name",
+ Collections.emptyMap());
+
+ Assert.assertTrue(folded.getKey(), folded.getKey().contains("g.V()"));
+ Assert.assertEquals(expectedDate("yyyy-MM-dd", "2000-01-01"),
+ syntheticBinding(folded.getValue(), " cypher.fold.date."));
+ }
+
+ @Test
+ public void testDateInLabelledPropertyMapFolds() throws Exception {
+ Map.Entry> folded = translate(
+ "MATCH (n:cypher_person {note: date('2024-02-05')}) RETURN count(n) AS count",
+ Collections.emptyMap());
+
+ Assert.assertTrue(folded.getKey(), folded.getKey().contains("g.V()"));
+ Assert.assertEquals(expectedDate("yyyy-MM-dd", "2024-02-05"),
+ syntheticBinding(folded.getValue(), " cypher.fold.date."));
+ }
+
+ @Test
+ public void testDateUnderAndRangeFolds() throws Exception {
+ Map.Entry> folded = translate(
+ "MATCH (n) WHERE n.d0 > date('2024-02-05') AND n.d0 < date('2025-01-01') RETURN n",
+ Collections.emptyMap());
+
+ List foldedDates = new ArrayList<>();
+ for (Map.Entry entry : folded.getValue().entrySet()) {
+ if (entry.getKey().startsWith(" cypher.fold.date.")) {
+ foldedDates.add(entry.getValue());
+ }
+ }
+ Assert.assertEquals(2, foldedDates.size());
+ Assert.assertTrue(foldedDates.remove(expectedDate("yyyy-MM-dd", "2024-02-05")));
+ Assert.assertTrue(foldedDates.remove(expectedDate("yyyy-MM-dd", "2025-01-01")));
+ }
+
@Test
public void testUnfoldableDateStaysUnknownFunction() {
for (String query : new String[]{"MATCH (n) RETURN date(n.x)",
From c983b270f4a633207ebd4bd08e160106c1a8b8bc Mon Sep 17 00:00:00 2001
From: Rabari Mihir <169349389+Mihir-Rabari@users.noreply.github.com>
Date: Sat, 17 Oct 2026 23:15:40 +0000
Subject: [PATCH 3/5] fix(api): read temporal literal fractions as fractions of
a second
Signed-off-by: Rabari Mihir <169349389+Mihir-Rabari@users.noreply.github.com>
---
.../opencypher/CypherTemporalFunctions.java | 32 ++++++++-----------
.../cypher/CypherTemporalFunctionTest.java | 28 +++++++++++++++-
2 files changed, 41 insertions(+), 19 deletions(-)
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
index 529c239d76..9c20c6bf9e 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
@@ -21,15 +21,14 @@
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
-import java.text.ParsePosition;
-import java.text.SimpleDateFormat;
import java.time.LocalDate;
+import java.time.LocalDateTime;
import java.time.ZoneId;
+import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
-import java.util.Locale;
import java.util.Map;
import org.opencypher.gremlin.translation.CypherAst;
@@ -66,10 +65,6 @@ public final class CypherTemporalFunctions {
private static final String DATETIME = "datetime";
private static final String LOCALDATETIME = "localdatetime";
- private static final String[] DATE_FORMATS = {"yyyy-MM-dd"};
- private static final String[] DATETIME_FORMATS = {"yyyy-MM-dd'T'HH:mm:ss",
- "yyyy-MM-dd'T'HH:mm:ss.SSS"};
-
// Two leading spaces: parameter names coming from a Cypher query can
// never collide with this prefix, like the translator's own reserved names.
private static final String SYNTHETIC_PREFIX = " cypher.fold.";
@@ -228,19 +223,20 @@ private static Date currentValue(String name) {
}
private static Date parse(String name, String literal) {
- String[] patterns = DATE.equals(name) ? DATE_FORMATS : DATETIME_FORMATS;
- for (String pattern : patterns) {
- SimpleDateFormat format = new SimpleDateFormat(pattern, Locale.ROOT);
- format.setLenient(false);
- // Require the literal to match the pattern completely, so a
- // shorter pattern cannot silently drop a trailing part.
- ParsePosition position = new ParsePosition(0);
- Date parsed = format.parse(literal, position);
- if (parsed != null && position.getIndex() == literal.length()) {
- return parsed;
+ // ISO parsing reads the fractional part as a fraction of a
+ // second, like Cypher and the server's own date parsing do.
+ try {
+ if (DATE.equals(name)) {
+ return Date.from(LocalDate.parse(literal)
+ .atStartOfDay(ZoneId.systemDefault())
+ .toInstant());
}
+ return Date.from(LocalDateTime.parse(literal)
+ .atZone(ZoneId.systemDefault())
+ .toInstant());
+ } catch (DateTimeParseException e) {
+ return null;
}
- return null;
}
private Object copyIndexedSeq(scala.collection.IndexedSeq> seq) throws Exception {
diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
index 2e154603af..ee62c4d8cd 100644
--- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
+++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
@@ -22,12 +22,14 @@
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
+import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.TimeZone;
import org.apache.hugegraph.opencypher.CypherTemporalFunctions;
import org.junit.Assert;
@@ -84,6 +86,20 @@ public void testDatetimeAndLocaldatetimeFold() throws Exception {
syntheticBinding(localdatetime.getValue(), " cypher.fold.localdatetime."));
}
+ @Test
+ public void testFractionalSecondsFoldAsFractionOfSecond() throws Exception {
+ Map.Entry> datetime = translate(
+ "RETURN datetime('2024-02-05T10:15:30.5')", Collections.emptyMap());
+ Assert.assertEquals(expectedDate(2024, 2, 5, 10, 15, 30, 500),
+ syntheticBinding(datetime.getValue(), " cypher.fold.datetime."));
+
+ Map.Entry> localdatetime = translate(
+ "RETURN localdatetime('2024-02-05T10:15:30.12')", Collections.emptyMap());
+ Assert.assertEquals(expectedDate(2024, 2, 5, 10, 15, 30, 120),
+ syntheticBinding(localdatetime.getValue(),
+ " cypher.fold.localdatetime."));
+ }
+
@Test
public void testDateInsideAnotherCallFolds() throws Exception {
Map.Entry> folded = translate(
@@ -147,7 +163,8 @@ public void testDateUnderAndRangeFolds() throws Exception {
@Test
public void testUnfoldableDateStaysUnknownFunction() {
for (String query : new String[]{"MATCH (n) RETURN date(n.x)",
- "RETURN date('tomorrow')"}) {
+ "RETURN date('tomorrow')",
+ "RETURN date('2024-2-5')"}) {
Exception failure = Assert.assertThrows(Exception.class,
() -> translate(query, Collections.emptyMap()));
Assert.assertTrue(query, failure.getMessage().contains("Unknown function 'date'"));
@@ -189,6 +206,15 @@ private static Date expectedDate(String pattern, String literal) throws ParseExc
return format.parse(literal);
}
+ private static Date expectedDate(int year, int month, int day, int hour,
+ int minute, int second, int millis) {
+ Calendar calendar = Calendar.getInstance(TimeZone.getDefault(), Locale.ROOT);
+ calendar.clear();
+ calendar.set(year, month - 1, day, hour, minute, second);
+ calendar.set(Calendar.MILLISECOND, millis);
+ return calendar.getTime();
+ }
+
private static Date syntheticBinding(Map parameters, String prefix) {
for (Map.Entry entry : parameters.entrySet()) {
if (entry.getKey().startsWith(prefix)) {
From fe5daa6d63e758062fff1d30533e49cf6e24f734 Mon Sep 17 00:00:00 2001
From: Rabari Mihir <169349389+Mihir-Rabari@users.noreply.github.com>
Date: Sun, 18 Oct 2026 01:17:06 +0000
Subject: [PATCH 4/5] fix(api): fold temporal constructors case-insensitively
and collision-free
Fold function names case-insensitively like the Cypher parser and
translator do, skip synthetic binding names the request already
occupies, read the clock once per statement for bare constructors,
and print folded dates in the EXPLAIN translation as explicit
new Date(...) constructors.
Signed-off-by: Rabari Mihir <169349389+Mihir-Rabari@users.noreply.github.com>
---
docs/cypher-api.md | 3 +
.../opencypher/CypherOpProcessor.java | 4 +-
.../opencypher/CypherTemporalFunctions.java | 77 ++++++++++++++--
.../cypher/CypherTemporalFunctionTest.java | 89 +++++++++++++++++++
4 files changed, 165 insertions(+), 8 deletions(-)
diff --git a/docs/cypher-api.md b/docs/cypher-api.md
index 3390b6ea80..12bad68e94 100644
--- a/docs/cypher-api.md
+++ b/docs/cypher-api.md
@@ -143,6 +143,9 @@ string literal argument, or a parameter bound to a string; `date()` uses the
that are not foldable, other temporal forms such as map or duration arguments,
and the remaining temporal functions (`time()`, `localtime()`, `duration()`)
keep failing translation with `Unknown function` as before.
+Constructor names are case-insensitive like the rest of Cypher, and the bare
+constructors of one statement read the clock once. EXPLAIN prints a folded
+value as an explicit `new Date(...)` constructor instead of a stringified date.
## Regression checks
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherOpProcessor.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherOpProcessor.java
index 9c97c833c6..8125b5566e 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherOpProcessor.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherOpProcessor.java
@@ -219,7 +219,9 @@ private void evalCypher(Context context) throws OpProcessorException {
Seq ir = ast.translate(strTranslator.flavor(),
strTranslator.features(), procedureContext);
- String gremlin = TranslationWriter.write(ir, strTranslator, parameters);
+ Map explainParameters =
+ CypherTemporalFunctions.explainParameters(parameters);
+ String gremlin = TranslationWriter.write(ir, strTranslator, explainParameters);
logger.trace("Gremlin: {}", gremlin);
if (ast.getOptions().contains(EXPLAIN)) {
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
index 9c20c6bf9e..9b9406bf52 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
@@ -21,6 +21,7 @@
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
+import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
@@ -29,6 +30,7 @@
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import org.opencypher.gremlin.translation.CypherAst;
@@ -65,8 +67,9 @@ public final class CypherTemporalFunctions {
private static final String DATETIME = "datetime";
private static final String LOCALDATETIME = "localdatetime";
- // Two leading spaces: parameter names coming from a Cypher query can
- // never collide with this prefix, like the translator's own reserved names.
+ // Two leading spaces keep the prefix away from ordinary parameter names;
+ // backtick-escaped Cypher parameters can still carry it, so generated
+ // names skip every one the request already occupies.
private static final String SYNTHETIC_PREFIX = " cypher.fold.";
private CypherTemporalFunctions() {
@@ -114,10 +117,57 @@ private static Object mergeParameters(Object parameters, Map bin
return merged;
}
+ /**
+ * Returns a copy of the given parameters in which folded temporal values
+ * are replaced by a printable constructor form, so the string translation
+ * stays executable Groovy instead of rendering a date through toString.
+ */
+ public static Map explainParameters(Map parameters) {
+ boolean folded = false;
+ for (String name : parameters.keySet()) {
+ if (name.startsWith(SYNTHETIC_PREFIX)) {
+ folded = true;
+ break;
+ }
+ }
+ if (!folded) {
+ return parameters;
+ }
+ Map explainable = new LinkedHashMap<>(parameters);
+ for (Map.Entry entry : explainable.entrySet()) {
+ if (entry.getKey().startsWith(SYNTHETIC_PREFIX) &&
+ entry.getValue() instanceof Date) {
+ entry.setValue(new GroovyDate((Date) entry.getValue()));
+ }
+ }
+ return explainable;
+ }
+
+ /**
+ * Renders a folded date as an explicit constructor so that the EXPLAIN
+ * translation of the string writer stays valid Groovy.
+ */
+ private static final class GroovyDate {
+
+ private final Date date;
+
+ private GroovyDate(Date date) {
+ this.date = date;
+ }
+
+ @Override
+ public String toString() {
+ return "new Date(" + this.date.getTime() + "L)";
+ }
+ }
+
private static final class TemporalFold {
private final Map parameters;
private final Map bindings = new LinkedHashMap<>();
+ // One clock read per statement: the bare constructors of a query
+ // derive from a single instant.
+ private final Instant now = Instant.now();
private int counter = 0;
private TemporalFold(Map parameters) {
@@ -174,7 +224,9 @@ private Object copy(Object node) throws Exception {
}
private Object foldCall(FunctionInvocation call) {
- String name = call.name();
+ // Function names resolve case-insensitively, like the parser and
+ // the translator do, so match the lower-case spelling.
+ String name = call.name().toLowerCase(Locale.ROOT);
if (!DATE.equals(name) && !DATETIME.equals(name) && !LOCALDATETIME.equals(name)) {
return null;
}
@@ -197,11 +249,20 @@ private Object foldCall(FunctionInvocation call) {
} else {
return null;
}
- String binding = SYNTHETIC_PREFIX + name + '.' + counter++;
+ String binding = nextBindingName(name);
this.bindings.put(binding, value);
return new Parameter(binding, AnyType.instance(), call.position());
}
+ private String nextBindingName(String name) {
+ String binding = SYNTHETIC_PREFIX + name + '.' + this.counter++;
+ while (this.parameters.containsKey(binding) ||
+ this.bindings.containsKey(binding)) {
+ binding = SYNTHETIC_PREFIX + name + '.' + this.counter++;
+ }
+ return binding;
+ }
+
private String literalOf(Object argument) {
if (argument instanceof StringLiteral) {
return ((StringLiteral) argument).value();
@@ -215,11 +276,13 @@ private String literalOf(Object argument) {
return null;
}
- private static Date currentValue(String name) {
+ private Date currentValue(String name) {
if (DATE.equals(name)) {
- return Date.from(LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant());
+ return Date.from(LocalDate.ofInstant(this.now, ZoneId.systemDefault())
+ .atStartOfDay(ZoneId.systemDefault())
+ .toInstant());
}
- return new Date();
+ return Date.from(this.now);
}
private static Date parse(String name, String literal) {
diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
index ee62c4d8cd..da44775a4e 100644
--- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
+++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
@@ -35,7 +35,12 @@
import org.junit.Assert;
import org.junit.Test;
import org.opencypher.gremlin.translation.CypherAst;
+import org.opencypher.gremlin.translation.groovy.GroovyPredicate;
+import org.opencypher.gremlin.translation.ir.TranslationWriter;
+import org.opencypher.gremlin.translation.ir.model.GremlinStep;
import org.opencypher.gremlin.translation.translator.Translator;
+import org.opencypher.gremlin.traversal.ProcedureContext;
+import scala.collection.Seq;
public class CypherTemporalFunctionTest {
@@ -190,6 +195,90 @@ public void testQueryWithoutTemporalFunctionsKeepsParameters() {
Assert.assertTrue(folded.getKey(), folded.getKey().contains("marko"));
}
+ @Test
+ public void testUpperAndMixedCaseConstructorsFold() throws Exception {
+ Map.Entry> upper = translate(
+ "MATCH (n) WHERE n.d0 = DATE('2000-01-01') RETURN n",
+ Collections.emptyMap());
+ Assert.assertEquals(expectedDate("yyyy-MM-dd", "2000-01-01"),
+ syntheticBinding(upper.getValue(), " cypher.fold.date."));
+
+ Map.Entry> mixed = translate(
+ "RETURN DateTime('2024-02-05T10:15:30')", Collections.emptyMap());
+ Assert.assertEquals(expectedDate("yyyy-MM-dd'T'HH:mm:ss", "2024-02-05T10:15:30"),
+ syntheticBinding(mixed.getValue(), " cypher.fold.datetime."));
+
+ Map.Entry> local = translate(
+ "RETURN localDateTime('2024-02-05T10:15:30')", Collections.emptyMap());
+ Assert.assertEquals(expectedDate("yyyy-MM-dd'T'HH:mm:ss", "2024-02-05T10:15:30"),
+ syntheticBinding(local.getValue(), " cypher.fold.localdatetime."));
+ }
+
+ @Test
+ public void testSyntheticNameSkipsOccupiedLiteralBinding() throws Exception {
+ Map parameters = new HashMap<>();
+ parameters.put(" cypher.fold.date.0", "original");
+ Map.Entry> folded = translate(
+ "RETURN date('2024-02-05') AS d, $` cypher.fold.date.0` AS other", parameters);
+
+ Assert.assertEquals("original", folded.getValue().get(" cypher.fold.date.0"));
+ Assert.assertEquals(expectedDate("yyyy-MM-dd", "2024-02-05"),
+ folded.getValue().get(" cypher.fold.date.1"));
+ Assert.assertTrue(folded.getKey(), folded.getKey().contains("original"));
+ }
+
+ @Test
+ public void testSyntheticNameSkipsOccupiedParameterBinding() throws Exception {
+ Map parameters = new HashMap<>();
+ parameters.put("d", "2024-02-05");
+ parameters.put(" cypher.fold.date.0", "original");
+ Map.Entry> folded = translate(
+ "RETURN date($d) AS d, $` cypher.fold.date.0` AS other", parameters);
+
+ Assert.assertEquals("original", folded.getValue().get(" cypher.fold.date.0"));
+ Assert.assertEquals(expectedDate("yyyy-MM-dd", "2024-02-05"),
+ folded.getValue().get(" cypher.fold.date.1"));
+ Assert.assertEquals("2024-02-05", folded.getValue().get("d"));
+ }
+
+ @Test
+ public void testBareConstructorsShareOneInstant() throws Exception {
+ Map.Entry> folded = translate(
+ "CREATE (a {t: datetime()}), (b {t: datetime()}) RETURN count(*)",
+ Collections.emptyMap());
+
+ List values = new ArrayList<>();
+ for (Map.Entry entry : folded.getValue().entrySet()) {
+ if (entry.getKey().startsWith(" cypher.fold.datetime.")) {
+ values.add((Date) entry.getValue());
+ }
+ }
+ Assert.assertEquals(2, values.size());
+ Assert.assertEquals(values.get(0), values.get(1));
+ }
+
+ @Test
+ public void testExplainTranslationPrintsFoldedDatesAsConstructors() {
+ String query = "MATCH (n) WHERE n.d0 = date('2000-01-01') RETURN n";
+ Map empty = Collections.emptyMap();
+ CypherAst ast = CypherAst.parse(query, empty);
+ Map effective = CypherTemporalFunctions.fold(ast, empty);
+ Date folded = syntheticBinding(effective, " cypher.fold.date.");
+
+ Translator strTranslator = Translator.builder()
+ .gremlinGroovy()
+ .build("gremlin+cfog_server_extensions" +
+ "+inline_parameters");
+ Seq ir = ast.translate(strTranslator.flavor(),
+ strTranslator.features(),
+ ProcedureContext.global());
+ String gremlin = TranslationWriter.write(ir, strTranslator,
+ CypherTemporalFunctions.explainParameters(effective));
+
+ Assert.assertTrue(gremlin, gremlin.contains("new Date(" + folded.getTime() + "L)"));
+ Assert.assertFalse(gremlin, gremlin.contains(folded.toString()));
+ }
+
private static Map.Entry> translate(String query,
Map parameters) {
CypherAst ast = CypherAst.parse(query, parameters);
From 74363d1ef34face0be80b5bc71f775a66f269ce3 Mon Sep 17 00:00:00 2001
From: Rabari Mihir <169349389+Mihir-Rabari@users.noreply.github.com>
Date: Sun, 18 Oct 2026 02:16:11 +0000
Subject: [PATCH 5/5] fix(api): carry expression types into the folded
statement
The fold replaced the statement and parameters of the parsed AST but kept the parse-time expression type map keyed by the original nodes, so a RETURN item typed as a node or relationship that contains a folded temporal constructor lost its valueMap() step and the normalizer cast the raw element to a Map. Record every visited expression with its rebuilt copy and add the inferred types to the map for the copies.
Signed-off-by: Rabari Mihir <169349389+Mihir-Rabari@users.noreply.github.com>
---
.../opencypher/CypherTemporalFunctions.java | 44 ++++++++++++++++++-
.../cypher/CypherTemporalFunctionTest.java | 19 ++++++++
2 files changed, 62 insertions(+), 1 deletion(-)
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
index 9b9406bf52..60c2132872 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/opencypher/CypherTemporalFunctions.java
@@ -28,17 +28,20 @@
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Date;
+import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.opencypher.gremlin.translation.CypherAst;
+import org.opencypher.v9_0.expressions.Expression;
import org.opencypher.v9_0.expressions.FunctionInvocation;
import org.opencypher.v9_0.expressions.Parameter;
import org.opencypher.v9_0.expressions.StringLiteral;
import org.opencypher.v9_0.util.InputPosition;
import org.opencypher.v9_0.util.symbols.AnyType;
+import org.opencypher.v9_0.util.symbols.CypherType;
import org.slf4j.Logger;
import scala.Option;
@@ -81,23 +84,28 @@ private CypherTemporalFunctions() {
* instance when nothing was folded, a copy carrying the synthetic
* bindings otherwise. The parsed statement is only replaced when the
* rewrite completed without errors, so a fold failure leaves the query
- * exactly as it was parsed.
+ * exactly as it was parsed. The inferred types of the rebuilt expressions
+ * are carried over with the statement.
*/
public static Map fold(CypherAst ast, Map parameters) {
TemporalFold fold = new TemporalFold(parameters);
try {
Field statementField = CypherAst.class.getDeclaredField("statement");
Field parametersField = CypherAst.class.getDeclaredField("parameters");
+ Field typesField = CypherAst.class.getDeclaredField("expressionTypes");
statementField.setAccessible(true);
parametersField.setAccessible(true);
+ typesField.setAccessible(true);
Object folded = fold.copy(ast.statement());
if (fold.bindings.isEmpty()) {
return parameters;
}
Object merged = mergeParameters(parametersField.get(ast), fold.bindings);
+ Object types = carryTypes(typesField.get(ast), fold.copies);
statementField.set(ast, folded);
parametersField.set(ast, merged);
+ typesField.set(ast, types);
} catch (Exception | LinkageError e) {
logger.warn("Failed to fold temporal functions, keeping the parsed statement", e);
return parameters;
@@ -117,6 +125,29 @@ private static Object mergeParameters(Object parameters, Map bin
return merged;
}
+ /**
+ * Returns the expression type map extended with the types of the rebuilt
+ * expressions, so a statement the fold touched still finds the type of
+ * every expression, for example the valueMap() decision for a RETURN
+ * item that holds a node or relationship.
+ */
+ @SuppressWarnings("unchecked")
+ private static Object carryTypes(Object types, Map copies) {
+ scala.collection.immutable.Map expressionTypes =
+ (scala.collection.immutable.Map) types;
+ scala.collection.immutable.Map carried = expressionTypes;
+ for (Map.Entry pair : copies.entrySet()) {
+ if (pair.getKey() == pair.getValue()) {
+ continue;
+ }
+ Option type = expressionTypes.get((Expression) pair.getKey());
+ if (type.isDefined()) {
+ carried = carried.$plus(new Tuple2<>((Expression) pair.getValue(), type.get()));
+ }
+ }
+ return carried;
+ }
+
/**
* Returns a copy of the given parameters in which folded temporal values
* are replaced by a printable constructor form, so the string translation
@@ -165,6 +196,9 @@ private static final class TemporalFold {
private final Map parameters;
private final Map bindings = new LinkedHashMap<>();
+ // Identity pairs of every visited expression and its rebuilt copy;
+ // the inferred types travel to the copies after the fold.
+ private final Map copies = new IdentityHashMap<>();
// One clock read per statement: the bare constructors of a query
// derive from a single instant.
private final Instant now = Instant.now();
@@ -181,6 +215,14 @@ private TemporalFold(Map parameters) {
* copy stays minimal.
*/
private Object copy(Object node) throws Exception {
+ Object copied = copyNode(node);
+ if (node instanceof Expression) {
+ this.copies.put(node, copied);
+ }
+ return copied;
+ }
+
+ private Object copyNode(Object node) throws Exception {
if (node == null || node instanceof String || node instanceof Number ||
node instanceof Boolean || node instanceof Character) {
return node;
diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
index da44775a4e..6f63c79f72 100644
--- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
+++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/cypher/CypherTemporalFunctionTest.java
@@ -279,6 +279,25 @@ public void testExplainTranslationPrintsFoldedDatesAsConstructors() {
Assert.assertFalse(gremlin, gremlin.contains(folded.toString()));
}
+ @Test
+ public void testCaseReturnWithFoldedDateKeepsValueMap() throws Exception {
+ Map parameters = new HashMap<>();
+ parameters.put("d", expectedDate("yyyy-MM-dd", "2000-01-01"));
+ Map.Entry> bound = translate(
+ "MATCH (n) RETURN CASE WHEN n.d > $d THEN n END AS m", parameters);
+ Assert.assertTrue(bound.getKey(), bound.getKey().contains("valueMap"));
+
+ Map.Entry> folded = translate(
+ "MATCH (n) RETURN CASE WHEN n.d > date('2000-01-01') THEN n END AS m",
+ Collections.emptyMap());
+ Assert.assertTrue(folded.getKey(), folded.getKey().contains("valueMap"));
+
+ Map.Entry> relationship = translate(
+ "MATCH (a)-[r]->(b) RETURN CASE WHEN r.since > date('2000-01-01') THEN r END AS rel",
+ Collections.emptyMap());
+ Assert.assertTrue(relationship.getKey(), relationship.getKey().contains("valueMap"));
+ }
+
private static Map.Entry> translate(String query,
Map parameters) {
CypherAst ast = CypherAst.parse(query, parameters);