diff --git a/pom.xml b/pom.xml
index b51818ac8..f0d5cf77e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -96,6 +96,7 @@
2.12.17
2.12
3.4.4
+ 1.6.1
1.20.0
1.39.0
diff --git a/wayang-api/wayang-api-scala-java/src/main/scala/org/apache/wayang/api/DataQuanta.scala b/wayang-api/wayang-api-scala-java/src/main/scala/org/apache/wayang/api/DataQuanta.scala
index 2a2f60cc0..e792d246b 100644
--- a/wayang-api/wayang-api-scala-java/src/main/scala/org/apache/wayang/api/DataQuanta.scala
+++ b/wayang-api/wayang-api-scala-java/src/main/scala/org/apache/wayang/api/DataQuanta.scala
@@ -34,12 +34,13 @@ import org.apache.wayang.core.optimizer.ProbabilisticDoubleInterval
import org.apache.wayang.core.optimizer.cardinality.CardinalityEstimator
import org.apache.wayang.core.optimizer.costs.LoadProfileEstimator
import org.apache.wayang.core.plan.wayangplan._
+import org.apache.wayang.core.api.spatial.{SpatialGeometry, SpatialPredicate}
import org.apache.wayang.core.platform.Platform
import org.apache.wayang.core.util.{Tuple => WayangTuple}
import org.apache.wayang.basic.data.{Record, Tuple2 => WayangTuple2}
-import org.apache.wayang.basic.model.{DLModel, LogisticRegressionModel,DecisionTreeRegressionModel};
+import org.apache.wayang.basic.model.{DLModel, LogisticRegressionModel,DecisionTreeRegressionModel}
import org.apache.wayang.commons.util.profiledb.model.Experiment
-import com.google.protobuf.ByteString;
+import com.google.protobuf.ByteString
import org.apache.wayang.api.python.function._
import org.tensorflow.ndarray.NdArray
@@ -632,6 +633,81 @@ class DataQuanta[Out: ClassTag](val operator: ElementaryOperator, outputIndex: I
joinOperator
}
+ /**
+ * Applies a spatial filter to this instance.
+ *
+ * @param keySelector UDF to extract spatial geometry from data quanta
+ * @param predicateType the spatial predicate type
+ * @param filterGeometry the geometry to filter against
+ * @param columnName optional SQL column name for database pushdown
+ * @return a new instance representing the filtered output
+ */
+ def spatialFilter(keySelector: Out => SpatialGeometry,
+ predicateType: SpatialPredicate,
+ filterGeometry: SpatialGeometry,
+ columnName: String = null): DataQuanta[Out] =
+ spatialFilterJava(toSerializableFunction(keySelector), predicateType, filterGeometry, columnName)
+
+ /**
+ * Applies a spatial filter to this instance.
+ *
+ * @param keySelector UDF to extract spatial geometry from data quanta
+ * @param predicateType the spatial predicate type
+ * @param filterGeometry the geometry to filter against
+ * @param columnName optional SQL column name for database pushdown
+ * @return a new instance representing the filtered output
+ */
+ def spatialFilterJava(keySelector: SerializableFunction[Out, _ <: SpatialGeometry],
+ predicateType: SpatialPredicate,
+ filterGeometry: SpatialGeometry,
+ columnName: String = null): DataQuanta[Out] = {
+ val op = new SpatialFilterOperator(predicateType, keySelector, dataSetType[Out], filterGeometry)
+ if (columnName != null) op.getKeyDescriptor.withSqlImplementation(null, columnName)
+ this.connectTo(op, 0)
+ wrap[Out](op)
+ }
+
+ /**
+ * Feeds this and a further instance into a [[SpatialJoinOperator]].
+ *
+ * @param thisKeyUdf UDF to extract spatial geometry from this instance's elements
+ * @param that the other instance
+ * @param thatKeyUdf UDF to extract spatial geometry from `that` instance's elements
+ * @param predicateType the spatial predicate type for the join
+ * @return a new instance representing the SpatialJoinOperator's output
+ */
+ def spatialJoin[ThatOut: ClassTag](
+ thisKeyUdf: Out => SpatialGeometry,
+ that: DataQuanta[ThatOut],
+ thatKeyUdf: ThatOut => SpatialGeometry,
+ predicateType: SpatialPredicate): DataQuanta[WayangTuple2[Out, ThatOut]] =
+ spatialJoinJava(toSerializableFunction(thisKeyUdf), that, toSerializableFunction(thatKeyUdf), predicateType)
+
+ /**
+ * Feeds this and a further instance into a [[SpatialJoinOperator]].
+ *
+ * @param thisKeyUdf UDF to extract spatial geometry from this instance's elements
+ * @param that the other instance
+ * @param thatKeyUdf UDF to extract spatial geometry from `that` instance's elements
+ * @param predicateType the spatial predicate type for the join
+ * @return a new instance representing the SpatialJoinOperator's output
+ */
+ def spatialJoinJava[ThatOut: ClassTag](
+ thisKeyUdf: SerializableFunction[Out, _ <: SpatialGeometry],
+ that: DataQuanta[ThatOut],
+ thatKeyUdf: SerializableFunction[ThatOut, _ <: SpatialGeometry],
+ predicateType: SpatialPredicate): DataQuanta[WayangTuple2[Out, ThatOut]] = {
+ require(this.planBuilder eq that.planBuilder, s"$this and $that must use the same plan builders.")
+ val op = new SpatialJoinOperator(
+ new TransformationDescriptor(thisKeyUdf.asInstanceOf[SerializableFunction[Out, SpatialGeometry]], basicDataUnitType[Out], basicDataUnitType[SpatialGeometry]),
+ new TransformationDescriptor(thatKeyUdf.asInstanceOf[SerializableFunction[ThatOut, SpatialGeometry]], basicDataUnitType[ThatOut], basicDataUnitType[SpatialGeometry]),
+ predicateType
+ )
+ this.connectTo(op, 0)
+ that.connectTo(op, 1)
+ wrap[WayangTuple2[Out, ThatOut]](op)
+ }
+
def predict[ThatOut: ClassTag](
that: DataQuanta[ThatOut],
inputType: Class[_ <: Any],
diff --git a/wayang-api/wayang-api-scala-java/src/main/scala/org/apache/wayang/api/DataQuantaBuilder.scala b/wayang-api/wayang-api-scala-java/src/main/scala/org/apache/wayang/api/DataQuantaBuilder.scala
index dad054a2f..391ae7a14 100644
--- a/wayang-api/wayang-api-scala-java/src/main/scala/org/apache/wayang/api/DataQuantaBuilder.scala
+++ b/wayang-api/wayang-api-scala-java/src/main/scala/org/apache/wayang/api/DataQuantaBuilder.scala
@@ -30,6 +30,7 @@ import org.apache.wayang.basic.data.{Record, Tuple2 => RT2}
import org.apache.wayang.basic.model.{DLModel, Model, LogisticRegressionModel,DecisionTreeRegressionModel}
import org.apache.wayang.basic.operators.{DLTrainingOperator, GlobalReduceOperator, LocalCallbackSink, MapOperator, SampleOperator, LogisticRegressionOperator,DecisionTreeRegressionOperator, LinearSVCOperator}
import org.apache.wayang.commons.util.profiledb.model.Experiment
+import org.apache.wayang.core.api.spatial.{SpatialGeometry, SpatialPredicate}
import org.apache.wayang.core.function.FunctionDescriptor.{SerializableBiFunction, SerializableBinaryOperator, SerializableFunction, SerializableIntUnaryOperator, SerializablePredicate}
import org.apache.wayang.core.optimizer.ProbabilisticDoubleInterval
import org.apache.wayang.core.optimizer.cardinality.CardinalityEstimator
@@ -281,6 +282,57 @@ trait DataQuantaBuilder[+This <: DataQuantaBuilder[_, Out], Out] extends Logging
thatKeyUdf: SerializableFunction[ThatOut, Key]) =
new JoinDataQuantaBuilder(this, that, thisKeyUdf, thatKeyUdf)
+ /**
+ * Feed the built [[DataQuanta]] into a spatial filter operator.
+ * Requires the wayang-spatial plugin to be loaded.
+ *
+ * @param keyUdf function to extract geometry from elements
+ * @param predicate the spatial predicate type
+ * @param filterGeometry the geometry to filter against
+ * @return a [[DataQuantaBuilder]] representing the filtered output
+ */
+ def spatialFilter(
+ keyUdf: SerializableFunction[Out, _ <: SpatialGeometry],
+ predicate: SpatialPredicate,
+ filterGeometry: SpatialGeometry
+ ): SpatialFilterDataQuantaBuilder[Out] =
+ new SpatialFilterDataQuantaBuilder(this, keyUdf, predicate, filterGeometry)
+
+ /**
+ * Feed the built [[DataQuanta]] into a spatial filter operator with SQL pushdown support.
+ *
+ * @param keyUdf function to extract geometry from elements
+ * @param predicate the spatial predicate type
+ * @param filterGeometry the geometry to filter against
+ * @param sqlGeometryColumn the name of the geometry column in the database for SQL pushdown
+ * @return a [[SpatialFilterDataQuantaBuilder]] representing the filtered output
+ */
+ def spatialFilter(
+ keyUdf: SerializableFunction[Out, _ <: SpatialGeometry],
+ predicate: SpatialPredicate,
+ filterGeometry: SpatialGeometry,
+ sqlGeometryColumn: String
+ ): SpatialFilterDataQuantaBuilder[Out] =
+ new SpatialFilterDataQuantaBuilder(this, keyUdf, predicate, filterGeometry)
+ .withSqlGeometryColumnName(sqlGeometryColumn)
+
+ /**
+ * Feed the built [[DataQuanta]] of this and the given instance into a spatial join operator.
+ *
+ * @param thisKeyUdf function to extract geometry from this instance's elements
+ * @param that the other [[DataQuantaBuilder]] to join with
+ * @param thatKeyUdf function to extract geometry from `that` instance's elements
+ * @param predicate the spatial predicate type
+ * @return a [[SpatialJoinDataQuantaBuilder]] representing the joined output as Tuple2
+ */
+ def spatialJoin[ThatOut](
+ thisKeyUdf: SerializableFunction[Out, _ <: SpatialGeometry],
+ that: DataQuantaBuilder[_, ThatOut],
+ thatKeyUdf: SerializableFunction[ThatOut, _ <: SpatialGeometry],
+ predicate: SpatialPredicate
+ ): SpatialJoinDataQuantaBuilder[Out, ThatOut] =
+ new SpatialJoinDataQuantaBuilder(this, that, thisKeyUdf, thatKeyUdf, predicate)
+
/**
* Feed the built [[DataQuanta]] of this and the given instance into a
* [[org.apache.wayang.basic.operators.DLTrainingOperator]].
@@ -510,12 +562,12 @@ trait DataQuantaBuilder[+This <: DataQuantaBuilder[_, Out], Out] extends Logging
* @param catalog Iceberg Catalog
* @param schema Iceberg Schema of the table to create
* @param tableIdentifier Iceberg Table Identifier of the table to create
- * @param outputFileFormat File format of the output data files
+ * @param outputFileFormat File format of the output data files
* @return the collected data quanta
*/
- def writeIcebergTable(catalog: Catalog,
- schema: Schema,
+ def writeIcebergTable(catalog: Catalog,
+ schema: Schema,
tableIdentifier: TableIdentifier,
outputFileFormat: FileFormat,
jobName: String): Unit = {
@@ -1929,3 +1981,41 @@ class KeyedDataQuantaBuilder[Out, Key](private val dataQuantaBuilder: DataQuanta
dataQuantaBuilder.coGroup(this.keyExtractor, that.dataQuantaBuilder, that.keyExtractor)
}
+
+class SpatialFilterDataQuantaBuilder[T](inputDataQuanta: DataQuantaBuilder[_, T],
+ keySelector: SerializableFunction[T, _ <: SpatialGeometry],
+ predicateType: SpatialPredicate,
+ filterGeometry: SpatialGeometry)
+ (implicit javaPlanBuilder: JavaPlanBuilder)
+ extends BasicDataQuantaBuilder[SpatialFilterDataQuantaBuilder[T], T] {
+
+ private var columnName: String = _
+
+ def withSqlGeometryColumnName(columnName: String): SpatialFilterDataQuantaBuilder[T] = {
+ this.columnName = columnName
+ this
+ }
+
+ override protected def build: DataQuanta[T] = {
+ val dq = inputDataQuanta.dataQuanta()
+ dq.spatialFilterJava(keySelector, predicateType, filterGeometry, this.columnName)
+ }
+}
+
+class SpatialJoinDataQuantaBuilder[In0, In1](inputDataQuanta0: DataQuantaBuilder[_, In0],
+ inputDataQuanta1: DataQuantaBuilder[_, In1],
+ keyUdf0: SerializableFunction[In0, _ <: SpatialGeometry],
+ keyUdf1: SerializableFunction[In1, _ <: SpatialGeometry],
+ predicateType: SpatialPredicate)
+ (implicit javaPlanBuilder: JavaPlanBuilder)
+ extends BasicDataQuantaBuilder[SpatialJoinDataQuantaBuilder[In0, In1], RT2[In0, In1]] {
+
+ override protected def build: DataQuanta[RT2[In0, In1]] = {
+ val dq0 = inputDataQuanta0.dataQuanta()
+ val dq1 = inputDataQuanta1.dataQuanta()
+ applyTargetPlatforms(
+ dq0.spatialJoinJava(keyUdf0, dq1, keyUdf1, predicateType)(inputDataQuanta1.classTag),
+ this.getTargetPlatforms()
+ )
+ }
+}
diff --git a/wayang-api/wayang-api-scala-java/src/test/java/org/apache/wayang/api/JavaApiTest.java b/wayang-api/wayang-api-scala-java/src/test/java/org/apache/wayang/api/JavaApiTest.java
index 9240f2d9c..4ade8bc26 100644
--- a/wayang-api/wayang-api-scala-java/src/test/java/org/apache/wayang/api/JavaApiTest.java
+++ b/wayang-api/wayang-api-scala-java/src/test/java/org/apache/wayang/api/JavaApiTest.java
@@ -113,6 +113,22 @@ void testMapReduceBy() {
assertEquals(WayangCollections.asSet(4 + 16, 1 + 9), WayangCollections.asSet(outputCollection));
}
+ @Test
+ void testFilter() {
+ WayangContext wayangContext = new WayangContext().with(Java.basicPlugin());
+ JavaPlanBuilder builder = new JavaPlanBuilder(wayangContext);
+
+ final List inputValues = Arrays.asList(1, 2, 3, 4, 5, 6);
+
+ final Collection outputValues = builder
+ .loadCollection(inputValues).withName("Load input values")
+ .filter(i -> (i & 1) == 0).withName("Filter even numbers")
+ .collect();
+
+ Set expectedValues = WayangCollections.asSet(2, 4, 6);
+ assertEquals(expectedValues, WayangCollections.asSet(outputValues));
+ }
+
@Test
void testBroadcast2() {
WayangContext wayangContext = new WayangContext().with(Java.basicPlugin());
diff --git a/wayang-api/wayang-api-sql/src/main/java/org/apache/wayang/api/sql/sources/fs/CsvRowConverter.java b/wayang-api/wayang-api-sql/src/main/java/org/apache/wayang/api/sql/sources/fs/CsvRowConverter.java
index 4d8682b1b..af490acb1 100755
--- a/wayang-api/wayang-api-sql/src/main/java/org/apache/wayang/api/sql/sources/fs/CsvRowConverter.java
+++ b/wayang-api/wayang-api-sql/src/main/java/org/apache/wayang/api/sql/sources/fs/CsvRowConverter.java
@@ -132,6 +132,7 @@ public static Object convert(RelDataType fieldType, String string) {
} catch (ParseException e) {
return null;
}
+ case GEOMETRY:
case VARCHAR:
default:
return string;
diff --git a/wayang-benchmark/pom.xml b/wayang-benchmark/pom.xml
index ffdb42af8..e37b99b38 100644
--- a/wayang-benchmark/pom.xml
+++ b/wayang-benchmark/pom.xml
@@ -54,6 +54,11 @@
wayang-postgres
1.1.2-SNAPSHOT
+
+ org.apache.wayang
+ wayang-spatial
+ 1.1.2-SNAPSHOT
+
org.apache.wayang
wayang-sqlite3
diff --git a/wayang-benchmark/src/main/java/org/apache/wayang/apps/spatial/SpatialFilter.java b/wayang-benchmark/src/main/java/org/apache/wayang/apps/spatial/SpatialFilter.java
new file mode 100644
index 000000000..65f678d3a
--- /dev/null
+++ b/wayang-benchmark/src/main/java/org/apache/wayang/apps/spatial/SpatialFilter.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.apps.spatial;
+
+import org.apache.wayang.api.JavaPlanBuilder;
+import org.apache.wayang.core.api.spatial.SpatialGeometry;
+import org.apache.wayang.spark.Spark;
+import org.apache.wayang.spatial.data.WayangGeometry;
+import org.apache.wayang.core.api.Configuration;
+import org.apache.wayang.core.api.WayangContext;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.java.Java;
+import org.apache.wayang.spatial.Spatial;
+
+import java.util.Arrays;
+import java.util.Collection;
+
+public class SpatialFilter {
+ public static void main(String[] args) {
+ System.out.println("Running Spatial Filter Benchmark with args " + Arrays.toString(args));
+
+ String fileUrl = args[1];
+ String platform = args[2];
+ String selectivity = args[3];
+
+ WayangContext wayangContext = new WayangContext(new Configuration())
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Spark.basicPlugin())
+ .withPlugin(Spatial.plugin());
+
+ JavaPlanBuilder planBuilder = new JavaPlanBuilder(wayangContext)
+ .withJobName("filter test")
+ .withUdfJarOf(SpatialFilter.class);
+
+ SpatialGeometry queryGeometry = WayangGeometry.fromStringInput(
+ "POLYGON((0.0 0.0, " + selectivity + " 0.0, " + selectivity + " " + selectivity + ", 0.0 " + selectivity + ", 0.0 0.0))"
+ );
+
+ Collection outputcount =
+ planBuilder.readTextFile(fileUrl)
+ .spatialFilter(
+ (input -> WayangGeometry.fromStringInput((input.split("\",")[0]).replace("\"", ""))),
+ SpatialPredicate.INTERSECTS,
+ queryGeometry
+ ).withTargetPlatform(
+ switch (platform) {
+ case "java" -> Java.platform();
+ case "spark" -> Spark.platform();
+ default -> Java.platform();
+ }
+ )
+ .count()
+ .collect();
+
+ System.out.println("Spatial Filter Result Size: " + outputcount);
+ }
+}
\ No newline at end of file
diff --git a/wayang-benchmark/src/main/java/org/apache/wayang/apps/spatial/SpatialFilterPostgis.java b/wayang-benchmark/src/main/java/org/apache/wayang/apps/spatial/SpatialFilterPostgis.java
new file mode 100644
index 000000000..fa3568f69
--- /dev/null
+++ b/wayang-benchmark/src/main/java/org/apache/wayang/apps/spatial/SpatialFilterPostgis.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.apps.spatial;
+
+import org.apache.wayang.api.JavaPlanBuilder;
+import org.apache.wayang.core.api.Configuration;
+import org.apache.wayang.core.api.WayangContext;
+import org.apache.wayang.core.api.spatial.SpatialGeometry;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.java.Java;
+import org.apache.wayang.postgres.Postgres;
+import org.apache.wayang.postgres.operators.PostgresTableSource;
+import org.apache.wayang.spatial.Spatial;
+import org.apache.wayang.spatial.data.WayangGeometry;
+
+import java.util.Arrays;
+import java.util.Collection;
+
+public class SpatialFilterPostgis {
+ public static void main(String[] args) {
+ System.out.println("Running Spatial Filter Benchmark with args " + Arrays.toString(args) + " on Postgres");
+
+ Configuration configuration = new Configuration();
+
+ String tableName = args[1];
+ String node_name = args[2];
+ String database_name = args[3];
+ String selectivity = args[4];
+
+ configuration.setProperty("wayang.postgres.jdbc.url", "jdbc:postgresql://" + node_name + ":5432/" + database_name); // Default port 5432
+ configuration.setProperty("wayang.postgres.jdbc.user", "wayang_user");
+ configuration.setProperty("wayang.postgres.jdbc.password", "wayang");
+
+ WayangContext wayangContext = new WayangContext(configuration)
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Postgres.plugin())
+ .withPlugin(Spatial.plugin());
+
+ JavaPlanBuilder builder = new JavaPlanBuilder(wayangContext);
+
+ SpatialGeometry queryGeometry = WayangGeometry.fromStringInput(
+ "POLYGON((0.0 0.0, " + selectivity + " 0.0, " + selectivity + " " + selectivity + ", 0.0 " + selectivity + ", 0.0 0.0))"
+ );
+
+ final Collection outputcount = builder
+ .readTable(new PostgresTableSource(tableName, "ST_AsText(geom)"))
+ .spatialFilter(
+ (input -> WayangGeometry.fromStringInput(input.getString(0))),
+ SpatialPredicate.INTERSECTS,
+ queryGeometry
+ ).withSqlGeometryColumnName("geom")
+ .withTargetPlatform(Postgres.platform())
+ .count()
+ .collect();
+
+ System.out.println("Spatial Filter Postgres Result Size: " + outputcount);
+ }
+}
diff --git a/wayang-benchmark/src/main/java/org/apache/wayang/apps/spatial/SpatialJoin.java b/wayang-benchmark/src/main/java/org/apache/wayang/apps/spatial/SpatialJoin.java
new file mode 100644
index 000000000..23a92ebdd
--- /dev/null
+++ b/wayang-benchmark/src/main/java/org/apache/wayang/apps/spatial/SpatialJoin.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.apps.spatial;
+
+import org.apache.wayang.api.DataQuantaBuilder;
+import org.apache.wayang.api.JavaPlanBuilder;
+import org.apache.wayang.api.UnarySourceDataQuantaBuilder;
+import org.apache.wayang.core.api.Configuration;
+import org.apache.wayang.core.api.WayangContext;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.java.Java;
+import org.apache.wayang.spark.Spark;
+import org.apache.wayang.spatial.Spatial;
+import org.apache.wayang.spatial.data.WayangGeometry;
+
+import java.util.Arrays;
+import java.util.Collection;
+
+public class SpatialJoin {
+
+ public static void main(String[] args) {
+ System.out.println("Running Spatial Join Benchmark with args " + Arrays.toString(args));
+
+ WayangContext wayangContext = new WayangContext(new Configuration())
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Spark.basicPlugin())
+ .withPlugin(Spatial.plugin());
+
+ JavaPlanBuilder planBuilder = new JavaPlanBuilder(wayangContext);
+
+ String file1Url = args[1];
+ String file2Url = args[2];
+ String platform = args[3];
+ DataQuantaBuilder, String> table1 = planBuilder.readTextFile(file1Url);
+ DataQuantaBuilder, String> table2 = planBuilder.readTextFile(file2Url);
+
+ Collection outputcount = table1
+ .spatialJoin(
+ WayangGeometry::fromStringInput,
+ table2,
+ WayangGeometry::fromStringInput,
+ SpatialPredicate.INTERSECTS
+ ).withTargetPlatform(
+ switch (platform) {
+ case "java" -> Java.platform();
+ case "spark" -> Spark.platform();
+ default -> Java.platform();
+ })
+ .count()
+ .collect();
+ System.out.println("Spatial Join Result Size: " + outputcount);
+ }
+}
diff --git a/wayang-benchmark/src/main/java/org/apache/wayang/apps/spatial/SpatialJoinPostgis.java b/wayang-benchmark/src/main/java/org/apache/wayang/apps/spatial/SpatialJoinPostgis.java
new file mode 100644
index 000000000..b892efc73
--- /dev/null
+++ b/wayang-benchmark/src/main/java/org/apache/wayang/apps/spatial/SpatialJoinPostgis.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.apps.spatial;
+
+import org.apache.wayang.basic.operators.SpatialJoinOperator;
+import org.apache.wayang.basic.operators.TableSource;
+import org.apache.wayang.core.api.Configuration;
+import org.apache.wayang.core.api.WayangContext;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.java.Java;
+import org.apache.wayang.core.plan.wayangplan.WayangPlan;
+import org.apache.wayang.postgres.Postgres;
+import org.apache.wayang.postgres.operators.PostgresTableSource;
+import org.apache.wayang.spark.Spark;
+import org.apache.wayang.spatial.Spatial;
+import org.apache.wayang.spatial.data.WayangGeometry;
+import org.apache.wayang.basic.data.Tuple2;
+import org.apache.wayang.basic.operators.*;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.basic.data.Record;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+
+public class SpatialJoinPostgis {
+ public static void main(String[] args) {
+ System.out.println("Running Spatial Join Benchmark with args " + Arrays.toString(args) + " on Postgres");
+
+ Configuration configuration = new Configuration();
+
+ String tableName1 = args[1];
+ String tableName2 = args[2];
+ String node_name = args[3];
+ String database_name = args[4];
+ String platform = args[5];
+
+ configuration.setProperty("wayang.postgres.jdbc.url", "jdbc:postgresql://" + node_name + ":5432/" + database_name);
+ configuration.setProperty("wayang.postgres.jdbc.user", "wayang_user");
+ configuration.setProperty("wayang.postgres.jdbc.password", "wayang");
+
+ WayangContext wayangContext = new WayangContext(configuration)
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Postgres.plugin())
+ .withPlugin(Spark.basicPlugin())
+ .withPlugin(Spatial.plugin());
+
+ TableSource table1 = new PostgresTableSource(tableName1, "ST_AsText(geom)");
+ TableSource table2 = new PostgresTableSource(tableName2, "ST_AsText(geom)");
+
+ SpatialJoinOperator spatialJoin = new SpatialJoinOperator<>(
+ (record -> WayangGeometry.fromStringInput(record.getString(0))),
+ (record -> WayangGeometry.fromStringInput(record.getString(0))),
+ Record.class, Record.class,
+ SpatialPredicate.INTERSECTS
+ );
+
+ spatialJoin.getKeyDescriptor0().withSqlImplementation(tableName1, "geom");
+ spatialJoin.getKeyDescriptor1().withSqlImplementation(tableName2, "geom");
+ spatialJoin.addTargetPlatform(switch (platform) {
+ case "java" -> Java.platform();
+ case "spark" -> Spark.platform();
+ default -> Postgres.platform();
+ });
+
+ table1.connectTo(0, spatialJoin, 0);
+ table2.connectTo(0, spatialJoin, 1);
+
+ CountOperator> count = new CountOperator<>(
+ DataSetType.createDefaultUnchecked(Tuple2.class)
+ );
+ spatialJoin.connectTo(0, count, 0);
+
+ Collection outputcount = new ArrayList<>();
+ LocalCallbackSink sink = LocalCallbackSink.createCollectingSink(
+ outputcount,
+ DataSetType.createDefaultUnchecked(Long.class)
+ );
+
+ count.connectTo(0, sink, 0);
+
+ wayangContext.execute("Benchmark spatial_join", new WayangPlan(sink));
+
+ System.out.println("Spatial Join Postgres Result Size: " + outputcount);
+ }
+}
diff --git a/wayang-commons/wayang-basic/pom.xml b/wayang-commons/wayang-basic/pom.xml
index 70de0debc..e76ca2408 100644
--- a/wayang-commons/wayang-basic/pom.xml
+++ b/wayang-commons/wayang-basic/pom.xml
@@ -175,7 +175,7 @@
slf4j-simple
2.0.16
-
+
diff --git a/wayang-commons/wayang-basic/src/main/java/org/apache/wayang/basic/operators/GeoJsonFileSource.java b/wayang-commons/wayang-basic/src/main/java/org/apache/wayang/basic/operators/GeoJsonFileSource.java
new file mode 100644
index 000000000..0f47d4b5e
--- /dev/null
+++ b/wayang-commons/wayang-basic/src/main/java/org/apache/wayang/basic/operators/GeoJsonFileSource.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.basic.operators;
+
+import org.apache.wayang.basic.data.Record;
+import org.apache.wayang.core.plan.wayangplan.UnarySource;
+import org.apache.wayang.core.types.DataSetType;
+
+/**
+ * Logical operator representing a GeoJSON file source producing {@link Record} elements.
+ */
+public class GeoJsonFileSource extends UnarySource {
+
+ private final String inputUrl;
+
+ public GeoJsonFileSource(String inputUrl) {
+ super(DataSetType.createDefault(Record.class));
+ this.inputUrl = inputUrl;
+ }
+
+ public GeoJsonFileSource(GeoJsonFileSource that) {
+ super(that);
+ this.inputUrl = that.getInputUrl();
+ }
+
+ public String getInputUrl() {
+ return inputUrl;
+ }
+}
diff --git a/wayang-commons/wayang-basic/src/main/java/org/apache/wayang/basic/operators/SpatialFilterOperator.java b/wayang-commons/wayang-basic/src/main/java/org/apache/wayang/basic/operators/SpatialFilterOperator.java
new file mode 100644
index 000000000..ca54ff576
--- /dev/null
+++ b/wayang-commons/wayang-basic/src/main/java/org/apache/wayang/basic/operators/SpatialFilterOperator.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.basic.operators;
+
+import org.apache.wayang.core.api.spatial.SpatialGeometry;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.core.function.FunctionDescriptor;
+import org.apache.wayang.core.function.TransformationDescriptor;
+import org.apache.wayang.core.optimizer.OptimizationContext;
+import org.apache.wayang.core.optimizer.cardinality.CardinalityEstimate;
+import org.apache.wayang.core.plan.wayangplan.UnaryToUnaryOperator;
+import org.apache.wayang.core.types.DataSetType;
+
+
+/**
+ * This operator returns a new dataset after filtering by applying a spatial predicate.
+ */
+public class SpatialFilterOperator extends UnaryToUnaryOperator {
+
+ protected final SpatialPredicate predicateType;
+ protected final TransformationDescriptor keyDescriptor;
+ protected final SpatialGeometry referenceGeometry;
+
+ @SuppressWarnings("unchecked")
+ public SpatialFilterOperator(SpatialPredicate predicateType,
+ FunctionDescriptor.SerializableFunction keyExtractor,
+ DataSetType inputClassDatasetType,
+ SpatialGeometry geometry) {
+ super(inputClassDatasetType, inputClassDatasetType, true);
+ this.predicateType = predicateType;
+ this.keyDescriptor = new TransformationDescriptor<>(
+ (FunctionDescriptor.SerializableFunction) (FunctionDescriptor.SerializableFunction) keyExtractor,
+ inputClassDatasetType.getDataUnitType().getTypeClass(), SpatialGeometry.class);
+ this.referenceGeometry = geometry;
+ }
+
+ /**
+ * Copies an instance (exclusive of broadcasts).
+ *
+ * @param that that should be copied
+ */
+ public SpatialFilterOperator(SpatialFilterOperator that) {
+ super(that);
+ this.predicateType = that.predicateType;
+ this.keyDescriptor = that.keyDescriptor;
+ this.referenceGeometry = that.referenceGeometry;
+ }
+
+ public SpatialPredicate getPredicateType() {
+ return this.predicateType;
+ }
+
+ public SpatialGeometry getReferenceGeometry() {
+ return this.referenceGeometry;
+ }
+
+ public TransformationDescriptor getKeyDescriptor() {
+ return this.keyDescriptor;
+ }
+
+ /**
+ * Custom {@link org.apache.wayang.core.optimizer.cardinality.CardinalityEstimator} for {@link SpatialFilterOperator}s.
+ */
+ private class CardinalityEstimator implements org.apache.wayang.core.optimizer.cardinality.CardinalityEstimator {
+
+ @Override
+ public CardinalityEstimate estimate(OptimizationContext optimizationContext, CardinalityEstimate... inputEstimates) {
+ return new CardinalityEstimate(10, 800, 0.9);
+ }
+ }
+}
diff --git a/wayang-commons/wayang-basic/src/main/java/org/apache/wayang/basic/operators/SpatialJoinOperator.java b/wayang-commons/wayang-basic/src/main/java/org/apache/wayang/basic/operators/SpatialJoinOperator.java
new file mode 100644
index 000000000..4d0e2f4b5
--- /dev/null
+++ b/wayang-commons/wayang-basic/src/main/java/org/apache/wayang/basic/operators/SpatialJoinOperator.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.basic.operators;
+
+import org.apache.wayang.basic.data.Tuple2;
+import org.apache.wayang.core.api.spatial.SpatialGeometry;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.core.function.FunctionDescriptor;
+import org.apache.wayang.core.function.TransformationDescriptor;
+import org.apache.wayang.core.optimizer.OptimizationContext;
+import org.apache.wayang.core.optimizer.cardinality.CardinalityEstimate;
+import org.apache.wayang.core.plan.wayangplan.BinaryToUnaryOperator;
+import org.apache.wayang.core.types.DataSetType;
+
+/**
+ * This operator returns a new dataset after joining the input tables using the predicate.
+ */
+public class SpatialJoinOperator extends BinaryToUnaryOperator> {
+
+ private static DataSetType> createOutputDataSetType() {
+ return DataSetType.createDefaultUnchecked(Tuple2.class);
+ }
+
+ protected final TransformationDescriptor keyDescriptor0;
+
+ protected final TransformationDescriptor keyDescriptor1;
+
+ protected final SpatialPredicate predicateType;
+
+ public SpatialJoinOperator(TransformationDescriptor keyDescriptor0,
+ TransformationDescriptor keyDescriptor1,
+ SpatialPredicate predicateType) {
+ super(DataSetType.createDefault(keyDescriptor0.getInputType()),
+ DataSetType.createDefault(keyDescriptor1.getInputType()),
+ SpatialJoinOperator.createOutputDataSetType(),
+ true);
+ this.keyDescriptor0 = keyDescriptor0;
+ this.keyDescriptor1 = keyDescriptor1;
+ this.predicateType = predicateType;
+ }
+
+ public SpatialJoinOperator(TransformationDescriptor keyDescriptor0,
+ TransformationDescriptor keyDescriptor1,
+ DataSetType inputType0,
+ DataSetType inputType1,
+ SpatialPredicate predicateType) {
+ super(inputType0, inputType1, SpatialJoinOperator.createOutputDataSetType(), true);
+ this.keyDescriptor0 = keyDescriptor0;
+ this.keyDescriptor1 = keyDescriptor1;
+ this.predicateType = predicateType;
+ }
+
+ public SpatialJoinOperator(SpatialJoinOperator that) {
+ super(that);
+ this.keyDescriptor0 = that.keyDescriptor0;
+ this.keyDescriptor1 = that.keyDescriptor1;
+ this.predicateType = that.predicateType;
+ }
+
+ @SuppressWarnings("unchecked")
+ public SpatialJoinOperator(
+ FunctionDescriptor.SerializableFunction keyExtractor0,
+ FunctionDescriptor.SerializableFunction keyExtractor1,
+ Class input0Class,
+ Class input1Class,
+ SpatialPredicate predicateType) {
+ this(
+ new TransformationDescriptor<>(
+ (FunctionDescriptor.SerializableFunction) keyExtractor0,
+ input0Class, SpatialGeometry.class),
+ new TransformationDescriptor<>(
+ (FunctionDescriptor.SerializableFunction) keyExtractor1,
+ input1Class, SpatialGeometry.class),
+ predicateType
+ );
+ }
+
+ public TransformationDescriptor getKeyDescriptor0() {
+ return this.keyDescriptor0;
+ }
+
+ public TransformationDescriptor getKeyDescriptor1() {
+ return this.keyDescriptor1;
+ }
+
+ public SpatialPredicate getPredicateType() {
+ return this.predicateType;
+ }
+
+ private class CardinalityEstimator implements org.apache.wayang.core.optimizer.cardinality.CardinalityEstimator {
+
+ @Override
+ public CardinalityEstimate estimate(OptimizationContext optimizationContext, CardinalityEstimate... inputEstimates) {
+ return new CardinalityEstimate(10, 800, 0.9);
+ }
+ }
+}
diff --git a/wayang-commons/wayang-core/src/main/java/org/apache/wayang/core/api/spatial/SpatialGeometry.java b/wayang-commons/wayang-core/src/main/java/org/apache/wayang/core/api/spatial/SpatialGeometry.java
new file mode 100644
index 000000000..f7141d59f
--- /dev/null
+++ b/wayang-commons/wayang-core/src/main/java/org/apache/wayang/core/api/spatial/SpatialGeometry.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.core.api.spatial;
+
+import java.io.Serializable;
+
+/**
+ * Abstract geometry interface for spatial operations.
+ * Implementations (e.g., WayangGeometry) provide JTS-backed functionality.
+ */
+public interface SpatialGeometry extends Serializable {
+
+ /**
+ * Returns Well-Known Text (WKT) representation of this geometry.
+ *
+ * @return WKT string
+ */
+ String toWKT();
+
+ /**
+ * Returns Well-Known Binary (WKB) representation of this geometry as hex string.
+ *
+ * @return WKB hex string
+ */
+ String toWKB();
+}
diff --git a/wayang-commons/wayang-core/src/main/java/org/apache/wayang/core/api/spatial/SpatialPredicate.java b/wayang-commons/wayang-core/src/main/java/org/apache/wayang/core/api/spatial/SpatialPredicate.java
new file mode 100644
index 000000000..eabaa3def
--- /dev/null
+++ b/wayang-commons/wayang-core/src/main/java/org/apache/wayang/core/api/spatial/SpatialPredicate.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.core.api.spatial;
+
+/**
+ * Spatial relationship predicates for filtering and joining.
+ */
+public enum SpatialPredicate {
+ INTERSECTS,
+ CONTAINS,
+ WITHIN,
+ OVERLAPS,
+ TOUCHES,
+ CROSSES,
+ EQUALS
+}
diff --git a/wayang-platforms/wayang-jdbc-template/src/main/java/org/apache/wayang/jdbc/execution/JdbcExecutor.java b/wayang-platforms/wayang-jdbc-template/src/main/java/org/apache/wayang/jdbc/execution/JdbcExecutor.java
index f7a9d7c5a..9f3e7bbe9 100644
--- a/wayang-platforms/wayang-jdbc-template/src/main/java/org/apache/wayang/jdbc/execution/JdbcExecutor.java
+++ b/wayang-platforms/wayang-jdbc-template/src/main/java/org/apache/wayang/jdbc/execution/JdbcExecutor.java
@@ -20,6 +20,10 @@
import org.apache.wayang.basic.channels.FileChannel;
import org.apache.wayang.basic.data.Tuple2;
+import org.apache.wayang.basic.operators.SpatialFilterOperator;
+import org.apache.wayang.basic.operators.SpatialJoinOperator;
+import org.apache.wayang.basic.operators.FilterOperator;
+import org.apache.wayang.basic.operators.JoinOperator;
import org.apache.wayang.basic.operators.TableSource;
import org.apache.wayang.core.api.Job;
import org.apache.wayang.core.api.exception.WayangException;
@@ -170,21 +174,22 @@ protected static Tuple2 createSqlQuery(final E
// Extract the different types of ExecutionOperators from the stage.
final JdbcTableSource tableOp = (JdbcTableSource) startTask.getOperator();
SqlQueryChannel.Instance tipChannelInstance = JdbcExecutor.instantiateOutboundChannel(startTask, context, jdbcExecutor);
- final Collection filterTasks = new ArrayList<>(4);
+ final Collection filterTasks = new ArrayList<>(4);
JdbcProjectionOperator projectionTask = null;
- final Collection> joinTasks = new ArrayList<>();
+ final Collection joinTasks = new ArrayList<>();
final Set allTasks = stage.getAllTasks();
assert allTasks.size() <= 3;
ExecutionTask nextTask = JdbcExecutor.findJdbcExecutionOperatorTaskInStage(startTask, stage);
while (nextTask != null) {
// Evaluate the nextTask.
- if (nextTask.getOperator() instanceof final JdbcFilterOperator filterOperator) {
- filterTasks.add(filterOperator);
- } else if (nextTask.getOperator() instanceof JdbcProjectionOperator projectionOperator) {
+ final var operator = nextTask.getOperator();
+ if (operator instanceof FilterOperator || operator instanceof SpatialFilterOperator) {
+ filterTasks.add((JdbcExecutionOperator) operator);
+ } else if (operator instanceof JdbcProjectionOperator) {
assert projectionTask == null; // Allow one projection operator per stage for now.
- projectionTask = projectionOperator;
- } else if (nextTask.getOperator() instanceof JdbcJoinOperator joinOperator) {
- joinTasks.add(joinOperator);
+ projectionTask = (JdbcProjectionOperator) operator;
+ } else if (operator instanceof JoinOperator || (operator instanceof SpatialJoinOperator)) {
+ joinTasks.add((JdbcExecutionOperator) operator);
} else {
throw new WayangException(String.format("Unsupported JDBC execution task %s", nextTask.toString()));
}
@@ -202,8 +207,9 @@ protected static Tuple2 createSqlQuery(final E
}
public static StringBuilder createSqlString(final JdbcExecutor jdbcExecutor, final JdbcTableSource tableOp,
- final Collection filterTasks, JdbcProjectionOperator projectionTask,
- final Collection> joinTasks) {
+ final Collection filterTasks,
+ JdbcProjectionOperator projectionTask,
+ final Collection joinTasks) {
final String tableName = tableOp.createSqlClause(jdbcExecutor.connection, jdbcExecutor.functionCompiler);
final Collection conditions = filterTasks.stream()
.map(op -> op.createSqlClause(jdbcExecutor.connection, jdbcExecutor.functionCompiler))
diff --git a/wayang-platforms/wayang-spark/src/main/java/org/apache/wayang/spark/platform/SparkPlatform.java b/wayang-platforms/wayang-spark/src/main/java/org/apache/wayang/spark/platform/SparkPlatform.java
index 77fbcc1b5..de3fb612d 100644
--- a/wayang-platforms/wayang-spark/src/main/java/org/apache/wayang/spark/platform/SparkPlatform.java
+++ b/wayang-platforms/wayang-spark/src/main/java/org/apache/wayang/spark/platform/SparkPlatform.java
@@ -161,6 +161,7 @@ public SparkContextReference getSparkContext(Job job) {
this.registerJarIfNotNull(ReflectionUtils.getDeclaringJar(SparkPlatform.class)); // wayang-spark
this.registerJarIfNotNull(ReflectionUtils.getDeclaringJar(WayangBasic.class)); // wayang-basic
this.registerJarIfNotNull(ReflectionUtils.getDeclaringJar(WayangContext.class)); // wayang-core
+
final Set udfJarPaths = job.getUdfJarPaths();
if (udfJarPaths.isEmpty()) {
this.logger.warn("Non-local SparkContext but not UDF JARs have been declared.");
diff --git a/wayang-plugins/pom.xml b/wayang-plugins/pom.xml
index 6c6e597b3..1d11b2da0 100644
--- a/wayang-plugins/pom.xml
+++ b/wayang-plugins/pom.xml
@@ -38,6 +38,7 @@
wayang-iejoin
+ wayang-spatial
diff --git a/wayang-plugins/wayang-spatial/pom.xml b/wayang-plugins/wayang-spatial/pom.xml
new file mode 100644
index 000000000..3f959dcdc
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/pom.xml
@@ -0,0 +1,180 @@
+
+
+
+
+ 4.0.0
+
+
+ wayang-plugins
+ org.apache.wayang
+ 1.1.2-SNAPSHOT
+
+
+ wayang-spatial
+ 1.1.2-SNAPSHOT
+
+
+ org.apache.wayang.extensions.spatial
+ 1.19.0
+
+
+
+
+
+ org.apache.wayang
+ wayang-core
+ 1.1.2-SNAPSHOT
+
+
+ org.apache.wayang
+ wayang-basic
+ 1.1.2-SNAPSHOT
+
+
+ org.apache.wayang
+ wayang-java
+ 1.1.2-SNAPSHOT
+
+
+ org.apache.wayang
+ wayang-spark
+ 1.1.2-SNAPSHOT
+
+
+ org.apache.wayang
+ wayang-jdbc-template
+ 1.1.2-SNAPSHOT
+
+
+ org.apache.wayang
+ wayang-postgres
+ 1.1.2-SNAPSHOT
+
+
+
+
+ org.apache.wayang
+ wayang-api-scala-java
+ 1.1.2-SNAPSHOT
+
+
+
+
+ org.scala-lang
+ scala-library
+ ${scala.version}
+
+
+
+
+ org.locationtech.jts
+ jts-core
+ 1.19.0
+ test
+
+
+ org.locationtech.jts.io
+ jts-io-common
+ ${jts.version}
+
+
+
+
+ org.apache.sedona
+ sedona-spark-shaded-3.4_2.12
+ ${sedona.version}
+
+
+
+
+ org.apache.spark
+ spark-core_2.12
+ ${spark.version}
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-core
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson.version}
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.mockito
+ mockito-core
+ ${mockito.version}
+ test
+
+
+ org.hsqldb
+ hsqldb
+ 2.7.1
+ test
+
+
+
+
+
+
+
+
+ net.alchim31.maven
+ scala-maven-plugin
+ 4.9.5
+
+
+ compile-scala
+ process-resources
+
+ add-source
+ compile
+
+
+ ${scala.version}
+ ${project.build.sourceDirectory}/../scala
+
+
+
+ test-compile-scala
+ process-test-resources
+
+ testCompile
+
+
+ ${scala.version}
+ ${project.build.testSourceDirectory}/../scala
+
+
+
+
+
+
+
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/Spatial.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/Spatial.java
new file mode 100644
index 000000000..6fc97554e
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/Spatial.java
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial;
+
+import org.apache.wayang.basic.operators.GeoJsonFileSource;
+import org.apache.wayang.basic.operators.SpatialFilterOperator;
+import org.apache.wayang.basic.operators.SpatialJoinOperator;
+import org.apache.wayang.core.api.Configuration;
+import org.apache.wayang.core.mapping.Mapping;
+import org.apache.wayang.core.optimizer.channels.ChannelConversion;
+import org.apache.wayang.core.platform.Platform;
+import org.apache.wayang.core.plugin.Plugin;
+import org.apache.wayang.spatial.mapping.Mappings;
+import org.apache.wayang.java.Java;
+import org.apache.wayang.java.platform.JavaPlatform;
+import org.apache.wayang.spark.Spark;
+import org.apache.wayang.spark.platform.SparkPlatform;
+import org.apache.wayang.postgres.Postgres;
+import org.apache.wayang.postgres.platform.PostgresPlatform;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+
+/**
+ * Provides {@link Plugin}s that enable usage of the {@link SpatialFilterOperator}, {@link SpatialJoinOperator},
+ * and {@link GeoJsonFileSource}.
+ */
+public class Spatial {
+
+ /**
+ * Enables use with the {@link JavaPlatform}, {@link SparkPlatform}, and {@link PostgresPlatform}.
+ */
+ private static final Plugin PLUGIN = new Plugin() {
+
+ @Override
+ public Collection getRequiredPlatforms() {
+ return Arrays.asList(Java.platform(), Spark.platform(), Postgres.platform());
+ }
+
+ @Override
+ public Collection getMappings() {
+ Collection mappings = new ArrayList<>();
+ mappings.addAll(Mappings.javaMappings);
+ mappings.addAll(Mappings.sparkMappings);
+ mappings.addAll(Mappings.postgresMappings);
+ return mappings;
+ }
+
+ @Override
+ public Collection getChannelConversions() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public void setProperties(Configuration configuration) {
+ }
+ };
+
+ /**
+ * Retrieve a {@link Plugin} to use spatial operators on the
+ * {@link JavaPlatform}, {@link SparkPlatform}, and {@link PostgresPlatform}.
+ *
+ * @return the {@link Plugin}
+ */
+ public static Plugin plugin() {
+ return PLUGIN;
+ }
+
+ /**
+ * Enables use with the {@link JavaPlatform}.
+ */
+ private static final Plugin JAVA_PLUGIN = new Plugin() {
+
+ @Override
+ public Collection getRequiredPlatforms() {
+ return Collections.singleton(Java.platform());
+ }
+
+ @Override
+ public Collection getMappings() {
+ return Mappings.javaMappings;
+ }
+
+ @Override
+ public Collection getChannelConversions() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public void setProperties(Configuration configuration) {
+ }
+ };
+
+ /**
+ * Retrieve a {@link Plugin} to use spatial operators on the {@link JavaPlatform}.
+ *
+ * @return the {@link Plugin}
+ */
+ public static Plugin javaPlugin() {
+ return JAVA_PLUGIN;
+ }
+
+ /**
+ * Enables use with the {@link SparkPlatform}.
+ */
+ public static final Plugin SPARK_PLUGIN = new Plugin() {
+
+ @Override
+ public Collection getRequiredPlatforms() {
+ return Collections.singleton(Spark.platform());
+ }
+
+ @Override
+ public Collection getMappings() {
+ return Mappings.sparkMappings;
+ }
+
+ @Override
+ public Collection getChannelConversions() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public void setProperties(Configuration configuration) {
+ }
+ };
+
+ /**
+ * Retrieve a {@link Plugin} to use spatial operators on the {@link SparkPlatform}.
+ *
+ * @return the {@link Plugin}
+ */
+ public static Plugin sparkPlugin() {
+ return SPARK_PLUGIN;
+ }
+
+ /**
+ * Enables use with the {@link PostgresPlatform}.
+ */
+ public static final Plugin POSTGRES_PLUGIN = new Plugin() {
+
+ @Override
+ public Collection getRequiredPlatforms() {
+ return Collections.singleton(Postgres.platform());
+ }
+
+ @Override
+ public Collection getMappings() {
+ return Mappings.postgresMappings;
+ }
+
+ @Override
+ public Collection getChannelConversions() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public void setProperties(Configuration configuration) {
+ }
+ };
+
+ /**
+ * Retrieve a {@link Plugin} to use spatial operators on the {@link PostgresPlatform}.
+ *
+ * @return the {@link Plugin}
+ */
+ public static Plugin postgresPlugin() {
+ return POSTGRES_PLUGIN;
+ }
+
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/data/WayangGeometry.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/data/WayangGeometry.java
new file mode 100644
index 000000000..e08682462
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/data/WayangGeometry.java
@@ -0,0 +1,271 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.data;
+
+import org.apache.wayang.core.api.spatial.SpatialGeometry;
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.locationtech.jts.io.*;
+import org.locationtech.jts.io.geojson.GeoJsonReader;
+import org.locationtech.jts.io.geojson.GeoJsonWriter;
+
+import java.util.HashMap;
+
+public class WayangGeometry implements SpatialGeometry {
+
+ private final HashMap data;
+
+ public WayangGeometry() {
+ this.data = new HashMap<>();
+ }
+
+ /**
+ * Backwards-compatible constructor, treats input as WKT.
+ */
+ public WayangGeometry(String wkt) {
+ this();
+ this.data.put("wkt", wkt);
+ }
+ /**
+ * Create WayangGeometry from string input.
+ * Detects WKT, WKB-hex, or GeoJSON and stores only that
+ * representation initially. Other conversions are done lazily.
+ *
+ * @param input geometry string (WKT / WKB-hex / GeoJSON)
+ * @return WayangGeometry instance
+ */
+ public static WayangGeometry fromStringInput(String input) {
+ String trimmed = input.trim();
+ WayangGeometry wg = new WayangGeometry();
+
+ if (wg.looksLikeWKT(trimmed)) {
+ wg.data.put("wkt", trimmed);
+ } else if (wg.looksLikeGeoJSON(trimmed)) {
+ wg.data.put("geojson", trimmed);
+ } else {
+ // Assume WKB hex string
+ wg.data.put("wkb", trimmed);
+ }
+
+ return wg;
+ }
+
+ /**
+ * Create WayangGeometry from an existing JTS Geometry object.
+ * The geometry is stored, and all other formats (WKT/WKB/GeoJSON)
+ * are generated lazily when their getters are called.
+ *
+ * @param geometry JTS Geometry instance
+ * @return WayangGeometry wrapper
+ */
+ public static WayangGeometry fromGeometry(Geometry geometry) {
+ if (geometry == null) {
+ throw new IllegalArgumentException("Geometry must not be null.");
+ }
+ WayangGeometry wg = new WayangGeometry();
+ wg.data.put("geometry", geometry);
+ return wg;
+ }
+
+ public static WayangGeometry fromGeoJson(String geoJson) {
+ WayangGeometry wg = new WayangGeometry();
+ wg.data.put("geojson", geoJson);
+ // could directly create the respective geometry with jts
+ return wg;
+ }
+
+ /**
+ * Get the geometry as WKT. If WKT is not yet available, it is
+ * generated from another stored representation and cached.
+ *
+ * @return WKT string
+ */
+ @Override
+ public String toWKT() {
+ return getWKT();
+ }
+
+ /**
+ * Get the geometry as WKB hex string. If WKB is not yet available,
+ * it is generated from another stored representation and cached.
+ *
+ * @return WKB hex string
+ */
+ @Override
+ public String toWKB() {
+ return getWKB();
+ }
+
+ /**
+ * Get the geometry as WKT. If WKT is not yet available, it is
+ * generated from another stored representation and cached.
+ *
+ * @return WKT string
+ */
+ public String getWKT() {
+ Object wktObj = this.data.get("wkt");
+ if (wktObj != null) {
+ return wktObj.toString();
+ }
+
+ Geometry geometry = getGeometry();
+ WKTWriter writer = new WKTWriter();
+ String wkt = writer.write(geometry);
+ this.data.put("wkt", wkt);
+ return wkt;
+ }
+
+ /**
+ * Get the geometry as WKB hex string. If WKB is not yet available,
+ * it is generated from another stored representation and cached.
+ *
+ * @return WKB hex string
+ */
+ public String getWKB() {
+ Object wkbObj = this.data.get("wkb");
+ if (wkbObj != null) {
+ return wkbObj.toString();
+ }
+
+ Geometry geometry = getGeometry();
+ WKBWriter writer = new WKBWriter();
+ byte[] wkbBytes = writer.write(geometry);
+ String wkbHex = WKBWriter.toHex(wkbBytes);
+ this.data.put("wkb", wkbHex);
+ return wkbHex;
+ }
+
+ /**
+ * Get the geometry as GeoJSON string. If GeoJSON is not yet
+ * available, it is generated from another stored representation
+ * and cached.
+ *
+ * @return GeoJSON string
+ */
+ public String getGeoJSON() {
+ Object geoJsonObj = this.data.get("geojson");
+ if (geoJsonObj != null) {
+ return geoJsonObj.toString();
+ }
+
+ Geometry geometry = getGeometry();
+ GeoJsonWriter writer = new GeoJsonWriter();
+ String geoJson = writer.write(geometry);
+ this.data.put("geojson", geoJson);
+ return geoJson;
+ }
+
+ /**
+ * Convert one of the stored geometry representations (WKT, WKB-hex,
+ * or GeoJSON) into a JTS Geometry object.
+ *
+ * The first available representation is used in this order:
+ * WKT -> WKB-hex -> GeoJSON
+ *
+ * The resulting Geometry is cached in the data map under "geometry".
+ *
+ * @return JTS Geometry instance
+ */
+ public Geometry getGeometry() {
+ Object geomObj = this.data.get("geometry");
+ if (geomObj instanceof Geometry) {
+ return (Geometry) geomObj;
+ }
+
+ GeometryFactory gf = new GeometryFactory();
+ Geometry geometry;
+
+ try {
+ if (this.data.containsKey("wkt")) {
+ String wkt = cleanSRID(this.data.get("wkt").toString().trim());
+ WKTReader reader = new WKTReader(gf);
+ geometry = reader.read(wkt);
+
+ } else if (this.data.containsKey("wkb")) {
+ String wkbHex = this.data.get("wkb").toString().trim();
+ byte[] wkbBytes = WKBReader.hexToBytes(wkbHex);
+ WKBReader reader = new WKBReader(gf);
+ geometry = reader.read(wkbBytes);
+
+ } else if (this.data.containsKey("geojson")) {
+ String geoJson = this.data.get("geojson").toString().trim();
+ GeoJsonReader reader = new GeoJsonReader(gf);
+ geometry = reader.read(geoJson);
+
+ } else {
+ throw new IllegalStateException("No geometry representation available in WayangGeometry.");
+ }
+ } catch (ParseException e) {
+ throw new RuntimeException("Failed to parse geometry from stored representations.", e);
+ }
+
+ this.data.put("geometry", geometry);
+ return geometry;
+ }
+
+ // ---------- Helpers ---------- //
+
+ private boolean looksLikeWKT(String s) {
+ return s.startsWith("SRID=") ||
+ s.startsWith("POINT") ||
+ s.startsWith("LINESTRING") ||
+ s.startsWith("POLYGON") ||
+ s.startsWith("MULTI") ||
+ s.startsWith("GEOMETRYCOLLECTION");
+ }
+
+ private boolean looksLikeGeoJSON(String s) {
+ return s.startsWith("{") && s.contains("\"type\"");
+ }
+
+ private String cleanSRID(String wkt) {
+ if (wkt.startsWith("SRID=")) {
+ int idx = wkt.indexOf(';');
+ if (idx > 0 && idx < wkt.length() - 1) {
+ return wkt.substring(idx + 1);
+ }
+ }
+ return wkt;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof WayangGeometry)) return false;
+
+ WayangGeometry that = (WayangGeometry) o;
+
+ Geometry g1 = this.getGeometry();
+ Geometry g2 = that.getGeometry();
+
+ if (g1 == null || g2 == null) {
+ return g1 == g2;
+ }
+
+ // Delegate to JTS Geometry equality (structural / topological, depending on JTS version).
+ return g1.equals(g2);
+ }
+
+ @Override
+ public int hashCode() {
+ Geometry geometry = this.getGeometry();
+ return geometry != null ? geometry.hashCode() : 0;
+ }
+
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/function/JtsSpatialPredicate.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/function/JtsSpatialPredicate.java
new file mode 100644
index 000000000..3d95f450e
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/function/JtsSpatialPredicate.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.function;
+
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.locationtech.jts.geom.Geometry;
+
+import java.util.Arrays;
+import java.util.function.BiPredicate;
+
+public enum JtsSpatialPredicate {
+
+ INTERSECTS("INTERSECTS", "ST_Intersects", Geometry::intersects),
+ CONTAINS("CONTAINS", "ST_Contains", Geometry::contains),
+ WITHIN("WITHIN", "ST_Within", Geometry::within),
+ TOUCHES("TOUCHES", "ST_Touches", Geometry::touches),
+ OVERLAPS("OVERLAPS", "ST_Overlaps", Geometry::overlaps),
+ CROSSES("CROSSES", "ST_Crosses", Geometry::crosses),
+ EQUALS("EQUALS", "ST_Equals", Geometry::equalsTopo);
+
+ private final String opName;
+ private final String sqlFunctionName;
+ private final BiPredicate predicate;
+
+ JtsSpatialPredicate(String opName,
+ String sqlFunctionName,
+ BiPredicate predicate) {
+ this.opName = opName;
+ this.sqlFunctionName = sqlFunctionName;
+ this.predicate = predicate;
+ }
+
+ public static JtsSpatialPredicate fromString(String opName) {
+ return Arrays.stream(values())
+ .filter(r -> r.opName.equalsIgnoreCase(opName))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException(
+ "Unsupported spatial filter type: " + opName));
+ }
+
+ /**
+ * Convert from the core module's {@link SpatialPredicate} to this enum.
+ *
+ * @param predicate the spatial predicate
+ * @return the corresponding JtsSpatialPredicate
+ */
+ public static JtsSpatialPredicate of(SpatialPredicate predicate) {
+ return switch (predicate) {
+ case INTERSECTS -> INTERSECTS;
+ case CONTAINS -> CONTAINS;
+ case WITHIN -> WITHIN;
+ case OVERLAPS -> OVERLAPS;
+ case TOUCHES -> TOUCHES;
+ case CROSSES -> CROSSES;
+ case EQUALS -> EQUALS;
+ };
+ }
+
+ public boolean test(Geometry candidate, Geometry reference) {
+ return predicate.test(candidate, reference);
+ }
+
+ public String toSql(String columnExpr, String geomLiteral) {
+ return String.format("%s(%s, %s)", this.sqlFunctionName, columnExpr, geomLiteral);
+ }
+
+ public String toSql(String leftTable, String leftKey, String rightTable, String rightKey) {
+ return String.format("%s(%s.%s, %s.%s)", this.sqlFunctionName, leftTable, leftKey, rightTable, rightKey);
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/Mappings.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/Mappings.java
new file mode 100644
index 000000000..7b70039cb
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/Mappings.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.mapping;
+
+import org.apache.wayang.basic.operators.GeoJsonFileSource;
+import org.apache.wayang.basic.operators.SpatialFilterOperator;
+import org.apache.wayang.basic.operators.SpatialJoinOperator;
+import org.apache.wayang.core.mapping.Mapping;
+import org.apache.wayang.java.platform.JavaPlatform;
+import org.apache.wayang.spark.platform.SparkPlatform;
+import org.apache.wayang.postgres.platform.PostgresPlatform;
+
+import java.util.Arrays;
+import java.util.Collection;
+
+/**
+ * {@link Mapping}s for the {@link SpatialFilterOperator}, {@link SpatialJoinOperator}, and {@link GeoJsonFileSource}.
+ */
+public class Mappings {
+
+ /**
+ * {@link Mapping}s towards the {@link JavaPlatform}.
+ */
+ public static Collection javaMappings = Arrays.asList(
+ new org.apache.wayang.spatial.mapping.java.SpatialFilterMapping(),
+ new org.apache.wayang.spatial.mapping.java.SpatialJoinMapping(),
+ new org.apache.wayang.spatial.mapping.java.GeoJsonFileSourceMapping()
+ );
+
+ /**
+ * {@link Mapping}s towards the {@link SparkPlatform}.
+ */
+ public static Collection sparkMappings = Arrays.asList(
+ new org.apache.wayang.spatial.mapping.spark.SpatialFilterMapping(),
+ new org.apache.wayang.spatial.mapping.spark.SpatialJoinMapping()
+ );
+
+ /**
+ * {@link Mapping}s towards the {@link PostgresPlatform}.
+ */
+ public static Collection postgresMappings = Arrays.asList(
+ new org.apache.wayang.spatial.mapping.postgres.SpatialFilterMapping(),
+ new org.apache.wayang.spatial.mapping.postgres.SpatialJoinMapping()
+ );
+
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/java/GeoJsonFileSourceMapping.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/java/GeoJsonFileSourceMapping.java
new file mode 100644
index 000000000..4f101a314
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/java/GeoJsonFileSourceMapping.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.mapping.java;
+
+import org.apache.wayang.basic.operators.GeoJsonFileSource;
+import org.apache.wayang.core.mapping.Mapping;
+import org.apache.wayang.core.mapping.OperatorPattern;
+import org.apache.wayang.core.mapping.SubplanPattern;
+import org.apache.wayang.core.mapping.PlanTransformation;
+import org.apache.wayang.core.mapping.ReplacementSubplanFactory;
+import org.apache.wayang.spatial.operators.java.JavaGeoJsonFileSource;
+import org.apache.wayang.java.platform.JavaPlatform;
+
+import java.util.Collection;
+import java.util.Collections;
+
+/**
+ * Mapping from {@link GeoJsonFileSource} to {@link JavaGeoJsonFileSource}.
+ */
+public class GeoJsonFileSourceMapping implements Mapping {
+ @Override
+ public Collection getTransformations() {
+ return Collections.singleton(new PlanTransformation(
+ this.createSubplanPattern(),
+ this.createReplacementSubplanFactory(),
+ JavaPlatform.getInstance()
+ ));
+ }
+
+ private SubplanPattern createSubplanPattern() {
+ final OperatorPattern operatorPattern = new OperatorPattern(
+ "source", new GeoJsonFileSource((String) null), false
+ );
+ return SubplanPattern.createSingleton(operatorPattern);
+ }
+
+ private ReplacementSubplanFactory createReplacementSubplanFactory() {
+ return new ReplacementSubplanFactory.OfSingleOperators(
+ (matchedOperator, epoch) -> new JavaGeoJsonFileSource(matchedOperator).at(epoch)
+ );
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/java/SpatialFilterMapping.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/java/SpatialFilterMapping.java
new file mode 100644
index 000000000..8644fcb3b
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/java/SpatialFilterMapping.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.mapping.java;
+
+import org.apache.wayang.basic.operators.SpatialFilterOperator;
+import org.apache.wayang.core.mapping.*;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.spatial.operators.java.JavaSpatialFilterOperator;
+import org.apache.wayang.java.platform.JavaPlatform;
+
+import java.util.Collection;
+import java.util.Collections;
+
+/**
+ * Mapping from {@link SpatialFilterOperator} to {@link JavaSpatialFilterOperator}.
+ */
+@SuppressWarnings("unchecked")
+public class SpatialFilterMapping implements Mapping {
+
+ @Override
+ public Collection getTransformations() {
+ return Collections.singleton(
+ new PlanTransformation(
+ this.createSubplanPattern(),
+ this.createReplacementSubplanFactory(),
+ JavaPlatform.getInstance()
+ )
+ );
+ }
+
+ private SubplanPattern createSubplanPattern() {
+ final OperatorPattern operatorPattern = new OperatorPattern(
+ "spatialFilter", new SpatialFilterOperator(null, null, DataSetType.none(), null), false);
+ return SubplanPattern.createSingleton(operatorPattern);
+ }
+
+ private ReplacementSubplanFactory createReplacementSubplanFactory() {
+ return new ReplacementSubplanFactory.OfSingleOperators(
+ (matchedOperator, epoch) -> new JavaSpatialFilterOperator(matchedOperator).at(epoch)
+ );
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/java/SpatialJoinMapping.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/java/SpatialJoinMapping.java
new file mode 100644
index 000000000..e68196eaa
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/java/SpatialJoinMapping.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.mapping.java;
+
+import org.apache.wayang.basic.operators.SpatialJoinOperator;
+import org.apache.wayang.core.mapping.*;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.spatial.operators.java.JavaSpatialJoinOperator;
+import org.apache.wayang.java.platform.JavaPlatform;
+
+import java.util.Collection;
+import java.util.Collections;
+
+/**
+ * Mapping from {@link SpatialJoinOperator} to {@link JavaSpatialJoinOperator}.
+ */
+public class SpatialJoinMapping implements Mapping {
+
+ @Override
+ public Collection getTransformations() {
+ return Collections.singleton(new PlanTransformation(
+ this.createSubplanPattern(),
+ this.createReplacementSubplanFactory(),
+ JavaPlatform.getInstance()
+ ));
+ }
+
+ private SubplanPattern createSubplanPattern() {
+ final OperatorPattern operatorPattern = new OperatorPattern<>(
+ "spatialJoin", new SpatialJoinOperator<>(null, null, DataSetType.none(), DataSetType.none(), null), false
+ );
+ return SubplanPattern.createSingleton(operatorPattern);
+ }
+
+ private ReplacementSubplanFactory createReplacementSubplanFactory() {
+ return new ReplacementSubplanFactory.OfSingleOperators>(
+ (matchedOperator, epoch) -> new JavaSpatialJoinOperator<>(matchedOperator).at(epoch)
+ );
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/postgres/SpatialFilterMapping.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/postgres/SpatialFilterMapping.java
new file mode 100644
index 000000000..9841175b2
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/postgres/SpatialFilterMapping.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.mapping.postgres;
+
+import org.apache.wayang.basic.operators.SpatialFilterOperator;
+import org.apache.wayang.core.mapping.*;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.spatial.operators.postgres.PostgresSpatialFilterOperator;
+import org.apache.wayang.postgres.platform.PostgresPlatform;
+
+import java.util.Collection;
+import java.util.Collections;
+
+
+/**
+ * Mapping from {@link SpatialFilterOperator} to {@link PostgresSpatialFilterOperator}.
+ */
+@SuppressWarnings("unchecked")
+public class SpatialFilterMapping implements Mapping {
+
+ @Override
+ public Collection getTransformations() {
+ return Collections.singleton(new PlanTransformation(
+ this.createSubplanPattern(),
+ this.createReplacementSubplanFactory(),
+ PostgresPlatform.getInstance()
+ ));
+ }
+
+ private SubplanPattern createSubplanPattern() {
+ final OperatorPattern operatorPattern = new OperatorPattern<>(
+ "spatialFilter", new SpatialFilterOperator(null, null, DataSetType.none(), null), false
+ ).withAdditionalTest(op -> op.getKeyDescriptor().getSqlImplementation() != null);
+ return SubplanPattern.createSingleton(operatorPattern);
+ }
+
+ private ReplacementSubplanFactory createReplacementSubplanFactory() {
+ return new ReplacementSubplanFactory.OfSingleOperators(
+ (matchedOperator, epoch) -> new PostgresSpatialFilterOperator(matchedOperator).at(epoch)
+ );
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/postgres/SpatialJoinMapping.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/postgres/SpatialJoinMapping.java
new file mode 100644
index 000000000..510c77b96
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/postgres/SpatialJoinMapping.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.mapping.postgres;
+
+import org.apache.wayang.basic.operators.SpatialJoinOperator;
+import org.apache.wayang.core.mapping.*;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.spatial.operators.postgres.PostgresSpatialJoinOperator;
+import org.apache.wayang.postgres.platform.PostgresPlatform;
+
+import java.util.Collection;
+import java.util.Collections;
+
+
+/**
+ * Mapping from {@link SpatialJoinOperator} to {@link PostgresSpatialJoinOperator}.
+ */
+@SuppressWarnings("unchecked")
+public class SpatialJoinMapping implements Mapping {
+
+ @Override
+ public Collection getTransformations() {
+ return Collections.singleton(new PlanTransformation(
+ this.createSubplanPattern(),
+ this.createReplacementSubplanFactory(),
+ PostgresPlatform.getInstance()
+ ));
+ }
+
+ private SubplanPattern createSubplanPattern() {
+ final OperatorPattern operatorPattern = new OperatorPattern<>(
+ "spatialFilter", new SpatialJoinOperator(null, null, DataSetType.none(), DataSetType.none(), null), false
+ ).withAdditionalTest(op -> op.getKeyDescriptor0().getSqlImplementation() != null
+ && op.getKeyDescriptor1().getSqlImplementation() != null); // require SQL pushdown support
+ return SubplanPattern.createSingleton(operatorPattern);
+ }
+
+ private ReplacementSubplanFactory createReplacementSubplanFactory() {
+ return new ReplacementSubplanFactory.OfSingleOperators(
+ (matchedOperator, epoch) -> new PostgresSpatialJoinOperator(matchedOperator).at(epoch)
+ );
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/spark/SpatialFilterMapping.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/spark/SpatialFilterMapping.java
new file mode 100644
index 000000000..c8362720c
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/spark/SpatialFilterMapping.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.mapping.spark;
+
+import org.apache.wayang.basic.operators.SpatialFilterOperator;
+import org.apache.wayang.core.mapping.*;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.spatial.operators.spark.SparkSpatialFilterOperator;
+import org.apache.wayang.spark.platform.SparkPlatform;
+
+import java.util.Collection;
+import java.util.Collections;
+
+/**
+ * Mapping from {@link SpatialFilterOperator} to {@link SparkSpatialFilterOperator}.
+ */
+@SuppressWarnings("unchecked")
+public class SpatialFilterMapping implements Mapping {
+
+ @Override
+ public Collection getTransformations() {
+ return Collections.singleton(new PlanTransformation(
+ this.createSubplanPattern(),
+ this.createReplacementSubplanFactory(),
+ SparkPlatform.getInstance()
+ ));
+ }
+
+ private SubplanPattern createSubplanPattern() {
+ final OperatorPattern operatorPattern = new OperatorPattern<>(
+ "spatialFilter", new SpatialFilterOperator(null, null, DataSetType.none(), null), false
+ );
+ return SubplanPattern.createSingleton(operatorPattern);
+ }
+
+ private ReplacementSubplanFactory createReplacementSubplanFactory() {
+ return new ReplacementSubplanFactory.OfSingleOperators(
+ (matchedOperator, epoch) -> new SparkSpatialFilterOperator(matchedOperator).at(epoch)
+ );
+ }
+
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/spark/SpatialJoinMapping.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/spark/SpatialJoinMapping.java
new file mode 100644
index 000000000..4c90f94ae
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/mapping/spark/SpatialJoinMapping.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.mapping.spark;
+
+import org.apache.wayang.basic.operators.SpatialJoinOperator;
+import org.apache.wayang.core.mapping.*;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.spatial.operators.spark.SparkSpatialJoinOperator;
+import org.apache.wayang.spark.platform.SparkPlatform;
+
+import java.util.Collection;
+import java.util.Collections;
+
+/**
+ * Mapping from {@link SpatialJoinOperator} to {@link SparkSpatialJoinOperator}.
+ */
+@SuppressWarnings("unchecked")
+public class SpatialJoinMapping implements Mapping {
+
+ @Override
+ public Collection getTransformations() {
+ return Collections.singleton(new PlanTransformation(
+ this.createSubplanPattern(),
+ this.createReplacementSubplanFactory(),
+ SparkPlatform.getInstance()
+ ));
+ }
+
+ private SubplanPattern createSubplanPattern() {
+ final OperatorPattern operatorPattern = new OperatorPattern<>(
+ "spatialJoin", new SpatialJoinOperator<>(
+ null,
+ null,
+ DataSetType.none(),
+ DataSetType.none(),
+ null), false
+ );
+ return SubplanPattern.createSingleton(operatorPattern);
+ }
+
+ private ReplacementSubplanFactory createReplacementSubplanFactory() {
+ return new ReplacementSubplanFactory.OfSingleOperators>(
+ (matchedOperator, epoch) -> new SparkSpatialJoinOperator<>(
+ matchedOperator
+ ).at(epoch)
+ );
+ }
+
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/java/JavaGeoJsonFileSource.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/java/JavaGeoJsonFileSource.java
new file mode 100644
index 000000000..661b2ff0a
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/java/JavaGeoJsonFileSource.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.operators.java;
+
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.wayang.basic.data.Record;
+import org.apache.wayang.spatial.data.WayangGeometry;
+import org.apache.wayang.basic.operators.GeoJsonFileSource;
+import org.apache.wayang.core.api.exception.WayangException;
+import org.apache.wayang.core.optimizer.OptimizationContext;
+import org.apache.wayang.core.plan.wayangplan.ExecutionOperator;
+import org.apache.wayang.core.platform.ChannelDescriptor;
+import org.apache.wayang.core.platform.ChannelInstance;
+import org.apache.wayang.core.platform.lineage.ExecutionLineageNode;
+import org.apache.wayang.core.util.Tuple;
+import org.apache.wayang.java.channels.StreamChannel;
+import org.apache.wayang.java.execution.JavaExecutor;
+import org.apache.wayang.java.operators.JavaExecutionOperator;
+
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.io.InputStream;
+import java.util.*;
+import java.util.stream.Stream;
+
+/**
+ * Java execution operator that parses a GeoJSON document and emits each feature as a {@link Record}.
+ * Each emitted Record is created from the feature JSON text. The Record consists of the geometry and properties
+ * of the feature (i.e., the Record's schema has two fields: "geometry" and "properties", where "geometry"
+ * is of type {@link WayangGeometry} and "properties" is of type {@linkplain Map}).
+ */
+public class JavaGeoJsonFileSource extends GeoJsonFileSource implements JavaExecutionOperator {
+
+ public JavaGeoJsonFileSource(String inputUrl) {
+ super(inputUrl);
+ }
+
+ public JavaGeoJsonFileSource(GeoJsonFileSource that) {
+ super(that);
+ }
+
+ public static Stream readFeatureCollectionFromFile(final String path) {
+ try {
+ final URI uri = URI.create(path);
+
+ // use streaming parser to avoid loading entire file into memory
+ ObjectMapper objectMapper = new ObjectMapper();
+ JsonFactory jsonFactory = objectMapper.getFactory();
+ List records = new ArrayList<>();
+
+ try (InputStream in = Files.newInputStream(Paths.get(uri.getPath()));
+ JsonParser parser = jsonFactory.createParser(in)) {
+
+ // advance to start object
+ if (parser.nextToken() != JsonToken.START_OBJECT) {
+ throw new WayangException("Expected JSON object at root");
+ }
+
+ // find the "features" array
+ while (parser.nextToken() != null) {
+ if (parser.currentToken() == JsonToken.FIELD_NAME
+ && "features".equals(parser.getCurrentName())) {
+ if (parser.nextToken() != JsonToken.START_ARRAY) {
+ throw new WayangException("Expected 'features' to be an array");
+ }
+ // iterate features
+ while (parser.nextToken() != JsonToken.END_ARRAY) {
+ // parser is at START_OBJECT of a feature
+ JsonNode featureNode = objectMapper.readTree(parser);
+ JsonNode geometryNode = featureNode.path("geometry");
+ JsonNode propertiesNode = featureNode.path("properties");
+
+ String geometryJsonString = objectMapper.writeValueAsString(geometryNode);
+ WayangGeometry wayangGeometry = WayangGeometry.fromGeoJson(geometryJsonString);
+
+ Map propertiesMap = objectMapper.convertValue(propertiesNode, Map.class);
+
+ Record record = new Record();
+ record.addField(wayangGeometry);
+ record.addField(propertiesMap);
+ records.add(record);
+ }
+ break;
+ }
+ }
+ }
+ return records.stream();
+ } catch (final Exception e) {
+ throw new WayangException(e);
+ }
+ }
+
+ @Override
+ public Tuple, Collection> evaluate(
+ final ChannelInstance[] inputs,
+ final ChannelInstance[] outputs,
+ final JavaExecutor javaExecutor,
+ final OptimizationContext.OperatorContext operatorContext) {
+
+ assert outputs.length == this.getNumOutputs();
+
+ final String path = this.getInputUrl();
+ final Stream wayangGeometryStream = readFeatureCollectionFromFile(path);
+
+ ((StreamChannel.Instance) outputs[0]).accept(wayangGeometryStream);
+
+ return ExecutionOperator.modelLazyExecution(inputs, outputs, operatorContext);
+ }
+
+ @Override
+ public JavaGeoJsonFileSource copy() {
+ return new JavaGeoJsonFileSource(this.getInputUrl());
+ }
+
+ @Override
+ public List getSupportedInputChannels(final int index) {
+ throw new UnsupportedOperationException(String.format("%s does not have input channels.", this));
+ }
+
+ @Override
+ public List getSupportedOutputChannels(final int index) {
+ assert index <= this.getNumOutputs() || (index == 0 && this.getNumOutputs() == 0);
+ return Collections.singletonList(StreamChannel.DESCRIPTOR);
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/java/JavaSpatialFilterOperator.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/java/JavaSpatialFilterOperator.java
new file mode 100644
index 000000000..5ec2ac845
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/java/JavaSpatialFilterOperator.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.operators.java;
+
+import org.apache.wayang.basic.operators.SpatialFilterOperator;
+import org.apache.wayang.core.api.spatial.SpatialGeometry;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.core.function.FunctionDescriptor;
+import org.apache.wayang.core.optimizer.OptimizationContext;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.core.plan.wayangplan.ExecutionOperator;
+import org.apache.wayang.core.platform.ChannelDescriptor;
+import org.apache.wayang.core.platform.ChannelInstance;
+import org.apache.wayang.core.platform.lineage.ExecutionLineageNode;
+import org.apache.wayang.core.util.Tuple;
+import org.apache.wayang.java.channels.CollectionChannel;
+import org.apache.wayang.java.channels.JavaChannelInstance;
+import org.apache.wayang.java.channels.StreamChannel;
+import org.apache.wayang.java.execution.JavaExecutor;
+import org.apache.wayang.java.operators.JavaExecutionOperator;
+import org.apache.wayang.spatial.data.WayangGeometry;
+import org.apache.wayang.spatial.function.JtsSpatialPredicate;
+import org.locationtech.jts.geom.Geometry;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+/**
+ * Java implementation of the {@link SpatialFilterOperator}.
+ */
+public class JavaSpatialFilterOperator
+ extends SpatialFilterOperator
+ implements JavaExecutionOperator {
+
+ /**
+ * Creates a new instance.
+ *
+ * @param relation the type of spatial filter (e.g., "INTERSECTS", "CONTAINS", "WITHIN")
+ */
+ public JavaSpatialFilterOperator(SpatialPredicate relation,
+ FunctionDescriptor.SerializableFunction keyExtractor,
+ DataSetType inputClassDatasetType,
+ SpatialGeometry geometry) {
+ super(relation, keyExtractor, inputClassDatasetType, geometry);
+ }
+
+ public JavaSpatialFilterOperator(SpatialFilterOperator that) {
+ super(that);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public Tuple, Collection> evaluate(
+ ChannelInstance[] inputs,
+ ChannelInstance[] outputs,
+ JavaExecutor javaExecutor,
+ OptimizationContext.OperatorContext operatorContext) {
+
+ final Predicate filterPredicate = this.buildSpatialPredicate(javaExecutor);
+ ((StreamChannel.Instance) outputs[0]).accept(
+ ((JavaChannelInstance) inputs[0]).provideStream().filter(filterPredicate)
+ );
+
+ return ExecutionOperator.modelLazyExecution(inputs, outputs, operatorContext);
+ }
+
+ private Predicate buildSpatialPredicate(JavaExecutor javaExecutor) {
+ WayangGeometry wRef = (WayangGeometry) this.referenceGeometry;
+ final Geometry reference = wRef.getGeometry();
+ final Function keyExtractor = javaExecutor.getCompiler().compile(this.keyDescriptor);
+ JtsSpatialPredicate predicate = JtsSpatialPredicate.of(this.predicateType);
+
+ return input -> predicate.test(((WayangGeometry) keyExtractor.apply(input)).getGeometry(), reference);
+ }
+
+ @Override
+ public List getSupportedInputChannels(int index) {
+ assert index <= this.getNumInputs() || (index == 0 && this.getNumInputs() == 0);
+ if (this.getInput(index).isBroadcast()) return Collections.singletonList(CollectionChannel.DESCRIPTOR);
+ return Arrays.asList(CollectionChannel.DESCRIPTOR, StreamChannel.DESCRIPTOR);
+ }
+
+ @Override
+ public List getSupportedOutputChannels(int index) {
+ assert index <= this.getNumOutputs() || (index == 0 && this.getNumOutputs() == 0);
+ return Collections.singletonList(StreamChannel.DESCRIPTOR);
+ }
+
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/java/JavaSpatialJoinOperator.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/java/JavaSpatialJoinOperator.java
new file mode 100644
index 000000000..00a8f3d10
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/java/JavaSpatialJoinOperator.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.operators.java;
+
+import org.apache.wayang.basic.data.Tuple2;
+import org.apache.wayang.basic.operators.SpatialJoinOperator;
+import org.apache.wayang.core.api.spatial.SpatialGeometry;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.core.function.FunctionDescriptor;
+import org.apache.wayang.core.function.TransformationDescriptor;
+import org.apache.wayang.core.optimizer.OptimizationContext;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.core.plan.wayangplan.ExecutionOperator;
+import org.apache.wayang.core.platform.ChannelDescriptor;
+import org.apache.wayang.core.platform.ChannelInstance;
+import org.apache.wayang.core.platform.lineage.ExecutionLineageNode;
+import org.apache.wayang.core.util.Tuple;
+import org.apache.wayang.java.channels.CollectionChannel;
+import org.apache.wayang.java.channels.StreamChannel;
+import org.apache.wayang.java.execution.JavaExecutor;
+import org.apache.wayang.java.operators.JavaExecutionOperator;
+import org.apache.wayang.spatial.data.WayangGeometry;
+import org.apache.wayang.spatial.function.JtsSpatialPredicate;
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.index.strtree.STRtree;
+
+import java.util.*;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+public class JavaSpatialJoinOperator
+ extends SpatialJoinOperator
+ implements JavaExecutionOperator {
+
+ public JavaSpatialJoinOperator(TransformationDescriptor keyDescriptor0,
+ TransformationDescriptor keyDescriptor1,
+ DataSetType inputType0,
+ DataSetType inputType1,
+ SpatialPredicate predicate) {
+ super(keyDescriptor0, keyDescriptor1, inputType0, inputType1, predicate);
+ }
+
+ public JavaSpatialJoinOperator(FunctionDescriptor.SerializableFunction keyExtractor0,
+ FunctionDescriptor.SerializableFunction keyExtractor1,
+ Class input0Class,
+ Class input1Class,
+ SpatialPredicate predicate) {
+ super(keyExtractor0, keyExtractor1, input0Class, input1Class, predicate);
+ }
+
+
+ public JavaSpatialJoinOperator(SpatialJoinOperator that) {
+ super(that);
+ }
+
+ @Override
+ public Tuple, Collection> evaluate(
+ ChannelInstance[] inputs,
+ ChannelInstance[] outputs,
+ JavaExecutor javaExecutor,
+ OptimizationContext.OperatorContext operatorContext) {
+
+ assert inputs.length == this.getNumInputs();
+ assert outputs.length == this.getNumOutputs();
+
+ final Function keyExtractor0 =
+ javaExecutor.getCompiler().compile(this.keyDescriptor0);
+ final Function keyExtractor1 =
+ javaExecutor.getCompiler().compile(this.keyDescriptor1);
+
+ final Stream leftStream =
+ ((org.apache.wayang.java.channels.JavaChannelInstance) inputs[0])
+ .provideStream();
+ final Stream rightStream =
+ ((org.apache.wayang.java.channels.JavaChannelInstance) inputs[1])
+ .provideStream();
+
+ JtsSpatialPredicate predicate = JtsSpatialPredicate.of(this.predicateType);
+
+ STRtree index = new STRtree();
+
+ rightStream.forEach(v1 -> {
+ WayangGeometry wGeom = (WayangGeometry) keyExtractor1.apply(v1);
+ Geometry geom = (wGeom == null) ? null : wGeom.getGeometry();
+ if (geom != null) {
+ index.insert(geom.getEnvelopeInternal(), new AbstractMap.SimpleEntry<>(v1, geom));
+ }
+ });
+
+ index.build();
+
+ final Stream> joinStream = leftStream.flatMap(v0 -> {
+ Geometry geom0 = Optional.ofNullable((WayangGeometry) keyExtractor0.apply(v0))
+ .map(WayangGeometry::getGeometry).orElse(null);
+ if (geom0 == null) return Stream.empty();
+
+ List> candidates = index.query(geom0.getEnvelopeInternal());
+
+ return candidates.stream()
+ .filter(e -> predicate.test(geom0, e.getValue()))
+ .map(e -> new Tuple2<>(v0, e.getKey()));
+ });
+
+ // Push the result into the output channel.
+ ((org.apache.wayang.java.channels.StreamChannel.Instance) outputs[0]).accept(joinStream);
+
+ // Use the standard lazy-execution lineage modeling.
+ return ExecutionOperator.modelLazyExecution(inputs, outputs, operatorContext);
+ }
+
+ @Override
+ public List getSupportedInputChannels(int index) {
+ assert index <= this.getNumInputs() || (index == 0 && this.getNumInputs() == 0);
+ if (this.getInput(index).isBroadcast()) return Collections.singletonList(CollectionChannel.DESCRIPTOR);
+ return Arrays.asList(CollectionChannel.DESCRIPTOR, StreamChannel.DESCRIPTOR);
+ }
+
+ @Override
+ public List getSupportedOutputChannels(int index) {
+ assert index <= this.getNumOutputs() || (index == 0 && this.getNumOutputs() == 0);
+ return Collections.singletonList(StreamChannel.DESCRIPTOR);
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/jdbc/JdbcSpatialFilterOperator.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/jdbc/JdbcSpatialFilterOperator.java
new file mode 100644
index 000000000..aab8d65c4
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/jdbc/JdbcSpatialFilterOperator.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.operators.jdbc;
+
+import org.apache.wayang.basic.operators.SpatialFilterOperator;
+import org.apache.wayang.core.api.spatial.SpatialGeometry;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.core.function.FunctionDescriptor;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.spatial.function.JtsSpatialPredicate;
+import org.apache.wayang.jdbc.compiler.FunctionCompiler;
+import org.apache.wayang.jdbc.operators.JdbcExecutionOperator;
+
+import java.sql.Connection;
+
+
+/**
+ * Template for JDBC-based {@link SpatialFilterOperator}.
+ */
+public abstract class JdbcSpatialFilterOperator extends SpatialFilterOperator implements JdbcExecutionOperator {
+
+ /**
+ * Creates a new instance.
+ *
+ * @param relation the type of spatial filter (e.g., "INTERSECTS", "CONTAINS", "WITHIN")
+ */
+ public JdbcSpatialFilterOperator(SpatialPredicate relation,
+ FunctionDescriptor.SerializableFunction keyExtractor,
+ DataSetType inputClassDatasetType,
+ SpatialGeometry geometry) {
+ super(relation, keyExtractor, inputClassDatasetType, geometry);
+ }
+
+ public JdbcSpatialFilterOperator(SpatialFilterOperator that) {
+ super(that);
+ }
+
+ @Override
+ public String createSqlClause(Connection connection, FunctionCompiler compiler) {
+ if (this.referenceGeometry == null) {
+ throw new IllegalStateException("Geometry for spatial filter must not be null.");
+ }
+
+ // Column expression (e.g. "geom" or "t.geom")
+ final String columnExpr = this.keyDescriptor.getSqlImplementation().getField1();
+
+ // Geometry literal as ST_GeomFromText('WKT', srid)
+ final String wkt = this.referenceGeometry.toWKT();
+ // TODO: Check which SRID to use.
+ final int srid = 4326;
+
+ final String geomLiteral;
+ if (srid > 0) {
+ geomLiteral = String.format("ST_GeomFromText('%s', %d)", wkt, srid);
+ } else {
+ geomLiteral = String.format("ST_GeomFromText('%s')", wkt);
+ }
+
+ JtsSpatialPredicate relation = JtsSpatialPredicate.of(this.predicateType);
+ return relation.toSql(columnExpr, geomLiteral);
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/jdbc/JdbcSpatialJoinOperator.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/jdbc/JdbcSpatialJoinOperator.java
new file mode 100644
index 000000000..f44e88638
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/jdbc/JdbcSpatialJoinOperator.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.operators.jdbc;
+
+import org.apache.wayang.basic.operators.SpatialJoinOperator;
+import org.apache.wayang.basic.data.Record;
+import org.apache.wayang.core.api.spatial.SpatialGeometry;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.core.function.TransformationDescriptor;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.core.util.Tuple;
+import org.apache.wayang.spatial.function.JtsSpatialPredicate;
+import org.apache.wayang.jdbc.compiler.FunctionCompiler;
+import org.apache.wayang.jdbc.operators.JdbcExecutionOperator;
+
+import java.sql.Connection;
+
+public abstract class JdbcSpatialJoinOperator
+ extends SpatialJoinOperator
+ implements JdbcExecutionOperator {
+
+
+ public JdbcSpatialJoinOperator(
+ TransformationDescriptor keyDescriptor0,
+ TransformationDescriptor keyDescriptor1,
+ SpatialPredicate predicateType
+ ) {
+ super(
+ keyDescriptor0,
+ keyDescriptor1,
+ predicateType
+ );
+ }
+
+ /**
+ * Copies an instance.
+ *
+ * @param that that should be copied
+ */
+ public JdbcSpatialJoinOperator(SpatialJoinOperator that) {
+ super(that);
+ }
+
+ public String createSqlClause(Connection connection, FunctionCompiler compiler) {
+ final Tuple left = this.keyDescriptor0.getSqlImplementation();
+ final Tuple right = this.keyDescriptor1.getSqlImplementation();
+ if (left == null || right == null) {
+ throw new IllegalStateException("Spatial join requires SQL implementations for both inputs.");
+ }
+ final String leftTableName = left.field0;
+ final String leftKey = left.field1;
+ final String rightTableName = right.field0;
+ final String rightKey = right.field1;
+
+ JtsSpatialPredicate predicate = JtsSpatialPredicate.of(this.predicateType);
+ return "JOIN " + rightTableName + " ON " +
+ predicate.toSql(leftTableName, leftKey, rightTableName, rightKey);
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/postgres/PostgresSpatialFilterOperator.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/postgres/PostgresSpatialFilterOperator.java
new file mode 100644
index 000000000..eca725b45
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/postgres/PostgresSpatialFilterOperator.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.operators.postgres;
+
+import org.apache.wayang.basic.operators.SpatialFilterOperator;
+import org.apache.wayang.core.api.spatial.SpatialGeometry;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.core.function.FunctionDescriptor;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.spatial.operators.jdbc.JdbcSpatialFilterOperator;
+import org.apache.wayang.postgres.operators.PostgresExecutionOperator;
+
+
+/**
+ * PostgreSQL implementation of the {@link SpatialFilterOperator}.
+ */
+public class PostgresSpatialFilterOperator extends JdbcSpatialFilterOperator implements PostgresExecutionOperator {
+
+ /**
+ * Creates a new instance.
+ *
+ * @param relation the type of spatial filter (e.g., "INTERSECTS", "CONTAINS", "WITHIN")
+ */
+ public PostgresSpatialFilterOperator(SpatialPredicate relation,
+ FunctionDescriptor.SerializableFunction keyExtractor,
+ DataSetType inputClassDatasetType,
+ SpatialGeometry geometry) {
+ super(relation, keyExtractor, inputClassDatasetType, geometry);
+ }
+
+ /**
+ * Copies an instance (exclusive of broadcasts).
+ *
+ * @param that that should be copied
+ */
+ public PostgresSpatialFilterOperator(SpatialFilterOperator that) {
+ super(that);
+ }
+
+ @Override
+ protected PostgresSpatialFilterOperator createCopy() {
+ return new PostgresSpatialFilterOperator<>(this);
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/postgres/PostgresSpatialJoinOperator.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/postgres/PostgresSpatialJoinOperator.java
new file mode 100644
index 000000000..72489f234
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/postgres/PostgresSpatialJoinOperator.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.operators.postgres;
+
+import org.apache.wayang.basic.operators.SpatialJoinOperator;
+import org.apache.wayang.core.api.spatial.SpatialGeometry;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.core.function.TransformationDescriptor;
+import org.apache.wayang.spatial.operators.jdbc.JdbcSpatialJoinOperator;
+import org.apache.wayang.postgres.operators.PostgresExecutionOperator;
+
+public class PostgresSpatialJoinOperator extends JdbcSpatialJoinOperator implements PostgresExecutionOperator {
+ /**
+ * Creates a new instance.
+ *
+ * @param predicate the type of spatial join (e.g., "INTERSECTS", "CONTAINS", "WITHIN")
+ */
+ public PostgresSpatialJoinOperator(TransformationDescriptor keyDescriptor0,
+ TransformationDescriptor keyDescriptor1,
+ SpatialPredicate predicate) {
+ super(keyDescriptor0, keyDescriptor1, predicate);
+ }
+
+ /**
+ * Copies an instance (exclusive of broadcasts).
+ *
+ * @param that that should be copied
+ */
+ public PostgresSpatialJoinOperator(SpatialJoinOperator that) {
+ super(that);
+ }
+
+ @Override
+ protected PostgresSpatialJoinOperator createCopy() {
+ return new PostgresSpatialJoinOperator<>(this);
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/spark/SparkSpatialFilterOperator.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/spark/SparkSpatialFilterOperator.java
new file mode 100644
index 000000000..5fe582bb6
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/spark/SparkSpatialFilterOperator.java
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.operators.spark;
+
+import org.apache.sedona.core.spatialOperator.RangeQuery;
+import org.apache.sedona.core.spatialRDD.SpatialRDD;
+import org.apache.spark.api.java.JavaRDD;
+import org.apache.wayang.basic.operators.SpatialFilterOperator;
+import org.apache.wayang.core.api.spatial.SpatialGeometry;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.core.function.FunctionDescriptor;
+import org.apache.wayang.core.optimizer.OptimizationContext;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.core.plan.wayangplan.ExecutionOperator;
+import org.apache.wayang.core.platform.ChannelDescriptor;
+import org.apache.wayang.core.platform.ChannelInstance;
+import org.apache.wayang.core.platform.lineage.ExecutionLineageNode;
+import org.apache.wayang.core.util.ReflectionUtils;
+import org.apache.wayang.core.util.Tuple;
+import org.apache.wayang.spark.channels.BroadcastChannel;
+import org.apache.wayang.spark.channels.RddChannel;
+import org.apache.wayang.spark.execution.SparkExecutor;
+import org.apache.wayang.spark.operators.SparkExecutionOperator;
+import org.apache.wayang.spatial.data.WayangGeometry;
+import org.locationtech.jts.geom.Geometry;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Spark implementation of the {@link SpatialFilterOperator}.
+ */
+public class SparkSpatialFilterOperator
+ extends SpatialFilterOperator
+ implements SparkExecutionOperator {
+
+ /**
+ * Creates a new instance.
+ *
+ * @param relation the type of spatial filter (e.g., "INTERSECTS", "CONTAINS", "WITHIN")
+ *
+ */
+ public SparkSpatialFilterOperator(SpatialPredicate relation,
+ FunctionDescriptor.SerializableFunction keyExtractor,
+ DataSetType inputClassDatasetType,
+ SpatialGeometry geometry) {
+ super(relation, keyExtractor, inputClassDatasetType, geometry);
+ }
+
+ /**
+ * Copies an instance (exclusive of broadcasts).
+ *
+ * @param that that should be copied
+ */
+ public SparkSpatialFilterOperator(SpatialFilterOperator that) {
+ super(that);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public Tuple, Collection> evaluate(
+ ChannelInstance[] inputs,
+ ChannelInstance[] outputs,
+ SparkExecutor sparkExecutor,
+ OptimizationContext.OperatorContext operatorContext) {
+ assert inputs.length == this.getNumInputs();
+ assert outputs.length == this.getNumOutputs();
+
+ // Register Sedona JAR with Spark executors if running in cluster mode.
+ if (!sparkExecutor.sc.isLocal()) {
+ String sedonaJar = ReflectionUtils.getDeclaringJar(SpatialRDD.class);
+ if (sedonaJar != null) {
+ sparkExecutor.sc.addJar(sedonaJar);
+ }
+ }
+
+ WayangGeometry wRef = (WayangGeometry) this.referenceGeometry;
+ final Geometry reference = wRef == null ? null : wRef.getGeometry();
+ if (reference == null) {
+ throw new IllegalStateException("Reference geometry must not be null for spatial filtering.");
+ }
+
+ final JavaRDD inputRdd = ((RddChannel.Instance) inputs[0]).provideRdd();
+
+ final FunctionDescriptor.SerializableFunction keyExtractor =
+ (FunctionDescriptor.SerializableFunction) this.keyDescriptor.getJavaImplementation();
+
+ // Build an RDD of Geometries where userData = original element (Type)
+ final JavaRDD geometryRdd = inputRdd
+ .map((Type value) -> {
+ final WayangGeometry wGeom = (WayangGeometry) keyExtractor.apply(value);
+ if (wGeom == null) {
+ return null;
+ }
+ final Geometry geom = wGeom.getGeometry();
+ if (geom != null) {
+ geom.setUserData(value); // keep original object
+ }
+ return geom;
+ })
+ .filter(Objects::nonNull);
+
+ final SpatialRDD spatialRDD = new SpatialRDD<>();
+ spatialRDD.setRawSpatialRDD(geometryRdd);
+ spatialRDD.analyze();
+
+ final JavaRDD outputRdd = this.applySedonaSpatialFilter(spatialRDD, reference);
+ this.name(outputRdd);
+ ((RddChannel.Instance) outputs[0]).accept(outputRdd, sparkExecutor);
+
+ return ExecutionOperator.modelLazyExecution(inputs, outputs, operatorContext);
+ }
+
+
+ private JavaRDD applySedonaSpatialFilter(SpatialRDD spatialRDD, Geometry reference) {
+ final org.apache.sedona.core.spatialOperator.SpatialPredicate predicate = toSedonaPredicate(this.predicateType);
+
+ try {
+ final JavaRDD matched =
+ RangeQuery.SpatialRangeQuery(spatialRDD, reference, predicate, false);
+
+ // Extract original input object from userData
+ return matched.map(geom -> (Type) geom.getUserData());
+ } catch (Exception e) {
+ throw new RuntimeException("Sedona range query failed for spatial filter.", e);
+ }
+ }
+
+ private org.apache.sedona.core.spatialOperator.SpatialPredicate toSedonaPredicate(SpatialPredicate predicateType) {
+ return switch (predicateType) {
+ case INTERSECTS -> org.apache.sedona.core.spatialOperator.SpatialPredicate.INTERSECTS;
+ case CONTAINS -> org.apache.sedona.core.spatialOperator.SpatialPredicate.CONTAINS;
+ case WITHIN -> org.apache.sedona.core.spatialOperator.SpatialPredicate.WITHIN;
+ case TOUCHES -> org.apache.sedona.core.spatialOperator.SpatialPredicate.TOUCHES;
+ case OVERLAPS -> org.apache.sedona.core.spatialOperator.SpatialPredicate.OVERLAPS;
+ case CROSSES -> org.apache.sedona.core.spatialOperator.SpatialPredicate.CROSSES;
+ case EQUALS -> org.apache.sedona.core.spatialOperator.SpatialPredicate.EQUALS;
+ default -> throw new IllegalStateException("Unsupported spatial filter predicate: " + predicateType);
+ };
+ }
+
+
+ @Override
+ public String getLoadProfileEstimatorConfigurationKey() {
+ return "wayang.spark.spatialfilter.load";
+ }
+
+ @Override
+ public List getSupportedInputChannels(int index) {
+ if (index == 0) {
+ return Arrays.asList(RddChannel.UNCACHED_DESCRIPTOR, RddChannel.CACHED_DESCRIPTOR);
+ } else {
+ return Collections.singletonList(BroadcastChannel.DESCRIPTOR);
+ }
+ }
+
+ @Override
+ public List getSupportedOutputChannels(int index) {
+ return Collections.singletonList(RddChannel.UNCACHED_DESCRIPTOR);
+ }
+
+ @Override
+ public boolean containsAction() {
+ return false;
+ }
+
+}
diff --git a/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/spark/SparkSpatialJoinOperator.java b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/spark/SparkSpatialJoinOperator.java
new file mode 100644
index 000000000..7416ae1e0
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/main/java/org/apache/wayang/spatial/operators/spark/SparkSpatialJoinOperator.java
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.operators.spark;
+
+import org.apache.sedona.core.enums.GridType;
+import org.apache.sedona.core.spatialOperator.JoinQuery;
+import org.apache.sedona.core.spatialRDD.SpatialRDD;
+import org.apache.spark.api.java.JavaPairRDD;
+import org.apache.spark.api.java.JavaRDD;
+import org.apache.wayang.basic.data.Tuple2;
+import org.apache.wayang.basic.operators.SpatialJoinOperator;
+import org.apache.wayang.core.api.spatial.SpatialGeometry;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.core.function.FunctionDescriptor;
+import org.apache.wayang.core.function.TransformationDescriptor;
+import org.apache.wayang.core.optimizer.OptimizationContext;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.core.plan.wayangplan.ExecutionOperator;
+import org.apache.wayang.core.platform.ChannelDescriptor;
+import org.apache.wayang.core.platform.ChannelInstance;
+import org.apache.wayang.core.platform.lineage.ExecutionLineageNode;
+import org.apache.wayang.core.util.ReflectionUtils;
+import org.apache.wayang.core.util.Tuple;
+import org.apache.wayang.spark.channels.RddChannel;
+import org.apache.wayang.spark.execution.SparkExecutor;
+import org.apache.wayang.spark.operators.SparkExecutionOperator;
+import org.apache.wayang.spatial.data.WayangGeometry;
+import org.locationtech.jts.geom.Geometry;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+public class SparkSpatialJoinOperator
+ extends SpatialJoinOperator
+ implements SparkExecutionOperator {
+
+ public SparkSpatialJoinOperator(SparkSpatialJoinOperator that) {
+ super(that);
+ }
+
+ public SparkSpatialJoinOperator(SpatialJoinOperator that) {
+ super(that);
+ }
+
+ public SparkSpatialJoinOperator(
+ TransformationDescriptor keyDescriptor0,
+ TransformationDescriptor keyDescriptor1,
+ DataSetType inputType0,
+ DataSetType inputType1,
+ SpatialPredicate predicateType) {
+ super(keyDescriptor0, keyDescriptor1, inputType0, inputType1, predicateType);
+ }
+
+ public SparkSpatialJoinOperator(
+ FunctionDescriptor.SerializableFunction keyExtractor0,
+ FunctionDescriptor.SerializableFunction keyExtractor1,
+ Class input0Class,
+ Class input1Class,
+ SpatialPredicate predicateType) {
+ super(keyExtractor0, keyExtractor1, input0Class, input1Class, predicateType);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public Tuple, Collection> evaluate(ChannelInstance[] inputs, ChannelInstance[] outputs, SparkExecutor sparkExecutor, OptimizationContext.OperatorContext operatorContext) {
+ // Register Sedona JAR with Spark executors if running in cluster mode.
+ if (!sparkExecutor.sc.isLocal()) {
+ String sedonaJar = ReflectionUtils.getDeclaringJar(SpatialRDD.class);
+ if (sedonaJar != null) {
+ sparkExecutor.sc.addJar(sedonaJar);
+ }
+ }
+
+ final JavaRDD leftIn = ((RddChannel.Instance) inputs[0]).provideRdd();
+ final JavaRDD rightIn = ((RddChannel.Instance) inputs[1]).provideRdd();
+
+ final FunctionDescriptor.SerializableFunction keyExtractor0 =
+ (FunctionDescriptor.SerializableFunction) this.keyDescriptor0.getJavaImplementation();
+ final FunctionDescriptor.SerializableFunction keyExtractor1 =
+ (FunctionDescriptor.SerializableFunction) this.keyDescriptor1.getJavaImplementation();
+
+
+ final JavaRDD leftInGeometry = leftIn.map((InputType0 in1) -> {
+ final WayangGeometry wGeom = (WayangGeometry) keyExtractor0.apply(in1);
+ Geometry geom = wGeom.getGeometry();
+ geom.setUserData(in1);
+ return geom;
+ });
+
+ final JavaRDD rightInGeometry = rightIn.map((InputType1 in2) -> {
+ final WayangGeometry wGeom = (WayangGeometry) keyExtractor1.apply(in2);
+ Geometry geom = wGeom.getGeometry();
+ geom.setUserData(in2);
+ return geom;
+ });
+
+
+ final SpatialRDD spatialRDDLeft = new SpatialRDD<>();
+ final SpatialRDD spatialRDDRight = new SpatialRDD<>();
+
+ try {
+ spatialRDDLeft.setRawSpatialRDD(leftInGeometry);
+ spatialRDDRight.setRawSpatialRDD(rightInGeometry);
+
+ spatialRDDLeft.analyze();
+ spatialRDDRight.analyze();
+
+ final int maxPartitions = 64; // constant for now, later depend on cluster size
+ final long estimatedCount = spatialRDDLeft.approximateTotalCount;
+ final int numPartitions = (int) Math.max(1, Math.min(estimatedCount / 2, maxPartitions));
+ spatialRDDLeft.spatialPartitioning(GridType.QUADTREE, numPartitions);
+ spatialRDDRight.spatialPartitioning(spatialRDDLeft.getPartitioner());
+
+ JavaPairRDD sedonaJoin = JoinQuery.spatialJoin(
+ spatialRDDLeft,
+ spatialRDDRight,
+ new JoinQuery.JoinParams(false, toSedonaPredicate(this.predicateType))
+ );
+ final JavaRDD> outputRdd =
+ sedonaJoin.map(geoTuple ->
+ new Tuple2<>(
+ (InputType0) geoTuple._1().getUserData(),
+ (InputType1) geoTuple._2().getUserData()
+ )
+ );
+
+ ((RddChannel.Instance) outputs[0]).accept(outputRdd, sparkExecutor);
+ return ExecutionOperator.modelLazyExecution(inputs, outputs, operatorContext);
+
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private org.apache.sedona.core.spatialOperator.SpatialPredicate toSedonaPredicate(SpatialPredicate predicateType) {
+ return switch (predicateType) {
+ case INTERSECTS -> org.apache.sedona.core.spatialOperator.SpatialPredicate.INTERSECTS;
+ case CONTAINS -> org.apache.sedona.core.spatialOperator.SpatialPredicate.CONTAINS;
+ case WITHIN -> org.apache.sedona.core.spatialOperator.SpatialPredicate.WITHIN;
+ case TOUCHES -> org.apache.sedona.core.spatialOperator.SpatialPredicate.TOUCHES;
+ case OVERLAPS -> org.apache.sedona.core.spatialOperator.SpatialPredicate.OVERLAPS;
+ case CROSSES -> org.apache.sedona.core.spatialOperator.SpatialPredicate.CROSSES;
+ case EQUALS -> org.apache.sedona.core.spatialOperator.SpatialPredicate.EQUALS;
+ default -> throw new IllegalStateException("Unsupported spatial filter predicate: " + predicateType);
+ };
+ }
+
+ @Override
+ public String getLoadProfileEstimatorConfigurationKey() {
+ return "wayang.spark.spatialjoin.load";
+ }
+
+ @Override
+ public List getSupportedInputChannels(int index) {
+ assert index <= this.getNumInputs() || (index == 0 && this.getNumInputs() == 0);
+ return Arrays.asList(RddChannel.UNCACHED_DESCRIPTOR, RddChannel.CACHED_DESCRIPTOR);
+ }
+
+ @Override
+ public List getSupportedOutputChannels(int index) {
+ assert index <= this.getNumOutputs() || (index == 0 && this.getNumOutputs() == 0);
+ return Collections.singletonList(RddChannel.UNCACHED_DESCRIPTOR);
+ }
+
+ @Override
+ public boolean containsAction() {
+ return false;
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/api/JavaApiSpatialTest.java b/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/api/JavaApiSpatialTest.java
new file mode 100644
index 000000000..d2de4b5e7
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/api/JavaApiSpatialTest.java
@@ -0,0 +1,574 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.api;
+
+import org.apache.wayang.basic.data.Record;
+import org.apache.wayang.core.api.Configuration;
+import org.apache.wayang.core.api.WayangContext;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.java.Java;
+import org.apache.wayang.postgres.Postgres;
+import org.apache.wayang.postgres.operators.PostgresTableSource;
+import org.apache.wayang.spark.Spark;
+import org.apache.wayang.spatial.Spatial;
+import org.apache.wayang.spatial.data.WayangGeometry;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for the fluent spatial API on DataQuantaBuilder.
+ */
+public class JavaApiSpatialTest {
+
+ // ==================== Java Platform Tests ====================
+
+ @Test
+ void testSpatialFilter() {
+ WayangContext wayangContext = new WayangContext(new Configuration())
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Spatial.javaPlugin());
+
+ JavaPlanBuilder planBuilder = new JavaPlanBuilder(wayangContext)
+ .withJobName("Spatial Filter Test");
+
+ List testData = Arrays.asList(
+ "0.0,0.0,1.0,1.0", // Box at origin
+ "0.5,0.5,1.5,1.5", // Overlapping box
+ "2.0,2.0,3.0,3.0", // Non-overlapping box
+ "0.25,0.25,0.75,0.75" // Box inside first
+ );
+
+ WayangGeometry queryGeometry = WayangGeometry.fromStringInput(
+ "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))"
+ );
+
+ Collection result = planBuilder.loadCollection(testData)
+ .spatialFilter(
+ (input -> {
+ String[] parts = input.split(",");
+ double xmin = Double.parseDouble(parts[0]);
+ double ymin = Double.parseDouble(parts[1]);
+ double xmax = Double.parseDouble(parts[2]);
+ double ymax = Double.parseDouble(parts[3]);
+ String wkt = String.format(
+ "POLYGON((%f %f, %f %f, %f %f, %f %f, %f %f))",
+ xmin, ymin, xmax, ymin, xmax, ymax, xmin, ymax, xmin, ymin
+ );
+ return WayangGeometry.fromStringInput(wkt);
+ }),
+ SpatialPredicate.INTERSECTS,
+ queryGeometry
+ )
+ .count()
+ .collect();
+
+ // Should match 3 boxes (first overlaps, second overlaps, fourth is inside)
+ assertEquals(1, result.size());
+ Long count = result.iterator().next();
+ assertEquals(3L, count);
+ }
+
+ @Test
+ void testSpatialJoin() {
+ WayangContext wayangContext = new WayangContext(new Configuration())
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Spatial.javaPlugin());
+
+ JavaPlanBuilder planBuilder = new JavaPlanBuilder(wayangContext)
+ .withJobName("Spatial Join Test");
+
+ List leftData = Arrays.asList(
+ "POINT(0.5 0.5)", // Inside first box
+ "POINT(1.5 1.5)", // Inside second box
+ "POINT(0.25 0.75)" // Inside first box
+ );
+
+ List rightData = Arrays.asList(
+ "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))", // Contains first and third points
+ "POLYGON((1 1, 2 1, 2 2, 1 2, 1 1))" // Contains second point
+ );
+
+ Collection result = planBuilder.loadCollection(leftData)
+ .spatialJoin(
+ (WayangGeometry::fromStringInput),
+ planBuilder.loadCollection(rightData),
+ (WayangGeometry::fromStringInput),
+ SpatialPredicate.INTERSECTS
+ )
+ .count()
+ .collect();
+
+ // Should have 3 matches:
+ // - POINT(0.5 0.5) with first box
+ // - POINT(1.5 1.5) with second box
+ // - POINT(0.25 0.75) with first box
+ assertEquals(1, result.size());
+ Long count = result.iterator().next();
+ assertEquals(3L, count);
+ }
+
+ @Test
+ void testChainedOperations() {
+ WayangContext wayangContext = new WayangContext(new Configuration())
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Spatial.javaPlugin());
+
+ JavaPlanBuilder planBuilder = new JavaPlanBuilder(wayangContext)
+ .withJobName("Chained Operations Test");
+
+ // Data: "id,xmin,ymin,xmax,ymax"
+ List testData = Arrays.asList(
+ "1,0.0,0.0,1.0,1.0",
+ "2,0.5,0.5,1.5,1.5",
+ "3,2.0,2.0,3.0,3.0",
+ "4,0.25,0.25,0.75,0.75"
+ );
+
+ WayangGeometry queryGeometry = WayangGeometry.fromStringInput(
+ "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))"
+ );
+
+ // Chain: spatialFilter -> map (extract id) -> filter (id > 1) -> collect
+ Collection result = planBuilder.loadCollection(testData)
+ .spatialFilter(
+ (input -> {
+ String[] parts = input.split(",");
+ double xmin = Double.parseDouble(parts[1]);
+ double ymin = Double.parseDouble(parts[2]);
+ double xmax = Double.parseDouble(parts[3]);
+ double ymax = Double.parseDouble(parts[4]);
+ String wkt = String.format(
+ "POLYGON((%f %f, %f %f, %f %f, %f %f, %f %f))",
+ xmin, ymin, xmax, ymin, xmax, ymax, xmin, ymax, xmin, ymin
+ );
+ return WayangGeometry.fromStringInput(wkt);
+ }),
+ SpatialPredicate.INTERSECTS,
+ queryGeometry
+ )
+ .map(line -> Integer.parseInt(line.split(",")[0])) // Extract ID
+ .filter(id -> id > 1) // Keep only IDs > 1
+ .collect();
+
+ // Should match boxes 1, 2, 4 (intersect), then filter to IDs > 1 -> 2, 4
+ assertEquals(2, result.size());
+ assertTrue(result.contains(2));
+ assertTrue(result.contains(4));
+ }
+
+ @Test
+ void testSpatialJoinChainedWithMapAndReduce() {
+ WayangContext wayangContext = new WayangContext(new Configuration())
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Spatial.javaPlugin());
+
+ JavaPlanBuilder planBuilder = new JavaPlanBuilder(wayangContext)
+ .withJobName("Spatial Join Chained Test");
+
+ // Left data: points with values "wkt;value"
+ List leftData = Arrays.asList(
+ "POINT(0.5 0.5);10",
+ "POINT(1.5 1.5);20",
+ "POINT(0.25 0.75);30"
+ );
+
+ // Right data: boxes with multipliers "wkt;multiplier"
+ List rightData = Arrays.asList(
+ "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0));2",
+ "POLYGON((1 1, 2 1, 2 2, 1 2, 1 1));3"
+ );
+
+ // Chain: spatialJoin -> map (multiply values) -> reduce (sum)
+ Collection result = planBuilder.loadCollection(leftData)
+ .spatialJoin(
+ (input -> WayangGeometry.fromStringInput(input.split(";")[0])),
+ planBuilder.loadCollection(rightData),
+ (input -> WayangGeometry.fromStringInput(input.split(";")[0])),
+ SpatialPredicate.INTERSECTS
+ )
+ .map(tuple -> {
+ int leftValue = Integer.parseInt(tuple.field0.split(";")[1]);
+ int rightMultiplier = Integer.parseInt(tuple.field1.split(";")[1]);
+ return leftValue * rightMultiplier;
+ })
+ .reduce((a, b) -> a + b)
+ .collect();
+
+ // Matches:
+ // - POINT(0.5 0.5);10 with box;2 -> 10*2 = 20
+ // - POINT(1.5 1.5);20 with box;3 -> 20*3 = 60
+ // - POINT(0.25 0.75);30 with box;2 -> 30*2 = 60
+ // Sum = 20 + 60 + 60 = 140
+ assertEquals(1, result.size());
+ assertEquals(140, result.iterator().next());
+ }
+
+ @Test
+ void testChainedSpatialFilters() {
+ WayangContext wayangContext = new WayangContext(new Configuration())
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Spatial.javaPlugin());
+
+ JavaPlanBuilder planBuilder = new JavaPlanBuilder(wayangContext)
+ .withJobName("Chained Spatial Filters Test");
+
+ List testData = Arrays.asList(
+ "POLYGON((0.1 0.1, 0.3 0.1, 0.3 0.3, 0.1 0.3, 0.1 0.1))", // Inside both query geometries
+ "POLYGON((0.6 0.6, 0.8 0.6, 0.8 0.8, 0.6 0.8, 0.6 0.6))", // Inside first, outside second
+ "POLYGON((2 2, 3 2, 3 3, 2 3, 2 2))" // Outside both
+ );
+
+ WayangGeometry queryGeometry1 = WayangGeometry.fromStringInput(
+ "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))" // Unit square
+ );
+ WayangGeometry queryGeometry2 = WayangGeometry.fromStringInput(
+ "POLYGON((0 0, 0.5 0, 0.5 0.5, 0 0.5, 0 0))" // Smaller square (0-0.5 range)
+ );
+
+ // Chain two spatial filters
+ Collection result = planBuilder.loadCollection(testData)
+ .spatialFilter(
+ (WayangGeometry::fromStringInput),
+ SpatialPredicate.INTERSECTS,
+ queryGeometry1
+ )
+ .map(x -> x).withOutputClass(String.class) // Preserve type for chaining
+ .spatialFilter(
+ (WayangGeometry::fromStringInput),
+ SpatialPredicate.INTERSECTS,
+ queryGeometry2
+ )
+ .count()
+ .collect();
+
+ // Only the first box (0.1-0.3) should pass both filters
+ assertEquals(1, result.size());
+ assertEquals(1L, result.iterator().next());
+ }
+
+ @Test
+ void testSpatialFilterFollowedBySpatialJoin() {
+ WayangContext wayangContext = new WayangContext(new Configuration())
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Spatial.javaPlugin());
+
+ JavaPlanBuilder planBuilder = new JavaPlanBuilder(wayangContext)
+ .withJobName("Spatial Filter then Join Test");
+
+ List leftData = Arrays.asList(
+ "POINT(0.5 0.5)",
+ "POINT(1.5 1.5)",
+ "POINT(0.25 0.25)"
+ );
+
+ List rightData = Arrays.asList(
+ "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
+ "POLYGON((1 1, 2 1, 2 2, 1 2, 1 1))"
+ );
+
+ WayangGeometry preFilterGeometry = WayangGeometry.fromStringInput(
+ "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))"
+ );
+
+ // Filter left data, then join with right
+ var filteredLeft = planBuilder.loadCollection(leftData)
+ .spatialFilter(
+ (input -> WayangGeometry.fromStringInput(input)),
+ SpatialPredicate.INTERSECTS,
+ preFilterGeometry
+ )
+ .map(x -> x).withOutputClass(String.class);
+
+ Collection result = filteredLeft
+ .spatialJoin(
+ (input -> WayangGeometry.fromStringInput(input)),
+ planBuilder.loadCollection(rightData),
+ (input -> WayangGeometry.fromStringInput(input)),
+ SpatialPredicate.INTERSECTS
+ )
+ .count()
+ .collect();
+
+ // After filter: POINT(0.5 0.5) and POINT(0.25 0.25) remain
+ // Join matches: both with first box = 2 matches
+ assertEquals(1, result.size());
+ assertEquals(2L, result.iterator().next());
+ }
+
+ // ==================== Spark Platform Tests ====================
+
+ @Test
+ void testSpatialFilterWithSpark() {
+ WayangContext wayangContext = new WayangContext(new Configuration())
+ .withPlugin(Spark.basicPlugin())
+ .withPlugin(Spatial.sparkPlugin());
+
+ JavaPlanBuilder planBuilder = new JavaPlanBuilder(wayangContext)
+ .withJobName("Spatial Filter Spark Test");
+
+ List testData = Arrays.asList(
+ "0.0,0.0,1.0,1.0",
+ "0.5,0.5,1.5,1.5",
+ "2.0,2.0,3.0,3.0",
+ "0.25,0.25,0.75,0.75"
+ );
+
+ WayangGeometry queryGeometry = WayangGeometry.fromStringInput(
+ "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))"
+ );
+
+ Collection result = planBuilder.loadCollection(testData)
+ .spatialFilter(
+ (input -> {
+ String[] parts = input.split(",");
+ double xmin = Double.parseDouble(parts[0]);
+ double ymin = Double.parseDouble(parts[1]);
+ double xmax = Double.parseDouble(parts[2]);
+ double ymax = Double.parseDouble(parts[3]);
+ String wkt = String.format(
+ "POLYGON((%f %f, %f %f, %f %f, %f %f, %f %f))",
+ xmin, ymin, xmax, ymin, xmax, ymax, xmin, ymax, xmin, ymin
+ );
+ return WayangGeometry.fromStringInput(wkt);
+ }),
+ SpatialPredicate.INTERSECTS,
+ queryGeometry
+ )
+ .withTargetPlatform(Spark.platform())
+ .count()
+ .collect();
+
+ assertEquals(1, result.size());
+ assertEquals(3L, result.iterator().next());
+ }
+
+ @Test
+ void testSpatialJoinWithSpark() {
+ WayangContext wayangContext = new WayangContext(new Configuration())
+ .withPlugin(Spark.basicPlugin())
+ .withPlugin(Spatial.sparkPlugin());
+
+ JavaPlanBuilder planBuilder = new JavaPlanBuilder(wayangContext)
+ .withJobName("Spatial Join Spark Test");
+
+ List leftData = Arrays.asList(
+ "POINT(0.5 0.5)",
+ "POINT(1.5 1.5)",
+ "POINT(0.25 0.75)"
+ );
+
+ List rightData = Arrays.asList(
+ "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
+ "POLYGON((1 1, 2 1, 2 2, 1 2, 1 1))"
+ );
+
+ Collection result = planBuilder.loadCollection(leftData)
+ .spatialJoin(
+ (input -> WayangGeometry.fromStringInput(input)),
+ planBuilder.loadCollection(rightData),
+ (input -> WayangGeometry.fromStringInput(input)),
+ SpatialPredicate.INTERSECTS
+ )
+ .withTargetPlatform(Spark.platform())
+ .count()
+ .collect();
+
+ assertEquals(1, result.size());
+ assertEquals(3L, result.iterator().next());
+ }
+
+ @Test
+ void testSpatialFilterWithJavaAndSpark() {
+ WayangContext wayangContext = new WayangContext(new Configuration())
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Spark.basicPlugin())
+ .withPlugin(Spatial.plugin());
+
+ JavaPlanBuilder planBuilder = new JavaPlanBuilder(wayangContext)
+ .withJobName("Spatial Filter Java+Spark Test");
+
+ List testData = Arrays.asList(
+ "0.0,0.0,1.0,1.0",
+ "0.5,0.5,1.5,1.5",
+ "2.0,2.0,3.0,3.0",
+ "0.25,0.25,0.75,0.75"
+ );
+
+ WayangGeometry queryGeometry = WayangGeometry.fromStringInput(
+ "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))"
+ );
+
+ // Let Wayang choose the platform
+ Collection result = planBuilder.loadCollection(testData)
+ .spatialFilter(
+ (input -> {
+ String[] parts = input.split(",");
+ double xmin = Double.parseDouble(parts[0]);
+ double ymin = Double.parseDouble(parts[1]);
+ double xmax = Double.parseDouble(parts[2]);
+ double ymax = Double.parseDouble(parts[3]);
+ String wkt = String.format(
+ "POLYGON((%f %f, %f %f, %f %f, %f %f, %f %f))",
+ xmin, ymin, xmax, ymin, xmax, ymax, xmin, ymax, xmin, ymin
+ );
+ return WayangGeometry.fromStringInput(wkt);
+ }),
+ SpatialPredicate.INTERSECTS,
+ queryGeometry
+ )
+ .count()
+ .collect();
+
+ assertEquals(1, result.size());
+ assertEquals(3L, result.iterator().next());
+ }
+
+ // ==================== PostgreSQL Platform Tests ====================
+ // These tests use PostgreSQL spatial operators with ST_Intersects pushdown.
+
+ /**
+ * Helper method to create a PostgreSQL-configured WayangContext.
+ * Connects to spiderdb on localhost:5433.
+ */
+ private Configuration getPostgresConfiguration() {
+ Configuration configuration = new Configuration();
+ configuration.setProperty("wayang.postgres.jdbc.url", "jdbc:postgresql://localhost:5433/spiderdb");
+ configuration.setProperty("wayang.postgres.jdbc.user", "postgres");
+ configuration.setProperty("wayang.postgres.jdbc.password", "postgres");
+ return configuration;
+ }
+
+ @Test
+ @Disabled("Requires local Postgres test database.")
+ void testSpatialFilterWithPostgres() {
+ Configuration configuration = getPostgresConfiguration();
+
+ WayangContext wayangContext = new WayangContext(configuration)
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Postgres.plugin())
+ .withPlugin(Spatial.postgresPlugin());
+
+ JavaPlanBuilder planBuilder = new JavaPlanBuilder(wayangContext)
+ .withJobName("Spatial Filter with Postgres Test");
+
+ // Query geometry: a box in the lower-left quadrant (0,0) to (0.4, 0.4)
+ WayangGeometry queryGeometry = WayangGeometry.fromStringInput(
+ "POLYGON((0.0 0.0, 0.4 0.0, 0.4 0.4, 0.0 0.4, 0.0 0.0))"
+ );
+
+ // Read from spider_boxes table and apply spatial filter using PostgreSQL ST_Intersects
+ Collection result = planBuilder
+ .readTable(new PostgresTableSource("spider_boxes", "x_min", "y_min", "x_max", "y_max", "geom"))
+ .spatialFilter(
+ (Record record) -> WayangGeometry.fromStringInput(record.getString(4)),
+ SpatialPredicate.INTERSECTS,
+ queryGeometry,
+ "geom" // SQL geometry column name for PostgreSQL pushdown
+ )
+ .withTargetPlatform(Postgres.platform())
+ .count()
+ .collect();
+
+ // Verify we got results (exact count depends on data in spider_boxes)
+ assertEquals(1, result.size());
+ Long count = result.iterator().next();
+ assertTrue(count > 0, "Expected at least one box intersecting the query geometry");
+ System.out.println("PostgreSQL Spatial Filter (ST_Intersects): " + count + " boxes intersect the query geometry");
+ }
+
+ @Test
+ @Disabled("Requires local Postgres test database.")
+ void testSpatialFilterWithPostgresAndMapping() {
+ Configuration configuration = getPostgresConfiguration();
+
+ WayangContext wayangContext = new WayangContext(configuration)
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Postgres.plugin())
+ .withPlugin(Spatial.postgresPlugin());
+
+ JavaPlanBuilder planBuilder = new JavaPlanBuilder(wayangContext)
+ .withJobName("Spatial Filter with Postgres and Mapping Test");
+
+ // Query geometry covering center area
+ WayangGeometry queryGeometry = WayangGeometry.fromStringInput(
+ "POLYGON((0.3 0.3, 0.7 0.3, 0.7 0.7, 0.3 0.7, 0.3 0.3))"
+ );
+
+ // Read from spider_boxes, filter spatially with PostgreSQL, then map to extract bounds
+ Collection result = planBuilder
+ .readTable(new PostgresTableSource("spider_boxes", "x_min", "y_min", "x_max", "y_max", "geom"))
+ .spatialFilter(
+ (Record record) -> WayangGeometry.fromStringInput(record.getString(4)),
+ SpatialPredicate.INTERSECTS,
+ queryGeometry,
+ "geom" // SQL geometry column name for PostgreSQL pushdown
+ )
+ .withTargetPlatform(Postgres.platform())
+ .map((Record record) -> String.format("Box: (%.2f,%.2f)-(%.2f,%.2f)",
+ record.getDouble(0), record.getDouble(1),
+ record.getDouble(2), record.getDouble(3)))
+ .collect();
+
+ assertTrue(result.size() > 0, "Expected at least one box intersecting the query geometry");
+ System.out.println("PostgreSQL Spatial Filter + Mapping: " + result.size() + " results");
+ result.stream().limit(5).forEach(System.out::println);
+ }
+
+ @Test
+ @Disabled("Requires local Postgres test database.")
+ void testSpatialFilterWithPostgresContains() {
+ Configuration configuration = getPostgresConfiguration();
+
+ WayangContext wayangContext = new WayangContext(configuration)
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Postgres.plugin())
+ .withPlugin(Spatial.postgresPlugin());
+
+ JavaPlanBuilder planBuilder = new JavaPlanBuilder(wayangContext)
+ .withJobName("Spatial Filter with Postgres Contains Test");
+
+ // Query geometry: full unit square - should contain all boxes that are fully inside
+ WayangGeometry queryGeometry = WayangGeometry.fromStringInput(
+ "POLYGON((0.0 0.0, 1.0 0.0, 1.0 1.0, 0.0 1.0, 0.0 0.0))"
+ );
+
+ // Test WITHIN predicate - find boxes that are completely within the query geometry
+ Collection result = planBuilder
+ .readTable(new PostgresTableSource("spider_boxes", "x_min", "y_min", "x_max", "y_max", "geom"))
+ .spatialFilter(
+ (Record record) -> WayangGeometry.fromStringInput(record.getString(4)),
+ SpatialPredicate.WITHIN,
+ queryGeometry,
+ "geom" // SQL geometry column name for PostgreSQL pushdown
+ )
+ .withTargetPlatform(Postgres.platform())
+ .count()
+ .collect();
+
+ assertEquals(1, result.size());
+ Long count = result.iterator().next();
+ System.out.println("PostgreSQL Spatial Filter (ST_Within): " + count + " boxes within the query geometry");
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/spatial/data/WayangGeometryTest.java b/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/spatial/data/WayangGeometryTest.java
new file mode 100644
index 000000000..02d2855bb
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/spatial/data/WayangGeometryTest.java
@@ -0,0 +1,221 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.data;
+
+import org.apache.wayang.spatial.data.WayangGeometry;
+import org.junit.Test;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.locationtech.jts.geom.Point;
+import org.locationtech.jts.io.WKBReader;
+import org.locationtech.jts.io.WKBWriter;
+import org.locationtech.jts.io.WKTWriter;
+
+import static org.junit.Assert.*;
+
+public class WayangGeometryTest {
+
+ private final GeometryFactory gf = new GeometryFactory();
+
+ @Test
+ public void testFromGeometryStoresAndCachesGeometry() {
+ Point point = gf.createPoint(new Coordinate(1.0, 2.0));
+
+ WayangGeometry wGeometry = WayangGeometry.fromGeometry(point);
+
+ // First call should give us exactly the same instance
+ Geometry first = wGeometry.getGeometry();
+ assertSame("Geometry instance should be the same as the one passed in.",
+ point, first);
+
+ // Second call should return the same cached instance
+ Geometry second = wGeometry.getGeometry();
+ assertSame("Geometry instance should be cached and reused.", first, second);
+
+ // Derived representations should be non-null / non-empty
+ String wkt = wGeometry.getWKT();
+ String wkb = wGeometry.getWKB();
+ String geoJson = wGeometry.getGeoJSON();
+
+ assertNotNull("WKT should not be null.", wkt);
+ assertFalse("WKT should not be empty.", wkt.isEmpty());
+ assertNotNull("WKB should not be null.", wkb);
+ assertFalse("WKB should not be empty.", wkb.isEmpty());
+ assertNotNull("GeoJSON should not be null.", geoJson);
+ assertFalse("GeoJSON should not be empty.", geoJson.isEmpty());
+ }
+
+ @Test
+ public void testFromStringInputWKTAndSRIDCleaning() {
+ // WKT with SRID prefix
+ String wktWithSrid = "SRID=4326;POINT (1 2)";
+ WayangGeometry wGeometry = WayangGeometry.fromStringInput(wktWithSrid);
+
+ Geometry geom = wGeometry.getGeometry();
+ assertTrue("Geometry should be a Point.", geom instanceof Point);
+ Point p = (Point) geom;
+ assertEquals(1.0, p.getX(), 1e-9);
+ assertEquals(2.0, p.getY(), 1e-9);
+
+ // getWKT returns the original stored WKT, including SRID
+ String wkt = wGeometry.getWKT();
+ assertTrue("Original WKT (with SRID) should be preserved.", wkt.startsWith("SRID="));
+
+ // Verify that parsing the same WKT without SRID gives an equal geometry,
+ // which indirectly asserts that cleanSRID() worked as expected.
+ String wktWithoutSrid = "POINT (1 2)";
+ WayangGeometry wGeometryNoSrid = WayangGeometry.fromStringInput(wktWithoutSrid);
+ Geometry geomNoSrid = wGeometryNoSrid.getGeometry();
+
+ assertTrue("Geometry from SRID-prefixed WKT should equal geometry from plain WKT.",
+ geom.equalsExact(geomNoSrid));
+ }
+
+
+ @Test
+ public void testFromStringInputPlainWKT() {
+ // Use JTS writer to generate canonical WKT string
+ Point point = gf.createPoint(new Coordinate(3.0, 4.0));
+ String canonicalWkt = new WKTWriter().write(point);
+
+ WayangGeometry wGeometry = WayangGeometry.fromStringInput(canonicalWkt);
+
+ Geometry geom = wGeometry.getGeometry();
+ assertTrue(geom instanceof Point);
+ assertEquals(point.getCoordinate().x, geom.getCoordinate().x, 1e-9);
+ assertEquals(point.getCoordinate().y, geom.getCoordinate().y, 1e-9);
+
+ // getWKT should match the canonical representation from JTS
+ String wkt = wGeometry.getWKT();
+ assertEquals("WKT should match JTS canonical representation.", canonicalWkt, wkt);
+ }
+
+ @Test
+ public void testFromStringInputWKBHexRoundTrip() {
+ Point original = gf.createPoint(new Coordinate(5.0, 6.0));
+
+ // Encode to WKB hex using same mechanism as WayangGeometry
+ WKBWriter wkbWriter = new WKBWriter();
+ byte[] wkbBytes = wkbWriter.write(original);
+ String wkbHex = WKBWriter.toHex(wkbBytes);
+
+ WayangGeometry wGeometry = WayangGeometry.fromStringInput(wkbHex);
+ Geometry parsed = wGeometry.getGeometry();
+
+ assertTrue("Parsed geometry should be a Point.", parsed instanceof Point);
+ assertTrue("Parsed geometry should be exactly equal to original.",
+ original.equalsExact(parsed));
+
+ // getWKB should give back a hex string that decodes to the same WKB bytes
+ String producedHex = wGeometry.getWKB();
+ byte[] producedBytes = WKBReader.hexToBytes(producedHex);
+ assertArrayEquals("WKB bytes should be identical after round-trip.",
+ wkbBytes, producedBytes);
+ }
+
+ @Test
+ public void testFromStringInputGeoJSONAndRoundTripThroughGeometry() {
+ // Simple GeoJSON Point
+ String geoJson = "{\"type\":\"Point\",\"coordinates\":[7.0,8.0]}";
+
+ WayangGeometry wGeometry = WayangGeometry.fromStringInput(geoJson);
+ Geometry geom = wGeometry.getGeometry();
+
+ assertTrue("Geometry should be a Point.", geom instanceof Point);
+ Point p = (Point) geom;
+ assertEquals(7.0, p.getX(), 1e-9);
+ assertEquals(8.0, p.getY(), 1e-9);
+
+ // Now go back through fromGeometry + GeoJSON
+ WayangGeometry fromGeom = WayangGeometry.fromGeometry(geom);
+ String generatedGeoJson = fromGeom.getGeoJSON();
+
+ // We don't depend on exact string equality/ordering of JSON,
+ // but we do expect that parsing generated GeoJSON yields an equal geometry.
+ WayangGeometry reParsed = WayangGeometry.fromStringInput(generatedGeoJson);
+ Geometry geom2 = reParsed.getGeometry();
+
+ assertTrue("Geometry from re-parsed GeoJSON should be exactly equal.",
+ geom.equalsExact(geom2));
+ }
+
+ @Test
+ public void testPreferredRepresentationOrderWktThenWkbThenGeoJson() {
+ // Start with WKT-only instance
+ Point point = gf.createPoint(new Coordinate(10.0, 20.0));
+ String wkt = new WKTWriter().write(point);
+ WayangGeometry wFromWkt = WayangGeometry.fromStringInput(wkt);
+
+ Geometry g1 = wFromWkt.getGeometry();
+ assertTrue(g1 instanceof Point);
+ assertEquals(point.getX(), g1.getCoordinate().x, 1e-9);
+ assertEquals(point.getY(), g1.getCoordinate().y, 1e-9);
+
+ // Now WKB-only instance
+ WKBWriter wkbWriter = new WKBWriter();
+ byte[] wkbBytes = wkbWriter.write(point);
+ String wkbHex = WKBWriter.toHex(wkbBytes);
+ WayangGeometry wFromWkb = WayangGeometry.fromStringInput(wkbHex);
+
+ Geometry g2 = wFromWkb.getGeometry();
+ assertTrue(g2 instanceof Point);
+ assertTrue(point.equalsExact(g2));
+
+ // And GeoJSON-only instance
+ WayangGeometry wFromGeo = WayangGeometry.fromGeometry(point);
+ String geoJson = wFromGeo.getGeoJSON();
+ WayangGeometry wFromGeoOnly = WayangGeometry.fromStringInput(geoJson);
+
+ Geometry g3 = wFromGeoOnly.getGeometry();
+ assertTrue(g3 instanceof Point);
+ assertTrue(point.equalsExact(g3));
+ }
+
+ @Test(expected = RuntimeException.class)
+ public void testInvalidWKTThrowsRuntimeException() {
+ // This should cause JTS WKTReader to throw ParseException,
+ // which WayangGeometry wraps in a RuntimeException.
+ String invalidWkt = "POINT (1)";
+ WayangGeometry wGeometry = WayangGeometry.fromStringInput(invalidWkt);
+
+ // Should throw
+ wGeometry.getGeometry();
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testNoRepresentationAvailableThrowsIllegalStateException() {
+ // Default constructor, no wkt/wkb/geojson/geometry set
+ WayangGeometry wGeometry = new WayangGeometry();
+
+ // Should hit the "No geometry representation available" branch
+ wGeometry.getGeometry();
+ }
+
+ @Test
+ public void testGetGeometryIsCached() {
+ Point point = gf.createPoint(new Coordinate(11.0, 22.0));
+ WayangGeometry wGeometry = WayangGeometry.fromGeometry(point);
+
+ Geometry g1 = wGeometry.getGeometry();
+ Geometry g2 = wGeometry.getGeometry();
+
+ assertSame("getGeometry should cache and return the same instance.", g1, g2);
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/spatial/integration/PostgresSpatialIntegrationTest.java b/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/spatial/integration/PostgresSpatialIntegrationTest.java
new file mode 100644
index 000000000..66721a3ad
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/spatial/integration/PostgresSpatialIntegrationTest.java
@@ -0,0 +1,284 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.integration;
+
+import org.apache.wayang.basic.data.Record;
+import org.apache.wayang.basic.data.Tuple2;
+import org.apache.wayang.spatial.data.WayangGeometry;
+import org.apache.wayang.basic.operators.*;
+import org.apache.wayang.core.api.Configuration;
+import org.apache.wayang.core.api.WayangContext;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.core.plan.wayangplan.WayangPlan;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.core.util.ReflectionUtils;
+import org.apache.wayang.java.Java;
+import org.apache.wayang.postgres.Postgres;
+import org.apache.wayang.postgres.operators.PostgresTableSource;
+import org.apache.wayang.spark.Spark;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.locationtech.jts.geom.Envelope;
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.GeometryFactory;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@Disabled("Requires local Postgres test database.")
+public class PostgresSpatialIntegrationTest {
+
+
+ public static void main(String[] args) {
+ WayangPlan wayangPlan;
+ Configuration configuration = new Configuration();
+ configuration.setProperty("wayang.postgres.jdbc.url", "jdbc:postgresql://localhost:5432/imdb");
+ configuration.setProperty("wayang.postgres.jdbc.user", "postgres");
+ configuration.setProperty("wayang.postgres.jdbc.password", "password");
+
+ WayangContext wayangContext = new WayangContext(configuration)
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Spark.basicPlugin())
+ .withPlugin(Postgres.plugin());
+
+ Collection collector = new ArrayList<>();
+
+ TableSource customer = new PostgresTableSource("person");
+ MapOperator projection = MapOperator.createProjection(
+ Record.class,
+ Record.class,
+ "name");
+
+ LocalCallbackSink sink = LocalCallbackSink.createCollectingSink(collector, Record.class);
+ customer.connectTo(0,projection,0);
+ projection.connectTo(0,sink,0);
+
+
+ wayangPlan = new WayangPlan(sink);
+
+ wayangContext.execute("PostgreSql test", wayangPlan);
+
+
+ int count = 10;
+ for(Record r : collector) {
+ System.out.println(r.getField(0).toString());
+ if(--count == 0 ) {
+ break;
+ }
+ }
+ System.out.println("Done");
+ }
+
+ WayangContext getTestWayangContext() {
+ Configuration configuration = new Configuration();
+ configuration.setProperty("wayang.postgres.jdbc.url", "jdbc:postgresql://localhost:5433/postgres"); // Default port 5432
+ configuration.setProperty("wayang.postgres.jdbc.user", "postgres");
+ configuration.setProperty("wayang.postgres.jdbc.password", "postgres");
+
+ return new WayangContext(configuration);
+ }
+
+ @Test
+ @Disabled("Requires local Postgres test database.")
+ void testSpatialFilterOperator() {
+ WayangContext wayangContext = getTestWayangContext()
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Spark.basicPlugin())
+ .withPlugin(Postgres.plugin());
+
+ /// Scalar Geometry
+ GeometryFactory geometryFactory = new GeometryFactory();
+ Envelope envelope = new Envelope(0.00, 0.4, 0.00, 0.40);
+ Geometry geom2 = geometryFactory.toGeometry(envelope);
+
+ TableSource spider =
+ new PostgresTableSource("spider_boxes", "id", "geom");
+
+ SpatialFilterOperator spatialFilterOperator = new SpatialFilterOperator(
+ SpatialPredicate.INTERSECTS,
+ (record -> (WayangGeometry.fromStringInput(record.getString(1)))),
+ DataSetType.createDefaultUnchecked(Record.class),
+ WayangGeometry.fromStringInput("POLYGON((0.00 0.00,0.4 0.00,0.4 0.4,0.00 0.4,0.00 0.00))"));
+
+ spatialFilterOperator.getKeyDescriptor().withSqlImplementation("spatialdb", "geom");
+ spatialFilterOperator.addTargetPlatform(Spark.platform());
+ spider.connectTo(0,spatialFilterOperator,0);
+
+ Collection> collector = new ArrayList<>();
+ LocalCallbackSink> sink
+ = LocalCallbackSink.createCollectingSink(collector, DataSetType.createDefaultUnchecked(Record.class));
+ spatialFilterOperator.connectTo(0, sink, 0);
+
+ wayangContext.execute("PostgreSql test", new WayangPlan(sink));
+
+ System.out.println(collector);
+
+ assertEquals(19, collector.size());
+ }
+
+ @Test
+ @Disabled("Requires local Postgres test database.")
+ void testSpatialFilterWithTuple() {
+ WayangContext wayangContext = getTestWayangContext()
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Spark.basicPlugin())
+ .withPlugin(Postgres.plugin());
+
+ /// Scalar Geometry
+ GeometryFactory geometryFactory = new GeometryFactory();
+ Envelope envelope = new Envelope(0.00, 0.4, 0.00, 0.40);
+ Geometry geom2 = geometryFactory.toGeometry(envelope);
+
+ TableSource spider =
+ new PostgresTableSource("spider", "id", "geom");
+
+ MapOperator> mapToTuple = new MapOperator>(
+ record -> {
+ Tuple2 tuple = new Tuple2<>();
+ tuple.field0 = record.getInt(0);
+ tuple.field1 = WayangGeometry.fromStringInput(record.getField(1).toString());
+ return tuple;
+ },
+ Record.class,
+ ReflectionUtils.specify(Tuple2.class)
+ );
+
+ SpatialFilterOperator> spatialFilterOperator = new SpatialFilterOperator>(
+ SpatialPredicate.INTERSECTS,
+ Tuple2::getField1,
+ DataSetType.createDefaultUnchecked(Tuple2.class),
+ WayangGeometry.fromStringInput("POLYGON((0.00 0.00,0.4 0.00,0.4 0.4,0.00 0.4,0.00 0.00))"));
+
+ spatialFilterOperator.addTargetPlatform(Java.platform());
+ spider.connectTo(0,mapToTuple,0);
+ mapToTuple.connectTo(0,spatialFilterOperator,0);
+
+ Collection> collector = new ArrayList<>();
+ LocalCallbackSink> sink
+ = LocalCallbackSink.createCollectingSink(collector, DataSetType.createDefaultUnchecked(Tuple2.class));
+ spatialFilterOperator.connectTo(0, sink, 0);
+
+ wayangContext.execute("PostgreSql test", new WayangPlan(sink));
+
+ System.out.println(collector);
+ assertEquals(19, collector.size());
+ }
+
+ @Test
+ @Disabled("Requires local Postgres test database.")
+ void testSpatialJoin() {
+ WayangContext wayangContext = getTestWayangContext()
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Spark.basicPlugin())
+ .withPlugin(Postgres.plugin());
+
+ TableSource table1 = new PostgresTableSource("spider_boxes", "id", "x_min", "y_min", "x_max", "y_max", "geom");
+
+ // Input polygons: nested axis-aligned squares.
+ final List inputValues = Arrays.asList(
+ WayangGeometry.fromStringInput("POLYGON((0.00 0.00,0.40 0.00,0.40 0.40,0.00 0.40,0.00 0.00))"),
+ WayangGeometry.fromStringInput("POLYGON((0.00 0.00,0.30 0.00,0.30 0.30,0.00 0.30,0.00 0.00))"),
+ WayangGeometry.fromStringInput("POLYGON((0.00 0.00,0.20 0.00,0.20 0.20,0.00 0.20,0.00 0.00))"),
+ WayangGeometry.fromStringInput("POLYGON((0.00 0.00,0.10 0.00,0.10 0.10,0.00 0.10,0.00 0.00))")
+ );
+ CollectionSource inputCollection = new CollectionSource<>(inputValues, WayangGeometry.class);
+
+
+ SpatialJoinOperator spatialJoinOperator = new SpatialJoinOperator<>(
+ record -> WayangGeometry.fromStringInput(record.getString(4)),
+ wgeometry -> wgeometry,
+ Record.class, WayangGeometry.class,
+ SpatialPredicate.INTERSECTS
+ );
+ table1.connectTo(0, spatialJoinOperator, 0);
+ inputCollection.connectTo(0, spatialJoinOperator, 1);
+
+ Collection> collector = new ArrayList<>();
+ LocalCallbackSink> sink
+ = LocalCallbackSink.createCollectingSink(collector, DataSetType.createDefaultUnchecked(Tuple2.class));
+ spatialJoinOperator.connectTo(0, sink, 0);
+ wayangContext.execute("PostgreSql test", new WayangPlan(sink));
+
+ System.out.println(collector);
+
+ assertEquals(30, collector.size());
+ }
+
+ @Test
+ @Disabled("Requires local Postgres test database.")
+ void testSpatialJoinDbSources() {
+ WayangContext wayangContext = getTestWayangContext()
+ .withPlugin(Java.basicPlugin())
+ .withPlugin(Spark.basicPlugin())
+ .withPlugin(Postgres.plugin());
+
+ // Two logical sources over the same table.
+ TableSource table1 = new PostgresTableSource("spider_boxes", "id", "x_min", "y_min", "x_max", "y_max", "geom");
+ TableSource table2 = new PostgresTableSource("spider_boxes", "id", "x_min", "y_min", "x_max", "y_max", "geom");
+
+ // Spatial join on INTERSECTS; both sides use the geom column (index 5).
+ SpatialJoinOperator spatialJoinOperator =
+ new SpatialJoinOperator<>(
+ record -> WayangGeometry.fromStringInput(record.getString(5)),
+ record -> WayangGeometry.fromStringInput(record.getString(5)),
+ Record.class, Record.class,
+ SpatialPredicate.INTERSECTS
+ );
+
+ // Register SQL implementations for both inputs
+ spatialJoinOperator.getKeyDescriptor0()
+ .withSqlImplementation("spiderdb", "geom");
+ spatialJoinOperator.getKeyDescriptor1()
+ .withSqlImplementation("spiderdb", "geom");
+
+ spatialJoinOperator.addTargetPlatform(Postgres.platform());
+
+ // Wire up both DB sources as inputs to the spatial join.
+ table1.connectTo(0, spatialJoinOperator, 0);
+ table2.connectTo(0, spatialJoinOperator, 1);
+
+ // Collect results.
+ Collection> collector = new ArrayList<>();
+ LocalCallbackSink> sink =
+ LocalCallbackSink.createCollectingSink(
+ collector,
+ DataSetType.createDefaultUnchecked(Tuple2.class)
+ );
+ spatialJoinOperator.connectTo(0, sink, 0);
+
+ // Execute the plan.
+ wayangContext.execute("PostgreSql spatial join DB-DB", new WayangPlan(sink));
+
+ // Basic sanity check: we should get at least self-intersections.
+ assertFalse(collector.isEmpty(), "Spatial join result should not be empty.");
+
+ // Semantic check: every returned pair must actually intersect according to JTS.
+ for (Tuple2 pair : collector) {
+ Geometry g1 = WayangGeometry.fromStringInput(pair.field0.getString(1)).getGeometry();
+ Geometry g2 = WayangGeometry.fromStringInput(pair.field1.getString(1)).getGeometry();
+ assertTrue(
+ g1.intersects(g2),
+ "Found non-intersecting pair in spatial join result."
+ );
+ }
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/spatial/operators/java/JavaSpatialFilterOperatorTest.java b/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/spatial/operators/java/JavaSpatialFilterOperatorTest.java
new file mode 100644
index 000000000..a2962fc8f
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/spatial/operators/java/JavaSpatialFilterOperatorTest.java
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.operators.java;
+
+import org.apache.wayang.core.api.Configuration;
+import org.apache.wayang.core.api.Job;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.core.optimizer.DefaultOptimizationContext;
+import org.apache.wayang.core.optimizer.OptimizationContext;
+import org.apache.wayang.core.optimizer.cardinality.CardinalityEstimate;
+import org.apache.wayang.core.plan.wayangplan.Operator;
+import org.apache.wayang.core.platform.CrossPlatformExecutor;
+import org.apache.wayang.core.profiling.NoInstrumentationStrategy;
+import org.apache.wayang.core.types.DataSetType;
+import org.apache.wayang.java.channels.JavaChannelInstance;
+import org.apache.wayang.java.channels.StreamChannel;
+import org.apache.wayang.java.execution.JavaExecutor;
+import org.apache.wayang.java.platform.JavaPlatform;
+import org.apache.wayang.spatial.data.WayangGeometry;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Test suite for {@link JavaSpatialFilterOperator}.
+ */
+class JavaSpatialFilterOperatorTest {
+
+ private static Configuration configuration;
+ private static Job job;
+
+ @BeforeAll
+ static void init() {
+ configuration = new Configuration();
+ job = mock(Job.class);
+ when(job.getConfiguration()).thenReturn(configuration);
+ DefaultOptimizationContext optimizationContext = new DefaultOptimizationContext(job);
+ when(job.getCrossPlatformExecutor()).thenReturn(new CrossPlatformExecutor(job, new NoInstrumentationStrategy()));
+ when(job.getOptimizationContext()).thenReturn(optimizationContext);
+ }
+
+ private static JavaExecutor createExecutor() {
+ return new JavaExecutor(JavaPlatform.getInstance(), job);
+ }
+
+ private static OptimizationContext.OperatorContext createOperatorContext(Operator operator) {
+ OptimizationContext optimizationContext = job.getOptimizationContext();
+ final OptimizationContext.OperatorContext operatorContext = optimizationContext.addOneTimeOperator(operator);
+ for (int i = 0; i < operator.getNumInputs(); i++) {
+ operatorContext.setInputCardinality(i, new CardinalityEstimate(100, 10000, 0.1));
+ }
+ for (int i = 0; i < operator.getNumOutputs(); i++) {
+ operatorContext.setOutputCardinality(i, new CardinalityEstimate(100, 10000, 0.1));
+ }
+ return operatorContext;
+ }
+
+ private static StreamChannel.Instance createStreamChannelInstance() {
+ return (StreamChannel.Instance) StreamChannel.DESCRIPTOR
+ .createChannel(null, configuration)
+ .createInstance(mock(JavaExecutor.class), null, -1);
+ }
+
+ private static StreamChannel.Instance createStreamChannelInstance(Stream> stream) {
+ StreamChannel.Instance instance = createStreamChannelInstance();
+ instance.accept(stream);
+ return instance;
+ }
+
+ @Test
+ void testIntersectsFilter() {
+ // 4 polygons: larger than reference, overlapping, fully inside, fully outside
+ List input = Arrays.asList(
+ new WayangGeometry("POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"),
+ new WayangGeometry("POLYGON ((0.5 0.5, 1.5 0.5, 1.5 1.5, 0.5 1.5, 0.5 0.5))"),
+ new WayangGeometry("POLYGON ((0.2 0.2, 0.8 0.2, 0.8 0.8, 0.2 0.8, 0.2 0.2))"),
+ new WayangGeometry("POLYGON ((5 5, 6 5, 6 6, 5 6, 5 5))")
+ );
+
+ WayangGeometry reference = new WayangGeometry("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))");
+
+ JavaSpatialFilterOperator filterOp = new JavaSpatialFilterOperator<>(
+ SpatialPredicate.INTERSECTS,
+ w -> w,
+ DataSetType.createDefault(WayangGeometry.class),
+ reference
+ );
+
+ JavaChannelInstance[] inputs = new JavaChannelInstance[]{createStreamChannelInstance(input.stream())};
+ JavaChannelInstance[] outputs = new JavaChannelInstance[]{createStreamChannelInstance()};
+ filterOp.evaluate(inputs, outputs, createExecutor(), createOperatorContext(filterOp));
+
+ List result = outputs[0].provideStream().collect(Collectors.toList());
+ assertEquals(3, result.size());
+ }
+
+ @Test
+ void testWithinFilter() {
+ // Same 4 polygons; only the fully-inside one is WITHIN the unit square
+ List input = Arrays.asList(
+ new WayangGeometry("POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"),
+ new WayangGeometry("POLYGON ((0.5 0.5, 1.5 0.5, 1.5 1.5, 0.5 1.5, 0.5 0.5))"),
+ new WayangGeometry("POLYGON ((0.2 0.2, 0.8 0.2, 0.8 0.8, 0.2 0.8, 0.2 0.2))"),
+ new WayangGeometry("POLYGON ((5 5, 6 5, 6 6, 5 6, 5 5))")
+ );
+
+ WayangGeometry reference = new WayangGeometry("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))");
+
+ JavaSpatialFilterOperator filterOp = new JavaSpatialFilterOperator<>(
+ SpatialPredicate.WITHIN,
+ w -> w,
+ DataSetType.createDefault(WayangGeometry.class),
+ reference
+ );
+
+ JavaChannelInstance[] inputs = new JavaChannelInstance[]{createStreamChannelInstance(input.stream())};
+ JavaChannelInstance[] outputs = new JavaChannelInstance[]{createStreamChannelInstance()};
+ filterOp.evaluate(inputs, outputs, createExecutor(), createOperatorContext(filterOp));
+
+ List result = outputs[0].provideStream().collect(Collectors.toList());
+ assertEquals(1, result.size());
+ }
+
+ @Test
+ void testFilterNoMatches() {
+ List input = Arrays.asList(
+ new WayangGeometry("POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"),
+ new WayangGeometry("POLYGON ((0.5 0.5, 1.5 0.5, 1.5 1.5, 0.5 1.5, 0.5 0.5))"),
+ new WayangGeometry("POLYGON ((0.2 0.2, 0.8 0.2, 0.8 0.8, 0.2 0.8, 0.2 0.2))"),
+ new WayangGeometry("POLYGON ((5 5, 6 5, 6 6, 5 6, 5 5))")
+ );
+
+ // Distant geometry — no intersections
+ WayangGeometry reference = new WayangGeometry("POLYGON ((100 100, 101 100, 101 101, 100 101, 100 100))");
+
+ JavaSpatialFilterOperator filterOp = new JavaSpatialFilterOperator<>(
+ SpatialPredicate.INTERSECTS,
+ w -> w,
+ DataSetType.createDefault(WayangGeometry.class),
+ reference
+ );
+
+ JavaChannelInstance[] inputs = new JavaChannelInstance[]{createStreamChannelInstance(input.stream())};
+ JavaChannelInstance[] outputs = new JavaChannelInstance[]{createStreamChannelInstance()};
+ filterOp.evaluate(inputs, outputs, createExecutor(), createOperatorContext(filterOp));
+
+ List result = outputs[0].provideStream().collect(Collectors.toList());
+ assertEquals(0, result.size());
+ }
+}
diff --git a/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/spatial/operators/java/JavaSpatialJoinOperatorTest.java b/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/spatial/operators/java/JavaSpatialJoinOperatorTest.java
new file mode 100644
index 000000000..3b528adfe
--- /dev/null
+++ b/wayang-plugins/wayang-spatial/src/test/java/org/apache/wayang/spatial/operators/java/JavaSpatialJoinOperatorTest.java
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.wayang.spatial.operators.java;
+
+import org.apache.wayang.basic.data.Tuple2;
+import org.apache.wayang.core.api.Configuration;
+import org.apache.wayang.core.api.Job;
+import org.apache.wayang.core.api.spatial.SpatialPredicate;
+import org.apache.wayang.core.optimizer.DefaultOptimizationContext;
+import org.apache.wayang.core.optimizer.OptimizationContext;
+import org.apache.wayang.core.optimizer.cardinality.CardinalityEstimate;
+import org.apache.wayang.core.plan.wayangplan.Operator;
+import org.apache.wayang.core.platform.CrossPlatformExecutor;
+import org.apache.wayang.core.profiling.NoInstrumentationStrategy;
+import org.apache.wayang.java.channels.JavaChannelInstance;
+import org.apache.wayang.java.channels.StreamChannel;
+import org.apache.wayang.java.execution.JavaExecutor;
+import org.apache.wayang.java.platform.JavaPlatform;
+import org.apache.wayang.spatial.data.WayangGeometry;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Test suite for {@link JavaSpatialJoinOperator}.
+ */
+class JavaSpatialJoinOperatorTest {
+
+ private static Configuration configuration;
+ private static Job job;
+
+ @BeforeAll
+ static void init() {
+ configuration = new Configuration();
+ job = mock(Job.class);
+ when(job.getConfiguration()).thenReturn(configuration);
+ DefaultOptimizationContext optimizationContext = new DefaultOptimizationContext(job);
+ when(job.getCrossPlatformExecutor()).thenReturn(new CrossPlatformExecutor(job, new NoInstrumentationStrategy()));
+ when(job.getOptimizationContext()).thenReturn(optimizationContext);
+ }
+
+ private static JavaExecutor createExecutor() {
+ return new JavaExecutor(JavaPlatform.getInstance(), job);
+ }
+
+ private static OptimizationContext.OperatorContext createOperatorContext(Operator operator) {
+ OptimizationContext optimizationContext = job.getOptimizationContext();
+ final OptimizationContext.OperatorContext operatorContext = optimizationContext.addOneTimeOperator(operator);
+ for (int i = 0; i < operator.getNumInputs(); i++) {
+ operatorContext.setInputCardinality(i, new CardinalityEstimate(100, 10000, 0.1));
+ }
+ for (int i = 0; i < operator.getNumOutputs(); i++) {
+ operatorContext.setOutputCardinality(i, new CardinalityEstimate(100, 10000, 0.1));
+ }
+ return operatorContext;
+ }
+
+ private static StreamChannel.Instance createStreamChannelInstance() {
+ return (StreamChannel.Instance) StreamChannel.DESCRIPTOR
+ .createChannel(null, configuration)
+ .createInstance(mock(JavaExecutor.class), null, -1);
+ }
+
+ private static StreamChannel.Instance createStreamChannelInstance(Stream> stream) {
+ StreamChannel.Instance instance = createStreamChannelInstance();
+ instance.accept(stream);
+ return instance;
+ }
+
+ @Test
+ void testIntersectsJoin() {
+ // Left: 3 points — two in box1, one in box2
+ List