This is an automated email from the ASF dual-hosted git repository. afs pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/jena.git
commit 185ca9ff747b0dfa5c0fb75c125819afec2cd217 Author: Edmond Chuc <[email protected]> AuthorDate: Mon Sep 28 13:46:15 2026 +1000 GH-4265: Add GeoSPARQL 1.1 area functions Implement geof:area and geof:metricArea. --- .../nontopological/filter_functions/AreaFF.java | 53 +++++ .../filter_functions/MetricAreaFF.java | 43 +++++ .../implementation/AreaUnitsOfMeasure.java | 76 ++++++++ .../geosparql/implementation/GeometryArea.java | 90 +++++++++ .../geosparql/implementation/GeometryWrapper.java | 19 ++ .../function_registration/NonTopological.java | 4 + .../geosparql/implementation/vocabulary/Geof.java | 3 + .../implementation/vocabulary/Unit_URI.java | 12 ++ .../filter_functions/AreaFFTest.java | 164 ++++++++++++++++ .../filter_functions/AreaFunctionTestSupport.java | 51 +++++ .../filter_functions/MetricAreaFFTest.java | 107 +++++++++++ .../geosparql/implementation/GeometryAreaTest.java | 213 +++++++++++++++++++++ 12 files changed, 835 insertions(+) diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/AreaFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/AreaFF.java new file mode 100644 index 0000000000..3e1fe0bf55 --- /dev/null +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/AreaFF.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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.apache.jena.geosparql.geof.nontopological.filter_functions; + +import org.apache.jena.datatypes.DatatypeFormatException; +import org.apache.jena.datatypes.xsd.XSDDatatype; +import org.apache.jena.geosparql.implementation.GeometryWrapper; +import org.apache.jena.geosparql.implementation.UnitsConversionException; +import org.apache.jena.geosparql.implementation.registry.UnitsURIException; +import org.apache.jena.sparql.expr.ExprEvalException; +import org.apache.jena.sparql.expr.NodeValue; +import org.apache.jena.sparql.function.FunctionBase2; + +/** Implements geof:area with an explicit area unit IRI or xsd:anyURI literal. */ +public class AreaFF extends FunctionBase2 { + + @Override + public NodeValue exec(NodeValue value, NodeValue units) { + try { + GeometryWrapper geometry = GeometryWrapper.extract(value); + String unitsURI; + if (units.isIRI()) { + unitsURI = units.asNode().getURI(); + } else if (units.asNode().isLiteral() + && XSDDatatype.XSDanyURI.getURI().equals(units.asNode().getLiteralDatatypeURI())) { + unitsURI = units.asNode().getLiteralLexicalForm(); + } else { + throw new ExprEvalException("Expected a unit IRI or xsd:anyURI literal: " + units); + } + return NodeValue.makeDouble(geometry.area(unitsURI)); + } catch (DatatypeFormatException | UnitsConversionException | UnitsURIException ex) { + throw new ExprEvalException(ex.getMessage(), ex); + } + } +} diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/MetricAreaFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/MetricAreaFF.java new file mode 100644 index 0000000000..4a19f52dac --- /dev/null +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/MetricAreaFF.java @@ -0,0 +1,43 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.apache.jena.geosparql.geof.nontopological.filter_functions; + +import org.apache.jena.datatypes.DatatypeFormatException; +import org.apache.jena.geosparql.implementation.GeometryWrapper; +import org.apache.jena.geosparql.implementation.UnitsConversionException; +import org.apache.jena.geosparql.implementation.registry.UnitsURIException; +import org.apache.jena.sparql.expr.ExprEvalException; +import org.apache.jena.sparql.expr.NodeValue; +import org.apache.jena.sparql.function.FunctionBase1; + +/** Implements geof:metricArea in square metres. */ +public class MetricAreaFF extends FunctionBase1 { + + @Override + public NodeValue exec(NodeValue value) { + try { + GeometryWrapper geometry = GeometryWrapper.extract(value); + return NodeValue.makeDouble(geometry.area()); + } catch (DatatypeFormatException | UnitsConversionException | UnitsURIException ex) { + throw new ExprEvalException(ex.getMessage(), ex); + } + } +} diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/AreaUnitsOfMeasure.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/AreaUnitsOfMeasure.java new file mode 100644 index 0000000000..eda857c922 --- /dev/null +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/AreaUnitsOfMeasure.java @@ -0,0 +1,76 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.apache.jena.geosparql.implementation; + +import java.util.HashMap; +import java.util.Map; + +import javax.measure.Unit; +import javax.measure.quantity.Area; + +import org.apache.jena.geosparql.implementation.registry.UnitsRegistry; +import org.apache.jena.geosparql.implementation.registry.UnitsURIException; +import org.apache.jena.geosparql.implementation.vocabulary.Unit_URI; +import org.apache.sis.measure.Units; + +/** + * Explicit area units accepted by {@code geof:area}. + * + * This registry is separate from {@link UnitsRegistry}, which does not provide + * quantity-typed lookup. Units added only to {@code UnitsRegistry} are not + * accepted as area units here. + */ +final class AreaUnitsOfMeasure { + private static final Map<String, Unit<Area>> UNITS = new HashMap<>(); + + static { + UNITS.put(Unit_URI.SQUARE_METRE_QUDT, Units.SQUARE_METRE); + UNITS.put(Unit_URI.SQUARE_KILOMETRE_QUDT, square(Units.KILOMETRE)); + UNITS.put(Unit_URI.SQUARE_CENTIMETRE_QUDT, square(Units.CENTIMETRE)); + UNITS.put(Unit_URI.SQUARE_MILLIMETRE_QUDT, square(Units.MILLIMETRE)); + UNITS.put(Unit_URI.SQUARE_FOOT_QUDT, square(Units.FOOT)); + UNITS.put(Unit_URI.SQUARE_YARD_QUDT, square(Units.FOOT.multiply(3))); + UNITS.put(Unit_URI.SQUARE_INCH_QUDT, square(Units.INCH)); + UNITS.put(Unit_URI.SQUARE_MILE_QUDT, square(Units.STATUTE_MILE)); + UNITS.put(Unit_URI.HECTARE_QUDT, Units.HECTARE); + UNITS.put(Unit_URI.ACRE_QUDT, Units.FOOT.pow(2).multiply(43560).asType(Area.class)); + } + + private AreaUnitsOfMeasure() { + } + + static Unit<Area> getUnit(String uri) { + Unit<Area> unit = UNITS.get(uri); + if (unit != null) { + return unit; + } + try { + UnitsRegistry.getUnit(uri); + } catch (UnitsURIException ex) { + throw new UnitsURIException("Unrecognised area unit URI: " + uri, ex); + } + throw new UnitsConversionException("Area requires an area unit: " + uri); + } + + private static Unit<Area> square(Unit<?> lengthUnit) { + return lengthUnit.pow(2).asType(Area.class); + } +} diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryArea.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryArea.java new file mode 100644 index 0000000000..501e9f40d8 --- /dev/null +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryArea.java @@ -0,0 +1,90 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.apache.jena.geosparql.implementation; + +import javax.measure.IncommensurableException; +import javax.measure.Unit; +import javax.measure.quantity.Area; + +import org.apache.sis.measure.Quantities; +import org.apache.sis.referencing.CRS; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Polygon; +import org.opengis.referencing.crs.CoordinateReferenceSystem; +import org.opengis.referencing.cs.CoordinateSystem; + +/** + * Calculates planar area for polygonal geometries and converts to target area units. + * + * <p>Area is calculated for {@link Polygon} and {@link MultiPolygon} geometries; + * empty geometries and non-polygonal types return zero. Non-empty polygonal geometries + * require a non-geographic horizontal CRS with equivalent linear units on both axes. + */ +final class GeometryArea { + private GeometryArea() { + } + + static double calculate(GeometryWrapper geometry, String targetUnitUri) { + Unit<Area> targetUnit = AreaUnitsOfMeasure.getUnit(targetUnitUri); + Geometry xyGeometry = geometry.getXYGeometry(); + if (!(xyGeometry instanceof Polygon || xyGeometry instanceof MultiPolygon) + || xyGeometry.isEmpty()) { + return 0.0; + } + if (geometry.getSrsInfo().isGeographic()) { + throw new UnitsConversionException("Area is not supported for geographic coordinate reference systems."); + } + Unit<Area> sourceUnit = equivalentHorizontalAxisUnits(geometry).getUnit() + .pow(2).asType(Area.class); + return Quantities.create(xyGeometry.getArea(), sourceUnit) + .to(targetUnit).getValue().doubleValue(); + } + + private static UnitsOfMeasure equivalentHorizontalAxisUnits(GeometryWrapper geometry) { + CoordinateReferenceSystem horizontalCrs = CRS.getHorizontalComponent( + geometry.getSrsInfo().getCrs()); + if (horizontalCrs == null || horizontalCrs.getCoordinateSystem() == null + || horizontalCrs.getCoordinateSystem().getDimension() != 2) { + throw new UnitsConversionException( + "Area requires a two-dimensional horizontal source coordinate system."); + } + CoordinateSystem coordinateSystem = horizontalCrs.getCoordinateSystem(); + Unit<?> firstAxisUnit = coordinateSystem.getAxis(0).getUnit(); + Unit<?> secondAxisUnit = coordinateSystem.getAxis(1).getUnit(); + try { + if (!firstAxisUnit.isCompatible(secondAxisUnit) + || !firstAxisUnit.getConverterToAny(secondAxisUnit).isIdentity()) { + throw new UnitsConversionException( + "Area requires equivalent linear units on both horizontal source axes."); + } + } catch (IncommensurableException e) { + throw new UnitsConversionException( + "Area requires equivalent linear units on both horizontal source axes.", e); + } + UnitsOfMeasure sourceUnits = new UnitsOfMeasure(horizontalCrs); + if (!sourceUnits.isLinearUnits()) { + throw new UnitsConversionException( + "Area requires linear units on both horizontal source axes."); + } + return sourceUnits; + } +} diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryWrapper.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryWrapper.java index 038d471348..411d5319e9 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryWrapper.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryWrapper.java @@ -810,6 +810,25 @@ public class GeometryWrapper implements Serializable { return new GeometryWrapper(parsingGeo, xyGeo, srsInfo.getSrsURI(), geometryDatatypeURI, dimensionInfo); } + /** Polygon or MultiPolygon area defaulting to square metres. */ + public double area() { + return area(Unit_URI.SQUARE_METRE_QUDT); + } + + /** + * Returns Polygon or MultiPolygon area in the requested area unit. + * + * Empty and non-polygonal geometries return zero. Nonempty polygons require + * a non-geographic horizontal CRS with equivalent linear units on both axes. + * + * @param unitsURI URI of an explicit area unit. + * @throws org.apache.jena.geosparql.implementation.registry.UnitsURIException if the unit URI is unknown. + * @throws UnitsConversionException if the source CRS or target units are unsupported. + */ + public double area(String unitsURI) { + return GeometryArea.calculate(this, unitsURI); + } + /** * Returns the planar centroid in the source SRS and datatype. A result in a * three-dimensional CRS has {@code Z=0}; {@code Z} and {@code M} are ignored diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/NonTopological.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/NonTopological.java index fd893806ae..7397e06c7b 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/NonTopological.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/NonTopological.java @@ -20,6 +20,7 @@ */ package org.apache.jena.geosparql.implementation.function_registration; +import org.apache.jena.geosparql.geof.nontopological.filter_functions.AreaFF; import org.apache.jena.geosparql.geof.nontopological.filter_functions.AsGeoJSONFF; import org.apache.jena.geosparql.geof.nontopological.filter_functions.BoundaryFF; import org.apache.jena.geosparql.geof.nontopological.filter_functions.BufferFF; @@ -30,6 +31,7 @@ import org.apache.jena.geosparql.geof.nontopological.filter_functions.DistanceFF import org.apache.jena.geosparql.geof.nontopological.filter_functions.EnvelopFF; import org.apache.jena.geosparql.geof.nontopological.filter_functions.GetSRIDFF; import org.apache.jena.geosparql.geof.nontopological.filter_functions.IntersectionFF; +import org.apache.jena.geosparql.geof.nontopological.filter_functions.MetricAreaFF; import org.apache.jena.geosparql.geof.nontopological.filter_functions.SymmetricDifferenceFF; import org.apache.jena.geosparql.geof.nontopological.filter_functions.UnionFF; import org.apache.jena.geosparql.implementation.vocabulary.Geof; @@ -62,6 +64,8 @@ public class NonTopological { registry.put(Geof.INTERSECTION_NAME, IntersectionFF.class); registry.put(Geof.SYMDIFFERENCE_NAME, SymmetricDifferenceFF.class); registry.put(Geof.UNION_NAME, UnionFF.class); + registry.put(Geof.AREA_NAME, AreaFF.class); + registry.put(Geof.METRIC_AREA_NAME, MetricAreaFF.class); registry.put(Geof.AS_GEOJSON, AsGeoJSONFF.class); } } diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/vocabulary/Geof.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/vocabulary/Geof.java index aa3384ed29..c7eae6da3f 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/vocabulary/Geof.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/vocabulary/Geof.java @@ -75,6 +75,9 @@ public interface Geof { public static final String CONVEXHULL_NAME = GEOF_URI + "convexHull"; public static final String GETSRID_NAME = GEOF_URI + "getSRID"; + public static final String AREA_NAME = GEOF_URI + "area"; + public static final String METRIC_AREA_NAME = GEOF_URI + "metricArea"; + // Geometry property function symbols: public static final String GEOMETRY_TYPE = GEOF_URI + "geometryType"; public static final String IS_3D = GEOF_URI + "is3D"; diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/vocabulary/Unit_URI.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/vocabulary/Unit_URI.java index f2f4270137..03727200eb 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/vocabulary/Unit_URI.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/vocabulary/Unit_URI.java @@ -53,6 +53,18 @@ public interface Unit_URI { public static final String NAUTICAL_MILE_URL = GeoSPARQL_URI.UOM_URI + "nauticalMile"; public static final String US_SURVEY_FOOT_URL = GeoSPARQL_URI.UOM_URI + "surveyFootUS"; + // Area units from QUDT. + public static final String SQUARE_METRE_QUDT = "http://qudt.org/vocab/unit/M2"; + public static final String SQUARE_KILOMETRE_QUDT = "http://qudt.org/vocab/unit/KiloM2"; + public static final String SQUARE_CENTIMETRE_QUDT = "http://qudt.org/vocab/unit/CentiM2"; + public static final String SQUARE_MILLIMETRE_QUDT = "http://qudt.org/vocab/unit/MilliM2"; + public static final String SQUARE_FOOT_QUDT = "http://qudt.org/vocab/unit/FT2"; + public static final String SQUARE_YARD_QUDT = "http://qudt.org/vocab/unit/YD2"; + public static final String SQUARE_INCH_QUDT = "http://qudt.org/vocab/unit/IN2"; + public static final String SQUARE_MILE_QUDT = "http://qudt.org/vocab/unit/MI2"; + public static final String HECTARE_QUDT = "http://qudt.org/vocab/unit/HA"; + public static final String ACRE_QUDT = "http://qudt.org/vocab/unit/AC"; + //URN references in: https://sis.apache.org/apidocs/org/apache/sis/measure/Units.html //Angular public static final String RADIAN_URN = "urn:ogc:def:uom:EPSG::9101"; diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/AreaFFTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/AreaFFTest.java new file mode 100644 index 0000000000..267a4386e3 --- /dev/null +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/AreaFFTest.java @@ -0,0 +1,164 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.apache.jena.geosparql.geof.nontopological.filter_functions; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import org.apache.jena.geosparql.configuration.GeoSPARQLConfig; +import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; +import org.apache.jena.geosparql.implementation.vocabulary.Unit_URI; +import org.apache.jena.graph.Node; +import org.apache.jena.graph.NodeFactory; +import org.apache.jena.query.QueryBuildException; +import org.apache.jena.sparql.expr.ExprEvalException; +import org.apache.jena.sparql.expr.NodeValue; +import org.junit.BeforeClass; +import org.junit.Test; + +public class AreaFFTest { + private final AreaFF function = new AreaFF(); + private static final String PROJECTED = "<http://www.opengis.net/def/crs/EPSG/0/27700> "; + private static final String POLYGON = "'<http://www.opengis.net/def/crs/EPSG/0/27700> POLYGON ((0 0, 1000 0, 1000 1000, 0 1000, 0 0))'^^geo:wktLiteral"; + private static final String SQUARE_METRE = "<" + Unit_URI.SQUARE_METRE_QUDT + ">"; + + @BeforeClass + public static void setup() { + GeoSPARQLConfig.setupNoIndex(); + } + + @Test + public void polygonReturnsDoubleSquareMetres() { + String polygon = "'" + PROJECTED + "POLYGON ((0 0, 3 0, 3 4, 0 4, 0 0))'^^geo:wktLiteral"; + assertEquals(NodeValue.makeDouble(12).asNode(), evaluate(polygon, SQUARE_METRE)); + } + + @Test + public void nonPolygonalAndEmptyGeometriesReturnZero() { + for (String wkt : new String[] { "POINT (1 2)", PROJECTED + "POLYGON EMPTY" }) { + assertEquals(NodeValue.makeDouble(0).asNode(), evaluate("'" + wkt + "'^^geo:wktLiteral", SQUARE_METRE)); + } + } + + @Test + public void geographicPolygonRaisesExpressionError() { + String wkt = "POLYGON ((0 0, 1 0, 1 1, 0 0))"; + assertNull(evaluate("'" + wkt + "'^^geo:wktLiteral", SQUARE_METRE)); + assertThrows(ExprEvalException.class, + () -> function.exec(NodeValue.makeNode(wkt, WKTDatatype.INSTANCE), + NodeValue.makeNode(NodeFactory.createURI(Unit_URI.SQUARE_METRE_QUDT)))); + } + + @Test + public void gmlPolygonIsAccepted() { + String gml = """ + '<gml:Polygon xmlns:gml="http://www.opengis.net/gml/3.2" + srsName="http://www.opengis.net/def/crs/EPSG/0/27700"> + <gml:exterior><gml:LinearRing><gml:posList>0 0 3 0 3 4 0 4 0 0</gml:posList></gml:LinearRing></gml:exterior> + </gml:Polygon>'^^geo:gmlLiteral + """.replace("\n", " "); + assertEquals(NodeValue.makeDouble(12).asNode(), evaluate(gml, SQUARE_METRE)); + } + + @Test + public void malformedOrUnboundGeometryLeavesBindUnbound() { + for (String value : new String[] { "42", "'POINT (1 2)'", "<urn:geometry>", "'invalid'^^geo:wktLiteral", "?missing" }) { + assertNull(value, evaluate(value, SQUARE_METRE)); + } + } + + @Test + public void invalidGeometryArgumentsRaiseExpressionErrors() { + NodeValue squareMetre = NodeValue.makeNode(NodeFactory.createURI(Unit_URI.SQUARE_METRE_QUDT)); + for (NodeValue value : new NodeValue[] { NodeValue.makeInteger(42), NodeValue.makeString("POINT (1 2)"), + NodeValue.makeNode(NodeFactory.createURI("urn:geometry")), NodeValue.makeNode("invalid", WKTDatatype.INSTANCE) }) { + assertThrows(ExprEvalException.class, () -> function.exec(value, squareMetre)); + } + } + + @Test + public void wrongArityIsRejectedAtQueryBuild() { + String geometry = "'POINT EMPTY'^^geo:wktLiteral"; + assertThrows(QueryBuildException.class, () -> AreaFunctionTestSupport.evaluate("geof:area()")); + assertThrows(QueryBuildException.class, () -> AreaFunctionTestSupport.evaluate("geof:area(" + geometry + ")")); + assertThrows(QueryBuildException.class, () -> AreaFunctionTestSupport.evaluate("geof:area(" + geometry + ", 1, 2)")); + } + + @Test + public void explicitAreaUnitsConvertTheAreaQuantity() { + Object[][] expected = { + { Unit_URI.SQUARE_METRE_QUDT, 1_000_000.0 }, + { Unit_URI.SQUARE_KILOMETRE_QUDT, 1.0 }, + { Unit_URI.SQUARE_CENTIMETRE_QUDT, 10_000_000_000.0 }, + { Unit_URI.SQUARE_MILLIMETRE_QUDT, 1_000_000_000_000.0 }, + { Unit_URI.SQUARE_FOOT_QUDT, 10_763_910.416709722 }, + { Unit_URI.SQUARE_YARD_QUDT, 1_195_990.0463010803 }, + { Unit_URI.SQUARE_INCH_QUDT, 1_550_003_100.0062 }, + { Unit_URI.SQUARE_MILE_QUDT, 0.38610215854244585 }, + { Unit_URI.HECTARE_QUDT, 100.0 }, + { Unit_URI.ACRE_QUDT, 247.10538146716534 }, + }; + for (Object[] entry : expected) { + String uri = (String) entry[0]; + double area = (double) entry[1]; + Node result = evaluate(POLYGON, "<" + uri + ">"); + assertEquals(uri, area, ((Number) result.getLiteralValue()).doubleValue(), area * 1e-12); + } + } + + @Test + public void undeclaredOgcLookingAreaUriIsRejected() { + String uri = "http://www.opengis.net/def/uom/OGC/1.0/squareMetre"; + assertThrows(ExprEvalException.class, + () -> function.exec(NodeValue.makeNode("POINT EMPTY", WKTDatatype.INSTANCE), + NodeValue.makeNode(NodeFactory.createURI(uri)))); + assertNull(evaluate(POLYGON, "<" + uri + ">")); + } + + @Test + public void anyUriUnitLiteralIsAccepted() { + assertEquals(NodeValue.makeDouble(1).asNode(), evaluate(POLYGON, "'" + Unit_URI.SQUARE_KILOMETRE_QUDT + "'^^xsd:anyURI")); + } + + @Test + public void invalidUnitsRaiseExpressionErrorsEvenForEmptyGeometry() { + NodeValue empty = NodeValue.makeNode("POINT EMPTY", WKTDatatype.INSTANCE); + for (NodeValue unit : new NodeValue[] { NodeValue.makeString(Unit_URI.SQUARE_METRE_QUDT), NodeValue.makeInteger(1), + NodeValue.makeNode(NodeFactory.createURI("urn:unknown-unit")), + NodeValue.makeNode(NodeFactory.createURI(Unit_URI.KILOMETRE_URL)) }) { + assertThrows(ExprEvalException.class, () -> function.exec(empty, unit)); + } + } + + @Test + public void invalidUnitsLeaveBindUnboundForEmptyAndNonemptyInputs() { + for (String unit : new String[] { "'" + Unit_URI.SQUARE_METRE_QUDT + "'", "1", "<urn:unknown-unit>", + "<" + Unit_URI.KILOMETRE_URL + ">", "?missing" }) { + assertNull(unit, evaluate(POLYGON, unit)); + assertNull(unit, evaluate("'POINT EMPTY'^^geo:wktLiteral", unit)); + } + } + + private Node evaluate(String geometry, String unit) { + return AreaFunctionTestSupport.evaluate("geof:area(" + geometry + ", " + unit + ")"); + } +} diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/AreaFunctionTestSupport.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/AreaFunctionTestSupport.java new file mode 100644 index 0000000000..eb6d60346e --- /dev/null +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/AreaFunctionTestSupport.java @@ -0,0 +1,51 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.apache.jena.geosparql.geof.nontopological.filter_functions; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.apache.jena.graph.Node; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.query.QuerySolution; +import org.apache.jena.query.ResultSet; +import org.apache.jena.rdf.model.ModelFactory; + +final class AreaFunctionTestSupport { + private AreaFunctionTestSupport() { + } + + static Node evaluate(String expression) { + String query = """ + PREFIX geof: <http://www.opengis.net/def/function/geosparql/> + PREFIX geo: <http://www.opengis.net/ont/geosparql#> + PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> + SELECT ?result WHERE { BIND(%s AS ?result) } + """.formatted(expression); + try (QueryExecution execution = QueryExecution.create(query, ModelFactory.createDefaultModel())) { + ResultSet results = execution.execSelect(); + assertTrue(expression, results.hasNext()); + QuerySolution solution = results.next(); + assertFalse(expression, results.hasNext()); + return solution.contains("result") ? solution.get("result").asNode() : null; + } + } +} diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/MetricAreaFFTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/MetricAreaFFTest.java new file mode 100644 index 0000000000..af9019e246 --- /dev/null +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/MetricAreaFFTest.java @@ -0,0 +1,107 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.apache.jena.geosparql.geof.nontopological.filter_functions; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import org.apache.jena.geosparql.configuration.GeoSPARQLConfig; +import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; +import org.apache.jena.graph.Node; +import org.apache.jena.graph.NodeFactory; +import org.apache.jena.query.QueryBuildException; +import org.apache.jena.sparql.expr.ExprEvalException; +import org.apache.jena.sparql.expr.NodeValue; +import org.junit.BeforeClass; +import org.junit.Test; + +public class MetricAreaFFTest { + private static final String PROJECTED = "<http://www.opengis.net/def/crs/EPSG/0/27700> "; + private final MetricAreaFF function = new MetricAreaFF(); + + @BeforeClass + public static void setup() { + GeoSPARQLConfig.setupNoIndex(); + } + + @Test + public void polygonReturnsDoubleSquareMetres() { + String polygon = "'" + PROJECTED + "POLYGON ((0 0, 3 0, 3 4, 0 4, 0 0))'^^geo:wktLiteral"; + assertEquals(NodeValue.makeDouble(12).asNode(), evaluate(polygon)); + } + + @Test + public void nonPolygonalAndEmptyGeometriesReturnZero() { + for (String wkt : new String[] { "POINT (1 2)", PROJECTED + "POLYGON EMPTY" }) { + assertEquals(NodeValue.makeDouble(0).asNode(), evaluate("'" + wkt + "'^^geo:wktLiteral")); + } + } + + @Test + public void geographicPolygonRaisesExpressionError() { + String wkt = "POLYGON ((0 0, 1 0, 1 1, 0 0))"; + assertNull(evaluate("'" + wkt + "'^^geo:wktLiteral")); + assertThrows(ExprEvalException.class, () -> exec(NodeValue.makeNode(wkt, WKTDatatype.INSTANCE))); + } + + @Test + public void gmlPolygonIsAccepted() { + String gml = """ + '<gml:Polygon xmlns:gml="http://www.opengis.net/gml/3.2" + srsName="http://www.opengis.net/def/crs/EPSG/0/27700"> + <gml:exterior><gml:LinearRing><gml:posList>0 0 3 0 3 4 0 4 0 0</gml:posList></gml:LinearRing></gml:exterior> + </gml:Polygon>'^^geo:gmlLiteral + """.replace("\n", " "); + assertEquals(NodeValue.makeDouble(12).asNode(), evaluate(gml)); + } + + @Test + public void malformedOrUnboundGeometryLeavesBindUnbound() { + for (String value : new String[] { "42", "'POINT (1 2)'", "<urn:geometry>", "'invalid'^^geo:wktLiteral", "?missing" }) { + assertNull(value, evaluate(value)); + } + } + + @Test + public void invalidGeometryArgumentsRaiseExpressionErrors() { + for (NodeValue value : new NodeValue[] { NodeValue.makeInteger(42), NodeValue.makeString("POINT (1 2)"), + NodeValue.makeNode(NodeFactory.createURI("urn:geometry")), NodeValue.makeNode("invalid", WKTDatatype.INSTANCE) }) { + assertThrows(ExprEvalException.class, () -> exec(value)); + } + } + + @Test + public void wrongArityIsRejectedAtQueryBuild() { + String geometry = "'POINT EMPTY'^^geo:wktLiteral"; + assertThrows(QueryBuildException.class, () -> AreaFunctionTestSupport.evaluate("geof:metricArea()")); + assertThrows(QueryBuildException.class, () -> AreaFunctionTestSupport.evaluate("geof:metricArea(" + geometry + ", 1)")); + assertThrows(QueryBuildException.class, () -> AreaFunctionTestSupport.evaluate("geof:metricArea(" + geometry + ", 1, 2)")); + } + + private NodeValue exec(NodeValue value) { + return function.exec(value); + } + + private Node evaluate(String geometry) { + return AreaFunctionTestSupport.evaluate("geof:metricArea(" + geometry + ")"); + } +} diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryAreaTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryAreaTest.java new file mode 100644 index 0000000000..c39434160d --- /dev/null +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryAreaTest.java @@ -0,0 +1,213 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.apache.jena.geosparql.implementation; + +import org.apache.jena.geosparql.configuration.GeoSPARQLConfig; +import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; +import org.apache.jena.geosparql.implementation.registry.UnitsURIException; +import org.apache.jena.geosparql.implementation.vocabulary.Unit_URI; +import org.apache.sis.referencing.CRS; +import org.junit.BeforeClass; +import org.junit.Test; +import org.opengis.referencing.crs.CoordinateReferenceSystem; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class GeometryAreaTest { + private static final String EPSG_32634 = "http://www.opengis.net/def/crs/EPSG/0/32634"; + private static final String EPSG_2227 = "http://www.opengis.net/def/crs/EPSG/0/2227"; + private static final String UNKNOWN_UNIT = "http://example.com/unit/unknown"; + + @BeforeClass + public static void initializeJena() { + GeoSPARQLConfig.setupNoIndex(); + } + + @Test + public void polygonAreaUsesTheExplicitTargetAreaUnit() { + GeometryWrapper polygon = geometry("<" + EPSG_32634 + "> POLYGON((" + + "500000 4600000,501000 4600000,501000 4601000," + + "500000 4601000,500000 4600000))"); + + assertEquals(1.0, polygon.area(Unit_URI.SQUARE_KILOMETRE_QUDT), 0.0); + } + + @Test + public void areaAccumulatesMultiPolygonShellsAndHoles() { + GeometryWrapper multiPolygon = geometry("<" + EPSG_32634 + "> MULTIPOLYGON(" + + "((500000 4600000,500010 4600000,500010 4600010," + + "500000 4600010,500000 4600000)," + + "(500004 4600004,500006 4600004,500006 4600006," + + "500004 4600006,500004 4600004))," + + "((500020 4600000,500023 4600000,500023 4600004," + + "500020 4600004,500020 4600000)))"); + + assertEquals(108.0, multiPolygon.area(), 0.0); + } + + @Test + public void emptyGeometriesReturnZeroRegardlessOfCrs() { + for (String type : List.of("POINT", "LINESTRING", "POLYGON", "MULTIPOINT", + "MULTILINESTRING", "MULTIPOLYGON", "GEOMETRYCOLLECTION")) { + assertEquals(0.0, geometry(type + " EMPTY").area(), 0.0); + assertEquals(0.0, geometry("<" + EPSG_32634 + "> " + type + " EMPTY").area(), 0.0); + } + } + + @Test + public void zAndMDoNotContributeToArea() { + for (String wkt : List.of( + "POLYGON Z ((0 0 1, 3 0 2, 3 4 3, 0 4 4, 0 0 1))", + "POLYGON M ((0 0 1, 3 0 2, 3 4 3, 0 4 4, 0 0 1))", + "POLYGON ZM ((0 0 1 9, 3 0 2 8, 3 4 3 7, 0 4 4 6, 0 0 1 9))")) { + assertEquals(12.0, geometry("<" + EPSG_32634 + "> " + wkt).area(), 0.0); + } + } + + @Test + public void areaIneligibleGeometryTypesReturnZeroWithoutSourceCrsCalculation() { + for (String wkt : List.of( + "POINT(1 1)", + "LINESTRING(0 0,1 1)", + "MULTIPOINT((0 0),(1 1))", + "MULTILINESTRING((0 0,1 1),(2 2,3 3))", + "GEOMETRYCOLLECTION(POLYGON((0 0,0 1,1 1,1 0,0 0)))")) { + assertEquals(0.0, geometry(wkt).area(), 0.0); + } + } + + @Test + public void eligibleAreaRejectsDifferentHorizontalAxisScales() throws Exception { + GeometryWrapper polygon = geometryWithCrs( + "<" + EPSG_32634 + "> POLYGON((0 0,0 10,10 10,10 0,0 0))", + CRS.fromWKT("ENGCRS[\"Mixed axis scale\"," + + "EDATUM[\"Engineering datum\"],CS[Cartesian,2]," + + "AXIS[\"x\",east,ORDER[1],LENGTHUNIT[\"metre\",1]]," + + "AXIS[\"y\",north,ORDER[2],LENGTHUNIT[\"foot\",0.3048]]]")); + + assertThrows(UnitsConversionException.class, + polygon::area); + } + + @Test + public void projectedNonMetreAreaConvertsTheSourceAreaQuantity() { + GeometryWrapper polygon = geometry("<" + EPSG_2227 + "> POLYGON((" + + "6300000 2000000,6300003 2000000,6300003 2000004," + + "6300000 2000004,6300000 2000000))"); + + assertEquals(1.114840939359298, + polygon.area(), 1e-12); + } + + @Test + public void multiPolygonAreaIsAccumulatedBeforeUnitConversion() { + GeometryWrapper multiPolygon = geometry("<" + EPSG_32634 + "> MULTIPOLYGON(" + + "((0 0,0.02 0,0.02 0.02,0 0.02,0 0))," + + "((1 0,1.02 0,1.02 0.02,1 0.02,1 0)))"); + + assertEquals(0.0000000008, + multiPolygon.area(Unit_URI.SQUARE_KILOMETRE_QUDT), 1e-20); + } + + @Test + public void compoundCrsUsesItsTwoDimensionalHorizontalAxisUnits() throws Exception { + CoordinateReferenceSystem compoundCrs = CRS.compound( + CRS.forCode(EPSG_32634), CRS.forCode("EPSG:5703")); + GeometryWrapper polygon = geometryWithCrs( + "<" + EPSG_32634 + "> POLYGON Z ((0 0 5,0 10 5,10 10 5,10 0 5,0 0 5))", + compoundCrs); + + assertEquals(100.0, polygon.area(), 0.0); + } + + @Test + public void eligibleGeographicAreaIsRejected() { + for (String wkt : List.of( + "POLYGON((0 0,0 1,1 1,1 0,0 0))", + "MULTIPOLYGON(((0 0,0 1,1 1,1 0,0 0)))")) { + assertThrows(UnitsConversionException.class, geometry(wkt)::area); + } + } + + @Test + public void emptyEligibleAreaSkipsSourceAxisValidation() throws Exception { + CoordinateReferenceSystem mixedScaleCrs = CRS.fromWKT("ENGCRS[\"Mixed axis scale\"," + + "EDATUM[\"Engineering datum\"],CS[Cartesian,2]," + + "AXIS[\"x\",east,ORDER[1],LENGTHUNIT[\"metre\",1]]," + + "AXIS[\"y\",north,ORDER[2],LENGTHUNIT[\"foot\",0.3048]]]"); + + assertEquals(0.0, geometryWithCrs( + "<" + EPSG_32634 + "> POLYGON EMPTY", mixedScaleCrs).area(), 0.0); + assertEquals(0.0, geometryWithCrs( + "<" + EPSG_32634 + "> MULTIPOLYGON EMPTY", mixedScaleCrs).area(), 0.0); + } + + @Test + public void zeroAreaStillValidatesTargetUnits() { + for (GeometryWrapper geometry : List.of( + geometry("POINT(1 1)"), + geometry("<" + EPSG_32634 + "> POLYGON EMPTY"))) { + assertThrows(UnitsConversionException.class, + () -> geometry.area(Unit_URI.DEGREE_URL)); + assertThrows(UnitsURIException.class, + () -> geometry.area(UNKNOWN_UNIT)); + } + } + + private GeometryWrapper geometry(String wkt) { + return GeometryWrapper.extract(wkt, WKTDatatype.URI); + } + + private GeometryWrapper geometryWithCrs(String wkt, CoordinateReferenceSystem crs) { + return new GeometryWrapperWithSrsInfo(geometry(wkt), new TestSrsInfo(crs)); + } + + private static final class GeometryWrapperWithSrsInfo extends GeometryWrapper { + private final SRSInfo srsInfo; + + private GeometryWrapperWithSrsInfo(GeometryWrapper geometry, SRSInfo srsInfo) { + super(geometry); + this.srsInfo = srsInfo; + } + + @Override + public SRSInfo getSrsInfo() { + return srsInfo; + } + } + + private static final class TestSrsInfo extends SRSInfo { + private final CoordinateReferenceSystem crs; + + private TestSrsInfo(CoordinateReferenceSystem crs) { + super(EPSG_32634); + this.crs = crs; + } + + @Override + public CoordinateReferenceSystem getCrs() { + return crs; + } + } +}
