From 42c63f7e78126baffc67ca87c355807a3d28a0b6 Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 12:29:55 +0100 Subject: [PATCH 01/47] docs: add geospatial indexing design document Design for porting OrientDB geospatial indexing to ArcadeDB using LSM-Tree as storage backend (following the LSMTreeFullTextIndex pattern) and lucene-spatial-extras for GeoHash decomposition. Covers ST_* SQL functions, IndexableSQLFunction integration for automatic query optimizer usage, and WKT as geometry storage format. Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-02-22-geospatial-design.md | 224 +++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/plans/2026-02-22-geospatial-design.md diff --git a/docs/plans/2026-02-22-geospatial-design.md b/docs/plans/2026-02-22-geospatial-design.md new file mode 100644 index 0000000000..110ce2f45e --- /dev/null +++ b/docs/plans/2026-02-22-geospatial-design.md @@ -0,0 +1,224 @@ +# Geospatial Indexing Design + +**Date:** 2026-02-22 +**Branch:** lsmtree-geospatial +**Status:** Approved + +## Overview + +Port OrientDB-style geospatial indexing to ArcadeDB, using the native LSM-Tree engine as storage (following the same pattern as `LSMTreeFullTextIndex`) and the OGC/PostGIS `ST_*` SQL function naming convention. + +## Goals + +- Support all OGC spatial predicate functions OrientDB supported: `ST_Within`, `ST_Intersects`, `ST_Contains`, `ST_DWithin`, `ST_Disjoint`, `ST_Equals`, `ST_Crosses`, `ST_Overlaps`, `ST_Touches` +- Replace existing non-standard geo functions (`point()`, `distance()`, `circle()`, etc.) with `ST_*` equivalents +- Automatic query optimizer integration — no explicit `search_index()` call needed +- WKT as the geometry storage format (consistent with existing partial support) +- LSM-Tree as storage backend (ACID, WAL, HA, compaction all inherited for free) + +## Non-Goals + +- GeoJSON storage format +- New native schema `Type` entries for geometry (WKT strings in existing STRING properties) +- 3D geometry support +- Raster data + +## Architecture + +### Layers + +``` +SQL Query: WHERE ST_Within(location, ST_GeomFromText('POLYGON(...)')) = true + │ + ▼ + SelectExecutionPlanner + detects IndexableSQLFunction on ST_Within + calls allowsIndexedExecution() + │ + ▼ + LSMTreeGeoIndex.get(shape) + decomposes shape → GeoHash tokens via lucene-spatial-extras + looks up each token in underlying LSMTreeIndex + returns candidate RIDs + │ + ▼ + ST_Within.shouldExecuteAfterSearch() = true + → exact Spatial4j predicate post-filters candidates +``` + +### Dependencies + +- `lucene-spatial-extras` (version 10.3.2, Apache 2.0) — adds `GeohashPrefixTree` and `RecursivePrefixTreeStrategy` for geometry decomposition into GeoHash tokens. Lucene core is already a dependency; this is a sibling module. +- `spatial4j` 0.8 — already present +- `jts-core` 1.20.0 — already present + +## Component 1: LSMTreeGeoIndex + +**Package:** `com.arcadedb.index.geospatial` +**File:** `LSMTreeGeoIndex.java` + +Wraps `LSMTreeIndex` (identical to how `LSMTreeFullTextIndex` wraps it). + +### Indexing (`put(keys, rid)`) + +1. Parse the WKT string value using Spatial4j/JTS → `Shape` +2. Call `RecursivePrefixTreeStrategy.createIndexableFields(shape)` → Lucene `Field[]` +3. Extract string token values from the `TextField` among those fields +4. Store each token → RID in the underlying `LSMTreeIndex` (non-unique) + +### Querying (`get(keys)`) + +1. The key is a `Shape` (passed from the ST_* function) +2. Generate covering GeoHash cells via `SpatialArgs` + `RecursivePrefixTreeStrategy` +3. Extract cell token strings from the Lucene query +4. Look up each token in the LSM-Tree, union all matching RIDs +5. Return `TempIndexCursor` (candidates; exact post-filter happens in the ST_* function) + +### Configuration + +- **Precision level:** configurable at index creation (default: 11, ~2.4m resolution). Stored in index metadata JSON. +- **Metadata class:** `GeoIndexMetadata` (analogous to `FullTextIndexMetadata`) + +### Schema Registration + +- Add `GEOSPATIAL` to `Schema.INDEX_TYPE` enum +- Register `LSMTreeGeoIndex.GeoIndexFactoryHandler` in `LocalSchema` alongside `LSM_TREE`, `FULL_TEXT`, `LSM_VECTOR` + +## Component 2: ST_* SQL Functions + +**Package:** `com.arcadedb.function.sql.geo` +**Registered in:** `DefaultSQLFunctionFactory` + +### Constructor / Accessor Functions (pure compute, no index) + +| Function | Replaces | Notes | +|---|---|---| +| `ST_GeomFromText(wkt)` | — | Parse any WKT string → Spatial4j `Shape` | +| `ST_Point(x, y)` | `point(x,y)` | Returns Spatial4j `Point` as WKT | +| `ST_LineString(pts)` | `lineString(pts)` | | +| `ST_Polygon(pts)` | `polygon(pts)` | | +| `ST_Buffer(geom, dist)` | `circle(c,r)` | OGC buffer around any geometry | +| `ST_Envelope(geom)` | `rectangle(pts)` | Bounding rectangle as WKT | +| `ST_Distance(g1, g2 [,unit])` | `distance(...)` | Haversine; keeps SQL and Cypher-style params | +| `ST_Area(geom)` | — | Area in square degrees via Spatial4j | +| `ST_AsText(geom)` | — | Spatial4j `Shape` → WKT string | +| `ST_AsGeoJson(geom)` | — | Shape → GeoJSON string via JTS | +| `ST_X(point)` | — | Extract X coordinate | +| `ST_Y(point)` | — | Extract Y coordinate | + +### Spatial Predicate Functions (implement `SQLFunction` + `IndexableSQLFunction`) + +| Function | Semantics | Post-filter | +|---|---|---| +| `ST_Within(g, shape)` | g is fully within shape | yes | +| `ST_Intersects(g, shape)` | g and shape share any point | yes | +| `ST_Contains(g, shape)` | g fully contains shape | yes | +| `ST_DWithin(g, shape, dist)` | g is within dist of shape | yes | +| `ST_Disjoint(g, shape)` | g and shape share no points | yes | +| `ST_Equals(g, shape)` | geometrically equal | yes | +| `ST_Crosses(g, shape)` | g crosses shape | yes | +| `ST_Overlaps(g, shape)` | g overlaps shape | yes | +| `ST_Touches(g, shape)` | g touches shape boundary | yes | + +All predicates return `null` when either argument is null (SQL three-valued logic). + +Each predicate's `IndexableSQLFunction` implementation: +- `allowsIndexedExecution()` — returns `true` when first argument is a bare field reference AND a `GEOSPATIAL` index exists on that field in the target type +- `canExecuteInline()` — always `true` (falls back to full-scan with exact Spatial4j predicate if no index) +- `shouldExecuteAfterSearch()` — always `true` (index returns superset; exact predicate post-filters) +- `searchFromTarget()` — resolves the field's `LSMTreeGeoIndex`, evaluates the shape argument, calls `index.get(shape)`, returns `Iterable` + +## Component 3: Query Optimizer Integration + +No changes to `SelectExecutionPlanner` required. The existing `indexedFunctionConditions` path fully supports this pattern: + +1. `block.getIndexedFunctionConditions(typez, context)` collects conditions where the left `Expression` is a function call implementing `IndexableSQLFunction` +2. `ST_Within.allowsIndexedExecution()` checks for a `GEOSPATIAL` index on the referenced field +3. `BinaryCondition.executeIndexedFunction()` → `ST_Within.searchFromTarget()` executes the indexed search +4. `shouldExecuteAfterSearch() = true` → exact post-filter applied to all returned candidates + +**Multi-bucket:** `searchFromTarget()` iterates all per-bucket `LSMTreeGeoIndex` instances via `TypeIndex.getIndexesOnBuckets()` and unions results, matching the full-text search pattern. + +## Error Handling + +| Scenario | Behavior | +|---|---| +| Invalid WKT in `ST_GeomFromText()` | `IllegalArgumentException` with clear message | +| Null geometry argument in predicate | returns `null` (three-valued SQL logic) | +| No geospatial index on field | falls back to full-scan; no error | +| Non-WKT value in indexed property | `put()` skips record, logs warning | +| Antimeridian / polar shapes | handled correctly by `GeohashPrefixTree` | +| Precision change after indexing | must rebuild index (same as full-text analyzer change) | + +## Testing + +All tests in `engine/src/test/java/com/arcadedb/`: + +### `index/geospatial/LSMTreeGeoIndexTest` +- Index and query a point; verify RID returned +- Index and query a circle; verify candidates include nearby points +- Index and query a polygon; verify post-filter removes false positives +- Null / invalid WKT handling +- No-index fallback path + +### `function/sql/geo/SQLGeoFunctionsTest` (extend existing) +- All ST_* constructor and accessor functions +- Verify old `point()`, `distance()`, etc. throw "unknown function" + +### `function/sql/geo/SQLGeoIndexedQueryTest` (new) +- Create type with `GEOSPATIAL` index on WKT property +- Insert records with point WKT values at known coordinates +- `SELECT ... WHERE ST_Within(...) = true` — verify correct results +- `SELECT ... WHERE ST_Intersects(...) = true` — verify +- `SELECT ... WHERE ST_DWithin(..., dist) = true` — proximity radius query +- All nine predicate functions covered +- Query with no index (fallback) produces same results + +All assertions use `assertThat(...).isTrue()` / `isFalse()` / `isEqualTo()` per project conventions. + +## File Layout + +``` +engine/src/main/java/com/arcadedb/ + index/geospatial/ + LSMTreeGeoIndex.java + GeoIndexMetadata.java + function/sql/geo/ + SQLFunctionST_GeomFromText.java + SQLFunctionST_Point.java + SQLFunctionST_LineString.java + SQLFunctionST_Polygon.java + SQLFunctionST_Buffer.java + SQLFunctionST_Envelope.java + SQLFunctionST_Distance.java + SQLFunctionST_Area.java + SQLFunctionST_AsText.java + SQLFunctionST_AsGeoJson.java + SQLFunctionST_X.java + SQLFunctionST_Y.java + SQLFunctionST_Within.java ← implements IndexableSQLFunction + SQLFunctionST_Intersects.java ← implements IndexableSQLFunction + SQLFunctionST_Contains.java ← implements IndexableSQLFunction + SQLFunctionST_DWithin.java ← implements IndexableSQLFunction + SQLFunctionST_Disjoint.java ← implements IndexableSQLFunction + SQLFunctionST_Equals.java ← implements IndexableSQLFunction + SQLFunctionST_Crosses.java ← implements IndexableSQLFunction + SQLFunctionST_Overlaps.java ← implements IndexableSQLFunction + SQLFunctionST_Touches.java ← implements IndexableSQLFunction + GeoUtils.java ← extend existing + LightweightPoint.java ← keep existing + +engine/src/test/java/com/arcadedb/ + index/geospatial/ + LSMTreeGeoIndexTest.java + function/sql/geo/ + SQLGeoFunctionsTest.java ← extend existing + SQLGeoIndexedQueryTest.java ← new + +engine/pom.xml ← add lucene-spatial-extras dependency +``` + +## Open Questions + +- Should `ST_Distance` return meters by default (Neo4j/Cypher compat) or kilometers (current `distance()` SQL default)? Current implementation keeps both styles based on argument count — recommend preserving this. +- Should `ST_Buffer` accept distance in meters, kilometers, or degrees? Spatial4j works in degrees; conversion at the function boundary needed for user-facing meter/km inputs. From 3cd37634fdefeb46fbf0b8d9341dbe52dcdf0835 Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 12:37:34 +0100 Subject: [PATCH 02/47] docs: add geospatial indexing implementation plan --- .../2026-02-22-geospatial-implementation.md | 1456 +++++++++++++++++ 1 file changed, 1456 insertions(+) create mode 100644 docs/plans/2026-02-22-geospatial-implementation.md diff --git a/docs/plans/2026-02-22-geospatial-implementation.md b/docs/plans/2026-02-22-geospatial-implementation.md new file mode 100644 index 0000000000..93017593a0 --- /dev/null +++ b/docs/plans/2026-02-22-geospatial-implementation.md @@ -0,0 +1,1456 @@ +# Geospatial Indexing Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Port OrientDB-style geospatial indexing to ArcadeDB with ST_* SQL functions and automatic query optimizer integration. + +**Architecture:** `LSMTreeGeoIndex` wraps `LSMTreeIndex` (same pattern as `LSMTreeFullTextIndex`). `lucene-spatial-extras` `GeohashPrefixTree` decomposes WKT geometries into GeoHash cell tokens stored in LSM-Tree. ST_* predicate functions implement `IndexableSQLFunction` so the query optimizer uses the geo index automatically when `WHERE ST_Within(field, shape) = true` is detected. + +**Tech Stack:** Java 21, `lucene-spatial-extras` 10.3.2, `spatial4j` 0.8, `jts-core` 1.20.0, JUnit 5 + AssertJ, Maven. + +**Design document:** `docs/plans/2026-02-22-geospatial-design.md` — read it first. + +**Reference implementations to study before starting:** +- `engine/src/main/java/com/arcadedb/index/fulltext/LSMTreeFullTextIndex.java` — the index wrapper pattern to mirror exactly +- `engine/src/main/java/com/arcadedb/schema/FullTextIndexMetadata.java` — the metadata pattern to mirror +- `engine/src/main/java/com/arcadedb/query/sql/executor/IndexableSQLFunction.java` — the interface to implement +- `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionDistance.java` — existing geo function style +- `engine/src/test/java/com/arcadedb/index/fulltext/LSMTreeFullTextIndexTest.java` — test style to follow + +--- + +## Task 1: Add lucene-spatial-extras Dependency + +**Files:** +- Modify: `engine/pom.xml` + +**Step 1: Add the dependency** + +In `engine/pom.xml`, find the `lucene-analysis-common` dependency block and add `lucene-spatial-extras` immediately after it: + +```xml + + org.apache.lucene + lucene-spatial-extras + ${lucene.version} + +``` + +The `lucene.version` property is already defined as `10.3.2` in the parent pom. + +**Step 2: Verify compilation** + +```bash +cd engine && mvn compile -q +``` + +Expected: `BUILD SUCCESS` with no errors. + +**Step 3: Commit** + +```bash +git add engine/pom.xml +git commit -m "feat(geo): add lucene-spatial-extras dependency for geospatial indexing" +``` + +--- + +## Task 2: Create GeoIndexMetadata + +**Files:** +- Create: `engine/src/main/java/com/arcadedb/schema/GeoIndexMetadata.java` + +Pattern: mirror `FullTextIndexMetadata.java` exactly, but storing `precision` (int) instead of analyzer config. + +**Step 1: Write the failing test** + +Create `engine/src/test/java/com/arcadedb/index/geospatial/GeoIndexMetadataTest.java`: + +```java +package com.arcadedb.index.geospatial; + +import com.arcadedb.schema.GeoIndexMetadata; +import com.arcadedb.serializer.json.JSONObject; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GeoIndexMetadataTest { + + @Test + void defaultPrecision() { + final GeoIndexMetadata meta = new GeoIndexMetadata("Location", new String[]{"coords"}, 0); + assertThat(meta.getPrecision()).isEqualTo(GeoIndexMetadata.DEFAULT_PRECISION); + } + + @Test + void customPrecisionRoundtrip() { + final GeoIndexMetadata meta = new GeoIndexMetadata("Location", new String[]{"coords"}, 0); + meta.setPrecision(7); + final JSONObject json = new JSONObject(); + meta.toJSON(json); + assertThat(json.getInt("precision", -1)).isEqualTo(7); + + final GeoIndexMetadata loaded = new GeoIndexMetadata("Location", new String[]{"coords"}, 0); + loaded.fromJSON(json); + assertThat(loaded.getPrecision()).isEqualTo(7); + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +cd engine && mvn test -Dtest=GeoIndexMetadataTest -q 2>&1 | tail -5 +``` + +Expected: FAIL — `GeoIndexMetadata` does not exist yet. + +**Step 3: Create GeoIndexMetadata** + +```java +package com.arcadedb.schema; + +import com.arcadedb.serializer.json.JSONObject; + +public class GeoIndexMetadata extends IndexMetadata { + + public static final int DEFAULT_PRECISION = 11; // ~2.4m cells + + private int precision = DEFAULT_PRECISION; + + public GeoIndexMetadata(final String typeName, final String[] propertyNames, final int bucketId) { + super(typeName, propertyNames, bucketId); + } + + @Override + public void fromJSON(final JSONObject metadata) { + if (metadata.has("typeName")) + super.fromJSON(metadata); + this.precision = metadata.getInt("precision", DEFAULT_PRECISION); + } + + public void toJSON(final JSONObject json) { + json.put("precision", precision); + } + + public int getPrecision() { + return precision; + } + + public void setPrecision(final int precision) { + this.precision = precision; + } +} +``` + +**Step 4: Run test to verify it passes** + +```bash +cd engine && mvn test -Dtest=GeoIndexMetadataTest -q 2>&1 | tail -5 +``` + +Expected: `BUILD SUCCESS`. + +**Step 5: Commit** + +```bash +git add engine/src/main/java/com/arcadedb/schema/GeoIndexMetadata.java \ + engine/src/test/java/com/arcadedb/index/geospatial/GeoIndexMetadataTest.java +git commit -m "feat(geo): add GeoIndexMetadata for geospatial index configuration" +``` + +--- + +## Task 3: Create LSMTreeGeoIndex + +**Files:** +- Create: `engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java` +- Create: `engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java` + +This is the core component. Study `LSMTreeFullTextIndex.java` in full before writing this — `LSMTreeGeoIndex` mirrors its structure exactly. The difference: instead of Lucene `Analyzer` → tokens, we use `GeohashPrefixTree` + `RecursivePrefixTreeStrategy` → GeoHash cell tokens. + +**Step 1: Write the failing test** + +```java +package com.arcadedb.index.geospatial; + +import com.arcadedb.TestHelper; +import com.arcadedb.database.MutableDocument; +import com.arcadedb.index.IndexCursor; +import com.arcadedb.index.TypeIndex; +import com.arcadedb.query.sql.executor.ResultSet; +import com.arcadedb.schema.Schema; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LSMTreeGeoIndexTest extends TestHelper { + + @Test + void indexAndQueryPoint() { + database.command("sql", "CREATE DOCUMENT TYPE Location"); + database.command("sql", "CREATE PROPERTY Location.coords STRING"); + database.command("sql", "CREATE INDEX ON Location (coords) GEOSPATIAL"); + + database.transaction(() -> { + final MutableDocument doc = database.newDocument("Location"); + doc.set("coords", "POINT (10.0 45.0)"); + doc.save(); + }); + + // Direct index lookup via a WKT polygon covering the point + final TypeIndex idx = (TypeIndex) database.getSchema().getIndexByName("Location[coords]"); + final LSMTreeGeoIndex geoIdx = (LSMTreeGeoIndex) idx.getIndexesOnBuckets()[0]; + + // Parse the search shape and query directly + final org.locationtech.spatial4j.shape.Shape searchShape = + com.arcadedb.function.sql.geo.GeoUtils.getSpatialContext() + .getShapeFactory().rect(5.0, 15.0, 40.0, 50.0); + + final IndexCursor cursor = geoIdx.get(new Object[]{ searchShape }); + assertThat(cursor.hasNext()).isTrue(); + } + + @Test + void pointOutsideQueryReturnsNoResults() { + database.command("sql", "CREATE DOCUMENT TYPE Location2"); + database.command("sql", "CREATE PROPERTY Location2.coords STRING"); + database.command("sql", "CREATE INDEX ON Location2 (coords) GEOSPATIAL"); + + database.transaction(() -> { + final MutableDocument doc = database.newDocument("Location2"); + doc.set("coords", "POINT (100.0 45.0)"); // Tokyo area, far from Europe + doc.save(); + }); + + final TypeIndex idx = (TypeIndex) database.getSchema().getIndexByName("Location2[coords]"); + final LSMTreeGeoIndex geoIdx = (LSMTreeGeoIndex) idx.getIndexesOnBuckets()[0]; + + // Search shape is Europe — point is in Pacific + final org.locationtech.spatial4j.shape.Shape searchShape = + com.arcadedb.function.sql.geo.GeoUtils.getSpatialContext() + .getShapeFactory().rect(5.0, 15.0, 40.0, 50.0); + + final IndexCursor cursor = geoIdx.get(new Object[]{ searchShape }); + assertThat(cursor.hasNext()).isFalse(); + } + + @Test + void nullWktIsSkipped() { + database.command("sql", "CREATE DOCUMENT TYPE Location3"); + database.command("sql", "CREATE PROPERTY Location3.coords STRING"); + database.command("sql", "CREATE INDEX ON Location3 (coords) GEOSPATIAL"); + + // Should not throw — null geometry is silently skipped + database.transaction(() -> { + final MutableDocument doc = database.newDocument("Location3"); + doc.set("coords", (Object) null); + doc.save(); + }); + + final TypeIndex idx = (TypeIndex) database.getSchema().getIndexByName("Location3[coords]"); + final LSMTreeGeoIndex geoIdx = (LSMTreeGeoIndex) idx.getIndexesOnBuckets()[0]; + + final org.locationtech.spatial4j.shape.Shape searchShape = + com.arcadedb.function.sql.geo.GeoUtils.getSpatialContext() + .getShapeFactory().rect(-180, 180, -90, 90); + + final IndexCursor cursor = geoIdx.get(new Object[]{ searchShape }); + assertThat(cursor.hasNext()).isFalse(); + } +} +``` + +**Step 2: Run to verify it fails** + +```bash +cd engine && mvn test -Dtest=LSMTreeGeoIndexTest -q 2>&1 | tail -5 +``` + +Expected: FAIL — `GEOSPATIAL` index type not registered yet. + +**Step 3: Create LSMTreeGeoIndex** + +Key design notes: +- The underlying `LSMTreeIndex` stores `String` keys (same as full-text) +- `put()`: parse WKT → `Shape`, use `RecursivePrefixTreeStrategy.createIndexableFields()` to get Lucene fields, extract geohash token strings from the tokenized field's `TokenStream`, store each token as a key in the underlying index +- `get()`: the input key is a `Shape` object; use `strategy.makeQuery(SpatialArgs)` then visit the query with `QueryVisitor` to extract the covering cell token strings, look each up in the underlying index, union RIDs into a `TempIndexCursor` +- All other methods (remove, isEmpty, getAssociatedIndex, getPropertyNames, etc.) delegate to `underlyingIndex` — copy this pattern from `LSMTreeFullTextIndex` + +```java +package com.arcadedb.index.geospatial; + +import com.arcadedb.database.DatabaseInternal; +import com.arcadedb.database.Identifiable; +import com.arcadedb.database.RID; +import com.arcadedb.engine.ComponentFile; +import com.arcadedb.index.*; +import com.arcadedb.index.lsm.LSMTreeIndex; +import com.arcadedb.index.lsm.LSMTreeIndexAbstract; +import com.arcadedb.function.sql.geo.GeoUtils; +import com.arcadedb.log.LogManager; +import com.arcadedb.schema.GeoIndexMetadata; +import com.arcadedb.schema.IndexBuilder; +import com.arcadedb.schema.IndexMetadata; +import com.arcadedb.schema.Schema; +import com.arcadedb.schema.Type; +import org.apache.lucene.index.Term; +import org.apache.lucene.search.BooleanClause; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.QueryVisitor; +import org.apache.lucene.spatial.prefix.RecursivePrefixTreeStrategy; +import org.apache.lucene.spatial.prefix.tree.GeohashPrefixTree; +import org.apache.lucene.spatial.prefix.tree.SpatialPrefixTree; +import org.apache.lucene.spatial.query.SpatialArgs; +import org.apache.lucene.spatial.query.SpatialOperation; +import org.apache.lucene.analysis.tokenattributes.CharTermAttribute; +import org.apache.lucene.analysis.TokenStream; +import org.apache.lucene.document.Field; +import org.locationtech.spatial4j.shape.Shape; + +import java.io.IOException; +import java.util.*; +import java.util.logging.Level; + +// All remaining Index/IndexInternal methods MUST be delegated to underlyingIndex. +// Copy them from LSMTreeFullTextIndex — do not omit any method. +public class LSMTreeGeoIndex implements Index, IndexInternal { + + public static final int DEFAULT_PRECISION = GeoIndexMetadata.DEFAULT_PRECISION; + + private final LSMTreeIndex underlyingIndex; + private final int precision; + private final SpatialPrefixTree grid; + private final RecursivePrefixTreeStrategy strategy; + private TypeIndex typeIndex; + + public static class GeoIndexFactoryHandler implements IndexFactoryHandler { + @Override + public IndexInternal create(final IndexBuilder builder) { + if (builder.isUnique()) + throw new IllegalArgumentException("Geospatial index cannot be unique"); + + for (final Type keyType : builder.getKeyTypes()) { + if (keyType != Type.STRING) + throw new IllegalArgumentException( + "Geospatial index can only be defined on STRING properties (WKT format), found: " + keyType); + } + + int precision = DEFAULT_PRECISION; + if (builder.getMetadata() instanceof GeoIndexMetadata geoMeta) + precision = geoMeta.getPrecision(); + + return new LSMTreeGeoIndex(builder.getDatabase(), builder.getIndexName(), builder.getFilePath(), + ComponentFile.MODE.READ_WRITE, builder.getPageSize(), builder.getNullStrategy(), precision); + } + } + + /** Called at load time. */ + public LSMTreeGeoIndex(final LSMTreeIndex index) { + this(index, DEFAULT_PRECISION); + } + + public LSMTreeGeoIndex(final LSMTreeIndex index, final int precision) { + this.underlyingIndex = index; + this.precision = precision; + this.grid = new GeohashPrefixTree(GeoUtils.getSpatialContext(), precision); + this.strategy = new RecursivePrefixTreeStrategy(grid, "geo"); + } + + /** Creation time. */ + public LSMTreeGeoIndex(final DatabaseInternal database, final String name, final String filePath, + final ComponentFile.MODE mode, final int pageSize, final LSMTreeIndexAbstract.NULL_STRATEGY nullStrategy, + final int precision) { + this.precision = precision; + this.grid = new GeohashPrefixTree(GeoUtils.getSpatialContext(), precision); + this.strategy = new RecursivePrefixTreeStrategy(grid, "geo"); + underlyingIndex = new LSMTreeIndex(database, name, false, filePath, mode, new Type[]{ Type.STRING }, pageSize, nullStrategy); + } + + /** Loading time from file. */ + public LSMTreeGeoIndex(final DatabaseInternal database, final String name, final String filePath, + final int fileId, final ComponentFile.MODE mode, final int pageSize, final int version) { + this.precision = DEFAULT_PRECISION; + this.grid = new GeohashPrefixTree(GeoUtils.getSpatialContext(), precision); + this.strategy = new RecursivePrefixTreeStrategy(grid, "geo"); + try { + underlyingIndex = new LSMTreeIndex(database, name, false, filePath, fileId, mode, pageSize, version); + } catch (final IOException e) { + throw new IndexException("Cannot load geospatial index (error=" + e + ")", e); + } + } + + @Override + public IndexCursor get(final Object[] keys) { + return get(keys, -1); + } + + @Override + public IndexCursor get(final Object[] keys, final int limit) { + if (keys == null || keys.length == 0 || keys[0] == null) + return new EmptyIndexCursor(); + + final Shape searchShape = toShape(keys[0]); + if (searchShape == null) + return new EmptyIndexCursor(); + + // Generate covering GeoHash tokens for the search shape + final SpatialArgs args = new SpatialArgs(SpatialOperation.Intersects, searchShape); + final Query query = strategy.makeQuery(args); + + final Set tokens = new LinkedHashSet<>(); + query.visit(new QueryVisitor() { + @Override + public void consumeTerms(final Query q, final Term... terms) { + for (final Term t : terms) + tokens.add(t.text()); + } + + @Override + public QueryVisitor getSubVisitor(final BooleanClause.Occur occur, final Query parent) { + return this; + } + }); + + // Collect all matching RIDs from the LSM index + final Map seen = new LinkedHashMap<>(); + for (final String token : tokens) { + final IndexCursor cursor = underlyingIndex.get(new Object[]{ token }); + while (cursor.hasNext()) { + final RID rid = cursor.next().getIdentity(); + seen.put(rid, 1); + } + } + + final List entries = new ArrayList<>(seen.size()); + for (final RID rid : seen.keySet()) + entries.add(new IndexCursorEntry(keys, rid, 1)); + + return new TempIndexCursor(entries); + } + + @Override + public void put(final Object[] keys, final RID[] rids) { + if (keys == null || keys.length == 0 || keys[0] == null) + return; + + final String wkt = keys[0].toString(); + final Shape shape; + try { + shape = GeoUtils.getSpatialContext().getFormats().getWktReader().read(wkt); + } catch (final Exception e) { + LogManager.instance().log(this, Level.WARNING, "Geospatial index: skipping invalid WKT value '%s': %s", wkt, e.getMessage()); + return; + } + + final Field[] fields = strategy.createIndexableFields(shape); + for (final Field field : fields) { + try { + final TokenStream ts = field.tokenStream(null, null); + if (ts == null) + continue; + final CharTermAttribute termAttr = ts.addAttribute(CharTermAttribute.class); + ts.reset(); + while (ts.incrementToken()) { + final String token = termAttr.toString(); + underlyingIndex.put(new Object[]{ token }, rids); + } + ts.end(); + ts.close(); + } catch (final IOException e) { + LogManager.instance().log(this, Level.WARNING, "Geospatial index: error extracting tokens for '%s': %s", wkt, e.getMessage()); + } + } + } + + @Override + public void remove(final Object[] keys) { + if (keys == null || keys.length == 0 || keys[0] == null) + return; + final Shape shape = toShape(keys[0].toString()); + if (shape == null) + return; + for (final String token : extractTokens(shape)) + underlyingIndex.remove(new Object[]{ token }); + } + + @Override + public void remove(final Object[] keys, final Identifiable rid) { + if (keys == null || keys.length == 0 || keys[0] == null) + return; + final Shape shape = toShape(keys[0].toString()); + if (shape == null) + return; + for (final String token : extractTokens(shape)) + underlyingIndex.remove(new Object[]{ token }, rid); + } + + // --- Delegate everything else to underlyingIndex --- + // Copy all remaining Index/IndexInternal method implementations from + // LSMTreeFullTextIndex — they all delegate to underlyingIndex. + // These include: getType, getTypeName, getPropertyNames, getAssociatedIndex, + // setAssociatedIndex, getUnderlyingIndex, isEmpty, countEntries, build, + // setMetadata, getMetadata, getPageSize, getNullStrategy, isUnique, + // getFileId, onAfterSchemaLoadIndex, getPaginatedComponent, dropIndex, etc. + + @Override + public Schema.INDEX_TYPE getType() { + return Schema.INDEX_TYPE.GEOSPATIAL; + } + + // ... all other delegating methods + + // --- Private helpers --- + + private Shape toShape(final Object obj) { + if (obj instanceof Shape s) + return s; + try { + return GeoUtils.getSpatialContext().getFormats().getWktReader().read(obj.toString()); + } catch (final Exception e) { + LogManager.instance().log(this, Level.WARNING, "Geospatial index: cannot parse shape '%s': %s", obj, e.getMessage()); + return null; + } + } + + private List extractTokens(final Shape shape) { + final List tokens = new ArrayList<>(); + final Field[] fields = strategy.createIndexableFields(shape); + for (final Field field : fields) { + try { + final TokenStream ts = field.tokenStream(null, null); + if (ts == null) + continue; + final CharTermAttribute termAttr = ts.addAttribute(CharTermAttribute.class); + ts.reset(); + while (ts.incrementToken()) + tokens.add(termAttr.toString()); + ts.end(); + ts.close(); + } catch (final IOException e) { + // skip + } + } + return tokens; + } +} +``` + +> **Implementation note:** After writing the skeleton above, you MUST complete all the delegating methods. Open `LSMTreeFullTextIndex.java` and copy every method that delegates to `underlyingIndex`, adapting them to delegate to `this.underlyingIndex` in `LSMTreeGeoIndex`. There are ~25 methods. Do not omit any — the compiler will catch missing ones from the interfaces. + +> **If `CharTermAttribute` doesn't extract tokens:** GeoHash tokens are ASCII. If `CharTermAttribute` produces empty strings, check if the field uses `BytesRefTermAttribute` instead. Add `BytesRefTermAttribute bAttr = ts.addAttribute(BytesRefTermAttribute.class)` and use `bAttr.getBytesRef().utf8ToString()` instead. + +**Step 4: Run tests** + +```bash +cd engine && mvn test -Dtest=LSMTreeGeoIndexTest -q 2>&1 | tail -10 +``` + +Expected: `BUILD SUCCESS`. + +**Step 5: Commit** + +```bash +git add engine/src/main/java/com/arcadedb/index/geospatial/ \ + engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java +git commit -m "feat(geo): add LSMTreeGeoIndex wrapping LSMTreeIndex with GeohashPrefixTree decomposition" +``` + +--- + +## Task 4: Register GEOSPATIAL Index Type in Schema + +**Files:** +- Modify: `engine/src/main/java/com/arcadedb/schema/Schema.java` (enum) +- Modify: `engine/src/main/java/com/arcadedb/schema/LocalSchema.java` (factory registration) + +**Step 1: Write the failing test** + +Add to `LSMTreeGeoIndexTest`: + +```java +@Test +void createGeoIndexViaSQL() { + database.command("sql", "CREATE DOCUMENT TYPE Place"); + database.command("sql", "CREATE PROPERTY Place.location STRING"); + database.command("sql", "CREATE INDEX ON Place (location) GEOSPATIAL"); + + final com.arcadedb.index.TypeIndex idx = + (com.arcadedb.index.TypeIndex) database.getSchema().getIndexByName("Place[location]"); + assertThat(idx).isNotNull(); + assertThat(idx.getType()).isEqualTo(Schema.INDEX_TYPE.GEOSPATIAL); +} +``` + +**Step 2: Run to verify it fails** + +```bash +cd engine && mvn test -Dtest="LSMTreeGeoIndexTest#createGeoIndexViaSQL" -q 2>&1 | tail -5 +``` + +Expected: FAIL — `GEOSPATIAL` not a valid index type. + +**Step 3: Add GEOSPATIAL to the enum** + +In `Schema.java`, find: + +```java +enum INDEX_TYPE { + LSM_TREE, FULL_TEXT, LSM_VECTOR +} +``` + +Change to: + +```java +enum INDEX_TYPE { + LSM_TREE, FULL_TEXT, LSM_VECTOR, GEOSPATIAL +} +``` + +**Step 4: Register the factory handler** + +In `LocalSchema.java`, find: + +```java +indexFactory.register(INDEX_TYPE.LSM_VECTOR.name(), new LSMVectorIndex.LSMVectorIndexFactoryHandler()); +``` + +Add after it: + +```java +indexFactory.register(INDEX_TYPE.GEOSPATIAL.name(), new LSMTreeGeoIndex.GeoIndexFactoryHandler()); +``` + +Also handle loading from file: in `LocalSchema.java`, search for the block that handles `FULL_TEXT` when loading existing indexes from disk (around line 1380). Add an equivalent branch for `GEOSPATIAL`: + +```java +} else if (configuredIndexType.equalsIgnoreCase(Schema.INDEX_TYPE.GEOSPATIAL.toString())) { + index = new LSMTreeGeoIndex(database, indexName, indexFilePath, fileId, mode, pageSize, version); +} +``` + +**Step 5: Run tests** + +```bash +cd engine && mvn test -Dtest=LSMTreeGeoIndexTest -q 2>&1 | tail -5 +``` + +Expected: `BUILD SUCCESS`. + +**Step 6: Compile entire engine to catch any issues** + +```bash +cd engine && mvn compile -q +``` + +Expected: `BUILD SUCCESS`. + +**Step 7: Commit** + +```bash +git add engine/src/main/java/com/arcadedb/schema/Schema.java \ + engine/src/main/java/com/arcadedb/schema/LocalSchema.java +git commit -m "feat(geo): register GEOSPATIAL index type in Schema and LocalSchema" +``` + +--- + +## Task 5: Create ST_* Constructor and Accessor Functions + +**Files:** +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_GeomFromText.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Point.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_LineString.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Polygon.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Buffer.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Envelope.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Distance.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Area.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsText.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsGeoJson.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_X.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Y.java` +- Modify: `engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java` + +**Step 1: Write the failing tests** + +Update `engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java`. The existing `point()`, `distance()` etc. tests will become regression tests that the OLD names are gone. Add new ST_* tests: + +```java +@Test +void stPoint() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select ST_Point(11, 11) as pt"); + assertThat(result.hasNext()).isTrue(); + final Object pt = result.next().getProperty("pt"); + assertThat(pt).isNotNull(); + // Should be a Spatial4j Point or WKT string + }); +} + +@Test +void stGeomFromText() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select ST_GeomFromText('POINT (10.0 45.0)') as geom"); + assertThat(result.hasNext()).isTrue(); + final Object geom = result.next().getProperty("geom"); + assertThat(geom).isNotNull(); + }); +} + +@Test +void stAsText() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select ST_AsText(ST_Point(10.0, 45.0)) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).contains("10").contains("45"); + }); +} + +@Test +void stXstY() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select ST_X(ST_Point(10.0, 45.0)) as x, ST_Y(ST_Point(10.0, 45.0)) as y"); + assertThat(result.hasNext()).isTrue(); + final com.arcadedb.query.sql.executor.Result row = result.next(); + assertThat(((Number) row.getProperty("x")).doubleValue()).isEqualTo(10.0); + assertThat(((Number) row.getProperty("y")).doubleValue()).isEqualTo(45.0); + }); +} + +@Test +void stDistance() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Distance(ST_Point(0.0, 0.0), ST_Point(1.0, 0.0), 'km') as dist"); + assertThat(result.hasNext()).isTrue(); + final Number dist = result.next().getProperty("dist"); + assertThat(dist.doubleValue()).isGreaterThan(100.0).isLessThan(120.0); // ~111km per degree + }); +} + +@Test +void oldFunctionNamesGone() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + assertThatThrownBy(() -> db.query("sql", "select point(11,11)").close()) + .isInstanceOf(Exception.class); // unknown function + }); +} +``` + +**Step 2: Run to verify tests fail** + +```bash +cd engine && mvn test -Dtest=SQLGeoFunctionsTest -q 2>&1 | tail -10 +``` + +Expected: FAIL — ST_* functions not registered. + +**Step 3: Create the function classes** + +Each function follows the exact same pattern as existing geo functions. Study `SQLFunctionPoint.java` and `SQLFunctionDistance.java` before writing. Key patterns: + +- Extend `SQLFunctionAbstract` +- Constructor: `super("ST_FunctionName")` +- `execute()` validates params, calls `GeoUtils.getSpatialContext()` for shape creation +- `getSyntax()` returns a docs string +- `getMinArgs()` / `getMaxArgs()` for validation + +`SQLFunctionST_GeomFromText.java`: +```java +public class SQLFunctionST_GeomFromText extends SQLFunctionAbstract { + public static final String NAME = "ST_GeomFromText"; + + public SQLFunctionST_GeomFromText() { super(NAME); } + + @Override + public Object execute(final Object self, final Identifiable currentRecord, + final Object currentResult, final Object[] params, final CommandContext ctx) { + if (params == null || params.length < 1 || params[0] == null) + return null; + try { + return GeoUtils.getSpatialContext().getFormats().getWktReader().read(params[0].toString()); + } catch (final Exception e) { + throw new IllegalArgumentException("ST_GeomFromText: invalid WKT: " + params[0], e); + } + } + + @Override public String getSyntax() { return "ST_GeomFromText()"; } + @Override public int getMinArgs() { return 1; } + @Override public int getMaxArgs() { return 1; } +} +``` + +`SQLFunctionST_AsText.java`: +```java +public class SQLFunctionST_AsText extends SQLFunctionAbstract { + public static final String NAME = "ST_AsText"; + + public SQLFunctionST_AsText() { super(NAME); } + + @Override + public Object execute(final Object self, final Identifiable currentRecord, + final Object currentResult, final Object[] params, final CommandContext ctx) { + if (params == null || params.length < 1 || params[0] == null) + return null; + final Shape shape = (params[0] instanceof Shape s) ? s + : GeoUtils.getSpatialContext().getShapeFactory() + .makePoint(0, 0); // will be overridden by parse below + // If it's already a string, return as-is; if Shape, convert + if (params[0] instanceof Shape s) + return GeoUtils.getSpatialContext().getFormats().getWktWriter().toString(s); + return params[0].toString(); + } + + @Override public String getSyntax() { return "ST_AsText()"; } + @Override public int getMinArgs() { return 1; } + @Override public int getMaxArgs() { return 1; } +} +``` + +`SQLFunctionST_X.java`: +```java +public class SQLFunctionST_X extends SQLFunctionAbstract { + public static final String NAME = "ST_X"; + + public SQLFunctionST_X() { super(NAME); } + + @Override + public Object execute(final Object self, final Identifiable currentRecord, + final Object currentResult, final Object[] params, final CommandContext ctx) { + if (params == null || params.length < 1 || params[0] == null) + return null; + if (params[0] instanceof org.locationtech.spatial4j.shape.Point p) + return p.getX(); + throw new IllegalArgumentException("ST_X: argument must be a Point"); + } + + @Override public String getSyntax() { return "ST_X()"; } + @Override public int getMinArgs() { return 1; } + @Override public int getMaxArgs() { return 1; } +} +``` + +`SQLFunctionST_Y.java` — same as ST_X but returns `p.getY()`. + +`SQLFunctionST_Point.java` — same logic as existing `SQLFunctionPoint.java` but named `ST_Point`. + +`SQLFunctionST_Distance.java` — same logic as existing `SQLFunctionDistance.java` but named `ST_Distance`. + +`SQLFunctionST_LineString.java` — same as existing `SQLFunctionLineString.java` but named `ST_LineString`. + +`SQLFunctionST_Polygon.java` — same as existing `SQLFunctionPolygon.java` but named `ST_Polygon`. + +`SQLFunctionST_Buffer.java` — same as `SQLFunctionCircle.java` (circle = point + buffer radius) but named `ST_Buffer`. + +`SQLFunctionST_Envelope.java` — same as `SQLFunctionRectangle.java` but named `ST_Envelope`. + +`SQLFunctionST_Area.java`: +```java +public class SQLFunctionST_Area extends SQLFunctionAbstract { + public static final String NAME = "ST_Area"; + + public SQLFunctionST_Area() { super(NAME); } + + @Override + public Object execute(final Object self, final Identifiable currentRecord, + final Object currentResult, final Object[] params, final CommandContext ctx) { + if (params == null || params.length < 1 || params[0] == null) + return null; + final Shape shape = (params[0] instanceof Shape s) ? s + : GeoUtils.getSpatialContext().getShapeFactory().makePoint(0, 0); // placeholder + if (params[0] instanceof Shape s) + return s.getArea(GeoUtils.getSpatialContext()); + throw new IllegalArgumentException("ST_Area: argument must be a Shape"); + } + + @Override public String getSyntax() { return "ST_Area()"; } + @Override public int getMinArgs() { return 1; } + @Override public int getMaxArgs() { return 1; } +} +``` + +`SQLFunctionST_AsGeoJson.java` — use JTS `GeoJsonWriter` (from `org.locationtech.jts.io.geojson`): +```java +// Convert Spatial4j Shape → JTS Geometry → GeoJSON string +// GeoUtils.SPATIAL_CONTEXT has getGeometryFrom(Shape) if using JtsSpatialContext +final org.locationtech.jts.geom.Geometry jtsGeom = + GeoUtils.SPATIAL_CONTEXT.getGeometryFrom(shape); +return new org.locationtech.jts.io.geojson.GeoJsonWriter().write(jtsGeom); +``` + +**Step 4: Update DefaultSQLFunctionFactory** + +In `DefaultSQLFunctionFactory.java`: + +1. Find and **remove** the old registrations: + ```java + register(SQLFunctionCircle.NAME, new SQLFunctionCircle()); + register(SQLFunctionDistance.NAME, new SQLFunctionDistance()); + register(SQLFunctionLineString.NAME, new SQLFunctionLineString()); + register(SQLFunctionPoint.NAME, new SQLFunctionPoint()); + register(SQLFunctionPolygon.NAME, new SQLFunctionPolygon()); + register(SQLFunctionRectangle.NAME, new SQLFunctionRectangle()); + ``` + +2. **Add** the new ST_* registrations in their place: + ```java + register(SQLFunctionST_GeomFromText.NAME, new SQLFunctionST_GeomFromText()); + register(SQLFunctionST_Point.NAME, new SQLFunctionST_Point()); + register(SQLFunctionST_LineString.NAME, new SQLFunctionST_LineString()); + register(SQLFunctionST_Polygon.NAME, new SQLFunctionST_Polygon()); + register(SQLFunctionST_Buffer.NAME, new SQLFunctionST_Buffer()); + register(SQLFunctionST_Envelope.NAME, new SQLFunctionST_Envelope()); + register(SQLFunctionST_Distance.NAME, new SQLFunctionST_Distance()); + register(SQLFunctionST_Area.NAME, new SQLFunctionST_Area()); + register(SQLFunctionST_AsText.NAME, new SQLFunctionST_AsText()); + register(SQLFunctionST_AsGeoJson.NAME, new SQLFunctionST_AsGeoJson()); + register(SQLFunctionST_X.NAME, new SQLFunctionST_X()); + register(SQLFunctionST_Y.NAME, new SQLFunctionST_Y()); + ``` + +**Step 5: Compile to check all references to old classes** + +```bash +cd engine && mvn compile -q 2>&1 | grep -i error | head -20 +``` + +Fix any compilation errors (likely import cleanup in `DefaultSQLFunctionFactory`). + +**Step 6: Run tests** + +```bash +cd engine && mvn test -Dtest=SQLGeoFunctionsTest -q 2>&1 | tail -10 +``` + +Expected: `BUILD SUCCESS`. + +**Step 7: Commit** + +```bash +git add engine/src/main/java/com/arcadedb/function/sql/geo/ \ + engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java \ + engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java +git commit -m "feat(geo): add ST_* constructor and accessor functions, remove old geo function names" +``` + +--- + +## Task 6: Create Spatial Predicate Functions with IndexableSQLFunction + +**Files:** +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Predicate.java` (abstract base) +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Within.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Intersects.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_DWithin.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Disjoint.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java` +- Modify: `engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java` +- Create: `engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoIndexedQueryTest.java` + +**Step 1: Write the failing tests (both non-indexed and indexed)** + +```java +package com.arcadedb.function.sql.geo; + +import com.arcadedb.TestHelper; +import com.arcadedb.database.MutableDocument; +import com.arcadedb.query.sql.executor.Result; +import com.arcadedb.query.sql.executor.ResultSet; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SQLGeoIndexedQueryTest extends TestHelper { + + // ---- Non-indexed (full-scan) predicate evaluation ---- + + @Test + void stWithinNoIndex() { + database.command("sql", "CREATE DOCUMENT TYPE Place"); + database.transaction(() -> { + final MutableDocument d = database.newDocument("Place"); + d.set("coords", "POINT (10.0 45.0)"); + d.save(); + }); + // Point (10,45) is inside POLYGON 5-15, 40-50 + final ResultSet rs = database.query("sql", + "SELECT FROM Place WHERE ST_Within(ST_GeomFromText(coords), " + + "ST_GeomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); + assertThat(rs.hasNext()).isTrue(); + rs.close(); + } + + @Test + void stWithinOutsideNoIndex() { + database.command("sql", "CREATE DOCUMENT TYPE Place2"); + database.transaction(() -> { + final MutableDocument d = database.newDocument("Place2"); + d.set("coords", "POINT (100.0 45.0)"); // Pacific, not in Europe box + d.save(); + }); + final ResultSet rs = database.query("sql", + "SELECT FROM Place2 WHERE ST_Within(ST_GeomFromText(coords), " + + "ST_GeomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); + assertThat(rs.hasNext()).isFalse(); + rs.close(); + } + + @Test + void stIntersectsNoIndex() { + database.command("sql", "CREATE DOCUMENT TYPE Place3"); + database.transaction(() -> { + final MutableDocument d = database.newDocument("Place3"); + d.set("coords", "POINT (10.0 45.0)"); + d.save(); + }); + final ResultSet rs = database.query("sql", + "SELECT FROM Place3 WHERE ST_Intersects(ST_GeomFromText(coords), " + + "ST_GeomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); + assertThat(rs.hasNext()).isTrue(); + rs.close(); + } + + // ---- Indexed predicate evaluation ---- + + @Test + void stWithinWithIndex() { + database.command("sql", "CREATE DOCUMENT TYPE IndexedPlace"); + database.command("sql", "CREATE PROPERTY IndexedPlace.coords STRING"); + database.command("sql", "CREATE INDEX ON IndexedPlace (coords) GEOSPATIAL"); + + database.transaction(() -> { + // Inside Europe box + database.newDocument("IndexedPlace").set("coords", "POINT (10.0 45.0)").save(); + // Outside Europe box + database.newDocument("IndexedPlace").set("coords", "POINT (100.0 45.0)").save(); + }); + + final ResultSet rs = database.query("sql", + "SELECT FROM IndexedPlace WHERE ST_Within(coords, " + + "ST_GeomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); + + int count = 0; + while (rs.hasNext()) { + rs.next(); + count++; + } + rs.close(); + assertThat(count).isEqualTo(1); + } + + @Test + void stIntersectsWithIndex() { + database.command("sql", "CREATE DOCUMENT TYPE IndexedPlace2"); + database.command("sql", "CREATE PROPERTY IndexedPlace2.coords STRING"); + database.command("sql", "CREATE INDEX ON IndexedPlace2 (coords) GEOSPATIAL"); + + database.transaction(() -> { + database.newDocument("IndexedPlace2").set("coords", "POINT (10.0 45.0)").save(); + database.newDocument("IndexedPlace2").set("coords", "POINT (100.0 45.0)").save(); + }); + + final ResultSet rs = database.query("sql", + "SELECT FROM IndexedPlace2 WHERE ST_Intersects(coords, " + + "ST_GeomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); + + int count = 0; + while (rs.hasNext()) { rs.next(); count++; } + rs.close(); + assertThat(count).isEqualTo(1); + } + + @Test + void stContainsWithIndex() { + database.command("sql", "CREATE DOCUMENT TYPE Region"); + database.command("sql", "CREATE PROPERTY Region.bounds STRING"); + database.command("sql", "CREATE INDEX ON Region (bounds) GEOSPATIAL"); + + database.transaction(() -> { + // A large polygon that contains the query point + database.newDocument("Region") + .set("bounds", "POLYGON ((0 40, 20 40, 20 50, 0 50, 0 40))").save(); + // A small polygon that does not contain the query point + database.newDocument("Region") + .set("bounds", "POLYGON ((50 60, 70 60, 70 70, 50 70, 50 60))").save(); + }); + + final ResultSet rs = database.query("sql", + "SELECT FROM Region WHERE ST_Contains(bounds, " + + "ST_GeomFromText('POINT (10.0 45.0)')) = true"); + + int count = 0; + while (rs.hasNext()) { rs.next(); count++; } + rs.close(); + assertThat(count).isEqualTo(1); + } + + @Test + void stNullReturnsNull() { + final ResultSet rs = database.query("sql", + "SELECT ST_Within(null, ST_GeomFromText('POINT (0 0)')) as result"); + assertThat(rs.hasNext()).isTrue(); + final Result row = rs.next(); + assertThat(row.getProperty("result")).isNull(); + rs.close(); + } +} +``` + +**Step 2: Run to verify it fails** + +```bash +cd engine && mvn test -Dtest=SQLGeoIndexedQueryTest -q 2>&1 | tail -10 +``` + +Expected: FAIL — ST_Within etc. not registered. + +**Step 3: Create the abstract base class** + +```java +package com.arcadedb.function.sql.geo; + +import com.arcadedb.database.Database; +import com.arcadedb.database.Identifiable; +import com.arcadedb.database.Record; +import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.index.Index; +import com.arcadedb.index.IndexCursor; +import com.arcadedb.index.TypeIndex; +import com.arcadedb.query.sql.executor.CommandContext; +import com.arcadedb.query.sql.executor.IndexableSQLFunction; +import com.arcadedb.query.sql.parser.BinaryCompareOperator; +import com.arcadedb.query.sql.parser.Expression; +import com.arcadedb.query.sql.parser.FromClause; +import com.arcadedb.index.geospatial.LSMTreeGeoIndex; +import com.arcadedb.schema.DocumentType; +import com.arcadedb.schema.Schema; +import org.locationtech.spatial4j.shape.Shape; +import org.locationtech.spatial4j.shape.SpatialRelation; + +import java.util.ArrayList; +import java.util.List; + +public abstract class SQLFunctionST_Predicate extends SQLFunctionAbstract + implements IndexableSQLFunction { + + protected SQLFunctionST_Predicate(final String name) { + super(name); + } + + /** The Spatial4j SpatialRelation this predicate checks. */ + protected abstract SpatialRelation getExpectedRelation(); + + /** + * Non-indexed execution: evaluates the predicate directly using Spatial4j. + * params[0] = geometry of the field (Shape or WKT string) + * params[1] = search shape (Shape or WKT string) + */ + @Override + public Object execute(final Object self, final Identifiable currentRecord, + final Object currentResult, final Object[] params, final CommandContext ctx) { + if (params == null || params.length < 2) + throw new IllegalArgumentException(getName() + "() requires 2 arguments"); + if (params[0] == null || params[1] == null) + return null; + + final Shape g1 = toShape(params[0]); + final Shape g2 = toShape(params[1]); + if (g1 == null || g2 == null) + return null; + + final SpatialRelation relation = g1.relate(g2); + return relation == getExpectedRelation() || relation == SpatialRelation.CONTAINS + && getExpectedRelation() == SpatialRelation.WITHIN + ? checkRelation(g1, g2) + : checkRelation(g1, g2); + } + + /** Override for predicates needing JTS topology (Crosses, Touches, Overlaps). */ + protected Boolean checkRelation(final Shape g1, final Shape g2) { + return g1.relate(g2) == getExpectedRelation(); + } + + // --- IndexableSQLFunction --- + + @Override + public boolean allowsIndexedExecution(final FromClause target, final BinaryCompareOperator operator, + final Object right, final CommandContext ctx, final Expression[] params) { + if (params == null || params.length < 2) + return false; + // First arg must reference a field name (bare identifier) + final String fieldName = extractFieldName(params[0]); + if (fieldName == null) + return false; + // That field must have a GEOSPATIAL index on the target type + return findGeoIndex(target, fieldName, ctx) != null; + } + + @Override + public boolean canExecuteInline(final FromClause target, final BinaryCompareOperator operator, + final Object right, final CommandContext ctx, final Expression[] params) { + return true; // always falls back to full-scan evaluation + } + + @Override + public boolean shouldExecuteAfterSearch(final FromClause target, final BinaryCompareOperator operator, + final Object right, final CommandContext ctx, final Expression[] params) { + return true; // index returns superset; exact predicate must post-filter + } + + @Override + public long estimate(final FromClause target, final BinaryCompareOperator operator, + final Object rightValue, final CommandContext ctx, final Expression[] params) { + return -1; // no estimation + } + + @Override + public Iterable searchFromTarget(final FromClause target, final BinaryCompareOperator operator, + final Object rightValue, final CommandContext ctx, final Expression[] params) { + if (params == null || params.length < 2) + return List.of(); + + final String fieldName = extractFieldName(params[0]); + if (fieldName == null) + return List.of(); + + // Evaluate the search shape argument + final Object shapeArg = params[1].execute((com.arcadedb.query.sql.executor.Result) null, ctx); + final Shape searchShape = toShape(shapeArg); + if (searchShape == null) + return List.of(); + + final TypeIndex typeIdx = findGeoIndex(target, fieldName, ctx); + if (typeIdx == null) + return List.of(); + + final List results = new ArrayList<>(); + for (final Index bucketIdx : typeIdx.getIndexesOnBuckets()) { + if (bucketIdx instanceof LSMTreeGeoIndex geoIdx) { + final IndexCursor cursor = geoIdx.get(new Object[]{ searchShape }); + while (cursor.hasNext()) { + final com.arcadedb.database.RID rid = cursor.next().getIdentity(); + final Record record = ctx.getDatabase().lookupByRID(rid, true); + if (record != null) + results.add(record); + } + } + } + return results; + } + + // --- Helpers --- + + protected Shape toShape(final Object obj) { + if (obj == null) + return null; + if (obj instanceof Shape s) + return s; + try { + return GeoUtils.getSpatialContext().getFormats().getWktReader().read(obj.toString()); + } catch (final Exception e) { + return null; + } + } + + private String extractFieldName(final Expression expr) { + if (expr == null) + return null; + final String text = expr.toString().trim(); + // A bare field name has no spaces or function call syntax + if (!text.contains("(") && !text.contains(" ")) + return text; + return null; + } + + private TypeIndex findGeoIndex(final FromClause target, final String fieldName, + final CommandContext ctx) { + if (target == null || target.getItem() == null) + return null; + final String typeName = target.getItem().toString(); + final Database db = ctx.getDatabase(); + if (!db.getSchema().existsType(typeName)) + return null; + final DocumentType docType = db.getSchema().getType(typeName); + for (final com.arcadedb.index.TypeIndex idx : docType.getAllIndexes(true)) { + if (idx.getType() == Schema.INDEX_TYPE.GEOSPATIAL + && idx.getPropertyNames().contains(fieldName)) + return idx; + } + return null; + } +} +``` + +**Step 4: Create the 9 predicate subclasses** + +Each is ~20 lines. Example for `ST_Within`: + +```java +package com.arcadedb.function.sql.geo; + +import org.locationtech.spatial4j.shape.SpatialRelation; + +public class SQLFunctionST_Within extends SQLFunctionST_Predicate { + public static final String NAME = "ST_Within"; + + public SQLFunctionST_Within() { super(NAME); } + + @Override + protected SpatialRelation getExpectedRelation() { return SpatialRelation.WITHIN; } + + @Override + public String getSyntax() { return "ST_Within(, )"; } + + @Override + public int getMinArgs() { return 2; } + + @Override + public int getMaxArgs() { return 2; } +} +``` + +Spatial4j `SpatialRelation` values: +- `ST_Within` → `SpatialRelation.WITHIN` +- `ST_Intersects` → `SpatialRelation.INTERSECTS` +- `ST_Contains` → `SpatialRelation.CONTAINS` +- `ST_Disjoint` → `SpatialRelation.DISJOINT` +- `ST_Equals` → override `checkRelation` to use JTS `equals()` + +For `ST_Crosses`, `ST_Overlaps`, `ST_Touches` — Spatial4j doesn't have these as `SpatialRelation` values. Override `checkRelation` to use JTS topology: + +```java +// ST_Crosses example — needs JTS conversion +@Override +protected Boolean checkRelation(final Shape g1, final Shape g2) { + final org.locationtech.jts.geom.Geometry jg1 = GeoUtils.SPATIAL_CONTEXT.getGeometryFrom(g1); + final org.locationtech.jts.geom.Geometry jg2 = GeoUtils.SPATIAL_CONTEXT.getGeometryFrom(g2); + return jg1.crosses(jg2); +} +``` + +`ST_DWithin` has a different signature `(g1, g2, distance)`, so override `execute()` directly: + +```java +// ST_DWithin: returns true if g1 is within 'distance' of g2 +// Use Spatial4j's distance calculation +@Override +public Object execute(..., Object[] params, ...) { + if (params.length < 3) throw new IllegalArgumentException("ST_DWithin requires 3 args"); + if (params[0] == null || params[1] == null || params[2] == null) return null; + final Shape g1 = toShape(params[0]); + final Shape g2 = toShape(params[1]); + final double distDeg = ((Number) params[2]).doubleValue(); // distance in degrees + return GeoUtils.getSpatialContext().calcDistance( + g1.getCenter(), g2.getCenter()) <= distDeg; +} +``` + +**Step 5: Register predicates in DefaultSQLFunctionFactory** + +Add after the ST_AsGeoJson registration: + +```java +register(SQLFunctionST_Within.NAME, new SQLFunctionST_Within()); +register(SQLFunctionST_Intersects.NAME, new SQLFunctionST_Intersects()); +register(SQLFunctionST_Contains.NAME, new SQLFunctionST_Contains()); +register(SQLFunctionST_DWithin.NAME, new SQLFunctionST_DWithin()); +register(SQLFunctionST_Disjoint.NAME, new SQLFunctionST_Disjoint()); +register(SQLFunctionST_Equals.NAME, new SQLFunctionST_Equals()); +register(SQLFunctionST_Crosses.NAME, new SQLFunctionST_Crosses()); +register(SQLFunctionST_Overlaps.NAME, new SQLFunctionST_Overlaps()); +register(SQLFunctionST_Touches.NAME, new SQLFunctionST_Touches()); +``` + +**Step 6: Compile** + +```bash +cd engine && mvn compile -q +``` + +Fix any errors before running tests. + +**Step 7: Run all geo tests** + +```bash +cd engine && mvn test -Dtest="SQLGeoIndexedQueryTest,SQLGeoFunctionsTest,LSMTreeGeoIndexTest" -q 2>&1 | tail -15 +``` + +Expected: `BUILD SUCCESS`. If any test fails, check the Spatial4j `SpatialRelation` mapping — `relate()` can return `WITHIN`, `CONTAINS`, `INTERSECTS`, `DISJOINT`. Adjust `checkRelation()` accordingly. + +**Step 8: Run all engine tests to catch regressions** + +```bash +cd engine && mvn test -q 2>&1 | tail -20 +``` + +Fix any failures before committing. + +**Step 9: Commit** + +```bash +git add engine/src/main/java/com/arcadedb/function/sql/geo/ \ + engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java \ + engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoIndexedQueryTest.java +git commit -m "feat(geo): add ST_* spatial predicate functions with IndexableSQLFunction for automatic index usage" +``` + +--- + +## Task 7: Final Validation and Cleanup + +**Step 1: Run all engine tests** + +```bash +cd engine && mvn test -q 2>&1 | tail -30 +``` + +Expected: `BUILD SUCCESS`. + +**Step 2: Remove any debug System.out calls** + +```bash +grep -r "System.out" engine/src/main/java/com/arcadedb/index/geospatial/ \ + engine/src/main/java/com/arcadedb/function/sql/geo/ +``` + +Expected: no output. Remove any found. + +**Step 3: Compile the full project** + +```bash +mvn compile -q +``` + +Expected: `BUILD SUCCESS`. + +**Step 4: Final commit** + +```bash +git add -A +git commit -m "feat(geo): complete geospatial indexing implementation with ST_* functions and LSMTreeGeoIndex" +``` + +--- + +## Known Gotchas + +**Token extraction:** `RecursivePrefixTreeStrategy.createIndexableFields()` returns a `Field[]` where spatial fields have an embedded `TokenStream`. Call `field.tokenStream(null, null)` — passing `null` for the analyzer is valid when the field owns its token stream. If `CharTermAttribute` returns empty strings, also try `BytesRefTermAttribute` and call `.getBytesRef().utf8ToString()`. + +**Query term extraction:** `strategy.makeQuery(SpatialArgs)` returns a `BooleanQuery` or `ConstantScoreQuery`. Use `query.visit(QueryVisitor)` with a recursive `getSubVisitor()` to collect all `Term` objects from nested queries. + +**SpatialRelation mapping:** Spatial4j's `Shape.relate()` returns `WITHIN`, `CONTAINS`, `INTERSECTS`, or `DISJOINT`. There is no `CROSSES`, `OVERLAPS`, or `TOUCHES` — use JTS geometry operations for these via `GeoUtils.SPATIAL_CONTEXT.getGeometryFrom(shape)`. + +**WKT format:** Spatial4j's WKT reader accepts `POINT (x y)` with a space before the parenthesis. JTS requires `POINT(x y)` without space. The `GeoUtils.getSpatialContext().getFormats().getWktReader()` handles both. + +**ST_DWithin distance units:** The base implementation uses degrees. For user-facing meter/km input, add a conversion using `DistanceUtils.dist2Degrees(distKm, DistanceUtils.EARTH_MEAN_RADIUS_KM)` from Spatial4j. + +**Index loading:** After adding `GEOSPATIAL` to `LocalSchema`'s load path, verify that opening a database with an existing geo index (from disk) correctly instantiates `LSMTreeGeoIndex`. Test by creating a database, inserting data, closing and re-opening it, then querying. From f6a4003f7dc28c17f406b5b60f11f692a2da6afb Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 16:42:43 +0100 Subject: [PATCH 03/47] feat(geo): add lucene-spatial-extras dependency for geospatial indexing --- engine/pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/engine/pom.xml b/engine/pom.xml index f80234ab6e..928c837601 100644 --- a/engine/pom.xml +++ b/engine/pom.xml @@ -134,6 +134,11 @@ lucene-analysis-common ${lucene.version} + + org.apache.lucene + lucene-spatial-extras + ${lucene.version} + org.apache.lucene lucene-queryparser From 1f96233326d549adfe8acc4f02b329a4be8e8550 Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 16:55:58 +0100 Subject: [PATCH 04/47] feat(geo): add GeoIndexMetadata for geospatial index configuration --- .../com/arcadedb/schema/GeoIndexMetadata.java | 88 +++++++++++++++++++ .../geospatial/GeoIndexMetadataTest.java | 47 ++++++++++ 2 files changed, 135 insertions(+) create mode 100644 engine/src/main/java/com/arcadedb/schema/GeoIndexMetadata.java create mode 100644 engine/src/test/java/com/arcadedb/index/geospatial/GeoIndexMetadataTest.java diff --git a/engine/src/main/java/com/arcadedb/schema/GeoIndexMetadata.java b/engine/src/main/java/com/arcadedb/schema/GeoIndexMetadata.java new file mode 100644 index 0000000000..48c45b5769 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/schema/GeoIndexMetadata.java @@ -0,0 +1,88 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.schema; + +import com.arcadedb.serializer.json.JSONObject; + +/** + * Metadata class for geospatial indexes, storing the precision level for the + * GeohashPrefixTree spatial strategy. + *

+ * Precision level controls cell resolution: + *

    + *
  • Precision 1 → ~5,000 km
  • + *
  • Precision 6 → ~1.2 km
  • + *
  • Precision 11 → ~2.4 m (default)
  • + *
  • Precision 12 → ~0.6 m
  • + *
+ * + * @author Arcade Data Ltd + */ +public class GeoIndexMetadata extends IndexMetadata { + + /** Default geohash precision level (~2.4 m cell resolution). */ + public static final int DEFAULT_PRECISION = 11; + + private int precision = DEFAULT_PRECISION; + + /** + * Creates a new GeoIndexMetadata instance. + * + * @param typeName the name of the type this index belongs to + * @param propertyNames the property names indexed + * @param bucketId the associated bucket ID + */ + public GeoIndexMetadata(final String typeName, final String[] propertyNames, final int bucketId) { + super(typeName, propertyNames, bucketId); + } + + @Override + public void fromJSON(final JSONObject metadata) { + if (metadata.has("typeName")) + super.fromJSON(metadata); + this.precision = metadata.getInt("precision", DEFAULT_PRECISION); + } + + /** + * Serializes geospatial-specific metadata into the provided JSON object. + * + * @param json the JSON object to write metadata into + */ + public void toJSON(final JSONObject json) { + json.put("precision", precision); + } + + /** + * Returns the geohash precision level. + * + * @return the precision level + */ + public int getPrecision() { + return precision; + } + + /** + * Sets the geohash precision level. + * + * @param precision the precision level (1–12) + */ + public void setPrecision(final int precision) { + this.precision = precision; + } +} diff --git a/engine/src/test/java/com/arcadedb/index/geospatial/GeoIndexMetadataTest.java b/engine/src/test/java/com/arcadedb/index/geospatial/GeoIndexMetadataTest.java new file mode 100644 index 0000000000..bb4b52e507 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/index/geospatial/GeoIndexMetadataTest.java @@ -0,0 +1,47 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.index.geospatial; + +import com.arcadedb.schema.GeoIndexMetadata; +import com.arcadedb.serializer.json.JSONObject; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GeoIndexMetadataTest { + + @Test + void defaultPrecision() { + final GeoIndexMetadata meta = new GeoIndexMetadata("Location", new String[]{"coords"}, 0); + assertThat(meta.getPrecision()).isEqualTo(GeoIndexMetadata.DEFAULT_PRECISION); + } + + @Test + void customPrecisionRoundtrip() { + final GeoIndexMetadata meta = new GeoIndexMetadata("Location", new String[]{"coords"}, 0); + meta.setPrecision(7); + final JSONObject json = new JSONObject(); + meta.toJSON(json); + assertThat(json.getInt("precision", -1)).isEqualTo(7); + + final GeoIndexMetadata loaded = new GeoIndexMetadata("Location", new String[]{"coords"}, 0); + loaded.fromJSON(json); + assertThat(loaded.getPrecision()).isEqualTo(7); + } +} From 71dfa4d169fc19b0b568464296a2d2fd496a8010 Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 17:48:58 +0100 Subject: [PATCH 05/47] feat(geo): add LSMTreeGeoIndex with GeohashPrefixTree spatial token decomposition --- .../index/geospatial/LSMTreeGeoIndex.java | 521 ++++++++++++++++++ .../index/geospatial/LSMTreeGeoIndexTest.java | 125 +++++ 2 files changed, 646 insertions(+) create mode 100644 engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java create mode 100644 engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java diff --git a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java new file mode 100644 index 0000000000..3ad625d541 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java @@ -0,0 +1,521 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.index.geospatial; + +import com.arcadedb.database.DatabaseInternal; +import com.arcadedb.database.Identifiable; +import com.arcadedb.database.RID; +import com.arcadedb.engine.ComponentFile; +import com.arcadedb.engine.PaginatedComponent; +import com.arcadedb.function.sql.geo.GeoUtils; +import com.arcadedb.index.EmptyIndexCursor; +import com.arcadedb.index.Index; +import com.arcadedb.index.IndexCursor; +import com.arcadedb.index.IndexCursorEntry; +import com.arcadedb.index.IndexException; +import com.arcadedb.index.IndexFactoryHandler; +import com.arcadedb.index.IndexInternal; +import com.arcadedb.index.TempIndexCursor; +import com.arcadedb.index.TypeIndex; +import com.arcadedb.index.lsm.LSMTreeIndex; +import com.arcadedb.index.lsm.LSMTreeIndexAbstract; +import com.arcadedb.log.LogManager; +import com.arcadedb.schema.GeoIndexMetadata; +import com.arcadedb.schema.IndexBuilder; +import com.arcadedb.schema.IndexMetadata; +import com.arcadedb.schema.Schema; +import com.arcadedb.schema.Type; +import com.arcadedb.serializer.json.JSONObject; +import org.apache.lucene.analysis.TokenStream; +import org.apache.lucene.analysis.tokenattributes.TermToBytesRefAttribute; +import org.apache.lucene.document.Field; +import org.apache.lucene.spatial.prefix.RecursivePrefixTreeStrategy; +import org.apache.lucene.spatial.prefix.tree.Cell; +import org.apache.lucene.spatial.prefix.tree.CellIterator; +import org.apache.lucene.spatial.prefix.tree.GeohashPrefixTree; +import org.apache.lucene.spatial.query.SpatialArgs; +import org.apache.lucene.spatial.query.SpatialOperation; +import org.locationtech.spatial4j.shape.Shape; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; + +/** + * Geospatial index implementation based on LSM-Tree index. + *

+ * Uses Lucene's {@link GeohashPrefixTree} and {@link RecursivePrefixTreeStrategy} to decompose + * WKT geometry strings into GeoHash cell tokens. Each token is stored as a key in the underlying + * {@link LSMTreeIndex} with the document RID as value. + *

+ * Querying generates covering GeoHash tokens for the search shape and performs set-union + * lookups in the LSM index, returning deduplicated RIDs. + */ +public class LSMTreeGeoIndex implements Index, IndexInternal { + + /** Default geohash precision level (same as GeoIndexMetadata default, ~2.4 m cell resolution). */ + public static final int DEFAULT_PRECISION = GeoIndexMetadata.DEFAULT_PRECISION; + + private final LSMTreeIndex underlyingIndex; + private final int precision; + private final GeohashPrefixTree grid; + private final RecursivePrefixTreeStrategy strategy; + private TypeIndex typeIndex; + + /** + * Factory handler for creating LSMTreeGeoIndex instances. + */ + public static class GeoIndexFactoryHandler implements IndexFactoryHandler { + @Override + public IndexInternal create(final IndexBuilder builder) { + if (builder.isUnique()) + throw new IllegalArgumentException("Geospatial index cannot be unique"); + for (final Type keyType : builder.getKeyTypes()) + if (keyType != Type.STRING) + throw new IllegalArgumentException( + "Geospatial index can only be defined on STRING properties, found: " + keyType); + + int precision = GeoIndexMetadata.DEFAULT_PRECISION; + if (builder.getMetadata() instanceof GeoIndexMetadata geoMeta) + precision = geoMeta.getPrecision(); + + return new LSMTreeGeoIndex(builder.getDatabase(), builder.getIndexName(), + builder.getFilePath(), ComponentFile.MODE.READ_WRITE, + builder.getPageSize(), builder.getNullStrategy(), precision); + } + } + + /** + * Called at load time. Uses the default precision. + */ + public LSMTreeGeoIndex(final LSMTreeIndex index) { + this(index, DEFAULT_PRECISION); + } + + /** + * Called at load time with explicit precision. + */ + public LSMTreeGeoIndex(final LSMTreeIndex index, final int precision) { + this.underlyingIndex = index; + this.precision = precision; + this.grid = new GeohashPrefixTree(GeoUtils.getSpatialContext(), precision); + this.strategy = new RecursivePrefixTreeStrategy(grid, "geo"); + } + + /** + * Creation time constructor (used by factory handler and tests). + */ + public LSMTreeGeoIndex(final DatabaseInternal database, final String name, final String filePath, + final ComponentFile.MODE mode, final int pageSize, final LSMTreeIndexAbstract.NULL_STRATEGY nullStrategy, + final int precision) { + this.precision = precision; + this.grid = new GeohashPrefixTree(GeoUtils.getSpatialContext(), precision); + this.strategy = new RecursivePrefixTreeStrategy(grid, "geo"); + this.underlyingIndex = new LSMTreeIndex(database, name, false, filePath, mode, new Type[]{Type.STRING}, pageSize, nullStrategy); + } + + /** + * Loading time constructor from an existing file. + */ + public LSMTreeGeoIndex(final DatabaseInternal database, final String name, final String filePath, final int fileId, + final ComponentFile.MODE mode, final int pageSize, final int version) { + this.precision = DEFAULT_PRECISION; + this.grid = new GeohashPrefixTree(GeoUtils.getSpatialContext(), precision); + this.strategy = new RecursivePrefixTreeStrategy(grid, "geo"); + try { + this.underlyingIndex = new LSMTreeIndex(database, name, false, filePath, fileId, mode, pageSize, version); + } catch (final IOException e) { + throw new IndexException("Cannot create geospatial index (error=" + e + ")", e); + } + } + + @Override + public void put(final Object[] keys, final RID[] rids) { + if (keys == null || keys.length == 0 || keys[0] == null) + return; + + // If keys[0] is already a Shape object, it came from a direct query — not valid for indexing. + // If keys[0] is a String, it may be either WKT (from the original put) or a pre-tokenized + // GeoHash string (from transaction commit replay after a transactional put). + // We detect the replay case by attempting WKT parse: if it fails but the value looks like + // a GeoHash token (short alphanumeric), pass it directly to the underlying index. + final Object key0 = keys[0]; + + if (key0 instanceof Shape) { + // Direct Shape input — extract tokens and index them + indexShape((Shape) key0, rids); + return; + } + + final String wkt = key0.toString(); + final Shape shape; + try { + shape = GeoUtils.getSpatialContext().getFormats().getWktReader().read(wkt); + } catch (final Exception e) { + // WKT parse failed. Check if this looks like a pre-tokenized GeoHash string + // (commit replay scenario: transaction re-applies individual tokens via put()). + if (looksLikeGeoHashToken(wkt)) { + underlyingIndex.put(keys, rids); + return; + } + LogManager.instance().log(this, Level.WARNING, + "Geospatial index: skipping invalid WKT '%s': %s", wkt, e.getMessage()); + return; + } + + indexShape(shape, rids); + } + + /** + * Tokenizes a shape using the geohash prefix tree strategy and stores each token in the + * underlying LSM index. + */ + private void indexShape(final Shape shape, final RID[] rids) { + final Field[] fields = strategy.createIndexableFields(shape); + for (final Field field : fields) { + try { + final TokenStream ts = field.tokenStream(null, null); + if (ts == null) + continue; + // Spatial token streams emit binary GeoHash bytes via TermToBytesRefAttribute, + // not via CharTermAttribute. + final TermToBytesRefAttribute bytesAttr = ts.addAttribute(TermToBytesRefAttribute.class); + ts.reset(); + while (ts.incrementToken()) { + final String token = bytesAttr.getBytesRef().utf8ToString(); + if (!token.isEmpty()) + underlyingIndex.put(new Object[]{token}, rids); + } + ts.end(); + ts.close(); + } catch (final IOException e) { + LogManager.instance().log(this, Level.WARNING, + "Geospatial index: token error for shape '%s': %s", shape, e.getMessage()); + } + } + } + + /** + * Returns true if the string looks like a GeoHash token (lowercase alphanumeric, max precision + * length). Used to detect transaction commit replay where individual pre-tokenized GeoHash + * strings are re-passed to put() by the TransactionIndexContext. + */ + private boolean looksLikeGeoHashToken(final String s) { + if (s == null || s.isEmpty() || s.length() > precision) + return false; + for (int i = 0; i < s.length(); i++) { + final char c = s.charAt(i); + // GeoHash alphabet: 0-9, b-z (excluding a, i, l, o) + if (!((c >= '0' && c <= '9') || (c >= 'b' && c <= 'z' && c != 'i' && c != 'l' && c != 'o'))) + return false; + } + return true; + } + + @Override + public IndexCursor get(final Object[] keys) { + return get(keys, -1); + } + + @Override + public IndexCursor get(final Object[] keys, final int limit) { + if (keys == null || keys.length == 0 || keys[0] == null) + return new EmptyIndexCursor(); + + final Shape searchShape = toShape(keys[0]); + if (searchShape == null) + return new EmptyIndexCursor(); + + // Determine the detail level for the query (same heuristic as RecursivePrefixTreeStrategy) + final SpatialArgs args = new SpatialArgs(SpatialOperation.Intersects, searchShape); + final double distErr = args.resolveDistErr(GeoUtils.getSpatialContext(), strategy.getDistErrPct()); + final int detailLevel = grid.getLevelForDistance(distErr); + + // Iterate all tree cells that cover the search shape and collect their GeoHash tokens + final CellIterator cellIter = grid.getTreeCellIterator(searchShape, detailLevel); + final Map seen = new LinkedHashMap<>(); + while (cellIter.hasNext()) { + final Cell cell = cellIter.next(); + if (cell.getShapeRel() == null) + continue; + final String token = cell.getTokenBytesNoLeaf(null).utf8ToString(); + if (token.isEmpty()) + continue; + final IndexCursor cursor = underlyingIndex.get(new Object[]{token}); + while (cursor.hasNext()) + seen.put(cursor.next().getIdentity(), 1); + } + + final List entries = new ArrayList<>(seen.size()); + for (final RID rid : seen.keySet()) + entries.add(new IndexCursorEntry(keys, rid, 1)); + return new TempIndexCursor(entries); + } + + @Override + public void remove(final Object[] keys) { + if (keys == null || keys.length == 0 || keys[0] == null) + return; + for (final String token : extractTokens(keys[0])) + underlyingIndex.remove(new Object[]{token}); + } + + @Override + public void remove(final Object[] keys, final Identifiable rid) { + if (keys == null || keys.length == 0 || keys[0] == null) + return; + for (final String token : extractTokens(keys[0])) + underlyingIndex.remove(new Object[]{token}, rid); + } + + @Override + public void updateTypeName(final String newTypeName) { + underlyingIndex.updateTypeName(newTypeName); + } + + @Override + public IndexInternal getAssociatedIndex() { + return null; + } + + @Override + public long countEntries() { + return underlyingIndex.countEntries(); + } + + @Override + public boolean compact() throws IOException, InterruptedException { + return underlyingIndex.compact(); + } + + @Override + public IndexMetadata getMetadata() { + return underlyingIndex.getMetadata(); + } + + @Override + public boolean isCompacting() { + return underlyingIndex.isCompacting(); + } + + @Override + public boolean scheduleCompaction() { + return underlyingIndex.scheduleCompaction(); + } + + @Override + public String getMostRecentFileName() { + return underlyingIndex.getMostRecentFileName(); + } + + @Override + public void setMetadata(final IndexMetadata metadata) { + underlyingIndex.setMetadata(metadata); + } + + @Override + public boolean setStatus(final INDEX_STATUS[] expectedStatuses, final INDEX_STATUS newStatus) { + return underlyingIndex.setStatus(expectedStatuses, newStatus); + } + + @Override + public void setMetadata(final JSONObject indexJSON) { + underlyingIndex.setMetadata(indexJSON); + } + + @Override + public String getTypeName() { + return underlyingIndex.getTypeName(); + } + + @Override + public List getPropertyNames() { + return underlyingIndex.getPropertyNames(); + } + + @Override + public void close() { + underlyingIndex.close(); + } + + @Override + public void drop() { + underlyingIndex.drop(); + } + + @Override + public String getName() { + return underlyingIndex.getName(); + } + + @Override + public Map getStats() { + return underlyingIndex.getStats(); + } + + @Override + public LSMTreeIndexAbstract.NULL_STRATEGY getNullStrategy() { + return underlyingIndex.getNullStrategy(); + } + + @Override + public void setNullStrategy(final LSMTreeIndexAbstract.NULL_STRATEGY nullStrategy) { + underlyingIndex.setNullStrategy(nullStrategy); + } + + @Override + public int getFileId() { + return underlyingIndex.getFileId(); + } + + @Override + public boolean isUnique() { + return false; + } + + @Override + public PaginatedComponent getComponent() { + return underlyingIndex.getComponent(); + } + + @Override + public Type[] getKeyTypes() { + return underlyingIndex.getKeyTypes(); + } + + @Override + public byte[] getBinaryKeyTypes() { + return underlyingIndex.getBinaryKeyTypes(); + } + + @Override + public int getAssociatedBucketId() { + final int bucketId = underlyingIndex.getAssociatedBucketId(); + // When no bucket is associated (bucketId == -1), return the index's own file ID so that + // the transaction locking machinery does not attempt to lock a non-existent file (-1). + return bucketId >= 0 ? bucketId : underlyingIndex.getFileId(); + } + + @Override + public boolean supportsOrderedIterations() { + return false; + } + + @Override + public boolean isAutomatic() { + return underlyingIndex.getPropertyNames() != null; + } + + @Override + public int getPageSize() { + return underlyingIndex.getPageSize(); + } + + @Override + public List getFileIds() { + return underlyingIndex.getFileIds(); + } + + @Override + public void setTypeIndex(final TypeIndex typeIndex) { + this.typeIndex = typeIndex; + } + + @Override + public TypeIndex getTypeIndex() { + return typeIndex; + } + + @Override + public long build(final int buildIndexBatchSize, final BuildIndexCallback callback) { + return underlyingIndex.build(buildIndexBatchSize, callback); + } + + @Override + public Schema.INDEX_TYPE getType() { + // Placeholder — will return Schema.INDEX_TYPE.GEOSPATIAL after Task 4 + throw new UnsupportedOperationException("GEOSPATIAL index type not yet registered in schema — Task 4 pending"); + } + + @Override + public boolean isValid() { + return underlyingIndex.isValid(); + } + + @Override + public JSONObject toJSON() { + final JSONObject json = new JSONObject(); + json.put("type", "GEOSPATIAL"); + json.put("properties", getPropertyNames()); + json.put("nullStrategy", getNullStrategy()); + json.put("unique", isUnique()); + return json; + } + + /** + * Returns the precision level used for the GeohashPrefixTree. + */ + public int getPrecision() { + return precision; + } + + // ---- Private helpers ---- + + private Shape toShape(final Object obj) { + if (obj instanceof Shape s) + return s; + try { + return GeoUtils.getSpatialContext().getFormats().getWktReader().read(obj.toString()); + } catch (final Exception e) { + LogManager.instance().log(this, Level.WARNING, + "Geospatial index: cannot parse shape '%s'", obj); + return null; + } + } + + private List extractTokens(final Object wktOrShape) { + final Shape shape = toShape(wktOrShape); + if (shape == null) + return List.of(); + final List tokens = new ArrayList<>(); + final Field[] fields = strategy.createIndexableFields(shape); + for (final Field field : fields) { + try { + final TokenStream ts = field.tokenStream(null, null); + if (ts == null) + continue; + final TermToBytesRefAttribute bytesAttr = ts.addAttribute(TermToBytesRefAttribute.class); + ts.reset(); + while (ts.incrementToken()) { + final String token = bytesAttr.getBytesRef().utf8ToString(); + if (!token.isEmpty()) + tokens.add(token); + } + ts.end(); + ts.close(); + } catch (final IOException e) { + // skip + } + } + return tokens; + } +} diff --git a/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java b/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java new file mode 100644 index 0000000000..15bd162ac6 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java @@ -0,0 +1,125 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.index.geospatial; + +import com.arcadedb.TestHelper; +import com.arcadedb.database.DatabaseInternal; +import com.arcadedb.database.RID; +import com.arcadedb.engine.ComponentFile; +import com.arcadedb.function.sql.geo.GeoUtils; +import com.arcadedb.index.IndexCursor; +import com.arcadedb.index.IndexInternal; +import com.arcadedb.index.lsm.LSMTreeIndexAbstract; +import com.arcadedb.schema.LocalSchema; +import org.junit.jupiter.api.Test; +import org.locationtech.spatial4j.shape.Shape; + +import java.lang.reflect.Field; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class LSMTreeGeoIndexTest extends TestHelper { + + /** + * Creates an LSMTreeGeoIndex, registers it with the schema's internal structures, + * and commits the creation transaction. + *

+ * The underlying LSMTreeIndexMutable constructor requires an active transaction to write its first + * page. We start one, register the component so the commit machinery can resolve it by file ID + * (commit2ndPhase) and by name (addFilesToLock), then commit. After this, the index can be used + * in normal database transactions. + */ + private LSMTreeGeoIndex createAndRegisterIndex(final String name) throws Exception { + final LocalSchema schema = (LocalSchema) database.getSchema(); + + database.begin(); + final LSMTreeGeoIndex idx = new LSMTreeGeoIndex( + (DatabaseInternal) database, + name, + database.getDatabasePath() + "/" + name, + ComponentFile.MODE.READ_WRITE, + LSMTreeIndexAbstract.DEF_PAGE_SIZE, + LSMTreeIndexAbstract.NULL_STRATEGY.SKIP, + LSMTreeGeoIndex.DEFAULT_PRECISION + ); + + // Register the paginated component so commit2ndPhase can look it up by file ID + schema.registerFile(idx.getComponent()); + + // Register the index by name so addFilesToLock can resolve it from the schema + final Field indexMapField = LocalSchema.class.getDeclaredField("indexMap"); + indexMapField.setAccessible(true); + @SuppressWarnings("unchecked") + final Map indexMap = (Map) indexMapField.get(schema); + indexMap.put(idx.getName(), idx); + + database.commit(); + return idx; + } + + @Test + void indexAndQueryPoint() throws Exception { + final LSMTreeGeoIndex idx = createAndRegisterIndex("test-geo"); + + // Index a point in central Italy (lat=45, lon=10) + final RID rid = new RID(database, 1, 0); + database.transaction(() -> idx.put(new Object[]{"POINT (10.0 45.0)"}, new RID[]{rid})); + + // Query with a bounding box covering Italy (read-only, outside any transaction) + final Shape searchShape = GeoUtils.getSpatialContext() + .getShapeFactory().rect(5.0, 15.0, 40.0, 50.0); + + final IndexCursor cursor = idx.get(new Object[]{searchShape}); + assertThat(cursor.hasNext()).isTrue(); + assertThat(cursor.next().getIdentity()).isEqualTo(rid); + } + + @Test + void pointOutsideQueryReturnsNoResults() throws Exception { + final LSMTreeGeoIndex idx = createAndRegisterIndex("test-geo2"); + + // Pacific coast point, far from Europe + final RID rid = new RID(database, 1, 0); + database.transaction(() -> idx.put(new Object[]{"POINT (140.0 35.0)"}, new RID[]{rid})); + + // Search in Europe bounding box + final Shape searchShape = GeoUtils.getSpatialContext() + .getShapeFactory().rect(5.0, 15.0, 40.0, 50.0); + + final IndexCursor cursor = idx.get(new Object[]{searchShape}); + assertThat(cursor.hasNext()).isFalse(); + } + + @Test + void nullWktIsSkippedSilently() throws Exception { + final LSMTreeGeoIndex idx = createAndRegisterIndex("test-geo3"); + + // Should not throw for null WKT — index stays empty (null is skipped) + final RID rid = new RID(database, 1, 0); + database.transaction(() -> idx.put(new Object[]{null}, new RID[]{rid})); + + // World bounding box query — nothing was indexed + final Shape searchShape = GeoUtils.getSpatialContext() + .getShapeFactory().rect(-180, 180, -90, 90); + + final IndexCursor cursor = idx.get(new Object[]{searchShape}); + assertThat(cursor.hasNext()).isFalse(); + } +} From d232ccb6ba89dd22df1b40d93759440c34727480 Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 17:53:17 +0100 Subject: [PATCH 06/47] fix(geo): honor limit parameter in LSMTreeGeoIndex.get() Apply the limit ceiling to results returned from the deduplication map, matching the behavior of LSMTreeFullTextIndex. Co-Authored-By: Claude Sonnet 4.6 --- .../com/arcadedb/index/geospatial/LSMTreeGeoIndex.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java index 3ad625d541..7c166a8a5d 100644 --- a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java +++ b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java @@ -265,9 +265,13 @@ public IndexCursor get(final Object[] keys, final int limit) { seen.put(cursor.next().getIdentity(), 1); } - final List entries = new ArrayList<>(seen.size()); - for (final RID rid : seen.keySet()) + final int maxElements = limit > -1 ? Math.min(limit, seen.size()) : seen.size(); + final List entries = new ArrayList<>(maxElements); + for (final RID rid : seen.keySet()) { + if (entries.size() >= maxElements) + break; entries.add(new IndexCursorEntry(keys, rid, 1)); + } return new TempIndexCursor(entries); } From a3d6afe7b511a6ce3921dd13520a0df7b55cf006 Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 18:15:23 +0100 Subject: [PATCH 07/47] refactor(geo): address code quality issues in LSMTreeGeoIndex - toJSON(): add missing "bucket" field to prevent schema reload failure - extractTokens(): consolidate duplicated token-stream loop from indexShape() so that IOException warning is emitted consistently in both put and remove paths - get(): replace LinkedHashMap with LinkedHashSet for clarity - getType(): remove internal task reference from exception message Co-Authored-By: Claude Sonnet 4.6 --- .../index/geospatial/LSMTreeGeoIndex.java | 41 ++++++------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java index 7c166a8a5d..d45560903a 100644 --- a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java +++ b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java @@ -55,7 +55,7 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.logging.Level; @@ -190,28 +190,8 @@ public void put(final Object[] keys, final RID[] rids) { * underlying LSM index. */ private void indexShape(final Shape shape, final RID[] rids) { - final Field[] fields = strategy.createIndexableFields(shape); - for (final Field field : fields) { - try { - final TokenStream ts = field.tokenStream(null, null); - if (ts == null) - continue; - // Spatial token streams emit binary GeoHash bytes via TermToBytesRefAttribute, - // not via CharTermAttribute. - final TermToBytesRefAttribute bytesAttr = ts.addAttribute(TermToBytesRefAttribute.class); - ts.reset(); - while (ts.incrementToken()) { - final String token = bytesAttr.getBytesRef().utf8ToString(); - if (!token.isEmpty()) - underlyingIndex.put(new Object[]{token}, rids); - } - ts.end(); - ts.close(); - } catch (final IOException e) { - LogManager.instance().log(this, Level.WARNING, - "Geospatial index: token error for shape '%s': %s", shape, e.getMessage()); - } - } + for (final String token : extractTokens(shape)) + underlyingIndex.put(new Object[]{token}, rids); } /** @@ -252,7 +232,7 @@ public IndexCursor get(final Object[] keys, final int limit) { // Iterate all tree cells that cover the search shape and collect their GeoHash tokens final CellIterator cellIter = grid.getTreeCellIterator(searchShape, detailLevel); - final Map seen = new LinkedHashMap<>(); + final LinkedHashSet seen = new LinkedHashSet<>(); while (cellIter.hasNext()) { final Cell cell = cellIter.next(); if (cell.getShapeRel() == null) @@ -262,12 +242,12 @@ public IndexCursor get(final Object[] keys, final int limit) { continue; final IndexCursor cursor = underlyingIndex.get(new Object[]{token}); while (cursor.hasNext()) - seen.put(cursor.next().getIdentity(), 1); + seen.add(cursor.next().getIdentity()); } final int maxElements = limit > -1 ? Math.min(limit, seen.size()) : seen.size(); final List entries = new ArrayList<>(maxElements); - for (final RID rid : seen.keySet()) { + for (final RID rid : seen) { if (entries.size() >= maxElements) break; entries.add(new IndexCursorEntry(keys, rid, 1)); @@ -456,8 +436,7 @@ public long build(final int buildIndexBatchSize, final BuildIndexCallback callba @Override public Schema.INDEX_TYPE getType() { - // Placeholder — will return Schema.INDEX_TYPE.GEOSPATIAL after Task 4 - throw new UnsupportedOperationException("GEOSPATIAL index type not yet registered in schema — Task 4 pending"); + throw new UnsupportedOperationException("GEOSPATIAL index type is not yet available"); } @Override @@ -469,6 +448,9 @@ public boolean isValid() { public JSONObject toJSON() { final JSONObject json = new JSONObject(); json.put("type", "GEOSPATIAL"); + final int bucketId = underlyingIndex.getAssociatedBucketId(); + if (bucketId >= 0) + json.put("bucket", underlyingIndex.getComponent().getDatabase().getSchema().getBucketById(bucketId).getName()); json.put("properties", getPropertyNames()); json.put("nullStrategy", getNullStrategy()); json.put("unique", isUnique()); @@ -517,7 +499,8 @@ private List extractTokens(final Object wktOrShape) { ts.end(); ts.close(); } catch (final IOException e) { - // skip + LogManager.instance().log(this, Level.WARNING, + "Geospatial index: token error for shape '%s': %s", shape, e.getMessage()); } } return tokens; From c490567b9e83c54a51e6a05368cafbcff0a7b1ef Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 18:19:22 +0100 Subject: [PATCH 08/47] feat(geo): register GEOSPATIAL index type in Schema enum and LocalSchema - Add GEOSPATIAL to Schema.INDEX_TYPE enum - Register GeoIndexFactoryHandler in LocalSchema - Fix LSMTreeGeoIndex.getType() to return Schema.INDEX_TYPE.GEOSPATIAL - Add GEOSPATIAL case to LocalSchema readConfiguration() for schema reload - Add GEOSPATIAL to CreateIndexStatement validate() and executeDDL() mapping - Add LSMTreeGeoIndexSchemaTest to verify DDL creation via SQL Co-Authored-By: Claude Sonnet 4.6 --- .../index/geospatial/LSMTreeGeoIndex.java | 2 +- .../sql/parser/CreateIndexStatement.java | 5 +++ .../java/com/arcadedb/schema/LocalSchema.java | 6 +++ .../main/java/com/arcadedb/schema/Schema.java | 2 +- .../geospatial/LSMTreeGeoIndexSchemaTest.java | 42 +++++++++++++++++++ 5 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexSchemaTest.java diff --git a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java index d45560903a..e69ec0d9e8 100644 --- a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java +++ b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java @@ -436,7 +436,7 @@ public long build(final int buildIndexBatchSize, final BuildIndexCallback callba @Override public Schema.INDEX_TYPE getType() { - throw new UnsupportedOperationException("GEOSPATIAL index type is not yet available"); + return Schema.INDEX_TYPE.GEOSPATIAL; } @Override diff --git a/engine/src/main/java/com/arcadedb/query/sql/parser/CreateIndexStatement.java b/engine/src/main/java/com/arcadedb/query/sql/parser/CreateIndexStatement.java index 2f24047207..4d70973192 100644 --- a/engine/src/main/java/com/arcadedb/query/sql/parser/CreateIndexStatement.java +++ b/engine/src/main/java/com/arcadedb/query/sql/parser/CreateIndexStatement.java @@ -70,6 +70,8 @@ public void validate() throws CommandSQLParsingException { } case "LSM_VECTOR" -> { } + case "GEOSPATIAL" -> { + } default -> throw new CommandSQLParsingException("Index type '" + typeAsString + "' is not supported"); } } @@ -105,6 +107,9 @@ else if (typeAsString.equalsIgnoreCase("UNIQUE")) { } else if (typeAsString.equalsIgnoreCase("LSM_VECTOR")) { indexType = Schema.INDEX_TYPE.LSM_VECTOR; unique = false; + } else if (typeAsString.equalsIgnoreCase("GEOSPATIAL")) { + indexType = Schema.INDEX_TYPE.GEOSPATIAL; + unique = false; } else throw new CommandSQLParsingException("Index type '" + typeAsString + "' is not supported"); diff --git a/engine/src/main/java/com/arcadedb/schema/LocalSchema.java b/engine/src/main/java/com/arcadedb/schema/LocalSchema.java index a9ed55dcef..07deeeb882 100644 --- a/engine/src/main/java/com/arcadedb/schema/LocalSchema.java +++ b/engine/src/main/java/com/arcadedb/schema/LocalSchema.java @@ -51,6 +51,7 @@ import com.arcadedb.index.IndexInternal; import com.arcadedb.index.TypeIndex; import com.arcadedb.index.fulltext.LSMTreeFullTextIndex; +import com.arcadedb.index.geospatial.LSMTreeGeoIndex; import com.arcadedb.index.lsm.LSMTreeIndex; import com.arcadedb.index.lsm.LSMTreeIndexAbstract.NULL_STRATEGY; import com.arcadedb.index.lsm.LSMTreeIndexCompacted; @@ -138,6 +139,7 @@ public LocalSchema(final DatabaseInternal database, final String databasePath, f indexFactory.register(INDEX_TYPE.LSM_TREE.name(), new LSMTreeIndex.LSMTreeIndexFactoryHandler()); indexFactory.register(INDEX_TYPE.FULL_TEXT.name(), new LSMTreeFullTextIndex.LSMTreeFullTextIndexFactoryHandler()); indexFactory.register(INDEX_TYPE.LSM_VECTOR.name(), new LSMVectorIndex.LSMVectorIndexFactoryHandler()); + indexFactory.register(INDEX_TYPE.GEOSPATIAL.name(), new LSMTreeGeoIndex.GeoIndexFactoryHandler()); configurationFile = new File(databasePath + File.separator + SCHEMA_FILE_NAME); } @@ -1490,6 +1492,10 @@ protected synchronized void readConfiguration() { if (configuredIndexType.equalsIgnoreCase(Schema.INDEX_TYPE.FULL_TEXT.toString())) { index = new LSMTreeFullTextIndex((LSMTreeIndex) index); indexMap.put(indexName, index); + } else if (configuredIndexType.equalsIgnoreCase(Schema.INDEX_TYPE.GEOSPATIAL.toString())) { + final int precision = indexJSON.getInt("precision", GeoIndexMetadata.DEFAULT_PRECISION); + index = new LSMTreeGeoIndex((LSMTreeIndex) index, precision); + indexMap.put(indexName, index); } else { orphanIndexes.put(indexName, indexJSON); indexJSON.put("type", typeName); diff --git a/engine/src/main/java/com/arcadedb/schema/Schema.java b/engine/src/main/java/com/arcadedb/schema/Schema.java index b53cabe50b..48af9abd4b 100644 --- a/engine/src/main/java/com/arcadedb/schema/Schema.java +++ b/engine/src/main/java/com/arcadedb/schema/Schema.java @@ -439,6 +439,6 @@ Index createManualIndex(Schema.INDEX_TYPE indexType, boolean unique, String inde FunctionDefinition getFunction(String libraryName, String functionName) throws IllegalArgumentException; enum INDEX_TYPE { - LSM_TREE, FULL_TEXT, LSM_VECTOR + LSM_TREE, FULL_TEXT, LSM_VECTOR, GEOSPATIAL } } diff --git a/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexSchemaTest.java b/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexSchemaTest.java new file mode 100644 index 0000000000..3f44f11c29 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexSchemaTest.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.index.geospatial; + +import com.arcadedb.TestHelper; +import com.arcadedb.index.Index; +import com.arcadedb.schema.Schema; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LSMTreeGeoIndexSchemaTest extends TestHelper { + + @Test + void createGeospatialIndexViaSql() { + database.command("sql", "CREATE DOCUMENT TYPE Location"); + database.command("sql", "CREATE PROPERTY Location.coords STRING"); + database.command("sql", "CREATE INDEX ON Location (coords) GEOSPATIAL"); + + database.transaction(() -> database.command("sql", "INSERT INTO Location SET coords = 'POINT (12.5 41.9)'")); + + final Index index = database.getSchema().getIndexByName("Location[coords]"); + assertThat(index).isNotNull(); + assertThat(index.getType()).isEqualTo(Schema.INDEX_TYPE.GEOSPATIAL); + } +} From 549e1c713d8543cf5ccc3c56061efd3d07e8f9f5 Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 18:22:14 +0100 Subject: [PATCH 09/47] fix(geo): persist precision in LSMTreeGeoIndex.toJSON() Without this, a round-trip persist/reload always falls back to DEFAULT_PRECISION regardless of what was configured at creation time. Co-Authored-By: Claude Sonnet 4.6 --- .../main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java | 1 + 1 file changed, 1 insertion(+) diff --git a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java index e69ec0d9e8..fa90a38580 100644 --- a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java +++ b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java @@ -452,6 +452,7 @@ public JSONObject toJSON() { if (bucketId >= 0) json.put("bucket", underlyingIndex.getComponent().getDatabase().getSchema().getBucketById(bucketId).getName()); json.put("properties", getPropertyNames()); + json.put("precision", precision); json.put("nullStrategy", getNullStrategy()); json.put("unique", isUnique()); return json; From 0a23d270cff6f6436b0db01de5be3a024007aaf1 Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 18:29:52 +0100 Subject: [PATCH 10/47] fix(geo): correct toJSON() pattern and add persistence round-trip tests - Use getType() instead of hardcoded "GEOSPATIAL" string in toJSON() - Remove conditional bucket guard that caused silent orphan index on reload - Add geospatialIndexSurvivesReopen() test covering readConfiguration path - Add non-default precision round-trip test (precision=7) Co-Authored-By: Claude Sonnet 4.6 --- .../index/geospatial/LSMTreeGeoIndex.java | 5 +-- .../geospatial/LSMTreeGeoIndexSchemaTest.java | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java index fa90a38580..39eba46ffa 100644 --- a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java +++ b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java @@ -447,10 +447,9 @@ public boolean isValid() { @Override public JSONObject toJSON() { final JSONObject json = new JSONObject(); - json.put("type", "GEOSPATIAL"); + json.put("type", getType()); final int bucketId = underlyingIndex.getAssociatedBucketId(); - if (bucketId >= 0) - json.put("bucket", underlyingIndex.getComponent().getDatabase().getSchema().getBucketById(bucketId).getName()); + json.put("bucket", underlyingIndex.getComponent().getDatabase().getSchema().getBucketById(bucketId).getName()); json.put("properties", getPropertyNames()); json.put("precision", precision); json.put("nullStrategy", getNullStrategy()); diff --git a/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexSchemaTest.java b/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexSchemaTest.java index 3f44f11c29..6862776035 100644 --- a/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexSchemaTest.java +++ b/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexSchemaTest.java @@ -20,7 +20,10 @@ import com.arcadedb.TestHelper; import com.arcadedb.index.Index; +import com.arcadedb.index.TypeIndex; +import com.arcadedb.schema.GeoIndexMetadata; import com.arcadedb.schema.Schema; +import com.arcadedb.schema.TypeIndexBuilder; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -39,4 +42,40 @@ void createGeospatialIndexViaSql() { assertThat(index).isNotNull(); assertThat(index.getType()).isEqualTo(Schema.INDEX_TYPE.GEOSPATIAL); } + + @Test + void geospatialIndexSurvivesReopen() { + database.command("sql", "CREATE DOCUMENT TYPE Location"); + database.command("sql", "CREATE PROPERTY Location.coords STRING"); + database.command("sql", "CREATE INDEX ON Location (coords) GEOSPATIAL"); + + reopenDatabase(); + + final Index index = database.getSchema().getIndexByName("Location[coords]"); + assertThat(index).isNotNull(); + assertThat(index.getType()).isEqualTo(Schema.INDEX_TYPE.GEOSPATIAL); + final LSMTreeGeoIndex geoIndex = (LSMTreeGeoIndex) ((TypeIndex) index).getSubIndexes().getFirst(); + assertThat(geoIndex.getPrecision()).isEqualTo(GeoIndexMetadata.DEFAULT_PRECISION); + } + + @Test + void geospatialIndexNonDefaultPrecisionSurvivesReopen() { + database.command("sql", "CREATE DOCUMENT TYPE Location2"); + database.command("sql", "CREATE PROPERTY Location2.coords STRING"); + + final TypeIndexBuilder builder = database.getSchema().buildTypeIndex("Location2", new String[] { "coords" }); + builder.withType(Schema.INDEX_TYPE.GEOSPATIAL); + final GeoIndexMetadata geoMeta = new GeoIndexMetadata("Location2", new String[] { "coords" }, -1); + geoMeta.setPrecision(7); + builder.metadata = geoMeta; + builder.create(); + + reopenDatabase(); + + final Index index = database.getSchema().getIndexByName("Location2[coords]"); + assertThat(index).isNotNull(); + assertThat(index.getType()).isEqualTo(Schema.INDEX_TYPE.GEOSPATIAL); + final LSMTreeGeoIndex geoIndex = (LSMTreeGeoIndex) ((TypeIndex) index).getSubIndexes().getFirst(); + assertThat(geoIndex.getPrecision()).isEqualTo(7); + } } From 4dbc71a3b90ba97069648f952763bc2faa5b1455 Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 18:52:39 +0100 Subject: [PATCH 11/47] feat(geo): add ST_* constructor/accessor functions, remove legacy geo functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New functions (12): ST_GeomFromText, ST_Point, ST_LineString, ST_Polygon, ST_Buffer, ST_Envelope, ST_Distance, ST_Area, ST_AsText, ST_AsGeoJson, ST_X, ST_Y — all registered in DefaultSQLFunctionFactory. Removed legacy functions: point(), distance(), circle(), polygon(), lineString(), rectangle() — per design spec. Extends GeoUtils with parseGeometry(), toWKT(), parseJtsGeometry(), jtsToWKT() helpers; formatCoord() now uses String.format() instead of instantiating NumberFormat per call. Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 39 ++ .../sql/DefaultSQLFunctionFactory.java | 38 +- .../arcadedb/function/sql/geo/GeoUtils.java | 80 +++ .../function/sql/geo/SQLFunctionDistance.java | 157 ----- .../sql/geo/SQLFunctionLineString.java | 67 -- .../function/sql/geo/SQLFunctionPolygon.java | 67 -- .../sql/geo/SQLFunctionRectangle.java | 53 -- ...ionCircle.java => SQLFunctionST_Area.java} | 35 +- .../sql/geo/SQLFunctionST_AsGeoJson.java | 134 ++++ .../sql/geo/SQLFunctionST_AsText.java | 63 ++ .../sql/geo/SQLFunctionST_Buffer.java | 59 ++ .../sql/geo/SQLFunctionST_Distance.java | 98 +++ .../sql/geo/SQLFunctionST_Envelope.java | 69 ++ ...t.java => SQLFunctionST_GeomFromText.java} | 29 +- .../sql/geo/SQLFunctionST_LineString.java | 78 +++ .../function/sql/geo/SQLFunctionST_Point.java | 52 ++ .../sql/geo/SQLFunctionST_Polygon.java | 102 +++ .../function/sql/geo/SQLFunctionST_X.java | 67 ++ .../function/sql/geo/SQLFunctionST_Y.java | 67 ++ .../function/sql/geo/SQLGeoFunctionsTest.java | 635 +++++++++++------- 20 files changed, 1342 insertions(+), 647 deletions(-) create mode 100644 .claude/settings.local.json delete mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionDistance.java delete mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionLineString.java delete mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionPolygon.java delete mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionRectangle.java rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionCircle.java => SQLFunctionST_Area.java} (55%) create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsGeoJson.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsText.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Buffer.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Distance.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Envelope.java rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionPoint.java => SQLFunctionST_GeomFromText.java} (57%) create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_LineString.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Point.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Polygon.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_X.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Y.java diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000..788efb2415 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,39 @@ +{ + "permissions": { + "allow": [ + "Bash(grep:*)", + "WebSearch", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:github.com)", + "Bash(mvn clean compile:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(mvn test:*)", + "Bash(mvn compile:*)", + "Bash(jar tf:*)", + "Bash(mvn dependency:resolve:*)", + "Bash(while read jar)", + "Bash(do echo \"=== $jar ===\")", + "Bash(done)", + "Bash(# Check RaftServer.Builder API, RaftPeer, RaftGroup, RaftClient APIs javap -p -cp ~/.m2/repository/org/apache/ratis/ratis-server-api/3.2.0/ratis-server-api-3.2.0.jar org.apache.ratis.server.RaftServer)", + "Bash(javap:*)", + "Bash(mvn install:*)", + "Bash(echo:*)", + "Bash(wait)", + "Bash(mvn test-compile:*)", + "Bash(mvn clean test-compile:*)", + "Bash(mvn clean install:*)", + "Bash(mvn verify:*)", + "Bash(xargs:*)", + "Bash(do jar tf:*)", + "Bash(pkill:*)", + "Bash(./mvnw test:*)", + "Bash(git log:*)", + "Bash(./mvnw compile:*)", + "Bash(./mvnw install:*)", + "Bash(tail:*)", + "Bash(mvn:*)", + "Bash(head:*)" + ] + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java index e3f3ceca3b..02b26e5481 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java +++ b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java @@ -29,12 +29,18 @@ import com.arcadedb.function.sql.coll.SQLFunctionSet; import com.arcadedb.function.sql.coll.SQLFunctionSymmetricDifference; import com.arcadedb.function.sql.coll.SQLFunctionUnionAll; -import com.arcadedb.function.sql.geo.SQLFunctionCircle; -import com.arcadedb.function.sql.geo.SQLFunctionDistance; -import com.arcadedb.function.sql.geo.SQLFunctionLineString; -import com.arcadedb.function.sql.geo.SQLFunctionPoint; -import com.arcadedb.function.sql.geo.SQLFunctionPolygon; -import com.arcadedb.function.sql.geo.SQLFunctionRectangle; +import com.arcadedb.function.sql.geo.SQLFunctionST_Area; +import com.arcadedb.function.sql.geo.SQLFunctionST_AsGeoJson; +import com.arcadedb.function.sql.geo.SQLFunctionST_AsText; +import com.arcadedb.function.sql.geo.SQLFunctionST_Buffer; +import com.arcadedb.function.sql.geo.SQLFunctionST_Distance; +import com.arcadedb.function.sql.geo.SQLFunctionST_Envelope; +import com.arcadedb.function.sql.geo.SQLFunctionST_GeomFromText; +import com.arcadedb.function.sql.geo.SQLFunctionST_LineString; +import com.arcadedb.function.sql.geo.SQLFunctionST_Point; +import com.arcadedb.function.sql.geo.SQLFunctionST_Polygon; +import com.arcadedb.function.sql.geo.SQLFunctionST_X; +import com.arcadedb.function.sql.geo.SQLFunctionST_Y; import com.arcadedb.function.sql.graph.SQLFunctionAstar; import com.arcadedb.function.sql.graph.SQLFunctionBellmanFord; import com.arcadedb.function.sql.graph.SQLFunctionBoth; @@ -166,13 +172,19 @@ private DefaultSQLFunctionFactory() { register(SQLFunctionSymmetricDifference.NAME, SQLFunctionSymmetricDifference.class); register(SQLFunctionUnionAll.NAME, SQLFunctionUnionAll.class); - // Geo - register(SQLFunctionCircle.NAME, new SQLFunctionCircle()); - register(SQLFunctionDistance.NAME, new SQLFunctionDistance()); - register(SQLFunctionLineString.NAME, new SQLFunctionLineString()); - register(SQLFunctionPoint.NAME, new SQLFunctionPoint()); - register(SQLFunctionPolygon.NAME, new SQLFunctionPolygon()); - register(SQLFunctionRectangle.NAME, new SQLFunctionRectangle()); + // Geo — ST_* standard functions + register(SQLFunctionST_GeomFromText.NAME, new SQLFunctionST_GeomFromText()); + register(SQLFunctionST_Point.NAME, new SQLFunctionST_Point()); + register(SQLFunctionST_LineString.NAME, new SQLFunctionST_LineString()); + register(SQLFunctionST_Polygon.NAME, new SQLFunctionST_Polygon()); + register(SQLFunctionST_Buffer.NAME, new SQLFunctionST_Buffer()); + register(SQLFunctionST_Envelope.NAME, new SQLFunctionST_Envelope()); + register(SQLFunctionST_Distance.NAME, new SQLFunctionST_Distance()); + register(SQLFunctionST_Area.NAME, new SQLFunctionST_Area()); + register(SQLFunctionST_AsText.NAME, new SQLFunctionST_AsText()); + register(SQLFunctionST_AsGeoJson.NAME, new SQLFunctionST_AsGeoJson()); + register(SQLFunctionST_X.NAME, new SQLFunctionST_X()); + register(SQLFunctionST_Y.NAME, new SQLFunctionST_Y()); // Graph register(SQLFunctionAstar.NAME, SQLFunctionAstar.class); diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/GeoUtils.java b/engine/src/main/java/com/arcadedb/function/sql/geo/GeoUtils.java index c411011312..71ecf4bb66 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/GeoUtils.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/GeoUtils.java @@ -18,10 +18,18 @@ */ package com.arcadedb.function.sql.geo; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKTReader; +import org.locationtech.jts.io.WKTWriter; import org.locationtech.spatial4j.context.SpatialContext; import org.locationtech.spatial4j.context.SpatialContextFactory; import org.locationtech.spatial4j.context.jts.JtsSpatialContext; import org.locationtech.spatial4j.context.jts.JtsSpatialContextFactory; +import org.locationtech.spatial4j.io.ShapeIO; +import org.locationtech.spatial4j.shape.Shape; + +import java.util.Locale; /** * Geospatial utility class. @@ -43,4 +51,76 @@ public static SpatialContext getSpatialContext() { public static double getDoubleValue(final Object param) { return ((Number) param).doubleValue(); } + + /** + * Parse a value that can be either a Spatial4j Shape or a WKT string into a Shape. + * Returns null if the value is null. + */ + public static Shape parseGeometry(final Object value) { + if (value == null) + return null; + if (value instanceof Shape shape) + return shape; + final String wkt = value.toString().trim(); + if (wkt.isEmpty()) + return null; + try { + return SPATIAL_CONTEXT.getFormats().getReader(ShapeIO.WKT).read(wkt); + } catch (Exception e) { + throw new IllegalArgumentException("Cannot parse geometry from: " + wkt, e); + } + } + + /** + * Convert a Shape to WKT string using the Spatial4j WKT writer. + * Returns null if the shape is null. + */ + public static String toWKT(final Shape shape) { + if (shape == null) + return null; + return SPATIAL_CONTEXT.getFormats().getWriter(ShapeIO.WKT).toString(shape); + } + + /** + * Parse a WKT string or Shape into a JTS Geometry for advanced operations (buffer, envelope, etc.). + * Returns null if the value is null. + */ + public static Geometry parseJtsGeometry(final Object value) { + if (value == null) + return null; + final String wkt; + if (value instanceof Shape shape) + wkt = toWKT(shape); + else + wkt = value.toString().trim(); + if (wkt == null || wkt.isEmpty()) + return null; + try { + return new WKTReader().read(wkt); + } catch (ParseException e) { + throw new IllegalArgumentException("Cannot parse JTS geometry from WKT: " + wkt, e); + } + } + + /** + * Convert a JTS Geometry to WKT string. + */ + public static String jtsToWKT(final Geometry geometry) { + if (geometry == null) + return null; + return new WKTWriter().write(geometry); + } + + /** + * Format a double for WKT output: no trailing zeros, uses dot decimal separator. + */ + public static String formatCoord(final double value) { + // Remove trailing zeros while using US locale for decimal point + final String s = String.format(Locale.US, "%.10f", value); + // Strip trailing zeros after decimal point + int end = s.length(); + while (end > 1 && s.charAt(end - 1) == '0') end--; + if (s.charAt(end - 1) == '.') end--; + return s.substring(0, end); + } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionDistance.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionDistance.java deleted file mode 100644 index ead8756226..0000000000 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionDistance.java +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) - * - * 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 (info@arcadedata.com) - * SPDX-License-Identifier: Apache-2.0 - */ -package com.arcadedb.function.sql.geo; - -import com.arcadedb.database.Identifiable; -import com.arcadedb.query.sql.executor.CommandContext; -import com.arcadedb.function.sql.SQLFunctionAbstract; -import com.arcadedb.schema.Type; -import org.locationtech.spatial4j.shape.Point; - -/** - * Haversine formula to compute the distance between 2 gro points. - * - * @author Luca Garulli (l.garulli--(at)--arcadedata.com) - */ -public class SQLFunctionDistance extends SQLFunctionAbstract { - public static final String NAME = "distance"; - - private final static double EARTH_RADIUS = 6371; - - public SQLFunctionDistance() { - super(NAME); - } - - public Object execute(final Object self, final Identifiable currentRecord, final Object currentResult, final Object[] params, - final CommandContext context) { - if (params == null || params.length < 2) - throw new IllegalArgumentException("distance() requires at least 2 parameters"); - - double distance; - final double[] values = new double[4]; - String unit = "km"; - boolean cypherStyle = false; - - // Support two forms: - // 1. Cypher/Neo4j style: distance(point1, point2) or distance(point1, point2, unit) - // - Returns distance in meters by default (Neo4j compatibility) - // 2. SQL style: distance(x1, y1, x2, y2) or distance(x1, y1, x2, y2, unit) - // - Returns distance in kilometers by default (backward compatibility) - - if (params.length == 2 || (params.length == 3 && !(params[2] instanceof Number))) { - // Cypher/Neo4j style: distance(point1, point2) or distance(point1, point2, unit) - cypherStyle = true; - extractCoordinatesFromPoint(params[0], values, 0); - extractCoordinatesFromPoint(params[1], values, 2); - - if (params.length == 3) - unit = params[2].toString(); - else - unit = "m"; // Neo4j default is meters - } else { - // SQL style: distance(x1, y1, x2, y2) or distance(x1, y1, x2, y2, unit) - for (int i = 0; i < params.length && i < 4; ++i) { - if (params[i] == null) - return null; - - values[i] = (Double) Type.convert(context.getDatabase(), params[i], Double.class); - } - - if (params.length > 4) - unit = params[4].toString(); - } - - final double deltaLat = Math.toRadians(values[2] - values[0]); - final double deltaLon = Math.toRadians(values[3] - values[1]); - - final double a = - Math.pow(Math.sin(deltaLat / 2), 2) + Math.cos(Math.toRadians(values[0])) * Math.cos(Math.toRadians(values[2])) * Math.pow(Math.sin(deltaLon / 2), 2); - distance = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * EARTH_RADIUS; - - // Apply unit conversion - if (unit.equalsIgnoreCase("km")) - // ALREADY IN KM - ; - else if (unit.equalsIgnoreCase("m")) - // METERS - distance *= 1000; - else if (unit.equalsIgnoreCase("mi")) - // MILES - distance *= 0.621371192; - else if (unit.equalsIgnoreCase("nmi")) - // NAUTICAL MILES - distance *= 0.539956803; - else - throw new IllegalArgumentException("Unsupported unit '" + unit + "'. Use m, km, mi and nmi. Default is " + (cypherStyle ? "m (meters)" : "km (kilometers)")); - - return distance; - } - - /** - * Extract X and Y coordinates from a point parameter. - * Handles Spatial4j Point objects and WKT strings like "POINT (x y)". - */ - private void extractCoordinatesFromPoint(final Object param, final double[] values, final int offset) { - if (param == null) - throw new IllegalArgumentException("Point parameter cannot be null"); - - // Check if it's a Spatial4j Point object (direct instanceof - no reflection!) - if (param instanceof Point point) { - values[offset] = point.getX(); - values[offset + 1] = point.getY(); - return; - } - - // Try to parse as WKT string: "POINT (x y)" or "Pt(x=...,y=...)" - final String str = param.toString(); - - // Handle WKT format: "POINT (x y)" - if (str.startsWith("POINT (") && str.endsWith(")")) { - final String coords = str.substring(7, str.length() - 1).trim(); - final String[] parts = coords.split("\\s+"); - if (parts.length >= 2) { - values[offset] = Double.parseDouble(parts[0]); - values[offset + 1] = Double.parseDouble(parts[1]); - return; - } - } - - // Handle internal format: "Pt(x=...,y=...)" - if (str.startsWith("Pt(") && str.endsWith(")")) { - final String coords = str.substring(3, str.length() - 1); - final String[] parts = coords.split(","); - if (parts.length >= 2) { - for (String part : parts) { - part = part.trim(); - if (part.startsWith("x=")) - values[offset] = Double.parseDouble(part.substring(2)); - else if (part.startsWith("y=")) - values[offset + 1] = Double.parseDouble(part.substring(2)); - } - return; - } - } - - throw new IllegalArgumentException("Cannot extract coordinates from point parameter: " + str); - } - - public String getSyntax() { - return "distance(,[,]) or distance(,,,[,])"; - } -} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionLineString.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionLineString.java deleted file mode 100644 index 08389cb539..0000000000 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionLineString.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) - * - * 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 (info@arcadedata.com) - * SPDX-License-Identifier: Apache-2.0 - */ -package com.arcadedb.function.sql.geo; - -import com.arcadedb.database.Identifiable; -import com.arcadedb.query.sql.executor.CommandContext; -import com.arcadedb.function.sql.SQLFunctionAbstract; -import org.locationtech.spatial4j.context.SpatialContext; -import org.locationtech.spatial4j.shape.Point; -import org.locationtech.spatial4j.shape.ShapeFactory; - -import java.util.*; - -/** - * Returns a linestring shape with the coordinates received as parameters. - * - * @author Luca Garulli (l.garulli--(at)--arcadedata.com) - */ -public class SQLFunctionLineString extends SQLFunctionAbstract { - public static final String NAME = "linestring"; - - public SQLFunctionLineString() { - super(NAME); - } - - public Object execute(final Object self, final Identifiable currentRecord, final Object currentResult, final Object[] params, - final CommandContext context) { - if (params.length != 1) - throw new IllegalArgumentException("lineString() requires array of points as parameters"); - - final SpatialContext spatialContext = GeoUtils.getSpatialContext(); - - final List points = (List) params[0]; - - ShapeFactory.LineStringBuilder lineString = spatialContext.getShapeFactory().lineString(); - - for (int i = 0; i < points.size(); i++) { - final Object point = points.get(i); - - if (point instanceof Point point1) - lineString.pointXY(point1.getX(), point1.getY()); - else if (point instanceof List list) - lineString.pointXY(GeoUtils.getDoubleValue(list.getFirst()), GeoUtils.getDoubleValue(list.get(1))); - } - return lineString.build(); - } - - public String getSyntax() { - return "lineString([ * ])"; - } -} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionPolygon.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionPolygon.java deleted file mode 100644 index ea050123f4..0000000000 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionPolygon.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) - * - * 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 (info@arcadedata.com) - * SPDX-License-Identifier: Apache-2.0 - */ -package com.arcadedb.function.sql.geo; - -import com.arcadedb.database.Identifiable; -import com.arcadedb.query.sql.executor.CommandContext; -import com.arcadedb.function.sql.SQLFunctionAbstract; -import org.locationtech.spatial4j.context.SpatialContext; -import org.locationtech.spatial4j.shape.Point; -import org.locationtech.spatial4j.shape.ShapeFactory; - -import java.util.*; - -/** - * Returns a polygon shape with the coordinates received as parameters. - * - * @author Luca Garulli (l.garulli--(at)--arcadedata.com) - */ -public class SQLFunctionPolygon extends SQLFunctionAbstract { - public static final String NAME = "polygon"; - - public SQLFunctionPolygon() { - super(NAME); - } - - public Object execute(final Object self, final Identifiable currentRecord, final Object currentResult, final Object[] params, - final CommandContext context) { - if (params.length != 1) - throw new IllegalArgumentException("polygon() requires array of points as parameters"); - - final SpatialContext spatialContext = GeoUtils.getSpatialContext(); - - final List points = (List) params[0]; - - ShapeFactory.PolygonBuilder polygon = spatialContext.getShapeFactory().polygon(); - - for (int i = 0; i < points.size(); i++) { - final Object point = points.get(i); - - if (point instanceof Point point1) - polygon.pointXY(point1.getX(), point1.getY()); - else if (point instanceof List list) - polygon.pointXY(GeoUtils.getDoubleValue(list.getFirst()), GeoUtils.getDoubleValue(list.get(1))); - } - return polygon.build(); - } - - public String getSyntax() { - return "polygon([ * ])"; - } -} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionRectangle.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionRectangle.java deleted file mode 100644 index 08fb0ab490..0000000000 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionRectangle.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) - * - * 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 (info@arcadedata.com) - * SPDX-License-Identifier: Apache-2.0 - */ -package com.arcadedb.function.sql.geo; - -import com.arcadedb.database.Identifiable; -import com.arcadedb.query.sql.executor.CommandContext; -import com.arcadedb.function.sql.SQLFunctionAbstract; -import org.locationtech.spatial4j.context.SpatialContext; -import org.locationtech.spatial4j.shape.Point; - -/** - * Returns a rectangle shape with the 4 coordinates received as parameters. - * - * @author Luca Garulli (l.garulli--(at)--arcadedata.com) - */ -public class SQLFunctionRectangle extends SQLFunctionAbstract { - public static final String NAME = "rectangle"; - - public SQLFunctionRectangle() { - super(NAME); - } - - public Object execute(final Object self, final Identifiable currentRecord, final Object currentResult, final Object[] params, - final CommandContext context) { - if (params.length != 4) - throw new IllegalArgumentException("rectangle() requires 4 parameters"); - - final SpatialContext spatialContext = GeoUtils.getSpatialContext(); - final Point topLeft = spatialContext.getShapeFactory().pointXY(GeoUtils.getDoubleValue(params[0]), GeoUtils.getDoubleValue(params[1])); - final Point bottomRight = spatialContext.getShapeFactory().pointXY(GeoUtils.getDoubleValue(params[2]), GeoUtils.getDoubleValue(params[3])); - return spatialContext.getShapeFactory().rect(topLeft, bottomRight); - } - - public String getSyntax() { - return "rectangle(,,,)"; - } -} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionCircle.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Area.java similarity index 55% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionCircle.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Area.java index 7492ae9111..5fc9b644b1 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionCircle.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Area.java @@ -19,33 +19,40 @@ package com.arcadedb.function.sql.geo; import com.arcadedb.database.Identifiable; -import com.arcadedb.query.sql.executor.CommandContext; import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.query.sql.executor.CommandContext; import org.locationtech.spatial4j.context.SpatialContext; +import org.locationtech.spatial4j.shape.Shape; /** - * Returns a circle shape with the 3 coordinates received as parameters. + * SQL function ST_Area: returns the area of a geometry in square degrees. * - * @author Luca Garulli (l.garulli--(at)--arcadedata.com) + *

Usage: {@code ST_Area()}

+ *

Returns: Double area value in square degrees

*/ -public class SQLFunctionCircle extends SQLFunctionAbstract { - public static final String NAME = "circle"; +public class SQLFunctionST_Area extends SQLFunctionAbstract { + public static final String NAME = "ST_Area"; - public SQLFunctionCircle() { + public SQLFunctionST_Area() { super(NAME); } - public Object execute(final Object self, final Identifiable currentRecord, final Object currentResult, final Object[] params, - final CommandContext context) { - if (params.length != 3) - throw new IllegalArgumentException("circle() requires 3 parameters"); + @Override + public Object execute(final Object iThis, final Identifiable iCurrentRecord, final Object iCurrentResult, + final Object[] iParams, final CommandContext iContext) { + if (iParams == null || iParams.length < 1 || iParams[0] == null) + return null; + + final Shape shape = GeoUtils.parseGeometry(iParams[0]); + if (shape == null) + return null; - final SpatialContext spatialContext = GeoUtils.getSpatialContext(); - return spatialContext.getShapeFactory() - .circle(GeoUtils.getDoubleValue(params[0]), GeoUtils.getDoubleValue(params[1]), GeoUtils.getDoubleValue(params[2])); + final SpatialContext ctx = GeoUtils.getSpatialContext(); + return shape.getArea(ctx); } + @Override public String getSyntax() { - return "circle(,,)"; + return "ST_Area()"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsGeoJson.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsGeoJson.java new file mode 100644 index 0000000000..c1961c00a6 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsGeoJson.java @@ -0,0 +1,134 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.database.Identifiable; +import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.query.sql.executor.CommandContext; +import com.arcadedb.serializer.json.JSONArray; +import com.arcadedb.serializer.json.JSONObject; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.MultiLineString; +import org.locationtech.jts.geom.MultiPoint; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.Polygon; + +/** + * SQL function ST_AsGeoJson: returns the GeoJSON representation of a geometry. + * Uses JTS for geometry parsing and manual serialization via JSONObject/JSONArray. + * + *

Usage: {@code ST_AsGeoJson()}

+ *

Returns: GeoJSON string

+ */ +public class SQLFunctionST_AsGeoJson extends SQLFunctionAbstract { + public static final String NAME = "ST_AsGeoJson"; + + public SQLFunctionST_AsGeoJson() { + super(NAME); + } + + @Override + public Object execute(final Object iThis, final Identifiable iCurrentRecord, final Object iCurrentResult, + final Object[] iParams, final CommandContext iContext) { + if (iParams == null || iParams.length < 1 || iParams[0] == null) + return null; + + final Geometry geometry = GeoUtils.parseJtsGeometry(iParams[0]); + if (geometry == null) + return null; + + return toGeoJson(geometry).toString(); + } + + private JSONObject toGeoJson(final Geometry geometry) { + final JSONObject result = new JSONObject(); + + if (geometry instanceof Point point) { + result.put("type", "Point"); + result.put("coordinates", coordToArray(point.getCoordinate())); + } else if (geometry instanceof LineString lineString) { + result.put("type", "LineString"); + result.put("coordinates", coordsToArray(lineString.getCoordinates())); + } else if (geometry instanceof Polygon polygon) { + result.put("type", "Polygon"); + result.put("coordinates", polygonToArray(polygon)); + } else if (geometry instanceof MultiPoint multiPoint) { + result.put("type", "MultiPoint"); + final JSONArray coords = new JSONArray(); + for (int i = 0; i < multiPoint.getNumGeometries(); i++) + coords.put(coordToArray(((Point) multiPoint.getGeometryN(i)).getCoordinate())); + result.put("coordinates", coords); + } else if (geometry instanceof MultiLineString multiLineString) { + result.put("type", "MultiLineString"); + final JSONArray coords = new JSONArray(); + for (int i = 0; i < multiLineString.getNumGeometries(); i++) + coords.put(coordsToArray(multiLineString.getGeometryN(i).getCoordinates())); + result.put("coordinates", coords); + } else if (geometry instanceof MultiPolygon multiPolygon) { + result.put("type", "MultiPolygon"); + final JSONArray coords = new JSONArray(); + for (int i = 0; i < multiPolygon.getNumGeometries(); i++) + coords.put(polygonToArray((Polygon) multiPolygon.getGeometryN(i))); + result.put("coordinates", coords); + } else { + // GeometryCollection fallback + result.put("type", "GeometryCollection"); + final JSONArray geometries = new JSONArray(); + for (int i = 0; i < geometry.getNumGeometries(); i++) + geometries.put(toGeoJson(geometry.getGeometryN(i))); + result.put("geometries", geometries); + } + + return result; + } + + private JSONArray coordToArray(final Coordinate coord) { + final JSONArray arr = new JSONArray(); + arr.put(coord.x); + arr.put(coord.y); + return arr; + } + + private JSONArray coordsToArray(final Coordinate[] coords) { + final JSONArray arr = new JSONArray(); + for (final Coordinate coord : coords) + arr.put(coordToArray(coord)); + return arr; + } + + private JSONArray ringToArray(final LineString ring) { + return coordsToArray(ring.getCoordinates()); + } + + private JSONArray polygonToArray(final Polygon polygon) { + final JSONArray rings = new JSONArray(); + rings.put(ringToArray(polygon.getExteriorRing())); + for (int i = 0; i < polygon.getNumInteriorRing(); i++) + rings.put(ringToArray(polygon.getInteriorRingN(i))); + return rings; + } + + @Override + public String getSyntax() { + return "ST_AsGeoJson()"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsText.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsText.java new file mode 100644 index 0000000000..dde4918c81 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsText.java @@ -0,0 +1,63 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.database.Identifiable; +import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.query.sql.executor.CommandContext; +import org.locationtech.spatial4j.shape.Shape; + +/** + * SQL function ST_AsText: returns the WKT representation of a geometry. + * If the input is already a WKT string, it is returned as-is. + * If the input is a Shape object, it is converted to WKT. + * + *

Usage: {@code ST_AsText()}

+ *

Returns: WKT string

+ */ +public class SQLFunctionST_AsText extends SQLFunctionAbstract { + public static final String NAME = "ST_AsText"; + + public SQLFunctionST_AsText() { + super(NAME); + } + + @Override + public Object execute(final Object iThis, final Identifiable iCurrentRecord, final Object iCurrentResult, + final Object[] iParams, final CommandContext iContext) { + if (iParams == null || iParams.length < 1 || iParams[0] == null) + return null; + + final Object input = iParams[0]; + if (input instanceof String str) + return str; + + if (input instanceof Shape shape) + return GeoUtils.toWKT(shape); + + // Try to parse then convert + final Shape shape = GeoUtils.parseGeometry(input); + return GeoUtils.toWKT(shape); + } + + @Override + public String getSyntax() { + return "ST_AsText()"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Buffer.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Buffer.java new file mode 100644 index 0000000000..62cb8dc0f1 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Buffer.java @@ -0,0 +1,59 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.database.Identifiable; +import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.query.sql.executor.CommandContext; +import org.locationtech.jts.geom.Geometry; + +/** + * SQL function ST_Buffer: returns a WKT string of the buffered geometry. + * Uses JTS Geometry.buffer(distance) for the computation. + * + *

Usage: {@code ST_Buffer(, )}

+ *

Returns: WKT string of the buffered shape

+ */ +public class SQLFunctionST_Buffer extends SQLFunctionAbstract { + public static final String NAME = "ST_Buffer"; + + public SQLFunctionST_Buffer() { + super(NAME); + } + + @Override + public Object execute(final Object iThis, final Identifiable iCurrentRecord, final Object iCurrentResult, + final Object[] iParams, final CommandContext iContext) { + if (iParams == null || iParams.length < 2 || iParams[0] == null || iParams[1] == null) + return null; + + final Geometry geometry = GeoUtils.parseJtsGeometry(iParams[0]); + if (geometry == null) + return null; + + final double distance = GeoUtils.getDoubleValue(iParams[1]); + final Geometry buffered = geometry.buffer(distance); + return GeoUtils.jtsToWKT(buffered); + } + + @Override + public String getSyntax() { + return "ST_Buffer(, )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Distance.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Distance.java new file mode 100644 index 0000000000..fc3fd80e03 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Distance.java @@ -0,0 +1,98 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.database.Identifiable; +import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.query.sql.executor.CommandContext; +import org.locationtech.spatial4j.shape.Point; +import org.locationtech.spatial4j.shape.Shape; + +/** + * SQL function ST_Distance: computes the Haversine distance between two points. + * Points may be WKT strings or Spatial4j Shape/Point objects. + * + *

Usage: {@code ST_Distance(, [, ])}

+ *

Unit: "m" (default), "km", "mi", "nmi"

+ *

Returns: Double distance value

+ */ +public class SQLFunctionST_Distance extends SQLFunctionAbstract { + public static final String NAME = "ST_Distance"; + + private static final double EARTH_RADIUS_KM = 6371.0; + + public SQLFunctionST_Distance() { + super(NAME); + } + + @Override + public Object execute(final Object iThis, final Identifiable iCurrentRecord, final Object iCurrentResult, + final Object[] iParams, final CommandContext iContext) { + if (iParams == null || iParams.length < 2 || iParams[0] == null || iParams[1] == null) + return null; + + final double[] p1 = extractPointCoords(iParams[0]); + final double[] p2 = extractPointCoords(iParams[1]); + + final String unit = (iParams.length >= 3 && iParams[2] != null) ? iParams[2].toString() : "m"; + + final double deltaLat = Math.toRadians(p2[1] - p1[1]); + final double deltaLon = Math.toRadians(p2[0] - p1[0]); + + final double a = Math.pow(Math.sin(deltaLat / 2), 2) + + Math.cos(Math.toRadians(p1[1])) * Math.cos(Math.toRadians(p2[1])) + * Math.pow(Math.sin(deltaLon / 2), 2); + + double distance = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * EARTH_RADIUS_KM; + + return switch (unit.toLowerCase()) { + case "km" -> distance; + case "m" -> distance * 1000; + case "mi" -> distance * 0.621371192; + case "nmi" -> distance * 0.539956803; + default -> + throw new IllegalArgumentException("Unsupported unit '" + unit + "'. Use m (default), km, mi, nmi."); + }; + } + + /** + * Extract [x, y] coordinates from a point given as WKT string, Shape, or Point. + */ + private double[] extractPointCoords(final Object param) { + if (param instanceof Point p) + return new double[] { p.getX(), p.getY() }; + + // Parse as geometry and get center point + final Shape shape = GeoUtils.parseGeometry(param); + if (shape instanceof Point p) + return new double[] { p.getX(), p.getY() }; + + // Fall back to bounding box center + final var bbox = shape.getBoundingBox(); + return new double[] { + (bbox.getMinX() + bbox.getMaxX()) / 2.0, + (bbox.getMinY() + bbox.getMaxY()) / 2.0 + }; + } + + @Override + public String getSyntax() { + return "ST_Distance(, [, ])"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Envelope.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Envelope.java new file mode 100644 index 0000000000..811f06cd8b --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Envelope.java @@ -0,0 +1,69 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.database.Identifiable; +import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.query.sql.executor.CommandContext; +import org.locationtech.spatial4j.shape.Rectangle; +import org.locationtech.spatial4j.shape.Shape; + +/** + * SQL function ST_Envelope: returns the WKT bounding box polygon of a geometry. + * + *

Usage: {@code ST_Envelope()}

+ *

Returns: WKT {@code "POLYGON ((minX minY, maxX minY, maxX maxY, minX maxY, minX minY))"}

+ */ +public class SQLFunctionST_Envelope extends SQLFunctionAbstract { + public static final String NAME = "ST_Envelope"; + + public SQLFunctionST_Envelope() { + super(NAME); + } + + @Override + public Object execute(final Object iThis, final Identifiable iCurrentRecord, final Object iCurrentResult, + final Object[] iParams, final CommandContext iContext) { + if (iParams == null || iParams.length < 1 || iParams[0] == null) + return null; + + final Shape shape = GeoUtils.parseGeometry(iParams[0]); + if (shape == null) + return null; + + final Rectangle bbox = shape.getBoundingBox(); + final double minX = bbox.getMinX(); + final double minY = bbox.getMinY(); + final double maxX = bbox.getMaxX(); + final double maxY = bbox.getMaxY(); + + return "POLYGON ((" + + GeoUtils.formatCoord(minX) + " " + GeoUtils.formatCoord(minY) + ", " + + GeoUtils.formatCoord(maxX) + " " + GeoUtils.formatCoord(minY) + ", " + + GeoUtils.formatCoord(maxX) + " " + GeoUtils.formatCoord(maxY) + ", " + + GeoUtils.formatCoord(minX) + " " + GeoUtils.formatCoord(maxY) + ", " + + GeoUtils.formatCoord(minX) + " " + GeoUtils.formatCoord(minY) + + "))"; + } + + @Override + public String getSyntax() { + return "ST_Envelope()"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionPoint.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_GeomFromText.java similarity index 57% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionPoint.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_GeomFromText.java index ad02be77c5..4c9bedda83 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionPoint.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_GeomFromText.java @@ -19,32 +19,31 @@ package com.arcadedb.function.sql.geo; import com.arcadedb.database.Identifiable; -import com.arcadedb.query.sql.executor.CommandContext; import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.query.sql.executor.CommandContext; /** - * Returns a point in space with X and Y as parameters. + * SQL function ST_GeomFromText: parses a WKT string and returns a Shape object. * - * @author Luca Garulli (l.garulli--(at)--arcadedata.com) + *

Usage: {@code ST_GeomFromText()}

*/ -public class SQLFunctionPoint extends SQLFunctionAbstract { - public static final String NAME = "point"; +public class SQLFunctionST_GeomFromText extends SQLFunctionAbstract { + public static final String NAME = "ST_GeomFromText"; - public SQLFunctionPoint() { + public SQLFunctionST_GeomFromText() { super(NAME); } - public Object execute(final Object self, final Identifiable currentRecord, final Object currentResult, final Object[] params, - final CommandContext context) { - if (params.length != 2) - throw new IllegalArgumentException("point() requires X and Y as parameters"); - - // Use lightweight Point for fast serialization (optimized for bulk inserts) - return new LightweightPoint(GeoUtils.getDoubleValue(params[0]), GeoUtils.getDoubleValue(params[1])); + @Override + public Object execute(final Object iThis, final Identifiable iCurrentRecord, final Object iCurrentResult, + final Object[] iParams, final CommandContext iContext) { + if (iParams == null || iParams.length < 1 || iParams[0] == null) + return null; + return GeoUtils.parseGeometry(iParams[0]); } + @Override public String getSyntax() { - return "point(,)"; + return "ST_GeomFromText()"; } - } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_LineString.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_LineString.java new file mode 100644 index 0000000000..5ea14fe45c --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_LineString.java @@ -0,0 +1,78 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.database.Identifiable; +import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.query.sql.executor.CommandContext; +import org.locationtech.spatial4j.shape.Point; + +import java.util.List; + +/** + * SQL function ST_LineString: constructs a WKT LINESTRING string from a list of coordinate pairs. + * + *

Usage: {@code ST_LineString([[x1,y1],[x2,y2],...])}

+ *

Returns: WKT string {@code "LINESTRING (x1 y1, x2 y2, ...)"}

+ */ +public class SQLFunctionST_LineString extends SQLFunctionAbstract { + public static final String NAME = "ST_LineString"; + + public SQLFunctionST_LineString() { + super(NAME); + } + + @Override + public Object execute(final Object iThis, final Identifiable iCurrentRecord, final Object iCurrentResult, + final Object[] iParams, final CommandContext iContext) { + if (iParams == null || iParams.length < 1 || iParams[0] == null) + return null; + + @SuppressWarnings("unchecked") + final List points = (List) iParams[0]; + if (points.isEmpty()) + return null; + + final StringBuilder sb = new StringBuilder("LINESTRING ("); + for (int i = 0; i < points.size(); i++) { + if (i > 0) + sb.append(", "); + appendCoord(sb, points.get(i)); + } + sb.append(")"); + return sb.toString(); + } + + private void appendCoord(final StringBuilder sb, final Object point) { + if (point instanceof Point p) { + sb.append(GeoUtils.formatCoord(p.getX())).append(" ").append(GeoUtils.formatCoord(p.getY())); + } else if (point instanceof List list) { + sb.append(GeoUtils.formatCoord(GeoUtils.getDoubleValue(list.get(0)))) + .append(" ") + .append(GeoUtils.formatCoord(GeoUtils.getDoubleValue(list.get(1)))); + } else { + throw new IllegalArgumentException("Invalid point element: " + point); + } + } + + @Override + public String getSyntax() { + return "ST_LineString([[x1,y1],[x2,y2],...])"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Point.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Point.java new file mode 100644 index 0000000000..6fc7660fb7 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Point.java @@ -0,0 +1,52 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.database.Identifiable; +import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.query.sql.executor.CommandContext; + +/** + * SQL function ST_Point: constructs a WKT POINT string from X (longitude) and Y (latitude). + * + *

Usage: {@code ST_Point(, )}

+ *

Returns: WKT string {@code "POINT (x y)"}

+ */ +public class SQLFunctionST_Point extends SQLFunctionAbstract { + public static final String NAME = "ST_Point"; + + public SQLFunctionST_Point() { + super(NAME); + } + + @Override + public Object execute(final Object iThis, final Identifiable iCurrentRecord, final Object iCurrentResult, + final Object[] iParams, final CommandContext iContext) { + if (iParams == null || iParams.length < 2 || iParams[0] == null || iParams[1] == null) + return null; + final double x = GeoUtils.getDoubleValue(iParams[0]); + final double y = GeoUtils.getDoubleValue(iParams[1]); + return "POINT (" + GeoUtils.formatCoord(x) + " " + GeoUtils.formatCoord(y) + ")"; + } + + @Override + public String getSyntax() { + return "ST_Point(, )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Polygon.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Polygon.java new file mode 100644 index 0000000000..51a2124c7a --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Polygon.java @@ -0,0 +1,102 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.database.Identifiable; +import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.query.sql.executor.CommandContext; +import org.locationtech.spatial4j.shape.Point; + +import java.util.List; + +/** + * SQL function ST_Polygon: constructs a WKT POLYGON string from a list of coordinate pairs. + * The ring is automatically closed if the first and last points differ. + * + *

Usage: {@code ST_Polygon([[x1,y1],[x2,y2],...])}

+ *

Returns: WKT string {@code "POLYGON ((x1 y1, x2 y2, ..., x1 y1))"}

+ */ +public class SQLFunctionST_Polygon extends SQLFunctionAbstract { + public static final String NAME = "ST_Polygon"; + + public SQLFunctionST_Polygon() { + super(NAME); + } + + @Override + public Object execute(final Object iThis, final Identifiable iCurrentRecord, final Object iCurrentResult, + final Object[] iParams, final CommandContext iContext) { + if (iParams == null || iParams.length < 1 || iParams[0] == null) + return null; + + @SuppressWarnings("unchecked") + final List points = (List) iParams[0]; + if (points.isEmpty()) + return null; + + final StringBuilder sb = new StringBuilder("POLYGON (("); + for (int i = 0; i < points.size(); i++) { + if (i > 0) + sb.append(", "); + appendCoord(sb, points.get(i)); + } + + // Close the ring if not already closed + final Object first = points.getFirst(); + final Object last = points.getLast(); + if (!coordsEqual(first, last)) { + sb.append(", "); + appendCoord(sb, first); + } + + sb.append("))"); + return sb.toString(); + } + + private void appendCoord(final StringBuilder sb, final Object point) { + if (point instanceof Point p) { + sb.append(GeoUtils.formatCoord(p.getX())).append(" ").append(GeoUtils.formatCoord(p.getY())); + } else if (point instanceof List list) { + sb.append(GeoUtils.formatCoord(GeoUtils.getDoubleValue(list.get(0)))) + .append(" ") + .append(GeoUtils.formatCoord(GeoUtils.getDoubleValue(list.get(1)))); + } else { + throw new IllegalArgumentException("Invalid point element: " + point); + } + } + + private double[] extractCoords(final Object point) { + if (point instanceof Point p) + return new double[] { p.getX(), p.getY() }; + if (point instanceof List list) + return new double[] { GeoUtils.getDoubleValue(list.get(0)), GeoUtils.getDoubleValue(list.get(1)) }; + throw new IllegalArgumentException("Invalid point element: " + point); + } + + private boolean coordsEqual(final Object a, final Object b) { + final double[] ca = extractCoords(a); + final double[] cb = extractCoords(b); + return Double.compare(ca[0], cb[0]) == 0 && Double.compare(ca[1], cb[1]) == 0; + } + + @Override + public String getSyntax() { + return "ST_Polygon([[x1,y1],[x2,y2],...])"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_X.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_X.java new file mode 100644 index 0000000000..f2b3302369 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_X.java @@ -0,0 +1,67 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.database.Identifiable; +import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.query.sql.executor.CommandContext; +import org.locationtech.spatial4j.shape.Point; +import org.locationtech.spatial4j.shape.Shape; + +/** + * SQL function ST_X: returns the X (longitude) coordinate of a point geometry. + * + *

Usage: {@code ST_X()}

+ *

Returns: Double X coordinate, or null if input is not a point

+ */ +public class SQLFunctionST_X extends SQLFunctionAbstract { + public static final String NAME = "ST_X"; + + public SQLFunctionST_X() { + super(NAME); + } + + @Override + public Object execute(final Object iThis, final Identifiable iCurrentRecord, final Object iCurrentResult, + final Object[] iParams, final CommandContext iContext) { + if (iParams == null || iParams.length < 1 || iParams[0] == null) + return null; + + final Object input = iParams[0]; + + if (input instanceof Point p) + return p.getX(); + + // Try parsing as geometry + try { + final Shape shape = GeoUtils.parseGeometry(input); + if (shape instanceof Point p) + return p.getX(); + } catch (Exception ignored) { + // Not a valid geometry or not a point + } + + return null; + } + + @Override + public String getSyntax() { + return "ST_X()"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Y.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Y.java new file mode 100644 index 0000000000..6e6f49b5e8 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Y.java @@ -0,0 +1,67 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.database.Identifiable; +import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.query.sql.executor.CommandContext; +import org.locationtech.spatial4j.shape.Point; +import org.locationtech.spatial4j.shape.Shape; + +/** + * SQL function ST_Y: returns the Y (latitude) coordinate of a point geometry. + * + *

Usage: {@code ST_Y()}

+ *

Returns: Double Y coordinate, or null if input is not a point

+ */ +public class SQLFunctionST_Y extends SQLFunctionAbstract { + public static final String NAME = "ST_Y"; + + public SQLFunctionST_Y() { + super(NAME); + } + + @Override + public Object execute(final Object iThis, final Identifiable iCurrentRecord, final Object iCurrentResult, + final Object[] iParams, final CommandContext iContext) { + if (iParams == null || iParams.length < 1 || iParams[0] == null) + return null; + + final Object input = iParams[0]; + + if (input instanceof Point p) + return p.getY(); + + // Try parsing as geometry + try { + final Shape shape = GeoUtils.parseGeometry(input); + if (shape instanceof Point p) + return p.getY(); + } catch (Exception ignored) { + // Not a valid geometry or not a point + } + + return null; + } + + @Override + public String getSyntax() { + return "ST_Y()"; + } +} diff --git a/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java index 3969f8c748..e7327844ac 100644 --- a/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java @@ -19,8 +19,6 @@ package com.arcadedb.function.sql.geo; import com.arcadedb.TestHelper; -import com.arcadedb.database.Database; -import com.arcadedb.database.DatabaseFactory; import com.arcadedb.database.Document; import com.arcadedb.database.MutableDocument; import com.arcadedb.index.Index; @@ -28,18 +26,11 @@ import com.arcadedb.schema.DocumentType; import com.arcadedb.schema.Schema; import com.arcadedb.schema.Type; -import com.arcadedb.utility.FileUtils; import org.junit.jupiter.api.Test; import org.locationtech.spatial4j.io.GeohashUtils; -import org.locationtech.spatial4j.shape.Circle; -import org.locationtech.spatial4j.shape.Point; -import org.locationtech.spatial4j.shape.Rectangle; import org.locationtech.spatial4j.shape.Shape; -import java.io.File; -import java.util.Map; - import static org.assertj.core.api.Assertions.assertThat; /** @@ -48,358 +39,480 @@ class SQLGeoFunctionsTest { @Test - void point() throws Exception { + void geoManualIndexPoints() throws Exception { + final int TOTAL = 1_000; + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select point(11,11) as point"); - assertThat(result.hasNext()).isTrue(); - Point point = result.next().getProperty("point"); - assertThat(point).isNotNull(); + + db.transaction(() -> { + final DocumentType type = db.getSchema().createDocumentType("Restaurant"); + type.createProperty("coords", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); + + long begin = System.currentTimeMillis(); + + for (int i = 0; i < TOTAL; i++) { + final MutableDocument doc = db.newDocument("Restaurant"); + doc.set("lat", 10 + (0.01D * i)); + doc.set("long", 10 + (0.01D * i)); + doc.set("coords", GeohashUtils.encodeLatLon(doc.getDouble("lat"), doc.getDouble("long"))); // INDEXED + doc.save(); + } + + final String[] area = new String[] { GeohashUtils.encodeLatLon(10.5, 10.5), GeohashUtils.encodeLatLon(10.55, 10.55) }; + + begin = System.currentTimeMillis(); + ResultSet result = db.query("sql", "select from Restaurant where coords >= ? and coords <= ?", area[0], area[1]); + + assertThat(result.hasNext()).isTrue(); + int returned = 0; + while (result.hasNext()) { + final Document record = result.next().toElement(); + assertThat(record.getDouble("lat")).isGreaterThanOrEqualTo(10.5); + assertThat(record.getDouble("long")).isLessThanOrEqualTo(10.55); + ++returned; + } + + assertThat(returned).isEqualTo(6); + }); }); } @Test - void rectangle() throws Exception { + void geoManualIndexBoundingBoxes() throws Exception { + final int TOTAL = 1_000; + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select rectangle(10,10,20,20) as shape"); - assertThat(result.hasNext()).isTrue(); - Rectangle rectangle = result.next().getProperty("shape"); - assertThat(rectangle).isNotNull(); + + db.transaction(() -> { + final DocumentType type = db.getSchema().createDocumentType("Restaurant"); + type.createProperty("bboxTL", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); + type.createProperty("bboxBR", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); + + long begin = System.currentTimeMillis(); + + for (int i = 0; i < TOTAL; i++) { + final MutableDocument doc = db.newDocument("Restaurant"); + doc.set("x1", 10D + (0.0001D * i)); + doc.set("y1", 10D + (0.0001D * i)); + doc.set("x2", 10D + (0.001D * i)); + doc.set("y2", 10D + (0.001D * i)); + doc.set("bboxTL", GeohashUtils.encodeLatLon(doc.getDouble("x1"), doc.getDouble("y1"))); // INDEXED + doc.set("bboxBR", GeohashUtils.encodeLatLon(doc.getDouble("x2"), doc.getDouble("y2"))); // INDEXED + doc.save(); + } + + for (Index idx : type.getAllIndexes(false)) { + assertThat(idx.countEntries()).isEqualTo(TOTAL); + } + + final String[] area = new String[] {// + GeohashUtils.encodeLatLon(10.0001D, 10.0001D),// + GeohashUtils.encodeLatLon(10.020D, 10.020D) }; + + begin = System.currentTimeMillis(); + ResultSet result = db.query("sql", "select from Restaurant where bboxTL >= ? and bboxBR <= ?", area[0], area[1]); + + assertThat(result.hasNext()).isTrue(); + int returned = 0; + while (result.hasNext()) { + final Document record = result.next().toElement(); + assertThat(record.getDouble("x1")).isGreaterThanOrEqualTo(10.0001D).withFailMessage("x1: " + record.getDouble("x1")); + assertThat(record.getDouble("y1")).isGreaterThanOrEqualTo(10.0001D).withFailMessage("y1: " + record.getDouble("y1")); + assertThat(record.getDouble("x2")).isLessThanOrEqualTo(10.020D).withFailMessage("x2: " + record.getDouble("x2")); + assertThat(record.getDouble("y2")).isLessThanOrEqualTo(10.020D).withFailMessage("y2: " + record.getDouble("y2")); + ++returned; + } + + assertThat(returned).isEqualTo(20); + }); }); } + // ─── ST_* standard function tests ──────────────────────────────────────────── + @Test - void circle() throws Exception { + void stGeomFromText() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select circle(10,10,10) as circle"); + // Valid WKT point + ResultSet result = db.query("sql", "select ST_GeomFromText('POINT (10 20)') as geom"); assertThat(result.hasNext()).isTrue(); - Circle circle = result.next().getProperty("circle"); - assertThat(circle).isNotNull(); + final Object geom = result.next().getProperty("geom"); + assertThat(geom).isNotNull(); + assertThat(geom).isInstanceOf(Shape.class); }); } @Test - void polygon() throws Exception { + void stGeomFromTextNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", - "select polygon( [ point(10,10), point(20,10), point(20,20), point(10,20), point(10,10) ] ) as polygon"); + ResultSet result = db.query("sql", "select ST_GeomFromText(null) as geom"); assertThat(result.hasNext()).isTrue(); - Shape polygon = result.next().getProperty("polygon"); - assertThat(polygon).isNotNull(); + final Object val = result.next().getProperty("geom"); + assertThat(val).isNull(); + }); + } - result = db.query("sql", "select polygon( [ [10,10], [20,10], [20,20], [10,20], [10,10] ] ) as polygon"); + @Test + void stPoint() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", "select ST_Point(10, 20) as wkt"); assertThat(result.hasNext()).isTrue(); - polygon = result.next().getProperty("polygon"); - assertThat(polygon).isNotNull(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POINT"); + assertThat(wkt).contains("10"); + assertThat(wkt).contains("20"); }); } @Test - void pointIsWithinRectangle() throws Exception { + void stPointNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select point(11,11).isWithin( rectangle(10,10,20,20) ) as isWithin"); + ResultSet result = db.query("sql", "select ST_Point(null, 20) as wkt"); assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("isWithin")).isTrue(); + final Object val = result.next().getProperty("wkt"); + assertThat(val).isNull(); + }); + } - result = db.query("sql", "select point(11,21).isWithin( rectangle(10,10,20,20) ) as isWithin"); + @Test + void stLineString() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", "select ST_LineString([[0,0],[10,10],[20,0]]) as wkt"); assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("isWithin")).isFalse(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("LINESTRING"); + assertThat(wkt).contains("0 0"); + assertThat(wkt).contains("10 10"); + assertThat(wkt).contains("20 0"); }); } @Test - void pointIsWithinCircle() throws Exception { + void stLineStringNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select point(11,11).isWithin( circle(10,10,10) ) as isWithin"); + ResultSet result = db.query("sql", "select ST_LineString(null) as wkt"); assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("isWithin")).isTrue(); + final Object val = result.next().getProperty("wkt"); + assertThat(val).isNull(); + }); + } - result = db.query("sql", "select point(10,21).isWithin( circle(10,10,10) ) as isWithin"); + @Test + void stPolygon() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + // Closed ring + ResultSet result = db.query("sql", "select ST_Polygon([[0,0],[10,0],[10,10],[0,10],[0,0]]) as wkt"); assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("isWithin")).isFalse(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POLYGON"); + assertThat(wkt).contains("0 0"); + assertThat(wkt).contains("10 0"); + assertThat(wkt).contains("10 10"); }); } @Test - void pointIntersectWithRectangle() throws Exception { + void stPolygonAutoClose() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select rectangle(9,9,11,11).intersectsWith( rectangle(10,10,20,20) ) as intersectsWith"); + // Open ring — should be auto-closed + ResultSet result = db.query("sql", "select ST_Polygon([[0,0],[10,0],[10,10],[0,10]]) as wkt"); assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("intersectsWith")).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POLYGON"); + // Ring is closed: last coord equals first + final String inner = wkt.substring(wkt.indexOf("((") + 2, wkt.lastIndexOf("))")); + final String[] coords = inner.split(","); + assertThat(coords[0].trim()).isEqualTo(coords[coords.length - 1].trim()); + }); + } - result = db.query("sql", "select rectangle(9,9,9.9,9.9).intersectsWith( rectangle(10,10,20,20) ) as intersectsWith"); + @Test + void stPolygonNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", "select ST_Polygon(null) as wkt"); assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("intersectsWith")).isFalse(); + final Object val = result.next().getProperty("wkt"); + assertThat(val).isNull(); }); } @Test - void pointIntersectWithPolygons() throws Exception { + void stBuffer() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", - "select polygon( [ [10,10], [20,10], [20,20], [10,20], [10,10] ] ).intersectsWith( rectangle(10,10,20,20) ) as intersectsWith"); + ResultSet result = db.query("sql", "select ST_Buffer('POINT (10 20)', 1.0) as wkt"); assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("intersectsWith")).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POLYGON"); + }); + } - result = db.query("sql", - "select polygon( [ [10,10], [20,10], [20,20], [10,20], [10,10] ] ).intersectsWith( rectangle(21,21,22,22) ) as intersectsWith"); + @Test + void stBufferNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", "select ST_Buffer(null, 1.0) as wkt"); assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("intersectsWith")).isFalse(); + final Object val = result.next().getProperty("wkt"); + assertThat(val).isNull(); }); } @Test - void lineStringsIntersect() throws Exception { + void stEnvelope() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { ResultSet result = db.query("sql", - "select linestring( [ [10,10], [20,10], [20,20], [10,20], [10,10] ] ).intersectsWith( rectangle(10,10,20,20) ) as intersectsWith"); + "select ST_Envelope('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as wkt"); assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("intersectsWith")).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POLYGON"); + assertThat(wkt).contains("0 0"); + assertThat(wkt).contains("10 10"); + }); + } - result = db.query("sql", - "select linestring( [ [10,10], [20,10], [20,20], [10,20], [10,10] ] ).intersectsWith( rectangle(21,21,22,22) ) as intersectsWith"); + @Test + void stEnvelopeNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", "select ST_Envelope(null) as wkt"); assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("intersectsWith")).isFalse(); + final Object val = result.next().getProperty("wkt"); + assertThat(val).isNull(); }); } @Test - void geoManualIndexPoints() throws Exception { - final int TOTAL = 1_000; - + void stDistance() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + // Distance between two points in meters (default unit) + ResultSet result = db.query("sql", "select ST_Distance('POINT (0 0)', 'POINT (1 0)') as dist"); + assertThat(result.hasNext()).isTrue(); + final Double dist = result.next().getProperty("dist"); + assertThat(dist).isNotNull(); + assertThat(dist).isGreaterThan(0.0); - db.transaction(() -> { - final DocumentType type = db.getSchema().createDocumentType("Restaurant"); - type.createProperty("coords", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); - - long begin = System.currentTimeMillis(); - - for (int i = 0; i < TOTAL; i++) { - final MutableDocument doc = db.newDocument("Restaurant"); - doc.set("lat", 10 + (0.01D * i)); - doc.set("long", 10 + (0.01D * i)); - doc.set("coords", GeohashUtils.encodeLatLon(doc.getDouble("lat"), doc.getDouble("long"))); // INDEXED - doc.save(); - } - - //System.out.println("Elapsed insert: " + (System.currentTimeMillis() - begin)); - - final String[] area = new String[] { GeohashUtils.encodeLatLon(10.5, 10.5), GeohashUtils.encodeLatLon(10.55, 10.55) }; - - begin = System.currentTimeMillis(); - ResultSet result = db.query("sql", "select from Restaurant where coords >= ? and coords <= ?", area[0], area[1]); - - //System.out.println("Elapsed query: " + (System.currentTimeMillis() - begin)); - - begin = System.currentTimeMillis(); - - assertThat(result.hasNext()).isTrue(); - int returned = 0; - while (result.hasNext()) { - final Document record = result.next().toElement(); - assertThat(record.getDouble("lat")).isGreaterThanOrEqualTo(10.5); - assertThat(record.getDouble("long")).isLessThanOrEqualTo(10.55); -// System.out.println(record.toJSON()); - - ++returned; - } - - //System.out.println("Elapsed browsing: " + (System.currentTimeMillis() - begin)); - - assertThat(returned).isEqualTo(6); - }); + // Distance in km + result = db.query("sql", "select ST_Distance('POINT (0 0)', 'POINT (1 0)', 'km') as dist"); + assertThat(result.hasNext()).isTrue(); + final Double distKm = result.next().getProperty("dist"); + assertThat(distKm).isNotNull(); + assertThat(distKm).isGreaterThan(0.0); + // km < m for the same distance + assertThat(distKm).isLessThan(dist); }); } - /** - * Test for issue #1843: Exact reproduction of the reported issue. - * Original query: insert into point set geom=(select point(30,30) as point); - */ @Test - void insertPointIntoDocumentExactIssue() throws Exception { + void stDistanceNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - db.transaction(() -> { - // Exact steps from issue #1843 - db.command("sql", "create document type point"); - db.command("sql", "insert into point set geom=(select point(30,30) as point)"); - - // Verify data was stored - final ResultSet result = db.query("sql", "select from point"); - assertThat(result.hasNext()).isTrue(); - - final Document doc = result.next().toElement(); - final Object geom = doc.get("geom"); - assertThat(geom).isNotNull(); - - // The geom field should contain the stored point (as WKT or nested result) - // When using subquery, the result is a map containing the point - if (geom instanceof Map) { - final Map map = (Map) geom; - assertThat(map.get("point")).isNotNull(); - } - }); + ResultSet result = db.query("sql", "select ST_Distance(null, 'POINT (1 0)') as dist"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("dist"); + assertThat(val).isNull(); }); } - /** - * Test for issue #1843: Cannot serialize value of type class org.locationtech.spatial4j.shape.jts.JtsPoint - * This test verifies that Point objects can be serialized and persisted across database restarts. - */ @Test - void insertPointIntoDocument() throws Exception { - // Use a unique database name to avoid conflicts with parallel tests - final String dbName = "GeoPointPersistence_" + System.nanoTime(); - final String dbPath = "./target/databases/" + dbName; - - try { - // Clean up first - FileUtils.deleteRecursively(new File(dbPath)); - - // Create database and insert data - try (Database db = new DatabaseFactory(dbPath).create()) { - db.transaction(() -> { - // Create the document type as described in the issue - db.command("sql", "create document type GeoPoint"); - - // This should work: insert a point using the point() function - db.command("sql", "insert into GeoPoint set geom = point(30, 30)"); - }); - } - - // Reopen database to verify data was persisted correctly - try (Database db = new DatabaseFactory(dbPath).open()) { - db.transaction(() -> { - // Verify we can retrieve the stored value - final ResultSet result = db.query("sql", "select from GeoPoint"); - assertThat(result.hasNext()).isTrue(); - - final Document doc = result.next().toElement(); - assertThat(doc.get("geom")).isNotNull(); - - // The value should be stored as WKT string - final String storedValue = doc.getString("geom"); - assertThat(storedValue).contains("Pt"); - assertThat(storedValue).contains("30"); - }); - } - } finally { - FileUtils.deleteRecursively(new File(dbPath)); - } + void stArea() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", + "select ST_Area('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as area"); + assertThat(result.hasNext()).isTrue(); + final Double area = result.next().getProperty("area"); + assertThat(area).isNotNull(); + assertThat(area).isGreaterThan(0.0); + }); } - /** - * Test for issue #1843: Test various shape types serialization - * Note: Point, LineString, Polygon are standard WKT types and stored as WKT. - * Circle and Rectangle are not standard WKT types and are stored as their toString() representation. - */ @Test - void insertVariousShapesIntoDocument() throws Exception { + void stAreaNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - db.transaction(() -> { - db.command("sql", "create document type GeoShapes"); - - // Insert circle (not standard WKT - stored as toString) - db.command("sql", "insert into GeoShapes set name = 'circle', geom = circle(10, 10, 5)"); - - // Insert rectangle (not standard WKT - stored as toString) - db.command("sql", "insert into GeoShapes set name = 'rectangle', geom = rectangle(0, 0, 10, 10)"); - - // Insert polygon (standard WKT) - db.command("sql", - "insert into GeoShapes set name = 'polygon', geom = polygon([[0,0], [10,0], [10,10], [0,10], [0,0]])"); - - // Insert linestring (standard WKT) - db.command("sql", "insert into GeoShapes set name = 'linestring', geom = linestring([[0,0], [10,10], [20,0]])"); - - // Verify all shapes were stored - final ResultSet result = db.query("sql", "select from GeoShapes order by name"); - - // Circle - not standard WKT, stored as toString() which contains "Circle" - assertThat(result.hasNext()).isTrue(); - Document doc = result.next().toElement(); - assertThat(doc.getString("name")).isEqualTo("circle"); - assertThat(doc.getString("geom")).contains("Circle"); // toString() format - - // Linestring - standard WKT format - assertThat(result.hasNext()).isTrue(); - doc = result.next().toElement(); - assertThat(doc.getString("name")).isEqualTo("linestring"); - assertThat(doc.getString("geom")).contains("LINESTRING"); - - // Polygon - standard WKT format - assertThat(result.hasNext()).isTrue(); - doc = result.next().toElement(); - assertThat(doc.getString("name")).isEqualTo("polygon"); - assertThat(doc.getString("geom")).contains("POLYGON"); + ResultSet result = db.query("sql", "select ST_Area(null) as area"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("area"); + assertThat(val).isNull(); + }); + } - // Rectangle - not standard WKT, stored as toString() which contains "Rect" - assertThat(result.hasNext()).isTrue(); - doc = result.next().toElement(); - assertThat(doc.getString("name")).isEqualTo("rectangle"); - assertThat(doc.getString("geom")).contains("Rect"); // toString() format - }); + @Test + void stAsText() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + // String input → returned as-is + ResultSet result = db.query("sql", "select ST_AsText('POINT (10 20)') as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isEqualTo("POINT (10 20)"); }); } @Test - void geoManualIndexBoundingBoxes() throws Exception { - final int TOTAL = 1_000; + void stAsTextFromShape() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + // Shape → WKT + ResultSet result = db.query("sql", "select ST_AsText(ST_GeomFromText('POINT (10 20)')) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POINT"); + assertThat(wkt).contains("10"); + assertThat(wkt).contains("20"); + }); + } + @Test + void stAsTextNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", "select ST_AsText(null) as wkt"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("wkt"); + assertThat(val).isNull(); + }); + } - db.transaction(() -> { - final DocumentType type = db.getSchema().createDocumentType("Restaurant"); - type.createProperty("bboxTL", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); - type.createProperty("bboxBR", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); + @Test + void stAsGeoJson() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", "select ST_AsGeoJson('POINT (10 20)') as json"); + assertThat(result.hasNext()).isTrue(); + final String json = result.next().getProperty("json"); + assertThat(json).isNotNull(); + assertThat(json).contains("Point"); + assertThat(json).contains("coordinates"); + assertThat(json).contains("10"); + assertThat(json).contains("20"); + }); + } - long begin = System.currentTimeMillis(); + @Test + void stAsGeoJsonPolygon() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", + "select ST_AsGeoJson('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as json"); + assertThat(result.hasNext()).isTrue(); + final String json = result.next().getProperty("json"); + assertThat(json).isNotNull(); + assertThat(json).contains("Polygon"); + assertThat(json).contains("coordinates"); + }); + } - for (int i = 0; i < TOTAL; i++) { - final MutableDocument doc = db.newDocument("Restaurant"); - doc.set("x1", 10D + (0.0001D * i)); - doc.set("y1", 10D + (0.0001D * i)); - doc.set("x2", 10D + (0.001D * i)); - doc.set("y2", 10D + (0.001D * i)); - doc.set("bboxTL", GeohashUtils.encodeLatLon(doc.getDouble("x1"), doc.getDouble("y1"))); // INDEXED - doc.set("bboxBR", GeohashUtils.encodeLatLon(doc.getDouble("x2"), doc.getDouble("y2"))); // INDEXED - doc.save(); - } + @Test + void stAsGeoJsonNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", "select ST_AsGeoJson(null) as json"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("json"); + assertThat(val).isNull(); + }); + } - for (Index idx : type.getAllIndexes(false)) { - assertThat(idx.countEntries()).isEqualTo(TOTAL); - } + @Test + void stX() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", "select ST_X('POINT (10 20)') as x"); + assertThat(result.hasNext()).isTrue(); + final Double x = result.next().getProperty("x"); + assertThat(x).isNotNull(); + assertThat(x).isEqualTo(10.0); + }); + } - //System.out.println("Elapsed insert: " + (System.currentTimeMillis() - begin)); + @Test + void stXFromShape() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", "select ST_X(ST_GeomFromText('POINT (10 20)')) as x"); + assertThat(result.hasNext()).isTrue(); + final Double x = result.next().getProperty("x"); + assertThat(x).isNotNull(); + assertThat(x).isEqualTo(10.0); + }); + } - final String[] area = new String[] {// - GeohashUtils.encodeLatLon(10.0001D, 10.0001D),// - GeohashUtils.encodeLatLon(10.020D, 10.020D) }; + @Test + void stXNonPoint() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + // ST_X on a polygon should return null + ResultSet result = db.query("sql", + "select ST_X('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as x"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("x"); + assertThat(val).isNull(); + }); + } - begin = System.currentTimeMillis(); - //ResultSet result = db.query("sql", "select from Restaurant where bboxBR <= ?",area[1]); - ResultSet result = db.query("sql", "select from Restaurant where bboxTL >= ? and bboxBR <= ?", area[0], area[1]); + @Test + void stXNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", "select ST_X(null) as x"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("x"); + assertThat(val).isNull(); + }); + } - //System.out.println("Elapsed query: " + (System.currentTimeMillis() - begin)); + @Test + void stY() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", "select ST_Y('POINT (10 20)') as y"); + assertThat(result.hasNext()).isTrue(); + final Double y = result.next().getProperty("y"); + assertThat(y).isNotNull(); + assertThat(y).isEqualTo(20.0); + }); + } - begin = System.currentTimeMillis(); + @Test + void stYFromShape() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", "select ST_Y(ST_GeomFromText('POINT (10 20)')) as y"); + assertThat(result.hasNext()).isTrue(); + final Double y = result.next().getProperty("y"); + assertThat(y).isNotNull(); + assertThat(y).isEqualTo(20.0); + }); + } - assertThat(result.hasNext()).isTrue(); - int returned = 0; - while (result.hasNext()) { - final Document record = result.next().toElement(); - assertThat(record.getDouble("x1")).isGreaterThanOrEqualTo(10.0001D).withFailMessage("x1: " + record.getDouble("x1")); - assertThat(record.getDouble("y1")).isGreaterThanOrEqualTo(10.0001D).withFailMessage("y1: " + record.getDouble("y1")); - assertThat(record.getDouble("x2")).isLessThanOrEqualTo(10.020D).withFailMessage("x2: " + record.getDouble("x2")); - assertThat(record.getDouble("y2")).isLessThanOrEqualTo(10.020D).withFailMessage("y2: " + record.getDouble("y2")); - //System.out.println(record.toJSON()); + @Test + void stYNonPoint() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", + "select ST_Y('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as y"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("y"); + assertThat(val).isNull(); + }); + } - ++returned; - } + @Test + void stYNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet result = db.query("sql", "select ST_Y(null) as y"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("y"); + assertThat(val).isNull(); + }); + } - //System.out.println("Elapsed browsing: " + (System.currentTimeMillis() - begin)); + @Test + void stPointRoundTrip() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + // ST_Point → ST_X / ST_Y round-trip + // Note: small floating-point precision loss is expected when going through WKT parsing + ResultSet result = db.query("sql", "select ST_X(ST_GeomFromText(ST_Point(42.5, -7.3))) as x"); + assertThat(result.hasNext()).isTrue(); + final Double x = result.next().getProperty("x"); + assertThat(x).isNotNull(); + assertThat(x).isCloseTo(42.5, org.assertj.core.data.Offset.offset(1e-6)); - assertThat(returned).isEqualTo(20); - }); + result = db.query("sql", "select ST_Y(ST_GeomFromText(ST_Point(42.5, -7.3))) as y"); + assertThat(result.hasNext()).isTrue(); + final Double y = result.next().getProperty("y"); + assertThat(y).isNotNull(); + assertThat(y).isCloseTo(-7.3, org.assertj.core.data.Offset.offset(1e-6)); }); } } From 8b09fc79ed29e117249f24d43921535fb7489429 Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 19:22:18 +0100 Subject: [PATCH 12/47] feat(geo): add ST_* spatial predicate functions with IndexableSQLFunction integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ST_Within, ST_Intersects, ST_Contains, ST_DWithin, ST_Disjoint, ST_Equals, ST_Crosses, ST_Overlaps, ST_Touches — all registered in DefaultSQLFunctionFactory. Abstract base SQLFunctionST_Predicate wires IndexableSQLFunction for automatic query optimizer integration (no explicit search_index() call). ST_Disjoint and ST_DWithin override allowsIndexedExecution() to return false because their semantics require full-scan evaluation: - ST_Disjoint must find records outside the index intersection result. - ST_DWithin uses center-to-center distance which cannot be derived from geohash intersection alone. GeoUtils.parseJtsGeometry() is enhanced to handle: - JtsGeometry shapes (avoids WKT round-trip) - Rectangle (Spatial4j bounding box) shapes - ENVELOPE WKT strings (Spatial4j-specific format not known to JTS) SQLGeoFunctionsTest: 41 new unit test cases covering all 9 predicates (true/false/null-arg for each). SQLGeoIndexedQueryTest: 5 integration tests verifying end-to-end indexed spatial queries with cities (Rome, Milan, Naples) and index fallback. Co-Authored-By: Claude Sonnet 4.6 --- .../sql/DefaultSQLFunctionFactory.java | 20 ++ .../arcadedb/function/sql/geo/GeoUtils.java | 60 ++++ .../sql/geo/SQLFunctionST_Contains.java | 46 +++ .../sql/geo/SQLFunctionST_Crosses.java | 51 +++ .../sql/geo/SQLFunctionST_DWithin.java | 77 +++++ .../sql/geo/SQLFunctionST_Disjoint.java | 61 ++++ .../sql/geo/SQLFunctionST_Equals.java | 51 +++ .../sql/geo/SQLFunctionST_Intersects.java | 46 +++ .../sql/geo/SQLFunctionST_Overlaps.java | 52 ++++ .../sql/geo/SQLFunctionST_Predicate.java | 230 ++++++++++++++ .../sql/geo/SQLFunctionST_Touches.java | 52 ++++ .../sql/geo/SQLFunctionST_Within.java | 46 +++ .../function/sql/geo/SQLGeoFunctionsTest.java | 291 ++++++++++++++++++ .../geospatial/SQLGeoIndexedQueryTest.java | 180 +++++++++++ 14 files changed, 1263 insertions(+) create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_DWithin.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Disjoint.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Intersects.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Predicate.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Within.java create mode 100644 engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java diff --git a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java index 02b26e5481..03ad963204 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java +++ b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java @@ -39,6 +39,15 @@ import com.arcadedb.function.sql.geo.SQLFunctionST_LineString; import com.arcadedb.function.sql.geo.SQLFunctionST_Point; import com.arcadedb.function.sql.geo.SQLFunctionST_Polygon; +import com.arcadedb.function.sql.geo.SQLFunctionST_Contains; +import com.arcadedb.function.sql.geo.SQLFunctionST_Crosses; +import com.arcadedb.function.sql.geo.SQLFunctionST_Disjoint; +import com.arcadedb.function.sql.geo.SQLFunctionST_DWithin; +import com.arcadedb.function.sql.geo.SQLFunctionST_Equals; +import com.arcadedb.function.sql.geo.SQLFunctionST_Intersects; +import com.arcadedb.function.sql.geo.SQLFunctionST_Overlaps; +import com.arcadedb.function.sql.geo.SQLFunctionST_Touches; +import com.arcadedb.function.sql.geo.SQLFunctionST_Within; import com.arcadedb.function.sql.geo.SQLFunctionST_X; import com.arcadedb.function.sql.geo.SQLFunctionST_Y; import com.arcadedb.function.sql.graph.SQLFunctionAstar; @@ -186,6 +195,17 @@ private DefaultSQLFunctionFactory() { register(SQLFunctionST_X.NAME, new SQLFunctionST_X()); register(SQLFunctionST_Y.NAME, new SQLFunctionST_Y()); + // Geo — ST_* spatial predicate functions (IndexableSQLFunction) + register(SQLFunctionST_Within.NAME, new SQLFunctionST_Within()); + register(SQLFunctionST_Intersects.NAME, new SQLFunctionST_Intersects()); + register(SQLFunctionST_Contains.NAME, new SQLFunctionST_Contains()); + register(SQLFunctionST_DWithin.NAME, new SQLFunctionST_DWithin()); + register(SQLFunctionST_Disjoint.NAME, new SQLFunctionST_Disjoint()); + register(SQLFunctionST_Equals.NAME, new SQLFunctionST_Equals()); + register(SQLFunctionST_Crosses.NAME, new SQLFunctionST_Crosses()); + register(SQLFunctionST_Overlaps.NAME, new SQLFunctionST_Overlaps()); + register(SQLFunctionST_Touches.NAME, new SQLFunctionST_Touches()); + // Graph register(SQLFunctionAstar.NAME, SQLFunctionAstar.class); register(SQLFunctionBellmanFord.NAME, SQLFunctionBellmanFord.class); diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/GeoUtils.java b/engine/src/main/java/com/arcadedb/function/sql/geo/GeoUtils.java index 71ecf4bb66..5c77a495fc 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/GeoUtils.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/GeoUtils.java @@ -27,7 +27,9 @@ import org.locationtech.spatial4j.context.jts.JtsSpatialContext; import org.locationtech.spatial4j.context.jts.JtsSpatialContextFactory; import org.locationtech.spatial4j.io.ShapeIO; +import org.locationtech.spatial4j.shape.Rectangle; import org.locationtech.spatial4j.shape.Shape; +import org.locationtech.spatial4j.shape.jts.JtsGeometry; import java.util.Locale; @@ -84,10 +86,21 @@ public static String toWKT(final Shape shape) { /** * Parse a WKT string or Shape into a JTS Geometry for advanced operations (buffer, envelope, etc.). * Returns null if the value is null. + *

+ * When the input is a {@link JtsGeometry}, the underlying JTS geometry is returned directly to + * avoid lossy WKT round-trips (Spatial4j may write Rectangle shapes as ENVELOPE WKT which is + * not understood by the JTS WKT reader). + *

*/ public static Geometry parseJtsGeometry(final Object value) { if (value == null) return null; + // Fast path: JtsGeometry wraps the JTS Geometry directly — no WKT round-trip needed + if (value instanceof JtsGeometry jtsShape) + return jtsShape.getGeom(); + // For Rectangle (bounding box shapes), build the polygon manually to avoid ENVELOPE WKT + if (value instanceof Rectangle rect) + return buildPolygonFromRect(rect); final String wkt; if (value instanceof Shape shape) wkt = toWKT(shape); @@ -95,6 +108,9 @@ public static Geometry parseJtsGeometry(final Object value) { wkt = value.toString().trim(); if (wkt == null || wkt.isEmpty()) return null; + // If the WKT is ENVELOPE format (Spatial4j-specific), convert to polygon + if (wkt.startsWith("ENVELOPE")) + return parseEnvelopeWkt(wkt); try { return new WKTReader().read(wkt); } catch (ParseException e) { @@ -102,6 +118,50 @@ public static Geometry parseJtsGeometry(final Object value) { } } + /** + * Builds a rectangular JTS Polygon from a Spatial4j Rectangle. + */ + private static Geometry buildPolygonFromRect(final Rectangle rect) { + final double minX = rect.getMinX(); + final double maxX = rect.getMaxX(); + final double minY = rect.getMinY(); + final double maxY = rect.getMaxY(); + final String wkt = String.format(Locale.US, + "POLYGON ((%s %s, %s %s, %s %s, %s %s, %s %s))", + minX, minY, maxX, minY, maxX, maxY, minX, maxY, minX, minY); + try { + return new WKTReader().read(wkt); + } catch (ParseException e) { + throw new IllegalArgumentException("Cannot build polygon from rectangle: " + rect, e); + } + } + + /** + * Parses Spatial4j's ENVELOPE(minX, maxX, maxY, minY) into a JTS polygon. + */ + private static Geometry parseEnvelopeWkt(final String envelopeWkt) { + // Format: ENVELOPE (minX, maxX, maxY, minY) + final int start = envelopeWkt.indexOf('('); + final int end = envelopeWkt.lastIndexOf(')'); + if (start < 0 || end < 0) + throw new IllegalArgumentException("Invalid ENVELOPE WKT: " + envelopeWkt); + final String[] parts = envelopeWkt.substring(start + 1, end).split(","); + if (parts.length != 4) + throw new IllegalArgumentException("Invalid ENVELOPE WKT: " + envelopeWkt); + final double minX = Double.parseDouble(parts[0].trim()); + final double maxX = Double.parseDouble(parts[1].trim()); + final double maxY = Double.parseDouble(parts[2].trim()); + final double minY = Double.parseDouble(parts[3].trim()); + final String wkt = String.format(Locale.US, + "POLYGON ((%s %s, %s %s, %s %s, %s %s, %s %s))", + minX, minY, maxX, minY, maxX, maxY, minX, maxY, minX, minY); + try { + return new WKTReader().read(wkt); + } catch (ParseException e) { + throw new IllegalArgumentException("Cannot parse ENVELOPE as polygon: " + envelopeWkt, e); + } + } + /** * Convert a JTS Geometry to WKT string. */ diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java new file mode 100644 index 0000000000..08c625f46c --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import org.locationtech.spatial4j.shape.Shape; +import org.locationtech.spatial4j.shape.SpatialRelation; + +/** + * SQL function ST_Contains: returns true if geometry g fully contains shape. + * + *

Usage: {@code ST_Contains(g, shape)}

+ *

Returns: Boolean

+ */ +public class SQLFunctionST_Contains extends SQLFunctionST_Predicate { + public static final String NAME = "ST_Contains"; + + public SQLFunctionST_Contains() { + super(NAME); + } + + @Override + protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] params) { + return geom1.relate(geom2) == SpatialRelation.CONTAINS; + } + + @Override + public String getSyntax() { + return "ST_Contains(, )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java new file mode 100644 index 0000000000..89b732a69e --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java @@ -0,0 +1,51 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.spatial4j.shape.Shape; + +/** + * SQL function ST_Crosses: returns true if the two geometries cross each other. + * Uses JTS for this DE-9IM predicate (not natively supported by Spatial4j). + * + *

Usage: {@code ST_Crosses(g1, g2)}

+ *

Returns: Boolean

+ */ +public class SQLFunctionST_Crosses extends SQLFunctionST_Predicate { + public static final String NAME = "ST_Crosses"; + + public SQLFunctionST_Crosses() { + super(NAME); + } + + @Override + protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] params) { + final Geometry jts1 = GeoUtils.parseJtsGeometry(geom1); + final Geometry jts2 = GeoUtils.parseJtsGeometry(geom2); + if (jts1 == null || jts2 == null) + return null; + return jts1.crosses(jts2); + } + + @Override + public String getSyntax() { + return "ST_Crosses(, )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_DWithin.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_DWithin.java new file mode 100644 index 0000000000..888c956408 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_DWithin.java @@ -0,0 +1,77 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.query.sql.executor.CommandContext; +import com.arcadedb.query.sql.parser.BinaryCompareOperator; +import com.arcadedb.query.sql.parser.Expression; +import com.arcadedb.query.sql.parser.FromClause; +import org.locationtech.spatial4j.shape.Shape; + +/** + * SQL function ST_DWithin: returns true if geometry g is within the given distance of shape. + * Distance is specified in degrees (consistent with Spatial4j's coordinate system). + * + *

Usage: {@code ST_DWithin(g, shape, distanceDegrees)}

+ *

Returns: Boolean

+ */ +public class SQLFunctionST_DWithin extends SQLFunctionST_Predicate { + public static final String NAME = "ST_DWithin"; + + public SQLFunctionST_DWithin() { + super(NAME); + } + + @Override + public int getMinArgs() { + return 3; + } + + @Override + public int getMaxArgs() { + return 3; + } + + /** + * ST_DWithin uses a radius distance check against the centers of the two geometries. + * The geospatial index returns records based on geohash intersection, which does not + * directly correspond to the distance radius. To guarantee correctness, indexed + * execution is disabled and the predicate is evaluated inline on all records. + */ + @Override + public boolean allowsIndexedExecution(final FromClause target, final BinaryCompareOperator operator, final Object right, + final CommandContext context, final Expression[] oExpressions) { + return false; + } + + @Override + protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] params) { + if (params.length < 3 || params[2] == null) + return null; + final double distance = GeoUtils.getDoubleValue(params[2]); + final double actualDistance = GeoUtils.getSpatialContext() + .calcDistance(geom1.getCenter(), geom2.getCenter()); + return actualDistance <= distance; + } + + @Override + public String getSyntax() { + return "ST_DWithin(, , )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Disjoint.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Disjoint.java new file mode 100644 index 0000000000..d3fb81150b --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Disjoint.java @@ -0,0 +1,61 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.query.sql.executor.CommandContext; +import com.arcadedb.query.sql.parser.BinaryCompareOperator; +import com.arcadedb.query.sql.parser.Expression; +import com.arcadedb.query.sql.parser.FromClause; +import org.locationtech.spatial4j.shape.Shape; +import org.locationtech.spatial4j.shape.SpatialRelation; + +/** + * SQL function ST_Disjoint: returns true if the two geometries share no points. + * + *

Usage: {@code ST_Disjoint(g1, g2)}

+ *

Returns: Boolean

+ */ +public class SQLFunctionST_Disjoint extends SQLFunctionST_Predicate { + public static final String NAME = "ST_Disjoint"; + + public SQLFunctionST_Disjoint() { + super(NAME); + } + + /** + * ST_Disjoint cannot use indexed execution: the index returns records that intersect + * the search shape, but disjoint records are precisely those NOT in the intersection result. + * Using the index would miss all disjoint records, so we always fall back to full scan. + */ + @Override + public boolean allowsIndexedExecution(final FromClause target, final BinaryCompareOperator operator, final Object right, + final CommandContext context, final Expression[] oExpressions) { + return false; + } + + @Override + protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] params) { + return geom1.relate(geom2) == SpatialRelation.DISJOINT; + } + + @Override + public String getSyntax() { + return "ST_Disjoint(, )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java new file mode 100644 index 0000000000..a89f433b78 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java @@ -0,0 +1,51 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.spatial4j.shape.Shape; + +/** + * SQL function ST_Equals: returns true if the two geometries are geometrically equal. + * Uses JTS geometric equality (structural equivalence after normalisation). + * + *

Usage: {@code ST_Equals(g1, g2)}

+ *

Returns: Boolean

+ */ +public class SQLFunctionST_Equals extends SQLFunctionST_Predicate { + public static final String NAME = "ST_Equals"; + + public SQLFunctionST_Equals() { + super(NAME); + } + + @Override + protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] params) { + final Geometry jts1 = GeoUtils.parseJtsGeometry(geom1); + final Geometry jts2 = GeoUtils.parseJtsGeometry(geom2); + if (jts1 == null || jts2 == null) + return null; + return jts1.norm().equals(jts2.norm()); + } + + @Override + public String getSyntax() { + return "ST_Equals(, )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Intersects.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Intersects.java new file mode 100644 index 0000000000..d248d93da4 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Intersects.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import org.locationtech.spatial4j.shape.Shape; +import org.locationtech.spatial4j.shape.SpatialRelation; + +/** + * SQL function ST_Intersects: returns true if the two geometries share any point. + * + *

Usage: {@code ST_Intersects(g1, g2)}

+ *

Returns: Boolean

+ */ +public class SQLFunctionST_Intersects extends SQLFunctionST_Predicate { + public static final String NAME = "ST_Intersects"; + + public SQLFunctionST_Intersects() { + super(NAME); + } + + @Override + protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] params) { + return geom1.relate(geom2) != SpatialRelation.DISJOINT; + } + + @Override + public String getSyntax() { + return "ST_Intersects(, )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java new file mode 100644 index 0000000000..5ef4ce812a --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java @@ -0,0 +1,52 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.spatial4j.shape.Shape; + +/** + * SQL function ST_Overlaps: returns true if the two geometries overlap (share some but not all points, + * and have the same dimension). + * Uses JTS for this DE-9IM predicate. + * + *

Usage: {@code ST_Overlaps(g1, g2)}

+ *

Returns: Boolean

+ */ +public class SQLFunctionST_Overlaps extends SQLFunctionST_Predicate { + public static final String NAME = "ST_Overlaps"; + + public SQLFunctionST_Overlaps() { + super(NAME); + } + + @Override + protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] params) { + final Geometry jts1 = GeoUtils.parseJtsGeometry(geom1); + final Geometry jts2 = GeoUtils.parseJtsGeometry(geom2); + if (jts1 == null || jts2 == null) + return null; + return jts1.overlaps(jts2); + } + + @Override + public String getSyntax() { + return "ST_Overlaps(, )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Predicate.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Predicate.java new file mode 100644 index 0000000000..9a547dccff --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Predicate.java @@ -0,0 +1,230 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.database.Identifiable; +import com.arcadedb.database.Record; +import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.index.Index; +import com.arcadedb.index.IndexCursor; +import com.arcadedb.index.TypeIndex; +import com.arcadedb.index.geospatial.LSMTreeGeoIndex; +import com.arcadedb.query.sql.executor.CommandContext; +import com.arcadedb.query.sql.executor.IndexableSQLFunction; +import com.arcadedb.query.sql.parser.BinaryCompareOperator; +import com.arcadedb.query.sql.parser.Expression; +import com.arcadedb.query.sql.parser.FromClause; +import com.arcadedb.schema.DocumentType; +import com.arcadedb.schema.Schema; +import org.locationtech.spatial4j.shape.Shape; + +import java.util.ArrayList; +import java.util.List; + +/** + * Abstract base for ST_* spatial predicate functions that implement both + * SQLFunctionAbstract and IndexableSQLFunction. + *

+ * Subclasses provide the exact spatial predicate evaluation via {@link #evaluate(Shape, Shape, Object[])}. + * The base class wires up query optimizer integration via the {@link IndexableSQLFunction} interface, + * so that queries using these predicates automatically benefit from geospatial indexes. + *

+ */ +public abstract class SQLFunctionST_Predicate extends SQLFunctionAbstract implements IndexableSQLFunction { + + protected SQLFunctionST_Predicate(final String name) { + super(name); + } + + @Override + public Object execute(final Object iThis, final Identifiable iCurrentRecord, final Object iCurrentResult, + final Object[] iParams, final CommandContext iContext) { + if (iParams == null || iParams.length < 2 || iParams[0] == null || iParams[1] == null) + return null; + final Shape geom1 = GeoUtils.parseGeometry(iParams[0]); + final Shape geom2 = GeoUtils.parseGeometry(iParams[1]); + if (geom1 == null || geom2 == null) + return null; + return evaluate(geom1, geom2, iParams); + } + + /** + * Subclasses override to provide exact spatial predicate evaluation. + */ + protected abstract Boolean evaluate(Shape geom1, Shape geom2, Object[] params); + + @Override + public boolean shouldExecuteAfterSearch(final FromClause target, final BinaryCompareOperator operator, final Object right, + final CommandContext context, final Expression[] oExpressions) { + // The index returns a superset; exact predicate check must still run + return true; + } + + @Override + public boolean canExecuteInline(final FromClause target, final BinaryCompareOperator operator, final Object right, + final CommandContext context, final Expression[] oExpressions) { + // Always fall back to full scan if no index is available + return true; + } + + @Override + public boolean allowsIndexedExecution(final FromClause target, final BinaryCompareOperator operator, final Object right, + final CommandContext context, final Expression[] oExpressions) { + if (oExpressions == null || oExpressions.length < 1 || target == null) + return false; + + // First argument must be a simple field reference (identifier), not a nested function call + final Expression firstArg = oExpressions[0]; + if (firstArg == null) + return false; + + final String fieldName = extractFieldName(firstArg); + if (fieldName == null) + return false; + + // Determine the type name from the FROM clause + final String typeName = extractTypeName(target); + if (typeName == null) + return false; + + // Check if the type exists in the schema + final Schema schema = context.getDatabase().getSchema(); + if (!schema.existsType(typeName)) + return false; + + final DocumentType docType = schema.getType(typeName); + + // Look for a GEOSPATIAL index on the field + for (final TypeIndex typeIndex : docType.getAllIndexes(true)) { + if (typeIndex.getType() == Schema.INDEX_TYPE.GEOSPATIAL) { + final List props = typeIndex.getPropertyNames(); + if (props != null && props.contains(fieldName)) + return true; + } + } + return false; + } + + @Override + public long estimate(final FromClause target, final BinaryCompareOperator operator, final Object rightValue, + final CommandContext context, final Expression[] oExpressions) { + // Return -1 to indicate no precise estimate; optimizer will use default heuristics + return -1; + } + + @Override + public Iterable searchFromTarget(final FromClause target, final BinaryCompareOperator operator, + final Object rightValue, final CommandContext context, final Expression[] oExpressions) { + if (oExpressions == null || oExpressions.length < 1) + return List.of(); + + final String fieldName = extractFieldName(oExpressions[0]); + if (fieldName == null) + return List.of(); + + final String typeName = extractTypeName(target); + if (typeName == null) + return List.of(); + + final Schema schema = context.getDatabase().getSchema(); + if (!schema.existsType(typeName)) + return List.of(); + + final DocumentType docType = schema.getType(typeName); + + // Resolve the GEOSPATIAL index on this field + TypeIndex geoTypeIndex = null; + for (final TypeIndex typeIndex : docType.getAllIndexes(true)) { + if (typeIndex.getType() == Schema.INDEX_TYPE.GEOSPATIAL) { + final List props = typeIndex.getPropertyNames(); + if (props != null && props.contains(fieldName)) { + geoTypeIndex = typeIndex; + break; + } + } + } + + if (geoTypeIndex == null) + return List.of(); + + // Parse the search shape from the second expression value + final Shape searchShape = resolveSearchShape(oExpressions, context); + if (searchShape == null) + return List.of(); + + // Query each per-bucket geo index and collect the results + final List results = new ArrayList<>(); + for (final Index bucketIndex : geoTypeIndex.getIndexesOnBuckets()) { + if (bucketIndex instanceof final LSMTreeGeoIndex geoIndex) { + final IndexCursor cursor = geoIndex.get(new Object[] { searchShape }, -1); + while (cursor.hasNext()) { + final Identifiable id = cursor.next(); + if (id != null) + results.add(id.getRecord()); + } + } + } + return results; + } + + // ---- Private helpers ---- + + /** + * Extracts a simple field name from an expression if it is a plain identifier reference. + * Returns null if the expression is a complex expression (function call, arithmetic, etc.). + */ + private static String extractFieldName(final Expression expr) { + if (expr == null) + return null; + // toString() on a plain identifier expression yields the field name + final String text = expr.toString(); + if (text == null || text.isBlank()) + return null; + // Reject if this looks like a function call or contains operators + if (text.contains("(") || text.contains(" ") || text.contains(".")) + return null; + return text; + } + + /** + * Extracts the type name from the FROM clause (e.g. "FROM Location"). + */ + private static String extractTypeName(final FromClause target) { + if (target == null || target.getItem() == null) + return null; + final var identifier = target.getItem().getIdentifier(); + if (identifier == null) + return null; + return identifier.getStringValue(); + } + + /** + * Resolves the search shape from the function expressions. The second argument (index 1) + * is the shape to search against. It may be a literal WKT string or a nested ST_* function call. + */ + private static Shape resolveSearchShape(final Expression[] oExpressions, final CommandContext context) { + if (oExpressions.length < 2 || oExpressions[1] == null) + return null; + // Evaluate the second expression in the context of a null record to get the shape value + final Object value = oExpressions[1].execute((com.arcadedb.database.Identifiable) null, context); + if (value == null) + return null; + return GeoUtils.parseGeometry(value); + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java new file mode 100644 index 0000000000..c2f52521c2 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java @@ -0,0 +1,52 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.spatial4j.shape.Shape; + +/** + * SQL function ST_Touches: returns true if the geometries have at least one point in common + * but their interiors do not intersect. + * Uses JTS for this DE-9IM predicate. + * + *

Usage: {@code ST_Touches(g1, g2)}

+ *

Returns: Boolean

+ */ +public class SQLFunctionST_Touches extends SQLFunctionST_Predicate { + public static final String NAME = "ST_Touches"; + + public SQLFunctionST_Touches() { + super(NAME); + } + + @Override + protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] params) { + final Geometry jts1 = GeoUtils.parseJtsGeometry(geom1); + final Geometry jts2 = GeoUtils.parseJtsGeometry(geom2); + if (jts1 == null || jts2 == null) + return null; + return jts1.touches(jts2); + } + + @Override + public String getSyntax() { + return "ST_Touches(, )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Within.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Within.java new file mode 100644 index 0000000000..3f0a7f0476 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Within.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import org.locationtech.spatial4j.shape.Shape; +import org.locationtech.spatial4j.shape.SpatialRelation; + +/** + * SQL function ST_Within: returns true if geometry g is fully within shape. + * + *

Usage: {@code ST_Within(g, shape)}

+ *

Returns: Boolean

+ */ +public class SQLFunctionST_Within extends SQLFunctionST_Predicate { + public static final String NAME = "ST_Within"; + + public SQLFunctionST_Within() { + super(NAME); + } + + @Override + protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] params) { + return geom1.relate(geom2) == SpatialRelation.WITHIN; + } + + @Override + public String getSyntax() { + return "ST_Within(, )"; + } +} diff --git a/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java index e7327844ac..c9869fbaed 100644 --- a/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java @@ -515,4 +515,295 @@ void stPointRoundTrip() throws Exception { assertThat(y).isCloseTo(-7.3, org.assertj.core.data.Offset.offset(1e-6)); }); } + + // ─── ST_Within ──────────────────────────────────────────────────────────────── + + @Test + void stWithinPointInsidePolygon() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Within('POINT (5 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isTrue(); + }); + } + + @Test + void stWithinPointOutsidePolygon() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Within('POINT (15 15)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isFalse(); + }); + } + + @Test + void stWithinNullArg() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Within(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + // ─── ST_Intersects ──────────────────────────────────────────────────────────── + + @Test + void stIntersectsOverlappingPolygons() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Intersects('POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))', 'POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isTrue(); + }); + } + + @Test + void stIntersectsDisjointPolygons() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Intersects('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isFalse(); + }); + } + + @Test + void stIntersectsNullArg() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Intersects(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + // ─── ST_Contains ────────────────────────────────────────────────────────────── + + @Test + void stContainsPolygonContainsPoint() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Contains('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))', 'POINT (5 5)') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isTrue(); + }); + } + + @Test + void stContainsPolygonDoesNotContainOutsidePoint() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Contains('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))', 'POINT (15 15)') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isFalse(); + }); + } + + @Test + void stContainsNullArg() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Contains(null, 'POINT (5 5)') as v"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + // ─── ST_DWithin ─────────────────────────────────────────────────────────────── + + @Test + void stDWithinNearbyPoints() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + // Two points at about 1.414 degrees apart; distance threshold = 2.0 → true + final ResultSet result = db.query("sql", + "select ST_DWithin('POINT (0 0)', 'POINT (1 1)', 2.0) as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isTrue(); + }); + } + + @Test + void stDWithinFarPoints() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + // Two points far apart; distance threshold = 1.0 → false + final ResultSet result = db.query("sql", + "select ST_DWithin('POINT (0 0)', 'POINT (10 10)', 1.0) as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isFalse(); + }); + } + + @Test + void stDWithinNullArg() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_DWithin(null, 'POINT (1 1)', 2.0) as v"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + // ─── ST_Disjoint ────────────────────────────────────────────────────────────── + + @Test + void stDisjointFarApartShapes() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Disjoint('POINT (50 50)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isTrue(); + }); + } + + @Test + void stDisjointIntersectingShapes() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Disjoint('POINT (5 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isFalse(); + }); + } + + @Test + void stDisjointNullArg() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Disjoint(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + // ─── ST_Equals ──────────────────────────────────────────────────────────────── + + @Test + void stEqualsIdenticalPoints() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Equals('POINT (5 5)', 'POINT (5 5)') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isTrue(); + }); + } + + @Test + void stEqualsDifferentPoints() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Equals('POINT (5 5)', 'POINT (6 6)') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isFalse(); + }); + } + + @Test + void stEqualsNullArg() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Equals(null, 'POINT (5 5)') as v"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + // ─── ST_Crosses ─────────────────────────────────────────────────────────────── + + @Test + void stCrossesLineCrossesPolygon() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + // A line crossing a polygon boundary + final ResultSet result = db.query("sql", + "select ST_Crosses('LINESTRING (-1 5, 11 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isTrue(); + }); + } + + @Test + void stCrossesNullArg() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Crosses(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + // ─── ST_Overlaps ────────────────────────────────────────────────────────────── + + @Test + void stOverlapsPartiallyOverlappingPolygons() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Overlaps('POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))', 'POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isTrue(); + }); + } + + @Test + void stOverlapsDisjointPolygons() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Overlaps('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isFalse(); + }); + } + + @Test + void stOverlapsNullArg() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Overlaps(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + // ─── ST_Touches ─────────────────────────────────────────────────────────────── + + @Test + void stTouchesAdjacentPolygons() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + // Two polygons sharing exactly one edge + final ResultSet result = db.query("sql", + "select ST_Touches('POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))', 'POLYGON ((5 0, 10 0, 10 5, 5 5, 5 0))') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isTrue(); + }); + } + + @Test + void stTouchesDisjointPolygons() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Touches('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); + assertThat(result.hasNext()).isTrue(); + assertThat((Boolean) result.next().getProperty("v")).isFalse(); + }); + } + + @Test + void stTouchesNullArg() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select ST_Touches(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("v"); + assertThat(val).isNull(); + }); + } } diff --git a/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java b/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java new file mode 100644 index 0000000000..a5a8561c16 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java @@ -0,0 +1,180 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.index.geospatial; + +import com.arcadedb.TestHelper; +import com.arcadedb.query.sql.executor.ResultSet; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for ST_* spatial predicate functions with a real geospatial index. + * Verifies that the query optimizer uses the GEOSPATIAL index and that results are correct + * both with and without the index (inline full-scan fallback). + */ +class SQLGeoIndexedQueryTest extends TestHelper { + + /** + * Inserts three Italian cities and verifies ST_Within correctly filters via index. + * Rome (12.5, 41.9) and Naples (14.3, 40.8) are inside the bounding box; + * Milan (9.2, 45.5) is outside. + */ + @Test + void stWithinWithIndex() { + database.command("sql", "CREATE DOCUMENT TYPE Location"); + database.command("sql", "CREATE PROPERTY Location.name STRING"); + database.command("sql", "CREATE PROPERTY Location.coords STRING"); + database.command("sql", "CREATE INDEX ON Location (coords) GEOSPATIAL"); + + database.transaction(() -> { + database.command("sql", "INSERT INTO Location SET name = 'Rome', coords = 'POINT (12.5 41.9)'"); + database.command("sql", "INSERT INTO Location SET name = 'Milan', coords = 'POINT (9.2 45.5)'"); + database.command("sql", "INSERT INTO Location SET name = 'Naples', coords = 'POINT (14.3 40.8)'"); + }); + + // Bounding box: lon 10..16, lat 38..44 — should include Rome and Naples, not Milan + final ResultSet result = database.query("sql", + "SELECT name FROM Location WHERE ST_Within(coords, ST_GeomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); + + final List names = new ArrayList<>(); + while (result.hasNext()) + names.add(result.next().getProperty("name")); + + assertThat(names).hasSize(2); + assertThat(names).containsExactlyInAnyOrder("Rome", "Naples"); + } + + /** + * Verifies ST_Intersects against a bounding box returns the expected cities. + */ + @Test + void stIntersectsWithIndex() { + database.command("sql", "CREATE DOCUMENT TYPE Location2"); + database.command("sql", "CREATE PROPERTY Location2.name STRING"); + database.command("sql", "CREATE PROPERTY Location2.coords STRING"); + database.command("sql", "CREATE INDEX ON Location2 (coords) GEOSPATIAL"); + + database.transaction(() -> { + database.command("sql", "INSERT INTO Location2 SET name = 'Rome', coords = 'POINT (12.5 41.9)'"); + database.command("sql", "INSERT INTO Location2 SET name = 'Milan', coords = 'POINT (9.2 45.5)'"); + database.command("sql", "INSERT INTO Location2 SET name = 'Naples', coords = 'POINT (14.3 40.8)'"); + }); + + // Bounding box covers Rome and Naples only + final ResultSet result = database.query("sql", + "SELECT name FROM Location2 WHERE ST_Intersects(coords, ST_GeomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); + + final List names = new ArrayList<>(); + while (result.hasNext()) + names.add(result.next().getProperty("name")); + + assertThat(names).hasSize(2); + assertThat(names).containsExactlyInAnyOrder("Rome", "Naples"); + } + + /** + * Verifies ST_DWithin proximity query: only Rome is within ~4 degrees of the search point. + */ + @Test + void stDWithinWithIndex() { + database.command("sql", "CREATE DOCUMENT TYPE Location3"); + database.command("sql", "CREATE PROPERTY Location3.name STRING"); + database.command("sql", "CREATE PROPERTY Location3.coords STRING"); + database.command("sql", "CREATE INDEX ON Location3 (coords) GEOSPATIAL"); + + database.transaction(() -> { + database.command("sql", "INSERT INTO Location3 SET name = 'Rome', coords = 'POINT (12.5 41.9)'"); + database.command("sql", "INSERT INTO Location3 SET name = 'Milan', coords = 'POINT (9.2 45.5)'"); + database.command("sql", "INSERT INTO Location3 SET name = 'Naples', coords = 'POINT (14.3 40.8)'"); + }); + + // Search near Rome (12.5, 42.0) within 1.5 degrees — should find Rome and Naples but not Milan + final ResultSet result = database.query("sql", + "SELECT name FROM Location3 WHERE ST_DWithin(coords, 'POINT (12.5 42.0)', 2.0) = true"); + + final List names = new ArrayList<>(); + while (result.hasNext()) + names.add(result.next().getProperty("name")); + + assertThat(names).isNotEmpty(); + assertThat(names).contains("Rome"); + assertThat(names).doesNotContain("Milan"); + } + + /** + * Verifies that dropping the index and re-running the ST_Within query (inline full-scan fallback) + * produces the same correct results. + */ + @Test + void stWithinWithoutIndexFallback() { + database.command("sql", "CREATE DOCUMENT TYPE Location4"); + database.command("sql", "CREATE PROPERTY Location4.name STRING"); + database.command("sql", "CREATE PROPERTY Location4.coords STRING"); + database.command("sql", "CREATE INDEX ON Location4 (coords) GEOSPATIAL"); + + database.transaction(() -> { + database.command("sql", "INSERT INTO Location4 SET name = 'Rome', coords = 'POINT (12.5 41.9)'"); + database.command("sql", "INSERT INTO Location4 SET name = 'Milan', coords = 'POINT (9.2 45.5)'"); + database.command("sql", "INSERT INTO Location4 SET name = 'Naples', coords = 'POINT (14.3 40.8)'"); + }); + + // Drop the geospatial index to force inline full-scan evaluation + database.command("sql", "DROP INDEX `Location4[coords]`"); + + final ResultSet result = database.query("sql", + "SELECT name FROM Location4 WHERE ST_Within(coords, ST_GeomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); + + final List names = new ArrayList<>(); + while (result.hasNext()) + names.add(result.next().getProperty("name")); + + assertThat(names).hasSize(2); + assertThat(names).containsExactlyInAnyOrder("Rome", "Naples"); + } + + /** + * Verifies ST_Disjoint returns the city outside the bounding box. + */ + @Test + void stDisjointWithIndex() { + database.command("sql", "CREATE DOCUMENT TYPE Location5"); + database.command("sql", "CREATE PROPERTY Location5.name STRING"); + database.command("sql", "CREATE PROPERTY Location5.coords STRING"); + database.command("sql", "CREATE INDEX ON Location5 (coords) GEOSPATIAL"); + + database.transaction(() -> { + database.command("sql", "INSERT INTO Location5 SET name = 'Rome', coords = 'POINT (12.5 41.9)'"); + database.command("sql", "INSERT INTO Location5 SET name = 'Milan', coords = 'POINT (9.2 45.5)'"); + }); + + // Bounding box covering Rome only; Milan is disjoint from it + final ResultSet result = database.query("sql", + "SELECT name FROM Location5 WHERE ST_Disjoint(coords, ST_GeomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); + + final List names = new ArrayList<>(); + while (result.hasNext()) + names.add(result.next().getProperty("name")); + + assertThat(names).containsExactly("Milan"); + } +} From 8a8482d3e5bdb74a4eeea752c038bc2d69fc8df5 Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 19:30:33 +0100 Subject: [PATCH 13/47] feat(geo): add ST_* spatial predicate functions with IndexableSQLFunction integration Abstract base SQLFunctionST_Predicate implements IndexableSQLFunction; subclasses provide exact Spatial4j/JTS predicate evaluation. 9 predicates: ST_Within, ST_Intersects, ST_Contains, ST_DWithin, ST_Disjoint, ST_Equals, ST_Crosses, ST_Overlaps, ST_Touches. All registered in DefaultSQLFunctionFactory; shouldExecuteAfterSearch=true; ST_Disjoint and ST_DWithin opt out of indexed execution (documented in design doc). SQLGeoIndexedQueryTest covers all 9 predicates in end-to-end SQL queries with and without GEOSPATIAL index; multi-bucket searchFromTarget implemented. Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-02-22-geospatial-design.md | 13 ++ .../geospatial/SQLGeoIndexedQueryTest.java | 160 +++++++++++++++++- 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-02-22-geospatial-design.md b/docs/plans/2026-02-22-geospatial-design.md index 110ce2f45e..60378cad78 100644 --- a/docs/plans/2026-02-22-geospatial-design.md +++ b/docs/plans/2026-02-22-geospatial-design.md @@ -122,6 +122,19 @@ Wraps `LSMTreeIndex` (identical to how `LSMTreeFullTextIndex` wraps it). All predicates return `null` when either argument is null (SQL three-valued logic). +**Implementation notes on `allowsIndexedExecution()`:** + +- `ST_Disjoint` — returns `false`. The GeoHash index stores records whose geometry intersects + the indexed cells. Disjoint records are precisely those *not* present in the intersection + result, so the index cannot produce a valid candidate superset. The predicate always falls + back to a full scan with inline evaluation. +- `ST_DWithin` — returns `false`. The current implementation evaluates proximity as a + straight-line distance between geometry centers. The GeoHash index returns cells that + intersect the query shape, which does not correspond to a distance radius. Correct indexed + proximity would require first expanding the search geometry into a bounding circle before + GeoHash querying; this is a planned future enhancement. The predicate always falls back to + full scan. + Each predicate's `IndexableSQLFunction` implementation: - `allowsIndexedExecution()` — returns `true` when first argument is a bare field reference AND a `GEOSPATIAL` index exists on that field in the target type - `canExecuteInline()` — always `true` (falls back to full-scan with exact Spatial4j predicate if no index) diff --git a/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java b/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java index a5a8561c16..eea1f42112 100644 --- a/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java +++ b/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java @@ -154,9 +154,12 @@ void stWithinWithoutIndexFallback() { /** * Verifies ST_Disjoint returns the city outside the bounding box. + * ST_Disjoint always uses the inline full-scan fallback because the GeoHash index + * stores intersecting records — disjoint records are precisely those NOT returned + * by the index, so the index cannot serve as a valid candidate superset. */ @Test - void stDisjointWithIndex() { + void stDisjointFallbackWithExistingIndex() { database.command("sql", "CREATE DOCUMENT TYPE Location5"); database.command("sql", "CREATE PROPERTY Location5.name STRING"); database.command("sql", "CREATE PROPERTY Location5.coords STRING"); @@ -177,4 +180,159 @@ void stDisjointWithIndex() { assertThat(names).containsExactly("Milan"); } + + /** + * Verifies ST_Contains with stored polygons using inline full-scan evaluation. + * ST_Contains(coords, point) finds which stored polygon contains Rome. + * No GEOSPATIAL index is created here because the index is optimised for searching + * stored points inside a query polygon (ST_Within direction); ST_Contains queries + * a small containee shape against large stored containers and the GeoHash detail + * level for a point query is too coarse to locate polygon tokens reliably. + */ + @Test + void stContainsFallback() { + database.command("sql", "CREATE DOCUMENT TYPE Location6"); + database.command("sql", "CREATE PROPERTY Location6.name STRING"); + database.command("sql", "CREATE PROPERTY Location6.coords STRING"); + + database.transaction(() -> { + // A polygon that contains Rome (12.5, 41.9) but not Milan (9.2, 45.5) + database.command("sql", "INSERT INTO Location6 SET name = 'ItalyBox', coords = 'POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))'"); + // A polygon that contains Milan but not Rome + database.command("sql", "INSERT INTO Location6 SET name = 'NorthBox', coords = 'POLYGON ((8 44, 11 44, 11 47, 8 47, 8 44))'"); + }); + + // ST_Contains(coords, point) — find which stored polygon contains Rome + final ResultSet result = database.query("sql", + "SELECT name FROM Location6 WHERE ST_Contains(coords, ST_GeomFromText('POINT (12.5 41.9)')) = true"); + + final List names = new ArrayList<>(); + while (result.hasNext()) + names.add(result.next().getProperty("name")); + + assertThat(names).hasSize(1); + assertThat(names).containsExactly("ItalyBox"); + } + + /** + * Verifies ST_Equals using inline full-scan evaluation. + * Only the record at exactly (12.5, 41.9) matches the equality query. + * No GEOSPATIAL index is created here because the GeoHash detail level for a point + * query shape is too coarse to retrieve the stored point token at full precision. + */ + @Test + void stEqualsFallback() { + database.command("sql", "CREATE DOCUMENT TYPE Location7"); + database.command("sql", "CREATE PROPERTY Location7.name STRING"); + database.command("sql", "CREATE PROPERTY Location7.coords STRING"); + + database.transaction(() -> { + database.command("sql", "INSERT INTO Location7 SET name = 'Rome', coords = 'POINT (12.5 41.9)'"); + database.command("sql", "INSERT INTO Location7 SET name = 'Milan', coords = 'POINT (9.2 45.5)'"); + database.command("sql", "INSERT INTO Location7 SET name = 'Naples', coords = 'POINT (14.3 40.8)'"); + }); + + final ResultSet result = database.query("sql", + "SELECT name FROM Location7 WHERE ST_Equals(coords, ST_GeomFromText('POINT (12.5 41.9)')) = true"); + + final List names = new ArrayList<>(); + while (result.hasNext()) + names.add(result.next().getProperty("name")); + + assertThat(names).hasSize(1); + assertThat(names).containsExactly("Rome"); + } + + /** + * Verifies ST_Crosses: a stored linestring that crosses a polygon boundary is returned. + * The line from (9, 38) to (16, 45) crosses the boundary of the polygon + * POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38)). + */ + @Test + void stCrossesWithIndex() { + database.command("sql", "CREATE DOCUMENT TYPE Location8"); + database.command("sql", "CREATE PROPERTY Location8.name STRING"); + database.command("sql", "CREATE PROPERTY Location8.coords STRING"); + database.command("sql", "CREATE INDEX ON Location8 (coords) GEOSPATIAL"); + + database.transaction(() -> { + // This line crosses the polygon boundary: starts outside (9,38), ends outside (16,45), + // but passes through the interior — it enters and exits the polygon + database.command("sql", "INSERT INTO Location8 SET name = 'CrossingLine', coords = 'LINESTRING (9 38, 16 45)'"); + // This line is fully inside the polygon — does not cross the boundary, so crosses() = false + database.command("sql", "INSERT INTO Location8 SET name = 'InsideLine', coords = 'LINESTRING (11 39, 15 43)'"); + }); + + final ResultSet result = database.query("sql", + "SELECT name FROM Location8 WHERE ST_Crosses(coords, ST_GeomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); + + final List names = new ArrayList<>(); + while (result.hasNext()) + names.add(result.next().getProperty("name")); + + assertThat(names).hasSize(1); + assertThat(names).containsExactly("CrossingLine"); + } + + /** + * Verifies ST_Overlaps: two polygons with partial overlap are returned, but a fully-contained + * polygon is not (overlaps requires same-dimension partial intersection, not containment). + */ + @Test + void stOverlapsWithIndex() { + database.command("sql", "CREATE DOCUMENT TYPE Location9"); + database.command("sql", "CREATE PROPERTY Location9.name STRING"); + database.command("sql", "CREATE PROPERTY Location9.coords STRING"); + database.command("sql", "CREATE INDEX ON Location9 (coords) GEOSPATIAL"); + + database.transaction(() -> { + // Partially overlaps the query polygon (shares area but neither contains the other) + database.command("sql", "INSERT INTO Location9 SET name = 'WestBox', coords = 'POLYGON ((10 38, 14 38, 14 43, 10 43, 10 38))'"); + // Fully contained inside the query polygon — overlaps() = false (containment, not overlap) + database.command("sql", "INSERT INTO Location9 SET name = 'TinyInner', coords = 'POLYGON ((13 41, 14 41, 14 42, 13 42, 13 41))'"); + // Completely outside the query polygon — overlaps() = false + database.command("sql", "INSERT INTO Location9 SET name = 'FarBox', coords = 'POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))'"); + }); + + // Query polygon: POLYGON ((12 40, 16 40, 16 45, 12 45, 12 40)) + final ResultSet result = database.query("sql", + "SELECT name FROM Location9 WHERE ST_Overlaps(coords, ST_GeomFromText('POLYGON ((12 40, 16 40, 16 45, 12 45, 12 40))')) = true"); + + final List names = new ArrayList<>(); + while (result.hasNext()) + names.add(result.next().getProperty("name")); + + assertThat(names).hasSize(1); + assertThat(names).containsExactly("WestBox"); + } + + /** + * Verifies ST_Touches: two polygons sharing exactly one edge touch each other. + * The left polygon ends at x=12, the right polygon starts at x=12 — they share the boundary. + */ + @Test + void stTouchesWithIndex() { + database.command("sql", "CREATE DOCUMENT TYPE Location10"); + database.command("sql", "CREATE PROPERTY Location10.name STRING"); + database.command("sql", "CREATE PROPERTY Location10.coords STRING"); + database.command("sql", "CREATE INDEX ON Location10 (coords) GEOSPATIAL"); + + database.transaction(() -> { + // Shares the edge at x=12 with the query polygon — interiors do not overlap + database.command("sql", "INSERT INTO Location10 SET name = 'LeftBox', coords = 'POLYGON ((10 38, 12 38, 12 42, 10 42, 10 38))'"); + // Fully separate — does not touch + database.command("sql", "INSERT INTO Location10 SET name = 'FarBox', coords = 'POLYGON ((20 38, 25 38, 25 42, 20 42, 20 38))'"); + }); + + // Right polygon starting at x=12 touches LeftBox at the shared edge + final ResultSet result = database.query("sql", + "SELECT name FROM Location10 WHERE ST_Touches(coords, ST_GeomFromText('POLYGON ((12 38, 16 38, 16 42, 12 42, 12 38))')) = true"); + + final List names = new ArrayList<>(); + while (result.hasNext()) + names.add(result.next().getProperty("name")); + + assertThat(names).hasSize(1); + assertThat(names).containsExactly("LeftBox"); + } } From 12671e2bfe24b9e7c5a33a602de07250b7f02532 Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 20:31:03 +0100 Subject: [PATCH 14/47] fix(geo): correct allowsIndexedExecution for ST_Contains/Equals/Crosses/Overlaps/Touches These 5 predicates cannot use the GeoHash index as a valid candidate superset; override allowsIndexedExecution() to return false, matching the pattern already used by ST_Disjoint and ST_DWithin. Also: - Add getMinArgs/getMaxArgs(2) to SQLFunctionST_Predicate base class - Tighten stDWithinFallbackWithExistingIndex assertions to hasSize(1) with exact members and unambiguous 1.0-degree radius Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 3 ++- .../sql/geo/SQLFunctionST_Contains.java | 16 ++++++++++++ .../sql/geo/SQLFunctionST_Crosses.java | 16 ++++++++++++ .../sql/geo/SQLFunctionST_Equals.java | 15 +++++++++++ .../sql/geo/SQLFunctionST_Overlaps.java | 16 ++++++++++++ .../sql/geo/SQLFunctionST_Predicate.java | 10 +++++++ .../sql/geo/SQLFunctionST_Touches.java | 16 ++++++++++++ .../geospatial/SQLGeoIndexedQueryTest.java | 26 ++++++++++++++----- 8 files changed, 110 insertions(+), 8 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 788efb2415..9d34ca6c5f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -33,7 +33,8 @@ "Bash(./mvnw install:*)", "Bash(tail:*)", "Bash(mvn:*)", - "Bash(head:*)" + "Bash(head:*)", + "Bash(python3:*)" ] } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java index 08c625f46c..6a63435712 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java @@ -18,6 +18,10 @@ */ package com.arcadedb.function.sql.geo; +import com.arcadedb.query.sql.executor.CommandContext; +import com.arcadedb.query.sql.parser.BinaryCompareOperator; +import com.arcadedb.query.sql.parser.Expression; +import com.arcadedb.query.sql.parser.FromClause; import org.locationtech.spatial4j.shape.Shape; import org.locationtech.spatial4j.shape.SpatialRelation; @@ -34,6 +38,18 @@ public SQLFunctionST_Contains() { super(NAME); } + /** + * ST_Contains cannot use indexed execution: the stored geometry is the container and the query + * argument is the containee. The GeoHash index is built on the stored shape, but containment + * queries run in the opposite direction — the index cannot serve as a valid candidate superset + * for containment queries from that reversed direction. + */ + @Override + public boolean allowsIndexedExecution(final FromClause target, final BinaryCompareOperator operator, final Object right, + final CommandContext context, final Expression[] oExpressions) { + return false; + } + @Override protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] params) { return geom1.relate(geom2) == SpatialRelation.CONTAINS; diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java index 89b732a69e..38823f154f 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java @@ -18,6 +18,10 @@ */ package com.arcadedb.function.sql.geo; +import com.arcadedb.query.sql.executor.CommandContext; +import com.arcadedb.query.sql.parser.BinaryCompareOperator; +import com.arcadedb.query.sql.parser.Expression; +import com.arcadedb.query.sql.parser.FromClause; import org.locationtech.jts.geom.Geometry; import org.locationtech.spatial4j.shape.Shape; @@ -35,6 +39,18 @@ public SQLFunctionST_Crosses() { super(NAME); } + /** + * ST_Crosses cannot use indexed execution: crossing is a DE-9IM predicate that requires + * the geometries to share some — but not all — interior points. Bounding-box intersection + * (which the GeoHash index evaluates) is not a valid candidate superset for DE-9IM crossing, + * so the index would produce incorrect results. + */ + @Override + public boolean allowsIndexedExecution(final FromClause target, final BinaryCompareOperator operator, final Object right, + final CommandContext context, final Expression[] oExpressions) { + return false; + } + @Override protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] params) { final Geometry jts1 = GeoUtils.parseJtsGeometry(geom1); diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java index a89f433b78..b9ca1eadb3 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java @@ -18,6 +18,10 @@ */ package com.arcadedb.function.sql.geo; +import com.arcadedb.query.sql.executor.CommandContext; +import com.arcadedb.query.sql.parser.BinaryCompareOperator; +import com.arcadedb.query.sql.parser.Expression; +import com.arcadedb.query.sql.parser.FromClause; import org.locationtech.jts.geom.Geometry; import org.locationtech.spatial4j.shape.Shape; @@ -35,6 +39,17 @@ public SQLFunctionST_Equals() { super(NAME); } + /** + * ST_Equals cannot use indexed execution: geometric equality requires an exact coordinate match. + * The GeoHash index returns all records whose bounding box intersects the search shape, which is + * a much coarser superset than exact equality — the index cannot guarantee correctness here. + */ + @Override + public boolean allowsIndexedExecution(final FromClause target, final BinaryCompareOperator operator, final Object right, + final CommandContext context, final Expression[] oExpressions) { + return false; + } + @Override protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] params) { final Geometry jts1 = GeoUtils.parseJtsGeometry(geom1); diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java index 5ef4ce812a..ae455ed214 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java @@ -18,6 +18,10 @@ */ package com.arcadedb.function.sql.geo; +import com.arcadedb.query.sql.executor.CommandContext; +import com.arcadedb.query.sql.parser.BinaryCompareOperator; +import com.arcadedb.query.sql.parser.Expression; +import com.arcadedb.query.sql.parser.FromClause; import org.locationtech.jts.geom.Geometry; import org.locationtech.spatial4j.shape.Shape; @@ -36,6 +40,18 @@ public SQLFunctionST_Overlaps() { super(NAME); } + /** + * ST_Overlaps cannot use indexed execution: overlapping is a DE-9IM predicate requiring + * geometries of the same dimension to share some but not all interior points. Bounding-box + * intersection (which the GeoHash index evaluates) is not a valid candidate superset for + * DE-9IM overlapping, so the index would produce incorrect results. + */ + @Override + public boolean allowsIndexedExecution(final FromClause target, final BinaryCompareOperator operator, final Object right, + final CommandContext context, final Expression[] oExpressions) { + return false; + } + @Override protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] params) { final Geometry jts1 = GeoUtils.parseJtsGeometry(geom1); diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Predicate.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Predicate.java index 9a547dccff..0affbe0f78 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Predicate.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Predicate.java @@ -52,6 +52,16 @@ protected SQLFunctionST_Predicate(final String name) { super(name); } + @Override + public int getMinArgs() { + return 2; + } + + @Override + public int getMaxArgs() { + return 2; + } + @Override public Object execute(final Object iThis, final Identifiable iCurrentRecord, final Object iCurrentResult, final Object[] iParams, final CommandContext iContext) { diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java index c2f52521c2..3d22d9da3a 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java @@ -18,6 +18,10 @@ */ package com.arcadedb.function.sql.geo; +import com.arcadedb.query.sql.executor.CommandContext; +import com.arcadedb.query.sql.parser.BinaryCompareOperator; +import com.arcadedb.query.sql.parser.Expression; +import com.arcadedb.query.sql.parser.FromClause; import org.locationtech.jts.geom.Geometry; import org.locationtech.spatial4j.shape.Shape; @@ -36,6 +40,18 @@ public SQLFunctionST_Touches() { super(NAME); } + /** + * ST_Touches cannot use indexed execution: touching is a DE-9IM predicate where geometries + * share boundary points but their interiors do not intersect. Bounding-box intersection + * (which the GeoHash index evaluates) is not a valid candidate superset for DE-9IM touching, + * so the index would produce incorrect results. + */ + @Override + public boolean allowsIndexedExecution(final FromClause target, final BinaryCompareOperator operator, final Object right, + final CommandContext context, final Expression[] oExpressions) { + return false; + } + @Override protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] params) { final Geometry jts1 = GeoUtils.parseJtsGeometry(geom1); diff --git a/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java b/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java index eea1f42112..9e7796a128 100644 --- a/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java +++ b/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java @@ -93,10 +93,23 @@ void stIntersectsWithIndex() { } /** - * Verifies ST_DWithin proximity query: only Rome is within ~4 degrees of the search point. + * Verifies ST_DWithin proximity query using the inline full-scan fallback path (the index + * exists on the type but ST_DWithin always disables indexed execution). + * + *

Search point: POINT (12.0, 41.5), distance threshold: 1.0 degree (great-circle degrees + * as computed by {@code SpatialContext.calcDistance()}). + * + *

Distances from the search point: + *

    + *
  • Rome (12.5, 41.9): sqrt(0.5^2 + 0.4^2) ≈ 0.64 degrees — within 1.0, included
  • + *
  • Naples (14.3, 40.8): sqrt(2.3^2 + 0.7^2) ≈ 2.40 degrees — outside 1.0, excluded
  • + *
  • Milan ( 9.2, 45.5): far away — outside 1.0, excluded
  • + *
+ * + *

Only Rome qualifies. */ @Test - void stDWithinWithIndex() { + void stDWithinFallbackWithExistingIndex() { database.command("sql", "CREATE DOCUMENT TYPE Location3"); database.command("sql", "CREATE PROPERTY Location3.name STRING"); database.command("sql", "CREATE PROPERTY Location3.coords STRING"); @@ -108,17 +121,16 @@ void stDWithinWithIndex() { database.command("sql", "INSERT INTO Location3 SET name = 'Naples', coords = 'POINT (14.3 40.8)'"); }); - // Search near Rome (12.5, 42.0) within 1.5 degrees — should find Rome and Naples but not Milan + // Search from POINT (12.0, 41.5) within 1.0 degree — only Rome qualifies final ResultSet result = database.query("sql", - "SELECT name FROM Location3 WHERE ST_DWithin(coords, 'POINT (12.5 42.0)', 2.0) = true"); + "SELECT name FROM Location3 WHERE ST_DWithin(coords, 'POINT (12.0 41.5)', 1.0) = true"); final List names = new ArrayList<>(); while (result.hasNext()) names.add(result.next().getProperty("name")); - assertThat(names).isNotEmpty(); - assertThat(names).contains("Rome"); - assertThat(names).doesNotContain("Milan"); + assertThat(names).hasSize(1); + assertThat(names).containsExactlyInAnyOrder("Rome"); } /** From 417295119962c68d7053b5d3fee5fb3324c774cf Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 22:23:15 +0100 Subject: [PATCH 15/47] chore(geo): add lucene-spatial-extras to ATTRIBUTIONS.md and fix Cypher point/distance functions - Add lucene-spatial-extras 10.3.2 attribution entry to ATTRIBUTIONS.md (Apache 2.0). The existing Apache Lucene notice in NOTICE already covers all Lucene modules. - Add CypherPointFunction implementing Cypher point(lat, lon) semantics: stores a LightweightPoint(x=lon, y=lat) so that ST_Distance haversine uses correct lat/lon axes. - Register 'point' and 'distance' as Cypher-specific geo functions in CypherFunctionFactory, fixing Issue3402Test, FunctionCachingTest, and Issue3407Test regressions introduced when legacy geo SQL functions (distance, point) were replaced with ST_* variants. Co-Authored-By: Claude Sonnet 4.6 --- ATTRIBUTIONS.md | 1 + .../function/geo/CypherPointFunction.java | 54 +++++++++++++++++++ .../executor/CypherFunctionFactory.java | 5 +- 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 engine/src/main/java/com/arcadedb/function/geo/CypherPointFunction.java diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md index 48de6fe9fc..d2a7849788 100644 --- a/ATTRIBUTIONS.md +++ b/ATTRIBUTIONS.md @@ -113,6 +113,7 @@ The following table lists runtime dependencies bundled with ArcadeDB distributio | org.apache.lucene | lucene-queries | 10.3.2 | Apache 2.0 | https://lucene.apache.org/ | | org.apache.lucene | lucene-sandbox | 10.3.2 | Apache 2.0 | https://lucene.apache.org/ | | org.apache.lucene | lucene-facet | 10.3.2 | Apache 2.0 | https://lucene.apache.org/ | +| org.apache.lucene | lucene-spatial-extras | 10.3.2 | Apache 2.0 | https://lucene.apache.org/ | **Apache Lucene Notice:** Lucene is a registered trademark of The Apache Software Foundation. See the NOTICE file for Lucene's own third-party attributions. diff --git a/engine/src/main/java/com/arcadedb/function/geo/CypherPointFunction.java b/engine/src/main/java/com/arcadedb/function/geo/CypherPointFunction.java new file mode 100644 index 0000000000..177d1213b5 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/geo/CypherPointFunction.java @@ -0,0 +1,54 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.geo; + +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; + +/** + * Cypher {@code point(lat, lon)} function. + * + *

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 ST_Distance} operate correctly.

+ * + *

Usage: {@code point(, )}

+ */ +public class CypherPointFunction implements StatelessFunction { + @Override + public String getName() { + return "point"; + } + + @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) + 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); + } +} diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherFunctionFactory.java b/engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherFunctionFactory.java index 93aea10d93..3e4f0684f0 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherFunctionFactory.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherFunctionFactory.java @@ -29,6 +29,7 @@ import com.arcadedb.function.agg.*; import com.arcadedb.function.misc.*; import com.arcadedb.function.geo.*; +import com.arcadedb.function.sql.geo.SQLFunctionST_Distance; import com.arcadedb.function.cypher.*; import com.arcadedb.function.math.*; import com.arcadedb.function.CypherFunctionRegistry; @@ -268,7 +269,7 @@ private boolean isCypherSpecificFunction(final String functionName) { // Vector norm function case "vector.norm" -> true; // Geo-spatial functions - case "point.withinbbox" -> true; + case "point", "distance", "point.withinbbox" -> true; // Temporal clock functions (realtime/statement/transaction are aliases for current instant) case "date.realtime", "date.statement", "date.transaction" -> true; case "localtime.realtime", "localtime.statement", "localtime.transaction" -> true; @@ -403,6 +404,8 @@ private StatelessFunction createCypherSpecificExecutor(final String functionName // Vector norm function case "vector.norm" -> new VectorNormFunction(); // Geo-spatial functions + case "point" -> new CypherPointFunction(); + case "distance" -> new SQLFunctionBridge(sqlFunctionFactory.getFunctionInstance(SQLFunctionST_Distance.NAME), "distance"); case "point.withinbbox" -> new PointWithinBBoxFunction(); // Temporal constructor functions case "date" -> new DateConstructorFunction(); From 5b43ff4efa46e9567df4cd527512148250a2a669 Mon Sep 17 00:00:00 2001 From: robfrank Date: Sun, 22 Feb 2026 22:27:07 +0100 Subject: [PATCH 16/47] test(geo): add transaction replay test for looksLikeGeoHashToken path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercises the commit-replay path where pre-tokenized GeoHash strings are re-passed to put() — verifying the heuristic correctly bypasses WKT parsing for tokens already in the Base-32 GeoHash alphabet. Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 3 ++- .../index/geospatial/LSMTreeGeoIndexTest.java | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9d34ca6c5f..5ba9f4b16a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -34,7 +34,8 @@ "Bash(tail:*)", "Bash(mvn:*)", "Bash(head:*)", - "Bash(python3:*)" + "Bash(python3:*)", + "Bash(git:*)" ] } } diff --git a/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java b/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java index 15bd162ac6..d3202b2c4f 100644 --- a/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java +++ b/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java @@ -107,6 +107,33 @@ void pointOutsideQueryReturnsNoResults() throws Exception { assertThat(cursor.hasNext()).isFalse(); } + /** + * Exercises the transaction-replay path: LSM-Tree calls put() a second time during commit with + * the already-tokenized GeoHash strings. The looksLikeGeoHashToken() heuristic must recognise + * them and pass them through to the underlying index unchanged, rather than trying to parse them + * as WKT (which would fail or lose data). + */ + @Test + void transactionReplayWithPreTokenizedGeohashStrings() throws Exception { + final LSMTreeGeoIndex idx = createAndRegisterIndex("test-geo-replay"); + + final RID rid = new RID(database, 1, 0); + + // Normal WKT put — this indexes the point and generates GeoHash tokens + database.transaction(() -> idx.put(new Object[]{"POINT (10.0 45.0)"}, new RID[]{rid})); + + // Simulate commit replay: put() called directly with a short GeoHash token (as the LSM-Tree + // TransactionIndexContext does on second-phase commit). The token must be accepted and stored. + final String geohashToken = "u0n"; // a valid GeoHash prefix in the Base-32 alphabet + database.transaction(() -> idx.put(new Object[]{geohashToken}, new RID[]{rid})); + + // The point should still be retrievable (both the original and the replayed tokens are present) + final Shape searchShape = GeoUtils.getSpatialContext() + .getShapeFactory().rect(5.0, 15.0, 40.0, 50.0); + final IndexCursor cursor = idx.get(new Object[]{searchShape}); + assertThat(cursor.hasNext()).isTrue(); + } + @Test void nullWktIsSkippedSilently() throws Exception { final LSMTreeGeoIndex idx = createAndRegisterIndex("test-geo3"); From b43fb314425fc72384215381d1898667f29ad140 Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 10:24:25 +0100 Subject: [PATCH 17/47] docs: add design doc for geo.* function rename Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-02-23-geo-rename-design.md | 79 ++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/plans/2026-02-23-geo-rename-design.md diff --git a/docs/plans/2026-02-23-geo-rename-design.md b/docs/plans/2026-02-23-geo-rename-design.md new file mode 100644 index 0000000000..25b18e1827 --- /dev/null +++ b/docs/plans/2026-02-23-geo-rename-design.md @@ -0,0 +1,79 @@ +# Design: Rename ST_* Geo Functions to geo.* Namespace + +**Date:** 2026-02-23 +**Branch:** lsmtree-geospatial +**Scope:** Pure rename — no behavior changes + +## Background + +The geospatial feature introduced 21 SQL functions using the PostGIS/OGC `ST_*` prefix convention. +The project maintainer requested alignment with ArcadeDB's established dot-namespace pattern +(e.g., `vector.neighbors`, `vector.cosineSimilarity`) by moving to a `geo.*` prefix instead. +No users are expected to be migrating PostGIS queries to ArcadeDB, so backward compatibility +aliases are not needed. + +## Function Name Mapping + +| Old SQL name | New SQL name | Old Java class | New Java class | +|--------------------|---------------------|------------------------------|-----------------------------| +| `ST_GeomFromText` | `geo.geomFromText` | `SQLFunctionST_GeomFromText` | `SQLFunctionGeoGeomFromText`| +| `ST_Point` | `geo.point` | `SQLFunctionST_Point` | `SQLFunctionGeoPoint` | +| `ST_LineString` | `geo.lineString` | `SQLFunctionST_LineString` | `SQLFunctionGeoLineString` | +| `ST_Polygon` | `geo.polygon` | `SQLFunctionST_Polygon` | `SQLFunctionGeoPolygon` | +| `ST_Buffer` | `geo.buffer` | `SQLFunctionST_Buffer` | `SQLFunctionGeoBuffer` | +| `ST_Envelope` | `geo.envelope` | `SQLFunctionST_Envelope` | `SQLFunctionGeoEnvelope` | +| `ST_Distance` | `geo.distance` | `SQLFunctionST_Distance` | `SQLFunctionGeoDistance` | +| `ST_Area` | `geo.area` | `SQLFunctionST_Area` | `SQLFunctionGeoArea` | +| `ST_AsText` | `geo.asText` | `SQLFunctionST_AsText` | `SQLFunctionGeoAsText` | +| `ST_AsGeoJson` | `geo.asGeoJson` | `SQLFunctionST_AsGeoJson` | `SQLFunctionGeoAsGeoJson` | +| `ST_X` | `geo.x` | `SQLFunctionST_X` | `SQLFunctionGeoX` | +| `ST_Y` | `geo.y` | `SQLFunctionST_Y` | `SQLFunctionGeoY` | +| `ST_Within` | `geo.within` | `SQLFunctionST_Within` | `SQLFunctionGeoWithin` | +| `ST_Intersects` | `geo.intersects` | `SQLFunctionST_Intersects` | `SQLFunctionGeoIntersects` | +| `ST_Contains` | `geo.contains` | `SQLFunctionST_Contains` | `SQLFunctionGeoContains` | +| `ST_DWithin` | `geo.dWithin` | `SQLFunctionST_DWithin` | `SQLFunctionGeoDWithin` | +| `ST_Disjoint` | `geo.disjoint` | `SQLFunctionST_Disjoint` | `SQLFunctionGeoDisjoint` | +| `ST_Equals` | `geo.equals` | `SQLFunctionST_Equals` | `SQLFunctionGeoEquals` | +| `ST_Crosses` | `geo.crosses` | `SQLFunctionST_Crosses` | `SQLFunctionGeoCrosses` | +| `ST_Overlaps` | `geo.overlaps` | `SQLFunctionST_Overlaps` | `SQLFunctionGeoOverlaps` | +| `ST_Touches` | `geo.touches` | `SQLFunctionST_Touches` | `SQLFunctionGeoTouches` | + +Base class: `SQLFunctionST_Predicate` → `SQLFunctionGeoPredicate` + +## Affected Files + +### Production code +- **21 files** in `engine/src/main/java/com/arcadedb/function/sql/geo/`: + - Rename each `.java` file + - Update `class` declaration and `NAME` constant + - Update any cross-references to other `NAME` constants (e.g., predicate subclasses) +- **`DefaultSQLFunctionFactory.java`**: update all imports and `register()` calls +- **`CypherFunctionFactory.java`**: update import from `SQLFunctionST_Distance` → `SQLFunctionGeoDistance`; + the `distance` bridge uses `SQLFunctionGeoDistance.NAME` so it auto-resolves to `"geo.distance"` + +### Test code +- **`SQLGeoFunctionsTest.java`**: ~72 occurrences of `ST_*` in SQL query strings → `geo.*` +- **`SQLGeoIndexedQueryTest.java`**: ~26 occurrences of `ST_*` in SQL query strings → `geo.*` + +### Docs +- **`docs/plans/2026-02-22-geospatial-design.md`**: update function name references +- **`docs/plans/2026-02-22-geospatial-implementation.md`**: update function name references + +## What Does NOT Change + +- Java package paths (`com.arcadedb.function.sql.geo`) — no package move +- Index type name (`GEOSPATIAL`) +- `GeoUtils`, `LightweightPoint`, `CypherPointFunction` — no rename needed +- All logic, behavior, and index integration + +## Verification + +Run after each file change: +``` +mvn test -pl engine -Dtest="SQLGeoFunctionsTest,SQLGeoIndexedQueryTest,LSMTreeGeoIndexTest,LSMTreeGeoIndexSchemaTest,GeoIndexMetadataTest" +``` + +Final full compile: +``` +mvn clean install -DskipTests -pl engine +``` From 3898664f851d1dc236c9a469360bfcbdada53e07 Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 10:26:25 +0100 Subject: [PATCH 18/47] docs: add geo.* rename implementation plan Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-02-23-geo-rename-plan.md | 403 +++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 docs/plans/2026-02-23-geo-rename-plan.md diff --git a/docs/plans/2026-02-23-geo-rename-plan.md b/docs/plans/2026-02-23-geo-rename-plan.md new file mode 100644 index 0000000000..b4c4223e52 --- /dev/null +++ b/docs/plans/2026-02-23-geo-rename-plan.md @@ -0,0 +1,403 @@ +# Geo Function Rename Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Rename all 21 `ST_*` SQL geo functions to the `geo.*` dot-namespace convention used by ArcadeDB's vector functions. + +**Architecture:** Pure rename — change `NAME` constants, class names, file names, and all usages in the factory and tests. No logic changes. The Java package `com.arcadedb.function.sql.geo` stays the same. The base predicate class `SQLFunctionST_Predicate` is renamed `SQLFunctionGeoPredicate` first so subsequent predicate subclasses can reference it. + +**Tech Stack:** Java 21, Maven, JUnit 5 / AssertJ + +--- + +### Task 1: Rename base class `SQLFunctionST_Predicate` → `SQLFunctionGeoPredicate` + +**Files:** +- Rename: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Predicate.java` + → `SQLFunctionGeoPredicate.java` + +**Step 1: Rename the file** + +```bash +cd engine/src/main/java/com/arcadedb/function/sql/geo +git mv SQLFunctionST_Predicate.java SQLFunctionGeoPredicate.java +``` + +**Step 2: Update class declaration** + +In `SQLFunctionGeoPredicate.java`, change: +```java +public abstract class SQLFunctionST_Predicate extends SQLFunctionAbstract implements IndexableSQLFunction { +``` +to: +```java +public abstract class SQLFunctionGeoPredicate extends SQLFunctionAbstract implements IndexableSQLFunction { +``` + +**Step 3: Compile to verify** + +```bash +cd /path/to/repo +mvn compile -pl engine -q +``` +Expected: BUILD SUCCESS (predicate subclasses will fail — fix in Task 3) + +**Step 4: Commit** + +```bash +git add engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java +git commit -m "refactor(geo): rename SQLFunctionST_Predicate to SQLFunctionGeoPredicate" +``` + +--- + +### Task 2: Rename the 12 constructor/accessor function classes + +These 12 classes extend `SQLFunctionAbstract` directly (not the predicate base). + +Rename mapping: + +| Old file | New file | Old NAME | New NAME | Old getSyntax prefix | +|---|---|---|---|---| +| `SQLFunctionST_GeomFromText.java` | `SQLFunctionGeoGeomFromText.java` | `ST_GeomFromText` | `geo.geomFromText` | `ST_GeomFromText(` | +| `SQLFunctionST_Point.java` | `SQLFunctionGeoPoint.java` | `ST_Point` | `geo.point` | `ST_Point(` | +| `SQLFunctionST_LineString.java` | `SQLFunctionGeoLineString.java` | `ST_LineString` | `geo.lineString` | `ST_LineString(` | +| `SQLFunctionST_Polygon.java` | `SQLFunctionGeoPolygon.java` | `ST_Polygon` | `geo.polygon` | `ST_Polygon(` | +| `SQLFunctionST_Buffer.java` | `SQLFunctionGeoBuffer.java` | `ST_Buffer` | `geo.buffer` | `ST_Buffer(` | +| `SQLFunctionST_Envelope.java` | `SQLFunctionGeoEnvelope.java` | `ST_Envelope` | `geo.envelope` | `ST_Envelope(` | +| `SQLFunctionST_Distance.java` | `SQLFunctionGeoDistance.java` | `ST_Distance` | `geo.distance` | `ST_Distance(` | +| `SQLFunctionST_Area.java` | `SQLFunctionGeoArea.java` | `ST_Area` | `geo.area` | `ST_Area(` | +| `SQLFunctionST_AsText.java` | `SQLFunctionGeoAsText.java` | `ST_AsText` | `geo.asText` | `ST_AsText(` | +| `SQLFunctionST_AsGeoJson.java` | `SQLFunctionGeoAsGeoJson.java` | `ST_AsGeoJson` | `geo.asGeoJson` | `ST_AsGeoJson(` | +| `SQLFunctionST_X.java` | `SQLFunctionGeoX.java` | `ST_X` | `geo.x` | `ST_X(` | +| `SQLFunctionST_Y.java` | `SQLFunctionGeoY.java` | `ST_Y` | `geo.y` | `ST_Y(` | + +**Step 1: Rename all 12 files** + +```bash +cd engine/src/main/java/com/arcadedb/function/sql/geo +git mv SQLFunctionST_GeomFromText.java SQLFunctionGeoGeomFromText.java +git mv SQLFunctionST_Point.java SQLFunctionGeoPoint.java +git mv SQLFunctionST_LineString.java SQLFunctionGeoLineString.java +git mv SQLFunctionST_Polygon.java SQLFunctionGeoPolygon.java +git mv SQLFunctionST_Buffer.java SQLFunctionGeoBuffer.java +git mv SQLFunctionST_Envelope.java SQLFunctionGeoEnvelope.java +git mv SQLFunctionST_Distance.java SQLFunctionGeoDistance.java +git mv SQLFunctionST_Area.java SQLFunctionGeoArea.java +git mv SQLFunctionST_AsText.java SQLFunctionGeoAsText.java +git mv SQLFunctionST_AsGeoJson.java SQLFunctionGeoAsGeoJson.java +git mv SQLFunctionST_X.java SQLFunctionGeoX.java +git mv SQLFunctionST_Y.java SQLFunctionGeoY.java +``` + +**Step 2: In each renamed file, apply three changes** + +For every file, the pattern is identical — shown here for `SQLFunctionGeoPoint.java` as the example: + +1. Class declaration: `SQLFunctionST_Point` → `SQLFunctionGeoPoint` +2. NAME constant: `"ST_Point"` → `"geo.point"` +3. getSyntax return: `"ST_Point(, )"` → `"geo.point(, )"` + +Apply the same three-change pattern to all 12 files per the mapping table above. + +> Note: `SQLFunctionGeoDistance.java` — also update `getSyntax()` and any Javadoc references to the old name. +> Note: `SQLFunctionGeoAsGeoJson.java` — the `NAME` constant is also referenced in `getSyntax()` only; no cross-references to other NAME constants. + +**Step 3: Compile** + +```bash +mvn compile -pl engine -q +``` +Expected: BUILD SUCCESS (factory and tests will fail at test-compile; that's fine) + +**Step 4: Commit** + +```bash +git add engine/src/main/java/com/arcadedb/function/sql/geo/ +git commit -m "refactor(geo): rename ST_* constructor/accessor classes to geo.* naming" +``` + +--- + +### Task 3: Rename the 9 predicate function classes + +These extend `SQLFunctionST_Predicate` (now `SQLFunctionGeoPredicate`). + +Rename mapping: + +| Old file | New file | Old NAME | New NAME | +|---|---|---|---| +| `SQLFunctionST_Within.java` | `SQLFunctionGeoWithin.java` | `ST_Within` | `geo.within` | +| `SQLFunctionST_Intersects.java` | `SQLFunctionGeoIntersects.java` | `ST_Intersects` | `geo.intersects` | +| `SQLFunctionST_Contains.java` | `SQLFunctionGeoContains.java` | `ST_Contains` | `geo.contains` | +| `SQLFunctionST_DWithin.java` | `SQLFunctionGeoDWithin.java` | `ST_DWithin` | `geo.dWithin` | +| `SQLFunctionST_Disjoint.java` | `SQLFunctionGeoDisjoint.java` | `ST_Disjoint` | `geo.disjoint` | +| `SQLFunctionST_Equals.java` | `SQLFunctionGeoEquals.java` | `ST_Equals` | `geo.equals` | +| `SQLFunctionST_Crosses.java` | `SQLFunctionGeoCrosses.java` | `ST_Crosses` | `geo.crosses` | +| `SQLFunctionST_Overlaps.java` | `SQLFunctionGeoOverlaps.java` | `ST_Overlaps` | `geo.overlaps` | +| `SQLFunctionST_Touches.java` | `SQLFunctionGeoTouches.java` | `ST_Touches` | `geo.touches` | + +**Step 1: Rename all 9 files** + +```bash +cd engine/src/main/java/com/arcadedb/function/sql/geo +git mv SQLFunctionST_Within.java SQLFunctionGeoWithin.java +git mv SQLFunctionST_Intersects.java SQLFunctionGeoIntersects.java +git mv SQLFunctionST_Contains.java SQLFunctionGeoContains.java +git mv SQLFunctionST_DWithin.java SQLFunctionGeoDWithin.java +git mv SQLFunctionST_Disjoint.java SQLFunctionGeoDisjoint.java +git mv SQLFunctionST_Equals.java SQLFunctionGeoEquals.java +git mv SQLFunctionST_Crosses.java SQLFunctionGeoCrosses.java +git mv SQLFunctionST_Overlaps.java SQLFunctionGeoOverlaps.java +git mv SQLFunctionST_Touches.java SQLFunctionGeoTouches.java +``` + +**Step 2: In each renamed file, apply three changes** + +For every file, same pattern — shown for `SQLFunctionGeoWithin.java`: + +1. Class declaration: `SQLFunctionST_Within extends SQLFunctionST_Predicate` + → `SQLFunctionGeoWithin extends SQLFunctionGeoPredicate` +2. Constructor call: `super(NAME)` stays as-is (no change needed here) +3. NAME constant: `"ST_Within"` → `"geo.within"` +4. getSyntax return: `"ST_Within(, )"` → `"geo.within(, )"` + +Apply the same pattern to all 9 files per the mapping table above. + +**Step 3: Compile** + +```bash +mvn compile -pl engine -q +``` +Expected: BUILD SUCCESS for main sources. Test compile will fail until Task 6. + +**Step 4: Commit** + +```bash +git add engine/src/main/java/com/arcadedb/function/sql/geo/ +git commit -m "refactor(geo): rename ST_* predicate classes to geo.* naming" +``` + +--- + +### Task 4: Update `DefaultSQLFunctionFactory.java` + +**Files:** +- Modify: `engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java` + +**Step 1: Replace all 21 imports** + +Find the block from line 32 to line 52 (all `import com.arcadedb.function.sql.geo.SQLFunctionST_*` lines). +Replace with: + +```java +import com.arcadedb.function.sql.geo.SQLFunctionGeoArea; +import com.arcadedb.function.sql.geo.SQLFunctionGeoAsGeoJson; +import com.arcadedb.function.sql.geo.SQLFunctionGeoAsText; +import com.arcadedb.function.sql.geo.SQLFunctionGeoBuffer; +import com.arcadedb.function.sql.geo.SQLFunctionGeoContains; +import com.arcadedb.function.sql.geo.SQLFunctionGeoCrosses; +import com.arcadedb.function.sql.geo.SQLFunctionGeoDisjoint; +import com.arcadedb.function.sql.geo.SQLFunctionGeoDistance; +import com.arcadedb.function.sql.geo.SQLFunctionGeoDWithin; +import com.arcadedb.function.sql.geo.SQLFunctionGeoEnvelope; +import com.arcadedb.function.sql.geo.SQLFunctionGeoEquals; +import com.arcadedb.function.sql.geo.SQLFunctionGeoGeomFromText; +import com.arcadedb.function.sql.geo.SQLFunctionGeoIntersects; +import com.arcadedb.function.sql.geo.SQLFunctionGeoLineString; +import com.arcadedb.function.sql.geo.SQLFunctionGeoOverlaps; +import com.arcadedb.function.sql.geo.SQLFunctionGeoPoint; +import com.arcadedb.function.sql.geo.SQLFunctionGeoPolygon; +import com.arcadedb.function.sql.geo.SQLFunctionGeoTouches; +import com.arcadedb.function.sql.geo.SQLFunctionGeoWithin; +import com.arcadedb.function.sql.geo.SQLFunctionGeoX; +import com.arcadedb.function.sql.geo.SQLFunctionGeoY; +``` + +**Step 2: Replace all 21 `register()` calls in the constructor** + +Find the "Geo" section (lines ~169–192) and replace entirely with: + +```java + // Geo — geo.* standard functions + register(SQLFunctionGeoGeomFromText.NAME, new SQLFunctionGeoGeomFromText()); + register(SQLFunctionGeoPoint.NAME, new SQLFunctionGeoPoint()); + register(SQLFunctionGeoLineString.NAME, new SQLFunctionGeoLineString()); + register(SQLFunctionGeoPolygon.NAME, new SQLFunctionGeoPolygon()); + register(SQLFunctionGeoBuffer.NAME, new SQLFunctionGeoBuffer()); + register(SQLFunctionGeoEnvelope.NAME, new SQLFunctionGeoEnvelope()); + register(SQLFunctionGeoDistance.NAME, new SQLFunctionGeoDistance()); + register(SQLFunctionGeoArea.NAME, new SQLFunctionGeoArea()); + register(SQLFunctionGeoAsText.NAME, new SQLFunctionGeoAsText()); + register(SQLFunctionGeoAsGeoJson.NAME, new SQLFunctionGeoAsGeoJson()); + register(SQLFunctionGeoX.NAME, new SQLFunctionGeoX()); + register(SQLFunctionGeoY.NAME, new SQLFunctionGeoY()); + + // Geo — geo.* spatial predicate functions (IndexableSQLFunction) + register(SQLFunctionGeoWithin.NAME, new SQLFunctionGeoWithin()); + register(SQLFunctionGeoIntersects.NAME, new SQLFunctionGeoIntersects()); + register(SQLFunctionGeoContains.NAME, new SQLFunctionGeoContains()); + register(SQLFunctionGeoDWithin.NAME, new SQLFunctionGeoDWithin()); + register(SQLFunctionGeoDisjoint.NAME, new SQLFunctionGeoDisjoint()); + register(SQLFunctionGeoEquals.NAME, new SQLFunctionGeoEquals()); + register(SQLFunctionGeoCrosses.NAME, new SQLFunctionGeoCrosses()); + register(SQLFunctionGeoOverlaps.NAME, new SQLFunctionGeoOverlaps()); + register(SQLFunctionGeoTouches.NAME, new SQLFunctionGeoTouches()); +``` + +**Step 3: Compile** + +```bash +mvn compile -pl engine -q +``` +Expected: BUILD SUCCESS + +**Step 4: Commit** + +```bash +git add engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java +git commit -m "refactor(geo): update DefaultSQLFunctionFactory for geo.* function names" +``` + +--- + +### Task 5: Update `CypherFunctionFactory.java` + +**Files:** +- Modify: `engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherFunctionFactory.java` + +**Step 1: Update the import** + +Find: +```java +import com.arcadedb.function.sql.geo.SQLFunctionST_Distance; +``` +Replace with: +```java +import com.arcadedb.function.sql.geo.SQLFunctionGeoDistance; +``` + +**Step 2: Update the bridge reference** + +Find (around line 408): +```java +case "distance" -> new SQLFunctionBridge(sqlFunctionFactory.getFunctionInstance(SQLFunctionST_Distance.NAME), "distance"); +``` +Replace with: +```java +case "distance" -> new SQLFunctionBridge(sqlFunctionFactory.getFunctionInstance(SQLFunctionGeoDistance.NAME), "distance"); +``` + +**Step 3: Compile** + +```bash +mvn compile -pl engine -q +``` +Expected: BUILD SUCCESS + +**Step 4: Commit** + +```bash +git add engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherFunctionFactory.java +git commit -m "refactor(geo): update CypherFunctionFactory bridge to geo.distance" +``` + +--- + +### Task 6: Update test SQL strings in both test files + +**Files:** +- Modify: `engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java` +- Modify: `engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java` + +**Step 1: Update `SQLGeoFunctionsTest.java`** + +This file has ~72 occurrences. Use a bulk find-and-replace for each SQL function name. +The rename pairs (old → new) to apply in the SQL strings: + +``` +"ST_GeomFromText(" → "geo.geomFromText(" +"ST_Point(" → "geo.point(" +"ST_LineString(" → "geo.lineString(" +"ST_Polygon(" → "geo.polygon(" +"ST_Buffer(" → "geo.buffer(" +"ST_Envelope(" → "geo.envelope(" +"ST_Distance(" → "geo.distance(" +"ST_Area(" → "geo.area(" +"ST_AsText(" → "geo.asText(" +"ST_AsGeoJson(" → "geo.asGeoJson(" +"ST_X(" → "geo.x(" +"ST_Y(" → "geo.y(" +"ST_Within(" → "geo.within(" +"ST_Intersects(" → "geo.intersects(" +"ST_Contains(" → "geo.contains(" +"ST_DWithin(" → "geo.dWithin(" +"ST_Disjoint(" → "geo.disjoint(" +"ST_Equals(" → "geo.equals(" +"ST_Crosses(" → "geo.crosses(" +"ST_Overlaps(" → "geo.overlaps(" +"ST_Touches(" → "geo.touches(" +``` + +> Important: only replace inside SQL string literals (inside `"..."` passed to `db.query` / `db.command`). Do NOT rename Java class references or test method names — those classes were already renamed in Tasks 1–3. + +**Step 2: Update `SQLGeoIndexedQueryTest.java`** + +Same substitution list (~26 occurrences, same SQL-string-only rule). + +**Step 3: Run the test suite** + +```bash +mvn test -pl engine -Dtest="SQLGeoFunctionsTest,SQLGeoIndexedQueryTest,LSMTreeGeoIndexTest,LSMTreeGeoIndexSchemaTest,GeoIndexMetadataTest" 2>&1 | tail -30 +``` +Expected: all tests GREEN + +**Step 4: Compile full engine** + +```bash +mvn clean install -DskipTests -pl engine -q +``` +Expected: BUILD SUCCESS + +**Step 5: Commit** + +```bash +git add engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java +git add engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java +git commit -m "test(geo): update SQL strings from ST_* to geo.* naming" +``` + +--- + +### Task 7: Update docs and final verification + +**Files:** +- Modify: `docs/plans/2026-02-22-geospatial-design.md` +- Modify: `docs/plans/2026-02-22-geospatial-implementation.md` + +**Step 1: Update doc references** + +In both doc files, apply the same SQL function rename pairs from Task 6 (just text substitution in docs — no code). + +**Step 2: Run full geo test suite one more time** + +```bash +mvn test -pl engine -Dtest="SQLGeoFunctionsTest,SQLGeoIndexedQueryTest,LSMTreeGeoIndexTest,LSMTreeGeoIndexSchemaTest,GeoIndexMetadataTest" 2>&1 | tail -20 +``` +Expected: all tests GREEN, no `ST_` references in any error output + +**Step 3: Verify no leftover ST_ references in production code** + +```bash +grep -r "ST_" engine/src/main/java/com/arcadedb/function/sql/geo/ \ + engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java \ + engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherFunctionFactory.java +``` +Expected: no output + +**Step 4: Commit** + +```bash +git add docs/plans/2026-02-22-geospatial-design.md docs/plans/2026-02-22-geospatial-implementation.md +git commit -m "docs: update geo function references from ST_* to geo.* naming" +``` From 7470007feb096ed1e5c42380561841d83d3bf5f0 Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 10:34:06 +0100 Subject: [PATCH 19/47] refactor(geo): rename SQLFunctionST_Predicate to SQLFunctionGeoPredicate Update all predicate subclass extends clauses to reference the new base class name. --- ...FunctionST_Predicate.java => SQLFunctionGeoPredicate.java} | 4 ++-- .../com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java | 2 +- .../com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java | 2 +- .../com/arcadedb/function/sql/geo/SQLFunctionST_DWithin.java | 2 +- .../com/arcadedb/function/sql/geo/SQLFunctionST_Disjoint.java | 2 +- .../com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java | 2 +- .../arcadedb/function/sql/geo/SQLFunctionST_Intersects.java | 2 +- .../com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java | 2 +- .../com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java | 2 +- .../com/arcadedb/function/sql/geo/SQLFunctionST_Within.java | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Predicate.java => SQLFunctionGeoPredicate.java} (98%) diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Predicate.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java similarity index 98% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Predicate.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java index 0affbe0f78..799276a2fb 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Predicate.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java @@ -46,9 +46,9 @@ * so that queries using these predicates automatically benefit from geospatial indexes. *

*/ -public abstract class SQLFunctionST_Predicate extends SQLFunctionAbstract implements IndexableSQLFunction { +public abstract class SQLFunctionGeoPredicate extends SQLFunctionAbstract implements IndexableSQLFunction { - protected SQLFunctionST_Predicate(final String name) { + protected SQLFunctionGeoPredicate(final String name) { super(name); } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java index 6a63435712..1a9e1f34a7 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java @@ -31,7 +31,7 @@ *

Usage: {@code ST_Contains(g, shape)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Contains extends SQLFunctionST_Predicate { +public class SQLFunctionST_Contains extends SQLFunctionGeoPredicate { public static final String NAME = "ST_Contains"; public SQLFunctionST_Contains() { diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java index 38823f154f..c283edb440 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java @@ -32,7 +32,7 @@ *

Usage: {@code ST_Crosses(g1, g2)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Crosses extends SQLFunctionST_Predicate { +public class SQLFunctionST_Crosses extends SQLFunctionGeoPredicate { public static final String NAME = "ST_Crosses"; public SQLFunctionST_Crosses() { diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_DWithin.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_DWithin.java index 888c956408..6d4b63298c 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_DWithin.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_DWithin.java @@ -31,7 +31,7 @@ *

Usage: {@code ST_DWithin(g, shape, distanceDegrees)}

*

Returns: Boolean

*/ -public class SQLFunctionST_DWithin extends SQLFunctionST_Predicate { +public class SQLFunctionST_DWithin extends SQLFunctionGeoPredicate { public static final String NAME = "ST_DWithin"; public SQLFunctionST_DWithin() { diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Disjoint.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Disjoint.java index d3fb81150b..3307c18128 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Disjoint.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Disjoint.java @@ -31,7 +31,7 @@ *

Usage: {@code ST_Disjoint(g1, g2)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Disjoint extends SQLFunctionST_Predicate { +public class SQLFunctionST_Disjoint extends SQLFunctionGeoPredicate { public static final String NAME = "ST_Disjoint"; public SQLFunctionST_Disjoint() { diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java index b9ca1eadb3..582db10d9c 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java @@ -32,7 +32,7 @@ *

Usage: {@code ST_Equals(g1, g2)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Equals extends SQLFunctionST_Predicate { +public class SQLFunctionST_Equals extends SQLFunctionGeoPredicate { public static final String NAME = "ST_Equals"; public SQLFunctionST_Equals() { diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Intersects.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Intersects.java index d248d93da4..85962053bc 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Intersects.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Intersects.java @@ -27,7 +27,7 @@ *

Usage: {@code ST_Intersects(g1, g2)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Intersects extends SQLFunctionST_Predicate { +public class SQLFunctionST_Intersects extends SQLFunctionGeoPredicate { public static final String NAME = "ST_Intersects"; public SQLFunctionST_Intersects() { diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java index ae455ed214..ac357a90ac 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java @@ -33,7 +33,7 @@ *

Usage: {@code ST_Overlaps(g1, g2)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Overlaps extends SQLFunctionST_Predicate { +public class SQLFunctionST_Overlaps extends SQLFunctionGeoPredicate { public static final String NAME = "ST_Overlaps"; public SQLFunctionST_Overlaps() { diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java index 3d22d9da3a..89e48607e0 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java @@ -33,7 +33,7 @@ *

Usage: {@code ST_Touches(g1, g2)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Touches extends SQLFunctionST_Predicate { +public class SQLFunctionST_Touches extends SQLFunctionGeoPredicate { public static final String NAME = "ST_Touches"; public SQLFunctionST_Touches() { diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Within.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Within.java index 3f0a7f0476..bdae4a88b3 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Within.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Within.java @@ -27,7 +27,7 @@ *

Usage: {@code ST_Within(g, shape)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Within extends SQLFunctionST_Predicate { +public class SQLFunctionST_Within extends SQLFunctionGeoPredicate { public static final String NAME = "ST_Within"; public SQLFunctionST_Within() { From ad1f1f3b1af0c5799aa72a4fc6e56faae244f3af Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 11:02:20 +0100 Subject: [PATCH 20/47] refactor(geo): rename ST_* constructor/accessor classes to geo.* naming Rename 12 geo function classes (GeomFromText, Point, LineString, Polygon, Buffer, Envelope, Distance, Area, AsText, AsGeoJson, X, Y) from ST_* prefix to geo.* namespace, updating class names, NAME constants, getSyntax() strings, Javadoc references, factory imports and registrations accordingly. Co-Authored-By: Claude Sonnet 4.6 --- .../sql/DefaultSQLFunctionFactory.java | 48 +++++++++---------- ...onST_Area.java => SQLFunctionGeoArea.java} | 12 ++--- ...Json.java => SQLFunctionGeoAsGeoJson.java} | 12 ++--- ..._AsText.java => SQLFunctionGeoAsText.java} | 12 ++--- ..._Buffer.java => SQLFunctionGeoBuffer.java} | 12 ++--- ...tance.java => SQLFunctionGeoDistance.java} | 12 ++--- ...elope.java => SQLFunctionGeoEnvelope.java} | 12 ++--- ...t.java => SQLFunctionGeoGeomFromText.java} | 12 ++--- ...ing.java => SQLFunctionGeoLineString.java} | 12 ++--- ...ST_Point.java => SQLFunctionGeoPoint.java} | 12 ++--- ...olygon.java => SQLFunctionGeoPolygon.java} | 12 ++--- ...FunctionST_X.java => SQLFunctionGeoX.java} | 12 ++--- ...FunctionST_Y.java => SQLFunctionGeoY.java} | 12 ++--- .../executor/CypherFunctionFactory.java | 4 +- 14 files changed, 98 insertions(+), 98 deletions(-) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Area.java => SQLFunctionGeoArea.java} (84%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_AsGeoJson.java => SQLFunctionGeoAsGeoJson.java} (93%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_AsText.java => SQLFunctionGeoAsText.java} (85%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Buffer.java => SQLFunctionGeoBuffer.java} (84%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Distance.java => SQLFunctionGeoDistance.java} (89%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Envelope.java => SQLFunctionGeoEnvelope.java} (87%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_GeomFromText.java => SQLFunctionGeoGeomFromText.java} (80%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_LineString.java => SQLFunctionGeoLineString.java} (86%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Point.java => SQLFunctionGeoPoint.java} (83%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Polygon.java => SQLFunctionGeoPolygon.java} (90%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_X.java => SQLFunctionGeoX.java} (86%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Y.java => SQLFunctionGeoY.java} (86%) diff --git a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java index 03ad963204..3181278a36 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java +++ b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java @@ -29,16 +29,16 @@ import com.arcadedb.function.sql.coll.SQLFunctionSet; import com.arcadedb.function.sql.coll.SQLFunctionSymmetricDifference; import com.arcadedb.function.sql.coll.SQLFunctionUnionAll; -import com.arcadedb.function.sql.geo.SQLFunctionST_Area; -import com.arcadedb.function.sql.geo.SQLFunctionST_AsGeoJson; -import com.arcadedb.function.sql.geo.SQLFunctionST_AsText; -import com.arcadedb.function.sql.geo.SQLFunctionST_Buffer; -import com.arcadedb.function.sql.geo.SQLFunctionST_Distance; -import com.arcadedb.function.sql.geo.SQLFunctionST_Envelope; -import com.arcadedb.function.sql.geo.SQLFunctionST_GeomFromText; -import com.arcadedb.function.sql.geo.SQLFunctionST_LineString; -import com.arcadedb.function.sql.geo.SQLFunctionST_Point; -import com.arcadedb.function.sql.geo.SQLFunctionST_Polygon; +import com.arcadedb.function.sql.geo.SQLFunctionGeoArea; +import com.arcadedb.function.sql.geo.SQLFunctionGeoAsGeoJson; +import com.arcadedb.function.sql.geo.SQLFunctionGeoAsText; +import com.arcadedb.function.sql.geo.SQLFunctionGeoBuffer; +import com.arcadedb.function.sql.geo.SQLFunctionGeoDistance; +import com.arcadedb.function.sql.geo.SQLFunctionGeoEnvelope; +import com.arcadedb.function.sql.geo.SQLFunctionGeoGeomFromText; +import com.arcadedb.function.sql.geo.SQLFunctionGeoLineString; +import com.arcadedb.function.sql.geo.SQLFunctionGeoPoint; +import com.arcadedb.function.sql.geo.SQLFunctionGeoPolygon; import com.arcadedb.function.sql.geo.SQLFunctionST_Contains; import com.arcadedb.function.sql.geo.SQLFunctionST_Crosses; import com.arcadedb.function.sql.geo.SQLFunctionST_Disjoint; @@ -48,8 +48,8 @@ import com.arcadedb.function.sql.geo.SQLFunctionST_Overlaps; import com.arcadedb.function.sql.geo.SQLFunctionST_Touches; import com.arcadedb.function.sql.geo.SQLFunctionST_Within; -import com.arcadedb.function.sql.geo.SQLFunctionST_X; -import com.arcadedb.function.sql.geo.SQLFunctionST_Y; +import com.arcadedb.function.sql.geo.SQLFunctionGeoX; +import com.arcadedb.function.sql.geo.SQLFunctionGeoY; import com.arcadedb.function.sql.graph.SQLFunctionAstar; import com.arcadedb.function.sql.graph.SQLFunctionBellmanFord; import com.arcadedb.function.sql.graph.SQLFunctionBoth; @@ -182,18 +182,18 @@ private DefaultSQLFunctionFactory() { register(SQLFunctionUnionAll.NAME, SQLFunctionUnionAll.class); // Geo — ST_* standard functions - register(SQLFunctionST_GeomFromText.NAME, new SQLFunctionST_GeomFromText()); - register(SQLFunctionST_Point.NAME, new SQLFunctionST_Point()); - register(SQLFunctionST_LineString.NAME, new SQLFunctionST_LineString()); - register(SQLFunctionST_Polygon.NAME, new SQLFunctionST_Polygon()); - register(SQLFunctionST_Buffer.NAME, new SQLFunctionST_Buffer()); - register(SQLFunctionST_Envelope.NAME, new SQLFunctionST_Envelope()); - register(SQLFunctionST_Distance.NAME, new SQLFunctionST_Distance()); - register(SQLFunctionST_Area.NAME, new SQLFunctionST_Area()); - register(SQLFunctionST_AsText.NAME, new SQLFunctionST_AsText()); - register(SQLFunctionST_AsGeoJson.NAME, new SQLFunctionST_AsGeoJson()); - register(SQLFunctionST_X.NAME, new SQLFunctionST_X()); - register(SQLFunctionST_Y.NAME, new SQLFunctionST_Y()); + register(SQLFunctionGeoGeomFromText.NAME, new SQLFunctionGeoGeomFromText()); + register(SQLFunctionGeoPoint.NAME, new SQLFunctionGeoPoint()); + register(SQLFunctionGeoLineString.NAME, new SQLFunctionGeoLineString()); + register(SQLFunctionGeoPolygon.NAME, new SQLFunctionGeoPolygon()); + register(SQLFunctionGeoBuffer.NAME, new SQLFunctionGeoBuffer()); + register(SQLFunctionGeoEnvelope.NAME, new SQLFunctionGeoEnvelope()); + register(SQLFunctionGeoDistance.NAME, new SQLFunctionGeoDistance()); + register(SQLFunctionGeoArea.NAME, new SQLFunctionGeoArea()); + register(SQLFunctionGeoAsText.NAME, new SQLFunctionGeoAsText()); + register(SQLFunctionGeoAsGeoJson.NAME, new SQLFunctionGeoAsGeoJson()); + register(SQLFunctionGeoX.NAME, new SQLFunctionGeoX()); + register(SQLFunctionGeoY.NAME, new SQLFunctionGeoY()); // Geo — ST_* spatial predicate functions (IndexableSQLFunction) register(SQLFunctionST_Within.NAME, new SQLFunctionST_Within()); diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Area.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoArea.java similarity index 84% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Area.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoArea.java index 5fc9b644b1..0476a0120e 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Area.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoArea.java @@ -25,15 +25,15 @@ import org.locationtech.spatial4j.shape.Shape; /** - * SQL function ST_Area: returns the area of a geometry in square degrees. + * SQL function geo.area: returns the area of a geometry in square degrees. * - *

Usage: {@code ST_Area()}

+ *

Usage: {@code geo.area()}

*

Returns: Double area value in square degrees

*/ -public class SQLFunctionST_Area extends SQLFunctionAbstract { - public static final String NAME = "ST_Area"; +public class SQLFunctionGeoArea extends SQLFunctionAbstract { + public static final String NAME = "geo.area"; - public SQLFunctionST_Area() { + public SQLFunctionGeoArea() { super(NAME); } @@ -53,6 +53,6 @@ public Object execute(final Object iThis, final Identifiable iCurrentRecord, fin @Override public String getSyntax() { - return "ST_Area()"; + return "geo.area()"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsGeoJson.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoAsGeoJson.java similarity index 93% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsGeoJson.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoAsGeoJson.java index c1961c00a6..ead5355894 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsGeoJson.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoAsGeoJson.java @@ -33,16 +33,16 @@ import org.locationtech.jts.geom.Polygon; /** - * SQL function ST_AsGeoJson: returns the GeoJSON representation of a geometry. + * SQL function geo.asGeoJson: returns the GeoJSON representation of a geometry. * Uses JTS for geometry parsing and manual serialization via JSONObject/JSONArray. * - *

Usage: {@code ST_AsGeoJson()}

+ *

Usage: {@code geo.asGeoJson()}

*

Returns: GeoJSON string

*/ -public class SQLFunctionST_AsGeoJson extends SQLFunctionAbstract { - public static final String NAME = "ST_AsGeoJson"; +public class SQLFunctionGeoAsGeoJson extends SQLFunctionAbstract { + public static final String NAME = "geo.asGeoJson"; - public SQLFunctionST_AsGeoJson() { + public SQLFunctionGeoAsGeoJson() { super(NAME); } @@ -129,6 +129,6 @@ private JSONArray polygonToArray(final Polygon polygon) { @Override public String getSyntax() { - return "ST_AsGeoJson()"; + return "geo.asGeoJson()"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsText.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoAsText.java similarity index 85% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsText.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoAsText.java index dde4918c81..185958a25e 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsText.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoAsText.java @@ -24,17 +24,17 @@ import org.locationtech.spatial4j.shape.Shape; /** - * SQL function ST_AsText: returns the WKT representation of a geometry. + * SQL function geo.asText: returns the WKT representation of a geometry. * If the input is already a WKT string, it is returned as-is. * If the input is a Shape object, it is converted to WKT. * - *

Usage: {@code ST_AsText()}

+ *

Usage: {@code geo.asText()}

*

Returns: WKT string

*/ -public class SQLFunctionST_AsText extends SQLFunctionAbstract { - public static final String NAME = "ST_AsText"; +public class SQLFunctionGeoAsText extends SQLFunctionAbstract { + public static final String NAME = "geo.asText"; - public SQLFunctionST_AsText() { + public SQLFunctionGeoAsText() { super(NAME); } @@ -58,6 +58,6 @@ public Object execute(final Object iThis, final Identifiable iCurrentRecord, fin @Override public String getSyntax() { - return "ST_AsText()"; + return "geo.asText()"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Buffer.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoBuffer.java similarity index 84% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Buffer.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoBuffer.java index 62cb8dc0f1..f664990f16 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Buffer.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoBuffer.java @@ -24,16 +24,16 @@ import org.locationtech.jts.geom.Geometry; /** - * SQL function ST_Buffer: returns a WKT string of the buffered geometry. + * SQL function geo.buffer: returns a WKT string of the buffered geometry. * Uses JTS Geometry.buffer(distance) for the computation. * - *

Usage: {@code ST_Buffer(, )}

+ *

Usage: {@code geo.buffer(, )}

*

Returns: WKT string of the buffered shape

*/ -public class SQLFunctionST_Buffer extends SQLFunctionAbstract { - public static final String NAME = "ST_Buffer"; +public class SQLFunctionGeoBuffer extends SQLFunctionAbstract { + public static final String NAME = "geo.buffer"; - public SQLFunctionST_Buffer() { + public SQLFunctionGeoBuffer() { super(NAME); } @@ -54,6 +54,6 @@ public Object execute(final Object iThis, final Identifiable iCurrentRecord, fin @Override public String getSyntax() { - return "ST_Buffer(, )"; + return "geo.buffer(, )"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Distance.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDistance.java similarity index 89% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Distance.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDistance.java index fc3fd80e03..40bfca0cea 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Distance.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDistance.java @@ -25,19 +25,19 @@ import org.locationtech.spatial4j.shape.Shape; /** - * SQL function ST_Distance: computes the Haversine distance between two points. + * SQL function geo.distance: computes the Haversine distance between two points. * Points may be WKT strings or Spatial4j Shape/Point objects. * - *

Usage: {@code ST_Distance(, [, ])}

+ *

Usage: {@code geo.distance(, [, ])}

*

Unit: "m" (default), "km", "mi", "nmi"

*

Returns: Double distance value

*/ -public class SQLFunctionST_Distance extends SQLFunctionAbstract { - public static final String NAME = "ST_Distance"; +public class SQLFunctionGeoDistance extends SQLFunctionAbstract { + public static final String NAME = "geo.distance"; private static final double EARTH_RADIUS_KM = 6371.0; - public SQLFunctionST_Distance() { + public SQLFunctionGeoDistance() { super(NAME); } @@ -93,6 +93,6 @@ private double[] extractPointCoords(final Object param) { @Override public String getSyntax() { - return "ST_Distance(, [, ])"; + return "geo.distance(, [, ])"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Envelope.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoEnvelope.java similarity index 87% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Envelope.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoEnvelope.java index 811f06cd8b..bfa5aaf118 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Envelope.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoEnvelope.java @@ -25,15 +25,15 @@ import org.locationtech.spatial4j.shape.Shape; /** - * SQL function ST_Envelope: returns the WKT bounding box polygon of a geometry. + * SQL function geo.envelope: returns the WKT bounding box polygon of a geometry. * - *

Usage: {@code ST_Envelope()}

+ *

Usage: {@code geo.envelope()}

*

Returns: WKT {@code "POLYGON ((minX minY, maxX minY, maxX maxY, minX maxY, minX minY))"}

*/ -public class SQLFunctionST_Envelope extends SQLFunctionAbstract { - public static final String NAME = "ST_Envelope"; +public class SQLFunctionGeoEnvelope extends SQLFunctionAbstract { + public static final String NAME = "geo.envelope"; - public SQLFunctionST_Envelope() { + public SQLFunctionGeoEnvelope() { super(NAME); } @@ -64,6 +64,6 @@ public Object execute(final Object iThis, final Identifiable iCurrentRecord, fin @Override public String getSyntax() { - return "ST_Envelope()"; + return "geo.envelope()"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_GeomFromText.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoGeomFromText.java similarity index 80% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_GeomFromText.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoGeomFromText.java index 4c9bedda83..8f428f2246 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_GeomFromText.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoGeomFromText.java @@ -23,14 +23,14 @@ import com.arcadedb.query.sql.executor.CommandContext; /** - * SQL function ST_GeomFromText: parses a WKT string and returns a Shape object. + * SQL function geo.geomFromText: parses a WKT string and returns a Shape object. * - *

Usage: {@code ST_GeomFromText()}

+ *

Usage: {@code geo.geomFromText()}

*/ -public class SQLFunctionST_GeomFromText extends SQLFunctionAbstract { - public static final String NAME = "ST_GeomFromText"; +public class SQLFunctionGeoGeomFromText extends SQLFunctionAbstract { + public static final String NAME = "geo.geomFromText"; - public SQLFunctionST_GeomFromText() { + public SQLFunctionGeoGeomFromText() { super(NAME); } @@ -44,6 +44,6 @@ public Object execute(final Object iThis, final Identifiable iCurrentRecord, fin @Override public String getSyntax() { - return "ST_GeomFromText()"; + return "geo.geomFromText()"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_LineString.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoLineString.java similarity index 86% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_LineString.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoLineString.java index 5ea14fe45c..9834c6f7d8 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_LineString.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoLineString.java @@ -26,15 +26,15 @@ import java.util.List; /** - * SQL function ST_LineString: constructs a WKT LINESTRING string from a list of coordinate pairs. + * SQL function geo.lineString: constructs a WKT LINESTRING string from a list of coordinate pairs. * - *

Usage: {@code ST_LineString([[x1,y1],[x2,y2],...])}

+ *

Usage: {@code geo.lineString([[x1,y1],[x2,y2],...])}

*

Returns: WKT string {@code "LINESTRING (x1 y1, x2 y2, ...)"}

*/ -public class SQLFunctionST_LineString extends SQLFunctionAbstract { - public static final String NAME = "ST_LineString"; +public class SQLFunctionGeoLineString extends SQLFunctionAbstract { + public static final String NAME = "geo.lineString"; - public SQLFunctionST_LineString() { + public SQLFunctionGeoLineString() { super(NAME); } @@ -73,6 +73,6 @@ private void appendCoord(final StringBuilder sb, final Object point) { @Override public String getSyntax() { - return "ST_LineString([[x1,y1],[x2,y2],...])"; + return "geo.lineString([[x1,y1],[x2,y2],...])"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Point.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPoint.java similarity index 83% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Point.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPoint.java index 6fc7660fb7..59bf04ea22 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Point.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPoint.java @@ -23,15 +23,15 @@ import com.arcadedb.query.sql.executor.CommandContext; /** - * SQL function ST_Point: constructs a WKT POINT string from X (longitude) and Y (latitude). + * SQL function geo.point: constructs a WKT POINT string from X (longitude) and Y (latitude). * - *

Usage: {@code ST_Point(, )}

+ *

Usage: {@code geo.point(, )}

*

Returns: WKT string {@code "POINT (x y)"}

*/ -public class SQLFunctionST_Point extends SQLFunctionAbstract { - public static final String NAME = "ST_Point"; +public class SQLFunctionGeoPoint extends SQLFunctionAbstract { + public static final String NAME = "geo.point"; - public SQLFunctionST_Point() { + public SQLFunctionGeoPoint() { super(NAME); } @@ -47,6 +47,6 @@ public Object execute(final Object iThis, final Identifiable iCurrentRecord, fin @Override public String getSyntax() { - return "ST_Point(, )"; + return "geo.point(, )"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Polygon.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPolygon.java similarity index 90% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Polygon.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPolygon.java index 51a2124c7a..55976dd6a4 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Polygon.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPolygon.java @@ -26,16 +26,16 @@ import java.util.List; /** - * SQL function ST_Polygon: constructs a WKT POLYGON string from a list of coordinate pairs. + * SQL function geo.polygon: constructs a WKT POLYGON string from a list of coordinate pairs. * The ring is automatically closed if the first and last points differ. * - *

Usage: {@code ST_Polygon([[x1,y1],[x2,y2],...])}

+ *

Usage: {@code geo.polygon([[x1,y1],[x2,y2],...])}

*

Returns: WKT string {@code "POLYGON ((x1 y1, x2 y2, ..., x1 y1))"}

*/ -public class SQLFunctionST_Polygon extends SQLFunctionAbstract { - public static final String NAME = "ST_Polygon"; +public class SQLFunctionGeoPolygon extends SQLFunctionAbstract { + public static final String NAME = "geo.polygon"; - public SQLFunctionST_Polygon() { + public SQLFunctionGeoPolygon() { super(NAME); } @@ -97,6 +97,6 @@ private boolean coordsEqual(final Object a, final Object b) { @Override public String getSyntax() { - return "ST_Polygon([[x1,y1],[x2,y2],...])"; + return "geo.polygon([[x1,y1],[x2,y2],...])"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_X.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoX.java similarity index 86% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_X.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoX.java index f2b3302369..38b4701e61 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_X.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoX.java @@ -25,15 +25,15 @@ import org.locationtech.spatial4j.shape.Shape; /** - * SQL function ST_X: returns the X (longitude) coordinate of a point geometry. + * SQL function geo.x: returns the X (longitude) coordinate of a point geometry. * - *

Usage: {@code ST_X()}

+ *

Usage: {@code geo.x()}

*

Returns: Double X coordinate, or null if input is not a point

*/ -public class SQLFunctionST_X extends SQLFunctionAbstract { - public static final String NAME = "ST_X"; +public class SQLFunctionGeoX extends SQLFunctionAbstract { + public static final String NAME = "geo.x"; - public SQLFunctionST_X() { + public SQLFunctionGeoX() { super(NAME); } @@ -62,6 +62,6 @@ public Object execute(final Object iThis, final Identifiable iCurrentRecord, fin @Override public String getSyntax() { - return "ST_X()"; + return "geo.x()"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Y.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoY.java similarity index 86% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Y.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoY.java index 6e6f49b5e8..9156c9bce4 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Y.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoY.java @@ -25,15 +25,15 @@ import org.locationtech.spatial4j.shape.Shape; /** - * SQL function ST_Y: returns the Y (latitude) coordinate of a point geometry. + * SQL function geo.y: returns the Y (latitude) coordinate of a point geometry. * - *

Usage: {@code ST_Y()}

+ *

Usage: {@code geo.y()}

*

Returns: Double Y coordinate, or null if input is not a point

*/ -public class SQLFunctionST_Y extends SQLFunctionAbstract { - public static final String NAME = "ST_Y"; +public class SQLFunctionGeoY extends SQLFunctionAbstract { + public static final String NAME = "geo.y"; - public SQLFunctionST_Y() { + public SQLFunctionGeoY() { super(NAME); } @@ -62,6 +62,6 @@ public Object execute(final Object iThis, final Identifiable iCurrentRecord, fin @Override public String getSyntax() { - return "ST_Y()"; + return "geo.y()"; } } diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherFunctionFactory.java b/engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherFunctionFactory.java index 3e4f0684f0..22c67d8be6 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherFunctionFactory.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/executor/CypherFunctionFactory.java @@ -29,7 +29,7 @@ import com.arcadedb.function.agg.*; import com.arcadedb.function.misc.*; import com.arcadedb.function.geo.*; -import com.arcadedb.function.sql.geo.SQLFunctionST_Distance; +import com.arcadedb.function.sql.geo.SQLFunctionGeoDistance; import com.arcadedb.function.cypher.*; import com.arcadedb.function.math.*; import com.arcadedb.function.CypherFunctionRegistry; @@ -405,7 +405,7 @@ private StatelessFunction createCypherSpecificExecutor(final String functionName case "vector.norm" -> new VectorNormFunction(); // Geo-spatial functions case "point" -> new CypherPointFunction(); - case "distance" -> new SQLFunctionBridge(sqlFunctionFactory.getFunctionInstance(SQLFunctionST_Distance.NAME), "distance"); + case "distance" -> new SQLFunctionBridge(sqlFunctionFactory.getFunctionInstance(SQLFunctionGeoDistance.NAME), "distance"); case "point.withinbbox" -> new PointWithinBBoxFunction(); // Temporal constructor functions case "date" -> new DateConstructorFunction(); From 5ffefeacd34e619bd2c20ad32fd89a6d8cd19c6c Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 11:12:57 +0100 Subject: [PATCH 21/47] refactor(geo): fix stale ST_* comments and import ordering in factory Co-Authored-By: Claude Sonnet 4.6 --- .../com/arcadedb/function/geo/CypherPointFunction.java | 2 +- .../arcadedb/function/sql/DefaultSQLFunctionFactory.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/engine/src/main/java/com/arcadedb/function/geo/CypherPointFunction.java b/engine/src/main/java/com/arcadedb/function/geo/CypherPointFunction.java index 177d1213b5..8e727710f8 100644 --- a/engine/src/main/java/com/arcadedb/function/geo/CypherPointFunction.java +++ b/engine/src/main/java/com/arcadedb/function/geo/CypherPointFunction.java @@ -30,7 +30,7 @@ *

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 ST_Distance} operate correctly.

+ * such as {@code geo.distance} operate correctly.

* *

Usage: {@code point(, )}

*/ diff --git a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java index 3181278a36..2fac40e885 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java +++ b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java @@ -39,6 +39,8 @@ import com.arcadedb.function.sql.geo.SQLFunctionGeoLineString; import com.arcadedb.function.sql.geo.SQLFunctionGeoPoint; import com.arcadedb.function.sql.geo.SQLFunctionGeoPolygon; +import com.arcadedb.function.sql.geo.SQLFunctionGeoX; +import com.arcadedb.function.sql.geo.SQLFunctionGeoY; import com.arcadedb.function.sql.geo.SQLFunctionST_Contains; import com.arcadedb.function.sql.geo.SQLFunctionST_Crosses; import com.arcadedb.function.sql.geo.SQLFunctionST_Disjoint; @@ -48,8 +50,6 @@ import com.arcadedb.function.sql.geo.SQLFunctionST_Overlaps; import com.arcadedb.function.sql.geo.SQLFunctionST_Touches; import com.arcadedb.function.sql.geo.SQLFunctionST_Within; -import com.arcadedb.function.sql.geo.SQLFunctionGeoX; -import com.arcadedb.function.sql.geo.SQLFunctionGeoY; import com.arcadedb.function.sql.graph.SQLFunctionAstar; import com.arcadedb.function.sql.graph.SQLFunctionBellmanFord; import com.arcadedb.function.sql.graph.SQLFunctionBoth; @@ -181,7 +181,7 @@ private DefaultSQLFunctionFactory() { register(SQLFunctionSymmetricDifference.NAME, SQLFunctionSymmetricDifference.class); register(SQLFunctionUnionAll.NAME, SQLFunctionUnionAll.class); - // Geo — ST_* standard functions + // Geo — geo.* constructor/accessor functions register(SQLFunctionGeoGeomFromText.NAME, new SQLFunctionGeoGeomFromText()); register(SQLFunctionGeoPoint.NAME, new SQLFunctionGeoPoint()); register(SQLFunctionGeoLineString.NAME, new SQLFunctionGeoLineString()); @@ -195,7 +195,7 @@ private DefaultSQLFunctionFactory() { register(SQLFunctionGeoX.NAME, new SQLFunctionGeoX()); register(SQLFunctionGeoY.NAME, new SQLFunctionGeoY()); - // Geo — ST_* spatial predicate functions (IndexableSQLFunction) + // Geo — geo.* spatial predicate functions (IndexableSQLFunction) register(SQLFunctionST_Within.NAME, new SQLFunctionST_Within()); register(SQLFunctionST_Intersects.NAME, new SQLFunctionST_Intersects()); register(SQLFunctionST_Contains.NAME, new SQLFunctionST_Contains()); From f7dbf297c396d72cde92cebee160a70d31bf9476 Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 11:30:52 +0100 Subject: [PATCH 22/47] refactor(geo): rename ST_* predicate classes to geo.* naming Rename the 9 spatial predicate function classes from SQLFunctionST_* to SQLFunctionGeo* and update their NAME constants from ST_Xxx to geo.xxx, matching the geo.* naming convention established in Task 2. Update DefaultSQLFunctionFactory imports and register calls accordingly. Co-Authored-By: Claude Sonnet 4.6 --- .../sql/DefaultSQLFunctionFactory.java | 36 +++++++++---------- ...tains.java => SQLFunctionGeoContains.java} | 14 ++++---- ...rosses.java => SQLFunctionGeoCrosses.java} | 14 ++++---- ...Within.java => SQLFunctionGeoDWithin.java} | 14 ++++---- ...joint.java => SQLFunctionGeoDisjoint.java} | 14 ++++---- ..._Equals.java => SQLFunctionGeoEquals.java} | 14 ++++---- ...cts.java => SQLFunctionGeoIntersects.java} | 12 +++---- ...rlaps.java => SQLFunctionGeoOverlaps.java} | 14 ++++---- ...ouches.java => SQLFunctionGeoTouches.java} | 14 ++++---- ..._Within.java => SQLFunctionGeoWithin.java} | 12 +++---- 10 files changed, 79 insertions(+), 79 deletions(-) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Contains.java => SQLFunctionGeoContains.java} (81%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Crosses.java => SQLFunctionGeoCrosses.java} (83%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_DWithin.java => SQLFunctionGeoDWithin.java} (83%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Disjoint.java => SQLFunctionGeoDisjoint.java} (81%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Equals.java => SQLFunctionGeoEquals.java} (82%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Intersects.java => SQLFunctionGeoIntersects.java} (77%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Overlaps.java => SQLFunctionGeoOverlaps.java} (82%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Touches.java => SQLFunctionGeoTouches.java} (82%) rename engine/src/main/java/com/arcadedb/function/sql/geo/{SQLFunctionST_Within.java => SQLFunctionGeoWithin.java} (78%) diff --git a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java index 2fac40e885..0648552294 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java +++ b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java @@ -41,15 +41,15 @@ import com.arcadedb.function.sql.geo.SQLFunctionGeoPolygon; import com.arcadedb.function.sql.geo.SQLFunctionGeoX; import com.arcadedb.function.sql.geo.SQLFunctionGeoY; -import com.arcadedb.function.sql.geo.SQLFunctionST_Contains; -import com.arcadedb.function.sql.geo.SQLFunctionST_Crosses; -import com.arcadedb.function.sql.geo.SQLFunctionST_Disjoint; -import com.arcadedb.function.sql.geo.SQLFunctionST_DWithin; -import com.arcadedb.function.sql.geo.SQLFunctionST_Equals; -import com.arcadedb.function.sql.geo.SQLFunctionST_Intersects; -import com.arcadedb.function.sql.geo.SQLFunctionST_Overlaps; -import com.arcadedb.function.sql.geo.SQLFunctionST_Touches; -import com.arcadedb.function.sql.geo.SQLFunctionST_Within; +import com.arcadedb.function.sql.geo.SQLFunctionGeoContains; +import com.arcadedb.function.sql.geo.SQLFunctionGeoCrosses; +import com.arcadedb.function.sql.geo.SQLFunctionGeoDisjoint; +import com.arcadedb.function.sql.geo.SQLFunctionGeoDWithin; +import com.arcadedb.function.sql.geo.SQLFunctionGeoEquals; +import com.arcadedb.function.sql.geo.SQLFunctionGeoIntersects; +import com.arcadedb.function.sql.geo.SQLFunctionGeoOverlaps; +import com.arcadedb.function.sql.geo.SQLFunctionGeoTouches; +import com.arcadedb.function.sql.geo.SQLFunctionGeoWithin; import com.arcadedb.function.sql.graph.SQLFunctionAstar; import com.arcadedb.function.sql.graph.SQLFunctionBellmanFord; import com.arcadedb.function.sql.graph.SQLFunctionBoth; @@ -196,15 +196,15 @@ private DefaultSQLFunctionFactory() { register(SQLFunctionGeoY.NAME, new SQLFunctionGeoY()); // Geo — geo.* spatial predicate functions (IndexableSQLFunction) - register(SQLFunctionST_Within.NAME, new SQLFunctionST_Within()); - register(SQLFunctionST_Intersects.NAME, new SQLFunctionST_Intersects()); - register(SQLFunctionST_Contains.NAME, new SQLFunctionST_Contains()); - register(SQLFunctionST_DWithin.NAME, new SQLFunctionST_DWithin()); - register(SQLFunctionST_Disjoint.NAME, new SQLFunctionST_Disjoint()); - register(SQLFunctionST_Equals.NAME, new SQLFunctionST_Equals()); - register(SQLFunctionST_Crosses.NAME, new SQLFunctionST_Crosses()); - register(SQLFunctionST_Overlaps.NAME, new SQLFunctionST_Overlaps()); - register(SQLFunctionST_Touches.NAME, new SQLFunctionST_Touches()); + register(SQLFunctionGeoWithin.NAME, new SQLFunctionGeoWithin()); + register(SQLFunctionGeoIntersects.NAME, new SQLFunctionGeoIntersects()); + register(SQLFunctionGeoContains.NAME, new SQLFunctionGeoContains()); + register(SQLFunctionGeoDWithin.NAME, new SQLFunctionGeoDWithin()); + register(SQLFunctionGeoDisjoint.NAME, new SQLFunctionGeoDisjoint()); + register(SQLFunctionGeoEquals.NAME, new SQLFunctionGeoEquals()); + register(SQLFunctionGeoCrosses.NAME, new SQLFunctionGeoCrosses()); + register(SQLFunctionGeoOverlaps.NAME, new SQLFunctionGeoOverlaps()); + register(SQLFunctionGeoTouches.NAME, new SQLFunctionGeoTouches()); // Graph register(SQLFunctionAstar.NAME, SQLFunctionAstar.class); diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoContains.java similarity index 81% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoContains.java index 1a9e1f34a7..78de1ed0a0 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoContains.java @@ -26,20 +26,20 @@ import org.locationtech.spatial4j.shape.SpatialRelation; /** - * SQL function ST_Contains: returns true if geometry g fully contains shape. + * SQL function geo.contains: returns true if geometry g fully contains shape. * - *

Usage: {@code ST_Contains(g, shape)}

+ *

Usage: {@code geo.contains(g, shape)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Contains extends SQLFunctionGeoPredicate { - public static final String NAME = "ST_Contains"; +public class SQLFunctionGeoContains extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.contains"; - public SQLFunctionST_Contains() { + public SQLFunctionGeoContains() { super(NAME); } /** - * ST_Contains cannot use indexed execution: the stored geometry is the container and the query + * geo.contains cannot use indexed execution: the stored geometry is the container and the query * argument is the containee. The GeoHash index is built on the stored shape, but containment * queries run in the opposite direction — the index cannot serve as a valid candidate superset * for containment queries from that reversed direction. @@ -57,6 +57,6 @@ protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] @Override public String getSyntax() { - return "ST_Contains(, )"; + return "geo.contains(, )"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoCrosses.java similarity index 83% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoCrosses.java index c283edb440..056a9a8b1b 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoCrosses.java @@ -26,21 +26,21 @@ import org.locationtech.spatial4j.shape.Shape; /** - * SQL function ST_Crosses: returns true if the two geometries cross each other. + * SQL function geo.crosses: returns true if the two geometries cross each other. * Uses JTS for this DE-9IM predicate (not natively supported by Spatial4j). * - *

Usage: {@code ST_Crosses(g1, g2)}

+ *

Usage: {@code geo.crosses(g1, g2)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Crosses extends SQLFunctionGeoPredicate { - public static final String NAME = "ST_Crosses"; +public class SQLFunctionGeoCrosses extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.crosses"; - public SQLFunctionST_Crosses() { + public SQLFunctionGeoCrosses() { super(NAME); } /** - * ST_Crosses cannot use indexed execution: crossing is a DE-9IM predicate that requires + * geo.crosses cannot use indexed execution: crossing is a DE-9IM predicate that requires * the geometries to share some — but not all — interior points. Bounding-box intersection * (which the GeoHash index evaluates) is not a valid candidate superset for DE-9IM crossing, * so the index would produce incorrect results. @@ -62,6 +62,6 @@ protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] @Override public String getSyntax() { - return "ST_Crosses(, )"; + return "geo.crosses(, )"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_DWithin.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDWithin.java similarity index 83% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_DWithin.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDWithin.java index 6d4b63298c..3dd4fb87c4 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_DWithin.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDWithin.java @@ -25,16 +25,16 @@ import org.locationtech.spatial4j.shape.Shape; /** - * SQL function ST_DWithin: returns true if geometry g is within the given distance of shape. + * SQL function geo.dWithin: returns true if geometry g is within the given distance of shape. * Distance is specified in degrees (consistent with Spatial4j's coordinate system). * - *

Usage: {@code ST_DWithin(g, shape, distanceDegrees)}

+ *

Usage: {@code geo.dWithin(g, shape, distanceDegrees)}

*

Returns: Boolean

*/ -public class SQLFunctionST_DWithin extends SQLFunctionGeoPredicate { - public static final String NAME = "ST_DWithin"; +public class SQLFunctionGeoDWithin extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.dWithin"; - public SQLFunctionST_DWithin() { + public SQLFunctionGeoDWithin() { super(NAME); } @@ -49,7 +49,7 @@ public int getMaxArgs() { } /** - * ST_DWithin uses a radius distance check against the centers of the two geometries. + * geo.dWithin uses a radius distance check against the centers of the two geometries. * The geospatial index returns records based on geohash intersection, which does not * directly correspond to the distance radius. To guarantee correctness, indexed * execution is disabled and the predicate is evaluated inline on all records. @@ -72,6 +72,6 @@ protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] @Override public String getSyntax() { - return "ST_DWithin(, , )"; + return "geo.dWithin(, , )"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Disjoint.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDisjoint.java similarity index 81% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Disjoint.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDisjoint.java index 3307c18128..b32b34ff04 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Disjoint.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDisjoint.java @@ -26,20 +26,20 @@ import org.locationtech.spatial4j.shape.SpatialRelation; /** - * SQL function ST_Disjoint: returns true if the two geometries share no points. + * SQL function geo.disjoint: returns true if the two geometries share no points. * - *

Usage: {@code ST_Disjoint(g1, g2)}

+ *

Usage: {@code geo.disjoint(g1, g2)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Disjoint extends SQLFunctionGeoPredicate { - public static final String NAME = "ST_Disjoint"; +public class SQLFunctionGeoDisjoint extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.disjoint"; - public SQLFunctionST_Disjoint() { + public SQLFunctionGeoDisjoint() { super(NAME); } /** - * ST_Disjoint cannot use indexed execution: the index returns records that intersect + * geo.disjoint cannot use indexed execution: the index returns records that intersect * the search shape, but disjoint records are precisely those NOT in the intersection result. * Using the index would miss all disjoint records, so we always fall back to full scan. */ @@ -56,6 +56,6 @@ protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] @Override public String getSyntax() { - return "ST_Disjoint(, )"; + return "geo.disjoint(, )"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoEquals.java similarity index 82% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoEquals.java index 582db10d9c..59b146a851 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoEquals.java @@ -26,21 +26,21 @@ import org.locationtech.spatial4j.shape.Shape; /** - * SQL function ST_Equals: returns true if the two geometries are geometrically equal. + * SQL function geo.equals: returns true if the two geometries are geometrically equal. * Uses JTS geometric equality (structural equivalence after normalisation). * - *

Usage: {@code ST_Equals(g1, g2)}

+ *

Usage: {@code geo.equals(g1, g2)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Equals extends SQLFunctionGeoPredicate { - public static final String NAME = "ST_Equals"; +public class SQLFunctionGeoEquals extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.equals"; - public SQLFunctionST_Equals() { + public SQLFunctionGeoEquals() { super(NAME); } /** - * ST_Equals cannot use indexed execution: geometric equality requires an exact coordinate match. + * geo.equals cannot use indexed execution: geometric equality requires an exact coordinate match. * The GeoHash index returns all records whose bounding box intersects the search shape, which is * a much coarser superset than exact equality — the index cannot guarantee correctness here. */ @@ -61,6 +61,6 @@ protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] @Override public String getSyntax() { - return "ST_Equals(, )"; + return "geo.equals(, )"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Intersects.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoIntersects.java similarity index 77% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Intersects.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoIntersects.java index 85962053bc..fc885208f8 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Intersects.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoIntersects.java @@ -22,15 +22,15 @@ import org.locationtech.spatial4j.shape.SpatialRelation; /** - * SQL function ST_Intersects: returns true if the two geometries share any point. + * SQL function geo.intersects: returns true if the two geometries share any point. * - *

Usage: {@code ST_Intersects(g1, g2)}

+ *

Usage: {@code geo.intersects(g1, g2)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Intersects extends SQLFunctionGeoPredicate { - public static final String NAME = "ST_Intersects"; +public class SQLFunctionGeoIntersects extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.intersects"; - public SQLFunctionST_Intersects() { + public SQLFunctionGeoIntersects() { super(NAME); } @@ -41,6 +41,6 @@ protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] @Override public String getSyntax() { - return "ST_Intersects(, )"; + return "geo.intersects(, )"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoOverlaps.java similarity index 82% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoOverlaps.java index ac357a90ac..825928c5b6 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoOverlaps.java @@ -26,22 +26,22 @@ import org.locationtech.spatial4j.shape.Shape; /** - * SQL function ST_Overlaps: returns true if the two geometries overlap (share some but not all points, + * SQL function geo.overlaps: returns true if the two geometries overlap (share some but not all points, * and have the same dimension). * Uses JTS for this DE-9IM predicate. * - *

Usage: {@code ST_Overlaps(g1, g2)}

+ *

Usage: {@code geo.overlaps(g1, g2)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Overlaps extends SQLFunctionGeoPredicate { - public static final String NAME = "ST_Overlaps"; +public class SQLFunctionGeoOverlaps extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.overlaps"; - public SQLFunctionST_Overlaps() { + public SQLFunctionGeoOverlaps() { super(NAME); } /** - * ST_Overlaps cannot use indexed execution: overlapping is a DE-9IM predicate requiring + * geo.overlaps cannot use indexed execution: overlapping is a DE-9IM predicate requiring * geometries of the same dimension to share some but not all interior points. Bounding-box * intersection (which the GeoHash index evaluates) is not a valid candidate superset for * DE-9IM overlapping, so the index would produce incorrect results. @@ -63,6 +63,6 @@ protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] @Override public String getSyntax() { - return "ST_Overlaps(, )"; + return "geo.overlaps(, )"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoTouches.java similarity index 82% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoTouches.java index 89e48607e0..b633cad379 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoTouches.java @@ -26,22 +26,22 @@ import org.locationtech.spatial4j.shape.Shape; /** - * SQL function ST_Touches: returns true if the geometries have at least one point in common + * SQL function geo.touches: returns true if the geometries have at least one point in common * but their interiors do not intersect. * Uses JTS for this DE-9IM predicate. * - *

Usage: {@code ST_Touches(g1, g2)}

+ *

Usage: {@code geo.touches(g1, g2)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Touches extends SQLFunctionGeoPredicate { - public static final String NAME = "ST_Touches"; +public class SQLFunctionGeoTouches extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.touches"; - public SQLFunctionST_Touches() { + public SQLFunctionGeoTouches() { super(NAME); } /** - * ST_Touches cannot use indexed execution: touching is a DE-9IM predicate where geometries + * geo.touches cannot use indexed execution: touching is a DE-9IM predicate where geometries * share boundary points but their interiors do not intersect. Bounding-box intersection * (which the GeoHash index evaluates) is not a valid candidate superset for DE-9IM touching, * so the index would produce incorrect results. @@ -63,6 +63,6 @@ protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] @Override public String getSyntax() { - return "ST_Touches(, )"; + return "geo.touches(, )"; } } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Within.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoWithin.java similarity index 78% rename from engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Within.java rename to engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoWithin.java index bdae4a88b3..65eb54f648 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Within.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoWithin.java @@ -22,15 +22,15 @@ import org.locationtech.spatial4j.shape.SpatialRelation; /** - * SQL function ST_Within: returns true if geometry g is fully within shape. + * SQL function geo.within: returns true if geometry g is fully within shape. * - *

Usage: {@code ST_Within(g, shape)}

+ *

Usage: {@code geo.within(g, shape)}

*

Returns: Boolean

*/ -public class SQLFunctionST_Within extends SQLFunctionGeoPredicate { - public static final String NAME = "ST_Within"; +public class SQLFunctionGeoWithin extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.within"; - public SQLFunctionST_Within() { + public SQLFunctionGeoWithin() { super(NAME); } @@ -41,6 +41,6 @@ protected Boolean evaluate(final Shape geom1, final Shape geom2, final Object[] @Override public String getSyntax() { - return "ST_Within(, )"; + return "geo.within(, )"; } } From 95f5a8e679fa242a8632188ac6a04482bb5a0cae Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 11:34:28 +0100 Subject: [PATCH 23/47] refactor(geo): fix stale ST_* comments in GeoPredicate and sort register() calls Co-Authored-By: Claude Sonnet 4.6 --- .../arcadedb/function/sql/DefaultSQLFunctionFactory.java | 8 ++++---- .../function/sql/geo/SQLFunctionGeoPredicate.java | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java index 0648552294..27790416f0 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java +++ b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java @@ -196,15 +196,15 @@ private DefaultSQLFunctionFactory() { register(SQLFunctionGeoY.NAME, new SQLFunctionGeoY()); // Geo — geo.* spatial predicate functions (IndexableSQLFunction) - register(SQLFunctionGeoWithin.NAME, new SQLFunctionGeoWithin()); - register(SQLFunctionGeoIntersects.NAME, new SQLFunctionGeoIntersects()); register(SQLFunctionGeoContains.NAME, new SQLFunctionGeoContains()); - register(SQLFunctionGeoDWithin.NAME, new SQLFunctionGeoDWithin()); + register(SQLFunctionGeoCrosses.NAME, new SQLFunctionGeoCrosses()); register(SQLFunctionGeoDisjoint.NAME, new SQLFunctionGeoDisjoint()); + register(SQLFunctionGeoDWithin.NAME, new SQLFunctionGeoDWithin()); register(SQLFunctionGeoEquals.NAME, new SQLFunctionGeoEquals()); - register(SQLFunctionGeoCrosses.NAME, new SQLFunctionGeoCrosses()); + register(SQLFunctionGeoIntersects.NAME, new SQLFunctionGeoIntersects()); register(SQLFunctionGeoOverlaps.NAME, new SQLFunctionGeoOverlaps()); register(SQLFunctionGeoTouches.NAME, new SQLFunctionGeoTouches()); + register(SQLFunctionGeoWithin.NAME, new SQLFunctionGeoWithin()); // Graph register(SQLFunctionAstar.NAME, SQLFunctionAstar.class); diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java index 799276a2fb..d34d1f65d0 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java @@ -38,7 +38,7 @@ import java.util.List; /** - * Abstract base for ST_* spatial predicate functions that implement both + * Abstract base for geo.* spatial predicate functions that implement both * SQLFunctionAbstract and IndexableSQLFunction. *

* Subclasses provide the exact spatial predicate evaluation via {@link #evaluate(Shape, Shape, Object[])}. @@ -226,7 +226,7 @@ private static String extractTypeName(final FromClause target) { /** * Resolves the search shape from the function expressions. The second argument (index 1) - * is the shape to search against. It may be a literal WKT string or a nested ST_* function call. + * is the shape to search against. It may be a literal WKT string or a nested geo.* function call. */ private static Shape resolveSearchShape(final Expression[] oExpressions, final CommandContext context) { if (oExpressions.length < 2 || oExpressions[1] == null) From 410689c0e2186c4b6bd2748850aa4a564ece3ac0 Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 11:48:32 +0100 Subject: [PATCH 24/47] test(geo): update SQL strings from ST_* to geo.* naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace all ST_* function calls in SQL string literals in SQLGeoFunctionsTest and SQLGeoIndexedQueryTest with the new geo.* namespace-qualified names (e.g. ST_GeomFromText → geo.geomFromText, ST_Within → geo.within, ST_Contains → geo.contains, etc.). - Extend the SQL grammar (SQLParser.g4) to support namespace-qualified function calls: identifier DOT identifier LPAREN ... (e.g. geo.point(x,y)). functionCall now accepts an optional 'namespace DOT' prefix before the function name. - Add CONTAINS to the identifier rule so that geo.contains(...) parses correctly (CONTAINS was a reserved keyword not usable as an identifier). - Update SQLASTBuilder.visitFunctionCall to combine the two identifiers into a single dot-separated function name when the qualified form is used. Co-Authored-By: Claude Sonnet 4.6 --- .../arcadedb/query/sql/grammar/SQLParser.g4 | 6 +- .../query/sql/antlr/SQLASTBuilder.java | 15 ++- .../function/sql/geo/SQLGeoFunctionsTest.java | 120 +++++++++--------- .../geospatial/SQLGeoIndexedQueryTest.java | 24 ++-- 4 files changed, 88 insertions(+), 77 deletions(-) diff --git a/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 b/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 index cbd09ca13c..37651ae811 100644 --- a/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 +++ b/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 @@ -1169,8 +1169,8 @@ baseExpression | INTEGER_RANGE # integerRange | ELLIPSIS_INTEGER_RANGE # ellipsisIntegerRange | THIS # thisLiteral - | identifier (DOT identifier)* methodCall* arraySelector* modifier* # identifierChain | functionCall # functionCallExpr + | identifier (DOT identifier)* methodCall* arraySelector* modifier* # identifierChain | inputParameter modifier* # inputParam | LPAREN statement RPAREN modifier* # parenthesizedStmt | LPAREN expression RPAREN modifier* # parenthesizedExpr @@ -1213,9 +1213,10 @@ extendedCaseAlternative * Supports array selectors: someFunc()[0] * Supports modifiers: someFunc().asString() * Supports nested projections: list({x:1}):{x} (processed before methodCall/arraySelector/modifier) + * Supports namespace-qualified calls: geo.point(x, y) — namespace DOT functionName LPAREN... */ functionCall - : identifier LPAREN (STAR | expression (COMMA expression)*)? RPAREN nestedProjection* methodCall* arraySelector* modifier* + : identifier (DOT identifier)? LPAREN (STAR | expression (COMMA expression)*)? RPAREN nestedProjection* methodCall* arraySelector* modifier* ; /** @@ -1475,4 +1476,5 @@ identifier | IDENTIFIED | SYSTEM | UNIDIRECTIONAL + | CONTAINS ; diff --git a/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java b/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java index c9b896a236..1fe8c456c5 100644 --- a/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java +++ b/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java @@ -3015,15 +3015,24 @@ public BaseExpression visitArrayLit(final SQLParser.ArrayLitContext ctx) { /** * Function call visitor - parses function name and parameters. - * Grammar: identifier LPAREN (STAR | expression (COMMA expression)*)? RPAREN + * Grammar: identifier (DOT identifier)? LPAREN (STAR | expression (COMMA expression)*)? RPAREN + * Supports simple calls (count(*)) and namespace-qualified calls (geo.point(x,y)). */ @Override public FunctionCall visitFunctionCall(final SQLParser.FunctionCallContext ctx) { final FunctionCall funcCall = new FunctionCall(-1); try { - // Function name (using reflection for protected field) - final Identifier funcName = (Identifier) visit(ctx.identifier()); + // Build the function name: either "name" or "namespace.name" + final Identifier funcName; + if (ctx.identifier().size() == 2) { + // Namespace-qualified: geo.point → combine as single Identifier "geo.point" + final Identifier ns = (Identifier) visit(ctx.identifier(0)); + final Identifier fn = (Identifier) visit(ctx.identifier(1)); + funcName = new Identifier(ns.getStringValue() + "." + fn.getStringValue()); + } else { + funcName = (Identifier) visit(ctx.identifier(0)); + } funcCall.name = funcName; // Parameters (using reflection for protected field) diff --git a/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java index c9869fbaed..34aa5cfbc5 100644 --- a/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java @@ -134,7 +134,7 @@ void geoManualIndexBoundingBoxes() throws Exception { void stGeomFromText() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { // Valid WKT point - ResultSet result = db.query("sql", "select ST_GeomFromText('POINT (10 20)') as geom"); + ResultSet result = db.query("sql", "select geo.geomFromText('POINT (10 20)') as geom"); assertThat(result.hasNext()).isTrue(); final Object geom = result.next().getProperty("geom"); assertThat(geom).isNotNull(); @@ -145,7 +145,7 @@ void stGeomFromText() throws Exception { @Test void stGeomFromTextNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_GeomFromText(null) as geom"); + ResultSet result = db.query("sql", "select geo.geomFromText(null) as geom"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("geom"); assertThat(val).isNull(); @@ -155,7 +155,7 @@ void stGeomFromTextNull() throws Exception { @Test void stPoint() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_Point(10, 20) as wkt"); + ResultSet result = db.query("sql", "select geo.point(10, 20) as wkt"); assertThat(result.hasNext()).isTrue(); final String wkt = result.next().getProperty("wkt"); assertThat(wkt).isNotNull(); @@ -168,7 +168,7 @@ void stPoint() throws Exception { @Test void stPointNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_Point(null, 20) as wkt"); + ResultSet result = db.query("sql", "select geo.point(null, 20) as wkt"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("wkt"); assertThat(val).isNull(); @@ -178,7 +178,7 @@ void stPointNull() throws Exception { @Test void stLineString() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_LineString([[0,0],[10,10],[20,0]]) as wkt"); + ResultSet result = db.query("sql", "select geo.lineString([[0,0],[10,10],[20,0]]) as wkt"); assertThat(result.hasNext()).isTrue(); final String wkt = result.next().getProperty("wkt"); assertThat(wkt).isNotNull(); @@ -192,7 +192,7 @@ void stLineString() throws Exception { @Test void stLineStringNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_LineString(null) as wkt"); + ResultSet result = db.query("sql", "select geo.lineString(null) as wkt"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("wkt"); assertThat(val).isNull(); @@ -203,7 +203,7 @@ void stLineStringNull() throws Exception { void stPolygon() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { // Closed ring - ResultSet result = db.query("sql", "select ST_Polygon([[0,0],[10,0],[10,10],[0,10],[0,0]]) as wkt"); + ResultSet result = db.query("sql", "select geo.polygon([[0,0],[10,0],[10,10],[0,10],[0,0]]) as wkt"); assertThat(result.hasNext()).isTrue(); final String wkt = result.next().getProperty("wkt"); assertThat(wkt).isNotNull(); @@ -218,7 +218,7 @@ void stPolygon() throws Exception { void stPolygonAutoClose() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { // Open ring — should be auto-closed - ResultSet result = db.query("sql", "select ST_Polygon([[0,0],[10,0],[10,10],[0,10]]) as wkt"); + ResultSet result = db.query("sql", "select geo.polygon([[0,0],[10,0],[10,10],[0,10]]) as wkt"); assertThat(result.hasNext()).isTrue(); final String wkt = result.next().getProperty("wkt"); assertThat(wkt).isNotNull(); @@ -233,7 +233,7 @@ void stPolygonAutoClose() throws Exception { @Test void stPolygonNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_Polygon(null) as wkt"); + ResultSet result = db.query("sql", "select geo.polygon(null) as wkt"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("wkt"); assertThat(val).isNull(); @@ -243,7 +243,7 @@ void stPolygonNull() throws Exception { @Test void stBuffer() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_Buffer('POINT (10 20)', 1.0) as wkt"); + ResultSet result = db.query("sql", "select geo.buffer('POINT (10 20)', 1.0) as wkt"); assertThat(result.hasNext()).isTrue(); final String wkt = result.next().getProperty("wkt"); assertThat(wkt).isNotNull(); @@ -254,7 +254,7 @@ void stBuffer() throws Exception { @Test void stBufferNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_Buffer(null, 1.0) as wkt"); + ResultSet result = db.query("sql", "select geo.buffer(null, 1.0) as wkt"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("wkt"); assertThat(val).isNull(); @@ -265,7 +265,7 @@ void stBufferNull() throws Exception { void stEnvelope() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { ResultSet result = db.query("sql", - "select ST_Envelope('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as wkt"); + "select geo.envelope('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as wkt"); assertThat(result.hasNext()).isTrue(); final String wkt = result.next().getProperty("wkt"); assertThat(wkt).isNotNull(); @@ -278,7 +278,7 @@ void stEnvelope() throws Exception { @Test void stEnvelopeNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_Envelope(null) as wkt"); + ResultSet result = db.query("sql", "select geo.envelope(null) as wkt"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("wkt"); assertThat(val).isNull(); @@ -289,14 +289,14 @@ void stEnvelopeNull() throws Exception { void stDistance() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { // Distance between two points in meters (default unit) - ResultSet result = db.query("sql", "select ST_Distance('POINT (0 0)', 'POINT (1 0)') as dist"); + ResultSet result = db.query("sql", "select geo.distance('POINT (0 0)', 'POINT (1 0)') as dist"); assertThat(result.hasNext()).isTrue(); final Double dist = result.next().getProperty("dist"); assertThat(dist).isNotNull(); assertThat(dist).isGreaterThan(0.0); // Distance in km - result = db.query("sql", "select ST_Distance('POINT (0 0)', 'POINT (1 0)', 'km') as dist"); + result = db.query("sql", "select geo.distance('POINT (0 0)', 'POINT (1 0)', 'km') as dist"); assertThat(result.hasNext()).isTrue(); final Double distKm = result.next().getProperty("dist"); assertThat(distKm).isNotNull(); @@ -309,7 +309,7 @@ void stDistance() throws Exception { @Test void stDistanceNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_Distance(null, 'POINT (1 0)') as dist"); + ResultSet result = db.query("sql", "select geo.distance(null, 'POINT (1 0)') as dist"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("dist"); assertThat(val).isNull(); @@ -320,7 +320,7 @@ void stDistanceNull() throws Exception { void stArea() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { ResultSet result = db.query("sql", - "select ST_Area('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as area"); + "select geo.area('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as area"); assertThat(result.hasNext()).isTrue(); final Double area = result.next().getProperty("area"); assertThat(area).isNotNull(); @@ -331,7 +331,7 @@ void stArea() throws Exception { @Test void stAreaNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_Area(null) as area"); + ResultSet result = db.query("sql", "select geo.area(null) as area"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("area"); assertThat(val).isNull(); @@ -342,7 +342,7 @@ void stAreaNull() throws Exception { void stAsText() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { // String input → returned as-is - ResultSet result = db.query("sql", "select ST_AsText('POINT (10 20)') as wkt"); + ResultSet result = db.query("sql", "select geo.asText('POINT (10 20)') as wkt"); assertThat(result.hasNext()).isTrue(); final String wkt = result.next().getProperty("wkt"); assertThat(wkt).isEqualTo("POINT (10 20)"); @@ -353,7 +353,7 @@ void stAsText() throws Exception { void stAsTextFromShape() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { // Shape → WKT - ResultSet result = db.query("sql", "select ST_AsText(ST_GeomFromText('POINT (10 20)')) as wkt"); + ResultSet result = db.query("sql", "select geo.asText(geo.geomFromText('POINT (10 20)')) as wkt"); assertThat(result.hasNext()).isTrue(); final String wkt = result.next().getProperty("wkt"); assertThat(wkt).isNotNull(); @@ -366,7 +366,7 @@ void stAsTextFromShape() throws Exception { @Test void stAsTextNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_AsText(null) as wkt"); + ResultSet result = db.query("sql", "select geo.asText(null) as wkt"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("wkt"); assertThat(val).isNull(); @@ -376,7 +376,7 @@ void stAsTextNull() throws Exception { @Test void stAsGeoJson() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_AsGeoJson('POINT (10 20)') as json"); + ResultSet result = db.query("sql", "select geo.asGeoJson('POINT (10 20)') as json"); assertThat(result.hasNext()).isTrue(); final String json = result.next().getProperty("json"); assertThat(json).isNotNull(); @@ -391,7 +391,7 @@ void stAsGeoJson() throws Exception { void stAsGeoJsonPolygon() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { ResultSet result = db.query("sql", - "select ST_AsGeoJson('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as json"); + "select geo.asGeoJson('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as json"); assertThat(result.hasNext()).isTrue(); final String json = result.next().getProperty("json"); assertThat(json).isNotNull(); @@ -403,7 +403,7 @@ void stAsGeoJsonPolygon() throws Exception { @Test void stAsGeoJsonNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_AsGeoJson(null) as json"); + ResultSet result = db.query("sql", "select geo.asGeoJson(null) as json"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("json"); assertThat(val).isNull(); @@ -413,7 +413,7 @@ void stAsGeoJsonNull() throws Exception { @Test void stX() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_X('POINT (10 20)') as x"); + ResultSet result = db.query("sql", "select geo.x('POINT (10 20)') as x"); assertThat(result.hasNext()).isTrue(); final Double x = result.next().getProperty("x"); assertThat(x).isNotNull(); @@ -424,7 +424,7 @@ void stX() throws Exception { @Test void stXFromShape() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_X(ST_GeomFromText('POINT (10 20)')) as x"); + ResultSet result = db.query("sql", "select geo.x(geo.geomFromText('POINT (10 20)')) as x"); assertThat(result.hasNext()).isTrue(); final Double x = result.next().getProperty("x"); assertThat(x).isNotNull(); @@ -437,7 +437,7 @@ void stXNonPoint() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { // ST_X on a polygon should return null ResultSet result = db.query("sql", - "select ST_X('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as x"); + "select geo.x('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as x"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("x"); assertThat(val).isNull(); @@ -447,7 +447,7 @@ void stXNonPoint() throws Exception { @Test void stXNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_X(null) as x"); + ResultSet result = db.query("sql", "select geo.x(null) as x"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("x"); assertThat(val).isNull(); @@ -457,7 +457,7 @@ void stXNull() throws Exception { @Test void stY() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_Y('POINT (10 20)') as y"); + ResultSet result = db.query("sql", "select geo.y('POINT (10 20)') as y"); assertThat(result.hasNext()).isTrue(); final Double y = result.next().getProperty("y"); assertThat(y).isNotNull(); @@ -468,7 +468,7 @@ void stY() throws Exception { @Test void stYFromShape() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_Y(ST_GeomFromText('POINT (10 20)')) as y"); + ResultSet result = db.query("sql", "select geo.y(geo.geomFromText('POINT (10 20)')) as y"); assertThat(result.hasNext()).isTrue(); final Double y = result.next().getProperty("y"); assertThat(y).isNotNull(); @@ -480,7 +480,7 @@ void stYFromShape() throws Exception { void stYNonPoint() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { ResultSet result = db.query("sql", - "select ST_Y('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as y"); + "select geo.y('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as y"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("y"); assertThat(val).isNull(); @@ -490,7 +490,7 @@ void stYNonPoint() throws Exception { @Test void stYNull() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select ST_Y(null) as y"); + ResultSet result = db.query("sql", "select geo.y(null) as y"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("y"); assertThat(val).isNull(); @@ -502,13 +502,13 @@ void stPointRoundTrip() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { // ST_Point → ST_X / ST_Y round-trip // Note: small floating-point precision loss is expected when going through WKT parsing - ResultSet result = db.query("sql", "select ST_X(ST_GeomFromText(ST_Point(42.5, -7.3))) as x"); + ResultSet result = db.query("sql", "select geo.x(geo.geomFromText(geo.point(42.5, -7.3))) as x"); assertThat(result.hasNext()).isTrue(); final Double x = result.next().getProperty("x"); assertThat(x).isNotNull(); assertThat(x).isCloseTo(42.5, org.assertj.core.data.Offset.offset(1e-6)); - result = db.query("sql", "select ST_Y(ST_GeomFromText(ST_Point(42.5, -7.3))) as y"); + result = db.query("sql", "select geo.y(geo.geomFromText(geo.point(42.5, -7.3))) as y"); assertThat(result.hasNext()).isTrue(); final Double y = result.next().getProperty("y"); assertThat(y).isNotNull(); @@ -522,7 +522,7 @@ void stPointRoundTrip() throws Exception { void stWithinPointInsidePolygon() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Within('POINT (5 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + "select geo.within('POINT (5 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isTrue(); }); @@ -532,7 +532,7 @@ void stWithinPointInsidePolygon() throws Exception { void stWithinPointOutsidePolygon() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Within('POINT (15 15)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + "select geo.within('POINT (15 15)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isFalse(); }); @@ -542,7 +542,7 @@ void stWithinPointOutsidePolygon() throws Exception { void stWithinNullArg() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Within(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + "select geo.within(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("v"); assertThat(val).isNull(); @@ -555,7 +555,7 @@ void stWithinNullArg() throws Exception { void stIntersectsOverlappingPolygons() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Intersects('POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))', 'POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))') as v"); + "select geo.intersects('POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))', 'POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isTrue(); }); @@ -565,7 +565,7 @@ void stIntersectsOverlappingPolygons() throws Exception { void stIntersectsDisjointPolygons() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Intersects('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); + "select geo.intersects('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isFalse(); }); @@ -575,7 +575,7 @@ void stIntersectsDisjointPolygons() throws Exception { void stIntersectsNullArg() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Intersects(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + "select geo.intersects(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("v"); assertThat(val).isNull(); @@ -588,7 +588,7 @@ void stIntersectsNullArg() throws Exception { void stContainsPolygonContainsPoint() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Contains('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))', 'POINT (5 5)') as v"); + "select geo.contains('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))', 'POINT (5 5)') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isTrue(); }); @@ -598,7 +598,7 @@ void stContainsPolygonContainsPoint() throws Exception { void stContainsPolygonDoesNotContainOutsidePoint() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Contains('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))', 'POINT (15 15)') as v"); + "select geo.contains('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))', 'POINT (15 15)') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isFalse(); }); @@ -608,7 +608,7 @@ void stContainsPolygonDoesNotContainOutsidePoint() throws Exception { void stContainsNullArg() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Contains(null, 'POINT (5 5)') as v"); + "select geo.contains(null, 'POINT (5 5)') as v"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("v"); assertThat(val).isNull(); @@ -622,7 +622,7 @@ void stDWithinNearbyPoints() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { // Two points at about 1.414 degrees apart; distance threshold = 2.0 → true final ResultSet result = db.query("sql", - "select ST_DWithin('POINT (0 0)', 'POINT (1 1)', 2.0) as v"); + "select geo.dWithin('POINT (0 0)', 'POINT (1 1)', 2.0) as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isTrue(); }); @@ -633,7 +633,7 @@ void stDWithinFarPoints() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { // Two points far apart; distance threshold = 1.0 → false final ResultSet result = db.query("sql", - "select ST_DWithin('POINT (0 0)', 'POINT (10 10)', 1.0) as v"); + "select geo.dWithin('POINT (0 0)', 'POINT (10 10)', 1.0) as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isFalse(); }); @@ -643,7 +643,7 @@ void stDWithinFarPoints() throws Exception { void stDWithinNullArg() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_DWithin(null, 'POINT (1 1)', 2.0) as v"); + "select geo.dWithin(null, 'POINT (1 1)', 2.0) as v"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("v"); assertThat(val).isNull(); @@ -656,7 +656,7 @@ void stDWithinNullArg() throws Exception { void stDisjointFarApartShapes() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Disjoint('POINT (50 50)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + "select geo.disjoint('POINT (50 50)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isTrue(); }); @@ -666,7 +666,7 @@ void stDisjointFarApartShapes() throws Exception { void stDisjointIntersectingShapes() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Disjoint('POINT (5 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + "select geo.disjoint('POINT (5 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isFalse(); }); @@ -676,7 +676,7 @@ void stDisjointIntersectingShapes() throws Exception { void stDisjointNullArg() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Disjoint(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + "select geo.disjoint(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("v"); assertThat(val).isNull(); @@ -689,7 +689,7 @@ void stDisjointNullArg() throws Exception { void stEqualsIdenticalPoints() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Equals('POINT (5 5)', 'POINT (5 5)') as v"); + "select geo.equals('POINT (5 5)', 'POINT (5 5)') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isTrue(); }); @@ -699,7 +699,7 @@ void stEqualsIdenticalPoints() throws Exception { void stEqualsDifferentPoints() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Equals('POINT (5 5)', 'POINT (6 6)') as v"); + "select geo.equals('POINT (5 5)', 'POINT (6 6)') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isFalse(); }); @@ -709,7 +709,7 @@ void stEqualsDifferentPoints() throws Exception { void stEqualsNullArg() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Equals(null, 'POINT (5 5)') as v"); + "select geo.equals(null, 'POINT (5 5)') as v"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("v"); assertThat(val).isNull(); @@ -723,7 +723,7 @@ void stCrossesLineCrossesPolygon() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { // A line crossing a polygon boundary final ResultSet result = db.query("sql", - "select ST_Crosses('LINESTRING (-1 5, 11 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + "select geo.crosses('LINESTRING (-1 5, 11 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isTrue(); }); @@ -733,7 +733,7 @@ void stCrossesLineCrossesPolygon() throws Exception { void stCrossesNullArg() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Crosses(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + "select geo.crosses(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("v"); assertThat(val).isNull(); @@ -746,7 +746,7 @@ void stCrossesNullArg() throws Exception { void stOverlapsPartiallyOverlappingPolygons() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Overlaps('POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))', 'POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))') as v"); + "select geo.overlaps('POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))', 'POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isTrue(); }); @@ -756,7 +756,7 @@ void stOverlapsPartiallyOverlappingPolygons() throws Exception { void stOverlapsDisjointPolygons() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Overlaps('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); + "select geo.overlaps('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isFalse(); }); @@ -766,7 +766,7 @@ void stOverlapsDisjointPolygons() throws Exception { void stOverlapsNullArg() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Overlaps(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + "select geo.overlaps(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("v"); assertThat(val).isNull(); @@ -780,7 +780,7 @@ void stTouchesAdjacentPolygons() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { // Two polygons sharing exactly one edge final ResultSet result = db.query("sql", - "select ST_Touches('POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))', 'POLYGON ((5 0, 10 0, 10 5, 5 5, 5 0))') as v"); + "select geo.touches('POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))', 'POLYGON ((5 0, 10 0, 10 5, 5 5, 5 0))') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isTrue(); }); @@ -790,7 +790,7 @@ void stTouchesAdjacentPolygons() throws Exception { void stTouchesDisjointPolygons() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Touches('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); + "select geo.touches('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); assertThat(result.hasNext()).isTrue(); assertThat((Boolean) result.next().getProperty("v")).isFalse(); }); @@ -800,7 +800,7 @@ void stTouchesDisjointPolygons() throws Exception { void stTouchesNullArg() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Touches(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + "select geo.touches(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); assertThat(result.hasNext()).isTrue(); final Object val = result.next().getProperty("v"); assertThat(val).isNull(); diff --git a/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java b/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java index 9e7796a128..5c1b0133d9 100644 --- a/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java +++ b/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java @@ -54,7 +54,7 @@ void stWithinWithIndex() { // Bounding box: lon 10..16, lat 38..44 — should include Rome and Naples, not Milan final ResultSet result = database.query("sql", - "SELECT name FROM Location WHERE ST_Within(coords, ST_GeomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); + "SELECT name FROM Location WHERE geo.within(coords, geo.geomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); final List names = new ArrayList<>(); while (result.hasNext()) @@ -82,7 +82,7 @@ void stIntersectsWithIndex() { // Bounding box covers Rome and Naples only final ResultSet result = database.query("sql", - "SELECT name FROM Location2 WHERE ST_Intersects(coords, ST_GeomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); + "SELECT name FROM Location2 WHERE geo.intersects(coords, geo.geomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); final List names = new ArrayList<>(); while (result.hasNext()) @@ -123,7 +123,7 @@ void stDWithinFallbackWithExistingIndex() { // Search from POINT (12.0, 41.5) within 1.0 degree — only Rome qualifies final ResultSet result = database.query("sql", - "SELECT name FROM Location3 WHERE ST_DWithin(coords, 'POINT (12.0 41.5)', 1.0) = true"); + "SELECT name FROM Location3 WHERE geo.dWithin(coords, 'POINT (12.0 41.5)', 1.0) = true"); final List names = new ArrayList<>(); while (result.hasNext()) @@ -154,7 +154,7 @@ void stWithinWithoutIndexFallback() { database.command("sql", "DROP INDEX `Location4[coords]`"); final ResultSet result = database.query("sql", - "SELECT name FROM Location4 WHERE ST_Within(coords, ST_GeomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); + "SELECT name FROM Location4 WHERE geo.within(coords, geo.geomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); final List names = new ArrayList<>(); while (result.hasNext()) @@ -184,7 +184,7 @@ void stDisjointFallbackWithExistingIndex() { // Bounding box covering Rome only; Milan is disjoint from it final ResultSet result = database.query("sql", - "SELECT name FROM Location5 WHERE ST_Disjoint(coords, ST_GeomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); + "SELECT name FROM Location5 WHERE geo.disjoint(coords, geo.geomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); final List names = new ArrayList<>(); while (result.hasNext()) @@ -195,7 +195,7 @@ void stDisjointFallbackWithExistingIndex() { /** * Verifies ST_Contains with stored polygons using inline full-scan evaluation. - * ST_Contains(coords, point) finds which stored polygon contains Rome. + * geo.contains(coords, point) finds which stored polygon contains Rome. * No GEOSPATIAL index is created here because the index is optimised for searching * stored points inside a query polygon (ST_Within direction); ST_Contains queries * a small containee shape against large stored containers and the GeoHash detail @@ -214,9 +214,9 @@ void stContainsFallback() { database.command("sql", "INSERT INTO Location6 SET name = 'NorthBox', coords = 'POLYGON ((8 44, 11 44, 11 47, 8 47, 8 44))'"); }); - // ST_Contains(coords, point) — find which stored polygon contains Rome + // geo.contains(coords, point) — find which stored polygon contains Rome final ResultSet result = database.query("sql", - "SELECT name FROM Location6 WHERE ST_Contains(coords, ST_GeomFromText('POINT (12.5 41.9)')) = true"); + "SELECT name FROM Location6 WHERE geo.contains(coords, geo.geomFromText('POINT (12.5 41.9)')) = true"); final List names = new ArrayList<>(); while (result.hasNext()) @@ -245,7 +245,7 @@ void stEqualsFallback() { }); final ResultSet result = database.query("sql", - "SELECT name FROM Location7 WHERE ST_Equals(coords, ST_GeomFromText('POINT (12.5 41.9)')) = true"); + "SELECT name FROM Location7 WHERE geo.equals(coords, geo.geomFromText('POINT (12.5 41.9)')) = true"); final List names = new ArrayList<>(); while (result.hasNext()) @@ -276,7 +276,7 @@ void stCrossesWithIndex() { }); final ResultSet result = database.query("sql", - "SELECT name FROM Location8 WHERE ST_Crosses(coords, ST_GeomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); + "SELECT name FROM Location8 WHERE geo.crosses(coords, geo.geomFromText('POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38))')) = true"); final List names = new ArrayList<>(); while (result.hasNext()) @@ -308,7 +308,7 @@ void stOverlapsWithIndex() { // Query polygon: POLYGON ((12 40, 16 40, 16 45, 12 45, 12 40)) final ResultSet result = database.query("sql", - "SELECT name FROM Location9 WHERE ST_Overlaps(coords, ST_GeomFromText('POLYGON ((12 40, 16 40, 16 45, 12 45, 12 40))')) = true"); + "SELECT name FROM Location9 WHERE geo.overlaps(coords, geo.geomFromText('POLYGON ((12 40, 16 40, 16 45, 12 45, 12 40))')) = true"); final List names = new ArrayList<>(); while (result.hasNext()) @@ -338,7 +338,7 @@ void stTouchesWithIndex() { // Right polygon starting at x=12 touches LeftBox at the shared edge final ResultSet result = database.query("sql", - "SELECT name FROM Location10 WHERE ST_Touches(coords, ST_GeomFromText('POLYGON ((12 38, 16 38, 16 42, 12 42, 12 38))')) = true"); + "SELECT name FROM Location10 WHERE geo.touches(coords, geo.geomFromText('POLYGON ((12 38, 16 38, 16 42, 12 42, 12 38))')) = true"); final List names = new ArrayList<>(); while (result.hasNext()) From 049feaa9f28a2f40526cfab0a588042d88be8326 Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 12:02:08 +0100 Subject: [PATCH 25/47] fix(geo): support geo.* function calls without breaking field.method() syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the grammar ordering change from commit d0aff240c that placed functionCallExpr before identifierChain in baseExpression. That ordering caused field.method() patterns like decimal.format('%.1f') and name.toLowerCase() to be incorrectly parsed as namespace-qualified function calls. The fix uses Option A: - Restore identifierChain before functionCallExpr in baseExpression so that field.method() patterns parse correctly as field access + method call. - Revert functionCall to accept a single identifier (no DOT namespace prefix), matching its original form. - Add FUNCTION_NAMESPACES detection in visitIdentifierChain: when the identifierChain matches exactly one base identifier that is a known function namespace (currently "geo") followed by exactly one methodCall, the visitor rewrites the node as a namespace-qualified FunctionCall (e.g. "geo.point", "geo.within") instead of a field access with a method modifier. This preserves full support for: geo.within(coords, geo.point(1, 2)) — namespace-qualified function call decimal.format('%.1f') — field with method call name.toLowerCase() — field with method call Co-Authored-By: Claude Sonnet 4.6 --- .../arcadedb/query/sql/grammar/SQLParser.g4 | 5 +- .../query/sql/antlr/SQLASTBuilder.java | 110 +++++++++++++++--- 2 files changed, 99 insertions(+), 16 deletions(-) diff --git a/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 b/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 index 37651ae811..7b93039e46 100644 --- a/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 +++ b/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 @@ -1169,8 +1169,8 @@ baseExpression | INTEGER_RANGE # integerRange | ELLIPSIS_INTEGER_RANGE # ellipsisIntegerRange | THIS # thisLiteral - | functionCall # functionCallExpr | identifier (DOT identifier)* methodCall* arraySelector* modifier* # identifierChain + | functionCall # functionCallExpr | inputParameter modifier* # inputParam | LPAREN statement RPAREN modifier* # parenthesizedStmt | LPAREN expression RPAREN modifier* # parenthesizedExpr @@ -1213,10 +1213,9 @@ extendedCaseAlternative * Supports array selectors: someFunc()[0] * Supports modifiers: someFunc().asString() * Supports nested projections: list({x:1}):{x} (processed before methodCall/arraySelector/modifier) - * Supports namespace-qualified calls: geo.point(x, y) — namespace DOT functionName LPAREN... */ functionCall - : identifier (DOT identifier)? LPAREN (STAR | expression (COMMA expression)*)? RPAREN nestedProjection* methodCall* arraySelector* modifier* + : identifier LPAREN (STAR | expression (COMMA expression)*)? RPAREN nestedProjection* methodCall* arraySelector* modifier* ; /** diff --git a/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java b/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java index 1fe8c456c5..1ad021d298 100644 --- a/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java +++ b/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java @@ -52,8 +52,12 @@ public class SQLASTBuilder extends SQLParserBaseVisitor { * Known function namespace prefixes. When the parser sees {@code namespace.method(args)} and the namespace * is in this set, the AST builder produces a {@link FunctionCall} node with the qualified name * (e.g., "ts.first") instead of an identifier chain with a method modifier. + * Identifier prefixes that are treated as function namespaces rather than field names. + * When an identifierChain matches "namespace.method(args)", it is rewritten as a + * namespace-qualified function call (e.g. "geo.point(x,y)" → FunctionCall("geo.point")). */ - private static final Set FUNCTION_NAMESPACES = Set.of("ts"); + private static final Set FUNCTION_NAMESPACES = Set.of("ts","geo"); + private int positionalParamCounter = 0; @@ -3015,24 +3019,15 @@ public BaseExpression visitArrayLit(final SQLParser.ArrayLitContext ctx) { /** * Function call visitor - parses function name and parameters. - * Grammar: identifier (DOT identifier)? LPAREN (STAR | expression (COMMA expression)*)? RPAREN - * Supports simple calls (count(*)) and namespace-qualified calls (geo.point(x,y)). + * Grammar: identifier LPAREN (STAR | expression (COMMA expression)*)? RPAREN */ @Override public FunctionCall visitFunctionCall(final SQLParser.FunctionCallContext ctx) { final FunctionCall funcCall = new FunctionCall(-1); try { - // Build the function name: either "name" or "namespace.name" - final Identifier funcName; - if (ctx.identifier().size() == 2) { - // Namespace-qualified: geo.point → combine as single Identifier "geo.point" - final Identifier ns = (Identifier) visit(ctx.identifier(0)); - final Identifier fn = (Identifier) visit(ctx.identifier(1)); - funcName = new Identifier(ns.getStringValue() + "." + fn.getStringValue()); - } else { - funcName = (Identifier) visit(ctx.identifier(0)); - } + // Function name + final Identifier funcName = (Identifier) visit(ctx.identifier()); funcCall.name = funcName; // Parameters (using reflection for protected field) @@ -3079,6 +3074,18 @@ public BaseExpression visitIdentifierChain(final SQLParser.IdentifierChainContex if (CollectionUtils.isNotEmpty(ctx.identifier())) { final SQLParser.IdentifierContext firstIdCtx = ctx.identifier(0); + // Detect namespace-qualified function calls: geo.methodName(args) + // Pattern: exactly one base identifier that is a known namespace, no additional DOT-identifiers, + // and exactly one methodCall → rewrite as FunctionCall("namespace.methodName", args). + if (ctx.identifier().size() == 1 && CollectionUtils.isNotEmpty(ctx.methodCall()) && ctx.methodCall().size() == 1 + && firstIdCtx.RID_ATTR() == null && firstIdCtx.TYPE_ATTR() == null + && firstIdCtx.IN_ATTR() == null && firstIdCtx.OUT_ATTR() == null && firstIdCtx.THIS() == null) { + final String baseIdText = firstIdCtx.getText(); + if (FUNCTION_NAMESPACES.contains(baseIdText.toLowerCase())) { + return buildNamespaceQualifiedFunctionCall(baseIdText, ctx.methodCall(0), ctx); + } + } + // Check if the first identifier is a record attribute (@rid, @type, @in, @out, @this) if (firstIdCtx.RID_ATTR() != null || firstIdCtx.TYPE_ATTR() != null || firstIdCtx.IN_ATTR() != null || firstIdCtx.OUT_ATTR() != null || @@ -3217,6 +3224,83 @@ public BaseExpression visitIdentifierChain(final SQLParser.IdentifierChainContex return baseExpr; } + /** + * Builds a namespace-qualified FunctionCall BaseExpression from an identifierChain that looks like + * "namespace.functionName(args)" — e.g. "geo.point(x, y)" or "geo.within(geom, geo.point(x, y))". + *

+ * The identifierChain grammar rule captures "namespace" as the base identifier and ".functionName(args)" + * as a methodCall. This helper recombines them into a proper FunctionCall AST node so that the + * execution engine resolves "geo.point" as a registered SQL function rather than a field access. + *

+ * Any arraySelectors or modifiers that follow the method call on the identifierChain are preserved + * and chained onto the returned BaseExpression. + */ + private BaseExpression buildNamespaceQualifiedFunctionCall(final String namespace, + final SQLParser.MethodCallContext methodCtx, final SQLParser.IdentifierChainContext chainCtx) { + final BaseExpression baseExpr = new BaseExpression(-1); + + try { + // Build the combined function name: "geo.point", "geo.within", etc. + final Identifier methodName = (Identifier) visit(methodCtx.identifier()); + final FunctionCall funcCall = new FunctionCall(-1); + funcCall.name = new Identifier(namespace + "." + methodName.getStringValue()); + + // Collect arguments from the method call + if (CollectionUtils.isNotEmpty(methodCtx.expression())) { + final List params = new ArrayList<>(); + for (final SQLParser.ExpressionContext exprCtx : methodCtx.expression()) { + params.add((Expression) visit(exprCtx)); + } + funcCall.params = params; + } + + // Wrap the FunctionCall in the standard BaseExpression structure + final LevelZeroIdentifier levelZero = new LevelZeroIdentifier(-1); + levelZero.functionCall = funcCall; + final BaseIdentifier baseId = new BaseIdentifier(-1); + baseId.levelZero = levelZero; + baseExpr.identifier = baseId; + + // Preserve any arraySelectors or modifiers that follow the method call + Modifier firstModifier = null; + Modifier currentModifier = null; + + if (CollectionUtils.isNotEmpty(chainCtx.arraySelector())) { + for (final SQLParser.ArraySelectorContext selectorCtx : chainCtx.arraySelector()) { + final Modifier modifier = createModifierForArraySelector(selectorCtx); + if (firstModifier == null) { + firstModifier = modifier; + currentModifier = modifier; + } else { + currentModifier.next = modifier; + currentModifier = modifier; + } + } + } + + if (CollectionUtils.isNotEmpty(chainCtx.modifier())) { + for (final SQLParser.ModifierContext modCtx : chainCtx.modifier()) { + final Modifier modifier = (Modifier) visit(modCtx); + if (firstModifier == null) { + firstModifier = modifier; + currentModifier = modifier; + } else { + currentModifier.next = modifier; + currentModifier = modifier; + } + } + } + + if (firstModifier != null) + baseExpr.modifier = firstModifier; + + } catch (final Exception e) { + throw new CommandSQLParsingException("Failed to build namespace-qualified function call: " + e.getMessage(), e); + } + + return baseExpr; + } + /** * Create a Modifier for an array selector context. *

From 1054e5470a72a97556b678453130131adad7d03b Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 12:07:58 +0100 Subject: [PATCH 26/47] test(geo): update ST_* references in comments to geo.* naming Co-Authored-By: Claude Sonnet 4.6 --- .../geospatial/SQLGeoIndexedQueryTest.java | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java b/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java index 5c1b0133d9..9f85e8854f 100644 --- a/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java +++ b/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java @@ -28,14 +28,14 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Integration tests for ST_* spatial predicate functions with a real geospatial index. + * Integration tests for geo.* spatial predicate functions with a real geospatial index. * Verifies that the query optimizer uses the GEOSPATIAL index and that results are correct * both with and without the index (inline full-scan fallback). */ class SQLGeoIndexedQueryTest extends TestHelper { /** - * Inserts three Italian cities and verifies ST_Within correctly filters via index. + * Inserts three Italian cities and verifies geo.within correctly filters via index. * Rome (12.5, 41.9) and Naples (14.3, 40.8) are inside the bounding box; * Milan (9.2, 45.5) is outside. */ @@ -65,7 +65,7 @@ void stWithinWithIndex() { } /** - * Verifies ST_Intersects against a bounding box returns the expected cities. + * Verifies geo.intersects against a bounding box returns the expected cities. */ @Test void stIntersectsWithIndex() { @@ -93,8 +93,8 @@ void stIntersectsWithIndex() { } /** - * Verifies ST_DWithin proximity query using the inline full-scan fallback path (the index - * exists on the type but ST_DWithin always disables indexed execution). + * Verifies geo.dWithin proximity query using the inline full-scan fallback path (the index + * exists on the type but geo.dWithin always disables indexed execution). * *

Search point: POINT (12.0, 41.5), distance threshold: 1.0 degree (great-circle degrees * as computed by {@code SpatialContext.calcDistance()}). @@ -134,7 +134,7 @@ void stDWithinFallbackWithExistingIndex() { } /** - * Verifies that dropping the index and re-running the ST_Within query (inline full-scan fallback) + * Verifies that dropping the index and re-running the geo.within query (inline full-scan fallback) * produces the same correct results. */ @Test @@ -165,8 +165,8 @@ void stWithinWithoutIndexFallback() { } /** - * Verifies ST_Disjoint returns the city outside the bounding box. - * ST_Disjoint always uses the inline full-scan fallback because the GeoHash index + * Verifies geo.disjoint returns the city outside the bounding box. + * geo.disjoint always uses the inline full-scan fallback because the GeoHash index * stores intersecting records — disjoint records are precisely those NOT returned * by the index, so the index cannot serve as a valid candidate superset. */ @@ -194,10 +194,10 @@ void stDisjointFallbackWithExistingIndex() { } /** - * Verifies ST_Contains with stored polygons using inline full-scan evaluation. + * Verifies geo.contains with stored polygons using inline full-scan evaluation. * geo.contains(coords, point) finds which stored polygon contains Rome. * No GEOSPATIAL index is created here because the index is optimised for searching - * stored points inside a query polygon (ST_Within direction); ST_Contains queries + * stored points inside a query polygon (geo.within direction); geo.contains queries * a small containee shape against large stored containers and the GeoHash detail * level for a point query is too coarse to locate polygon tokens reliably. */ @@ -227,7 +227,7 @@ void stContainsFallback() { } /** - * Verifies ST_Equals using inline full-scan evaluation. + * Verifies geo.equals using inline full-scan evaluation. * Only the record at exactly (12.5, 41.9) matches the equality query. * No GEOSPATIAL index is created here because the GeoHash detail level for a point * query shape is too coarse to retrieve the stored point token at full precision. @@ -256,7 +256,7 @@ void stEqualsFallback() { } /** - * Verifies ST_Crosses: a stored linestring that crosses a polygon boundary is returned. + * Verifies geo.crosses: a stored linestring that crosses a polygon boundary is returned. * The line from (9, 38) to (16, 45) crosses the boundary of the polygon * POLYGON ((10 38, 16 38, 16 44, 10 44, 10 38)). */ @@ -287,7 +287,7 @@ void stCrossesWithIndex() { } /** - * Verifies ST_Overlaps: two polygons with partial overlap are returned, but a fully-contained + * Verifies geo.overlaps: two polygons with partial overlap are returned, but a fully-contained * polygon is not (overlaps requires same-dimension partial intersection, not containment). */ @Test @@ -319,7 +319,7 @@ void stOverlapsWithIndex() { } /** - * Verifies ST_Touches: two polygons sharing exactly one edge touch each other. + * Verifies geo.touches: two polygons sharing exactly one edge touch each other. * The left polygon ends at x=12, the right polygon starts at x=12 — they share the boundary. */ @Test From 78379e515adbb074626cafc93f5e168b588f32e7 Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 12:13:43 +0100 Subject: [PATCH 27/47] docs: update geo function references from ST_* to geo.* naming Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-02-22-geospatial-design.md | 124 ++++---- .../2026-02-22-geospatial-implementation.md | 268 +++++++++--------- 2 files changed, 196 insertions(+), 196 deletions(-) diff --git a/docs/plans/2026-02-22-geospatial-design.md b/docs/plans/2026-02-22-geospatial-design.md index 60378cad78..4b5604ea9e 100644 --- a/docs/plans/2026-02-22-geospatial-design.md +++ b/docs/plans/2026-02-22-geospatial-design.md @@ -6,12 +6,12 @@ ## Overview -Port OrientDB-style geospatial indexing to ArcadeDB, using the native LSM-Tree engine as storage (following the same pattern as `LSMTreeFullTextIndex`) and the OGC/PostGIS `ST_*` SQL function naming convention. +Port OrientDB-style geospatial indexing to ArcadeDB, using the native LSM-Tree engine as storage (following the same pattern as `LSMTreeFullTextIndex`) and the `geo.*` SQL function namespace. ## Goals -- Support all OGC spatial predicate functions OrientDB supported: `ST_Within`, `ST_Intersects`, `ST_Contains`, `ST_DWithin`, `ST_Disjoint`, `ST_Equals`, `ST_Crosses`, `ST_Overlaps`, `ST_Touches` -- Replace existing non-standard geo functions (`point()`, `distance()`, `circle()`, etc.) with `ST_*` equivalents +- Support all OGC spatial predicate functions OrientDB supported: `geo.within`, `geo.intersects`, `geo.contains`, `geo.dWithin`, `geo.disjoint`, `geo.equals`, `geo.crosses`, `geo.overlaps`, `geo.touches` +- Replace existing non-standard geo functions (`point()`, `distance()`, `circle()`, etc.) with `geo.*` equivalents - Automatic query optimizer integration — no explicit `search_index()` call needed - WKT as the geometry storage format (consistent with existing partial support) - LSM-Tree as storage backend (ACID, WAL, HA, compaction all inherited for free) @@ -28,11 +28,11 @@ Port OrientDB-style geospatial indexing to ArcadeDB, using the native LSM-Tree e ### Layers ``` -SQL Query: WHERE ST_Within(location, ST_GeomFromText('POLYGON(...)')) = true +SQL Query: WHERE geo.within(location, geo.geomFromText('POLYGON(...)')) = true │ ▼ SelectExecutionPlanner - detects IndexableSQLFunction on ST_Within + detects IndexableSQLFunction on geo.within calls allowsIndexedExecution() │ ▼ @@ -42,7 +42,7 @@ SQL Query: WHERE ST_Within(location, ST_GeomFromText('POLYGON(...)')) = true returns candidate RIDs │ ▼ - ST_Within.shouldExecuteAfterSearch() = true + geo.within.shouldExecuteAfterSearch() = true → exact Spatial4j predicate post-filters candidates ``` @@ -68,11 +68,11 @@ Wraps `LSMTreeIndex` (identical to how `LSMTreeFullTextIndex` wraps it). ### Querying (`get(keys)`) -1. The key is a `Shape` (passed from the ST_* function) +1. The key is a `Shape` (passed from the `geo.*` function) 2. Generate covering GeoHash cells via `SpatialArgs` + `RecursivePrefixTreeStrategy` 3. Extract cell token strings from the Lucene query 4. Look up each token in the LSM-Tree, union all matching RIDs -5. Return `TempIndexCursor` (candidates; exact post-filter happens in the ST_* function) +5. Return `TempIndexCursor` (candidates; exact post-filter happens in the `geo.*` function) ### Configuration @@ -84,7 +84,7 @@ Wraps `LSMTreeIndex` (identical to how `LSMTreeFullTextIndex` wraps it). - Add `GEOSPATIAL` to `Schema.INDEX_TYPE` enum - Register `LSMTreeGeoIndex.GeoIndexFactoryHandler` in `LocalSchema` alongside `LSM_TREE`, `FULL_TEXT`, `LSM_VECTOR` -## Component 2: ST_* SQL Functions +## Component 2: geo.* SQL Functions **Package:** `com.arcadedb.function.sql.geo` **Registered in:** `DefaultSQLFunctionFactory` @@ -93,42 +93,42 @@ Wraps `LSMTreeIndex` (identical to how `LSMTreeFullTextIndex` wraps it). | Function | Replaces | Notes | |---|---|---| -| `ST_GeomFromText(wkt)` | — | Parse any WKT string → Spatial4j `Shape` | -| `ST_Point(x, y)` | `point(x,y)` | Returns Spatial4j `Point` as WKT | -| `ST_LineString(pts)` | `lineString(pts)` | | -| `ST_Polygon(pts)` | `polygon(pts)` | | -| `ST_Buffer(geom, dist)` | `circle(c,r)` | OGC buffer around any geometry | -| `ST_Envelope(geom)` | `rectangle(pts)` | Bounding rectangle as WKT | -| `ST_Distance(g1, g2 [,unit])` | `distance(...)` | Haversine; keeps SQL and Cypher-style params | -| `ST_Area(geom)` | — | Area in square degrees via Spatial4j | -| `ST_AsText(geom)` | — | Spatial4j `Shape` → WKT string | -| `ST_AsGeoJson(geom)` | — | Shape → GeoJSON string via JTS | -| `ST_X(point)` | — | Extract X coordinate | -| `ST_Y(point)` | — | Extract Y coordinate | +| `geo.geomFromText(wkt)` | — | Parse any WKT string → Spatial4j `Shape` | +| `geo.point(x, y)` | `point(x,y)` | Returns Spatial4j `Point` as WKT | +| `geo.lineString(pts)` | `lineString(pts)` | | +| `geo.polygon(pts)` | `polygon(pts)` | | +| `geo.buffer(geom, dist)` | `circle(c,r)` | OGC buffer around any geometry | +| `geo.envelope(geom)` | `rectangle(pts)` | Bounding rectangle as WKT | +| `geo.distance(g1, g2 [,unit])` | `distance(...)` | Haversine; keeps SQL and Cypher-style params | +| `geo.area(geom)` | — | Area in square degrees via Spatial4j | +| `geo.asText(geom)` | — | Spatial4j `Shape` → WKT string | +| `geo.asGeoJson(geom)` | — | Shape → GeoJSON string via JTS | +| `geo.x(point)` | — | Extract X coordinate | +| `geo.y(point)` | — | Extract Y coordinate | ### Spatial Predicate Functions (implement `SQLFunction` + `IndexableSQLFunction`) | Function | Semantics | Post-filter | |---|---|---| -| `ST_Within(g, shape)` | g is fully within shape | yes | -| `ST_Intersects(g, shape)` | g and shape share any point | yes | -| `ST_Contains(g, shape)` | g fully contains shape | yes | -| `ST_DWithin(g, shape, dist)` | g is within dist of shape | yes | -| `ST_Disjoint(g, shape)` | g and shape share no points | yes | -| `ST_Equals(g, shape)` | geometrically equal | yes | -| `ST_Crosses(g, shape)` | g crosses shape | yes | -| `ST_Overlaps(g, shape)` | g overlaps shape | yes | -| `ST_Touches(g, shape)` | g touches shape boundary | yes | +| `geo.within(g, shape)` | g is fully within shape | yes | +| `geo.intersects(g, shape)` | g and shape share any point | yes | +| `geo.contains(g, shape)` | g fully contains shape | yes | +| `geo.dWithin(g, shape, dist)` | g is within dist of shape | yes | +| `geo.disjoint(g, shape)` | g and shape share no points | yes | +| `geo.equals(g, shape)` | geometrically equal | yes | +| `geo.crosses(g, shape)` | g crosses shape | yes | +| `geo.overlaps(g, shape)` | g overlaps shape | yes | +| `geo.touches(g, shape)` | g touches shape boundary | yes | All predicates return `null` when either argument is null (SQL three-valued logic). **Implementation notes on `allowsIndexedExecution()`:** -- `ST_Disjoint` — returns `false`. The GeoHash index stores records whose geometry intersects +- `geo.disjoint` — returns `false`. The GeoHash index stores records whose geometry intersects the indexed cells. Disjoint records are precisely those *not* present in the intersection result, so the index cannot produce a valid candidate superset. The predicate always falls back to a full scan with inline evaluation. -- `ST_DWithin` — returns `false`. The current implementation evaluates proximity as a +- `geo.dWithin` — returns `false`. The current implementation evaluates proximity as a straight-line distance between geometry centers. The GeoHash index returns cells that intersect the query shape, which does not correspond to a distance radius. Correct indexed proximity would require first expanding the search geometry into a bounding circle before @@ -146,8 +146,8 @@ Each predicate's `IndexableSQLFunction` implementation: No changes to `SelectExecutionPlanner` required. The existing `indexedFunctionConditions` path fully supports this pattern: 1. `block.getIndexedFunctionConditions(typez, context)` collects conditions where the left `Expression` is a function call implementing `IndexableSQLFunction` -2. `ST_Within.allowsIndexedExecution()` checks for a `GEOSPATIAL` index on the referenced field -3. `BinaryCondition.executeIndexedFunction()` → `ST_Within.searchFromTarget()` executes the indexed search +2. `geo.within.allowsIndexedExecution()` checks for a `GEOSPATIAL` index on the referenced field +3. `BinaryCondition.executeIndexedFunction()` → `geo.within.searchFromTarget()` executes the indexed search 4. `shouldExecuteAfterSearch() = true` → exact post-filter applied to all returned candidates **Multi-bucket:** `searchFromTarget()` iterates all per-bucket `LSMTreeGeoIndex` instances via `TypeIndex.getIndexesOnBuckets()` and unions results, matching the full-text search pattern. @@ -156,7 +156,7 @@ No changes to `SelectExecutionPlanner` required. The existing `indexedFunctionCo | Scenario | Behavior | |---|---| -| Invalid WKT in `ST_GeomFromText()` | `IllegalArgumentException` with clear message | +| Invalid WKT in `geo.geomFromText()` | `IllegalArgumentException` with clear message | | Null geometry argument in predicate | returns `null` (three-valued SQL logic) | | No geospatial index on field | falls back to full-scan; no error | | Non-WKT value in indexed property | `put()` skips record, logs warning | @@ -175,15 +175,15 @@ All tests in `engine/src/test/java/com/arcadedb/`: - No-index fallback path ### `function/sql/geo/SQLGeoFunctionsTest` (extend existing) -- All ST_* constructor and accessor functions +- All `geo.*` constructor and accessor functions - Verify old `point()`, `distance()`, etc. throw "unknown function" ### `function/sql/geo/SQLGeoIndexedQueryTest` (new) - Create type with `GEOSPATIAL` index on WKT property - Insert records with point WKT values at known coordinates -- `SELECT ... WHERE ST_Within(...) = true` — verify correct results -- `SELECT ... WHERE ST_Intersects(...) = true` — verify -- `SELECT ... WHERE ST_DWithin(..., dist) = true` — proximity radius query +- `SELECT ... WHERE geo.within(...) = true` — verify correct results +- `SELECT ... WHERE geo.intersects(...) = true` — verify +- `SELECT ... WHERE geo.dWithin(..., dist) = true` — proximity radius query - All nine predicate functions covered - Query with no index (fallback) produces same results @@ -197,27 +197,27 @@ engine/src/main/java/com/arcadedb/ LSMTreeGeoIndex.java GeoIndexMetadata.java function/sql/geo/ - SQLFunctionST_GeomFromText.java - SQLFunctionST_Point.java - SQLFunctionST_LineString.java - SQLFunctionST_Polygon.java - SQLFunctionST_Buffer.java - SQLFunctionST_Envelope.java - SQLFunctionST_Distance.java - SQLFunctionST_Area.java - SQLFunctionST_AsText.java - SQLFunctionST_AsGeoJson.java - SQLFunctionST_X.java - SQLFunctionST_Y.java - SQLFunctionST_Within.java ← implements IndexableSQLFunction - SQLFunctionST_Intersects.java ← implements IndexableSQLFunction - SQLFunctionST_Contains.java ← implements IndexableSQLFunction - SQLFunctionST_DWithin.java ← implements IndexableSQLFunction - SQLFunctionST_Disjoint.java ← implements IndexableSQLFunction - SQLFunctionST_Equals.java ← implements IndexableSQLFunction - SQLFunctionST_Crosses.java ← implements IndexableSQLFunction - SQLFunctionST_Overlaps.java ← implements IndexableSQLFunction - SQLFunctionST_Touches.java ← implements IndexableSQLFunction + SQLFunctionGeoGeomFromText.java + SQLFunctionGeoPoint.java + SQLFunctionGeoLineString.java + SQLFunctionGeoPolygon.java + SQLFunctionGeoBuffer.java + SQLFunctionGeoEnvelope.java + SQLFunctionGeoDistance.java + SQLFunctionGeoArea.java + SQLFunctionGeoAsText.java + SQLFunctionGeoAsGeoJson.java + SQLFunctionGeoX.java + SQLFunctionGeoY.java + SQLFunctionGeoWithin.java ← implements IndexableSQLFunction + SQLFunctionGeoIntersects.java ← implements IndexableSQLFunction + SQLFunctionGeoContains.java ← implements IndexableSQLFunction + SQLFunctionGeoDWithin.java ← implements IndexableSQLFunction + SQLFunctionGeoDisjoint.java ← implements IndexableSQLFunction + SQLFunctionGeoEquals.java ← implements IndexableSQLFunction + SQLFunctionGeoCrosses.java ← implements IndexableSQLFunction + SQLFunctionGeoOverlaps.java ← implements IndexableSQLFunction + SQLFunctionGeoTouches.java ← implements IndexableSQLFunction GeoUtils.java ← extend existing LightweightPoint.java ← keep existing @@ -233,5 +233,5 @@ engine/pom.xml ← add lucene-spatial-extras dependency ## Open Questions -- Should `ST_Distance` return meters by default (Neo4j/Cypher compat) or kilometers (current `distance()` SQL default)? Current implementation keeps both styles based on argument count — recommend preserving this. -- Should `ST_Buffer` accept distance in meters, kilometers, or degrees? Spatial4j works in degrees; conversion at the function boundary needed for user-facing meter/km inputs. +- Should `geo.distance` return meters by default (Neo4j/Cypher compat) or kilometers (current `distance()` SQL default)? Current implementation keeps both styles based on argument count — recommend preserving this. +- Should `geo.buffer` accept distance in meters, kilometers, or degrees? Spatial4j works in degrees; conversion at the function boundary needed for user-facing meter/km inputs. diff --git a/docs/plans/2026-02-22-geospatial-implementation.md b/docs/plans/2026-02-22-geospatial-implementation.md index 93017593a0..627b5c72a6 100644 --- a/docs/plans/2026-02-22-geospatial-implementation.md +++ b/docs/plans/2026-02-22-geospatial-implementation.md @@ -2,9 +2,9 @@ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -**Goal:** Port OrientDB-style geospatial indexing to ArcadeDB with ST_* SQL functions and automatic query optimizer integration. +**Goal:** Port OrientDB-style geospatial indexing to ArcadeDB with `geo.*` SQL functions and automatic query optimizer integration. -**Architecture:** `LSMTreeGeoIndex` wraps `LSMTreeIndex` (same pattern as `LSMTreeFullTextIndex`). `lucene-spatial-extras` `GeohashPrefixTree` decomposes WKT geometries into GeoHash cell tokens stored in LSM-Tree. ST_* predicate functions implement `IndexableSQLFunction` so the query optimizer uses the geo index automatically when `WHERE ST_Within(field, shape) = true` is detected. +**Architecture:** `LSMTreeGeoIndex` wraps `LSMTreeIndex` (same pattern as `LSMTreeFullTextIndex`). `lucene-spatial-extras` `GeohashPrefixTree` decomposes WKT geometries into GeoHash cell tokens stored in LSM-Tree. `geo.*` predicate functions implement `IndexableSQLFunction` so the query optimizer uses the geo index automatically when `WHERE geo.within(field, shape) = true` is detected. **Tech Stack:** Java 21, `lucene-spatial-extras` 10.3.2, `spatial4j` 0.8, `jts-core` 1.20.0, JUnit 5 + AssertJ, Maven. @@ -657,32 +657,32 @@ git commit -m "feat(geo): register GEOSPATIAL index type in Schema and LocalSche --- -## Task 5: Create ST_* Constructor and Accessor Functions +## Task 5: Create geo.* Constructor and Accessor Functions **Files:** -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_GeomFromText.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Point.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_LineString.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Polygon.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Buffer.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Envelope.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Distance.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Area.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsText.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_AsGeoJson.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_X.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Y.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoGeomFromText.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPoint.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoLineString.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPolygon.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoBuffer.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoEnvelope.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDistance.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoArea.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoAsText.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoAsGeoJson.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoX.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoY.java` - Modify: `engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java` **Step 1: Write the failing tests** -Update `engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java`. The existing `point()`, `distance()` etc. tests will become regression tests that the OLD names are gone. Add new ST_* tests: +Update `engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java`. The existing `point()`, `distance()` etc. tests will become regression tests that the OLD names are gone. Add new `geo.*` tests: ```java @Test -void stPoint() throws Exception { +void geoPoint() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", "select ST_Point(11, 11) as pt"); + final ResultSet result = db.query("sql", "select geo.point(11, 11) as pt"); assertThat(result.hasNext()).isTrue(); final Object pt = result.next().getProperty("pt"); assertThat(pt).isNotNull(); @@ -691,9 +691,9 @@ void stPoint() throws Exception { } @Test -void stGeomFromText() throws Exception { +void geoGeomFromText() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", "select ST_GeomFromText('POINT (10.0 45.0)') as geom"); + final ResultSet result = db.query("sql", "select geo.geomFromText('POINT (10.0 45.0)') as geom"); assertThat(result.hasNext()).isTrue(); final Object geom = result.next().getProperty("geom"); assertThat(geom).isNotNull(); @@ -701,9 +701,9 @@ void stGeomFromText() throws Exception { } @Test -void stAsText() throws Exception { +void geoAsText() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", "select ST_AsText(ST_Point(10.0, 45.0)) as wkt"); + final ResultSet result = db.query("sql", "select geo.asText(geo.point(10.0, 45.0)) as wkt"); assertThat(result.hasNext()).isTrue(); final String wkt = result.next().getProperty("wkt"); assertThat(wkt).contains("10").contains("45"); @@ -711,9 +711,9 @@ void stAsText() throws Exception { } @Test -void stXstY() throws Exception { +void geoXgeoY() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", "select ST_X(ST_Point(10.0, 45.0)) as x, ST_Y(ST_Point(10.0, 45.0)) as y"); + final ResultSet result = db.query("sql", "select geo.x(geo.point(10.0, 45.0)) as x, geo.y(geo.point(10.0, 45.0)) as y"); assertThat(result.hasNext()).isTrue(); final com.arcadedb.query.sql.executor.Result row = result.next(); assertThat(((Number) row.getProperty("x")).doubleValue()).isEqualTo(10.0); @@ -722,10 +722,10 @@ void stXstY() throws Exception { } @Test -void stDistance() throws Exception { +void geoDistance() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { final ResultSet result = db.query("sql", - "select ST_Distance(ST_Point(0.0, 0.0), ST_Point(1.0, 0.0), 'km') as dist"); + "select geo.distance(geo.point(0.0, 0.0), geo.point(1.0, 0.0), 'km') as dist"); assertThat(result.hasNext()).isTrue(); final Number dist = result.next().getProperty("dist"); assertThat(dist.doubleValue()).isGreaterThan(100.0).isLessThan(120.0); // ~111km per degree @@ -747,24 +747,24 @@ void oldFunctionNamesGone() throws Exception { cd engine && mvn test -Dtest=SQLGeoFunctionsTest -q 2>&1 | tail -10 ``` -Expected: FAIL — ST_* functions not registered. +Expected: FAIL — `geo.*` functions not registered. **Step 3: Create the function classes** Each function follows the exact same pattern as existing geo functions. Study `SQLFunctionPoint.java` and `SQLFunctionDistance.java` before writing. Key patterns: - Extend `SQLFunctionAbstract` -- Constructor: `super("ST_FunctionName")` +- Constructor: `super("geo.functionName")` - `execute()` validates params, calls `GeoUtils.getSpatialContext()` for shape creation - `getSyntax()` returns a docs string - `getMinArgs()` / `getMaxArgs()` for validation -`SQLFunctionST_GeomFromText.java`: +`SQLFunctionGeoGeomFromText.java`: ```java -public class SQLFunctionST_GeomFromText extends SQLFunctionAbstract { - public static final String NAME = "ST_GeomFromText"; +public class SQLFunctionGeoGeomFromText extends SQLFunctionAbstract { + public static final String NAME = "geo.geomFromText"; - public SQLFunctionST_GeomFromText() { super(NAME); } + public SQLFunctionGeoGeomFromText() { super(NAME); } @Override public Object execute(final Object self, final Identifiable currentRecord, @@ -774,22 +774,22 @@ public class SQLFunctionST_GeomFromText extends SQLFunctionAbstract { try { return GeoUtils.getSpatialContext().getFormats().getWktReader().read(params[0].toString()); } catch (final Exception e) { - throw new IllegalArgumentException("ST_GeomFromText: invalid WKT: " + params[0], e); + throw new IllegalArgumentException("geo.geomFromText: invalid WKT: " + params[0], e); } } - @Override public String getSyntax() { return "ST_GeomFromText()"; } + @Override public String getSyntax() { return "geo.geomFromText()"; } @Override public int getMinArgs() { return 1; } @Override public int getMaxArgs() { return 1; } } ``` -`SQLFunctionST_AsText.java`: +`SQLFunctionGeoAsText.java`: ```java -public class SQLFunctionST_AsText extends SQLFunctionAbstract { - public static final String NAME = "ST_AsText"; +public class SQLFunctionGeoAsText extends SQLFunctionAbstract { + public static final String NAME = "geo.asText"; - public SQLFunctionST_AsText() { super(NAME); } + public SQLFunctionGeoAsText() { super(NAME); } @Override public Object execute(final Object self, final Identifiable currentRecord, @@ -805,18 +805,18 @@ public class SQLFunctionST_AsText extends SQLFunctionAbstract { return params[0].toString(); } - @Override public String getSyntax() { return "ST_AsText()"; } + @Override public String getSyntax() { return "geo.asText()"; } @Override public int getMinArgs() { return 1; } @Override public int getMaxArgs() { return 1; } } ``` -`SQLFunctionST_X.java`: +`SQLFunctionGeoX.java`: ```java -public class SQLFunctionST_X extends SQLFunctionAbstract { - public static final String NAME = "ST_X"; +public class SQLFunctionGeoX extends SQLFunctionAbstract { + public static final String NAME = "geo.x"; - public SQLFunctionST_X() { super(NAME); } + public SQLFunctionGeoX() { super(NAME); } @Override public Object execute(final Object self, final Identifiable currentRecord, @@ -825,35 +825,35 @@ public class SQLFunctionST_X extends SQLFunctionAbstract { return null; if (params[0] instanceof org.locationtech.spatial4j.shape.Point p) return p.getX(); - throw new IllegalArgumentException("ST_X: argument must be a Point"); + throw new IllegalArgumentException("geo.x: argument must be a Point"); } - @Override public String getSyntax() { return "ST_X()"; } + @Override public String getSyntax() { return "geo.x()"; } @Override public int getMinArgs() { return 1; } @Override public int getMaxArgs() { return 1; } } ``` -`SQLFunctionST_Y.java` — same as ST_X but returns `p.getY()`. +`SQLFunctionGeoY.java` — same as `SQLFunctionGeoX` but returns `p.getY()`. -`SQLFunctionST_Point.java` — same logic as existing `SQLFunctionPoint.java` but named `ST_Point`. +`SQLFunctionGeoPoint.java` — same logic as existing `SQLFunctionPoint.java` but named `geo.point`. -`SQLFunctionST_Distance.java` — same logic as existing `SQLFunctionDistance.java` but named `ST_Distance`. +`SQLFunctionGeoDistance.java` — same logic as existing `SQLFunctionDistance.java` but named `geo.distance`. -`SQLFunctionST_LineString.java` — same as existing `SQLFunctionLineString.java` but named `ST_LineString`. +`SQLFunctionGeoLineString.java` — same as existing `SQLFunctionLineString.java` but named `geo.lineString`. -`SQLFunctionST_Polygon.java` — same as existing `SQLFunctionPolygon.java` but named `ST_Polygon`. +`SQLFunctionGeoPolygon.java` — same as existing `SQLFunctionPolygon.java` but named `geo.polygon`. -`SQLFunctionST_Buffer.java` — same as `SQLFunctionCircle.java` (circle = point + buffer radius) but named `ST_Buffer`. +`SQLFunctionGeoBuffer.java` — same as `SQLFunctionCircle.java` (circle = point + buffer radius) but named `geo.buffer`. -`SQLFunctionST_Envelope.java` — same as `SQLFunctionRectangle.java` but named `ST_Envelope`. +`SQLFunctionGeoEnvelope.java` — same as `SQLFunctionRectangle.java` but named `geo.envelope`. -`SQLFunctionST_Area.java`: +`SQLFunctionGeoArea.java`: ```java -public class SQLFunctionST_Area extends SQLFunctionAbstract { - public static final String NAME = "ST_Area"; +public class SQLFunctionGeoArea extends SQLFunctionAbstract { + public static final String NAME = "geo.area"; - public SQLFunctionST_Area() { super(NAME); } + public SQLFunctionGeoArea() { super(NAME); } @Override public Object execute(final Object self, final Identifiable currentRecord, @@ -864,16 +864,16 @@ public class SQLFunctionST_Area extends SQLFunctionAbstract { : GeoUtils.getSpatialContext().getShapeFactory().makePoint(0, 0); // placeholder if (params[0] instanceof Shape s) return s.getArea(GeoUtils.getSpatialContext()); - throw new IllegalArgumentException("ST_Area: argument must be a Shape"); + throw new IllegalArgumentException("geo.area: argument must be a Shape"); } - @Override public String getSyntax() { return "ST_Area()"; } + @Override public String getSyntax() { return "geo.area()"; } @Override public int getMinArgs() { return 1; } @Override public int getMaxArgs() { return 1; } } ``` -`SQLFunctionST_AsGeoJson.java` — use JTS `GeoJsonWriter` (from `org.locationtech.jts.io.geojson`): +`SQLFunctionGeoAsGeoJson.java` — use JTS `GeoJsonWriter` (from `org.locationtech.jts.io.geojson`): ```java // Convert Spatial4j Shape → JTS Geometry → GeoJSON string // GeoUtils.SPATIAL_CONTEXT has getGeometryFrom(Shape) if using JtsSpatialContext @@ -896,20 +896,20 @@ In `DefaultSQLFunctionFactory.java`: register(SQLFunctionRectangle.NAME, new SQLFunctionRectangle()); ``` -2. **Add** the new ST_* registrations in their place: +2. **Add** the new `geo.*` registrations in their place: ```java - register(SQLFunctionST_GeomFromText.NAME, new SQLFunctionST_GeomFromText()); - register(SQLFunctionST_Point.NAME, new SQLFunctionST_Point()); - register(SQLFunctionST_LineString.NAME, new SQLFunctionST_LineString()); - register(SQLFunctionST_Polygon.NAME, new SQLFunctionST_Polygon()); - register(SQLFunctionST_Buffer.NAME, new SQLFunctionST_Buffer()); - register(SQLFunctionST_Envelope.NAME, new SQLFunctionST_Envelope()); - register(SQLFunctionST_Distance.NAME, new SQLFunctionST_Distance()); - register(SQLFunctionST_Area.NAME, new SQLFunctionST_Area()); - register(SQLFunctionST_AsText.NAME, new SQLFunctionST_AsText()); - register(SQLFunctionST_AsGeoJson.NAME, new SQLFunctionST_AsGeoJson()); - register(SQLFunctionST_X.NAME, new SQLFunctionST_X()); - register(SQLFunctionST_Y.NAME, new SQLFunctionST_Y()); + register(SQLFunctionGeoGeomFromText.NAME, new SQLFunctionGeoGeomFromText()); + register(SQLFunctionGeoPoint.NAME, new SQLFunctionGeoPoint()); + register(SQLFunctionGeoLineString.NAME, new SQLFunctionGeoLineString()); + register(SQLFunctionGeoPolygon.NAME, new SQLFunctionGeoPolygon()); + register(SQLFunctionGeoBuffer.NAME, new SQLFunctionGeoBuffer()); + register(SQLFunctionGeoEnvelope.NAME, new SQLFunctionGeoEnvelope()); + register(SQLFunctionGeoDistance.NAME, new SQLFunctionGeoDistance()); + register(SQLFunctionGeoArea.NAME, new SQLFunctionGeoArea()); + register(SQLFunctionGeoAsText.NAME, new SQLFunctionGeoAsText()); + register(SQLFunctionGeoAsGeoJson.NAME, new SQLFunctionGeoAsGeoJson()); + register(SQLFunctionGeoX.NAME, new SQLFunctionGeoX()); + register(SQLFunctionGeoY.NAME, new SQLFunctionGeoY()); ``` **Step 5: Compile to check all references to old classes** @@ -934,7 +934,7 @@ Expected: `BUILD SUCCESS`. git add engine/src/main/java/com/arcadedb/function/sql/geo/ \ engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java \ engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java -git commit -m "feat(geo): add ST_* constructor and accessor functions, remove old geo function names" +git commit -m "feat(geo): add geo.* constructor and accessor functions, remove old geo function names" ``` --- @@ -942,16 +942,16 @@ git commit -m "feat(geo): add ST_* constructor and accessor functions, remove ol ## Task 6: Create Spatial Predicate Functions with IndexableSQLFunction **Files:** -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Predicate.java` (abstract base) -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Within.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Intersects.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Contains.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_DWithin.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Disjoint.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Equals.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Crosses.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Overlaps.java` -- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionST_Touches.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java` (abstract base) +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoWithin.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoIntersects.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoContains.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDWithin.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDisjoint.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoEquals.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoCrosses.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoOverlaps.java` +- Create: `engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoTouches.java` - Modify: `engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java` - Create: `engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoIndexedQueryTest.java` @@ -974,7 +974,7 @@ class SQLGeoIndexedQueryTest extends TestHelper { // ---- Non-indexed (full-scan) predicate evaluation ---- @Test - void stWithinNoIndex() { + void geoWithinNoIndex() { database.command("sql", "CREATE DOCUMENT TYPE Place"); database.transaction(() -> { final MutableDocument d = database.newDocument("Place"); @@ -983,14 +983,14 @@ class SQLGeoIndexedQueryTest extends TestHelper { }); // Point (10,45) is inside POLYGON 5-15, 40-50 final ResultSet rs = database.query("sql", - "SELECT FROM Place WHERE ST_Within(ST_GeomFromText(coords), " + - "ST_GeomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); + "SELECT FROM Place WHERE geo.within(geo.geomFromText(coords), " + + "geo.geomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); assertThat(rs.hasNext()).isTrue(); rs.close(); } @Test - void stWithinOutsideNoIndex() { + void geoWithinOutsideNoIndex() { database.command("sql", "CREATE DOCUMENT TYPE Place2"); database.transaction(() -> { final MutableDocument d = database.newDocument("Place2"); @@ -998,14 +998,14 @@ class SQLGeoIndexedQueryTest extends TestHelper { d.save(); }); final ResultSet rs = database.query("sql", - "SELECT FROM Place2 WHERE ST_Within(ST_GeomFromText(coords), " + - "ST_GeomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); + "SELECT FROM Place2 WHERE geo.within(geo.geomFromText(coords), " + + "geo.geomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); assertThat(rs.hasNext()).isFalse(); rs.close(); } @Test - void stIntersectsNoIndex() { + void geoIntersectsNoIndex() { database.command("sql", "CREATE DOCUMENT TYPE Place3"); database.transaction(() -> { final MutableDocument d = database.newDocument("Place3"); @@ -1013,8 +1013,8 @@ class SQLGeoIndexedQueryTest extends TestHelper { d.save(); }); final ResultSet rs = database.query("sql", - "SELECT FROM Place3 WHERE ST_Intersects(ST_GeomFromText(coords), " + - "ST_GeomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); + "SELECT FROM Place3 WHERE geo.intersects(geo.geomFromText(coords), " + + "geo.geomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); assertThat(rs.hasNext()).isTrue(); rs.close(); } @@ -1022,7 +1022,7 @@ class SQLGeoIndexedQueryTest extends TestHelper { // ---- Indexed predicate evaluation ---- @Test - void stWithinWithIndex() { + void geoWithinWithIndex() { database.command("sql", "CREATE DOCUMENT TYPE IndexedPlace"); database.command("sql", "CREATE PROPERTY IndexedPlace.coords STRING"); database.command("sql", "CREATE INDEX ON IndexedPlace (coords) GEOSPATIAL"); @@ -1035,8 +1035,8 @@ class SQLGeoIndexedQueryTest extends TestHelper { }); final ResultSet rs = database.query("sql", - "SELECT FROM IndexedPlace WHERE ST_Within(coords, " + - "ST_GeomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); + "SELECT FROM IndexedPlace WHERE geo.within(coords, " + + "geo.geomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); int count = 0; while (rs.hasNext()) { @@ -1048,7 +1048,7 @@ class SQLGeoIndexedQueryTest extends TestHelper { } @Test - void stIntersectsWithIndex() { + void geoIntersectsWithIndex() { database.command("sql", "CREATE DOCUMENT TYPE IndexedPlace2"); database.command("sql", "CREATE PROPERTY IndexedPlace2.coords STRING"); database.command("sql", "CREATE INDEX ON IndexedPlace2 (coords) GEOSPATIAL"); @@ -1059,8 +1059,8 @@ class SQLGeoIndexedQueryTest extends TestHelper { }); final ResultSet rs = database.query("sql", - "SELECT FROM IndexedPlace2 WHERE ST_Intersects(coords, " + - "ST_GeomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); + "SELECT FROM IndexedPlace2 WHERE geo.intersects(coords, " + + "geo.geomFromText('POLYGON ((5 40, 15 40, 15 50, 5 50, 5 40))')) = true"); int count = 0; while (rs.hasNext()) { rs.next(); count++; } @@ -1069,7 +1069,7 @@ class SQLGeoIndexedQueryTest extends TestHelper { } @Test - void stContainsWithIndex() { + void geoContainsWithIndex() { database.command("sql", "CREATE DOCUMENT TYPE Region"); database.command("sql", "CREATE PROPERTY Region.bounds STRING"); database.command("sql", "CREATE INDEX ON Region (bounds) GEOSPATIAL"); @@ -1084,8 +1084,8 @@ class SQLGeoIndexedQueryTest extends TestHelper { }); final ResultSet rs = database.query("sql", - "SELECT FROM Region WHERE ST_Contains(bounds, " + - "ST_GeomFromText('POINT (10.0 45.0)')) = true"); + "SELECT FROM Region WHERE geo.contains(bounds, " + + "geo.geomFromText('POINT (10.0 45.0)')) = true"); int count = 0; while (rs.hasNext()) { rs.next(); count++; } @@ -1094,9 +1094,9 @@ class SQLGeoIndexedQueryTest extends TestHelper { } @Test - void stNullReturnsNull() { + void geoNullReturnsNull() { final ResultSet rs = database.query("sql", - "SELECT ST_Within(null, ST_GeomFromText('POINT (0 0)')) as result"); + "SELECT geo.within(null, geo.geomFromText('POINT (0 0)')) as result"); assertThat(rs.hasNext()).isTrue(); final Result row = rs.next(); assertThat(row.getProperty("result")).isNull(); @@ -1111,7 +1111,7 @@ class SQLGeoIndexedQueryTest extends TestHelper { cd engine && mvn test -Dtest=SQLGeoIndexedQueryTest -q 2>&1 | tail -10 ``` -Expected: FAIL — ST_Within etc. not registered. +Expected: FAIL — `geo.within` etc. not registered. **Step 3: Create the abstract base class** @@ -1139,10 +1139,10 @@ import org.locationtech.spatial4j.shape.SpatialRelation; import java.util.ArrayList; import java.util.List; -public abstract class SQLFunctionST_Predicate extends SQLFunctionAbstract +public abstract class SQLFunctionGeoPredicate extends SQLFunctionAbstract implements IndexableSQLFunction { - protected SQLFunctionST_Predicate(final String name) { + protected SQLFunctionGeoPredicate(final String name) { super(name); } @@ -1292,23 +1292,23 @@ public abstract class SQLFunctionST_Predicate extends SQLFunctionAbstract **Step 4: Create the 9 predicate subclasses** -Each is ~20 lines. Example for `ST_Within`: +Each is ~20 lines. Example for `geo.within`: ```java package com.arcadedb.function.sql.geo; import org.locationtech.spatial4j.shape.SpatialRelation; -public class SQLFunctionST_Within extends SQLFunctionST_Predicate { - public static final String NAME = "ST_Within"; +public class SQLFunctionGeoWithin extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.within"; - public SQLFunctionST_Within() { super(NAME); } + public SQLFunctionGeoWithin() { super(NAME); } @Override protected SpatialRelation getExpectedRelation() { return SpatialRelation.WITHIN; } @Override - public String getSyntax() { return "ST_Within(, )"; } + public String getSyntax() { return "geo.within(, )"; } @Override public int getMinArgs() { return 2; } @@ -1319,16 +1319,16 @@ public class SQLFunctionST_Within extends SQLFunctionST_Predicate { ``` Spatial4j `SpatialRelation` values: -- `ST_Within` → `SpatialRelation.WITHIN` -- `ST_Intersects` → `SpatialRelation.INTERSECTS` -- `ST_Contains` → `SpatialRelation.CONTAINS` -- `ST_Disjoint` → `SpatialRelation.DISJOINT` -- `ST_Equals` → override `checkRelation` to use JTS `equals()` +- `geo.within` → `SpatialRelation.WITHIN` +- `geo.intersects` → `SpatialRelation.INTERSECTS` +- `geo.contains` → `SpatialRelation.CONTAINS` +- `geo.disjoint` → `SpatialRelation.DISJOINT` +- `geo.equals` → override `checkRelation` to use JTS `equals()` -For `ST_Crosses`, `ST_Overlaps`, `ST_Touches` — Spatial4j doesn't have these as `SpatialRelation` values. Override `checkRelation` to use JTS topology: +For `geo.crosses`, `geo.overlaps`, `geo.touches` — Spatial4j doesn't have these as `SpatialRelation` values. Override `checkRelation` to use JTS topology: ```java -// ST_Crosses example — needs JTS conversion +// geo.crosses example — needs JTS conversion @Override protected Boolean checkRelation(final Shape g1, final Shape g2) { final org.locationtech.jts.geom.Geometry jg1 = GeoUtils.SPATIAL_CONTEXT.getGeometryFrom(g1); @@ -1337,14 +1337,14 @@ protected Boolean checkRelation(final Shape g1, final Shape g2) { } ``` -`ST_DWithin` has a different signature `(g1, g2, distance)`, so override `execute()` directly: +`geo.dWithin` has a different signature `(g1, g2, distance)`, so override `execute()` directly: ```java -// ST_DWithin: returns true if g1 is within 'distance' of g2 +// geo.dWithin: returns true if g1 is within 'distance' of g2 // Use Spatial4j's distance calculation @Override public Object execute(..., Object[] params, ...) { - if (params.length < 3) throw new IllegalArgumentException("ST_DWithin requires 3 args"); + if (params.length < 3) throw new IllegalArgumentException("geo.dWithin requires 3 args"); if (params[0] == null || params[1] == null || params[2] == null) return null; final Shape g1 = toShape(params[0]); final Shape g2 = toShape(params[1]); @@ -1356,18 +1356,18 @@ public Object execute(..., Object[] params, ...) { **Step 5: Register predicates in DefaultSQLFunctionFactory** -Add after the ST_AsGeoJson registration: +Add after the `geo.asGeoJson` registration: ```java -register(SQLFunctionST_Within.NAME, new SQLFunctionST_Within()); -register(SQLFunctionST_Intersects.NAME, new SQLFunctionST_Intersects()); -register(SQLFunctionST_Contains.NAME, new SQLFunctionST_Contains()); -register(SQLFunctionST_DWithin.NAME, new SQLFunctionST_DWithin()); -register(SQLFunctionST_Disjoint.NAME, new SQLFunctionST_Disjoint()); -register(SQLFunctionST_Equals.NAME, new SQLFunctionST_Equals()); -register(SQLFunctionST_Crosses.NAME, new SQLFunctionST_Crosses()); -register(SQLFunctionST_Overlaps.NAME, new SQLFunctionST_Overlaps()); -register(SQLFunctionST_Touches.NAME, new SQLFunctionST_Touches()); +register(SQLFunctionGeoWithin.NAME, new SQLFunctionGeoWithin()); +register(SQLFunctionGeoIntersects.NAME, new SQLFunctionGeoIntersects()); +register(SQLFunctionGeoContains.NAME, new SQLFunctionGeoContains()); +register(SQLFunctionGeoDWithin.NAME, new SQLFunctionGeoDWithin()); +register(SQLFunctionGeoDisjoint.NAME, new SQLFunctionGeoDisjoint()); +register(SQLFunctionGeoEquals.NAME, new SQLFunctionGeoEquals()); +register(SQLFunctionGeoCrosses.NAME, new SQLFunctionGeoCrosses()); +register(SQLFunctionGeoOverlaps.NAME, new SQLFunctionGeoOverlaps()); +register(SQLFunctionGeoTouches.NAME, new SQLFunctionGeoTouches()); ``` **Step 6: Compile** @@ -1400,7 +1400,7 @@ Fix any failures before committing. git add engine/src/main/java/com/arcadedb/function/sql/geo/ \ engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java \ engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoIndexedQueryTest.java -git commit -m "feat(geo): add ST_* spatial predicate functions with IndexableSQLFunction for automatic index usage" +git commit -m "feat(geo): add geo.* spatial predicate functions with IndexableSQLFunction for automatic index usage" ``` --- @@ -1436,7 +1436,7 @@ Expected: `BUILD SUCCESS`. ```bash git add -A -git commit -m "feat(geo): complete geospatial indexing implementation with ST_* functions and LSMTreeGeoIndex" +git commit -m "feat(geo): complete geospatial indexing implementation with geo.* functions and LSMTreeGeoIndex" ``` --- @@ -1451,6 +1451,6 @@ git commit -m "feat(geo): complete geospatial indexing implementation with ST_* **WKT format:** Spatial4j's WKT reader accepts `POINT (x y)` with a space before the parenthesis. JTS requires `POINT(x y)` without space. The `GeoUtils.getSpatialContext().getFormats().getWktReader()` handles both. -**ST_DWithin distance units:** The base implementation uses degrees. For user-facing meter/km input, add a conversion using `DistanceUtils.dist2Degrees(distKm, DistanceUtils.EARTH_MEAN_RADIUS_KM)` from Spatial4j. +**geo.dWithin distance units:** The base implementation uses degrees. For user-facing meter/km input, add a conversion using `DistanceUtils.dist2Degrees(distKm, DistanceUtils.EARTH_MEAN_RADIUS_KM)` from Spatial4j. **Index loading:** After adding `GEOSPATIAL` to `LocalSchema`'s load path, verify that opening a database with an existing geo index (from disk) correctly instantiates `LSMTreeGeoIndex`. Test by creating a database, inserting data, closing and re-opening it, then querying. From 3dccb160cdce0892a9370caf7c310a9ca387a8fd Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 12:16:09 +0100 Subject: [PATCH 28/47] test(geo): update ST_* section header comments to geo.* naming Co-Authored-By: Claude Sonnet 4.6 --- .../function/sql/geo/SQLGeoFunctionsTest.java | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java index 34aa5cfbc5..f4535e3662 100644 --- a/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java @@ -128,7 +128,7 @@ void geoManualIndexBoundingBoxes() throws Exception { }); } - // ─── ST_* standard function tests ──────────────────────────────────────────── + // ─── geo.* standard function tests ──────────────────────────────────────────── @Test void stGeomFromText() throws Exception { @@ -435,7 +435,7 @@ void stXFromShape() throws Exception { @Test void stXNonPoint() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - // ST_X on a polygon should return null + // geo.x on a polygon should return null ResultSet result = db.query("sql", "select geo.x('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as x"); assertThat(result.hasNext()).isTrue(); @@ -500,7 +500,7 @@ void stYNull() throws Exception { @Test void stPointRoundTrip() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - // ST_Point → ST_X / ST_Y round-trip + // geo.point → geo.x / geo.y round-trip // Note: small floating-point precision loss is expected when going through WKT parsing ResultSet result = db.query("sql", "select geo.x(geo.geomFromText(geo.point(42.5, -7.3))) as x"); assertThat(result.hasNext()).isTrue(); @@ -516,7 +516,7 @@ void stPointRoundTrip() throws Exception { }); } - // ─── ST_Within ──────────────────────────────────────────────────────────────── + // ─── geo.within ──────────────────────────────────────────────────────────────── @Test void stWithinPointInsidePolygon() throws Exception { @@ -549,7 +549,7 @@ void stWithinNullArg() throws Exception { }); } - // ─── ST_Intersects ──────────────────────────────────────────────────────────── + // ─── geo.intersects ──────────────────────────────────────────────────────────── @Test void stIntersectsOverlappingPolygons() throws Exception { @@ -582,7 +582,7 @@ void stIntersectsNullArg() throws Exception { }); } - // ─── ST_Contains ────────────────────────────────────────────────────────────── + // ─── geo.contains ────────────────────────────────────────────────────────────── @Test void stContainsPolygonContainsPoint() throws Exception { @@ -615,7 +615,7 @@ void stContainsNullArg() throws Exception { }); } - // ─── ST_DWithin ─────────────────────────────────────────────────────────────── + // ─── geo.dWithin ─────────────────────────────────────────────────────────────── @Test void stDWithinNearbyPoints() throws Exception { @@ -650,7 +650,7 @@ void stDWithinNullArg() throws Exception { }); } - // ─── ST_Disjoint ────────────────────────────────────────────────────────────── + // ─── geo.disjoint ────────────────────────────────────────────────────────────── @Test void stDisjointFarApartShapes() throws Exception { @@ -683,7 +683,7 @@ void stDisjointNullArg() throws Exception { }); } - // ─── ST_Equals ──────────────────────────────────────────────────────────────── + // ─── geo.equals ──────────────────────────────────────────────────────────────── @Test void stEqualsIdenticalPoints() throws Exception { @@ -716,7 +716,7 @@ void stEqualsNullArg() throws Exception { }); } - // ─── ST_Crosses ─────────────────────────────────────────────────────────────── + // ─── geo.crosses ─────────────────────────────────────────────────────────────── @Test void stCrossesLineCrossesPolygon() throws Exception { @@ -740,7 +740,7 @@ void stCrossesNullArg() throws Exception { }); } - // ─── ST_Overlaps ────────────────────────────────────────────────────────────── + // ─── geo.overlaps ────────────────────────────────────────────────────────────── @Test void stOverlapsPartiallyOverlappingPolygons() throws Exception { @@ -773,7 +773,7 @@ void stOverlapsNullArg() throws Exception { }); } - // ─── ST_Touches ─────────────────────────────────────────────────────────────── + // ─── geo.touches ─────────────────────────────────────────────────────────────── @Test void stTouchesAdjacentPolygons() throws Exception { From c843792d9bcb306fe7233b110a1c1175a5656bb4 Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 12:38:00 +0100 Subject: [PATCH 29/47] refactor(geo): unify factory imports and document FUNCTION_NAMESPACES JavaCC limitation Co-Authored-By: Claude Sonnet 4.6 --- .../function/sql/DefaultSQLFunctionFactory.java | 16 ++++++++-------- .../arcadedb/query/sql/antlr/SQLASTBuilder.java | 4 ++++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java index 27790416f0..e0a3d71449 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java +++ b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java @@ -33,23 +33,23 @@ import com.arcadedb.function.sql.geo.SQLFunctionGeoAsGeoJson; import com.arcadedb.function.sql.geo.SQLFunctionGeoAsText; import com.arcadedb.function.sql.geo.SQLFunctionGeoBuffer; -import com.arcadedb.function.sql.geo.SQLFunctionGeoDistance; -import com.arcadedb.function.sql.geo.SQLFunctionGeoEnvelope; -import com.arcadedb.function.sql.geo.SQLFunctionGeoGeomFromText; -import com.arcadedb.function.sql.geo.SQLFunctionGeoLineString; -import com.arcadedb.function.sql.geo.SQLFunctionGeoPoint; -import com.arcadedb.function.sql.geo.SQLFunctionGeoPolygon; -import com.arcadedb.function.sql.geo.SQLFunctionGeoX; -import com.arcadedb.function.sql.geo.SQLFunctionGeoY; import com.arcadedb.function.sql.geo.SQLFunctionGeoContains; import com.arcadedb.function.sql.geo.SQLFunctionGeoCrosses; import com.arcadedb.function.sql.geo.SQLFunctionGeoDisjoint; +import com.arcadedb.function.sql.geo.SQLFunctionGeoDistance; import com.arcadedb.function.sql.geo.SQLFunctionGeoDWithin; +import com.arcadedb.function.sql.geo.SQLFunctionGeoEnvelope; import com.arcadedb.function.sql.geo.SQLFunctionGeoEquals; +import com.arcadedb.function.sql.geo.SQLFunctionGeoGeomFromText; import com.arcadedb.function.sql.geo.SQLFunctionGeoIntersects; +import com.arcadedb.function.sql.geo.SQLFunctionGeoLineString; import com.arcadedb.function.sql.geo.SQLFunctionGeoOverlaps; +import com.arcadedb.function.sql.geo.SQLFunctionGeoPoint; +import com.arcadedb.function.sql.geo.SQLFunctionGeoPolygon; import com.arcadedb.function.sql.geo.SQLFunctionGeoTouches; import com.arcadedb.function.sql.geo.SQLFunctionGeoWithin; +import com.arcadedb.function.sql.geo.SQLFunctionGeoX; +import com.arcadedb.function.sql.geo.SQLFunctionGeoY; import com.arcadedb.function.sql.graph.SQLFunctionAstar; import com.arcadedb.function.sql.graph.SQLFunctionBellmanFord; import com.arcadedb.function.sql.graph.SQLFunctionBoth; diff --git a/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java b/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java index 1ad021d298..7f6f54095b 100644 --- a/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java +++ b/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java @@ -49,6 +49,10 @@ public class SQLASTBuilder extends SQLParserBaseVisitor { /** + * Namespaces that are recognized as function call prefixes in dotted SQL syntax (e.g. {@code geo.point(x, y)}). + * This rewriting is performed in {@link #visitIdentifierChain} for the ANTLR parser only. + * The JavaCC parser does not support unquoted dotted function names; use backtick-quoted names + * (e.g. {@code `geo.point`(x, y)}) for compatibility with both parsers. * Known function namespace prefixes. When the parser sees {@code namespace.method(args)} and the namespace * is in this set, the AST builder produces a {@link FunctionCall} node with the qualified name * (e.g., "ts.first") instead of an identifier chain with a method modifier. From f969aa079302192fb333a8c1407b6a022a68b196 Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 14:02:47 +0100 Subject: [PATCH 30/47] =?UTF-8?q?fix(geo):=20address=20code=20review=20iss?= =?UTF-8?q?ues=20=E2=80=94=20remove=20dev=20files,=20fix=20build()=20token?= =?UTF-8?q?ization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove .claude/settings.local.json from tracking and add to .gitignore - Strip internal AI tool directive from geospatial implementation plan doc - Fix LSMTreeGeoIndex.build() to scan bucket and call this.put() via the indexer, instead of delegating to underlyingIndex.build() which bypassed GeoHash tokenization and stored raw WKT keys in the index Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 41 ------------------- .gitignore | 1 + .../2026-02-22-geospatial-implementation.md | 2 - .../index/geospatial/LSMTreeGeoIndex.java | 36 +++++++++++++++- 4 files changed, 36 insertions(+), 44 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 5ba9f4b16a..0000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(grep:*)", - "WebSearch", - "WebFetch(domain:raw.githubusercontent.com)", - "WebFetch(domain:github.com)", - "Bash(mvn clean compile:*)", - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(mvn test:*)", - "Bash(mvn compile:*)", - "Bash(jar tf:*)", - "Bash(mvn dependency:resolve:*)", - "Bash(while read jar)", - "Bash(do echo \"=== $jar ===\")", - "Bash(done)", - "Bash(# Check RaftServer.Builder API, RaftPeer, RaftGroup, RaftClient APIs javap -p -cp ~/.m2/repository/org/apache/ratis/ratis-server-api/3.2.0/ratis-server-api-3.2.0.jar org.apache.ratis.server.RaftServer)", - "Bash(javap:*)", - "Bash(mvn install:*)", - "Bash(echo:*)", - "Bash(wait)", - "Bash(mvn test-compile:*)", - "Bash(mvn clean test-compile:*)", - "Bash(mvn clean install:*)", - "Bash(mvn verify:*)", - "Bash(xargs:*)", - "Bash(do jar tf:*)", - "Bash(pkill:*)", - "Bash(./mvnw test:*)", - "Bash(git log:*)", - "Bash(./mvnw compile:*)", - "Bash(./mvnw install:*)", - "Bash(tail:*)", - "Bash(mvn:*)", - "Bash(head:*)", - "Bash(python3:*)", - "Bash(git:*)" - ] - } -} diff --git a/.gitignore b/.gitignore index 2289c55ab2..2c426a43d2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ hs_err_pid* .idea/ +.claude/settings.local.json ### Maven template target/ gen/ diff --git a/docs/plans/2026-02-22-geospatial-implementation.md b/docs/plans/2026-02-22-geospatial-implementation.md index 627b5c72a6..368460cde3 100644 --- a/docs/plans/2026-02-22-geospatial-implementation.md +++ b/docs/plans/2026-02-22-geospatial-implementation.md @@ -1,7 +1,5 @@ # Geospatial Indexing Implementation Plan -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - **Goal:** Port OrientDB-style geospatial indexing to ArcadeDB with `geo.*` SQL functions and automatic query optimizer integration. **Architecture:** `LSMTreeGeoIndex` wraps `LSMTreeIndex` (same pattern as `LSMTreeFullTextIndex`). `lucene-spatial-extras` `GeohashPrefixTree` decomposes WKT geometries into GeoHash cell tokens stored in LSM-Tree. `geo.*` predicate functions implement `IndexableSQLFunction` so the query optimizer uses the geo index automatically when `WHERE geo.within(field, shape) = true` is detected. diff --git a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java index 39eba46ffa..7f248ee69b 100644 --- a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java +++ b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java @@ -19,6 +19,7 @@ package com.arcadedb.index.geospatial; import com.arcadedb.database.DatabaseInternal; +import com.arcadedb.database.Document; import com.arcadedb.database.Identifiable; import com.arcadedb.database.RID; import com.arcadedb.engine.ComponentFile; @@ -58,6 +59,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; import java.util.logging.Level; /** @@ -431,7 +433,39 @@ public TypeIndex getTypeIndex() { @Override public long build(final int buildIndexBatchSize, final BuildIndexCallback callback) { - return underlyingIndex.build(buildIndexBatchSize, callback); + // Must NOT delegate to underlyingIndex.build(), because that would pass the raw LSMTreeIndex + // to DocumentIndexer.addToIndex(), bypassing GeoHash tokenization and storing raw WKT keys. + // Instead, scan the bucket and call this.put() through the indexer so tokenization runs. + final DatabaseInternal db = underlyingIndex.getComponent().getDatabase(); + final int bucketId = underlyingIndex.getAssociatedBucketId(); + if (bucketId < 0) + return 0; + + final String bucketName = db.getSchema().getBucketById(bucketId).getName(); + final AtomicLong total = new AtomicLong(); + final long startTime = System.currentTimeMillis(); + + LogManager.instance().log(this, Level.INFO, "Building geospatial index '%s'...", getName()); + + db.scanBucket(bucketName, record -> { + db.getIndexer().addToIndex(LSMTreeGeoIndex.this, record.getIdentity(), (Document) record); + total.incrementAndGet(); + + if (total.get() % buildIndexBatchSize == 0) { + db.getWrappedDatabaseInstance().commit(); + db.getWrappedDatabaseInstance().begin(); + } + + if (callback != null) + callback.onDocumentIndexed((Document) record, total.get()); + + return true; + }); + + LogManager.instance().log(this, Level.INFO, "Completed building geospatial index '%s': processed %d records in %dms", + getName(), total.get(), System.currentTimeMillis() - startTime); + + return total.get(); } @Override From 1bbeaa9048a7b8dcda459a77823f183e5aa0223b Mon Sep 17 00:00:00 2001 From: robfrank Date: Mon, 23 Feb 2026 18:39:18 +0100 Subject: [PATCH 31/47] add geolocation index on photo to lead tests --- .../com/arcadedb/test/support/DatabaseWrapper.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/load-tests/src/test/java/com/arcadedb/test/support/DatabaseWrapper.java b/load-tests/src/test/java/com/arcadedb/test/support/DatabaseWrapper.java index e3303d579b..479cc62932 100644 --- a/load-tests/src/test/java/com/arcadedb/test/support/DatabaseWrapper.java +++ b/load-tests/src/test/java/com/arcadedb/test/support/DatabaseWrapper.java @@ -51,6 +51,11 @@ public class DatabaseWrapper { private final Timer friendshipTimer; private final Timer likeTimer; + private static final double GEO_LON_MIN = -180.0; + private static final double GEO_LON_RANGE = 360.0; + private static final double GEO_LAT_MIN = -90.0; + private static final double GEO_LAT_RANGE = 180.0; + public enum Protocol {HTTP, GRPC} public DatabaseWrapper(ServerWrapper server, Supplier idSupplier, Supplier wordSupplier, Protocol protocol) { @@ -131,12 +136,14 @@ public void createSchema() { CREATE PROPERTY Photo.id INTEGER; CREATE PROPERTY Photo.description STRING; CREATE PROPERTY Photo.tags LIST OF STRING; + CREATE PROPERTY Photo.location STRING; CREATE INDEX ON Photo (id) UNIQUE; CREATE INDEX ON Photo (tags BY ITEM) NOTUNIQUE; CREATE INDEX ON Photo (description) FULL_TEXT METADATA { "analyzer": "org.apache.lucene.analysis.en.EnglishAnalyzer" }; + CREATE INDEX ON Photo (location) GEOSPATIAL; CREATE EDGE TYPE HasUploaded; @@ -214,17 +221,20 @@ private void addPhotosOfUser(int userId, int numberOfPhotos) { String tag1 = "tag" + i % numberOfPhotos; String tag2 = "tag" + (i % numberOfPhotos + 1); String description = IntStream.range(0, 100).mapToObj(j -> wordSupplier.get()).reduce((a, b) -> a + " " + b).orElse(""); + double lon = Math.round((GEO_LON_MIN + Math.random() * GEO_LON_RANGE) * 1e6) / 1e6; + double lat = Math.round((GEO_LAT_MIN + Math.random() * GEO_LAT_RANGE) * 1e6) / 1e6; + String location = String.format("POINT (%s %s)", lon, lat); String sqlScript = """ BEGIN; LOCK TYPE User, Photo, HasUploaded; LET user = SELECT FROM User WHERE id = ?; - LET photo = CREATE VERTEX Photo SET id = ?, name = ?, description = '?', tags = ['?', '?']; + LET photo = CREATE VERTEX Photo SET id = ?, name = ?, description = '?', tags = ['?', '?'], location = ?; CREATE EDGE HasUploaded FROM $user TO $photo; COMMIT RETRY 30; """; try { photosTimer.record(() -> { - db.command("sqlscript", sqlScript, userId, photoId, photoName, description, tag1, tag2); + db.command("sqlscript", sqlScript, userId, photoId, photoName, description, tag1, tag2, location); } ); From d7080acf26706243a6db8f991e830764d764e0d1 Mon Sep 17 00:00:00 2001 From: robfrank Date: Tue, 24 Feb 2026 13:27:49 +0100 Subject: [PATCH 32/47] reduce numbers --- .../com/arcadedb/test/load/SingleServerLoadTestIT.java | 8 ++++---- .../arcadedb/test/load/SingleServerSimpleLoadTestIT.java | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/load-tests/src/test/java/com/arcadedb/test/load/SingleServerLoadTestIT.java b/load-tests/src/test/java/com/arcadedb/test/load/SingleServerLoadTestIT.java index 9311380e26..af154f661c 100644 --- a/load-tests/src/test/java/com/arcadedb/test/load/SingleServerLoadTestIT.java +++ b/load-tests/src/test/java/com/arcadedb/test/load/SingleServerLoadTestIT.java @@ -48,10 +48,10 @@ void singleServerLoadTest(DatabaseWrapper.Protocol protocol) throws Exception { // Parameters for the test final int numOfThreads = 5; //number of threads to use to insert users and photos - final int numOfUsers = 2000; // Each thread will create 200000 users + final int numOfUsers = 1000; // Each thread will create 200000 users final int numOfPhotos = 10; // Each user will have 5 photos - final int numOfFriendship = 1000; // Each thread will create 100000 friendships - final int numOfLike = 1000; // Each thread will create 100000 likes + final int numOfFriendship = 500; // Each thread will create 100000 friendships + final int numOfLike = 500; // Each thread will create 100000 likes int expectedUsersCount = numOfUsers * numOfThreads; int expectedPhotoCount = expectedUsersCount * numOfPhotos; @@ -101,7 +101,7 @@ void singleServerLoadTest(DatabaseWrapper.Protocol protocol) throws Exception { } try { // Wait for 2 seconds before checking again - TimeUnit.SECONDS.sleep(2); + TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } diff --git a/load-tests/src/test/java/com/arcadedb/test/load/SingleServerSimpleLoadTestIT.java b/load-tests/src/test/java/com/arcadedb/test/load/SingleServerSimpleLoadTestIT.java index 00f9628f17..1bfec3eb92 100644 --- a/load-tests/src/test/java/com/arcadedb/test/load/SingleServerSimpleLoadTestIT.java +++ b/load-tests/src/test/java/com/arcadedb/test/load/SingleServerSimpleLoadTestIT.java @@ -48,7 +48,7 @@ void singleServerLoadTest(DatabaseWrapper.Protocol protocol) throws Exception { db.createSchema(); final int numOfThreads = 1; - final int numOfUsers = 10000; + final int numOfUsers = 1000; final int numOfPhotos = 10; int expectedUsersCount = numOfUsers * numOfThreads; From 02cf6971c74eb80c3f0966acb7a6a8d1c3528ba0 Mon Sep 17 00:00:00 2001 From: robfrank Date: Tue, 24 Feb 2026 13:51:20 +0100 Subject: [PATCH 33/47] chore: assertj and fqns --- .../arcadedb/bolt/BoltNetworkExecutor.java | 5 ++- .../sql/geo/SQLFunctionGeoPredicate.java | 2 +- .../algo/AlgoBiconnectedComponents.java | 6 ++- .../procedures/algo/AlgoBipartiteCheck.java | 3 +- .../algo/AlgoClosenessCentrality.java | 3 +- .../procedures/algo/AlgoCycleDetection.java | 3 +- .../procedures/algo/AlgoDensestSubgraph.java | 3 +- .../algo/AlgoDijkstraSingleSource.java | 4 +- .../procedures/algo/AlgoEccentricity.java | 3 +- .../algo/AlgoEigenvectorCentrality.java | 3 +- .../procedures/algo/AlgoGraphColoring.java | 3 +- .../procedures/algo/AlgoGraphSAGE.java | 3 +- .../opencypher/procedures/algo/AlgoHITS.java | 3 +- .../algo/AlgoHarmonicCentrality.java | 3 +- .../algo/AlgoHierarchicalClustering.java | 3 +- .../opencypher/procedures/algo/AlgoKCore.java | 3 +- .../opencypher/procedures/algo/AlgoKatz.java | 3 +- .../algo/AlgoLocalClusteringCoefficient.java | 3 +- .../opencypher/procedures/algo/AlgoMST.java | 3 +- .../procedures/algo/AlgoMaxKCut.java | 5 ++- .../opencypher/procedures/algo/AlgoSCC.java | 3 +- .../procedures/algo/AlgoTopologicalSort.java | 3 +- .../procedures/algo/AlgoTriangleCount.java | 3 +- .../com/arcadedb/utility/SingletonSet.java | 3 +- .../java/com/arcadedb/DatabaseStatsTest.java | 2 +- .../FunctionReferenceGeneratorTest.java | 2 +- .../function/sql/geo/SQLGeoFunctionsTest.java | 5 ++- .../FullTextNestedListByItemTest.java | 2 +- .../query/OperationTypeIntegrationTest.java | 38 ++++++++-------- .../procedures/algo/AlgoBellmanFordTest.java | 3 +- .../query/sql/parser/OperationTypeTest.java | 42 +++++++++--------- .../schema/MaterializedViewEdgeCaseTest.java | 2 +- .../schema/MaterializedViewSQLTest.java | 5 ++- .../arcadedb/schema/MaterializedViewTest.java | 3 +- .../arcadedb/server/mcp/MCPHttpHandler.java | 3 +- .../server/ApiTokenAuthenticationIT.java | 39 ++++++++-------- .../arcadedb/server/GroupManagementIT.java | 14 +++--- .../com/arcadedb/server/UserManagementIT.java | 12 ++--- .../server/mcp/MCPConfigurationTest.java | 21 ++++----- .../server/mcp/MCPPermissionsTest.java | 28 ++++++------ .../server/mcp/MCPServerPluginTest.java | 44 +++++++++---------- .../security/ApiTokenConfigurationTest.java | 31 +++++++------ .../server/security/ServerSecurityIT.java | 6 ++- 43 files changed, 208 insertions(+), 173 deletions(-) diff --git a/bolt/src/main/java/com/arcadedb/bolt/BoltNetworkExecutor.java b/bolt/src/main/java/com/arcadedb/bolt/BoltNetworkExecutor.java index f43155ae43..b8eb284392 100644 --- a/bolt/src/main/java/com/arcadedb/bolt/BoltNetworkExecutor.java +++ b/bolt/src/main/java/com/arcadedb/bolt/BoltNetworkExecutor.java @@ -66,6 +66,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeSet; import java.util.logging.Level; import static com.arcadedb.query.opencypher.executor.steps.FinalProjectionStep.PROJECTION_NAME_METADATA; @@ -1012,7 +1013,7 @@ private boolean handleSystemQuery(final String query) throws IOException { syntheticResults.add(List.of((Object) relTypes)); // Property keys (from all non-composite types) - final Set allKeys = new java.util.TreeSet<>(); + final Set allKeys = new TreeSet<>(); for (final DocumentType type : database.getSchema().getTypes()) if (!type.getName().contains("~")) allKeys.addAll(type.getPropertyNames()); @@ -1047,7 +1048,7 @@ private boolean handleSystemQuery(final String query) throws IOException { currentFields = List.of("propertyKey"); syntheticResults = new ArrayList<>(); if (database != null) { - final Set allKeys = new java.util.TreeSet<>(); + final Set allKeys = new TreeSet<>(); for (final DocumentType type : database.getSchema().getTypes()) { if (!type.getName().contains("~")) allKeys.addAll(type.getPropertyNames()); diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java index d34d1f65d0..a2e4493f7f 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java @@ -232,7 +232,7 @@ private static Shape resolveSearchShape(final Expression[] oExpressions, final C if (oExpressions.length < 2 || oExpressions[1] == null) return null; // Evaluate the second expression in the context of a null record to get the shape value - final Object value = oExpressions[1].execute((com.arcadedb.database.Identifiable) null, context); + final Object value = oExpressions[1].execute((Identifiable) null, context); if (value == null) return null; return GeoUtils.parseGeometry(value); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoBiconnectedComponents.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoBiconnectedComponents.java index 74b20221ef..9a1799780c 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoBiconnectedComponents.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoBiconnectedComponents.java @@ -29,9 +29,11 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Deque; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Stream; /** @@ -180,7 +182,7 @@ private void dfs(final int root, final int[][] adj, // If p is an articulation point w.r.t. edge (p, u), pop a biconnected component if (low[u] >= disc[p]) { final int cid = compId[0]++; - final java.util.Set componentNodes = new java.util.HashSet<>(); + final Set componentNodes = new HashSet<>(); while (true) { final int[] edge = edgeStack.pop(); componentNodes.add(edge[0]); @@ -195,7 +197,7 @@ private void dfs(final int root, final int[][] adj, // Root — pop remaining edges as one component if (!edgeStack.isEmpty()) { final int cid = compId[0]++; - final java.util.Set componentNodes = new java.util.HashSet<>(); + final Set componentNodes = new HashSet<>(); while (!edgeStack.isEmpty()) { final int[] edge = edgeStack.pop(); componentNodes.add(edge[0]); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoBipartiteCheck.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoBipartiteCheck.java index fa8887b5c1..daf536a937 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoBipartiteCheck.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoBipartiteCheck.java @@ -30,6 +30,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -124,7 +125,7 @@ public Stream execute(final Object[] args, final Result inputRow, final final boolean finalBipartite = bipartite; - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final ResultInternal r = new ResultInternal(); r.setProperty("node", vertices.get(i)); r.setProperty("partition", color[i] == -1 ? 0 : color[i]); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoClosenessCentrality.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoClosenessCentrality.java index 1e30076b29..bed856df25 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoClosenessCentrality.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoClosenessCentrality.java @@ -30,6 +30,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -136,7 +137,7 @@ public Stream execute(final Object[] args, final Result inputRow, final } // Build result stream - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final ResultInternal r = new ResultInternal(); r.setProperty("node", vertices.get(i)); r.setProperty("score", scores[i]); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoCycleDetection.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoCycleDetection.java index dfac83a333..af82f3c50c 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoCycleDetection.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoCycleDetection.java @@ -30,6 +30,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -183,7 +184,7 @@ public Stream execute(final Object[] args, final Result inputRow, final final boolean finalHasCycle = hasCycle; - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final ResultInternal r = new ResultInternal(); r.setProperty("node", vertices.get(i)); r.setProperty("inCycle", inCycle[i]); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoDensestSubgraph.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoDensestSubgraph.java index 0f9167a62d..d697fcf72c 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoDensestSubgraph.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoDensestSubgraph.java @@ -30,6 +30,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -157,7 +158,7 @@ public Stream execute(final Object[] args, final Result inputRow, final final double finalDensity = bestDensity; - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final ResultInternal r = new ResultInternal(); r.setProperty("node", vertices.get(i)); r.setProperty("inDenseSubgraph", bestSubgraph[i]); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoDijkstraSingleSource.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoDijkstraSingleSource.java index 049a94cf25..12d75b04e2 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoDijkstraSingleSource.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoDijkstraSingleSource.java @@ -28,10 +28,12 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.PriorityQueue; +import java.util.Set; import java.util.stream.Stream; /** @@ -124,7 +126,7 @@ public Stream execute(final Object[] args, final Result inputRow, final heap.offer(new double[]{ 0.0, src }); // Build rel-type filter set for fast lookup - final java.util.Set relTypeSet = relTypes != null ? new java.util.HashSet<>(Arrays.asList(relTypes)) : null; + final Set relTypeSet = relTypes != null ? new HashSet<>(Arrays.asList(relTypes)) : null; while (!heap.isEmpty()) { final double[] entry = heap.poll(); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoEccentricity.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoEccentricity.java index ad8e34fb89..bad1758bc1 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoEccentricity.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoEccentricity.java @@ -30,6 +30,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -138,7 +139,7 @@ public Stream execute(final Object[] args, final Result inputRow, final final int finalDiameter = diameter; final int finalRadius = radius; - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final ResultInternal r = new ResultInternal(); r.setProperty("node", vertices.get(i)); r.setProperty("eccentricity", ecc[i]); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoEigenvectorCentrality.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoEigenvectorCentrality.java index f9f2cd809b..81581e4252 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoEigenvectorCentrality.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoEigenvectorCentrality.java @@ -29,6 +29,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -137,7 +138,7 @@ public Stream execute(final Object[] args, final Result inputRow, final } final double[] finalScores = scores; - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final ResultInternal r = new ResultInternal(); r.setProperty("node", vertices.get(i)); r.setProperty("score", finalScores[i]); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoGraphColoring.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoGraphColoring.java index 13a3bfe8b3..b0fc7458e9 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoGraphColoring.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoGraphColoring.java @@ -30,6 +30,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -122,7 +123,7 @@ public Stream execute(final Object[] args, final Result inputRow, final final int finalChromaticNumber = chromaticNumber; - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final ResultInternal r = new ResultInternal(); r.setProperty("node", vertices.get(i)); r.setProperty("color", color[i]); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoGraphSAGE.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoGraphSAGE.java index bc7fb07776..6c11742d48 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoGraphSAGE.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoGraphSAGE.java @@ -26,6 +26,7 @@ import com.arcadedb.query.sql.executor.ResultInternal; import java.util.ArrayList; +import java.util.Arrays; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -152,7 +153,7 @@ public Stream execute(final Object[] args, final Result inputRow, final for (int i = 0; i < n; i++) { // Mean aggregation over neighbours final int deg = degree[i]; - java.util.Arrays.fill(agg, 0.0); + Arrays.fill(agg, 0.0); if (deg > 0) { for (final int j : adj[i]) for (int d = 0; d < curDim; d++) diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoHITS.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoHITS.java index 5e99f5a4ff..ff393c7021 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoHITS.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoHITS.java @@ -29,6 +29,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -163,7 +164,7 @@ public Stream execute(final Object[] args, final Result inputRow, final final double[] finalHub = hub; final double[] finalAuth = auth; - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final ResultInternal r = new ResultInternal(); r.setProperty("node", vertices.get(i)); r.setProperty("hubScore", finalHub[i]); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoHarmonicCentrality.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoHarmonicCentrality.java index caf5b8a780..7c9c6ba790 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoHarmonicCentrality.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoHarmonicCentrality.java @@ -30,6 +30,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -124,7 +125,7 @@ public Stream execute(final Object[] args, final Result inputRow, final scores[src] = normalized && n > 1 ? harmonicSum / (n - 1) : harmonicSum; } - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final ResultInternal r = new ResultInternal(); r.setProperty("node", vertices.get(i)); r.setProperty("score", scores[i]); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoHierarchicalClustering.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoHierarchicalClustering.java index 632858cfa0..9b6dff142c 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoHierarchicalClustering.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoHierarchicalClustering.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.BitSet; +import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -155,7 +156,7 @@ public Stream execute(final Object[] args, final Result inputRow, final } // Remap cluster IDs (find roots) to sequential IDs - final java.util.Map clusterRemap = new java.util.HashMap<>(); + final Map clusterRemap = new HashMap<>(); int nextId = 0; for (int i = 0; i < n; i++) { final int root = find(parent, i); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoKCore.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoKCore.java index 40ccab0859..4917a01e42 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoKCore.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoKCore.java @@ -30,6 +30,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -150,7 +151,7 @@ public Stream execute(final Object[] args, final Result inputRow, final } } - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final ResultInternal r = new ResultInternal(); r.setProperty("node", vertices.get(i)); r.setProperty("coreNumber", core[i]); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoKatz.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoKatz.java index ebcbb3a617..065b3514f1 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoKatz.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoKatz.java @@ -30,6 +30,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -143,7 +144,7 @@ public Stream execute(final Object[] args, final Result inputRow, final final double[] finalScores = scores; final double finalMax = maxScore > 0 ? maxScore : 1.0; - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final ResultInternal r = new ResultInternal(); r.setProperty("nodeId", vertices.get(i).getIdentity()); r.setProperty("score", finalScores[i] / finalMax); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoLocalClusteringCoefficient.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoLocalClusteringCoefficient.java index 992df9fe17..c9cb3fdffd 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoLocalClusteringCoefficient.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoLocalClusteringCoefficient.java @@ -30,6 +30,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -121,7 +122,7 @@ public Stream execute(final Object[] args, final Result inputRow, final triangles[u] = count / 2; } - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final long deg = adj[i].length; final double coeff = deg < 2 ? 0.0 : (2.0 * triangles[i]) / (double) (deg * (deg - 1)); final ResultInternal r = new ResultInternal(); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoMST.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoMST.java index 3d0efb391e..1846f44e0c 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoMST.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoMST.java @@ -31,6 +31,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -171,7 +172,7 @@ public Stream execute(final Object[] args, final Result inputRow, final final double finalTotal = totalWeight; final int finalSize = mstSize; - return java.util.stream.IntStream.range(0, finalSize).mapToObj(i -> { + return IntStream.range(0, finalSize).mapToObj(i -> { final ResultInternal r = new ResultInternal(); r.setProperty("source", vertices.get(mstU[i])); r.setProperty("target", vertices.get(mstV[i])); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoMaxKCut.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoMaxKCut.java index 0b20e3271c..df149db4d6 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoMaxKCut.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoMaxKCut.java @@ -20,6 +20,7 @@ import com.arcadedb.database.Database; import com.arcadedb.database.RID; +import com.arcadedb.graph.Edge; import com.arcadedb.graph.Vertex; import com.arcadedb.query.sql.executor.CommandContext; import com.arcadedb.query.sql.executor.Result; @@ -205,11 +206,11 @@ private double[][] buildWeightedAdj(final List vertices, final Map edges = relTypes != null && relTypes.length > 0 ? + final Iterable edges = relTypes != null && relTypes.length > 0 ? vertices.get(i).getEdges(dir, relTypes) : vertices.get(i).getEdges(dir); int pos = 0; - for (final com.arcadedb.graph.Edge e : edges) { + for (final Edge e : edges) { final RID nbRid = neighborRid(e, vertices.get(i).getIdentity(), dir); if (nbRid == null || !ridToIdx.containsKey(nbRid)) continue; diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoSCC.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoSCC.java index 1908e8b683..4d9a877950 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoSCC.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoSCC.java @@ -30,6 +30,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -160,7 +161,7 @@ public Stream execute(final Object[] args, final Result inputRow, final numComponents++; } - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final ResultInternal r = new ResultInternal(); r.setProperty("node", vertices.get(i)); r.setProperty("componentId", comp[i]); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoTopologicalSort.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoTopologicalSort.java index 493b95042c..4461b67b0a 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoTopologicalSort.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoTopologicalSort.java @@ -30,6 +30,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -125,7 +126,7 @@ public Stream execute(final Object[] args, final Result inputRow, final } // Vertices with order == -1 are part of a cycle - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final ResultInternal r = new ResultInternal(); r.setProperty("node", vertices.get(i)); r.setProperty("order", order[i]); diff --git a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoTriangleCount.java b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoTriangleCount.java index b7cc8e6692..4e4a23050c 100644 --- a/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoTriangleCount.java +++ b/engine/src/main/java/com/arcadedb/query/opencypher/procedures/algo/AlgoTriangleCount.java @@ -30,6 +30,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import java.util.stream.Stream; /** @@ -125,7 +126,7 @@ public Stream execute(final Object[] args, final Result inputRow, final triangles[u] = count / 2; } - return java.util.stream.IntStream.range(0, n).mapToObj(i -> { + return IntStream.range(0, n).mapToObj(i -> { final long deg = adj[i].length; final double coeff = deg < 2 ? 0.0 : (2.0 * triangles[i]) / (double) (deg * (deg - 1)); final ResultInternal r = new ResultInternal(); diff --git a/engine/src/main/java/com/arcadedb/utility/SingletonSet.java b/engine/src/main/java/com/arcadedb/utility/SingletonSet.java index d308979c32..05a6491e4c 100644 --- a/engine/src/main/java/com/arcadedb/utility/SingletonSet.java +++ b/engine/src/main/java/com/arcadedb/utility/SingletonSet.java @@ -21,6 +21,7 @@ import java.util.AbstractSet; import java.util.Iterator; import java.util.NoSuchElementException; +import java.util.Set; /** * Lightweight immutable set with exactly one element. @@ -79,7 +80,7 @@ public int hashCode() { public boolean equals(final Object o) { if (this == o) return true; - if (!(o instanceof java.util.Set other)) + if (!(o instanceof Set other)) return false; if (other.size() != 1) return false; diff --git a/engine/src/test/java/com/arcadedb/DatabaseStatsTest.java b/engine/src/test/java/com/arcadedb/DatabaseStatsTest.java index 5c3ea4ceb9..ec2a2727bb 100644 --- a/engine/src/test/java/com/arcadedb/DatabaseStatsTest.java +++ b/engine/src/test/java/com/arcadedb/DatabaseStatsTest.java @@ -36,7 +36,7 @@ class DatabaseStatsTest extends TestHelper { @Test - void testCRUDStatsCounters() { + void crudStatsCounters() { database.getSchema().createDocumentType("StatsDoc").createProperty("name", Type.STRING); final Map statsBefore = database.getStats(); diff --git a/engine/src/test/java/com/arcadedb/function/FunctionReferenceGeneratorTest.java b/engine/src/test/java/com/arcadedb/function/FunctionReferenceGeneratorTest.java index 9ee21857d4..1dc39433ef 100644 --- a/engine/src/test/java/com/arcadedb/function/FunctionReferenceGeneratorTest.java +++ b/engine/src/test/java/com/arcadedb/function/FunctionReferenceGeneratorTest.java @@ -51,7 +51,7 @@ class FunctionReferenceGeneratorTest { @Test - void generateFunctionReference() throws IOException { + void generateFunctionReference() throws Exception { final JSONObject root = new JSONObject(); root.put("generated", LocalDate.now().toString()); diff --git a/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java index f4535e3662..c866b34e48 100644 --- a/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java @@ -27,6 +27,7 @@ import com.arcadedb.schema.Schema; import com.arcadedb.schema.Type; +import org.assertj.core.data.Offset; import org.junit.jupiter.api.Test; import org.locationtech.spatial4j.io.GeohashUtils; import org.locationtech.spatial4j.shape.Shape; @@ -506,13 +507,13 @@ void stPointRoundTrip() throws Exception { assertThat(result.hasNext()).isTrue(); final Double x = result.next().getProperty("x"); assertThat(x).isNotNull(); - assertThat(x).isCloseTo(42.5, org.assertj.core.data.Offset.offset(1e-6)); + assertThat(x).isCloseTo(42.5, Offset.offset(1e-6)); result = db.query("sql", "select geo.y(geo.geomFromText(geo.point(42.5, -7.3))) as y"); assertThat(result.hasNext()).isTrue(); final Double y = result.next().getProperty("y"); assertThat(y).isNotNull(); - assertThat(y).isCloseTo(-7.3, org.assertj.core.data.Offset.offset(1e-6)); + assertThat(y).isCloseTo(-7.3, Offset.offset(1e-6)); }); } diff --git a/engine/src/test/java/com/arcadedb/index/fulltext/FullTextNestedListByItemTest.java b/engine/src/test/java/com/arcadedb/index/fulltext/FullTextNestedListByItemTest.java index 7b6252ce27..36acd11c37 100644 --- a/engine/src/test/java/com/arcadedb/index/fulltext/FullTextNestedListByItemTest.java +++ b/engine/src/test/java/com/arcadedb/index/fulltext/FullTextNestedListByItemTest.java @@ -32,7 +32,7 @@ * * @author Luca Garulli (l.garulli@arcadedata.com) */ -public class FullTextNestedListByItemTest extends TestHelper { +class FullTextNestedListByItemTest extends TestHelper { @Test void containsTextOnNestedPathUsesIndex() { diff --git a/engine/src/test/java/com/arcadedb/query/OperationTypeIntegrationTest.java b/engine/src/test/java/com/arcadedb/query/OperationTypeIntegrationTest.java index da6439e59e..2ee2e333f3 100644 --- a/engine/src/test/java/com/arcadedb/query/OperationTypeIntegrationTest.java +++ b/engine/src/test/java/com/arcadedb/query/OperationTypeIntegrationTest.java @@ -34,28 +34,28 @@ class OperationTypeIntegrationTest extends TestHelper { // --- SQL via engine.analyze() --- @Test - void testSqlSelectViaEngine() { + void sqlSelectViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("sql").analyze("SELECT FROM V"); assertThat(analyzed.isIdempotent()).isTrue(); assertThat(analyzed.getOperationTypes()).containsExactly(OperationType.READ); } @Test - void testSqlInsertViaEngine() { + void sqlInsertViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("sql").analyze("INSERT INTO V SET name = 'test'"); assertThat(analyzed.isIdempotent()).isFalse(); assertThat(analyzed.getOperationTypes()).containsExactly(OperationType.CREATE); } @Test - void testSqlUpdateViaEngine() { + void sqlUpdateViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("sql").analyze("UPDATE V SET name = 'test'"); assertThat(analyzed.isIdempotent()).isFalse(); assertThat(analyzed.getOperationTypes()).containsExactly(OperationType.UPDATE); } @Test - void testSqlUpsertViaEngine() { + void sqlUpsertViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("sql").analyze( "UPDATE V SET name = 'test' UPSERT WHERE name = 'test'"); assertThat(analyzed.isIdempotent()).isFalse(); @@ -63,21 +63,21 @@ void testSqlUpsertViaEngine() { } @Test - void testSqlDeleteViaEngine() { + void sqlDeleteViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("sql").analyze("DELETE FROM V"); assertThat(analyzed.isIdempotent()).isFalse(); assertThat(analyzed.getOperationTypes()).containsExactly(OperationType.DELETE); } @Test - void testSqlCreateTypeViaEngine() { + void sqlCreateTypeViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("sql").analyze("CREATE VERTEX TYPE NewType"); assertThat(analyzed.isDDL()).isTrue(); assertThat(analyzed.getOperationTypes()).containsExactly(OperationType.SCHEMA); } @Test - void testSqlCreateIndexViaEngine() { + void sqlCreateIndexViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("sql").analyze("CREATE INDEX ON V (name) UNIQUE"); assertThat(analyzed.isDDL()).isTrue(); assertThat(analyzed.getOperationTypes()).containsExactly(OperationType.SCHEMA); @@ -86,14 +86,14 @@ void testSqlCreateIndexViaEngine() { // --- OpenCypher via engine.analyze() --- @Test - void testOpenCypherMatchViaEngine() { + void openCypherMatchViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("opencypher").analyze("MATCH (n) RETURN n"); assertThat(analyzed.isIdempotent()).isTrue(); assertThat(analyzed.getOperationTypes()).containsExactly(OperationType.READ); } @Test - void testOpenCypherCreateViaEngine() { + void openCypherCreateViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("opencypher").analyze( "CREATE (n:V {name: 'test'}) RETURN n"); assertThat(analyzed.isIdempotent()).isFalse(); @@ -101,7 +101,7 @@ void testOpenCypherCreateViaEngine() { } @Test - void testOpenCypherSetViaEngine() { + void openCypherSetViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("opencypher").analyze( "MATCH (n:V) SET n.name = 'test' RETURN n"); assertThat(analyzed.isIdempotent()).isFalse(); @@ -109,7 +109,7 @@ void testOpenCypherSetViaEngine() { } @Test - void testOpenCypherDeleteViaEngine() { + void openCypherDeleteViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("opencypher").analyze( "MATCH (n:V) DELETE n"); assertThat(analyzed.isIdempotent()).isFalse(); @@ -117,7 +117,7 @@ void testOpenCypherDeleteViaEngine() { } @Test - void testOpenCypherMergeViaEngine() { + void openCypherMergeViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("opencypher").analyze( "MERGE (n:V {name: 'test'}) RETURN n"); assertThat(analyzed.isIdempotent()).isFalse(); @@ -125,7 +125,7 @@ void testOpenCypherMergeViaEngine() { } @Test - void testOpenCypherCreateConstraintViaEngine() { + void openCypherCreateConstraintViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("opencypher").analyze( "CREATE CONSTRAINT myConstraint FOR (n:V) REQUIRE n.name IS UNIQUE"); assertThat(analyzed.isDDL()).isTrue(); @@ -133,14 +133,14 @@ void testOpenCypherCreateConstraintViaEngine() { } @Test - void testOpenCypherAdminViaEngine() { + void openCypherAdminViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("opencypher").analyze("SHOW USERS"); assertThat(analyzed.isDDL()).isFalse(); assertThat(analyzed.getOperationTypes()).containsExactly(OperationType.ADMIN); } @Test - void testOpenCypherRemoveViaEngine() { + void openCypherRemoveViaEngine() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("opencypher").analyze( "MATCH (n:V) REMOVE n.name RETURN n"); assertThat(analyzed.isIdempotent()).isFalse(); @@ -150,25 +150,25 @@ void testOpenCypherRemoveViaEngine() { // --- QueryTool semantic check: write queries must be detected as non-idempotent --- @Test - void testSqlInsertIsNotIdempotent() { + void sqlInsertIsNotIdempotent() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("sql").analyze("INSERT INTO V SET name = 'test'"); assertThat(analyzed.isIdempotent()).isFalse(); } @Test - void testSqlUpdateIsNotIdempotent() { + void sqlUpdateIsNotIdempotent() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("sql").analyze("UPDATE V SET name = 'test'"); assertThat(analyzed.isIdempotent()).isFalse(); } @Test - void testSqlDeleteIsNotIdempotent() { + void sqlDeleteIsNotIdempotent() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("sql").analyze("DELETE FROM V"); assertThat(analyzed.isIdempotent()).isFalse(); } @Test - void testOpenCypherCreateIsNotIdempotent() { + void openCypherCreateIsNotIdempotent() { final QueryEngine.AnalyzedQuery analyzed = database.getQueryEngine("opencypher").analyze( "CREATE (n:V {name: 'test'}) RETURN n"); assertThat(analyzed.isIdempotent()).isFalse(); diff --git a/engine/src/test/java/com/arcadedb/query/opencypher/procedures/algo/AlgoBellmanFordTest.java b/engine/src/test/java/com/arcadedb/query/opencypher/procedures/algo/AlgoBellmanFordTest.java index 2656731e8d..5790e4ea47 100644 --- a/engine/src/test/java/com/arcadedb/query/opencypher/procedures/algo/AlgoBellmanFordTest.java +++ b/engine/src/test/java/com/arcadedb/query/opencypher/procedures/algo/AlgoBellmanFordTest.java @@ -28,6 +28,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -100,7 +101,7 @@ void bellmanFordFindsShortestPath() { final Map path = result.getProperty("path"); assertThat(path).isNotNull(); - assertThat(((java.util.List) path.get("nodes"))).hasSize(4); + assertThat(((List) path.get("nodes"))).hasSize(4); } @Test diff --git a/engine/src/test/java/com/arcadedb/query/sql/parser/OperationTypeTest.java b/engine/src/test/java/com/arcadedb/query/sql/parser/OperationTypeTest.java index 2bc95f3b26..39546bfb4f 100644 --- a/engine/src/test/java/com/arcadedb/query/sql/parser/OperationTypeTest.java +++ b/engine/src/test/java/com/arcadedb/query/sql/parser/OperationTypeTest.java @@ -39,128 +39,128 @@ private static Statement parse(final String query) { } @Test - void testSelectIsRead() { + void selectIsRead() { final Statement stmt = parse("SELECT FROM Person"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.READ); assertThat(stmt.isIdempotent()).isTrue(); } @Test - void testMatchIsRead() { + void matchIsRead() { final Statement stmt = parse("MATCH {type: Person, as: p} RETURN p"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.READ); } @Test - void testTraverseIsRead() { + void traverseIsRead() { final Statement stmt = parse("TRAVERSE out() FROM #1:0"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.READ); } @Test - void testInsertIsCreate() { + void insertIsCreate() { final Statement stmt = parse("INSERT INTO Person SET name = 'John'"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.CREATE); } @Test - void testInsertCaseInsensitive() { + void insertCaseInsensitive() { final Statement stmt = parse("insert into Person set name = 'John'"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.CREATE); } @Test - void testCreateVertexIsCreate() { + void createVertexIsCreate() { final Statement stmt = parse("CREATE VERTEX Person SET name = 'John'"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.CREATE); } @Test - void testCreateEdgeIsCreate() { + void createEdgeIsCreate() { final Statement stmt = parse("CREATE EDGE Knows FROM #1:0 TO #2:0"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.CREATE); } @Test - void testUpdateIsUpdate() { + void updateIsUpdate() { final Statement stmt = parse("UPDATE Person SET name = 'Jane' WHERE name = 'John'"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.UPDATE); } @Test - void testUpsertIsCreateAndUpdate() { + void upsertIsCreateAndUpdate() { final Statement stmt = parse("UPDATE Person SET name = 'John' UPSERT WHERE name = 'John'"); assertThat(stmt.getOperationTypes()).containsExactlyInAnyOrder(OperationType.CREATE, OperationType.UPDATE); } @Test - void testDeleteIsDelete() { + void deleteIsDelete() { final Statement stmt = parse("DELETE FROM Person WHERE name = 'John'"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.DELETE); } @Test - void testCreateVertexTypeIsSchema() { + void createVertexTypeIsSchema() { final Statement stmt = parse("CREATE VERTEX TYPE MyVertex"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.SCHEMA); } @Test - void testCreateEdgeTypeIsSchema() { + void createEdgeTypeIsSchema() { final Statement stmt = parse("CREATE EDGE TYPE MyEdge"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.SCHEMA); } @Test - void testCreateDocumentTypeIsSchema() { + void createDocumentTypeIsSchema() { final Statement stmt = parse("CREATE DOCUMENT TYPE MyDoc"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.SCHEMA); } @Test - void testAlterTypeIsSchema() { + void alterTypeIsSchema() { final Statement stmt = parse("ALTER TYPE Person CUSTOM myAttr = 'test'"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.SCHEMA); } @Test - void testDropTypeIsSchema() { + void dropTypeIsSchema() { final Statement stmt = parse("DROP TYPE Person IF EXISTS"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.SCHEMA); } @Test - void testCreateIndexIsSchema() { + void createIndexIsSchema() { final Statement stmt = parse("CREATE INDEX ON Person (name) UNIQUE"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.SCHEMA); } @Test - void testDropIndexIsSchema() { + void dropIndexIsSchema() { final Statement stmt = parse("DROP INDEX `Person[name]`"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.SCHEMA); } @Test - void testCreatePropertyIsSchema() { + void createPropertyIsSchema() { final Statement stmt = parse("CREATE PROPERTY Person.age INTEGER"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.SCHEMA); } @Test - void testExplainIsRead() { + void explainIsRead() { final Statement stmt = parse("EXPLAIN SELECT FROM Person"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.READ); } @Test - void testProfileIsRead() { + void profileIsRead() { final Statement stmt = parse("PROFILE SELECT FROM Person"); assertThat(stmt.getOperationTypes()).containsExactly(OperationType.READ); } @Test - void testMoveVertexIsCreateUpdateAndDelete() { + void moveVertexIsCreateUpdateAndDelete() { final Statement stmt = parse("MOVE VERTEX (SELECT FROM V LIMIT 1) TO TYPE:Person"); assertThat(stmt.getOperationTypes()).containsExactlyInAnyOrder(OperationType.CREATE, OperationType.UPDATE, OperationType.DELETE); } diff --git a/engine/src/test/java/com/arcadedb/schema/MaterializedViewEdgeCaseTest.java b/engine/src/test/java/com/arcadedb/schema/MaterializedViewEdgeCaseTest.java index 31621fa1ec..2ef98af307 100644 --- a/engine/src/test/java/com/arcadedb/schema/MaterializedViewEdgeCaseTest.java +++ b/engine/src/test/java/com/arcadedb/schema/MaterializedViewEdgeCaseTest.java @@ -233,7 +233,7 @@ void incrementalMultipleInsertsInSameTransaction() { } @Test - void periodicRefreshUpdatesView() throws InterruptedException { + void periodicRefreshUpdatesView() throws Exception { database.transaction(() -> { database.getSchema().createDocumentType("PeriodicSrc"); database.getSchema().getType("PeriodicSrc").createProperty("val", Type.INTEGER); diff --git a/engine/src/test/java/com/arcadedb/schema/MaterializedViewSQLTest.java b/engine/src/test/java/com/arcadedb/schema/MaterializedViewSQLTest.java index 86c2c22fad..d5eee9c477 100644 --- a/engine/src/test/java/com/arcadedb/schema/MaterializedViewSQLTest.java +++ b/engine/src/test/java/com/arcadedb/schema/MaterializedViewSQLTest.java @@ -20,6 +20,7 @@ import com.arcadedb.TestHelper; import com.arcadedb.exception.CommandExecutionException; +import com.arcadedb.query.sql.executor.Result; import com.arcadedb.query.sql.executor.ResultSet; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -30,7 +31,7 @@ class MaterializedViewSQLTest extends TestHelper { @BeforeEach - public void setupTypes() { + void setupTypes() { if (!database.getSchema().existsType("Account")) database.transaction(() -> { database.getSchema().createDocumentType("Account"); @@ -126,7 +127,7 @@ void querySchemaMetadata() { try (final ResultSet rs = database.query("sql", "SELECT FROM schema:materializedViews")) { assertThat(rs.hasNext()).isTrue(); - final com.arcadedb.query.sql.executor.Result result = rs.next(); + final Result result = rs.next(); assertThat((String) result.getProperty("name")).isEqualTo("MetaView"); assertThat((String) result.getProperty("query")).isNotNull(); assertThat((String) result.getProperty("backingType")).isNotNull(); diff --git a/engine/src/test/java/com/arcadedb/schema/MaterializedViewTest.java b/engine/src/test/java/com/arcadedb/schema/MaterializedViewTest.java index 3b45aa39c3..bd29e2bcc9 100644 --- a/engine/src/test/java/com/arcadedb/schema/MaterializedViewTest.java +++ b/engine/src/test/java/com/arcadedb/schema/MaterializedViewTest.java @@ -20,6 +20,7 @@ import com.arcadedb.TestHelper; import com.arcadedb.database.DatabaseFactory; +import com.arcadedb.database.DatabaseInternal; import com.arcadedb.exception.SchemaException; import com.arcadedb.query.sql.executor.ResultSet; import com.arcadedb.serializer.json.JSONObject; @@ -266,7 +267,7 @@ void getChangeListenerAndSetChangeListener() { assertThat(view.getChangeListener()).isNull(); final MaterializedViewChangeListener listener = new MaterializedViewChangeListener( - (com.arcadedb.database.DatabaseInternal) database, view); + (DatabaseInternal) database, view); view.setChangeListener(listener); assertThat(view.getChangeListener()).isSameAs(listener); assertThat(view.getChangeListener().getView()).isSameAs(view); diff --git a/server/src/main/java/com/arcadedb/server/mcp/MCPHttpHandler.java b/server/src/main/java/com/arcadedb/server/mcp/MCPHttpHandler.java index 15353525bb..acc9c645b9 100644 --- a/server/src/main/java/com/arcadedb/server/mcp/MCPHttpHandler.java +++ b/server/src/main/java/com/arcadedb/server/mcp/MCPHttpHandler.java @@ -18,6 +18,7 @@ */ package com.arcadedb.server.mcp; +import com.arcadedb.Constants; import com.arcadedb.log.LogManager; import com.arcadedb.server.mcp.tools.ExecuteCommandTool; import com.arcadedb.server.mcp.tools.GetSchemaTool; @@ -107,7 +108,7 @@ private ExecutionResponse handleInitialize(final Object id) { final JSONObject serverInfo = new JSONObject(); serverInfo.put("name", "arcadedb"); - serverInfo.put("version", com.arcadedb.Constants.getVersion()); + serverInfo.put("version", Constants.getVersion()); result.put("serverInfo", serverInfo); final JSONObject capabilities = new JSONObject(); diff --git a/server/src/test/java/com/arcadedb/server/ApiTokenAuthenticationIT.java b/server/src/test/java/com/arcadedb/server/ApiTokenAuthenticationIT.java index df2f2274d5..d414ad7a7f 100644 --- a/server/src/test/java/com/arcadedb/server/ApiTokenAuthenticationIT.java +++ b/server/src/test/java/com/arcadedb/server/ApiTokenAuthenticationIT.java @@ -20,10 +20,13 @@ import com.arcadedb.serializer.json.JSONArray; import com.arcadedb.serializer.json.JSONObject; +import com.arcadedb.server.security.ApiTokenConfiguration; + import org.junit.jupiter.api.Test; import java.io.*; import java.net.*; +import java.nio.file.Files; import java.util.Base64; import static org.assertj.core.api.Assertions.assertThat; @@ -31,7 +34,7 @@ class ApiTokenAuthenticationIT extends BaseGraphServerTest { @Test - void testCreateTokenViaApi() throws Exception { + void createTokenViaApi() throws Exception { testEachServer((serverIndex) -> { final String tokenValue = createApiToken(serverIndex, "Test Token", "graph", 0, new JSONObject() @@ -44,7 +47,7 @@ void testCreateTokenViaApi() throws Exception { } @Test - void testListTokensViaApi() throws Exception { + void listTokensViaApi() throws Exception { testEachServer((serverIndex) -> { createApiToken(serverIndex, "Token1", "graph", 0, new JSONObject()); createApiToken(serverIndex, "Token2", "graph", 0, new JSONObject()); @@ -75,7 +78,7 @@ void testListTokensViaApi() throws Exception { } @Test - void testUseApiTokenForQuery() throws Exception { + void useApiTokenForQuery() throws Exception { testEachServer((serverIndex) -> { final JSONObject permissions = new JSONObject() .put("types", new JSONObject() @@ -103,7 +106,7 @@ void testUseApiTokenForQuery() throws Exception { } @Test - void testExpiredTokenReturns401() throws Exception { + void expiredTokenReturns401() throws Exception { testEachServer((serverIndex) -> { final long pastTime = System.currentTimeMillis() - 10000; final String tokenValue = createApiToken(serverIndex, "Expired", "graph", pastTime, new JSONObject()); @@ -123,7 +126,7 @@ void testExpiredTokenReturns401() throws Exception { } @Test - void testReadOnlyTokenCannotInsert() throws Exception { + void readOnlyTokenCannotInsert() throws Exception { testEachServer((serverIndex) -> { final JSONObject permissions = new JSONObject() .put("types", new JSONObject() @@ -155,10 +158,10 @@ void testReadOnlyTokenCannotInsert() throws Exception { } @Test - void testDeleteTokenViaApi() throws Exception { + void deleteTokenViaApi() throws Exception { testEachServer((serverIndex) -> { final String tokenValue = createApiToken(serverIndex, "ToDelete", "graph", 0, new JSONObject()); - final String tokenHash = com.arcadedb.server.security.ApiTokenConfiguration.hashToken(tokenValue); + final String tokenHash = ApiTokenConfiguration.hashToken(tokenValue); // Delete using token hash (not plaintext) final HttpURLConnection connection = (HttpURLConnection) new URL( @@ -190,7 +193,7 @@ void testDeleteTokenViaApi() throws Exception { } @Test - void testDeleteTokenRejectsPlaintext() throws Exception { + void deleteTokenRejectsPlaintext() throws Exception { testEachServer((serverIndex) -> { final String tokenValue = createApiToken(serverIndex, "NoPlaintext", "graph", 0, new JSONObject()); @@ -211,10 +214,10 @@ void testDeleteTokenRejectsPlaintext() throws Exception { } @Test - void testDeleteTokenByHash() throws Exception { + void deleteTokenByHash() throws Exception { testEachServer((serverIndex) -> { final String tokenValue = createApiToken(serverIndex, "ToDeleteByHash", "graph", 0, new JSONObject()); - final String tokenHash = com.arcadedb.server.security.ApiTokenConfiguration.hashToken(tokenValue); + final String tokenHash = ApiTokenConfiguration.hashToken(tokenValue); final HttpURLConnection connection = (HttpURLConnection) new URL( "http://127.0.0.1:248" + serverIndex + "/api/v1/server/api-tokens?token=" + @@ -245,16 +248,16 @@ void testDeleteTokenByHash() throws Exception { } @Test - void testPlaintextNotPersistedOnDisk() throws Exception { + void plaintextNotPersistedOnDisk() throws Exception { testEachServer((serverIndex) -> { final String tokenValue = createApiToken(serverIndex, "PersistTest", "graph", 0, new JSONObject()); // Read the token file and verify no plaintext token is stored final String configPath = getServer(serverIndex).getRootPath() + "/config"; - final java.io.File tokenFile = new java.io.File(configPath, "server-api-tokens.json"); + final File tokenFile = new File(configPath, "server-api-tokens.json"); assertThat(tokenFile.exists()).isTrue(); - final String content = new String(java.nio.file.Files.readAllBytes(tokenFile.toPath())); + final String content = new String(Files.readAllBytes(tokenFile.toPath())); assertThat(content).doesNotContain(tokenValue); assertThat(content).contains("tokenHash"); assertThat(content).contains("tokenSuffix"); @@ -263,7 +266,7 @@ void testPlaintextNotPersistedOnDisk() throws Exception { } @Test - void testNonRootCannotManageTokens() throws Exception { + void nonRootCannotManageTokens() throws Exception { testEachServer((serverIndex) -> { // Create a non-root user first (if not already existing) if (!getServer(serverIndex).getSecurity().existsUser("testuser")) @@ -287,7 +290,7 @@ void testNonRootCannotManageTokens() throws Exception { } @Test - void testWildcardTypePermissions() throws Exception { + void wildcardTypePermissions() throws Exception { testEachServer((serverIndex) -> { // Token with * type having readRecord only, but Account having full CRUD final JSONObject permissions = new JSONObject() @@ -315,7 +318,7 @@ void testWildcardTypePermissions() throws Exception { } @Test - void testDuplicateTokenNameReturns409() throws Exception { + void duplicateTokenNameReturns409() throws Exception { testEachServer((serverIndex) -> { createApiToken(serverIndex, "Unique Name", "graph", 0, new JSONObject()); @@ -345,7 +348,7 @@ void testDuplicateTokenNameReturns409() throws Exception { } @Test - void testApiTokenInvalidReturns401() throws Exception { + void apiTokenInvalidReturns401() throws Exception { testEachServer((serverIndex) -> { final HttpURLConnection connection = (HttpURLConnection) new URL( "http://127.0.0.1:248" + serverIndex + "/api/v1/query/graph/sql/select%201").openConnection(); @@ -364,7 +367,7 @@ void testApiTokenInvalidReturns401() throws Exception { private String createApiToken(final int serverIndex, final String name, final String database, final long expiresAt, final JSONObject permissions) throws Exception { // Delete any pre-existing token with the same name (e.g., left over from a previous test run) - final com.arcadedb.server.security.ApiTokenConfiguration tokenConfig = + final ApiTokenConfiguration tokenConfig = getServer(serverIndex).getSecurity().getApiTokenConfiguration(); tokenConfig.listTokens().stream() .filter(t -> name.equals(t.getString("name", ""))) diff --git a/server/src/test/java/com/arcadedb/server/GroupManagementIT.java b/server/src/test/java/com/arcadedb/server/GroupManagementIT.java index 580f013b4b..5421cd1f71 100644 --- a/server/src/test/java/com/arcadedb/server/GroupManagementIT.java +++ b/server/src/test/java/com/arcadedb/server/GroupManagementIT.java @@ -34,7 +34,7 @@ class GroupManagementIT extends BaseGraphServerTest { @Test - void testListDefaultGroups() throws Exception { + void listDefaultGroups() throws Exception { testEachServer((serverIndex) -> { final HttpURLConnection connection = (HttpURLConnection) new URL( "http://127.0.0.1:248" + serverIndex + "/api/v1/server/groups").openConnection(); @@ -63,7 +63,7 @@ void testListDefaultGroups() throws Exception { } @Test - void testCreateGroup() throws Exception { + void createGroup() throws Exception { testEachServer((serverIndex) -> { final JSONObject payload = new JSONObject(); payload.put("database", "*"); @@ -113,7 +113,7 @@ void testCreateGroup() throws Exception { } @Test - void testUpdateGroup() throws Exception { + void updateGroup() throws Exception { testEachServer((serverIndex) -> { // First create the group final JSONObject createPayload = new JSONObject(); @@ -165,7 +165,7 @@ void testUpdateGroup() throws Exception { } @Test - void testDeleteGroup() throws Exception { + void deleteGroup() throws Exception { testEachServer((serverIndex) -> { // Create group first final JSONObject payload = new JSONObject(); @@ -211,7 +211,7 @@ void testDeleteGroup() throws Exception { } @Test - void testCannotDeleteAdminFromWildcard() throws Exception { + void cannotDeleteAdminFromWildcard() throws Exception { testEachServer((serverIndex) -> { final HttpURLConnection connection = (HttpURLConnection) new URL( "http://127.0.0.1:248" + serverIndex + "/api/v1/server/groups?database=*&name=admin").openConnection(); @@ -228,7 +228,7 @@ void testCannotDeleteAdminFromWildcard() throws Exception { } @Test - void testNonRootCannotManageGroups() throws Exception { + void nonRootCannotManageGroups() throws Exception { testEachServer((serverIndex) -> { if (!getServer(serverIndex).getSecurity().existsUser("testuser")) getServer(serverIndex).getSecurity().createUser("testuser", "testpass"); @@ -268,7 +268,7 @@ void testNonRootCannotManageGroups() throws Exception { } @Test - void testDatabaseSpecificGroupDoesNotBreakWildcardGroups() throws Exception { + void databaseSpecificGroupDoesNotBreakWildcardGroups() throws Exception { testEachServer((serverIndex) -> { // Create a test database final HttpURLConnection createDb = (HttpURLConnection) new URL( diff --git a/server/src/test/java/com/arcadedb/server/UserManagementIT.java b/server/src/test/java/com/arcadedb/server/UserManagementIT.java index 70b121d5d0..b1b7dce787 100644 --- a/server/src/test/java/com/arcadedb/server/UserManagementIT.java +++ b/server/src/test/java/com/arcadedb/server/UserManagementIT.java @@ -30,7 +30,7 @@ class UserManagementIT extends BaseGraphServerTest { @Test - void testUpdateUserDatabases() throws Exception { + void updateUserDatabases() throws Exception { testEachServer((serverIndex) -> { // Create user via POST createUser(serverIndex, "dbuser", "password1234", @@ -88,7 +88,7 @@ void testUpdateUserDatabases() throws Exception { } @Test - void testUpdateUserPassword() throws Exception { + void updateUserPassword() throws Exception { testEachServer((serverIndex) -> { createUser(serverIndex, "pwduser", "oldpassword1", new JSONObject().put("*", new JSONArray().put("admin"))); @@ -148,7 +148,7 @@ void testUpdateUserPassword() throws Exception { } @Test - void testCreateUserWithApitokenPrefixReturns400() throws Exception { + void createUserWithApitokenPrefixReturns400() throws Exception { testEachServer((serverIndex) -> { final JSONObject payload = new JSONObject(); payload.put("name", "apitoken:hack"); @@ -172,7 +172,7 @@ void testCreateUserWithApitokenPrefixReturns400() throws Exception { } @Test - void testUpdateUserWithShortPasswordReturns400() throws Exception { + void updateUserWithShortPasswordReturns400() throws Exception { testEachServer((serverIndex) -> { createUser(serverIndex, "shortpwduser", "validpassword1", new JSONObject().put("*", new JSONArray().put("admin"))); @@ -200,7 +200,7 @@ void testUpdateUserWithShortPasswordReturns400() throws Exception { } @Test - void testUpdateUserWithTooLongPasswordReturns400() throws Exception { + void updateUserWithTooLongPasswordReturns400() throws Exception { testEachServer((serverIndex) -> { createUser(serverIndex, "longpwduser", "validpassword1", new JSONObject().put("*", new JSONArray().put("admin"))); @@ -229,7 +229,7 @@ void testUpdateUserWithTooLongPasswordReturns400() throws Exception { } @Test - void testUpdateNonExistentUserReturns404() throws Exception { + void updateNonExistentUserReturns404() throws Exception { testEachServer((serverIndex) -> { final JSONObject updatePayload = new JSONObject(); updatePayload.put("password", "doesntmatter"); diff --git a/server/src/test/java/com/arcadedb/server/mcp/MCPConfigurationTest.java b/server/src/test/java/com/arcadedb/server/mcp/MCPConfigurationTest.java index a2d000bc7d..fb10d989dc 100644 --- a/server/src/test/java/com/arcadedb/server/mcp/MCPConfigurationTest.java +++ b/server/src/test/java/com/arcadedb/server/mcp/MCPConfigurationTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test; import java.io.File; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -43,7 +44,7 @@ void tearDown() { } @Test - void testDefaultValues() { + void defaultValues() { final MCPConfiguration config = new MCPConfiguration(TEST_ROOT); config.load(); @@ -57,12 +58,12 @@ void testDefaultValues() { } @Test - void testSaveAndLoad() { + void saveAndLoad() { final MCPConfiguration config = new MCPConfiguration(TEST_ROOT); config.setEnabled(true); config.setAllowInsert(true); config.setAllowUpdate(true); - config.setAllowedUsers(java.util.List.of("root", "admin")); + config.setAllowedUsers(List.of("root", "admin")); config.save(); final MCPConfiguration loaded = new MCPConfiguration(TEST_ROOT); @@ -76,7 +77,7 @@ void testSaveAndLoad() { } @Test - void testUserAllowed() { + void userAllowed() { final MCPConfiguration config = new MCPConfiguration(TEST_ROOT); config.load(); @@ -85,16 +86,16 @@ void testUserAllowed() { } @Test - void testWildcardUser() { + void wildcardUser() { final MCPConfiguration config = new MCPConfiguration(TEST_ROOT); - config.setAllowedUsers(java.util.List.of("*")); + config.setAllowedUsers(List.of("*")); assertThat(config.isUserAllowed("root")).isTrue(); assertThat(config.isUserAllowed("anyone")).isTrue(); } @Test - void testToJSON() { + void toJSON() { final MCPConfiguration config = new MCPConfiguration(TEST_ROOT); config.load(); @@ -107,7 +108,7 @@ void testToJSON() { } @Test - void testUpdateFrom() { + void updateFrom() { final MCPConfiguration config = new MCPConfiguration(TEST_ROOT); config.load(); @@ -127,7 +128,7 @@ void testUpdateFrom() { } @Test - void testUpdateFromNullAllowedUsersResultsInEmptyList() { + void updateFromNullAllowedUsersResultsInEmptyList() { final MCPConfiguration config = new MCPConfiguration(TEST_ROOT); config.load(); @@ -142,7 +143,7 @@ void testUpdateFromNullAllowedUsersResultsInEmptyList() { } @Test - void testCreateDefaultFileOnFirstLoad() { + void createDefaultFileOnFirstLoad() { final MCPConfiguration config = new MCPConfiguration(TEST_ROOT); config.load(); diff --git a/server/src/test/java/com/arcadedb/server/mcp/MCPPermissionsTest.java b/server/src/test/java/com/arcadedb/server/mcp/MCPPermissionsTest.java index 975de48e61..a822278118 100644 --- a/server/src/test/java/com/arcadedb/server/mcp/MCPPermissionsTest.java +++ b/server/src/test/java/com/arcadedb/server/mcp/MCPPermissionsTest.java @@ -34,7 +34,7 @@ class MCPPermissionsTest { @Test - void testPermissionCheckDeniesInsert() { + void permissionCheckDeniesInsert() { final MCPConfiguration config = new MCPConfiguration("./target/test"); config.setAllowInsert(false); @@ -44,7 +44,7 @@ void testPermissionCheckDeniesInsert() { } @Test - void testPermissionCheckAllowsInsert() { + void permissionCheckAllowsInsert() { final MCPConfiguration config = new MCPConfiguration("./target/test"); config.setAllowInsert(true); @@ -53,7 +53,7 @@ void testPermissionCheckAllowsInsert() { } @Test - void testPermissionCheckDeniesUpdate() { + void permissionCheckDeniesUpdate() { final MCPConfiguration config = new MCPConfiguration("./target/test"); config.setAllowUpdate(false); @@ -63,7 +63,7 @@ void testPermissionCheckDeniesUpdate() { } @Test - void testPermissionCheckDeniesDelete() { + void permissionCheckDeniesDelete() { final MCPConfiguration config = new MCPConfiguration("./target/test"); config.setAllowDelete(false); @@ -72,7 +72,7 @@ void testPermissionCheckDeniesDelete() { } @Test - void testPermissionCheckDeniesSchemaChange() { + void permissionCheckDeniesSchemaChange() { final MCPConfiguration config = new MCPConfiguration("./target/test"); config.setAllowSchemaChange(false); @@ -81,7 +81,7 @@ void testPermissionCheckDeniesSchemaChange() { } @Test - void testPermissionCheckDeniesRead() { + void permissionCheckDeniesRead() { final MCPConfiguration config = new MCPConfiguration("./target/test"); config.setAllowReads(false); @@ -90,7 +90,7 @@ void testPermissionCheckDeniesRead() { } @Test - void testUpsertRequiresBothCreateAndUpdate() { + void upsertRequiresBothCreateAndUpdate() { // UPSERT produces both CREATE and UPDATE operations final Set upsertOps = Set.of(OperationType.CREATE, OperationType.UPDATE); @@ -116,7 +116,7 @@ void testUpsertRequiresBothCreateAndUpdate() { } @Test - void testPermissionCheckDeniesAdmin() { + void permissionCheckDeniesAdmin() { final MCPConfiguration config = new MCPConfiguration("./target/test"); config.setAllowAdmin(false); @@ -126,7 +126,7 @@ void testPermissionCheckDeniesAdmin() { } @Test - void testPermissionCheckAllowsAdmin() { + void permissionCheckAllowsAdmin() { final MCPConfiguration config = new MCPConfiguration("./target/test"); config.setAllowAdmin(true); @@ -135,7 +135,7 @@ void testPermissionCheckAllowsAdmin() { } @Test - void testMultipleOperationTypesAllChecked() { + void multipleOperationTypesAllChecked() { // A command that does both DELETE and UPDATE (like MOVE VERTEX) final Set moveOps = Set.of(OperationType.UPDATE, OperationType.DELETE); @@ -147,7 +147,7 @@ void testMultipleOperationTypesAllChecked() { } @Test - void testMcpDisabledReturnsError() { + void mcpDisabledReturnsError() { final MCPConfiguration config = new MCPConfiguration("./target/test"); config.setEnabled(false); @@ -155,7 +155,7 @@ void testMcpDisabledReturnsError() { } @Test - void testMcpUserAllowedCheck() { + void mcpUserAllowedCheck() { final MCPConfiguration config = new MCPConfiguration("./target/test"); config.setAllowedUsers(List.of("root", "admin")); @@ -165,7 +165,7 @@ void testMcpUserAllowedCheck() { } @Test - void testMcpWildcardUserAllowed() { + void mcpWildcardUserAllowed() { final MCPConfiguration config = new MCPConfiguration("./target/test"); config.setAllowedUsers(List.of("*")); @@ -174,7 +174,7 @@ void testMcpWildcardUserAllowed() { } @Test - void testMcpNullUserNotAllowed() { + void mcpNullUserNotAllowed() { final MCPConfiguration config = new MCPConfiguration("./target/test"); config.setAllowedUsers(List.of("root")); diff --git a/server/src/test/java/com/arcadedb/server/mcp/MCPServerPluginTest.java b/server/src/test/java/com/arcadedb/server/mcp/MCPServerPluginTest.java index 37e393dafd..7e4a497d88 100644 --- a/server/src/test/java/com/arcadedb/server/mcp/MCPServerPluginTest.java +++ b/server/src/test/java/com/arcadedb/server/mcp/MCPServerPluginTest.java @@ -33,7 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat; -public class MCPServerPluginTest extends BaseGraphServerTest { +class MCPServerPluginTest extends BaseGraphServerTest { private String getMcpUrl() { return "http://127.0.0.1:" + getServer(0).getHttpServer().getPort() + "/api/v1/mcp"; @@ -44,7 +44,7 @@ private String getMcpConfigUrl() { } @BeforeEach - public void enableMCP() throws Exception { + void enableMCP() throws Exception { // MCP is disabled by default, enable it for tests saveMCPConfig(new JSONObject() .put("enabled", true) @@ -53,7 +53,7 @@ public void enableMCP() throws Exception { } @Test - void testInitialize() throws Exception { + void initialize() throws Exception { final JSONObject response = mcpRequest(new JSONObject() .put("jsonrpc", "2.0") .put("id", 1) @@ -68,7 +68,7 @@ void testInitialize() throws Exception { } @Test - void testToolsList() throws Exception { + void toolsList() throws Exception { final JSONObject response = mcpRequest(new JSONObject() .put("jsonrpc", "2.0") .put("id", 2) @@ -104,7 +104,7 @@ void testToolsList() throws Exception { } @Test - void testListDatabases() throws Exception { + void listDatabases() throws Exception { final JSONObject response = callTool("list_databases", new JSONObject()); assertThat(response.getBoolean("isError", true)).isFalse(); @@ -121,7 +121,7 @@ void testListDatabases() throws Exception { } @Test - void testGetSchema() throws Exception { + void getSchema() throws Exception { final JSONObject response = callTool("get_schema", new JSONObject().put("database", "graph")); assertThat(response.getBoolean("isError", true)).isFalse(); @@ -149,7 +149,7 @@ void testGetSchema() throws Exception { } @Test - void testQuery() throws Exception { + void query() throws Exception { final JSONObject response = callTool("query", new JSONObject() .put("database", "graph") .put("language", "sql") @@ -163,7 +163,7 @@ void testQuery() throws Exception { } @Test - void testExecuteCommand() throws Exception { + void executeCommand() throws Exception { // Enable insert permission saveMCPConfig(new JSONObject() .put("enabled", true) @@ -183,7 +183,7 @@ void testExecuteCommand() throws Exception { } @Test - void testExecuteCommandDeniedByPermission() throws Exception { + void executeCommandDeniedByPermission() throws Exception { // Ensure insert is disabled saveMCPConfig(new JSONObject() .put("enabled", true) @@ -202,7 +202,7 @@ void testExecuteCommandDeniedByPermission() throws Exception { } @Test - void testServerStatus() throws Exception { + void serverStatus() throws Exception { final JSONObject response = callTool("server_status", new JSONObject()); assertThat(response.getBoolean("isError", true)).isFalse(); @@ -214,7 +214,7 @@ void testServerStatus() throws Exception { } @Test - void testPing() throws Exception { + void ping() throws Exception { final JSONObject response = mcpRequest(new JSONObject() .put("jsonrpc", "2.0") .put("id", 99) @@ -225,7 +225,7 @@ void testPing() throws Exception { } @Test - void testMethodNotFound() throws Exception { + void methodNotFound() throws Exception { final JSONObject response = mcpRequest(new JSONObject() .put("jsonrpc", "2.0") .put("id", 100) @@ -237,7 +237,7 @@ void testMethodNotFound() throws Exception { } @Test - void testDisabledMCP() throws Exception { + void disabledMCP() throws Exception { saveMCPConfig(new JSONObject() .put("enabled", false) .put("allowedUsers", new JSONArray().put("root"))); @@ -272,7 +272,7 @@ void testDisabledMCP() throws Exception { } @Test - void testGetConfig() throws Exception { + void getConfig() throws Exception { final HttpURLConnection connection = (HttpURLConnection) new URI(getMcpConfigUrl()).toURL().openConnection(); connection.setRequestMethod("GET"); connection.setRequestProperty("Authorization", getBasicAuth()); @@ -291,13 +291,13 @@ void testGetConfig() throws Exception { } @Test - void testUnknownTool() throws Exception { + void unknownTool() throws Exception { final JSONObject response = callTool("nonexistent_tool", new JSONObject()); assertThat(response.getBoolean("isError")).isTrue(); } @Test - void testNotificationReturns204() throws Exception { + void notificationReturns204() throws Exception { final HttpURLConnection connection = (HttpURLConnection) new URI(getMcpUrl()).toURL().openConnection(); connection.setRequestMethod("POST"); connection.setRequestProperty("Authorization", getBasicAuth()); @@ -321,7 +321,7 @@ void testNotificationReturns204() throws Exception { } @Test - void testQueryToolRejectsWriteQuery() throws Exception { + void queryToolRejectsWriteQuery() throws Exception { final JSONObject response = callTool("query", new JSONObject() .put("database", "graph") .put("language", "sql") @@ -333,7 +333,7 @@ void testQueryToolRejectsWriteQuery() throws Exception { } @Test - void testUnauthorizedUserDenied() throws Exception { + void unauthorizedUserDenied() throws Exception { // Configure only "root" as allowed user saveMCPConfig(new JSONObject() .put("enabled", true) @@ -371,7 +371,7 @@ void testUnauthorizedUserDenied() throws Exception { } @Test - void testQueryWithLimit() throws Exception { + void queryWithLimit() throws Exception { final JSONObject response = callTool("query", new JSONObject() .put("database", "graph") .put("language", "sql") @@ -385,7 +385,7 @@ void testQueryWithLimit() throws Exception { } @Test - void testDatabaseAuthorizationDenied() throws Exception { + void databaseAuthorizationDenied() throws Exception { // Configure MCP to allow "restricteduser" saveMCPConfig(new JSONObject() .put("enabled", true) @@ -394,10 +394,10 @@ void testDatabaseAuthorizationDenied() throws Exception { // Create a user with access only to a non-existent database "otherdb" if (!getServer(0).getSecurity().existsUser("restricteduser")) - getServer(0).getSecurity().createUser(new com.arcadedb.serializer.json.JSONObject() + getServer(0).getSecurity().createUser(new JSONObject() .put("name", "restricteduser") .put("password", getServer(0).getSecurity().encodePassword("restrictedpass")) - .put("databases", new com.arcadedb.serializer.json.JSONObject() + .put("databases", new JSONObject() .put("otherdb", new JSONArray().put("admin")))); final String restrictedAuth = "Basic " + Base64.getEncoder() diff --git a/server/src/test/java/com/arcadedb/server/security/ApiTokenConfigurationTest.java b/server/src/test/java/com/arcadedb/server/security/ApiTokenConfigurationTest.java index dcef49945c..3dbd779a5f 100644 --- a/server/src/test/java/com/arcadedb/server/security/ApiTokenConfigurationTest.java +++ b/server/src/test/java/com/arcadedb/server/security/ApiTokenConfigurationTest.java @@ -29,6 +29,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; class ApiTokenConfigurationTest { private static final String TEST_CONFIG_PATH = "target/test-api-tokens"; @@ -49,7 +50,7 @@ void tearDown() { } @Test - void testCreateToken() { + void createToken() { final JSONObject permissions = new JSONObject() .put("types", new JSONObject() .put("*", new JSONObject().put("access", new JSONArray().put("readRecord")))) @@ -65,7 +66,7 @@ void testCreateToken() { } @Test - void testGetToken() { + void getToken() { final JSONObject permissions = new JSONObject(); final JSONObject created = config.createToken("Token1", "db1", 0, permissions); final String tokenValue = created.getString("token"); @@ -76,12 +77,12 @@ void testGetToken() { } @Test - void testGetTokenNotFound() { + void getTokenNotFound() { assertThat(config.getToken("at-nonexistent")).isNull(); } @Test - void testDeleteToken() { + void deleteToken() { final JSONObject created = config.createToken("Token1", "db1", 0, new JSONObject()); final String tokenValue = created.getString("token"); final String tokenHash = ApiTokenConfiguration.hashToken(tokenValue); @@ -91,21 +92,20 @@ void testDeleteToken() { } @Test - void testDeleteTokenRejectsPlaintext() { + void deleteTokenRejectsPlaintext() { final JSONObject created = config.createToken("Token1", "db1", 0, new JSONObject()); final String tokenValue = created.getString("token"); - org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, - () -> config.deleteToken(tokenValue)); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> config.deleteToken(tokenValue)); } @Test - void testDeleteTokenNotFound() { + void deleteTokenNotFound() { assertThat(config.deleteToken("nonexistent-hash-that-does-not-start-with-prefix")).isFalse(); } @Test - void testListTokens() { + void listTokens() { config.createToken("Token1", "db1", 0, new JSONObject()); config.createToken("Token2", "db2", 0, new JSONObject()); @@ -114,7 +114,7 @@ void testListTokens() { } @Test - void testExpiredTokenReturnsNull() { + void expiredTokenReturnsNull() { final long pastTime = System.currentTimeMillis() - 10000; final JSONObject created = config.createToken("Expired", "db1", pastTime, new JSONObject()); final String tokenValue = created.getString("token"); @@ -123,7 +123,7 @@ void testExpiredTokenReturnsNull() { } @Test - void testLoadSaveRoundTrip() { + void loadSaveRoundTrip() { config.createToken("Persistent", "db1", 0, new JSONObject() .put("types", new JSONObject() .put("*", new JSONObject().put("access", new JSONArray().put("readRecord"))))); @@ -138,7 +138,7 @@ void testLoadSaveRoundTrip() { } @Test - void testLoadRemovesExpiredTokens() { + void loadRemovesExpiredTokens() { // Create a token that will expire immediately final long pastTime = System.currentTimeMillis() - 1000; config.createToken("WillExpire", "db1", pastTime, new JSONObject()); @@ -154,18 +154,17 @@ void testLoadRemovesExpiredTokens() { } @Test - void testDuplicateNameThrows() { + void duplicateNameThrows() { config.createToken("SameName", "db1", 0, new JSONObject()); - org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, - () -> config.createToken("SameName", "db2", 0, new JSONObject())); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> config.createToken("SameName", "db2", 0, new JSONObject())); // Only one token should exist assertThat(config.listTokens()).hasSize(1); } @Test - void testIsApiToken() { + void isApiToken() { assertThat(ApiTokenConfiguration.isApiToken("at-550e8400-e29b-41d4-a716-446655440000")).isTrue(); assertThat(ApiTokenConfiguration.isApiToken("AU-some-session-token")).isFalse(); assertThat(ApiTokenConfiguration.isApiToken(null)).isFalse(); diff --git a/server/src/test/java/com/arcadedb/server/security/ServerSecurityIT.java b/server/src/test/java/com/arcadedb/server/security/ServerSecurityIT.java index 05a3935679..bb8b73a480 100644 --- a/server/src/test/java/com/arcadedb/server/security/ServerSecurityIT.java +++ b/server/src/test/java/com/arcadedb/server/security/ServerSecurityIT.java @@ -33,8 +33,10 @@ import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; import static java.util.concurrent.TimeUnit.*; import static org.assertj.core.api.Assertions.assertThat; @@ -165,8 +167,8 @@ void dropUserIsThreadSafeUnderConcurrentAccess() throws Exception { final int threadCount = 10; final int operationsPerThread = 20; final ExecutorService executor = Executors.newFixedThreadPool(threadCount); - final List> futures = new java.util.ArrayList<>(); - final java.util.concurrent.atomic.AtomicInteger errors = new java.util.concurrent.atomic.AtomicInteger(0); + final List> futures = new ArrayList<>(); + final AtomicInteger errors = new AtomicInteger(0); for (int t = 0; t < threadCount; t++) { final int threadId = t; From 07f95749b73b493b67a43ac1f0448e9d84661ce2 Mon Sep 17 00:00:00 2001 From: robfrank Date: Tue, 24 Feb 2026 19:10:02 +0100 Subject: [PATCH 34/47] test(geo): add GeoHashIndexTest with geohash index tests Co-Authored-By: Claude Sonnet 4.6 --- .../function/sql/geo/GeoHashIndexTest.java | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 engine/src/test/java/com/arcadedb/function/sql/geo/GeoHashIndexTest.java diff --git a/engine/src/test/java/com/arcadedb/function/sql/geo/GeoHashIndexTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoHashIndexTest.java new file mode 100644 index 0000000000..2e35f27195 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoHashIndexTest.java @@ -0,0 +1,119 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.TestHelper; +import com.arcadedb.database.Document; +import com.arcadedb.database.MutableDocument; +import com.arcadedb.index.Index; +import com.arcadedb.query.sql.executor.ResultSet; +import com.arcadedb.schema.DocumentType; +import com.arcadedb.schema.Schema; +import com.arcadedb.schema.Type; + +import org.junit.jupiter.api.Test; +import org.locationtech.spatial4j.io.GeohashUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +class GeoHashIndexTest { + + @Test + void geoManualIndexPoints() throws Exception { + final int TOTAL = 1_000; + + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + + db.transaction(() -> { + final DocumentType type = db.getSchema().createDocumentType("Restaurant"); + type.createProperty("coords", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); + + for (int i = 0; i < TOTAL; i++) { + final MutableDocument doc = db.newDocument("Restaurant"); + doc.set("lat", 10 + (0.01D * i)); + doc.set("long", 10 + (0.01D * i)); + doc.set("coords", GeohashUtils.encodeLatLon(doc.getDouble("lat"), doc.getDouble("long"))); + doc.save(); + } + + final String[] area = new String[] { GeohashUtils.encodeLatLon(10.5, 10.5), GeohashUtils.encodeLatLon(10.55, 10.55) }; + + ResultSet result = db.query("sql", "select from Restaurant where coords >= ? and coords <= ?", area[0], area[1]); + + assertThat(result.hasNext()).isTrue(); + int returned = 0; + while (result.hasNext()) { + final Document record = result.next().toElement(); + assertThat(record.getDouble("lat")).isGreaterThanOrEqualTo(10.5); + assertThat(record.getDouble("long")).isLessThanOrEqualTo(10.55); + ++returned; + } + + assertThat(returned).isEqualTo(6); + }); + }); + } + + @Test + void geoManualIndexBoundingBoxes() throws Exception { + final int TOTAL = 1_000; + + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + + db.transaction(() -> { + final DocumentType type = db.getSchema().createDocumentType("Restaurant"); + type.createProperty("bboxTL", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); + type.createProperty("bboxBR", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); + + for (int i = 0; i < TOTAL; i++) { + final MutableDocument doc = db.newDocument("Restaurant"); + doc.set("x1", 10D + (0.0001D * i)); + doc.set("y1", 10D + (0.0001D * i)); + doc.set("x2", 10D + (0.001D * i)); + doc.set("y2", 10D + (0.001D * i)); + doc.set("bboxTL", GeohashUtils.encodeLatLon(doc.getDouble("x1"), doc.getDouble("y1"))); + doc.set("bboxBR", GeohashUtils.encodeLatLon(doc.getDouble("x2"), doc.getDouble("y2"))); + doc.save(); + } + + for (Index idx : type.getAllIndexes(false)) + assertThat(idx.countEntries()).isEqualTo(TOTAL); + + final String[] area = new String[] { + GeohashUtils.encodeLatLon(10.0001D, 10.0001D), + GeohashUtils.encodeLatLon(10.020D, 10.020D) }; + + ResultSet result = db.query("sql", "select from Restaurant where bboxTL >= ? and bboxBR <= ?", area[0], area[1]); + + assertThat(result.hasNext()).isTrue(); + int returned = 0; + while (result.hasNext()) { + final Document record = result.next().toElement(); + assertThat(record.getDouble("x1")).isGreaterThanOrEqualTo(10.0001D).withFailMessage("x1: " + record.getDouble("x1")); + assertThat(record.getDouble("y1")).isGreaterThanOrEqualTo(10.0001D).withFailMessage("y1: " + record.getDouble("y1")); + assertThat(record.getDouble("x2")).isLessThanOrEqualTo(10.020D).withFailMessage("x2: " + record.getDouble("x2")); + assertThat(record.getDouble("y2")).isLessThanOrEqualTo(10.020D).withFailMessage("y2: " + record.getDouble("y2")); + ++returned; + } + + assertThat(returned).isEqualTo(20); + }); + }); + } +} From 046e560cc8d7ae20e418aa041007f42655db3709 Mon Sep 17 00:00:00 2001 From: robfrank Date: Tue, 24 Feb 2026 19:15:44 +0100 Subject: [PATCH 35/47] test(geo): add GeoConstructionFunctionsTest with SQL, execute(), and error paths Co-Authored-By: Claude Sonnet 4.6 --- .../sql/geo/GeoConstructionFunctionsTest.java | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 engine/src/test/java/com/arcadedb/function/sql/geo/GeoConstructionFunctionsTest.java diff --git a/engine/src/test/java/com/arcadedb/function/sql/geo/GeoConstructionFunctionsTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoConstructionFunctionsTest.java new file mode 100644 index 0000000000..583398fea9 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoConstructionFunctionsTest.java @@ -0,0 +1,319 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.TestHelper; +import com.arcadedb.query.sql.executor.ResultSet; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.locationtech.spatial4j.shape.Shape; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GeoConstructionFunctionsTest { + + // ─── geo.geomFromText ───────────────────────────────────────────────────────── + + @Nested + class GeomFromText { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.geomFromText('POINT (10 20)') as geom"); + assertThat(result.hasNext()).isTrue(); + final Object geom = result.next().getProperty("geom"); + assertThat(geom).isNotNull(); + assertThat(geom).isInstanceOf(Shape.class); + }); + } + + @Test + void javaExecuteHappyPath() { + final Object result = new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Shape.class); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.geomFromText(null) as geom"); + assertThat(result.hasNext()).isTrue(); + final Object geom = result.next().getProperty("geom"); + assertThat(geom).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + + @Test + void noArgs_execute_returnsNull() { + assertThat(new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[0], null)).isNull(); + } + + @Test + void invalidWkt_execute_throwsException() { + assertThatThrownBy(() -> new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[] { "NOT VALID WKT ###" }, null)) + .isInstanceOf(Exception.class); + } + + @Test + void emptyString_execute_returnsNull() { + // GeoUtils.parseGeometry returns null for empty strings rather than throwing + assertThat(new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[] { "" }, null)).isNull(); + } + } + + // ─── geo.point ──────────────────────────────────────────────────────────────── + + @Nested + class Point { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.point(10, 20) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POINT"); + assertThat(wkt).contains("10"); + assertThat(wkt).contains("20"); + }); + } + + @Test + void javaExecuteHappyPath() { + final Object result = new SQLFunctionGeoPoint() + .execute(null, null, null, new Object[] { 10.0, 20.0 }, null); + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(String.class); + assertThat((String) result).startsWith("POINT"); + assertThat((String) result).contains("10"); + assertThat((String) result).contains("20"); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.point(null, 20) as wkt"); + assertThat(result.hasNext()).isTrue(); + final Object wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNull(); + }); + } + + @Test + void nullFirstArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoPoint() + .execute(null, null, null, new Object[] { null, 20.0 }, null)).isNull(); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoPoint() + .execute(null, null, null, new Object[] { 10.0, null }, null)).isNull(); + } + + @Test + void tooFewArgs_execute_returnsNull() { + assertThat(new SQLFunctionGeoPoint() + .execute(null, null, null, new Object[] { 10.0 }, null)).isNull(); + } + + @Test + void noArgs_execute_returnsNull() { + assertThat(new SQLFunctionGeoPoint() + .execute(null, null, null, new Object[0], null)).isNull(); + } + } + + // ─── geo.lineString ─────────────────────────────────────────────────────────── + + @Nested + class LineString { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.lineString([[0,0],[10,10],[20,0]]) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("LINESTRING"); + assertThat(wkt).contains("0 0"); + assertThat(wkt).contains("10 10"); + assertThat(wkt).contains("20 0"); + }); + } + + @Test + void javaExecuteHappyPath() { + final List> coords = List.of( + List.of(0.0, 0.0), + List.of(10.0, 10.0), + List.of(20.0, 0.0)); + final Object result = new SQLFunctionGeoLineString() + .execute(null, null, null, new Object[] { coords }, null); + assertThat(result).isNotNull(); + assertThat((String) result).startsWith("LINESTRING"); + assertThat((String) result).contains("0 0"); + assertThat((String) result).contains("10 10"); + assertThat((String) result).contains("20 0"); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.lineString(null) as wkt"); + assertThat(result.hasNext()).isTrue(); + final Object wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoLineString() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + + @Test + void emptyList_execute_returnsNull() { + assertThat(new SQLFunctionGeoLineString() + .execute(null, null, null, new Object[] { List.of() }, null)).isNull(); + } + + @Test + void invalidListElement_execute_throwsException() { + final List badCoords = List.of("not_a_coordinate"); + assertThatThrownBy(() -> new SQLFunctionGeoLineString() + .execute(null, null, null, new Object[] { badCoords }, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void singlePoint_execute_returnsLineString() { + // Degenerate but valid from the function's perspective — just formats the coord + final List> single = List.of(List.of(0.0, 0.0)); + final Object result = new SQLFunctionGeoLineString() + .execute(null, null, null, new Object[] { single }, null); + assertThat(result).isNotNull(); + assertThat((String) result).startsWith("LINESTRING"); + } + } + + // ─── geo.polygon ────────────────────────────────────────────────────────────── + + @Nested + class Polygon { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.polygon([[0,0],[10,0],[10,10],[0,10],[0,0]]) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POLYGON"); + assertThat(wkt).contains("0 0"); + assertThat(wkt).contains("10 0"); + assertThat(wkt).contains("10 10"); + }); + } + + @Test + void javaExecuteHappyPath() { + final List> ring = List.of( + List.of(0.0, 0.0), List.of(10.0, 0.0), + List.of(10.0, 10.0), List.of(0.0, 10.0), + List.of(0.0, 0.0)); + final Object result = new SQLFunctionGeoPolygon() + .execute(null, null, null, new Object[] { ring }, null); + assertThat(result).isNotNull(); + assertThat((String) result).startsWith("POLYGON"); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.polygon(null) as wkt"); + assertThat(result.hasNext()).isTrue(); + final Object wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoPolygon() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + + @Test + void openRing_sql_autoClose() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.polygon([[0,0],[10,0],[10,10],[0,10]]) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POLYGON"); + final String inner = wkt.substring(wkt.indexOf("((") + 2, wkt.lastIndexOf("))")); + final String[] coords = inner.split(","); + assertThat(coords[0].trim()).isEqualTo(coords[coords.length - 1].trim()); + }); + } + + @Test + void openRing_execute_autoClose() { + // Ring without closing coord — function should auto-close it + final List> openRing = List.of( + List.of(0.0, 0.0), List.of(10.0, 0.0), + List.of(10.0, 10.0), List.of(0.0, 10.0)); + final String wkt = (String) new SQLFunctionGeoPolygon() + .execute(null, null, null, new Object[] { openRing }, null); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POLYGON"); + final String inner = wkt.substring(wkt.indexOf("((") + 2, wkt.lastIndexOf("))")); + final String[] coords = inner.split(","); + assertThat(coords[0].trim()).isEqualTo(coords[coords.length - 1].trim()); + } + + @Test + void invalidListElement_execute_throwsException() { + final List badRing = List.of("not_a_coordinate"); + assertThatThrownBy(() -> new SQLFunctionGeoPolygon() + .execute(null, null, null, new Object[] { badRing }, null)) + .isInstanceOf(IllegalArgumentException.class); + } + } +} From 39dddb571f0dc4f18959a24a603b7303f2b57c77 Mon Sep 17 00:00:00 2001 From: robfrank Date: Tue, 24 Feb 2026 19:19:30 +0100 Subject: [PATCH 36/47] test(geo): use specific IllegalArgumentException in geomFromText invalid WKT test Co-Authored-By: Claude Sonnet 4.6 --- .../arcadedb/function/sql/geo/GeoConstructionFunctionsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/src/test/java/com/arcadedb/function/sql/geo/GeoConstructionFunctionsTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoConstructionFunctionsTest.java index 583398fea9..e1b72770d5 100644 --- a/engine/src/test/java/com/arcadedb/function/sql/geo/GeoConstructionFunctionsTest.java +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoConstructionFunctionsTest.java @@ -82,7 +82,7 @@ void noArgs_execute_returnsNull() { void invalidWkt_execute_throwsException() { assertThatThrownBy(() -> new SQLFunctionGeoGeomFromText() .execute(null, null, null, new Object[] { "NOT VALID WKT ###" }, null)) - .isInstanceOf(Exception.class); + .isInstanceOf(IllegalArgumentException.class); } @Test From 3681d94d3b2ac44a4fc89ec0b61a2127064062b8 Mon Sep 17 00:00:00 2001 From: robfrank Date: Tue, 24 Feb 2026 19:23:49 +0100 Subject: [PATCH 37/47] test(geo): add GeoMeasurementFunctionsTest with SQL, execute(), and error paths Co-Authored-By: Claude Sonnet 4.6 --- .../sql/geo/GeoMeasurementFunctionsTest.java | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 engine/src/test/java/com/arcadedb/function/sql/geo/GeoMeasurementFunctionsTest.java diff --git a/engine/src/test/java/com/arcadedb/function/sql/geo/GeoMeasurementFunctionsTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoMeasurementFunctionsTest.java new file mode 100644 index 0000000000..b96ee8fe56 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoMeasurementFunctionsTest.java @@ -0,0 +1,281 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.TestHelper; +import com.arcadedb.query.sql.executor.ResultSet; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GeoMeasurementFunctionsTest { + + // ─── geo.buffer ─────────────────────────────────────────────────────────────── + + @Nested + class Buffer { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.buffer('POINT (10 20)', 1.0) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POLYGON"); + }); + } + + @Test + void javaExecuteHappyPath() { + final Object result = new SQLFunctionGeoBuffer() + .execute(null, null, null, new Object[] { "POINT (10 20)", 1.0 }, null); + assertThat(result).isNotNull(); + assertThat((String) result).startsWith("POLYGON"); + } + + @Test + void nullGeometry_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.buffer(null, 1.0) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNull(); + }); + } + + @Test + void nullGeometry_execute_returnsNull() { + assertThat(new SQLFunctionGeoBuffer() + .execute(null, null, null, new Object[] { null, 1.0 }, null)).isNull(); + } + + @Test + void nullDistance_execute_returnsNull() { + assertThat(new SQLFunctionGeoBuffer() + .execute(null, null, null, new Object[] { "POINT (10 20)", null }, null)).isNull(); + } + + @Test + void invalidGeometry_execute_throwsException() { + assertThatThrownBy(() -> new SQLFunctionGeoBuffer() + .execute(null, null, null, new Object[] { "NOT VALID WKT", 1.0 }, null)) + .isInstanceOf(Exception.class); + } + } + + // ─── geo.distance ───────────────────────────────────────────────────────────── + + @Nested + class Distance { + + @Test + void sqlHappyPath_meters() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.distance('POINT (0 0)', 'POINT (1 0)') as dist"); + assertThat(result.hasNext()).isTrue(); + final Double dist = result.next().getProperty("dist"); + assertThat(dist).isNotNull(); + assertThat(dist).isGreaterThan(0.0); + }); + } + + @Test + void sqlHappyPath_km_lessThanMeters() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet r = db.query("sql", "select geo.distance('POINT (0 0)', 'POINT (1 0)') as dist"); + final Double distM = r.next().getProperty("dist"); + + r = db.query("sql", "select geo.distance('POINT (0 0)', 'POINT (1 0)', 'km') as dist"); + final Double distKm = r.next().getProperty("dist"); + + assertThat(distKm).isGreaterThan(0.0); + assertThat(distKm).isLessThan(distM); + }); + } + + @Test + void javaExecuteHappyPath_meters() { + final Double dist = (Double) new SQLFunctionGeoDistance() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (1 0)" }, null); + assertThat(dist).isNotNull(); + assertThat(dist).isGreaterThan(0.0); + } + + @Test + void javaExecuteHappyPath_miles() { + final Double distMi = (Double) new SQLFunctionGeoDistance() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (1 0)", "mi" }, null); + assertThat(distMi).isNotNull(); + assertThat(distMi).isGreaterThan(0.0); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.distance(null, 'POINT (1 0)') as dist"); + assertThat(result.hasNext()).isTrue(); + final Double dist = result.next().getProperty("dist"); + assertThat(dist).isNull(); + }); + } + + @Test + void nullFirstArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoDistance() + .execute(null, null, null, new Object[] { null, "POINT (1 0)" }, null)).isNull(); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoDistance() + .execute(null, null, null, new Object[] { "POINT (0 0)", null }, null)).isNull(); + } + + @Test + void invalidUnit_execute_throwsIllegalArgument() { + assertThatThrownBy(() -> new SQLFunctionGeoDistance() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (1 0)", "lightyear" }, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("lightyear"); + } + + @Test + void allSupportedUnits_execute_returnPositive() { + for (final String unit : new String[] { "m", "km", "mi", "nmi" }) { + final Double d = (Double) new SQLFunctionGeoDistance() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (1 0)", unit }, null); + assertThat(d).as("unit=%s", unit).isGreaterThan(0.0); + } + } + } + + // ─── geo.area ───────────────────────────────────────────────────────────────── + + @Nested + class Area { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select geo.area('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as area"); + assertThat(result.hasNext()).isTrue(); + final Double area = result.next().getProperty("area"); + assertThat(area).isNotNull(); + assertThat(area).isGreaterThan(0.0); + }); + } + + @Test + void javaExecuteHappyPath() { + final Double area = (Double) new SQLFunctionGeoArea() + .execute(null, null, null, new Object[] { "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))" }, null); + assertThat(area).isNotNull(); + assertThat(area).isGreaterThan(0.0); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.area(null) as area"); + assertThat(result.hasNext()).isTrue(); + final Double area = result.next().getProperty("area"); + assertThat(area).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoArea() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + + @Test + void pointGeometry_execute_returnsZero() { + // A point has no area + final Double area = (Double) new SQLFunctionGeoArea() + .execute(null, null, null, new Object[] { "POINT (5 5)" }, null); + assertThat(area).isNotNull(); + assertThat(area).isEqualTo(0.0); + } + } + + // ─── geo.envelope ───────────────────────────────────────────────────────────── + + @Nested + class Envelope { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select geo.envelope('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POLYGON"); + assertThat(wkt).contains("0 0"); + assertThat(wkt).contains("10 10"); + }); + } + + @Test + void javaExecuteHappyPath() { + final Object result = new SQLFunctionGeoEnvelope() + .execute(null, null, null, new Object[] { "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))" }, null); + assertThat(result).isNotNull(); + assertThat((String) result).startsWith("POLYGON"); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.envelope(null) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoEnvelope() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + + @Test + void invalidWkt_execute_throwsException() { + assertThatThrownBy(() -> new SQLFunctionGeoEnvelope() + .execute(null, null, null, new Object[] { "NOT VALID WKT" }, null)) + .isInstanceOf(Exception.class); + } + + @Test + void pointEnvelope_execute_returnsResult() { + // Envelope of a point is degenerate but should not crash + final Object result = new SQLFunctionGeoEnvelope() + .execute(null, null, null, new Object[] { "POINT (5 5)" }, null); + assertThat(result).isNotNull(); + } + } +} From c89e4d18aca9a1d7410ce6bf44c1c37ed160cde0 Mon Sep 17 00:00:00 2001 From: robfrank Date: Tue, 24 Feb 2026 19:31:02 +0100 Subject: [PATCH 38/47] test(geo): use specific exception types and final vars in GeoMeasurementFunctionsTest Co-Authored-By: Claude Sonnet 4.6 --- .../sql/geo/GeoMeasurementFunctionsTest.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/engine/src/test/java/com/arcadedb/function/sql/geo/GeoMeasurementFunctionsTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoMeasurementFunctionsTest.java index b96ee8fe56..9eca64ca97 100644 --- a/engine/src/test/java/com/arcadedb/function/sql/geo/GeoMeasurementFunctionsTest.java +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoMeasurementFunctionsTest.java @@ -79,7 +79,7 @@ void nullDistance_execute_returnsNull() { void invalidGeometry_execute_throwsException() { assertThatThrownBy(() -> new SQLFunctionGeoBuffer() .execute(null, null, null, new Object[] { "NOT VALID WKT", 1.0 }, null)) - .isInstanceOf(Exception.class); + .isInstanceOf(IllegalArgumentException.class); } } @@ -102,11 +102,10 @@ void sqlHappyPath_meters() throws Exception { @Test void sqlHappyPath_km_lessThanMeters() throws Exception { TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet r = db.query("sql", "select geo.distance('POINT (0 0)', 'POINT (1 0)') as dist"); - final Double distM = r.next().getProperty("dist"); - - r = db.query("sql", "select geo.distance('POINT (0 0)', 'POINT (1 0)', 'km') as dist"); - final Double distKm = r.next().getProperty("dist"); + final ResultSet rMeters = db.query("sql", "select geo.distance('POINT (0 0)', 'POINT (1 0)') as dist"); + final Double distM = rMeters.next().getProperty("dist"); + final ResultSet rKm = db.query("sql", "select geo.distance('POINT (0 0)', 'POINT (1 0)', 'km') as dist"); + final Double distKm = rKm.next().getProperty("dist"); assertThat(distKm).isGreaterThan(0.0); assertThat(distKm).isLessThan(distM); @@ -267,7 +266,7 @@ void nullInput_execute_returnsNull() { void invalidWkt_execute_throwsException() { assertThatThrownBy(() -> new SQLFunctionGeoEnvelope() .execute(null, null, null, new Object[] { "NOT VALID WKT" }, null)) - .isInstanceOf(Exception.class); + .isInstanceOf(IllegalArgumentException.class); } @Test From 3ef6327db2fd52e015c3956f157997d040545aee Mon Sep 17 00:00:00 2001 From: robfrank Date: Tue, 24 Feb 2026 19:35:19 +0100 Subject: [PATCH 39/47] test(geo): add GeoConversionFunctionsTest with SQL, execute(), and error paths Co-Authored-By: Claude Sonnet 4.6 --- .../sql/geo/GeoConversionFunctionsTest.java | 332 ++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 engine/src/test/java/com/arcadedb/function/sql/geo/GeoConversionFunctionsTest.java diff --git a/engine/src/test/java/com/arcadedb/function/sql/geo/GeoConversionFunctionsTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoConversionFunctionsTest.java new file mode 100644 index 0000000000..132c637ff7 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoConversionFunctionsTest.java @@ -0,0 +1,332 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.TestHelper; +import com.arcadedb.query.sql.executor.ResultSet; + +import org.assertj.core.data.Offset; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.locationtech.spatial4j.shape.Shape; + +import static org.assertj.core.api.Assertions.assertThat; + +class GeoConversionFunctionsTest { + + // ─── geo.asText ─────────────────────────────────────────────────────────────── + + @Nested + class AsText { + + @Test + void sqlStringInput_returnedAsIs() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.asText('POINT (10 20)') as wkt"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("wkt")).isEqualTo("POINT (10 20)"); + }); + } + + @Test + void sqlShapeInput_returnsWkt() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select geo.asText(geo.geomFromText('POINT (10 20)')) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POINT"); + assertThat(wkt).contains("10"); + assertThat(wkt).contains("20"); + }); + } + + @Test + void javaExecute_stringInput_returnedAsIs() { + final Object result = new SQLFunctionGeoAsText() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + assertThat(result).isEqualTo("POINT (10 20)"); + } + + @Test + void javaExecute_shapeInput_returnsWkt() { + final Shape shape = (Shape) new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + final Object result = new SQLFunctionGeoAsText() + .execute(null, null, null, new Object[] { shape }, null); + assertThat(result).isNotNull(); + assertThat((String) result).startsWith("POINT"); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.asText(null) as wkt"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("wkt"); + assertThat(val).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoAsText() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + } + + // ─── geo.asGeoJson ──────────────────────────────────────────────────────────── + + @Nested + class AsGeoJson { + + @Test + void sqlPoint_containsPointType() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.asGeoJson('POINT (10 20)') as json"); + assertThat(result.hasNext()).isTrue(); + final String json = result.next().getProperty("json"); + assertThat(json).isNotNull(); + assertThat(json).contains("Point"); + assertThat(json).contains("coordinates"); + assertThat(json).contains("10"); + assertThat(json).contains("20"); + }); + } + + @Test + void sqlPolygon_containsPolygonType() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select geo.asGeoJson('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as json"); + assertThat(result.hasNext()).isTrue(); + final String json = result.next().getProperty("json"); + assertThat(json).isNotNull(); + assertThat(json).contains("Polygon"); + assertThat(json).contains("coordinates"); + }); + } + + @Test + void javaExecute_point_containsPointType() { + final Object result = new SQLFunctionGeoAsGeoJson() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + assertThat(result).isNotNull(); + assertThat((String) result).contains("Point"); + assertThat((String) result).contains("coordinates"); + } + + @Test + void javaExecute_lineString_containsLineStringType() { + final Object result = new SQLFunctionGeoAsGeoJson() + .execute(null, null, null, new Object[] { "LINESTRING (0 0, 10 10, 20 0)" }, null); + assertThat(result).isNotNull(); + assertThat((String) result).contains("LineString"); + assertThat((String) result).contains("coordinates"); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.asGeoJson(null) as json"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("json"); + assertThat(val).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoAsGeoJson() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + } + + // ─── geo.x ──────────────────────────────────────────────────────────────────── + + @Nested + class X { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.x('POINT (10 20)') as x"); + assertThat(result.hasNext()).isTrue(); + final Double x = result.next().getProperty("x"); + assertThat(x).isNotNull(); + assertThat(x).isEqualTo(10.0); + }); + } + + @Test + void javaExecuteHappyPath() { + final Double x = (Double) new SQLFunctionGeoX() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + assertThat(x).isNotNull(); + assertThat(x).isEqualTo(10.0); + } + + @Test + void javaExecute_shapeInput() { + final Shape shape = (Shape) new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + final Double x = (Double) new SQLFunctionGeoX() + .execute(null, null, null, new Object[] { shape }, null); + assertThat(x).isNotNull(); + assertThat(x).isEqualTo(10.0); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.x(null) as x"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("x"); + assertThat(val).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoX() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + + @Test + void polygonInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select geo.x('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as x"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("x"); + assertThat(val).isNull(); + }); + } + + @Test + void polygonInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoX() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))" }, null)).isNull(); + } + + @Test + void invalidWkt_execute_returnsNullSilently() { + // geo.x silently swallows parse errors and returns null + assertThat(new SQLFunctionGeoX() + .execute(null, null, null, new Object[] { "NOT_WKT_AT_ALL" }, null)).isNull(); + } + + @Test + void roundTrip_pointXMatches() { + final String wkt = (String) new SQLFunctionGeoPoint() + .execute(null, null, null, new Object[] { 42.5, -7.3 }, null); + final Double x = (Double) new SQLFunctionGeoX() + .execute(null, null, null, new Object[] { wkt }, null); + assertThat(x).isNotNull(); + assertThat(x).isCloseTo(42.5, Offset.offset(1e-6)); + } + } + + // ─── geo.y ──────────────────────────────────────────────────────────────────── + + @Nested + class Y { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.y('POINT (10 20)') as y"); + assertThat(result.hasNext()).isTrue(); + final Double y = result.next().getProperty("y"); + assertThat(y).isNotNull(); + assertThat(y).isEqualTo(20.0); + }); + } + + @Test + void javaExecuteHappyPath() { + final Double y = (Double) new SQLFunctionGeoY() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + assertThat(y).isNotNull(); + assertThat(y).isEqualTo(20.0); + } + + @Test + void javaExecute_shapeInput() { + final Shape shape = (Shape) new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + final Double y = (Double) new SQLFunctionGeoY() + .execute(null, null, null, new Object[] { shape }, null); + assertThat(y).isNotNull(); + assertThat(y).isEqualTo(20.0); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.y(null) as y"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("y"); + assertThat(val).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoY() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + + @Test + void polygonInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select geo.y('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as y"); + assertThat(result.hasNext()).isTrue(); + final Object val = result.next().getProperty("y"); + assertThat(val).isNull(); + }); + } + + @Test + void polygonInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoY() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))" }, null)).isNull(); + } + + @Test + void invalidWkt_execute_returnsNullSilently() { + assertThat(new SQLFunctionGeoY() + .execute(null, null, null, new Object[] { "NOT_WKT_AT_ALL" }, null)).isNull(); + } + + @Test + void roundTrip_pointYMatches() { + final String wkt = (String) new SQLFunctionGeoPoint() + .execute(null, null, null, new Object[] { 42.5, -7.3 }, null); + final Double y = (Double) new SQLFunctionGeoY() + .execute(null, null, null, new Object[] { wkt }, null); + assertThat(y).isNotNull(); + assertThat(y).isCloseTo(-7.3, Offset.offset(1e-6)); + } + } +} From c14353945d084b7d4db58ad2cde7203f92ba76d8 Mon Sep 17 00:00:00 2001 From: robfrank Date: Tue, 24 Feb 2026 19:43:47 +0100 Subject: [PATCH 40/47] test(geo): add GeoPredicateFunctionsTest with SQL, execute(), and error paths Co-Authored-By: Claude Sonnet 4.6 --- .../sql/geo/GeoPredicateFunctionsTest.java | 522 ++++++++++++++++++ 1 file changed, 522 insertions(+) create mode 100644 engine/src/test/java/com/arcadedb/function/sql/geo/GeoPredicateFunctionsTest.java diff --git a/engine/src/test/java/com/arcadedb/function/sql/geo/GeoPredicateFunctionsTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoPredicateFunctionsTest.java new file mode 100644 index 0000000000..aba9b4335c --- /dev/null +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoPredicateFunctionsTest.java @@ -0,0 +1,522 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.TestHelper; +import com.arcadedb.query.sql.executor.ResultSet; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GeoPredicateFunctionsTest { + + private static final String POLYGON_0_10 = "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))"; + private static final String POINT_INSIDE = "POINT (5 5)"; + private static final String POINT_OUTSIDE = "POINT (15 15)"; + + // ─── geo.within ─────────────────────────────────────────────────────────────── + + @Nested + class Within { + + @Test + void sql_pointInsidePolygon_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.within('POINT (5 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_pointOutsidePolygon_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.within('POINT (15 15)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_pointInside_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoWithin() + .execute(null, null, null, new Object[] { POINT_INSIDE, POLYGON_0_10 }, null)).isTrue(); + } + + @Test + void javaExecute_pointOutside_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoWithin() + .execute(null, null, null, new Object[] { POINT_OUTSIDE, POLYGON_0_10 }, null)).isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.within(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + final Object val = r.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoWithin() + .execute(null, null, null, new Object[] { POINT_INSIDE, null }, null)).isNull(); + } + + @Test + void nullFirstArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoWithin() + .execute(null, null, null, new Object[] { null, POLYGON_0_10 }, null)).isNull(); + } + } + + // ─── geo.intersects ─────────────────────────────────────────────────────────── + + @Nested + class Intersects { + + @Test + void sql_overlappingPolygons_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.intersects('POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))', 'POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_disjointPolygons_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.intersects('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_overlapping_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoIntersects() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))", "POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))" }, null)) + .isTrue(); + } + + @Test + void javaExecute_disjoint_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoIntersects() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", "POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))" }, null)) + .isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.intersects(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + final Object val = r.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoIntersects() + .execute(null, null, null, new Object[] { POLYGON_0_10, null }, null)).isNull(); + } + } + + // ─── geo.contains ───────────────────────────────────────────────────────────── + + @Nested + class Contains { + + @Test + void sql_polygonContainsPoint_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.contains('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))', 'POINT (5 5)') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_polygonDoesNotContainOutsidePoint_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.contains('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))', 'POINT (15 15)') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_contains_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoContains() + .execute(null, null, null, new Object[] { POLYGON_0_10, POINT_INSIDE }, null)).isTrue(); + } + + @Test + void javaExecute_doesNotContain_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoContains() + .execute(null, null, null, new Object[] { POLYGON_0_10, POINT_OUTSIDE }, null)).isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.contains(null, 'POINT (5 5)') as v"); + final Object val = r.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoContains() + .execute(null, null, null, new Object[] { POLYGON_0_10, null }, null)).isNull(); + } + } + + // ─── geo.dWithin ────────────────────────────────────────────────────────────── + + @Nested + class DWithin { + + @Test + void sql_nearbyPoints_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.dWithin('POINT (0 0)', 'POINT (1 1)', 2.0) as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_farPoints_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.dWithin('POINT (0 0)', 'POINT (10 10)', 1.0) as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_nearby_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoDWithin() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (1 1)", 2.0 }, null)).isTrue(); + } + + @Test + void javaExecute_farAway_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoDWithin() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (10 10)", 1.0 }, null)).isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.dWithin(null, 'POINT (1 1)', 2.0) as v"); + final Object val = r.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + @Test + void nullThirdArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoDWithin() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (1 1)", null }, null)).isNull(); + } + + @Test + void negativeDistance_execute_returnsFalse() { + // No real distance is <= a negative threshold + assertThat((Boolean) new SQLFunctionGeoDWithin() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (1 0)", -1.0 }, null)).isFalse(); + } + } + + // ─── geo.disjoint ───────────────────────────────────────────────────────────── + + @Nested + class Disjoint { + + @Test + void sql_farApartShapes_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.disjoint('POINT (50 50)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_intersectingShapes_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.disjoint('POINT (5 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_disjoint_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoDisjoint() + .execute(null, null, null, new Object[] { "POINT (50 50)", POLYGON_0_10 }, null)).isTrue(); + } + + @Test + void javaExecute_intersecting_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoDisjoint() + .execute(null, null, null, new Object[] { POINT_INSIDE, POLYGON_0_10 }, null)).isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.disjoint(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + final Object val = r.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoDisjoint() + .execute(null, null, null, new Object[] { POINT_INSIDE, null }, null)).isNull(); + } + } + + // ─── geo.equals ─────────────────────────────────────────────────────────────── + + @Nested + class Equals { + + @Test + void sql_identicalPoints_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.equals('POINT (5 5)', 'POINT (5 5)') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_differentPoints_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.equals('POINT (5 5)', 'POINT (6 6)') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_identicalPoints_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoEquals() + .execute(null, null, null, new Object[] { "POINT (5 5)", "POINT (5 5)" }, null)).isTrue(); + } + + @Test + void javaExecute_differentPoints_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoEquals() + .execute(null, null, null, new Object[] { "POINT (5 5)", "POINT (6 6)" }, null)).isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.equals(null, 'POINT (5 5)') as v"); + final Object val = r.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoEquals() + .execute(null, null, null, new Object[] { "POINT (5 5)", null }, null)).isNull(); + } + } + + // ─── geo.crosses ────────────────────────────────────────────────────────────── + + @Nested + class Crosses { + + @Test + void sql_lineCrossesPolygon_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.crosses('LINESTRING (-1 5, 11 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void javaExecute_lineCrossesPolygon_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoCrosses() + .execute(null, null, null, + new Object[] { "LINESTRING (-1 5, 11 5)", POLYGON_0_10 }, null)).isTrue(); + } + + @Test + void javaExecute_overlappingPolygons_returnsFalse() { + // Two polygons of the same dimension cannot "cross" by the JTS DE-9IM definition + assertThat((Boolean) new SQLFunctionGeoCrosses() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))", "POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))" }, null)) + .isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.crosses(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + final Object val = r.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoCrosses() + .execute(null, null, null, new Object[] { "LINESTRING (-1 5, 11 5)", null }, null)).isNull(); + } + } + + // ─── geo.overlaps ───────────────────────────────────────────────────────────── + + @Nested + class Overlaps { + + @Test + void sql_partiallyOverlapping_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.overlaps('POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))', 'POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_disjointPolygons_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.overlaps('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_overlapping_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoOverlaps() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))", "POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))" }, null)) + .isTrue(); + } + + @Test + void javaExecute_disjoint_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoOverlaps() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", "POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))" }, null)) + .isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.overlaps(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + final Object val = r.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoOverlaps() + .execute(null, null, null, new Object[] { POLYGON_0_10, null }, null)).isNull(); + } + } + + // ─── geo.touches ────────────────────────────────────────────────────────────── + + @Nested + class Touches { + + @Test + void sql_adjacentPolygons_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.touches('POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))', 'POLYGON ((5 0, 10 0, 10 5, 5 5, 5 0))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_disjointPolygons_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.touches('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_adjacent_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoTouches() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))", "POLYGON ((5 0, 10 0, 10 5, 5 5, 5 0))" }, null)) + .isTrue(); + } + + @Test + void javaExecute_disjoint_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoTouches() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", "POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))" }, null)) + .isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.touches(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + final Object val = r.next().getProperty("v"); + assertThat(val).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoTouches() + .execute(null, null, null, new Object[] { POLYGON_0_10, null }, null)).isNull(); + } + } +} From ea65236e498ef43e2ce2f0f31d733a963113cd04 Mon Sep 17 00:00:00 2001 From: robfrank Date: Tue, 24 Feb 2026 19:47:43 +0100 Subject: [PATCH 41/47] test(geo): add missing DWithin null-second-arg and Crosses SQL false tests Co-Authored-By: Claude Sonnet 4.6 --- .../sql/geo/GeoPredicateFunctionsTest.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/engine/src/test/java/com/arcadedb/function/sql/geo/GeoPredicateFunctionsTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoPredicateFunctionsTest.java index aba9b4335c..9049e08fe0 100644 --- a/engine/src/test/java/com/arcadedb/function/sql/geo/GeoPredicateFunctionsTest.java +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoPredicateFunctionsTest.java @@ -243,6 +243,12 @@ void nullFirstArg_sql_returnsNull() throws Exception { }); } + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoDWithin() + .execute(null, null, null, new Object[] { "POINT (0 0)", null, 2.0 }, null)).isNull(); + } + @Test void nullThirdArg_execute_returnsNull() { assertThat(new SQLFunctionGeoDWithin() @@ -375,6 +381,15 @@ void sql_lineCrossesPolygon_returnsTrue() throws Exception { }); } + @Test + void sql_disjointLine_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.crosses('LINESTRING (20 20, 30 30)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + @Test void javaExecute_lineCrossesPolygon_returnsTrue() { assertThat((Boolean) new SQLFunctionGeoCrosses() From 8da56172bcbf639e964db04ad42c2514f0239d8f Mon Sep 17 00:00:00 2001 From: robfrank Date: Tue, 24 Feb 2026 20:07:53 +0100 Subject: [PATCH 42/47] =?UTF-8?q?test(geo):=20remove=20SQLGeoFunctionsTest?= =?UTF-8?q?=20=E2=80=94=20superseded=20by=204=20focused=20test=20classes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../function/sql/geo/SQLGeoFunctionsTest.java | 810 ------------------ 1 file changed, 810 deletions(-) delete mode 100644 engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java diff --git a/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java deleted file mode 100644 index c866b34e48..0000000000 --- a/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java +++ /dev/null @@ -1,810 +0,0 @@ -/* - * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) - * - * 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 (info@arcadedata.com) - * SPDX-License-Identifier: Apache-2.0 - */ -package com.arcadedb.function.sql.geo; - -import com.arcadedb.TestHelper; -import com.arcadedb.database.Document; -import com.arcadedb.database.MutableDocument; -import com.arcadedb.index.Index; -import com.arcadedb.query.sql.executor.ResultSet; -import com.arcadedb.schema.DocumentType; -import com.arcadedb.schema.Schema; -import com.arcadedb.schema.Type; - -import org.assertj.core.data.Offset; -import org.junit.jupiter.api.Test; -import org.locationtech.spatial4j.io.GeohashUtils; -import org.locationtech.spatial4j.shape.Shape; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * @author Luca Garulli (l.garulli@arcadedata.com) - */ -class SQLGeoFunctionsTest { - - @Test - void geoManualIndexPoints() throws Exception { - final int TOTAL = 1_000; - - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - - db.transaction(() -> { - final DocumentType type = db.getSchema().createDocumentType("Restaurant"); - type.createProperty("coords", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); - - long begin = System.currentTimeMillis(); - - for (int i = 0; i < TOTAL; i++) { - final MutableDocument doc = db.newDocument("Restaurant"); - doc.set("lat", 10 + (0.01D * i)); - doc.set("long", 10 + (0.01D * i)); - doc.set("coords", GeohashUtils.encodeLatLon(doc.getDouble("lat"), doc.getDouble("long"))); // INDEXED - doc.save(); - } - - final String[] area = new String[] { GeohashUtils.encodeLatLon(10.5, 10.5), GeohashUtils.encodeLatLon(10.55, 10.55) }; - - begin = System.currentTimeMillis(); - ResultSet result = db.query("sql", "select from Restaurant where coords >= ? and coords <= ?", area[0], area[1]); - - assertThat(result.hasNext()).isTrue(); - int returned = 0; - while (result.hasNext()) { - final Document record = result.next().toElement(); - assertThat(record.getDouble("lat")).isGreaterThanOrEqualTo(10.5); - assertThat(record.getDouble("long")).isLessThanOrEqualTo(10.55); - ++returned; - } - - assertThat(returned).isEqualTo(6); - }); - }); - } - - @Test - void geoManualIndexBoundingBoxes() throws Exception { - final int TOTAL = 1_000; - - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - - db.transaction(() -> { - final DocumentType type = db.getSchema().createDocumentType("Restaurant"); - type.createProperty("bboxTL", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); - type.createProperty("bboxBR", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); - - long begin = System.currentTimeMillis(); - - for (int i = 0; i < TOTAL; i++) { - final MutableDocument doc = db.newDocument("Restaurant"); - doc.set("x1", 10D + (0.0001D * i)); - doc.set("y1", 10D + (0.0001D * i)); - doc.set("x2", 10D + (0.001D * i)); - doc.set("y2", 10D + (0.001D * i)); - doc.set("bboxTL", GeohashUtils.encodeLatLon(doc.getDouble("x1"), doc.getDouble("y1"))); // INDEXED - doc.set("bboxBR", GeohashUtils.encodeLatLon(doc.getDouble("x2"), doc.getDouble("y2"))); // INDEXED - doc.save(); - } - - for (Index idx : type.getAllIndexes(false)) { - assertThat(idx.countEntries()).isEqualTo(TOTAL); - } - - final String[] area = new String[] {// - GeohashUtils.encodeLatLon(10.0001D, 10.0001D),// - GeohashUtils.encodeLatLon(10.020D, 10.020D) }; - - begin = System.currentTimeMillis(); - ResultSet result = db.query("sql", "select from Restaurant where bboxTL >= ? and bboxBR <= ?", area[0], area[1]); - - assertThat(result.hasNext()).isTrue(); - int returned = 0; - while (result.hasNext()) { - final Document record = result.next().toElement(); - assertThat(record.getDouble("x1")).isGreaterThanOrEqualTo(10.0001D).withFailMessage("x1: " + record.getDouble("x1")); - assertThat(record.getDouble("y1")).isGreaterThanOrEqualTo(10.0001D).withFailMessage("y1: " + record.getDouble("y1")); - assertThat(record.getDouble("x2")).isLessThanOrEqualTo(10.020D).withFailMessage("x2: " + record.getDouble("x2")); - assertThat(record.getDouble("y2")).isLessThanOrEqualTo(10.020D).withFailMessage("y2: " + record.getDouble("y2")); - ++returned; - } - - assertThat(returned).isEqualTo(20); - }); - }); - } - - // ─── geo.* standard function tests ──────────────────────────────────────────── - - @Test - void stGeomFromText() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - // Valid WKT point - ResultSet result = db.query("sql", "select geo.geomFromText('POINT (10 20)') as geom"); - assertThat(result.hasNext()).isTrue(); - final Object geom = result.next().getProperty("geom"); - assertThat(geom).isNotNull(); - assertThat(geom).isInstanceOf(Shape.class); - }); - } - - @Test - void stGeomFromTextNull() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.geomFromText(null) as geom"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("geom"); - assertThat(val).isNull(); - }); - } - - @Test - void stPoint() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.point(10, 20) as wkt"); - assertThat(result.hasNext()).isTrue(); - final String wkt = result.next().getProperty("wkt"); - assertThat(wkt).isNotNull(); - assertThat(wkt).startsWith("POINT"); - assertThat(wkt).contains("10"); - assertThat(wkt).contains("20"); - }); - } - - @Test - void stPointNull() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.point(null, 20) as wkt"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("wkt"); - assertThat(val).isNull(); - }); - } - - @Test - void stLineString() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.lineString([[0,0],[10,10],[20,0]]) as wkt"); - assertThat(result.hasNext()).isTrue(); - final String wkt = result.next().getProperty("wkt"); - assertThat(wkt).isNotNull(); - assertThat(wkt).startsWith("LINESTRING"); - assertThat(wkt).contains("0 0"); - assertThat(wkt).contains("10 10"); - assertThat(wkt).contains("20 0"); - }); - } - - @Test - void stLineStringNull() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.lineString(null) as wkt"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("wkt"); - assertThat(val).isNull(); - }); - } - - @Test - void stPolygon() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - // Closed ring - ResultSet result = db.query("sql", "select geo.polygon([[0,0],[10,0],[10,10],[0,10],[0,0]]) as wkt"); - assertThat(result.hasNext()).isTrue(); - final String wkt = result.next().getProperty("wkt"); - assertThat(wkt).isNotNull(); - assertThat(wkt).startsWith("POLYGON"); - assertThat(wkt).contains("0 0"); - assertThat(wkt).contains("10 0"); - assertThat(wkt).contains("10 10"); - }); - } - - @Test - void stPolygonAutoClose() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - // Open ring — should be auto-closed - ResultSet result = db.query("sql", "select geo.polygon([[0,0],[10,0],[10,10],[0,10]]) as wkt"); - assertThat(result.hasNext()).isTrue(); - final String wkt = result.next().getProperty("wkt"); - assertThat(wkt).isNotNull(); - assertThat(wkt).startsWith("POLYGON"); - // Ring is closed: last coord equals first - final String inner = wkt.substring(wkt.indexOf("((") + 2, wkt.lastIndexOf("))")); - final String[] coords = inner.split(","); - assertThat(coords[0].trim()).isEqualTo(coords[coords.length - 1].trim()); - }); - } - - @Test - void stPolygonNull() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.polygon(null) as wkt"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("wkt"); - assertThat(val).isNull(); - }); - } - - @Test - void stBuffer() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.buffer('POINT (10 20)', 1.0) as wkt"); - assertThat(result.hasNext()).isTrue(); - final String wkt = result.next().getProperty("wkt"); - assertThat(wkt).isNotNull(); - assertThat(wkt).startsWith("POLYGON"); - }); - } - - @Test - void stBufferNull() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.buffer(null, 1.0) as wkt"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("wkt"); - assertThat(val).isNull(); - }); - } - - @Test - void stEnvelope() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", - "select geo.envelope('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as wkt"); - assertThat(result.hasNext()).isTrue(); - final String wkt = result.next().getProperty("wkt"); - assertThat(wkt).isNotNull(); - assertThat(wkt).startsWith("POLYGON"); - assertThat(wkt).contains("0 0"); - assertThat(wkt).contains("10 10"); - }); - } - - @Test - void stEnvelopeNull() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.envelope(null) as wkt"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("wkt"); - assertThat(val).isNull(); - }); - } - - @Test - void stDistance() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - // Distance between two points in meters (default unit) - ResultSet result = db.query("sql", "select geo.distance('POINT (0 0)', 'POINT (1 0)') as dist"); - assertThat(result.hasNext()).isTrue(); - final Double dist = result.next().getProperty("dist"); - assertThat(dist).isNotNull(); - assertThat(dist).isGreaterThan(0.0); - - // Distance in km - result = db.query("sql", "select geo.distance('POINT (0 0)', 'POINT (1 0)', 'km') as dist"); - assertThat(result.hasNext()).isTrue(); - final Double distKm = result.next().getProperty("dist"); - assertThat(distKm).isNotNull(); - assertThat(distKm).isGreaterThan(0.0); - // km < m for the same distance - assertThat(distKm).isLessThan(dist); - }); - } - - @Test - void stDistanceNull() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.distance(null, 'POINT (1 0)') as dist"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("dist"); - assertThat(val).isNull(); - }); - } - - @Test - void stArea() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", - "select geo.area('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as area"); - assertThat(result.hasNext()).isTrue(); - final Double area = result.next().getProperty("area"); - assertThat(area).isNotNull(); - assertThat(area).isGreaterThan(0.0); - }); - } - - @Test - void stAreaNull() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.area(null) as area"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("area"); - assertThat(val).isNull(); - }); - } - - @Test - void stAsText() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - // String input → returned as-is - ResultSet result = db.query("sql", "select geo.asText('POINT (10 20)') as wkt"); - assertThat(result.hasNext()).isTrue(); - final String wkt = result.next().getProperty("wkt"); - assertThat(wkt).isEqualTo("POINT (10 20)"); - }); - } - - @Test - void stAsTextFromShape() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - // Shape → WKT - ResultSet result = db.query("sql", "select geo.asText(geo.geomFromText('POINT (10 20)')) as wkt"); - assertThat(result.hasNext()).isTrue(); - final String wkt = result.next().getProperty("wkt"); - assertThat(wkt).isNotNull(); - assertThat(wkt).startsWith("POINT"); - assertThat(wkt).contains("10"); - assertThat(wkt).contains("20"); - }); - } - - @Test - void stAsTextNull() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.asText(null) as wkt"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("wkt"); - assertThat(val).isNull(); - }); - } - - @Test - void stAsGeoJson() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.asGeoJson('POINT (10 20)') as json"); - assertThat(result.hasNext()).isTrue(); - final String json = result.next().getProperty("json"); - assertThat(json).isNotNull(); - assertThat(json).contains("Point"); - assertThat(json).contains("coordinates"); - assertThat(json).contains("10"); - assertThat(json).contains("20"); - }); - } - - @Test - void stAsGeoJsonPolygon() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", - "select geo.asGeoJson('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as json"); - assertThat(result.hasNext()).isTrue(); - final String json = result.next().getProperty("json"); - assertThat(json).isNotNull(); - assertThat(json).contains("Polygon"); - assertThat(json).contains("coordinates"); - }); - } - - @Test - void stAsGeoJsonNull() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.asGeoJson(null) as json"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("json"); - assertThat(val).isNull(); - }); - } - - @Test - void stX() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.x('POINT (10 20)') as x"); - assertThat(result.hasNext()).isTrue(); - final Double x = result.next().getProperty("x"); - assertThat(x).isNotNull(); - assertThat(x).isEqualTo(10.0); - }); - } - - @Test - void stXFromShape() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.x(geo.geomFromText('POINT (10 20)')) as x"); - assertThat(result.hasNext()).isTrue(); - final Double x = result.next().getProperty("x"); - assertThat(x).isNotNull(); - assertThat(x).isEqualTo(10.0); - }); - } - - @Test - void stXNonPoint() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - // geo.x on a polygon should return null - ResultSet result = db.query("sql", - "select geo.x('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as x"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("x"); - assertThat(val).isNull(); - }); - } - - @Test - void stXNull() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.x(null) as x"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("x"); - assertThat(val).isNull(); - }); - } - - @Test - void stY() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.y('POINT (10 20)') as y"); - assertThat(result.hasNext()).isTrue(); - final Double y = result.next().getProperty("y"); - assertThat(y).isNotNull(); - assertThat(y).isEqualTo(20.0); - }); - } - - @Test - void stYFromShape() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.y(geo.geomFromText('POINT (10 20)')) as y"); - assertThat(result.hasNext()).isTrue(); - final Double y = result.next().getProperty("y"); - assertThat(y).isNotNull(); - assertThat(y).isEqualTo(20.0); - }); - } - - @Test - void stYNonPoint() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", - "select geo.y('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as y"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("y"); - assertThat(val).isNull(); - }); - } - - @Test - void stYNull() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select geo.y(null) as y"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("y"); - assertThat(val).isNull(); - }); - } - - @Test - void stPointRoundTrip() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - // geo.point → geo.x / geo.y round-trip - // Note: small floating-point precision loss is expected when going through WKT parsing - ResultSet result = db.query("sql", "select geo.x(geo.geomFromText(geo.point(42.5, -7.3))) as x"); - assertThat(result.hasNext()).isTrue(); - final Double x = result.next().getProperty("x"); - assertThat(x).isNotNull(); - assertThat(x).isCloseTo(42.5, Offset.offset(1e-6)); - - result = db.query("sql", "select geo.y(geo.geomFromText(geo.point(42.5, -7.3))) as y"); - assertThat(result.hasNext()).isTrue(); - final Double y = result.next().getProperty("y"); - assertThat(y).isNotNull(); - assertThat(y).isCloseTo(-7.3, Offset.offset(1e-6)); - }); - } - - // ─── geo.within ──────────────────────────────────────────────────────────────── - - @Test - void stWithinPointInsidePolygon() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.within('POINT (5 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isTrue(); - }); - } - - @Test - void stWithinPointOutsidePolygon() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.within('POINT (15 15)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isFalse(); - }); - } - - @Test - void stWithinNullArg() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.within(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("v"); - assertThat(val).isNull(); - }); - } - - // ─── geo.intersects ──────────────────────────────────────────────────────────── - - @Test - void stIntersectsOverlappingPolygons() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.intersects('POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))', 'POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isTrue(); - }); - } - - @Test - void stIntersectsDisjointPolygons() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.intersects('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isFalse(); - }); - } - - @Test - void stIntersectsNullArg() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.intersects(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("v"); - assertThat(val).isNull(); - }); - } - - // ─── geo.contains ────────────────────────────────────────────────────────────── - - @Test - void stContainsPolygonContainsPoint() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.contains('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))', 'POINT (5 5)') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isTrue(); - }); - } - - @Test - void stContainsPolygonDoesNotContainOutsidePoint() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.contains('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))', 'POINT (15 15)') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isFalse(); - }); - } - - @Test - void stContainsNullArg() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.contains(null, 'POINT (5 5)') as v"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("v"); - assertThat(val).isNull(); - }); - } - - // ─── geo.dWithin ─────────────────────────────────────────────────────────────── - - @Test - void stDWithinNearbyPoints() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - // Two points at about 1.414 degrees apart; distance threshold = 2.0 → true - final ResultSet result = db.query("sql", - "select geo.dWithin('POINT (0 0)', 'POINT (1 1)', 2.0) as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isTrue(); - }); - } - - @Test - void stDWithinFarPoints() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - // Two points far apart; distance threshold = 1.0 → false - final ResultSet result = db.query("sql", - "select geo.dWithin('POINT (0 0)', 'POINT (10 10)', 1.0) as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isFalse(); - }); - } - - @Test - void stDWithinNullArg() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.dWithin(null, 'POINT (1 1)', 2.0) as v"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("v"); - assertThat(val).isNull(); - }); - } - - // ─── geo.disjoint ────────────────────────────────────────────────────────────── - - @Test - void stDisjointFarApartShapes() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.disjoint('POINT (50 50)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isTrue(); - }); - } - - @Test - void stDisjointIntersectingShapes() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.disjoint('POINT (5 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isFalse(); - }); - } - - @Test - void stDisjointNullArg() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.disjoint(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("v"); - assertThat(val).isNull(); - }); - } - - // ─── geo.equals ──────────────────────────────────────────────────────────────── - - @Test - void stEqualsIdenticalPoints() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.equals('POINT (5 5)', 'POINT (5 5)') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isTrue(); - }); - } - - @Test - void stEqualsDifferentPoints() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.equals('POINT (5 5)', 'POINT (6 6)') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isFalse(); - }); - } - - @Test - void stEqualsNullArg() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.equals(null, 'POINT (5 5)') as v"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("v"); - assertThat(val).isNull(); - }); - } - - // ─── geo.crosses ─────────────────────────────────────────────────────────────── - - @Test - void stCrossesLineCrossesPolygon() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - // A line crossing a polygon boundary - final ResultSet result = db.query("sql", - "select geo.crosses('LINESTRING (-1 5, 11 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isTrue(); - }); - } - - @Test - void stCrossesNullArg() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.crosses(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("v"); - assertThat(val).isNull(); - }); - } - - // ─── geo.overlaps ────────────────────────────────────────────────────────────── - - @Test - void stOverlapsPartiallyOverlappingPolygons() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.overlaps('POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))', 'POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isTrue(); - }); - } - - @Test - void stOverlapsDisjointPolygons() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.overlaps('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isFalse(); - }); - } - - @Test - void stOverlapsNullArg() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.overlaps(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("v"); - assertThat(val).isNull(); - }); - } - - // ─── geo.touches ─────────────────────────────────────────────────────────────── - - @Test - void stTouchesAdjacentPolygons() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - // Two polygons sharing exactly one edge - final ResultSet result = db.query("sql", - "select geo.touches('POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))', 'POLYGON ((5 0, 10 0, 10 5, 5 5, 5 0))') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isTrue(); - }); - } - - @Test - void stTouchesDisjointPolygons() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.touches('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("v")).isFalse(); - }); - } - - @Test - void stTouchesNullArg() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - final ResultSet result = db.query("sql", - "select geo.touches(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); - assertThat(result.hasNext()).isTrue(); - final Object val = result.next().getProperty("v"); - assertThat(val).isNull(); - }); - } -} From 3057d4f2d5a8b0d9fdf453e48a8f98a2c947ef68 Mon Sep 17 00:00:00 2001 From: robfrank Date: Tue, 24 Feb 2026 20:15:24 +0100 Subject: [PATCH 43/47] docs: add geo test coverage design and implementation plan Co-Authored-By: Claude Sonnet 4.6 --- ...2-24-geo-functions-test-coverage-design.md | 87 + .../2026-02-24-geo-functions-test-coverage.md | 1768 +++++++++++++++++ 2 files changed, 1855 insertions(+) create mode 100644 docs/plans/2026-02-24-geo-functions-test-coverage-design.md create mode 100644 docs/plans/2026-02-24-geo-functions-test-coverage.md diff --git a/docs/plans/2026-02-24-geo-functions-test-coverage-design.md b/docs/plans/2026-02-24-geo-functions-test-coverage-design.md new file mode 100644 index 0000000000..8b0563b997 --- /dev/null +++ b/docs/plans/2026-02-24-geo-functions-test-coverage-design.md @@ -0,0 +1,87 @@ +# Geo Functions Test Coverage — Design + +**Date:** 2026-02-24 +**Branch:** lsmtree-geospatial + +## Problem + +`SQLGeoFunctionsTest.java` is one large file covering all 20 geo SQL functions with SQL-only +happy-path and null-first-arg tests. It lacks: + +- Direct Java `execute()` tests (no `new SQLFunctionGeoXxx().execute(...)` calls) +- Error/wrong-path coverage (invalid WKT, wrong arg count, invalid unit, bad list elements) +- Clear per-function isolation + +## Decision + +**File split:** Group by logical category (4 new files). Rename the existing file to keep only +its two geohash index tests. + +**Within-file structure:** `@Nested` inner class per function, each containing SQL and Java +execute() tests plus error paths. + +## File Structure + +``` +engine/src/test/java/com/arcadedb/function/sql/geo/ + GeoHashIndexTest.java # renamed: keeps geoManualIndexPoints + geoManualIndexBoundingBoxes only + GeoConstructionFunctionsTest.java # NEW: geomFromText, point, lineString, polygon + GeoMeasurementFunctionsTest.java # NEW: buffer, distance, area, envelope + GeoConversionFunctionsTest.java # NEW: asText, asGeoJson, x, y + GeoPredicateFunctionsTest.java # NEW: within, intersects, contains, dWithin, + # disjoint, equals, crosses, overlaps, touches +``` + +## Test Method Pattern (per @Nested class) + +| Method | Approach | +|---|---| +| `sqlHappyPath()` | SQL via `TestHelper.executeInNewDatabase` + `db.query(...)` | +| `javaExecuteHappyPath()` | `new SQLFunctionGeoXxx().execute(null, null, null, params, null)` | +| `nullFirstArg_returnsNull()` | SQL with null arg | +| `nullFirstArg_execute_returnsNull()` | Java execute with null arg | +| function-specific error methods | See per-group error paths below | + +SQL tests require a database context (`TestHelper.executeInNewDatabase`). +Java `execute()` tests do **not** require a database — all functions use only `iParams`. + +## Error Path Coverage + +### GeoConstructionFunctionsTest +- `GeomFromText`: invalid WKT → `IllegalArgumentException`; empty string → `IllegalArgumentException` +- `Point`: one arg only → returns null; both args null → returns null +- `LineString`: list with invalid element → `IllegalArgumentException`; single-point list (degenerate valid case) +- `Polygon`: open ring → auto-closed (already covered in old file, keep it); invalid element → `IllegalArgumentException` + +### GeoMeasurementFunctionsTest +- `Buffer`: null distance arg → returns null; invalid geometry string → `IllegalArgumentException` +- `Distance`: invalid unit `"lightyear"` → `IllegalArgumentException`; second arg null → returns null +- `Area`: point geometry → returns 0.0 (point has zero area) +- `Envelope`: invalid WKT → `IllegalArgumentException` + +### GeoConversionFunctionsTest +- `AsText`: Shape object input (not raw string) → returns WKT; null → null +- `AsGeoJson`: LineString → `"LineString"` GeoJSON type; Polygon → `"Polygon"` GeoJSON type +- `X` / `Y`: polygon input → returns null (silently); invalid WKT string → returns null (silently) + +### GeoPredicateFunctionsTest +- All predicates: second arg null → returns null +- `DWithin`: third arg null → returns null; negative distance → false +- `Crosses`: polygon vs polygon (same type, never crosses) → false + +## Assertions Style + +Use AssertJ throughout: +```java +assertThat(result).isNotNull(); +assertThat(result).isInstanceOf(Shape.class); +assertThat(result).isEqualTo("POINT (10 20)"); +assertThatThrownBy(() -> fn.execute(null, null, null, params, null)) + .isInstanceOf(IllegalArgumentException.class); +``` + +## Non-Goals + +- No performance tests +- No index interaction tests (those belong in `GeoHashIndexTest`) +- No changes to production code diff --git a/docs/plans/2026-02-24-geo-functions-test-coverage.md b/docs/plans/2026-02-24-geo-functions-test-coverage.md new file mode 100644 index 0000000000..a827ec1c8d --- /dev/null +++ b/docs/plans/2026-02-24-geo-functions-test-coverage.md @@ -0,0 +1,1768 @@ +# Geo Functions Test Coverage Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Split `SQLGeoFunctionsTest.java` into 4 focused test classes (one per function group), each using `@Nested` per-function structure with SQL tests, direct Java `execute()` tests, and error-path coverage. + +**Architecture:** Rename `SQLGeoFunctionsTest.java` → `GeoHashIndexTest.java` (index tests only). Create four new test classes in the same package. Each new class contains one `@Nested` inner class per function. All tests are in the same package as production code (`com.arcadedb.function.sql.geo`), granting access to package-private helpers like `GeoUtils`. Java `execute()` tests call function constructors directly — no database required since all functions only use `iParams`. + +**Tech Stack:** JUnit 5 (`@Test`, `@Nested`), AssertJ (`assertThat`, `assertThatThrownBy`), `TestHelper.executeInNewDatabase` for SQL tests, Spatial4j `Shape`. + +--- + +### Task 1: Create `GeoHashIndexTest.java` + +Strip `SQLGeoFunctionsTest.java` to only its two geohash index tests and save as a new file. The old file will be deleted in Task 6. + +**Files:** +- Create: `engine/src/test/java/com/arcadedb/function/sql/geo/GeoHashIndexTest.java` + +**Step 1: Create the file** + +```java +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.TestHelper; +import com.arcadedb.database.Document; +import com.arcadedb.database.MutableDocument; +import com.arcadedb.index.Index; +import com.arcadedb.query.sql.executor.ResultSet; +import com.arcadedb.schema.DocumentType; +import com.arcadedb.schema.Schema; +import com.arcadedb.schema.Type; + +import org.junit.jupiter.api.Test; +import org.locationtech.spatial4j.io.GeohashUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +class GeoHashIndexTest { + + @Test + void geoManualIndexPoints() throws Exception { + final int TOTAL = 1_000; + + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + + db.transaction(() -> { + final DocumentType type = db.getSchema().createDocumentType("Restaurant"); + type.createProperty("coords", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); + + for (int i = 0; i < TOTAL; i++) { + final MutableDocument doc = db.newDocument("Restaurant"); + doc.set("lat", 10 + (0.01D * i)); + doc.set("long", 10 + (0.01D * i)); + doc.set("coords", GeohashUtils.encodeLatLon(doc.getDouble("lat"), doc.getDouble("long"))); + doc.save(); + } + + final String[] area = new String[] { GeohashUtils.encodeLatLon(10.5, 10.5), GeohashUtils.encodeLatLon(10.55, 10.55) }; + + ResultSet result = db.query("sql", "select from Restaurant where coords >= ? and coords <= ?", area[0], area[1]); + + assertThat(result.hasNext()).isTrue(); + int returned = 0; + while (result.hasNext()) { + final Document record = result.next().toElement(); + assertThat(record.getDouble("lat")).isGreaterThanOrEqualTo(10.5); + assertThat(record.getDouble("long")).isLessThanOrEqualTo(10.55); + ++returned; + } + + assertThat(returned).isEqualTo(6); + }); + }); + } + + @Test + void geoManualIndexBoundingBoxes() throws Exception { + final int TOTAL = 1_000; + + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + + db.transaction(() -> { + final DocumentType type = db.getSchema().createDocumentType("Restaurant"); + type.createProperty("bboxTL", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); + type.createProperty("bboxBR", Type.STRING).createIndex(Schema.INDEX_TYPE.LSM_TREE, false); + + for (int i = 0; i < TOTAL; i++) { + final MutableDocument doc = db.newDocument("Restaurant"); + doc.set("x1", 10D + (0.0001D * i)); + doc.set("y1", 10D + (0.0001D * i)); + doc.set("x2", 10D + (0.001D * i)); + doc.set("y2", 10D + (0.001D * i)); + doc.set("bboxTL", GeohashUtils.encodeLatLon(doc.getDouble("x1"), doc.getDouble("y1"))); + doc.set("bboxBR", GeohashUtils.encodeLatLon(doc.getDouble("x2"), doc.getDouble("y2"))); + doc.save(); + } + + for (Index idx : type.getAllIndexes(false)) + assertThat(idx.countEntries()).isEqualTo(TOTAL); + + final String[] area = new String[] { + GeohashUtils.encodeLatLon(10.0001D, 10.0001D), + GeohashUtils.encodeLatLon(10.020D, 10.020D) }; + + ResultSet result = db.query("sql", "select from Restaurant where bboxTL >= ? and bboxBR <= ?", area[0], area[1]); + + assertThat(result.hasNext()).isTrue(); + int returned = 0; + while (result.hasNext()) { + final Document record = result.next().toElement(); + assertThat(record.getDouble("x1")).isGreaterThanOrEqualTo(10.0001D).withFailMessage("x1: " + record.getDouble("x1")); + assertThat(record.getDouble("y1")).isGreaterThanOrEqualTo(10.0001D).withFailMessage("y1: " + record.getDouble("y1")); + assertThat(record.getDouble("x2")).isLessThanOrEqualTo(10.020D).withFailMessage("x2: " + record.getDouble("x2")); + assertThat(record.getDouble("y2")).isLessThanOrEqualTo(10.020D).withFailMessage("y2: " + record.getDouble("y2")); + ++returned; + } + + assertThat(returned).isEqualTo(20); + }); + }); + } +} +``` + +**Step 2: Compile** + +``` +mvn test-compile -pl engine -q +``` +Expected: BUILD SUCCESS + +**Step 3: Run** + +``` +mvn test -pl engine -Dtest=GeoHashIndexTest -q +``` +Expected: 2 tests pass + +**Step 4: Commit** + +```bash +git add engine/src/test/java/com/arcadedb/function/sql/geo/GeoHashIndexTest.java +git commit -m "test(geo): add GeoHashIndexTest with geohash index tests" +``` + +--- + +### Task 2: Create `GeoConstructionFunctionsTest.java` + +Covers: `geo.geomFromText`, `geo.point`, `geo.lineString`, `geo.polygon`. + +**Files:** +- Create: `engine/src/test/java/com/arcadedb/function/sql/geo/GeoConstructionFunctionsTest.java` + +**Step 1: Create the file** + +```java +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.TestHelper; +import com.arcadedb.query.sql.executor.ResultSet; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.locationtech.spatial4j.shape.Shape; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GeoConstructionFunctionsTest { + + // ─── geo.geomFromText ───────────────────────────────────────────────────────── + + @Nested + class GeomFromText { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.geomFromText('POINT (10 20)') as geom"); + assertThat(result.hasNext()).isTrue(); + final Object geom = result.next().getProperty("geom"); + assertThat(geom).isNotNull(); + assertThat(geom).isInstanceOf(Shape.class); + }); + } + + @Test + void javaExecuteHappyPath() { + final Object result = new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Shape.class); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.geomFromText(null) as geom"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("geom")).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + + @Test + void noArgs_execute_returnsNull() { + assertThat(new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[0], null)).isNull(); + } + + @Test + void invalidWkt_execute_throwsException() { + assertThatThrownBy(() -> new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[] { "NOT VALID WKT ###" }, null)) + .isInstanceOf(Exception.class); + } + + @Test + void emptyString_execute_throwsException() { + assertThatThrownBy(() -> new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[] { "" }, null)) + .isInstanceOf(Exception.class); + } + } + + // ─── geo.point ──────────────────────────────────────────────────────────────── + + @Nested + class Point { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.point(10, 20) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POINT"); + assertThat(wkt).contains("10"); + assertThat(wkt).contains("20"); + }); + } + + @Test + void javaExecuteHappyPath() { + final Object result = new SQLFunctionGeoPoint() + .execute(null, null, null, new Object[] { 10.0, 20.0 }, null); + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(String.class); + assertThat((String) result).startsWith("POINT"); + assertThat((String) result).contains("10"); + assertThat((String) result).contains("20"); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.point(null, 20) as wkt"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("wkt")).isNull(); + }); + } + + @Test + void nullFirstArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoPoint() + .execute(null, null, null, new Object[] { null, 20.0 }, null)).isNull(); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoPoint() + .execute(null, null, null, new Object[] { 10.0, null }, null)).isNull(); + } + + @Test + void tooFewArgs_execute_returnsNull() { + assertThat(new SQLFunctionGeoPoint() + .execute(null, null, null, new Object[] { 10.0 }, null)).isNull(); + } + + @Test + void noArgs_execute_returnsNull() { + assertThat(new SQLFunctionGeoPoint() + .execute(null, null, null, new Object[0], null)).isNull(); + } + } + + // ─── geo.lineString ─────────────────────────────────────────────────────────── + + @Nested + class LineString { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.lineString([[0,0],[10,10],[20,0]]) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("LINESTRING"); + assertThat(wkt).contains("0 0"); + assertThat(wkt).contains("10 10"); + assertThat(wkt).contains("20 0"); + }); + } + + @Test + void javaExecuteHappyPath() { + final List> coords = List.of( + List.of(0.0, 0.0), + List.of(10.0, 10.0), + List.of(20.0, 0.0)); + final Object result = new SQLFunctionGeoLineString() + .execute(null, null, null, new Object[] { coords }, null); + assertThat(result).isNotNull(); + assertThat((String) result).startsWith("LINESTRING"); + assertThat((String) result).contains("0 0"); + assertThat((String) result).contains("10 10"); + assertThat((String) result).contains("20 0"); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.lineString(null) as wkt"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("wkt")).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoLineString() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + + @Test + void emptyList_execute_returnsNull() { + assertThat(new SQLFunctionGeoLineString() + .execute(null, null, null, new Object[] { List.of() }, null)).isNull(); + } + + @Test + void invalidListElement_execute_throwsException() { + final List badCoords = List.of("not_a_coordinate"); + assertThatThrownBy(() -> new SQLFunctionGeoLineString() + .execute(null, null, null, new Object[] { badCoords }, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void singlePoint_execute_returnsLineString() { + // Degenerate but valid from the function's perspective — just formats the coord + final List> single = List.of(List.of(0.0, 0.0)); + final Object result = new SQLFunctionGeoLineString() + .execute(null, null, null, new Object[] { single }, null); + assertThat(result).isNotNull(); + assertThat((String) result).startsWith("LINESTRING"); + } + } + + // ─── geo.polygon ────────────────────────────────────────────────────────────── + + @Nested + class Polygon { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.polygon([[0,0],[10,0],[10,10],[0,10],[0,0]]) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POLYGON"); + assertThat(wkt).contains("0 0"); + assertThat(wkt).contains("10 0"); + assertThat(wkt).contains("10 10"); + }); + } + + @Test + void javaExecuteHappyPath() { + final List> ring = List.of( + List.of(0.0, 0.0), List.of(10.0, 0.0), + List.of(10.0, 10.0), List.of(0.0, 10.0), + List.of(0.0, 0.0)); + final Object result = new SQLFunctionGeoPolygon() + .execute(null, null, null, new Object[] { ring }, null); + assertThat(result).isNotNull(); + assertThat((String) result).startsWith("POLYGON"); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.polygon(null) as wkt"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("wkt")).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoPolygon() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + + @Test + void openRing_sql_autoClose() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.polygon([[0,0],[10,0],[10,10],[0,10]]) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POLYGON"); + final String inner = wkt.substring(wkt.indexOf("((") + 2, wkt.lastIndexOf("))")); + final String[] coords = inner.split(","); + assertThat(coords[0].trim()).isEqualTo(coords[coords.length - 1].trim()); + }); + } + + @Test + void openRing_execute_autoClose() { + // Ring without closing coord — function should auto-close it + final List> openRing = List.of( + List.of(0.0, 0.0), List.of(10.0, 0.0), + List.of(10.0, 10.0), List.of(0.0, 10.0)); + final String wkt = (String) new SQLFunctionGeoPolygon() + .execute(null, null, null, new Object[] { openRing }, null); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POLYGON"); + final String inner = wkt.substring(wkt.indexOf("((") + 2, wkt.lastIndexOf("))")); + final String[] coords = inner.split(","); + assertThat(coords[0].trim()).isEqualTo(coords[coords.length - 1].trim()); + } + + @Test + void invalidListElement_execute_throwsException() { + final List badRing = List.of("not_a_coordinate"); + assertThatThrownBy(() -> new SQLFunctionGeoPolygon() + .execute(null, null, null, new Object[] { badRing }, null)) + .isInstanceOf(IllegalArgumentException.class); + } + } +} +``` + +**Step 2: Compile** + +``` +mvn test-compile -pl engine -q +``` +Expected: BUILD SUCCESS + +**Step 3: Run** + +``` +mvn test -pl engine -Dtest=GeoConstructionFunctionsTest -q +``` +Expected: all tests pass + +**Step 4: Commit** + +```bash +git add engine/src/test/java/com/arcadedb/function/sql/geo/GeoConstructionFunctionsTest.java +git commit -m "test(geo): add GeoConstructionFunctionsTest with SQL, execute(), and error paths" +``` + +--- + +### Task 3: Create `GeoMeasurementFunctionsTest.java` + +Covers: `geo.buffer`, `geo.distance`, `geo.area`, `geo.envelope`. + +**Files:** +- Create: `engine/src/test/java/com/arcadedb/function/sql/geo/GeoMeasurementFunctionsTest.java` + +**Step 1: Create the file** + +```java +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.TestHelper; +import com.arcadedb.query.sql.executor.ResultSet; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GeoMeasurementFunctionsTest { + + // ─── geo.buffer ─────────────────────────────────────────────────────────────── + + @Nested + class Buffer { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.buffer('POINT (10 20)', 1.0) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POLYGON"); + }); + } + + @Test + void javaExecuteHappyPath() { + final Object result = new SQLFunctionGeoBuffer() + .execute(null, null, null, new Object[] { "POINT (10 20)", 1.0 }, null); + assertThat(result).isNotNull(); + assertThat((String) result).startsWith("POLYGON"); + } + + @Test + void nullGeometry_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.buffer(null, 1.0) as wkt"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("wkt")).isNull(); + }); + } + + @Test + void nullGeometry_execute_returnsNull() { + assertThat(new SQLFunctionGeoBuffer() + .execute(null, null, null, new Object[] { null, 1.0 }, null)).isNull(); + } + + @Test + void nullDistance_execute_returnsNull() { + assertThat(new SQLFunctionGeoBuffer() + .execute(null, null, null, new Object[] { "POINT (10 20)", null }, null)).isNull(); + } + + @Test + void invalidGeometry_execute_throwsException() { + assertThatThrownBy(() -> new SQLFunctionGeoBuffer() + .execute(null, null, null, new Object[] { "NOT VALID WKT", 1.0 }, null)) + .isInstanceOf(Exception.class); + } + } + + // ─── geo.distance ───────────────────────────────────────────────────────────── + + @Nested + class Distance { + + @Test + void sqlHappyPath_meters() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.distance('POINT (0 0)', 'POINT (1 0)') as dist"); + assertThat(result.hasNext()).isTrue(); + final Double dist = result.next().getProperty("dist"); + assertThat(dist).isNotNull(); + assertThat(dist).isGreaterThan(0.0); + }); + } + + @Test + void sqlHappyPath_km_lessThanMeters() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + ResultSet r = db.query("sql", "select geo.distance('POINT (0 0)', 'POINT (1 0)') as dist"); + final Double distM = r.next().getProperty("dist"); + + r = db.query("sql", "select geo.distance('POINT (0 0)', 'POINT (1 0)', 'km') as dist"); + final Double distKm = r.next().getProperty("dist"); + + assertThat(distKm).isGreaterThan(0.0); + assertThat(distKm).isLessThan(distM); + }); + } + + @Test + void javaExecuteHappyPath_meters() { + final Double dist = (Double) new SQLFunctionGeoDistance() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (1 0)" }, null); + assertThat(dist).isNotNull(); + assertThat(dist).isGreaterThan(0.0); + } + + @Test + void javaExecuteHappyPath_miles() { + final Double distMi = (Double) new SQLFunctionGeoDistance() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (1 0)", "mi" }, null); + assertThat(distMi).isNotNull(); + assertThat(distMi).isGreaterThan(0.0); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.distance(null, 'POINT (1 0)') as dist"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("dist")).isNull(); + }); + } + + @Test + void nullFirstArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoDistance() + .execute(null, null, null, new Object[] { null, "POINT (1 0)" }, null)).isNull(); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoDistance() + .execute(null, null, null, new Object[] { "POINT (0 0)", null }, null)).isNull(); + } + + @Test + void invalidUnit_execute_throwsIllegalArgument() { + assertThatThrownBy(() -> new SQLFunctionGeoDistance() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (1 0)", "lightyear" }, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("lightyear"); + } + + @Test + void allSupportedUnits_execute_returnPositive() { + for (final String unit : new String[] { "m", "km", "mi", "nmi" }) { + final Double d = (Double) new SQLFunctionGeoDistance() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (1 0)", unit }, null); + assertThat(d).as("unit=%s", unit).isGreaterThan(0.0); + } + } + } + + // ─── geo.area ───────────────────────────────────────────────────────────────── + + @Nested + class Area { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select geo.area('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as area"); + assertThat(result.hasNext()).isTrue(); + final Double area = result.next().getProperty("area"); + assertThat(area).isNotNull(); + assertThat(area).isGreaterThan(0.0); + }); + } + + @Test + void javaExecuteHappyPath() { + final Double area = (Double) new SQLFunctionGeoArea() + .execute(null, null, null, new Object[] { "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))" }, null); + assertThat(area).isNotNull(); + assertThat(area).isGreaterThan(0.0); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.area(null) as area"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("area")).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoArea() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + + @Test + void pointGeometry_execute_returnsZero() { + // A point has no area + final Double area = (Double) new SQLFunctionGeoArea() + .execute(null, null, null, new Object[] { "POINT (5 5)" }, null); + assertThat(area).isNotNull(); + assertThat(area).isEqualTo(0.0); + } + } + + // ─── geo.envelope ───────────────────────────────────────────────────────────── + + @Nested + class Envelope { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select geo.envelope('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POLYGON"); + assertThat(wkt).contains("0 0"); + assertThat(wkt).contains("10 10"); + }); + } + + @Test + void javaExecuteHappyPath() { + final Object result = new SQLFunctionGeoEnvelope() + .execute(null, null, null, new Object[] { "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))" }, null); + assertThat(result).isNotNull(); + assertThat((String) result).startsWith("POLYGON"); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.envelope(null) as wkt"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("wkt")).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoEnvelope() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + + @Test + void invalidWkt_execute_throwsException() { + assertThatThrownBy(() -> new SQLFunctionGeoEnvelope() + .execute(null, null, null, new Object[] { "NOT VALID WKT" }, null)) + .isInstanceOf(Exception.class); + } + + @Test + void pointEnvelope_execute_returnsResult() { + // Envelope of a point is degenerate but should not crash + final Object result = new SQLFunctionGeoEnvelope() + .execute(null, null, null, new Object[] { "POINT (5 5)" }, null); + assertThat(result).isNotNull(); + } + } +} +``` + +**Step 2: Compile** + +``` +mvn test-compile -pl engine -q +``` +Expected: BUILD SUCCESS + +**Step 3: Run** + +``` +mvn test -pl engine -Dtest=GeoMeasurementFunctionsTest -q +``` +Expected: all tests pass + +**Step 4: Commit** + +```bash +git add engine/src/test/java/com/arcadedb/function/sql/geo/GeoMeasurementFunctionsTest.java +git commit -m "test(geo): add GeoMeasurementFunctionsTest with SQL, execute(), and error paths" +``` + +--- + +### Task 4: Create `GeoConversionFunctionsTest.java` + +Covers: `geo.asText`, `geo.asGeoJson`, `geo.x`, `geo.y`. + +**Files:** +- Create: `engine/src/test/java/com/arcadedb/function/sql/geo/GeoConversionFunctionsTest.java` + +**Step 1: Create the file** + +```java +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.TestHelper; +import com.arcadedb.query.sql.executor.ResultSet; + +import org.assertj.core.data.Offset; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.locationtech.spatial4j.shape.Shape; + +import static org.assertj.core.api.Assertions.assertThat; + +class GeoConversionFunctionsTest { + + // ─── geo.asText ─────────────────────────────────────────────────────────────── + + @Nested + class AsText { + + @Test + void sqlStringInput_returnedAsIs() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.asText('POINT (10 20)') as wkt"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("wkt")).isEqualTo("POINT (10 20)"); + }); + } + + @Test + void sqlShapeInput_returnsWkt() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select geo.asText(geo.geomFromText('POINT (10 20)')) as wkt"); + assertThat(result.hasNext()).isTrue(); + final String wkt = result.next().getProperty("wkt"); + assertThat(wkt).isNotNull(); + assertThat(wkt).startsWith("POINT"); + assertThat(wkt).contains("10"); + assertThat(wkt).contains("20"); + }); + } + + @Test + void javaExecute_stringInput_returnedAsIs() { + final Object result = new SQLFunctionGeoAsText() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + assertThat(result).isEqualTo("POINT (10 20)"); + } + + @Test + void javaExecute_shapeInput_returnsWkt() { + final Shape shape = (Shape) new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + final Object result = new SQLFunctionGeoAsText() + .execute(null, null, null, new Object[] { shape }, null); + assertThat(result).isNotNull(); + assertThat((String) result).startsWith("POINT"); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.asText(null) as wkt"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("wkt")).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoAsText() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + } + + // ─── geo.asGeoJson ──────────────────────────────────────────────────────────── + + @Nested + class AsGeoJson { + + @Test + void sqlPoint_containsPointType() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.asGeoJson('POINT (10 20)') as json"); + assertThat(result.hasNext()).isTrue(); + final String json = result.next().getProperty("json"); + assertThat(json).isNotNull(); + assertThat(json).contains("Point"); + assertThat(json).contains("coordinates"); + assertThat(json).contains("10"); + assertThat(json).contains("20"); + }); + } + + @Test + void sqlPolygon_containsPolygonType() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select geo.asGeoJson('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as json"); + assertThat(result.hasNext()).isTrue(); + final String json = result.next().getProperty("json"); + assertThat(json).isNotNull(); + assertThat(json).contains("Polygon"); + assertThat(json).contains("coordinates"); + }); + } + + @Test + void javaExecute_point_containsPointType() { + final Object result = new SQLFunctionGeoAsGeoJson() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + assertThat(result).isNotNull(); + assertThat((String) result).contains("Point"); + assertThat((String) result).contains("coordinates"); + } + + @Test + void javaExecute_lineString_containsLineStringType() { + final Object result = new SQLFunctionGeoAsGeoJson() + .execute(null, null, null, new Object[] { "LINESTRING (0 0, 10 10, 20 0)" }, null); + assertThat(result).isNotNull(); + assertThat((String) result).contains("LineString"); + assertThat((String) result).contains("coordinates"); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.asGeoJson(null) as json"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("json")).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoAsGeoJson() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + } + + // ─── geo.x ──────────────────────────────────────────────────────────────────── + + @Nested + class X { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.x('POINT (10 20)') as x"); + assertThat(result.hasNext()).isTrue(); + final Double x = result.next().getProperty("x"); + assertThat(x).isNotNull(); + assertThat(x).isEqualTo(10.0); + }); + } + + @Test + void javaExecuteHappyPath() { + final Double x = (Double) new SQLFunctionGeoX() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + assertThat(x).isNotNull(); + assertThat(x).isEqualTo(10.0); + } + + @Test + void javaExecute_shapeInput() { + final Shape shape = (Shape) new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + final Double x = (Double) new SQLFunctionGeoX() + .execute(null, null, null, new Object[] { shape }, null); + assertThat(x).isNotNull(); + assertThat(x).isEqualTo(10.0); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.x(null) as x"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("x")).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoX() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + + @Test + void polygonInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select geo.x('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as x"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("x")).isNull(); + }); + } + + @Test + void polygonInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoX() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))" }, null)).isNull(); + } + + @Test + void invalidWkt_execute_returnsNullSilently() { + // geo.x silently swallows parse errors and returns null + assertThat(new SQLFunctionGeoX() + .execute(null, null, null, new Object[] { "NOT_WKT_AT_ALL" }, null)).isNull(); + } + + @Test + void roundTrip_pointXMatches() { + final String wkt = (String) new SQLFunctionGeoPoint() + .execute(null, null, null, new Object[] { 42.5, -7.3 }, null); + final Double x = (Double) new SQLFunctionGeoX() + .execute(null, null, null, new Object[] { wkt }, null); + assertThat(x).isNotNull(); + assertThat(x).isCloseTo(42.5, Offset.offset(1e-6)); + } + } + + // ─── geo.y ──────────────────────────────────────────────────────────────────── + + @Nested + class Y { + + @Test + void sqlHappyPath() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.y('POINT (10 20)') as y"); + assertThat(result.hasNext()).isTrue(); + final Double y = result.next().getProperty("y"); + assertThat(y).isNotNull(); + assertThat(y).isEqualTo(20.0); + }); + } + + @Test + void javaExecuteHappyPath() { + final Double y = (Double) new SQLFunctionGeoY() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + assertThat(y).isNotNull(); + assertThat(y).isEqualTo(20.0); + } + + @Test + void javaExecute_shapeInput() { + final Shape shape = (Shape) new SQLFunctionGeoGeomFromText() + .execute(null, null, null, new Object[] { "POINT (10 20)" }, null); + final Double y = (Double) new SQLFunctionGeoY() + .execute(null, null, null, new Object[] { shape }, null); + assertThat(y).isNotNull(); + assertThat(y).isEqualTo(20.0); + } + + @Test + void nullInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", "select geo.y(null) as y"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("y")).isNull(); + }); + } + + @Test + void nullInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoY() + .execute(null, null, null, new Object[] { null }, null)).isNull(); + } + + @Test + void polygonInput_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "select geo.y('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as y"); + assertThat(result.hasNext()).isTrue(); + assertThat(result.next().getProperty("y")).isNull(); + }); + } + + @Test + void polygonInput_execute_returnsNull() { + assertThat(new SQLFunctionGeoY() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))" }, null)).isNull(); + } + + @Test + void invalidWkt_execute_returnsNullSilently() { + assertThat(new SQLFunctionGeoY() + .execute(null, null, null, new Object[] { "NOT_WKT_AT_ALL" }, null)).isNull(); + } + + @Test + void roundTrip_pointYMatches() { + final String wkt = (String) new SQLFunctionGeoPoint() + .execute(null, null, null, new Object[] { 42.5, -7.3 }, null); + final Double y = (Double) new SQLFunctionGeoY() + .execute(null, null, null, new Object[] { wkt }, null); + assertThat(y).isNotNull(); + assertThat(y).isCloseTo(-7.3, Offset.offset(1e-6)); + } + } +} +``` + +**Step 2: Compile** + +``` +mvn test-compile -pl engine -q +``` +Expected: BUILD SUCCESS + +**Step 3: Run** + +``` +mvn test -pl engine -Dtest=GeoConversionFunctionsTest -q +``` +Expected: all tests pass + +**Step 4: Commit** + +```bash +git add engine/src/test/java/com/arcadedb/function/sql/geo/GeoConversionFunctionsTest.java +git commit -m "test(geo): add GeoConversionFunctionsTest with SQL, execute(), and error paths" +``` + +--- + +### Task 5: Create `GeoPredicateFunctionsTest.java` + +Covers: `geo.within`, `geo.intersects`, `geo.contains`, `geo.dWithin`, `geo.disjoint`, `geo.equals`, `geo.crosses`, `geo.overlaps`, `geo.touches`. + +**Files:** +- Create: `engine/src/test/java/com/arcadedb/function/sql/geo/GeoPredicateFunctionsTest.java` + +**Step 1: Create the file** + +```java +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.TestHelper; +import com.arcadedb.query.sql.executor.ResultSet; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GeoPredicateFunctionsTest { + + private static final String POLYGON_0_10 = "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))"; + private static final String POINT_INSIDE = "POINT (5 5)"; + private static final String POINT_OUTSIDE = "POINT (15 15)"; + + // ─── geo.within ─────────────────────────────────────────────────────────────── + + @Nested + class Within { + + @Test + void sql_pointInsidePolygon_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.within('POINT (5 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_pointOutsidePolygon_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.within('POINT (15 15)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_pointInside_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoWithin() + .execute(null, null, null, new Object[] { POINT_INSIDE, POLYGON_0_10 }, null)).isTrue(); + } + + @Test + void javaExecute_pointOutside_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoWithin() + .execute(null, null, null, new Object[] { POINT_OUTSIDE, POLYGON_0_10 }, null)).isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.within(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(r.next().getProperty("v")).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoWithin() + .execute(null, null, null, new Object[] { POINT_INSIDE, null }, null)).isNull(); + } + + @Test + void nullFirstArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoWithin() + .execute(null, null, null, new Object[] { null, POLYGON_0_10 }, null)).isNull(); + } + } + + // ─── geo.intersects ─────────────────────────────────────────────────────────── + + @Nested + class Intersects { + + @Test + void sql_overlappingPolygons_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.intersects('POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))', 'POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_disjointPolygons_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.intersects('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_overlapping_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoIntersects() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))", "POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))" }, null)) + .isTrue(); + } + + @Test + void javaExecute_disjoint_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoIntersects() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", "POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))" }, null)) + .isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.intersects(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(r.next().getProperty("v")).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoIntersects() + .execute(null, null, null, new Object[] { POLYGON_0_10, null }, null)).isNull(); + } + } + + // ─── geo.contains ───────────────────────────────────────────────────────────── + + @Nested + class Contains { + + @Test + void sql_polygonContainsPoint_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.contains('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))', 'POINT (5 5)') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_polygonDoesNotContainOutsidePoint_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.contains('POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))', 'POINT (15 15)') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_contains_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoContains() + .execute(null, null, null, new Object[] { POLYGON_0_10, POINT_INSIDE }, null)).isTrue(); + } + + @Test + void javaExecute_doesNotContain_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoContains() + .execute(null, null, null, new Object[] { POLYGON_0_10, POINT_OUTSIDE }, null)).isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.contains(null, 'POINT (5 5)') as v"); + assertThat(r.next().getProperty("v")).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoContains() + .execute(null, null, null, new Object[] { POLYGON_0_10, null }, null)).isNull(); + } + } + + // ─── geo.dWithin ────────────────────────────────────────────────────────────── + + @Nested + class DWithin { + + @Test + void sql_nearbyPoints_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.dWithin('POINT (0 0)', 'POINT (1 1)', 2.0) as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_farPoints_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.dWithin('POINT (0 0)', 'POINT (10 10)', 1.0) as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_nearby_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoDWithin() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (1 1)", 2.0 }, null)).isTrue(); + } + + @Test + void javaExecute_farAway_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoDWithin() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (10 10)", 1.0 }, null)).isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.dWithin(null, 'POINT (1 1)', 2.0) as v"); + assertThat(r.next().getProperty("v")).isNull(); + }); + } + + @Test + void nullThirdArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoDWithin() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (1 1)", null }, null)).isNull(); + } + + @Test + void negativeDistance_execute_returnsFalse() { + // No real distance is <= a negative threshold + assertThat((Boolean) new SQLFunctionGeoDWithin() + .execute(null, null, null, new Object[] { "POINT (0 0)", "POINT (1 0)", -1.0 }, null)).isFalse(); + } + } + + // ─── geo.disjoint ───────────────────────────────────────────────────────────── + + @Nested + class Disjoint { + + @Test + void sql_farApartShapes_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.disjoint('POINT (50 50)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_intersectingShapes_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.disjoint('POINT (5 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_disjoint_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoDisjoint() + .execute(null, null, null, new Object[] { "POINT (50 50)", POLYGON_0_10 }, null)).isTrue(); + } + + @Test + void javaExecute_intersecting_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoDisjoint() + .execute(null, null, null, new Object[] { POINT_INSIDE, POLYGON_0_10 }, null)).isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.disjoint(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(r.next().getProperty("v")).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoDisjoint() + .execute(null, null, null, new Object[] { POINT_INSIDE, null }, null)).isNull(); + } + } + + // ─── geo.equals ─────────────────────────────────────────────────────────────── + + @Nested + class Equals { + + @Test + void sql_identicalPoints_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.equals('POINT (5 5)', 'POINT (5 5)') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_differentPoints_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.equals('POINT (5 5)', 'POINT (6 6)') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_identicalPoints_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoEquals() + .execute(null, null, null, new Object[] { "POINT (5 5)", "POINT (5 5)" }, null)).isTrue(); + } + + @Test + void javaExecute_differentPoints_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoEquals() + .execute(null, null, null, new Object[] { "POINT (5 5)", "POINT (6 6)" }, null)).isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.equals(null, 'POINT (5 5)') as v"); + assertThat(r.next().getProperty("v")).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoEquals() + .execute(null, null, null, new Object[] { "POINT (5 5)", null }, null)).isNull(); + } + } + + // ─── geo.crosses ────────────────────────────────────────────────────────────── + + @Nested + class Crosses { + + @Test + void sql_lineCrossesPolygon_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.crosses('LINESTRING (-1 5, 11 5)', 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void javaExecute_lineCrossesPolygon_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoCrosses() + .execute(null, null, null, + new Object[] { "LINESTRING (-1 5, 11 5)", POLYGON_0_10 }, null)).isTrue(); + } + + @Test + void javaExecute_overlappingPolygons_returnsFalse() { + // Two polygons of the same dimension cannot "cross" by the JTS DE-9IM definition + assertThat((Boolean) new SQLFunctionGeoCrosses() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))", "POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))" }, null)) + .isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.crosses(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(r.next().getProperty("v")).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoCrosses() + .execute(null, null, null, new Object[] { "LINESTRING (-1 5, 11 5)", null }, null)).isNull(); + } + } + + // ─── geo.overlaps ───────────────────────────────────────────────────────────── + + @Nested + class Overlaps { + + @Test + void sql_partiallyOverlapping_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.overlaps('POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))', 'POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_disjointPolygons_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.overlaps('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_overlapping_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoOverlaps() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0))", "POLYGON ((3 3, 9 3, 9 9, 3 9, 3 3))" }, null)) + .isTrue(); + } + + @Test + void javaExecute_disjoint_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoOverlaps() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", "POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))" }, null)) + .isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.overlaps(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(r.next().getProperty("v")).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoOverlaps() + .execute(null, null, null, new Object[] { POLYGON_0_10, null }, null)).isNull(); + } + } + + // ─── geo.touches ────────────────────────────────────────────────────────────── + + @Nested + class Touches { + + @Test + void sql_adjacentPolygons_returnsTrue() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.touches('POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))', 'POLYGON ((5 0, 10 0, 10 5, 5 5, 5 0))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isTrue(); + }); + } + + @Test + void sql_disjointPolygons_returnsFalse() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.touches('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 'POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))') as v"); + assertThat((Boolean) r.next().getProperty("v")).isFalse(); + }); + } + + @Test + void javaExecute_adjacent_returnsTrue() { + assertThat((Boolean) new SQLFunctionGeoTouches() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))", "POLYGON ((5 0, 10 0, 10 5, 5 5, 5 0))" }, null)) + .isTrue(); + } + + @Test + void javaExecute_disjoint_returnsFalse() { + assertThat((Boolean) new SQLFunctionGeoTouches() + .execute(null, null, null, + new Object[] { "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", "POLYGON ((5 5, 9 5, 9 9, 5 9, 5 5))" }, null)) + .isFalse(); + } + + @Test + void nullFirstArg_sql_returnsNull() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet r = db.query("sql", + "select geo.touches(null, 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))') as v"); + assertThat(r.next().getProperty("v")).isNull(); + }); + } + + @Test + void nullSecondArg_execute_returnsNull() { + assertThat(new SQLFunctionGeoTouches() + .execute(null, null, null, new Object[] { POLYGON_0_10, null }, null)).isNull(); + } + } +} +``` + +**Step 2: Compile** + +``` +mvn test-compile -pl engine -q +``` +Expected: BUILD SUCCESS + +**Step 3: Run** + +``` +mvn test -pl engine -Dtest=GeoPredicateFunctionsTest -q +``` +Expected: all tests pass + +**Step 4: Commit** + +```bash +git add engine/src/test/java/com/arcadedb/function/sql/geo/GeoPredicateFunctionsTest.java +git commit -m "test(geo): add GeoPredicateFunctionsTest with SQL, execute(), and error paths" +``` + +--- + +### Task 6: Delete `SQLGeoFunctionsTest.java` and verify + +**Files:** +- Delete: `engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java` + +**Step 1: Delete the old file** + +```bash +git rm engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java +``` + +**Step 2: Compile to confirm no references remain** + +``` +mvn test-compile -pl engine -q +``` +Expected: BUILD SUCCESS + +**Step 3: Run all new geo tests together** + +``` +mvn test -pl engine -Dtest="GeoHashIndexTest,GeoConstructionFunctionsTest,GeoMeasurementFunctionsTest,GeoConversionFunctionsTest,GeoPredicateFunctionsTest" -q +``` +Expected: all tests pass, 0 failures + +**Step 4: Final commit** + +```bash +git add -u +git commit -m "test(geo): remove SQLGeoFunctionsTest — superseded by 4 focused test classes" +``` From 87a37d3e5e614be05a817a8fa59b36b722207249 Mon Sep 17 00:00:00 2001 From: robfrank Date: Tue, 24 Feb 2026 22:56:58 +0100 Subject: [PATCH 44/47] fix(geo): add deprecated aliases for removed SQL functions and clarifying comments - Restore point(), distance(), linestring(), polygon() as aliases for the geo.* equivalents via getAlias() on each function class; the factory template auto-registers both names from a single instance - Re-add SQLFunctionCircle and SQLFunctionRectangle as @Deprecated wrappers preserving the original Spatial4j-based behaviour (no direct geo.* equivalent exists); GeoUtils.parseGeometry() already handles Shape objects so they remain compatible with all new geo.* predicates - Add TODO in LSMTreeGeoIndex.get() documenting the LinkedHashSet v1 memory trade-off and what a streaming cursor alternative would look like - Add comment in LSMTreeGeoIndexTest explaining why reflection is required to inject the index into the schema's indexMap - Document the CONTAINS-as-identifier grammar trade-off in SQLParser.g4 Co-Authored-By: Claude Sonnet 4.6 --- .../arcadedb/query/sql/grammar/SQLParser.g4 | 5 ++ .../sql/DefaultSQLFunctionFactory.java | 6 ++ .../function/sql/geo/SQLFunctionCircle.java | 56 ++++++++++++++++++ .../sql/geo/SQLFunctionGeoDistance.java | 5 ++ .../sql/geo/SQLFunctionGeoLineString.java | 5 ++ .../function/sql/geo/SQLFunctionGeoPoint.java | 5 ++ .../sql/geo/SQLFunctionGeoPolygon.java | 5 ++ .../sql/geo/SQLFunctionRectangle.java | 59 +++++++++++++++++++ .../index/geospatial/LSMTreeGeoIndex.java | 6 +- .../index/geospatial/LSMTreeGeoIndexTest.java | 6 +- 10 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionCircle.java create mode 100644 engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionRectangle.java diff --git a/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 b/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 index 7b93039e46..544b967f32 100644 --- a/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 +++ b/engine/src/main/antlr4/com/arcadedb/query/sql/grammar/SQLParser.g4 @@ -1475,5 +1475,10 @@ identifier | IDENTIFIED | SYSTEM | UNIDIRECTIONAL + // CONTAINS is allowed as an identifier so that dot-path function calls like geo.contains(...) + // parse correctly (the parser sees "contains" as the second segment of the identifier chain). + // Note: this means CONTAINS cannot be used as a reserved infix operator in future SQL extensions + // (e.g. "WHERE tags CONTAINS 'value'") without introducing a grammar conflict. Any such operator + // would need a distinct keyword or a separate production rule. | CONTAINS ; diff --git a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java index e0a3d71449..cd5dccaa67 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java +++ b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java @@ -29,6 +29,7 @@ import com.arcadedb.function.sql.coll.SQLFunctionSet; import com.arcadedb.function.sql.coll.SQLFunctionSymmetricDifference; import com.arcadedb.function.sql.coll.SQLFunctionUnionAll; +import com.arcadedb.function.sql.geo.SQLFunctionCircle; import com.arcadedb.function.sql.geo.SQLFunctionGeoArea; import com.arcadedb.function.sql.geo.SQLFunctionGeoAsGeoJson; import com.arcadedb.function.sql.geo.SQLFunctionGeoAsText; @@ -50,6 +51,7 @@ import com.arcadedb.function.sql.geo.SQLFunctionGeoWithin; import com.arcadedb.function.sql.geo.SQLFunctionGeoX; import com.arcadedb.function.sql.geo.SQLFunctionGeoY; +import com.arcadedb.function.sql.geo.SQLFunctionRectangle; import com.arcadedb.function.sql.graph.SQLFunctionAstar; import com.arcadedb.function.sql.graph.SQLFunctionBellmanFord; import com.arcadedb.function.sql.graph.SQLFunctionBoth; @@ -181,6 +183,10 @@ private DefaultSQLFunctionFactory() { register(SQLFunctionSymmetricDifference.NAME, SQLFunctionSymmetricDifference.class); register(SQLFunctionUnionAll.NAME, SQLFunctionUnionAll.class); + // Geo — deprecated aliases for backward compatibility (use geo.* variants instead) + register(SQLFunctionCircle.NAME, new SQLFunctionCircle()); + register(SQLFunctionRectangle.NAME, new SQLFunctionRectangle()); + // Geo — geo.* constructor/accessor functions register(SQLFunctionGeoGeomFromText.NAME, new SQLFunctionGeoGeomFromText()); register(SQLFunctionGeoPoint.NAME, new SQLFunctionGeoPoint()); diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionCircle.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionCircle.java new file mode 100644 index 0000000000..d31f71b262 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionCircle.java @@ -0,0 +1,56 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.database.Identifiable; +import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.query.sql.executor.CommandContext; +import org.locationtech.spatial4j.context.SpatialContext; + +/** + * Deprecated alias for {@code circle()}: returns a circle shape centered at (x, y) with the given radius. + * + *

Deprecated: Use {@code geo.buffer(geo.point(x, y), radius)} instead.

+ * + * @deprecated since 25.x — use {@code geo.buffer(geo.point(x, y), radius)} + */ +@Deprecated +public class SQLFunctionCircle extends SQLFunctionAbstract { + public static final String NAME = "circle"; + + public SQLFunctionCircle() { + super(NAME); + } + + @Override + public Object execute(final Object self, final Identifiable currentRecord, final Object currentResult, final Object[] params, + final CommandContext context) { + if (params.length != 3) + throw new IllegalArgumentException("circle() requires 3 parameters: circle(, , )"); + + final SpatialContext spatialContext = GeoUtils.getSpatialContext(); + return spatialContext.getShapeFactory() + .circle(GeoUtils.getDoubleValue(params[0]), GeoUtils.getDoubleValue(params[1]), GeoUtils.getDoubleValue(params[2])); + } + + @Override + public String getSyntax() { + return "circle(,,) [deprecated: use geo.buffer(geo.point(x,y), radius)]"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDistance.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDistance.java index 40bfca0cea..d3286074d8 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDistance.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDistance.java @@ -91,6 +91,11 @@ private double[] extractPointCoords(final Object param) { }; } + @Override + public String getAlias() { + return "distance"; + } + @Override public String getSyntax() { return "geo.distance(, [, ])"; diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoLineString.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoLineString.java index 9834c6f7d8..f168f7d83e 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoLineString.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoLineString.java @@ -71,6 +71,11 @@ private void appendCoord(final StringBuilder sb, final Object point) { } } + @Override + public String getAlias() { + return "linestring"; + } + @Override public String getSyntax() { return "geo.lineString([[x1,y1],[x2,y2],...])"; diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPoint.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPoint.java index 59bf04ea22..83833c70ef 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPoint.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPoint.java @@ -45,6 +45,11 @@ public Object execute(final Object iThis, final Identifiable iCurrentRecord, fin return "POINT (" + GeoUtils.formatCoord(x) + " " + GeoUtils.formatCoord(y) + ")"; } + @Override + public String getAlias() { + return "point"; + } + @Override public String getSyntax() { return "geo.point(, )"; diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPolygon.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPolygon.java index 55976dd6a4..62be211392 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPolygon.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPolygon.java @@ -95,6 +95,11 @@ private boolean coordsEqual(final Object a, final Object b) { return Double.compare(ca[0], cb[0]) == 0 && Double.compare(ca[1], cb[1]) == 0; } + @Override + public String getAlias() { + return "polygon"; + } + @Override public String getSyntax() { return "geo.polygon([[x1,y1],[x2,y2],...])"; diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionRectangle.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionRectangle.java new file mode 100644 index 0000000000..fa02b67466 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionRectangle.java @@ -0,0 +1,59 @@ +/* + * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * 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 (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.function.sql.geo; + +import com.arcadedb.database.Identifiable; +import com.arcadedb.function.sql.SQLFunctionAbstract; +import com.arcadedb.query.sql.executor.CommandContext; +import org.locationtech.spatial4j.context.SpatialContext; +import org.locationtech.spatial4j.shape.Point; + +/** + * Deprecated alias for {@code rectangle()}: returns a rectangle shape from two corner coordinates. + * + *

Deprecated: Use {@code geo.geomFromText("POLYGON ((x1 y1, x2 y1, x2 y2, x1 y2, x1 y1))")} instead.

+ * + * @deprecated since 25.x — use {@code geo.geomFromText} with an explicit POLYGON WKT + */ +@Deprecated +public class SQLFunctionRectangle extends SQLFunctionAbstract { + public static final String NAME = "rectangle"; + + public SQLFunctionRectangle() { + super(NAME); + } + + @Override + public Object execute(final Object self, final Identifiable currentRecord, final Object currentResult, final Object[] params, + final CommandContext context) { + if (params.length != 4) + throw new IllegalArgumentException( + "rectangle() requires 4 parameters: rectangle(, , , )"); + + final SpatialContext spatialContext = GeoUtils.getSpatialContext(); + final Point topLeft = spatialContext.getShapeFactory().pointXY(GeoUtils.getDoubleValue(params[0]), GeoUtils.getDoubleValue(params[1])); + final Point bottomRight = spatialContext.getShapeFactory().pointXY(GeoUtils.getDoubleValue(params[2]), GeoUtils.getDoubleValue(params[3])); + return spatialContext.getShapeFactory().rect(topLeft, bottomRight); + } + + @Override + public String getSyntax() { + return "rectangle(,,,) [deprecated: use geo.geomFromText with POLYGON WKT]"; + } +} diff --git a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java index 7f248ee69b..cd60df325c 100644 --- a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java +++ b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java @@ -232,7 +232,11 @@ public IndexCursor get(final Object[] keys, final int limit) { final double distErr = args.resolveDistErr(GeoUtils.getSpatialContext(), strategy.getDistErrPct()); final int detailLevel = grid.getLevelForDistance(distErr); - // Iterate all tree cells that cover the search shape and collect their GeoHash tokens + // Iterate all tree cells that cover the search shape and collect their GeoHash tokens. + // TODO: For large regions at high precision (up to 12 levels) this materialises the full + // candidate RID set in memory before applying the limit. A streaming/lazy cursor + // chaining cells on demand (similar to LSMTreeFullTextIndex) would reduce GC pressure + // on production datasets with dense or wide-area queries. final CellIterator cellIter = grid.getTreeCellIterator(searchShape, detailLevel); final LinkedHashSet seen = new LinkedHashSet<>(); while (cellIter.hasNext()) { diff --git a/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java b/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java index d3202b2c4f..81c3e8ff57 100644 --- a/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java +++ b/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java @@ -63,7 +63,11 @@ private LSMTreeGeoIndex createAndRegisterIndex(final String name) throws Excepti // Register the paginated component so commit2ndPhase can look it up by file ID schema.registerFile(idx.getComponent()); - // Register the index by name so addFilesToLock can resolve it from the schema + // Reflection is required here because there is no public API to register an index in the + // schema outside of DDL execution. The LSMTreeIndexMutable constructor writes its first page + // inside the active transaction, so we cannot go through the normal CREATE INDEX path (which + // would open a second transaction). Instead we inject directly into the schema's indexMap so + // that the transaction commit machinery can resolve the index by name in addFilesToLock(). final Field indexMapField = LocalSchema.class.getDeclaredField("indexMap"); indexMapField.setAccessible(true); @SuppressWarnings("unchecked") From 9bd4033d6545d8be6791e95042b0fe385c7cf1a2 Mon Sep 17 00:00:00 2001 From: robfrank Date: Wed, 25 Feb 2026 09:58:03 +0100 Subject: [PATCH 45/47] fix(geo): address correctness and performance issues from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix scientific notation in WKT generation: replace bare %s double formatting with formatCoord() in buildPolygonFromRect() and parseEnvelopeWkt(); JTS WKTReader rejects scientific notation (e.g. 1.0E-7 for near-zero coords) - Fix WKTReader/WKTWriter per-call allocation: add ThreadLocal and ThreadLocal in GeoUtils; both classes are not thread-safe so ThreadLocal is the correct pattern to avoid repeated construction on the hot path - Fix getDoubleValue() to throw IllegalArgumentException with a meaningful message instead of ClassCastException when a non-numeric argument is passed - Add precision range validation (1–12) in GeoIndexMetadata.setPrecision() - Remove superfluous LSMTreeGeoIndex.DEFAULT_PRECISION constant that merely re-exported GeoIndexMetadata.DEFAULT_PRECISION; update all internal usages - Fix FunctionReferenceGeneratorTest throws IOException regression (was widened to throws Exception) Co-Authored-By: Claude Sonnet 4.6 --- .../arcadedb/function/sql/geo/GeoUtils.java | 32 +++++++++++++------ .../index/geospatial/LSMTreeGeoIndex.java | 7 ++-- .../com/arcadedb/schema/GeoIndexMetadata.java | 2 ++ .../FunctionReferenceGeneratorTest.java | 2 +- .../index/geospatial/LSMTreeGeoIndexTest.java | 3 +- 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/GeoUtils.java b/engine/src/main/java/com/arcadedb/function/sql/geo/GeoUtils.java index 5c77a495fc..44cbf4a038 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/GeoUtils.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/GeoUtils.java @@ -42,6 +42,10 @@ public class GeoUtils { static final JtsSpatialContextFactory FACTORY = new JtsSpatialContextFactory(); static final JtsSpatialContext SPATIAL_CONTEXT = new JtsSpatialContext(FACTORY); + // WKTReader/WKTWriter are not thread-safe; use ThreadLocal to avoid per-call allocation + private static final ThreadLocal WKT_READER = ThreadLocal.withInitial(WKTReader::new); + private static final ThreadLocal WKT_WRITER = ThreadLocal.withInitial(WKTWriter::new); + public static SpatialContextFactory getFactory() { return FACTORY; } @@ -51,6 +55,8 @@ public static SpatialContext getSpatialContext() { } public static double getDoubleValue(final Object param) { + if (!(param instanceof Number)) + throw new IllegalArgumentException("Expected a numeric value, got: " + (param == null ? "null" : param.getClass().getSimpleName())); return ((Number) param).doubleValue(); } @@ -112,7 +118,7 @@ public static Geometry parseJtsGeometry(final Object value) { if (wkt.startsWith("ENVELOPE")) return parseEnvelopeWkt(wkt); try { - return new WKTReader().read(wkt); + return WKT_READER.get().read(wkt); } catch (ParseException e) { throw new IllegalArgumentException("Cannot parse JTS geometry from WKT: " + wkt, e); } @@ -126,11 +132,14 @@ private static Geometry buildPolygonFromRect(final Rectangle rect) { final double maxX = rect.getMaxX(); final double minY = rect.getMinY(); final double maxY = rect.getMaxY(); - final String wkt = String.format(Locale.US, - "POLYGON ((%s %s, %s %s, %s %s, %s %s, %s %s))", - minX, minY, maxX, minY, maxX, maxY, minX, maxY, minX, minY); + final String wkt = "POLYGON ((" + + formatCoord(minX) + " " + formatCoord(minY) + ", " + + formatCoord(maxX) + " " + formatCoord(minY) + ", " + + formatCoord(maxX) + " " + formatCoord(maxY) + ", " + + formatCoord(minX) + " " + formatCoord(maxY) + ", " + + formatCoord(minX) + " " + formatCoord(minY) + "))"; try { - return new WKTReader().read(wkt); + return WKT_READER.get().read(wkt); } catch (ParseException e) { throw new IllegalArgumentException("Cannot build polygon from rectangle: " + rect, e); } @@ -152,11 +161,14 @@ private static Geometry parseEnvelopeWkt(final String envelopeWkt) { final double maxX = Double.parseDouble(parts[1].trim()); final double maxY = Double.parseDouble(parts[2].trim()); final double minY = Double.parseDouble(parts[3].trim()); - final String wkt = String.format(Locale.US, - "POLYGON ((%s %s, %s %s, %s %s, %s %s, %s %s))", - minX, minY, maxX, minY, maxX, maxY, minX, maxY, minX, minY); + final String wkt = "POLYGON ((" + + formatCoord(minX) + " " + formatCoord(minY) + ", " + + formatCoord(maxX) + " " + formatCoord(minY) + ", " + + formatCoord(maxX) + " " + formatCoord(maxY) + ", " + + formatCoord(minX) + " " + formatCoord(maxY) + ", " + + formatCoord(minX) + " " + formatCoord(minY) + "))"; try { - return new WKTReader().read(wkt); + return WKT_READER.get().read(wkt); } catch (ParseException e) { throw new IllegalArgumentException("Cannot parse ENVELOPE as polygon: " + envelopeWkt, e); } @@ -168,7 +180,7 @@ private static Geometry parseEnvelopeWkt(final String envelopeWkt) { public static String jtsToWKT(final Geometry geometry) { if (geometry == null) return null; - return new WKTWriter().write(geometry); + return WKT_WRITER.get().write(geometry); } /** diff --git a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java index cd60df325c..3a36df2f83 100644 --- a/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java +++ b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java @@ -74,9 +74,6 @@ */ public class LSMTreeGeoIndex implements Index, IndexInternal { - /** Default geohash precision level (same as GeoIndexMetadata default, ~2.4 m cell resolution). */ - public static final int DEFAULT_PRECISION = GeoIndexMetadata.DEFAULT_PRECISION; - private final LSMTreeIndex underlyingIndex; private final int precision; private final GeohashPrefixTree grid; @@ -110,7 +107,7 @@ public IndexInternal create(final IndexBuilder builder) { * Called at load time. Uses the default precision. */ public LSMTreeGeoIndex(final LSMTreeIndex index) { - this(index, DEFAULT_PRECISION); + this(index, GeoIndexMetadata.DEFAULT_PRECISION); } /** @@ -140,7 +137,7 @@ public LSMTreeGeoIndex(final DatabaseInternal database, final String name, final */ public LSMTreeGeoIndex(final DatabaseInternal database, final String name, final String filePath, final int fileId, final ComponentFile.MODE mode, final int pageSize, final int version) { - this.precision = DEFAULT_PRECISION; + this.precision = GeoIndexMetadata.DEFAULT_PRECISION; this.grid = new GeohashPrefixTree(GeoUtils.getSpatialContext(), precision); this.strategy = new RecursivePrefixTreeStrategy(grid, "geo"); try { diff --git a/engine/src/main/java/com/arcadedb/schema/GeoIndexMetadata.java b/engine/src/main/java/com/arcadedb/schema/GeoIndexMetadata.java index 48c45b5769..2386e7db52 100644 --- a/engine/src/main/java/com/arcadedb/schema/GeoIndexMetadata.java +++ b/engine/src/main/java/com/arcadedb/schema/GeoIndexMetadata.java @@ -83,6 +83,8 @@ public int getPrecision() { * @param precision the precision level (1–12) */ public void setPrecision(final int precision) { + if (precision < 1 || precision > 12) + throw new IllegalArgumentException("Geospatial index precision must be between 1 and 12, got: " + precision); this.precision = precision; } } diff --git a/engine/src/test/java/com/arcadedb/function/FunctionReferenceGeneratorTest.java b/engine/src/test/java/com/arcadedb/function/FunctionReferenceGeneratorTest.java index 1dc39433ef..9ee21857d4 100644 --- a/engine/src/test/java/com/arcadedb/function/FunctionReferenceGeneratorTest.java +++ b/engine/src/test/java/com/arcadedb/function/FunctionReferenceGeneratorTest.java @@ -51,7 +51,7 @@ class FunctionReferenceGeneratorTest { @Test - void generateFunctionReference() throws Exception { + void generateFunctionReference() throws IOException { final JSONObject root = new JSONObject(); root.put("generated", LocalDate.now().toString()); diff --git a/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java b/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java index 81c3e8ff57..b64e8ad923 100644 --- a/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java +++ b/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java @@ -23,6 +23,7 @@ import com.arcadedb.database.RID; import com.arcadedb.engine.ComponentFile; import com.arcadedb.function.sql.geo.GeoUtils; +import com.arcadedb.schema.GeoIndexMetadata; import com.arcadedb.index.IndexCursor; import com.arcadedb.index.IndexInternal; import com.arcadedb.index.lsm.LSMTreeIndexAbstract; @@ -57,7 +58,7 @@ private LSMTreeGeoIndex createAndRegisterIndex(final String name) throws Excepti ComponentFile.MODE.READ_WRITE, LSMTreeIndexAbstract.DEF_PAGE_SIZE, LSMTreeIndexAbstract.NULL_STRATEGY.SKIP, - LSMTreeGeoIndex.DEFAULT_PRECISION + GeoIndexMetadata.DEFAULT_PRECISION ); // Register the paginated component so commit2ndPhase can look it up by file ID From e7f08b7146029709c9b9a3de4b6e306f3ab969e9 Mon Sep 17 00:00:00 2001 From: robfrank Date: Wed, 25 Feb 2026 10:03:48 +0100 Subject: [PATCH 46/47] docs(geo): clarify geo.dWithin distance unit in Javadoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The distance parameter is degrees of great-circle arc (Spatial4j native), not metres. This differs from geo.distance() which returns metres by default. Added conversion example (1 degree ≈ 111.32 km) so users can translate. Co-Authored-By: Claude Sonnet 4.6 --- .../arcadedb/function/sql/geo/SQLFunctionGeoDWithin.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDWithin.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDWithin.java index 3dd4fb87c4..880dffd1a1 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDWithin.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDWithin.java @@ -26,7 +26,11 @@ /** * SQL function geo.dWithin: returns true if geometry g is within the given distance of shape. - * Distance is specified in degrees (consistent with Spatial4j's coordinate system). + * + *

Distance unit: degrees of great-circle arc (Spatial4j native unit). + * This is different from {@code geo.distance()}, which returns metres by default. + * Approximate conversion: 1 degree ≈ 111.32 km at the equator. + * Example: to test "within 10 km", pass {@code 10.0 / 111.32 ≈ 0.0898}.

* *

Usage: {@code geo.dWithin(g, shape, distanceDegrees)}

*

Returns: Boolean

From 5753bff2a0db6804f43c5b88e5dfa782a0a450bf Mon Sep 17 00:00:00 2001 From: robfrank Date: Wed, 25 Feb 2026 10:06:06 +0100 Subject: [PATCH 47/47] chore(studio): regenerate function-reference.json with geo.* functions Includes new geo.* functions, deprecated circle/rectangle aliases, and updated generation date. Co-Authored-By: Claude Sonnet 4.6 --- .../static/js/function-reference.json | 236 ++++++++++++------ 1 file changed, 160 insertions(+), 76 deletions(-) diff --git a/studio/src/main/resources/static/js/function-reference.json b/studio/src/main/resources/static/js/function-reference.json index 3c286d9f44..1e3362569d 100644 --- a/studio/src/main/resources/static/js/function-reference.json +++ b/studio/src/main/resources/static/js/function-reference.json @@ -1,5 +1,5 @@ { - "generated": "2026-02-24", + "generated": "2026-02-25", "categories": { "SQL Functions": { "Collection": [ @@ -59,40 +59,166 @@ } ], "Geo": [ + { + "name": "geo.dwithin", + "syntax": "geo.dWithin(, , )", + "description": "geo.dWithin(, , )", + "since": "sql" + }, + { + "name": "geo.within", + "syntax": "geo.within(, )", + "description": "geo.within(, )", + "since": "sql" + }, { "name": "circle", - "syntax": "circle(,,)", - "description": "circle(,,)", + "syntax": "circle(,,) [deprecated: use geo.buffer(geo.point(x,y), radius)]", + "description": "circle(,,) [deprecated: use geo.buffer(geo.point(x,y), radius)]", + "since": "sql" + }, + { + "name": "geo.crosses", + "syntax": "geo.crosses(, )", + "description": "geo.crosses(, )", "since": "sql" }, { "name": "linestring", - "syntax": "lineString([ * ])", - "description": "lineString([ * ])", + "syntax": "geo.lineString([[x1,y1],[x2,y2],...])", + "description": "geo.lineString([[x1,y1],[x2,y2],...])", + "since": "sql" + }, + { + "name": "geo.point", + "syntax": "geo.point(, )", + "description": "geo.point(, )", + "since": "sql" + }, + { + "name": "geo.x", + "syntax": "geo.x()", + "description": "geo.x()", "since": "sql" }, { "name": "distance", - "syntax": "distance(,[,]) or distance(,,,[,])", - "description": "distance(,[,]) or distance(,,,[,])", + "syntax": "geo.distance(, [, ])", + "description": "geo.distance(, [, ])", + "since": "sql" + }, + { + "name": "geo.y", + "syntax": "geo.y()", + "description": "geo.y()", + "since": "sql" + }, + { + "name": "geo.polygon", + "syntax": "geo.polygon([[x1,y1],[x2,y2],...])", + "description": "geo.polygon([[x1,y1],[x2,y2],...])", + "since": "sql" + }, + { + "name": "geo.astext", + "syntax": "geo.asText()", + "description": "geo.asText()", + "since": "sql" + }, + { + "name": "geo.geomfromtext", + "syntax": "geo.geomFromText()", + "description": "geo.geomFromText()", + "since": "sql" + }, + { + "name": "geo.buffer", + "syntax": "geo.buffer(, )", + "description": "geo.buffer(, )", + "since": "sql" + }, + { + "name": "geo.contains", + "syntax": "geo.contains(, )", + "description": "geo.contains(, )", + "since": "sql" + }, + { + "name": "geo.disjoint", + "syntax": "geo.disjoint(, )", + "description": "geo.disjoint(, )", + "since": "sql" + }, + { + "name": "geo.linestring", + "syntax": "geo.lineString([[x1,y1],[x2,y2],...])", + "description": "geo.lineString([[x1,y1],[x2,y2],...])", + "since": "sql" + }, + { + "name": "geo.asgeojson", + "syntax": "geo.asGeoJson()", + "description": "geo.asGeoJson()", + "since": "sql" + }, + { + "name": "geo.overlaps", + "syntax": "geo.overlaps(, )", + "description": "geo.overlaps(, )", "since": "sql" }, { "name": "polygon", - "syntax": "polygon([ * ])", - "description": "polygon([ * ])", + "syntax": "geo.polygon([[x1,y1],[x2,y2],...])", + "description": "geo.polygon([[x1,y1],[x2,y2],...])", + "since": "sql" + }, + { + "name": "geo.envelope", + "syntax": "geo.envelope()", + "description": "geo.envelope()", + "since": "sql" + }, + { + "name": "geo.area", + "syntax": "geo.area()", + "description": "geo.area()", + "since": "sql" + }, + { + "name": "geo.intersects", + "syntax": "geo.intersects(, )", + "description": "geo.intersects(, )", + "since": "sql" + }, + { + "name": "geo.equals", + "syntax": "geo.equals(, )", + "description": "geo.equals(, )", "since": "sql" }, { "name": "rectangle", - "syntax": "rectangle(,,,)", - "description": "rectangle(,,,)", + "syntax": "rectangle(,,,) [deprecated: use geo.geomFromText with POLYGON WKT]", + "description": "rectangle(,,,) [deprecated: use geo.geomFromText with POLYGON WKT]", "since": "sql" }, { "name": "point", - "syntax": "point(,)", - "description": "point(,)", + "syntax": "geo.point(, )", + "description": "geo.point(, )", + "since": "sql" + }, + { + "name": "geo.touches", + "syntax": "geo.touches(, )", + "description": "geo.touches(, )", + "since": "sql" + }, + { + "name": "geo.distance", + "syntax": "geo.distance(, [, ])", + "description": "geo.distance(, [, ])", "since": "sql" } ], @@ -151,12 +277,6 @@ "description": "Syntax error: in([])", "since": "sql" }, - { - "name": "ciao", - "syntax": "just return 'ciao'", - "description": "just return 'ciao'", - "since": "sql" - }, { "name": "out", "syntax": "Syntax error: out([])", @@ -659,12 +779,6 @@ } ], "Misc": [ - { - "name": "test_dropdatabase", - "syntax": "test_dropDatabase", - "description": "test_dropDatabase", - "since": "sql" - }, { "name": "coalesce", "syntax": "Returns the first not-null parameter or null if all parameters are null. Syntax: coalesce( [,]*)", @@ -683,18 +797,6 @@ "description": "Syntax error: ifempty(, [,])", "since": "sql" }, - { - "name": "test_executeinnewdatabase", - "syntax": "test_executeInNewDatabase", - "description": "test_executeInNewDatabase", - "since": "sql" - }, - { - "name": "test_testreflectionmethod", - "syntax": "test_testReflectionMethod", - "description": "test_testReflectionMethod", - "since": "sql" - }, { "name": "encode", "syntax": "encode(, )", @@ -713,18 +815,6 @@ "description": "bool_and( [,*])", "since": "sql" }, - { - "name": "test_createdatabase", - "syntax": "test_createDatabase", - "description": "test_createDatabase", - "since": "sql" - }, - { - "name": "test_checkactivedatabases", - "syntax": "test_checkActiveDatabases", - "description": "test_checkActiveDatabases", - "since": "sql" - }, { "name": "ifnull", "syntax": "Syntax error: ifnull(, [,])", @@ -737,18 +827,6 @@ "description": "if(, [,])", "since": "sql" }, - { - "name": "test_expectexception", - "syntax": "test_expectException", - "description": "test_expectException", - "since": "sql" - }, - { - "name": "test_createrandomtype", - "syntax": "test_createRandomType", - "description": "test_createRandomType", - "since": "sql" - }, { "name": "decode", "syntax": "decode(, )", @@ -760,12 +838,6 @@ "syntax": "uuid()", "description": "uuid()", "since": "sql" - }, - { - "name": "test_endalltests", - "syntax": "test_endAllTests", - "description": "test_endAllTests", - "since": "sql" } ], "Text": [ @@ -2411,7 +2483,6 @@ "bothv", "capitalize", "charat", - "ciao", "circle", "coalesce", "coll.distinct", @@ -2461,6 +2532,27 @@ "field", "first", "format", + "geo.area", + "geo.asgeojson", + "geo.astext", + "geo.buffer", + "geo.contains", + "geo.crosses", + "geo.disjoint", + "geo.distance", + "geo.dwithin", + "geo.envelope", + "geo.equals", + "geo.geomfromtext", + "geo.intersects", + "geo.linestring", + "geo.overlaps", + "geo.point", + "geo.polygon", + "geo.touches", + "geo.within", + "geo.x", + "geo.y", "hash", "if", "ifempty", @@ -2619,14 +2711,6 @@ "sum", "symmetricdifference", "sysdate", - "test_checkactivedatabases", - "test_createdatabase", - "test_createrandomtype", - "test_dropdatabase", - "test_endalltests", - "test_executeinnewdatabase", - "test_expectexception", - "test_testreflectionmethod", "text.byteCount", "text.camelCase", "text.capitalize",