Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
7f493e1
added neo4j functions tests
Feb 15, 2026
1496565
fixed factory
Feb 15, 2026
6c5270d
Revert "added elementid() (uses ID())"
Feb 15, 2026
41e378e
fixed some tests (expect 64bit, not 32)
Feb 15, 2026
38302fd
more 64bit fixes
Feb 15, 2026
978e109
fixed some errors
Feb 15, 2026
fa614f1
more errors fixes
Feb 15, 2026
9255052
Update engine/src/test/java/com/arcadedb/query/opencypher/functions/O…
ExtReMLapin Feb 16, 2026
2dcbdf0
Update engine/src/test/java/com/arcadedb/query/opencypher/functions/O…
ExtReMLapin Feb 16, 2026
e189ab6
fixed compilation error
Feb 18, 2026
e8949e1
fixed exception not being detected
Mar 19, 2026
51c364a
fixed missing exceptions
Mar 19, 2026
14d8c87
updated tests
Mar 19, 2026
db47e82
updated tests
Mar 20, 2026
a87e873
Coll insert/remove force second arg
Mar 20, 2026
effb6fd
point.distance cypher
Mar 20, 2026
00a43d8
cypher point use map like cypher, not like sql
Mar 20, 2026
21b6ca9
geo utils support map xy or longitude latitude
Mar 20, 2026
16b0dfc
left support two args error and neg len error
Mar 20, 2026
9f076a4
two args and neg len
Mar 20, 2026
8ed27f8
force 3 args for Replace function
Mar 20, 2026
2ee4675
lTrim support two args
Mar 20, 2026
b1c56e1
rtrim two args
Mar 20, 2026
47eca2a
substring better errors handling
Mar 20, 2026
586c09f
sync
Mar 20, 2026
e345a23
Round added precision and mode
Mar 20, 2026
d02c759
added timezones
Mar 20, 2026
948c8f9
updated vectors
Mar 20, 2026
bd3e5c7
sync
Mar 20, 2026
9b6d817
SQLFunctionStandardDeviation now returns 0.0 instead of null
Mar 20, 2026
f9263c8
Update engine/src/main/java/com/arcadedb/function/geo/CypherPointFunc…
ExtReMLapin Mar 20, 2026
62ebef3
updated old test
Mar 20, 2026
b70425b
sync
Apr 18, 2026
906dabb
sync
Apr 18, 2026
3a4b597
fixed codacy being a crybaby
Apr 18, 2026
b264a7e
address Gemini code review feedback on PR #3949
robfrank Apr 22, 2026
e8507a7
fix: add elementid to Cypher-specific function routing
robfrank Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,13 @@ public Object execute(final Object[] args, final CommandContext context) {
final List<Object> list = asList(args[0]);
if (list == null)
return null;
if (args[1] == null)
return null;
final int index = ((Number) args[1]).intValue();
if (index < 0)
throw new CommandExecutionException("coll.insert() does not support negative index: " + index);
if (index > list.size())
throw new CommandExecutionException("coll.insert() index " + index + " is out of range for list of size " + list.size());
final List<Object> result = new ArrayList<>(list);
result.add(index, args[2]);
return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,13 @@ public Object execute(final Object[] args, final CommandContext context) {
final List<Object> list = asList(args[0]);
if (list == null)
return null;
if (args[1] == null)
return null;

final int index = ((Number) args[1]).intValue();
if (index < 0 || index >= list.size())
if (index < 0)
throw new CommandExecutionException("coll.remove() does not support negative index: " + index);
if (index >= list.size())
throw new CommandExecutionException("coll.remove() index " + index + " is out of range for list of size " + list.size());
final int count = args.length > 2 ? ((Number) args[2]).intValue() : 1;
final List<Object> result = new ArrayList<>(list);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright © 2021-present Arcade Data Ltd ([email protected])
*
* Licensed 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.
*
* SPDX-FileCopyrightText: 2021-present Arcade Data Ltd ([email protected])
* SPDX-License-Identifier: Apache-2.0
*/
package com.arcadedb.function.geo;

import com.arcadedb.exception.CommandExecutionException;
import com.arcadedb.function.StatelessFunction;
import com.arcadedb.query.sql.executor.CommandContext;

import java.util.Map;

/**
* Cypher {@code point.distance(point1, point2)} function.
*
* <p>Computes the distance between two points. Uses Haversine formula for WGS-84
* geographic points (result in meters), and Euclidean distance for Cartesian points.</p>
*/
public class CypherPointDistanceFunction implements StatelessFunction {
private static final double EARTH_RADIUS_M = 6371000.0;

@Override
public String getName() {
return "point.distance";
}

@Override
public Object execute(final Object[] args, final CommandContext context) {
if (args == null || args.length != 2)
throw new CommandExecutionException("point.distance() requires exactly 2 arguments");
if (args[0] == null || args[1] == null)
return null;
if (!(args[0] instanceof Map) || !(args[1] instanceof Map))
throw new CommandExecutionException("point.distance() arguments must be point values (maps)");
final Map<?, ?> p1 = (Map<?, ?>) args[0];
final Map<?, ?> p2 = (Map<?, ?>) args[1];

// WGS-84: use Haversine formula
if (p1.containsKey("longitude") && p1.containsKey("latitude") &&
p2.containsKey("longitude") && p2.containsKey("latitude")) {
final Number lat1n = (Number) p1.get("latitude");
final Number lon1n = (Number) p1.get("longitude");
final Number lat2n = (Number) p2.get("latitude");
final Number lon2n = (Number) p2.get("longitude");
if (lat1n == null || lon1n == null || lat2n == null || lon2n == null)
return null;
return haversineDistance(lat1n.doubleValue(), lon1n.doubleValue(), lat2n.doubleValue(), lon2n.doubleValue());
}

// Cartesian: use Euclidean distance
final Number x1n = (Number) p1.get("x");
final Number y1n = (Number) p1.get("y");
final Number x2n = (Number) p2.get("x");
final Number y2n = (Number) p2.get("y");
if (x1n == null || y1n == null || x2n == null || y2n == null)
return null;
final double dx = x2n.doubleValue() - x1n.doubleValue();
final double dy = y2n.doubleValue() - y1n.doubleValue();
double sumSq = dx * dx + dy * dy;
final Number z1n = (Number) p1.get("z");
final Number z2n = (Number) p2.get("z");
if ((z1n == null) != (z2n == null))
return null;
if (z1n != null) {
final double dz = z2n.doubleValue() - z1n.doubleValue();
sumSq += dz * dz;
}
Comment on lines +74 to +81
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to Cypher specifications (e.g., Neo4j), point.distance() should return null if the points have different dimensions (e.g., comparing a 2D point with a 3D point). The current implementation falls back to a 2D distance calculation if one of the points is missing the 'z' coordinate, which may lead to inconsistent results compared to standard Cypher behavior.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added if ((z1n == null) != (z2n == null)) return null; before the z-distance calculation. Mixed 2D/3D Cartesian points now return null per Cypher spec. Added regression test pointDistanceMixedDimensions.

return Math.sqrt(sumSq);
}

private double haversineDistance(final double lat1, final double lon1, final double lat2, final double lon2) {
final double dLat = Math.toRadians(lat2 - lat1);
final double dLon = Math.toRadians(lon2 - lon1);
final double a = Math.pow(Math.sin(dLat / 2), 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.pow(Math.sin(dLon / 2), 2);
return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * EARTH_RADIUS_M;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,23 @@

import com.arcadedb.exception.CommandExecutionException;
import com.arcadedb.function.StatelessFunction;
import com.arcadedb.function.sql.geo.GeoUtils;
import com.arcadedb.function.sql.geo.LightweightPoint;
import com.arcadedb.query.sql.executor.CommandContext;

import java.util.LinkedHashMap;
import java.util.Map;

/**
* Cypher {@code point(lat, lon)} function.
*
* <p>Constructs a spatial point from latitude and longitude. Following Cypher/Neo4j convention,
* the first argument is latitude and the second is longitude. The point is stored internally
* using the spatial4j convention (x=longitude, y=latitude) so that spatial distance functions
* such as {@code geo.distance} operate correctly.</p>
* Cypher {@code point(map)} function.
*
* <p>Usage: {@code point(<latitude>, <longitude>)}</p>
* <p>Constructs a point from a map of coordinate properties. Supports:</p>
* <ul>
* <li>WGS-84 2D: {@code point({longitude: x, latitude: y})}</li>
* <li>WGS-84 3D: {@code point({longitude: x, latitude: y, height: z})}</li>
* <li>Cartesian 2D: {@code point({x: a, y: b})}</li>
* <li>Cartesian 3D: {@code point({x: a, y: b, z: c})}</li>
* </ul>
* <p>The returned map contains the coordinate keys and a {@code crs} field indicating
* the coordinate reference system.</p>
*/
public class CypherPointFunction implements StatelessFunction {
@Override
Expand All @@ -42,13 +46,84 @@ public String getName() {

@Override
public Object execute(final Object[] args, final CommandContext context) {
if (args == null || args.length < 2)
throw new CommandExecutionException("point() requires latitude and longitude as parameters");
if (args[0] == null || args[1] == null)
if (args == null || args.length == 0 || args.length > 2)
throw new CommandExecutionException("point() requires either one map argument (point({...})) or two numeric arguments (point(latitude, longitude))");

// 2-arg positional form: point(latitude, longitude) → WGS-84 2D
if (args.length == 2) {
if (args[0] == null || args[1] == null)
return null;
if (!(args[0] instanceof Number) || !(args[1] instanceof Number))
throw new CommandExecutionException("point() with two arguments requires numeric latitude and longitude");
final double lat = ((Number) args[0]).doubleValue();
final double lon = ((Number) args[1]).doubleValue();
final Map<String, Object> result = new LinkedHashMap<>();
result.put("latitude", lat);
result.put("longitude", lon);
result.put("x", lon);
result.put("y", lat);
result.put("crs", "WGS-84");
result.put("srid", 4326);
return result;
}

if (args[0] == null)
return null;
final double lat = GeoUtils.getDoubleValue(args[0]);
final double lon = GeoUtils.getDoubleValue(args[1]);
// Store as LightweightPoint(x=longitude, y=latitude) per spatial4j convention
return new LightweightPoint(lon, lat);
if (!(args[0] instanceof Map))
throw new CommandExecutionException("point() argument must be a map with coordinate properties");
final Map<?, ?> map = (Map<?, ?>) args[0];

final Map<String, Object> result = new LinkedHashMap<>();

if (map.containsKey("longitude") || map.containsKey("latitude")) {
// WGS-84 coordinate system
final Object lon = map.get("longitude");
final Object lat = map.get("latitude");
if (lon == null || lat == null)
return null;
final double x = ((Number) lon).doubleValue();
final double y = ((Number) lat).doubleValue();
result.put("longitude", x);
result.put("latitude", y);
result.put("x", x);
result.put("y", y);
addOptionalZ(result, map);
result.put("crs", result.containsKey("z") ? "WGS-84-3D" : "WGS-84");
result.put("srid", result.containsKey("z") ? 4979 : 4326);
} else if (map.containsKey("x") || map.containsKey("y")) {
// Cartesian coordinate system
final Object xv = map.get("x");
final Object yv = map.get("y");
if (xv == null || yv == null)
return null;
final double x = ((Number) xv).doubleValue();
final double y = ((Number) yv).doubleValue();
result.put("x", x);
result.put("y", y);
addOptionalZ(result, map);
final Object crsObj = map.get("crs");
if (crsObj != null)
result.put("crs", crsObj.toString());
else
result.put("crs", result.containsKey("z") ? "cartesian-3D" : "cartesian");
if (map.containsKey("srid"))
result.put("srid", ((Number) map.get("srid")).intValue());
} else {
throw new CommandExecutionException("point() map must contain x/y or longitude/latitude properties");
}

return result;
}

private void addOptionalZ(final Map<String, Object> result, final Map<?, ?> map) {
if (map.containsKey("z")) {
final Object zv = map.get("z");
if (zv != null)
result.put("z", ((Number) zv).doubleValue());
} else if (map.containsKey("height")) {
final Object hv = map.get("height");
if (hv != null)
result.put("z", ((Number) hv).doubleValue());
}
}
}
21 changes: 18 additions & 3 deletions engine/src/main/java/com/arcadedb/function/math/RoundFunction.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public String getName() {

@Override
public Object execute(final Object[] args, final CommandContext context) {
if (args.length < 1 || args.length > 2)
if (args.length < 1 || args.length > 3)
throw new CommandExecutionException("round() requires one or two arguments");

if (args[0] == null)
Expand All @@ -61,7 +61,7 @@ public Object execute(final Object[] args, final CommandContext context) {
return (double) Math.round(value);
}

// round(value, precision)
// round(value, precision) or round(value, precision, mode)
if (args[1] == null)
return null;

Expand All @@ -70,7 +70,22 @@ public Object execute(final Object[] args, final CommandContext context) {

final int precision = ((Number) args[1]).intValue();

final BigDecimal bd = BigDecimal.valueOf(value).setScale(precision, RoundingMode.HALF_UP);
RoundingMode mode = RoundingMode.HALF_UP;
if (args.length == 3 && args[2] != null) {
final String modeStr = args[2].toString().toUpperCase().replace(" ", "_");
mode = switch (modeStr) {
case "UP" -> RoundingMode.UP;
case "DOWN" -> RoundingMode.DOWN;
case "CEILING" -> RoundingMode.CEILING;
case "FLOOR" -> RoundingMode.FLOOR;
case "HALF_UP" -> RoundingMode.HALF_UP;
case "HALF_DOWN" -> RoundingMode.HALF_DOWN;
case "HALF_EVEN" -> RoundingMode.HALF_EVEN;
default -> throw new CommandExecutionException("round() unknown rounding mode: " + args[2]);
};
}

final BigDecimal bd = BigDecimal.valueOf(value).setScale(precision, mode);
return bd.doubleValue();
}
}
16 changes: 16 additions & 0 deletions engine/src/main/java/com/arcadedb/function/sql/geo/GeoUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import org.locationtech.spatial4j.shape.jts.JtsGeometry;

import java.util.Locale;
import java.util.Map;

/**
* Geospatial utility class.
Expand Down Expand Up @@ -69,6 +70,21 @@ public static Shape parseGeometry(final Object value) {
return null;
if (value instanceof Shape shape)
return shape;
// Cypher point() returns a Map with x/y or longitude/latitude keys
if (value instanceof Map<?, ?> map) {
double x;
double y;
if (map.containsKey("x") && map.containsKey("y")) {
x = ((Number) map.get("x")).doubleValue();
y = ((Number) map.get("y")).doubleValue();
} else if (map.containsKey("longitude") && map.containsKey("latitude")) {
x = ((Number) map.get("longitude")).doubleValue();
y = ((Number) map.get("latitude")).doubleValue();
} else {
throw new IllegalArgumentException("Cannot parse geometry from map: missing x/y or longitude/latitude keys");
}
return SPATIAL_CONTEXT.getShapeFactory().pointXY(x, y);
}
final String wkt = value.toString().trim();
if (wkt.isEmpty())
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ public Object getResult() {
final Object variance = super.getResult();
if (variance != null)
return Math.sqrt((Double) variance);
return null;
return 0.0;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Changing the return value from null to 0.0 when the variance is null aligns with Cypher's stDev() behavior for empty sets, but it breaks standard SQL semantics where aggregate functions over an empty set (except COUNT) should return NULL. Since this class is part of the SQL function package, this change might cause regressions in SQL queries. Consider handling the null to 0.0 conversion in the Cypher-specific layer instead.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 0.0 return is intentional. The Cypher tests stDevNull and stDevPNull in OpenCypherAggregatingFunctionsComprehensiveTest explicitly assert 0.0 for stDev([null, null]), matching Neo4j's documented behavior. No SQL test currently asserts that stddev returns null on an empty/all-null set - the SQL test suite only covers non-empty datasets. Moving this to a Cypher layer would be a larger refactor beyond the scope of this PR.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Map;

/**
Expand All @@ -48,8 +49,18 @@ public Object execute(final Object[] args, final CommandContext context) {
return CypherFunctionHelper.getStatementTime(context).get("date");
if (args[0] == null)
return null;
if (args[0] instanceof String)
return CypherDate.parse((String) args[0]);
if (args[0] instanceof String) {
final String str = (String) args[0];
try {
return CypherDate.parse(str);
} catch (final Exception e) {
try {
return new CypherDate(LocalDate.now(ZoneId.of(str)));
} catch (final Exception e2) {
throw new CommandExecutionException("date() cannot parse '" + str + "' as a date or timezone");
}
}
}
if (args[0] instanceof Map)
return CypherDate.fromMap((Map<String, Object>) args[0]);
if (args[0] instanceof CypherDate)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Map;

/**
Expand All @@ -48,8 +50,18 @@ public Object execute(final Object[] args, final CommandContext context) {
return CypherFunctionHelper.getStatementTime(context).get("datetime");
if (args[0] == null)
return null;
if (args[0] instanceof String)
return CypherDateTime.parse((String) args[0]);
if (args[0] instanceof String) {
final String str = (String) args[0];
try {
return CypherDateTime.parse(str);
} catch (final Exception e) {
try {
return new CypherDateTime(ZonedDateTime.now(ZoneId.of(str)));
} catch (final Exception e2) {
throw new CommandExecutionException("datetime() cannot parse '" + str + "' as a datetime or timezone");
}
}
}
if (args[0] instanceof Map)
return CypherDateTime.fromMap((Map<String, Object>) args[0]);
if (args[0] instanceof CypherDateTime)
Expand Down
Loading
Loading