Skip to content

Commit b8eeb95

Browse files
authored
[GH-2927] Add geometry ↔ Box2D Catalyst cast (#2952)
1 parent 1ae9c01 commit b8eeb95

12 files changed

Lines changed: 727 additions & 19 deletions

File tree

spark/common/src/main/scala/org/apache/sedona/sql/SedonaSqlExtensions.scala

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import org.apache.sedona.spark.SedonaContext
2222
import org.apache.spark.SparkContext
2323
import org.apache.spark.sql.SparkSessionExtensions
2424
import org.apache.spark.sql.parser.ParserFactory
25+
import org.apache.spark.sql.sedona_sql.optimization.Box2DCastResolutionRule
2526
import org.slf4j.{Logger, LoggerFactory}
2627

2728
class SedonaSqlExtensions extends (SparkSessionExtensions => Unit) {
@@ -36,6 +37,11 @@ class SedonaSqlExtensions extends (SparkSessionExtensions => Unit) {
3637
_ => ()
3738
})
3839

40+
// Resolve geometry↔Box2D casts during analysis so the analyzer accepts
41+
// `CAST(geom AS box2d)` / `CAST(box AS geometry)` despite Spark's stock cast resolver
42+
// refusing arbitrary UDT-to-UDT casts.
43+
e.injectResolutionRule(_ => new Box2DCastResolutionRule)
44+
3945
// Inject Sedona SQL parser
4046
if (enableParser) {
4147
// Try to inject the Sedona SQL parser but gracefully handle initialization failures.

spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Constructors.scala

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -554,9 +554,8 @@ private[apache] case class ST_MakeBox2D(inputExpressions: Seq[Expression])
554554

555555
/**
556556
* Convert a Box2D to a closed rectangular polygon Geometry. Equivalent to PostGIS {@code
557-
* box2d::geometry}. Exposed as a function rather than a Catalyst implicit cast because UDT-to-UDT
558-
* implicit casts require Catalyst-level work; ST_GeomFromBox2D lives alongside the other
559-
* ST_GeomFrom* constructors.
557+
* box2d::geometry}. `CAST(box AS geometry)` is also accepted (resolved to this expression by the
558+
* Box2D cast resolution rule).
560559
*
561560
* @param inputExpressions
562561
*/
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.spark.sql.sedona_sql.optimization
20+
21+
import org.apache.spark.sql.catalyst.expressions.Cast
22+
import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
23+
import org.apache.spark.sql.catalyst.rules.Rule
24+
import org.apache.spark.sql.sedona_sql.UDT.{Box2DUDT, GeometryUDT}
25+
import org.apache.spark.sql.sedona_sql.expressions.{ST_Box2D, ST_GeomFromBox2D}
26+
27+
/**
28+
* Analyzer rule that resolves Catalyst casts between Sedona UDTs that Spark's stock cast resolver
29+
* does not handle. Specifically:
30+
*
31+
* - `CAST(geom AS box2d)` → `ST_Box2D(geom)` (planar bounding box of the geometry)
32+
* - `CAST(box AS geometry)` → `ST_GeomFromBox2D(box)` (rectangular polygon from a Box2D)
33+
*
34+
* Spark's `Cast.canCast` returns `false` for arbitrary UDT-to-UDT casts, so without this rule the
35+
* analyzer would reject the cast. We rewrite during analysis (before `CheckAnalysis`) so the
36+
* downstream optimizer and codegen path see the expression tree of an ordinary Sedona expression.
37+
*
38+
* Implicit type coercion (e.g. passing a Geometry into a Box2D-typed function argument without an
39+
* explicit cast) is intentionally out of scope here; it requires hooking into Catalyst's type
40+
* coercion rules and is tracked separately.
41+
*/
42+
class Box2DCastResolutionRule extends Rule[LogicalPlan] {
43+
44+
override def apply(plan: LogicalPlan): LogicalPlan = plan.transformAllExpressions {
45+
case c: Cast
46+
if c.child.resolved
47+
&& c.child.dataType.isInstanceOf[GeometryUDT]
48+
&& c.dataType.isInstanceOf[Box2DUDT] =>
49+
ST_Box2D(Seq(c.child))
50+
51+
case c: Cast
52+
if c.child.resolved
53+
&& c.child.dataType.isInstanceOf[Box2DUDT]
54+
&& c.dataType.isInstanceOf[GeometryUDT] =>
55+
ST_GeomFromBox2D(Seq(c.child))
56+
}
57+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.sedona.sql
20+
21+
import org.apache.spark.sql.catalyst.expressions.{Alias, AttributeReference, Cast, Expression, Literal}
22+
import org.apache.spark.sql.catalyst.plans.logical.{LocalRelation, LogicalPlan, Project}
23+
import org.apache.spark.sql.sedona_sql.UDT.{Box2DUDT, GeometryUDT}
24+
import org.apache.spark.sql.sedona_sql.expressions.{ST_Box2D, ST_GeomFromBox2D}
25+
import org.apache.spark.sql.sedona_sql.optimization.Box2DCastResolutionRule
26+
import org.apache.spark.sql.types.LongType
27+
import org.scalatest.funspec.AnyFunSpec
28+
29+
class Box2DCastResolutionRuleSuite extends AnyFunSpec {
30+
31+
private val rule = new Box2DCastResolutionRule
32+
33+
private def projectExprPlan(input: AttributeReference, expr: Expression): LogicalPlan = {
34+
val rel = LocalRelation(input)
35+
Project(Seq(Alias(expr, "out")()), rel)
36+
}
37+
38+
describe("Box2DCastResolutionRule") {
39+
it("rewrites Cast(geometry-typed expression, Box2DUDT) into ST_Box2D") {
40+
val geomAttr = AttributeReference("g", GeometryUDT(), nullable = true)()
41+
val cast = Cast(geomAttr, Box2DUDT)
42+
val rewritten = rule(projectExprPlan(geomAttr, cast))
43+
val outExpr =
44+
rewritten.asInstanceOf[Project].projectList.head.asInstanceOf[Alias].child
45+
assert(outExpr.isInstanceOf[ST_Box2D])
46+
assert(outExpr.asInstanceOf[ST_Box2D].inputExpressions == Seq(geomAttr))
47+
assert(outExpr.dataType.isInstanceOf[Box2DUDT])
48+
}
49+
50+
it("rewrites Cast(Box2D-typed expression, GeometryUDT) into ST_GeomFromBox2D") {
51+
val boxAttr = AttributeReference("b", Box2DUDT, nullable = true)()
52+
val cast = Cast(boxAttr, GeometryUDT())
53+
val rewritten = rule(projectExprPlan(boxAttr, cast))
54+
val outExpr =
55+
rewritten.asInstanceOf[Project].projectList.head.asInstanceOf[Alias].child
56+
assert(outExpr.isInstanceOf[ST_GeomFromBox2D])
57+
assert(outExpr.asInstanceOf[ST_GeomFromBox2D].inputExpressions == Seq(boxAttr))
58+
assert(outExpr.dataType.isInstanceOf[GeometryUDT])
59+
}
60+
61+
it("leaves unrelated casts untouched") {
62+
val geomAttr = AttributeReference("g", GeometryUDT(), nullable = true)()
63+
val cast = Cast(Literal(1), LongType)
64+
val rewritten = rule(projectExprPlan(geomAttr, cast))
65+
val outExpr =
66+
rewritten.asInstanceOf[Project].projectList.head.asInstanceOf[Alias].child
67+
assert(outExpr.isInstanceOf[Cast])
68+
}
69+
}
70+
}

spark/spark-3.4/src/main/scala/org/apache/sedona/sql/parser/SedonaSqlAstBuilder.scala

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,19 @@ package org.apache.sedona.sql.parser
2020

2121
import org.apache.spark.sql.catalyst.parser.SqlBaseParser._
2222
import org.apache.spark.sql.execution.SparkSqlAstBuilder
23-
import org.apache.spark.sql.sedona_sql.UDT.GeometryUDT
23+
import org.apache.spark.sql.sedona_sql.UDT.{Box2DUDT, GeometryUDT}
2424
import org.apache.spark.sql.types.DataType
2525

2626
class SedonaSqlAstBuilder extends SparkSqlAstBuilder {
2727

2828
/**
29-
* Override the method to handle the geometry data type
30-
* @param ctx
31-
* @return
29+
* Recognize Sedona UDT names (GEOMETRY, BOX2D) as primitive data types so SQL `CAST(... AS
30+
* geometry)` / `CAST(... AS box2d)` parse to the matching UDT.
3231
*/
3332
override def visitPrimitiveDataType(ctx: PrimitiveDataTypeContext): DataType = {
3433
ctx.getText.toUpperCase() match {
3534
case "GEOMETRY" => GeometryUDT()
35+
case "BOX2D" => Box2DUDT
3636
case _ => super.visitPrimitiveDataType(ctx)
3737
}
3838
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.sedona.sql
20+
21+
import org.apache.sedona.common.geometryObjects.Box2D
22+
import org.apache.spark.sql.functions.{col, expr}
23+
import org.apache.spark.sql.sedona_sql.UDT.{Box2DUDT, GeometryUDT}
24+
25+
class Box2DCastSuite extends TestBaseScala {
26+
27+
/**
28+
* SQL `CAST(... AS box2d)` / `CAST(... AS geometry)` parsing requires Sedona's
29+
* `SedonaSqlAstBuilder` to be active. The test base randomizes
30+
* `spark.sedona.enableParserExtension` across CI runs, and `SparkContext` is JVM-singleton so
31+
* the active value can differ from this suite's session-level config. Probe directly by parsing
32+
* a tiny CAST: this matches the behavior the SQL tests actually depend on, and caches the
33+
* answer for the rest of the suite. DataFrame `.cast(...)` tests run unconditionally because
34+
* the resolution rule is always injected.
35+
*/
36+
private lazy val sqlCastSupported: Boolean = {
37+
try {
38+
sparkSession
39+
.sql("SELECT CAST(ST_GeomFromText('POINT (0 0)') AS box2d) AS b")
40+
.collect()
41+
true
42+
} catch {
43+
case _: org.apache.spark.sql.catalyst.parser.ParseException => false
44+
}
45+
}
46+
47+
describe("Geometry ↔ Box2D Catalyst cast") {
48+
49+
it("DataFrame .cast(Box2DUDT) rewrites to ST_Box2D") {
50+
import sparkSession.implicits._
51+
val df = Seq("LINESTRING (0 0, 10 20)").toDF("wkt")
52+
val box = df
53+
.select(expr("ST_GeomFromText(wkt)").alias("g"))
54+
.select(col("g").cast(Box2DUDT).alias("b"))
55+
.collect()
56+
.head
57+
.getAs[Box2D]("b")
58+
assert(box == new Box2D(0.0, 0.0, 10.0, 20.0))
59+
}
60+
61+
it("DataFrame .cast(GeometryUDT) rewrites to ST_GeomFromBox2D") {
62+
val df =
63+
sparkSession.sql("SELECT ST_MakeBox2D(ST_Point(0.0, 0.0), ST_Point(2.0, 4.0)) AS b")
64+
val wkt = df
65+
.select(col("b").cast(GeometryUDT()).alias("g"))
66+
.selectExpr("ST_AsText(g) AS wkt")
67+
.collect()
68+
.head
69+
.getString(0)
70+
assert(wkt == "POLYGON ((0 0, 0 4, 2 4, 2 0, 0 0))")
71+
}
72+
73+
it("DataFrame round-trip Geometry → Box2D → Geometry yields the envelope polygon") {
74+
import sparkSession.implicits._
75+
val df = Seq("LINESTRING (0 0, 5 10)").toDF("wkt")
76+
val wkt = df
77+
.select(expr("ST_GeomFromText(wkt)").alias("g"))
78+
.select(col("g").cast(Box2DUDT).cast(GeometryUDT()).alias("env"))
79+
.selectExpr("ST_AsText(env) AS wkt")
80+
.collect()
81+
.head
82+
.getString(0)
83+
assert(wkt == "POLYGON ((0 0, 0 10, 5 10, 5 0, 0 0))")
84+
}
85+
86+
it("DataFrame .cast(Box2DUDT) on NULL geometry returns null") {
87+
val box = sparkSession
88+
.sql("SELECT ST_GeomFromText(NULL) AS g")
89+
.select(col("g").cast(Box2DUDT).alias("b"))
90+
.collect()
91+
.head
92+
.getAs[Box2D]("b")
93+
assert(box == null)
94+
}
95+
96+
it("SQL CAST(geom AS box2d) returns the planar bbox") {
97+
assume(
98+
sqlCastSupported,
99+
"Sedona SQL parser extension is required for `CAST(... AS box2d)` syntax")
100+
val box = sparkSession
101+
.sql("SELECT CAST(ST_GeomFromText('LINESTRING (0 0, 10 20)') AS box2d) AS b")
102+
.collect()
103+
.head
104+
.getAs[Box2D]("b")
105+
assert(box == new Box2D(0.0, 0.0, 10.0, 20.0))
106+
}
107+
108+
it("SQL CAST(box AS geometry) returns the rectangular polygon") {
109+
assume(
110+
sqlCastSupported,
111+
"Sedona SQL parser extension is required for `CAST(... AS geometry)` syntax")
112+
val wkt = sparkSession
113+
.sql("SELECT ST_AsText(CAST(ST_MakeBox2D(ST_Point(0.0, 0.0), ST_Point(2.0, 4.0)) AS geometry)) AS w")
114+
.collect()
115+
.head
116+
.getString(0)
117+
assert(wkt == "POLYGON ((0 0, 0 4, 2 4, 2 0, 0 0))")
118+
}
119+
120+
it("SQL round-trip Geometry → Box2D → Geometry yields the envelope polygon") {
121+
assume(
122+
sqlCastSupported,
123+
"Sedona SQL parser extension is required for `CAST(... AS ...)` between UDTs")
124+
val wkt = sparkSession
125+
.sql("SELECT ST_AsText(CAST(CAST(ST_GeomFromText('LINESTRING (0 0, 5 10)') AS box2d) AS geometry)) AS w")
126+
.collect()
127+
.head
128+
.getString(0)
129+
assert(wkt == "POLYGON ((0 0, 0 10, 5 10, 5 0, 0 0))")
130+
}
131+
132+
it("SQL CAST(NULL geometry AS box2d) returns null") {
133+
assume(
134+
sqlCastSupported,
135+
"Sedona SQL parser extension is required for `CAST(... AS box2d)` syntax")
136+
val box = sparkSession
137+
.sql("SELECT CAST(ST_GeomFromText(NULL) AS box2d) AS b")
138+
.collect()
139+
.head
140+
.getAs[Box2D]("b")
141+
assert(box == null)
142+
}
143+
}
144+
}

spark/spark-3.5/src/main/scala/org/apache/sedona/sql/parser/SedonaSqlAstBuilder.scala

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,19 @@ package org.apache.sedona.sql.parser
2020

2121
import org.apache.spark.sql.catalyst.parser.SqlBaseParser._
2222
import org.apache.spark.sql.execution.SparkSqlAstBuilder
23-
import org.apache.spark.sql.sedona_sql.UDT.GeometryUDT
23+
import org.apache.spark.sql.sedona_sql.UDT.{Box2DUDT, GeometryUDT}
2424
import org.apache.spark.sql.types.DataType
2525

2626
class SedonaSqlAstBuilder extends SparkSqlAstBuilder {
2727

2828
/**
29-
* Override the method to handle the geometry data type
30-
* @param ctx
31-
* @return
29+
* Recognize Sedona UDT names (GEOMETRY, BOX2D) as primitive data types so SQL `CAST(... AS
30+
* geometry)` / `CAST(... AS box2d)` parse to the matching UDT.
3231
*/
3332
override def visitPrimitiveDataType(ctx: PrimitiveDataTypeContext): DataType = {
3433
ctx.getText.toUpperCase() match {
3534
case "GEOMETRY" => GeometryUDT()
35+
case "BOX2D" => Box2DUDT
3636
case _ => super.visitPrimitiveDataType(ctx)
3737
}
3838
}

0 commit comments

Comments
 (0)