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/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/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/docs/plans/2026-02-22-geospatial-design.md b/docs/plans/2026-02-22-geospatial-design.md new file mode 100644 index 0000000000..4b5604ea9e --- /dev/null +++ b/docs/plans/2026-02-22-geospatial-design.md @@ -0,0 +1,237 @@ +# 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 `geo.*` SQL function namespace. + +## Goals + +- 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) + +## 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 geo.within(location, geo.geomFromText('POLYGON(...)')) = true + │ + ▼ + SelectExecutionPlanner + detects IndexableSQLFunction on geo.within + calls allowsIndexedExecution() + │ + ▼ + LSMTreeGeoIndex.get(shape) + decomposes shape → GeoHash tokens via lucene-spatial-extras + looks up each token in underlying LSMTreeIndex + returns candidate RIDs + │ + ▼ + geo.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 `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 `geo.*` 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: geo.* SQL Functions + +**Package:** `com.arcadedb.function.sql.geo` +**Registered in:** `DefaultSQLFunctionFactory` + +### Constructor / Accessor Functions (pure compute, no index) + +| Function | Replaces | Notes | +|---|---|---| +| `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 | +|---|---|---| +| `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()`:** + +- `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. +- `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 + 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) +- `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. `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. + +## Error Handling + +| Scenario | Behavior | +|---|---| +| 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 | +| 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 `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 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 + +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/ + 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 + +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 `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 new file mode 100644 index 0000000000..368460cde3 --- /dev/null +++ b/docs/plans/2026-02-22-geospatial-implementation.md @@ -0,0 +1,1454 @@ +# Geospatial Indexing Implementation Plan + +**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. + +**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 geo.* Constructor and Accessor Functions + +**Files:** +- 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 `geo.*` tests: + +```java +@Test +void geoPoint() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + 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(); + // Should be a Spatial4j Point or WKT string + }); +} + +@Test +void geoGeomFromText() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + 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(); + }); +} + +@Test +void geoAsText() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + 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"); + }); +} + +@Test +void geoXgeoY() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + 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); + assertThat(((Number) row.getProperty("y")).doubleValue()).isEqualTo(45.0); + }); +} + +@Test +void geoDistance() throws Exception { + TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { + final ResultSet result = db.query("sql", + "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 + }); +} + +@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 — `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("geo.functionName")` +- `execute()` validates params, calls `GeoUtils.getSpatialContext()` for shape creation +- `getSyntax()` returns a docs string +- `getMinArgs()` / `getMaxArgs()` for validation + +`SQLFunctionGeoGeomFromText.java`: +```java +public class SQLFunctionGeoGeomFromText extends SQLFunctionAbstract { + public static final String NAME = "geo.geomFromText"; + + public SQLFunctionGeoGeomFromText() { 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("geo.geomFromText: invalid WKT: " + params[0], e); + } + } + + @Override public String getSyntax() { return "geo.geomFromText()"; } + @Override public int getMinArgs() { return 1; } + @Override public int getMaxArgs() { return 1; } +} +``` + +`SQLFunctionGeoAsText.java`: +```java +public class SQLFunctionGeoAsText extends SQLFunctionAbstract { + public static final String NAME = "geo.asText"; + + public SQLFunctionGeoAsText() { 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 "geo.asText()"; } + @Override public int getMinArgs() { return 1; } + @Override public int getMaxArgs() { return 1; } +} +``` + +`SQLFunctionGeoX.java`: +```java +public class SQLFunctionGeoX extends SQLFunctionAbstract { + public static final String NAME = "geo.x"; + + public SQLFunctionGeoX() { 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("geo.x: argument must be a Point"); + } + + @Override public String getSyntax() { return "geo.x()"; } + @Override public int getMinArgs() { return 1; } + @Override public int getMaxArgs() { return 1; } +} +``` + +`SQLFunctionGeoY.java` — same as `SQLFunctionGeoX` but returns `p.getY()`. + +`SQLFunctionGeoPoint.java` — same logic as existing `SQLFunctionPoint.java` but named `geo.point`. + +`SQLFunctionGeoDistance.java` — same logic as existing `SQLFunctionDistance.java` but named `geo.distance`. + +`SQLFunctionGeoLineString.java` — same as existing `SQLFunctionLineString.java` but named `geo.lineString`. + +`SQLFunctionGeoPolygon.java` — same as existing `SQLFunctionPolygon.java` but named `geo.polygon`. + +`SQLFunctionGeoBuffer.java` — same as `SQLFunctionCircle.java` (circle = point + buffer radius) but named `geo.buffer`. + +`SQLFunctionGeoEnvelope.java` — same as `SQLFunctionRectangle.java` but named `geo.envelope`. + +`SQLFunctionGeoArea.java`: +```java +public class SQLFunctionGeoArea extends SQLFunctionAbstract { + public static final String NAME = "geo.area"; + + public SQLFunctionGeoArea() { 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("geo.area: argument must be a Shape"); + } + + @Override public String getSyntax() { return "geo.area()"; } + @Override public int getMinArgs() { return 1; } + @Override public int getMaxArgs() { return 1; } +} +``` + +`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 +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 `geo.*` registrations in their place: + ```java + 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** + +```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 geo.* 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/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` + +**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 geoWithinNoIndex() { + 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 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 geoWithinOutsideNoIndex() { + 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 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 geoIntersectsNoIndex() { + 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 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(); + } + + // ---- Indexed predicate evaluation ---- + + @Test + 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"); + + 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 geo.within(coords, " + + "geo.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 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"); + + 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 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++; } + rs.close(); + assertThat(count).isEqualTo(1); + } + + @Test + 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"); + + 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 geo.contains(bounds, " + + "geo.geomFromText('POINT (10.0 45.0)')) = true"); + + int count = 0; + while (rs.hasNext()) { rs.next(); count++; } + rs.close(); + assertThat(count).isEqualTo(1); + } + + @Test + void geoNullReturnsNull() { + final ResultSet rs = database.query("sql", + "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(); + rs.close(); + } +} +``` + +**Step 2: Run to verify it fails** + +```bash +cd engine && mvn test -Dtest=SQLGeoIndexedQueryTest -q 2>&1 | tail -10 +``` + +Expected: FAIL — `geo.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 SQLFunctionGeoPredicate extends SQLFunctionAbstract + implements IndexableSQLFunction { + + protected SQLFunctionGeoPredicate(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 `geo.within`: + +```java +package com.arcadedb.function.sql.geo; + +import org.locationtech.spatial4j.shape.SpatialRelation; + +public class SQLFunctionGeoWithin extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.within"; + + public SQLFunctionGeoWithin() { super(NAME); } + + @Override + protected SpatialRelation getExpectedRelation() { return SpatialRelation.WITHIN; } + + @Override + public String getSyntax() { return "geo.within(, )"; } + + @Override + public int getMinArgs() { return 2; } + + @Override + public int getMaxArgs() { return 2; } +} +``` + +Spatial4j `SpatialRelation` values: +- `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 `geo.crosses`, `geo.overlaps`, `geo.touches` — Spatial4j doesn't have these as `SpatialRelation` values. Override `checkRelation` to use JTS topology: + +```java +// 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); + final org.locationtech.jts.geom.Geometry jg2 = GeoUtils.SPATIAL_CONTEXT.getGeometryFrom(g2); + return jg1.crosses(jg2); +} +``` + +`geo.dWithin` has a different signature `(g1, g2, distance)`, so override `execute()` directly: + +```java +// 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("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]); + 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 `geo.asGeoJson` registration: + +```java +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** + +```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 geo.* 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 geo.* 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. + +**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. 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 +``` 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" +``` 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" +``` 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 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..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,4 +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/geo/CypherPointFunction.java b/engine/src/main/java/com/arcadedb/function/geo/CypherPointFunction.java new file mode 100644 index 0000000000..8e727710f8 --- /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 geo.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/function/sql/DefaultSQLFunctionFactory.java b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java index e3f3ceca3b..cd5dccaa67 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java +++ b/engine/src/main/java/com/arcadedb/function/sql/DefaultSQLFunctionFactory.java @@ -30,10 +30,27 @@ 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.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; import com.arcadedb.function.sql.geo.SQLFunctionRectangle; import com.arcadedb.function.sql.graph.SQLFunctionAstar; import com.arcadedb.function.sql.graph.SQLFunctionBellmanFord; @@ -166,14 +183,35 @@ private DefaultSQLFunctionFactory() { register(SQLFunctionSymmetricDifference.NAME, SQLFunctionSymmetricDifference.class); register(SQLFunctionUnionAll.NAME, SQLFunctionUnionAll.class); - // Geo + // Geo — deprecated aliases for backward compatibility (use geo.* variants instead) 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 — geo.* constructor/accessor 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(SQLFunctionGeoContains.NAME, new SQLFunctionGeoContains()); + register(SQLFunctionGeoCrosses.NAME, new SQLFunctionGeoCrosses()); + register(SQLFunctionGeoDisjoint.NAME, new SQLFunctionGeoDisjoint()); + register(SQLFunctionGeoDWithin.NAME, new SQLFunctionGeoDWithin()); + register(SQLFunctionGeoEquals.NAME, new SQLFunctionGeoEquals()); + 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); 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 c411011312..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 @@ -18,10 +18,20 @@ */ 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.Rectangle; +import org.locationtech.spatial4j.shape.Shape; +import org.locationtech.spatial4j.shape.jts.JtsGeometry; + +import java.util.Locale; /** * Geospatial utility class. @@ -32,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; } @@ -41,6 +55,144 @@ 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(); } + + /** + * 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. + *

+ * 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); + else + 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 WKT_READER.get().read(wkt); + } catch (ParseException e) { + throw new IllegalArgumentException("Cannot parse JTS geometry from WKT: " + wkt, e); + } + } + + /** + * 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 = "POLYGON ((" + + formatCoord(minX) + " " + formatCoord(minY) + ", " + + formatCoord(maxX) + " " + formatCoord(minY) + ", " + + formatCoord(maxX) + " " + formatCoord(maxY) + ", " + + formatCoord(minX) + " " + formatCoord(maxY) + ", " + + formatCoord(minX) + " " + formatCoord(minY) + "))"; + try { + return WKT_READER.get().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 = "POLYGON ((" + + formatCoord(minX) + " " + formatCoord(minY) + ", " + + formatCoord(maxX) + " " + formatCoord(minY) + ", " + + formatCoord(maxX) + " " + formatCoord(maxY) + ", " + + formatCoord(minX) + " " + formatCoord(maxY) + ", " + + formatCoord(minX) + " " + formatCoord(minY) + "))"; + try { + return WKT_READER.get().read(wkt); + } catch (ParseException e) { + throw new IllegalArgumentException("Cannot parse ENVELOPE as polygon: " + envelopeWkt, e); + } + } + + /** + * Convert a JTS Geometry to WKT string. + */ + public static String jtsToWKT(final Geometry geometry) { + if (geometry == null) + return null; + return WKT_WRITER.get().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/SQLFunctionCircle.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionCircle.java index 7492ae9111..d31f71b262 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionCircle.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionCircle.java @@ -19,15 +19,18 @@ 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; /** - * Returns a circle shape with the 3 coordinates received as parameters. + * 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.

* - * @author Luca Garulli (l.garulli--(at)--arcadedata.com) + * @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"; @@ -35,17 +38,19 @@ 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"); + 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(,,)"; + return "circle(,,) [deprecated: use geo.buffer(geo.point(x,y), radius)]"; } } 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/SQLFunctionGeoArea.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoArea.java new file mode 100644 index 0000000000..0476a0120e --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoArea.java @@ -0,0 +1,58 @@ +/* + * 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.Shape; + +/** + * SQL function geo.area: returns the area of a geometry in square degrees. + * + *

Usage: {@code geo.area()}

+ *

Returns: Double area value in square degrees

+ */ +public class SQLFunctionGeoArea extends SQLFunctionAbstract { + public static final String NAME = "geo.area"; + + public SQLFunctionGeoArea() { + 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 SpatialContext ctx = GeoUtils.getSpatialContext(); + return shape.getArea(ctx); + } + + @Override + public String getSyntax() { + return "geo.area()"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoAsGeoJson.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoAsGeoJson.java new file mode 100644 index 0000000000..ead5355894 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoAsGeoJson.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 geo.asGeoJson: returns the GeoJSON representation of a geometry. + * Uses JTS for geometry parsing and manual serialization via JSONObject/JSONArray. + * + *

Usage: {@code geo.asGeoJson()}

+ *

Returns: GeoJSON string

+ */ +public class SQLFunctionGeoAsGeoJson extends SQLFunctionAbstract { + public static final String NAME = "geo.asGeoJson"; + + public SQLFunctionGeoAsGeoJson() { + 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 "geo.asGeoJson()"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoAsText.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoAsText.java new file mode 100644 index 0000000000..185958a25e --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoAsText.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 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 geo.asText()}

+ *

Returns: WKT string

+ */ +public class SQLFunctionGeoAsText extends SQLFunctionAbstract { + public static final String NAME = "geo.asText"; + + public SQLFunctionGeoAsText() { + 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 "geo.asText()"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoBuffer.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoBuffer.java new file mode 100644 index 0000000000..f664990f16 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoBuffer.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 geo.buffer: returns a WKT string of the buffered geometry. + * Uses JTS Geometry.buffer(distance) for the computation. + * + *

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

+ *

Returns: WKT string of the buffered shape

+ */ +public class SQLFunctionGeoBuffer extends SQLFunctionAbstract { + public static final String NAME = "geo.buffer"; + + public SQLFunctionGeoBuffer() { + 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 "geo.buffer(, )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoContains.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoContains.java new file mode 100644 index 0000000000..78de1ed0a0 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoContains.java @@ -0,0 +1,62 @@ +/* + * 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 geo.contains: returns true if geometry g fully contains shape. + * + *

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

+ *

Returns: Boolean

+ */ +public class SQLFunctionGeoContains extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.contains"; + + public SQLFunctionGeoContains() { + super(NAME); + } + + /** + * 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. + */ + @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; + } + + @Override + public String getSyntax() { + return "geo.contains(, )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoCrosses.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoCrosses.java new file mode 100644 index 0000000000..056a9a8b1b --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoCrosses.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.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; + +/** + * 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 geo.crosses(g1, g2)}

+ *

Returns: Boolean

+ */ +public class SQLFunctionGeoCrosses extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.crosses"; + + public SQLFunctionGeoCrosses() { + super(NAME); + } + + /** + * 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. + */ + @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); + final Geometry jts2 = GeoUtils.parseJtsGeometry(geom2); + if (jts1 == null || jts2 == null) + return null; + return jts1.crosses(jts2); + } + + @Override + public String getSyntax() { + return "geo.crosses(, )"; + } +} 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 new file mode 100644 index 0000000000..880dffd1a1 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDWithin.java @@ -0,0 +1,81 @@ +/* + * 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 geo.dWithin: returns true if geometry g is within the given distance of shape. + * + *

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

+ */ +public class SQLFunctionGeoDWithin extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.dWithin"; + + public SQLFunctionGeoDWithin() { + super(NAME); + } + + @Override + public int getMinArgs() { + return 3; + } + + @Override + public int getMaxArgs() { + return 3; + } + + /** + * 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. + */ + @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 "geo.dWithin(, , )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDisjoint.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDisjoint.java new file mode 100644 index 0000000000..b32b34ff04 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDisjoint.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 geo.disjoint: returns true if the two geometries share no points. + * + *

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

+ *

Returns: Boolean

+ */ +public class SQLFunctionGeoDisjoint extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.disjoint"; + + public SQLFunctionGeoDisjoint() { + super(NAME); + } + + /** + * 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. + */ + @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 "geo.disjoint(, )"; + } +} 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 new file mode 100644 index 0000000000..d3286074d8 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoDistance.java @@ -0,0 +1,103 @@ +/* + * 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 geo.distance: computes the Haversine distance between two points. + * Points may be WKT strings or Spatial4j Shape/Point objects. + * + *

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

+ *

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

+ *

Returns: Double distance value

+ */ +public class SQLFunctionGeoDistance extends SQLFunctionAbstract { + public static final String NAME = "geo.distance"; + + private static final double EARTH_RADIUS_KM = 6371.0; + + public SQLFunctionGeoDistance() { + 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 getAlias() { + return "distance"; + } + + @Override + public String getSyntax() { + return "geo.distance(, [, ])"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoEnvelope.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoEnvelope.java new file mode 100644 index 0000000000..bfa5aaf118 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoEnvelope.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 geo.envelope: returns the WKT bounding box polygon of a geometry. + * + *

Usage: {@code geo.envelope()}

+ *

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

+ */ +public class SQLFunctionGeoEnvelope extends SQLFunctionAbstract { + public static final String NAME = "geo.envelope"; + + public SQLFunctionGeoEnvelope() { + 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 "geo.envelope()"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoEquals.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoEquals.java new file mode 100644 index 0000000000..59b146a851 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoEquals.java @@ -0,0 +1,66 @@ +/* + * 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.jts.geom.Geometry; +import org.locationtech.spatial4j.shape.Shape; + +/** + * SQL function geo.equals: returns true if the two geometries are geometrically equal. + * Uses JTS geometric equality (structural equivalence after normalisation). + * + *

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

+ *

Returns: Boolean

+ */ +public class SQLFunctionGeoEquals extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.equals"; + + public SQLFunctionGeoEquals() { + super(NAME); + } + + /** + * 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. + */ + @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); + final Geometry jts2 = GeoUtils.parseJtsGeometry(geom2); + if (jts1 == null || jts2 == null) + return null; + return jts1.norm().equals(jts2.norm()); + } + + @Override + public String getSyntax() { + return "geo.equals(, )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionPoint.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoGeomFromText.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/SQLFunctionGeoGeomFromText.java index ad02be77c5..8f428f2246 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionPoint.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoGeomFromText.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 geo.geomFromText: parses a WKT string and returns a Shape object. * - * @author Luca Garulli (l.garulli--(at)--arcadedata.com) + *

Usage: {@code geo.geomFromText()}

*/ -public class SQLFunctionPoint extends SQLFunctionAbstract { - public static final String NAME = "point"; +public class SQLFunctionGeoGeomFromText extends SQLFunctionAbstract { + public static final String NAME = "geo.geomFromText"; - public SQLFunctionPoint() { + public SQLFunctionGeoGeomFromText() { 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 "geo.geomFromText()"; } - } diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoIntersects.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoIntersects.java new file mode 100644 index 0000000000..fc885208f8 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoIntersects.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 geo.intersects: returns true if the two geometries share any point. + * + *

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

+ *

Returns: Boolean

+ */ +public class SQLFunctionGeoIntersects extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.intersects"; + + public SQLFunctionGeoIntersects() { + 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 "geo.intersects(, )"; + } +} 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 new file mode 100644 index 0000000000..f168f7d83e --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoLineString.java @@ -0,0 +1,83 @@ +/* + * 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 geo.lineString: constructs a WKT LINESTRING string from a list of coordinate pairs. + * + *

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

+ *

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

+ */ +public class SQLFunctionGeoLineString extends SQLFunctionAbstract { + public static final String NAME = "geo.lineString"; + + public SQLFunctionGeoLineString() { + 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 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/SQLFunctionGeoOverlaps.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoOverlaps.java new file mode 100644 index 0000000000..825928c5b6 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoOverlaps.java @@ -0,0 +1,68 @@ +/* + * 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.jts.geom.Geometry; +import org.locationtech.spatial4j.shape.Shape; + +/** + * 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 geo.overlaps(g1, g2)}

+ *

Returns: Boolean

+ */ +public class SQLFunctionGeoOverlaps extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.overlaps"; + + public SQLFunctionGeoOverlaps() { + super(NAME); + } + + /** + * 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. + */ + @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); + final Geometry jts2 = GeoUtils.parseJtsGeometry(geom2); + if (jts1 == null || jts2 == null) + return null; + return jts1.overlaps(jts2); + } + + @Override + public String getSyntax() { + return "geo.overlaps(, )"; + } +} 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 new file mode 100644 index 0000000000..83833c70ef --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPoint.java @@ -0,0 +1,57 @@ +/* + * 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 geo.point: constructs a WKT POINT string from X (longitude) and Y (latitude). + * + *

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

+ *

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

+ */ +public class SQLFunctionGeoPoint extends SQLFunctionAbstract { + public static final String NAME = "geo.point"; + + public SQLFunctionGeoPoint() { + 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 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 new file mode 100644 index 0000000000..62be211392 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPolygon.java @@ -0,0 +1,107 @@ +/* + * 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 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 geo.polygon([[x1,y1],[x2,y2],...])}

+ *

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

+ */ +public class SQLFunctionGeoPolygon extends SQLFunctionAbstract { + public static final String NAME = "geo.polygon"; + + public SQLFunctionGeoPolygon() { + 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 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/SQLFunctionGeoPredicate.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java new file mode 100644 index 0000000000..a2e4493f7f --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoPredicate.java @@ -0,0 +1,240 @@ +/* + * 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 geo.* 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 SQLFunctionGeoPredicate extends SQLFunctionAbstract implements IndexableSQLFunction { + + protected SQLFunctionGeoPredicate(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) { + 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 geo.* 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((Identifiable) null, context); + if (value == null) + return null; + return GeoUtils.parseGeometry(value); + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoTouches.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoTouches.java new file mode 100644 index 0000000000..b633cad379 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoTouches.java @@ -0,0 +1,68 @@ +/* + * 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.jts.geom.Geometry; +import org.locationtech.spatial4j.shape.Shape; + +/** + * 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 geo.touches(g1, g2)}

+ *

Returns: Boolean

+ */ +public class SQLFunctionGeoTouches extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.touches"; + + public SQLFunctionGeoTouches() { + super(NAME); + } + + /** + * 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. + */ + @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); + final Geometry jts2 = GeoUtils.parseJtsGeometry(geom2); + if (jts1 == null || jts2 == null) + return null; + return jts1.touches(jts2); + } + + @Override + public String getSyntax() { + return "geo.touches(, )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoWithin.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoWithin.java new file mode 100644 index 0000000000..65eb54f648 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoWithin.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 geo.within: returns true if geometry g is fully within shape. + * + *

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

+ *

Returns: Boolean

+ */ +public class SQLFunctionGeoWithin extends SQLFunctionGeoPredicate { + public static final String NAME = "geo.within"; + + public SQLFunctionGeoWithin() { + 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 "geo.within(, )"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoX.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoX.java new file mode 100644 index 0000000000..38b4701e61 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoX.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 geo.x: returns the X (longitude) coordinate of a point geometry. + * + *

Usage: {@code geo.x()}

+ *

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

+ */ +public class SQLFunctionGeoX extends SQLFunctionAbstract { + public static final String NAME = "geo.x"; + + public SQLFunctionGeoX() { + 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 "geo.x()"; + } +} diff --git a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoY.java b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoY.java new file mode 100644 index 0000000000..9156c9bce4 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionGeoY.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 geo.y: returns the Y (latitude) coordinate of a point geometry. + * + *

Usage: {@code geo.y()}

+ *

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

+ */ +public class SQLFunctionGeoY extends SQLFunctionAbstract { + public static final String NAME = "geo.y"; + + public SQLFunctionGeoY() { + 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 "geo.y()"; + } +} 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 index 08fb0ab490..fa02b67466 100644 --- a/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionRectangle.java +++ b/engine/src/main/java/com/arcadedb/function/sql/geo/SQLFunctionRectangle.java @@ -19,16 +19,19 @@ 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.Point; /** - * Returns a rectangle shape with the 4 coordinates received as parameters. + * 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.

* - * @author Luca Garulli (l.garulli--(at)--arcadedata.com) + * @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"; @@ -36,10 +39,12 @@ 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"); + 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])); @@ -47,7 +52,8 @@ public Object execute(final Object self, final Identifiable currentRecord, final return spatialContext.getShapeFactory().rect(topLeft, bottomRight); } + @Override public String getSyntax() { - return "rectangle(,,,)"; + 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 new file mode 100644 index 0000000000..3a36df2f83 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/index/geospatial/LSMTreeGeoIndex.java @@ -0,0 +1,543 @@ +/* + * 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.Document; +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.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +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 { + + 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, GeoIndexMetadata.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 = GeoIndexMetadata.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) { + for (final String token : extractTokens(shape)) + underlyingIndex.put(new Object[]{token}, rids); + } + + /** + * 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. + // 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()) { + 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.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) { + if (entries.size() >= maxElements) + break; + 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) { + // 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 + public Schema.INDEX_TYPE getType() { + return Schema.INDEX_TYPE.GEOSPATIAL; + } + + @Override + public boolean isValid() { + return underlyingIndex.isValid(); + } + + @Override + public JSONObject toJSON() { + final JSONObject json = new JSONObject(); + json.put("type", getType()); + final int bucketId = underlyingIndex.getAssociatedBucketId(); + 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; + } + + /** + * 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) { + LogManager.instance().log(this, Level.WARNING, + "Geospatial index: token error for shape '%s': %s", shape, e.getMessage()); + } + } + return tokens; + } +} 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..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,6 +29,7 @@ import com.arcadedb.function.agg.*; import com.arcadedb.function.misc.*; import com.arcadedb.function.geo.*; +import com.arcadedb.function.sql.geo.SQLFunctionGeoDistance; 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(SQLFunctionGeoDistance.NAME), "distance"); case "point.withinbbox" -> new PointWithinBBoxFunction(); // Temporal constructor functions case "date" -> new DateConstructorFunction(); 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/query/sql/antlr/SQLASTBuilder.java b/engine/src/main/java/com/arcadedb/query/sql/antlr/SQLASTBuilder.java index c9b896a236..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,11 +49,19 @@ 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. + * 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; @@ -3022,7 +3030,7 @@ public FunctionCall visitFunctionCall(final SQLParser.FunctionCallContext ctx) { final FunctionCall funcCall = new FunctionCall(-1); try { - // Function name (using reflection for protected field) + // Function name final Identifier funcName = (Identifier) visit(ctx.identifier()); funcCall.name = funcName; @@ -3070,6 +3078,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 || @@ -3208,6 +3228,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. *

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/GeoIndexMetadata.java b/engine/src/main/java/com/arcadedb/schema/GeoIndexMetadata.java new file mode 100644 index 0000000000..2386e7db52 --- /dev/null +++ b/engine/src/main/java/com/arcadedb/schema/GeoIndexMetadata.java @@ -0,0 +1,90 @@ +/* + * 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) { + 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/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/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/sql/geo/GeoConstructionFunctionsTest.java b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoConstructionFunctionsTest.java new file mode 100644 index 0000000000..e1b72770d5 --- /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(IllegalArgumentException.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); + } + } +} 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)); + } + } +} 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); + }); + }); + } +} 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..9eca64ca97 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoMeasurementFunctionsTest.java @@ -0,0 +1,280 @@ +/* + * 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(IllegalArgumentException.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) -> { + 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); + }); + } + + @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(IllegalArgumentException.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(); + } + } +} 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..9049e08fe0 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/function/sql/geo/GeoPredicateFunctionsTest.java @@ -0,0 +1,537 @@ +/* + * 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 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() + .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 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() + .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(); + } + } +} 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 3969f8c748..0000000000 --- a/engine/src/test/java/com/arcadedb/function/sql/geo/SQLGeoFunctionsTest.java +++ /dev/null @@ -1,405 +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.Database; -import com.arcadedb.database.DatabaseFactory; -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 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; - -/** - * @author Luca Garulli (l.garulli@arcadedata.com) - */ -class SQLGeoFunctionsTest { - - @Test - void point() throws Exception { - 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(); - }); - } - - @Test - void rectangle() throws Exception { - 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(); - }); - } - - @Test - void circle() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select circle(10,10,10) as circle"); - assertThat(result.hasNext()).isTrue(); - Circle circle = result.next().getProperty("circle"); - assertThat(circle).isNotNull(); - }); - } - - @Test - void polygon() 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"); - assertThat(result.hasNext()).isTrue(); - Shape polygon = result.next().getProperty("polygon"); - assertThat(polygon).isNotNull(); - - result = db.query("sql", "select polygon( [ [10,10], [20,10], [20,20], [10,20], [10,10] ] ) as polygon"); - assertThat(result.hasNext()).isTrue(); - polygon = result.next().getProperty("polygon"); - assertThat(polygon).isNotNull(); - }); - } - - @Test - void pointIsWithinRectangle() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select point(11,11).isWithin( rectangle(10,10,20,20) ) as isWithin"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("isWithin")).isTrue(); - - result = db.query("sql", "select point(11,21).isWithin( rectangle(10,10,20,20) ) as isWithin"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("isWithin")).isFalse(); - }); - } - - @Test - void pointIsWithinCircle() throws Exception { - TestHelper.executeInNewDatabase("GeoDatabase", (db) -> { - ResultSet result = db.query("sql", "select point(11,11).isWithin( circle(10,10,10) ) as isWithin"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("isWithin")).isTrue(); - - result = db.query("sql", "select point(10,21).isWithin( circle(10,10,10) ) as isWithin"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("isWithin")).isFalse(); - }); - } - - @Test - void pointIntersectWithRectangle() 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"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("intersectsWith")).isTrue(); - - result = db.query("sql", "select rectangle(9,9,9.9,9.9).intersectsWith( rectangle(10,10,20,20) ) as intersectsWith"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("intersectsWith")).isFalse(); - }); - } - - @Test - void pointIntersectWithPolygons() 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"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("intersectsWith")).isTrue(); - - result = db.query("sql", - "select polygon( [ [10,10], [20,10], [20,20], [10,20], [10,10] ] ).intersectsWith( rectangle(21,21,22,22) ) as intersectsWith"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("intersectsWith")).isFalse(); - }); - } - - @Test - void lineStringsIntersect() 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"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("intersectsWith")).isTrue(); - - result = db.query("sql", - "select linestring( [ [10,10], [20,10], [20,20], [10,20], [10,10] ] ).intersectsWith( rectangle(21,21,22,22) ) as intersectsWith"); - assertThat(result.hasNext()).isTrue(); - assertThat((Boolean) result.next().getProperty("intersectsWith")).isFalse(); - }); - } - - @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(); - } - - //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); - }); - }); - } - - /** - * 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 { - 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(); - } - }); - }); - } - - /** - * 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)); - } - } - - /** - * 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 { - 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"); - - // 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 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); - } - - //System.out.println("Elapsed insert: " + (System.currentTimeMillis() - begin)); - - 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 bboxBR <= ?",area[1]); - ResultSet result = db.query("sql", "select from Restaurant where bboxTL >= ? and bboxBR <= ?", 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("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()); - - ++returned; - } - - //System.out.println("Elapsed browsing: " + (System.currentTimeMillis() - begin)); - - assertThat(returned).isEqualTo(20); - }); - }); - } -} 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/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); + } +} 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..6862776035 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexSchemaTest.java @@ -0,0 +1,81 @@ +/* + * 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.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; + +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); + } + + @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); + } +} 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..b64e8ad923 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/index/geospatial/LSMTreeGeoIndexTest.java @@ -0,0 +1,157 @@ +/* + * 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.schema.GeoIndexMetadata; +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, + GeoIndexMetadata.DEFAULT_PRECISION + ); + + // Register the paginated component so commit2ndPhase can look it up by file ID + schema.registerFile(idx.getComponent()); + + // 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") + 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(); + } + + /** + * 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"); + + // 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(); + } +} 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..9f85e8854f --- /dev/null +++ b/engine/src/test/java/com/arcadedb/index/geospatial/SQLGeoIndexedQueryTest.java @@ -0,0 +1,350 @@ +/* + * 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 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 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. + */ + @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 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()) + names.add(result.next().getProperty("name")); + + assertThat(names).hasSize(2); + assertThat(names).containsExactlyInAnyOrder("Rome", "Naples"); + } + + /** + * Verifies geo.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 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()) + names.add(result.next().getProperty("name")); + + assertThat(names).hasSize(2); + assertThat(names).containsExactlyInAnyOrder("Rome", "Naples"); + } + + /** + * 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()}). + * + *

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 stDWithinFallbackWithExistingIndex() { + 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 from POINT (12.0, 41.5) within 1.0 degree — only Rome qualifies + final ResultSet result = database.query("sql", + "SELECT name FROM Location3 WHERE geo.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).hasSize(1); + assertThat(names).containsExactlyInAnyOrder("Rome"); + } + + /** + * Verifies that dropping the index and re-running the geo.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 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()) + names.add(result.next().getProperty("name")); + + assertThat(names).hasSize(2); + assertThat(names).containsExactlyInAnyOrder("Rome", "Naples"); + } + + /** + * 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. + */ + @Test + 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"); + 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 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()) + names.add(result.next().getProperty("name")); + + assertThat(names).containsExactly("Milan"); + } + + /** + * 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 (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. + */ + @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))'"); + }); + + // geo.contains(coords, point) — find which stored polygon contains Rome + final ResultSet result = database.query("sql", + "SELECT name FROM Location6 WHERE geo.contains(coords, geo.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 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. + */ + @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 geo.equals(coords, geo.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 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)). + */ + @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 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()) + names.add(result.next().getProperty("name")); + + assertThat(names).hasSize(1); + assertThat(names).containsExactly("CrossingLine"); + } + + /** + * 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 + 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 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()) + names.add(result.next().getProperty("name")); + + assertThat(names).hasSize(1); + assertThat(names).containsExactly("WestBox"); + } + + /** + * 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 + 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 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()) + names.add(result.next().getProperty("name")); + + assertThat(names).hasSize(1); + assertThat(names).containsExactly("LeftBox"); + } +} 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/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; 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); } ); 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; 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",