From 6d6e170f3101e8047797220ed095e2ddbe1d7289 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Mon, 7 Sep 2026 12:57:02 +1000 Subject: [PATCH 1/2] GH-4204: Add four GeoSPARQL 1.1 geometry metadata functions Implement geometryType, is3D, isMeasured, and numGeometries with reusable GeometryWrapper methods and ARQ registration. Add documentation and tests covering geometry types, Z/M layouts, empty geometries, member counts, and SPARQL error handling. --- .../geometry_property/GeometryTypeFF.java | 42 ++++ .../geometry_property/Is3DFF.java | 41 ++++ .../geometry_property/IsMeasuredFF.java | 41 ++++ .../geometry_property/NumGeometriesFF.java | 41 ++++ .../implementation/GeometryWrapper.java | 61 +++++ .../GeometryProperty.java | 22 +- .../implementation/parsers/gml/GMLReader.java | 12 +- .../implementation/vocabulary/Geof.java | 8 +- .../GeometryMetadataErrorsTest.java | 148 ++++++++++++ .../GeometryMetadataFFTest.java | 220 ++++++++++++++++++ .../implementation/GeometryMetadataTest.java | 148 ++++++++++++ .../GeometryTypeSerializationTest.java | 84 +++++++ 12 files changed, 858 insertions(+), 10 deletions(-) create mode 100644 jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryTypeFF.java create mode 100644 jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/Is3DFF.java create mode 100644 jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/IsMeasuredFF.java create mode 100644 jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/NumGeometriesFF.java create mode 100644 jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryMetadataErrorsTest.java create mode 100644 jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryMetadataFFTest.java create mode 100644 jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryMetadataTest.java create mode 100644 jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryTypeSerializationTest.java diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryTypeFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryTypeFF.java new file mode 100644 index 00000000000..0d2adf5290b --- /dev/null +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryTypeFF.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 + * + * 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.topological.filter_functions.geometry_property; + +import org.apache.jena.datatypes.DatatypeFormatException; +import org.apache.jena.datatypes.xsd.XSDDatatype; +import org.apache.jena.geosparql.implementation.GeometryWrapper; +import org.apache.jena.sparql.expr.ExprEvalException; +import org.apache.jena.sparql.expr.NodeValue; +import org.apache.jena.sparql.function.FunctionBase1; + +/** Returns the serialization-specific geometry subtype as an xsd:anyURI literal. */ +public class GeometryTypeFF extends FunctionBase1 { + + @Override + public NodeValue exec(NodeValue value) { + try { + GeometryWrapper geometry = GeometryWrapper.extract(value); + return NodeValue.makeNode(geometry.getGeometryTypeURI(), XSDDatatype.XSDanyURI); + } catch (DatatypeFormatException | IllegalArgumentException ex) { + throw new ExprEvalException(ex.getMessage(), ex); + } + } +} diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/Is3DFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/Is3DFF.java new file mode 100644 index 00000000000..81a80003b77 --- /dev/null +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/Is3DFF.java @@ -0,0 +1,41 @@ +/* + * 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.topological.filter_functions.geometry_property; + +import org.apache.jena.datatypes.DatatypeFormatException; +import org.apache.jena.geosparql.implementation.GeometryWrapper; +import org.apache.jena.sparql.expr.ExprEvalException; +import org.apache.jena.sparql.expr.NodeValue; +import org.apache.jena.sparql.function.FunctionBase1; + +/** Reports whether the geometry coordinate layout includes Z. */ +public class Is3DFF extends FunctionBase1 { + + @Override + public NodeValue exec(NodeValue value) { + try { + GeometryWrapper geometry = GeometryWrapper.extract(value); + return NodeValue.makeBoolean(geometry.is3D()); + } catch (DatatypeFormatException ex) { + throw new ExprEvalException(ex.getMessage(), ex); + } + } +} diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/IsMeasuredFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/IsMeasuredFF.java new file mode 100644 index 00000000000..711978787f2 --- /dev/null +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/IsMeasuredFF.java @@ -0,0 +1,41 @@ +/* + * 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.topological.filter_functions.geometry_property; + +import org.apache.jena.datatypes.DatatypeFormatException; +import org.apache.jena.geosparql.implementation.GeometryWrapper; +import org.apache.jena.sparql.expr.ExprEvalException; +import org.apache.jena.sparql.expr.NodeValue; +import org.apache.jena.sparql.function.FunctionBase1; + +/** Reports whether the geometry coordinate layout includes M. */ +public class IsMeasuredFF extends FunctionBase1 { + + @Override + public NodeValue exec(NodeValue value) { + try { + GeometryWrapper geometry = GeometryWrapper.extract(value); + return NodeValue.makeBoolean(geometry.isMeasured()); + } catch (DatatypeFormatException ex) { + throw new ExprEvalException(ex.getMessage(), ex); + } + } +} diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/NumGeometriesFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/NumGeometriesFF.java new file mode 100644 index 00000000000..d7590e669d0 --- /dev/null +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/NumGeometriesFF.java @@ -0,0 +1,41 @@ +/* + * 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.topological.filter_functions.geometry_property; + +import org.apache.jena.datatypes.DatatypeFormatException; +import org.apache.jena.geosparql.implementation.GeometryWrapper; +import org.apache.jena.sparql.expr.ExprEvalException; +import org.apache.jena.sparql.expr.NodeValue; +import org.apache.jena.sparql.function.FunctionBase1; + +/** Counts direct geometry members without flattening nested collections. */ +public class NumGeometriesFF extends FunctionBase1 { + + @Override + public NodeValue exec(NodeValue value) { + try { + GeometryWrapper geometry = GeometryWrapper.extract(value); + return NodeValue.makeInteger(geometry.getNumGeometries()); + } catch (DatatypeFormatException ex) { + throw new ExprEvalException(ex.getMessage(), ex); + } + } +} 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 daedac38ca8..aa0a0e034be 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 @@ -20,6 +20,7 @@ */ package org.apache.jena.geosparql.implementation; +import java.io.IOException; import java.io.Serializable; import java.util.Objects; @@ -35,9 +36,11 @@ import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions; import org.apache.jena.geosparql.implementation.jts.CustomCoordinateSequence; import org.apache.jena.geosparql.implementation.jts.CustomGeometryFactory; +import org.apache.jena.geosparql.implementation.parsers.gml.GMLReader; import org.apache.jena.geosparql.implementation.registry.MathTransformRegistry; import org.apache.jena.geosparql.implementation.registry.SRSRegistry; import org.apache.jena.geosparql.implementation.registry.UnitsRegistry; +import org.apache.jena.geosparql.implementation.vocabulary.GeoSPARQL_URI; import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI; import org.apache.jena.geosparql.implementation.vocabulary.Unit_URI; import org.apache.jena.graph.Node; @@ -45,6 +48,7 @@ import org.apache.jena.rdf.model.ResourceFactory; import org.apache.jena.sparql.expr.NodeValue; import org.apache.sis.geometry.DirectPosition2D; +import org.jdom2.JDOMException; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; @@ -387,6 +391,63 @@ public String getGeometryType() { return parsingGeometry.getGeometryType(); } + /** + * Returns the geometry subtype URI appropriate to this serialization, + * including for typed empty geometries. Specialized subtypes are not + * inferred from coordinates. + * + * @throws IllegalArgumentException if the geometry type has no Simple Features mapping. + * @throws DatatypeFormatException if the GML literal cannot be read. + */ + public String getGeometryTypeURI() { + if (GMLDatatype.URI.equals(geometryDatatypeURI)) { + // Retained GML distinguishes source types such as Curve and Surface + // from their JTS approximations. Constructed geometries use generated GML. + try { + return GeoSPARQL_URI.GML_URI + GMLReader.readGeometryType(getLexicalForm()); + } catch (JDOMException | IOException ex) { + throw new DatatypeFormatException("Unable to read GML geometry type", ex); + } + } + String type = getGeometryType(); + return switch (type) { + case "Point", "LineString", "LinearRing", "Polygon", "MultiPoint", "MultiLineString", + "MultiPolygon", "GeometryCollection" -> GeoSPARQL_URI.SF_URI + type; + default -> throw new IllegalArgumentException("Unsupported Simple Features geometry type: " + type); + }; + } + + /** + * Returns whether the coordinate layout includes Z, including for empty geometries. + * Uses the wrapper's dimension metadata without aggregating member layouts. + * A WKT collection without a Z/M marker has XY metadata even if members + * declare their own Z/M layouts. + */ + public boolean is3D() { + CoordinateSequenceDimensions dimensions = getCoordinateSequenceDimensions(); + return dimensions == CoordinateSequenceDimensions.XYZ || dimensions == CoordinateSequenceDimensions.XYZM; + } + + /** + * Returns whether the coordinate layout includes M, including for empty geometries. + * Uses the wrapper's dimension metadata without aggregating member layouts. + * A WKT collection without a Z/M marker has XY metadata even if members + * declare their own Z/M layouts. + */ + public boolean isMeasured() { + CoordinateSequenceDimensions dimensions = getCoordinateSequenceDimensions(); + return dimensions == CoordinateSequenceDimensions.XYM || dimensions == CoordinateSequenceDimensions.XYZM; + } + + /** + * Returns the number of direct members of a Multi-geometry or GeometryCollection. + * Atomic geometries count as one, including empty atomic geometries; nested + * collections are not flattened. A collection with no members counts as zero. + */ + public int getNumGeometries() { + return parsingGeometry.getNumGeometries(); + } + /** * * @return GeometryDatatype of the literal. diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/GeometryProperty.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/GeometryProperty.java index c7c3fbe0894..5f44ec4360c 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/GeometryProperty.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/GeometryProperty.java @@ -28,9 +28,13 @@ import org.apache.jena.geosparql.geo.topological.property_functions.geometry_property.SpatialDimensionPF; import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.CoordinateDimensionFF; import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.DimensionFF; +import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.GeometryTypeFF; +import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.Is3DFF; import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.IsEmptyFF; +import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.IsMeasuredFF; import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.IsSimpleFF; import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.IsValidFF; +import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.NumGeometriesFF; import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.SpatialDimensionFF; import org.apache.jena.geosparql.implementation.vocabulary.Geo; import org.apache.jena.geosparql.implementation.vocabulary.Geof; @@ -38,13 +42,15 @@ import org.apache.jena.sparql.pfunction.PropertyFunctionRegistry; /** - * - * + * Registers functions that expose geometry metadata. + * The {@code geo:} predicates use Jena property functions; the {@code geof:} IRIs + * use SPARQL expression functions. */ public class GeometryProperty { /** - * This method loads all the Geometry property property functions. + * Registers {@code geo:} predicates as Jena property functions used in + * SPARQL triple patterns to access geometry metadata. * * @param registry - the PropertyFunctionRegistry to be used */ @@ -59,14 +65,18 @@ public static void loadPropertyFunctions(PropertyFunctionRegistry registry) { } /** - * This method loads all the Geometry property filter functions.
- * N.B. These functions are not part of the GeoSPARQL standard but have been - * included for convenience using GeometryLiterals. + * Registers {@code geof:} geometry metadata expression functions for use in + * {@code FILTER}, {@code BIND}, and projection expressions. + * Includes GeoSPARQL 1.1 geometry metadata functions and the isValid extension. * * @param registry - the FunctionRegistry to be used */ public static void loadFilterFunctions(FunctionRegistry registry) { + registry.put(Geof.GEOMETRY_TYPE, GeometryTypeFF.class); + registry.put(Geof.IS_3D, Is3DFF.class); + registry.put(Geof.IS_MEASURED, IsMeasuredFF.class); + registry.put(Geof.NUM_GEOMETRIES, NumGeometriesFF.class); registry.put(Geof.DIMENSION, DimensionFF.class); registry.put(Geof.COORDINATE_DIMENSION, CoordinateDimensionFF.class); registry.put(Geof.SPATIAL_DIMENSION, SpatialDimensionFF.class); diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLReader.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLReader.java index e6e470062cc..179c2e22e8b 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLReader.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLReader.java @@ -698,6 +698,15 @@ private static List getMembers(Element gmlElement, String memberLabel) private static final String EMPTY_GML_TEXT = ""; public static GMLReader extract(String gmlText) throws JDOMException, IOException { + return new GMLReader(readRootElement(gmlText)); + } + + /** Returns the source GML element type before conversion to JTS geometry. */ + public static String readGeometryType(String gmlText) throws JDOMException, IOException { + return readRootElement(gmlText).getName(); + } + + private static Element readRootElement(String gmlText) throws JDOMException, IOException { if (gmlText.isEmpty()) { gmlText = EMPTY_GML_TEXT; @@ -706,8 +715,7 @@ public static GMLReader extract(String gmlText) throws JDOMException, IOExceptio SAXBuilder jdomBuilder = newSAXBuilder(); InputStream stream = new ByteArrayInputStream(gmlText.getBytes(StandardCharsets.UTF_8)); Document xmlDoc = jdomBuilder.build(stream); - Element gmlElement = xmlDoc.getRootElement(); - return new GMLReader(gmlElement); + return xmlDoc.getRootElement(); } // ---- XXE safe SAXBuilder 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 ab13061da7c..6af9556b6bc 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 @@ -74,8 +74,12 @@ public interface Geof { public static final String CONVEXHULL_NAME = GEOF_URI + "convexHull"; public static final String GETSRID_NAME = GEOF_URI + "getSRID"; - //Geometry Property function symbols: - //N.B. These functions are not part of the GeoSPARQL standard but have been included for convenience using GeometryLiterals. + // Geometry metadata expression function symbols: + // GeoSPARQL 1.1 geometry metadata, plus the isValid extension. + public static final String GEOMETRY_TYPE = GEOF_URI + "geometryType"; + public static final String IS_3D = GEOF_URI + "is3D"; + public static final String IS_MEASURED = GEOF_URI + "isMeasured"; + public static final String NUM_GEOMETRIES = GEOF_URI + "numGeometries"; public static final String DIMENSION = GEOF_URI + "dimension"; public static final String COORDINATE_DIMENSION = GEOF_URI + "coordinateDimension"; public static final String SPATIAL_DIMENSION = GEOF_URI + "spatialDimension"; diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryMetadataErrorsTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryMetadataErrorsTest.java new file mode 100644 index 00000000000..316f4e67e9f --- /dev/null +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryMetadataErrorsTest.java @@ -0,0 +1,148 @@ +/* + * 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.topological.filter_functions.geometry_property; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; + +import java.util.List; + +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.query.QueryExecution; +import org.apache.jena.query.QuerySolution; +import org.apache.jena.query.ResultSet; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.sparql.expr.ExprEvalException; +import org.apache.jena.sparql.expr.NodeValue; +import org.apache.jena.sparql.function.FunctionBase1; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +@RunWith(Parameterized.class) +public class GeometryMetadataErrorsTest { + @Parameterized.Parameters(name = "function: {0}") + public static List functions() { + return List.of( + new Object[] { "geometryType", new GeometryTypeFF() }, + new Object[] { "is3D", new Is3DFF() }, + new Object[] { "isMeasured", new IsMeasuredFF() }, + new Object[] { "numGeometries", new NumGeometriesFF() } + ); + } + + private final String name; + private final FunctionBase1 function; + + public GeometryMetadataErrorsTest(String name, FunctionBase1 function) { + this.name = name; + this.function = function; + } + + @BeforeClass + public static void setup() { + GeoSPARQLConfig.setupNoIndex(); + } + + @Test + public void iriArgumentRaisesExpressionError() { + assertThrows(ExprEvalException.class, () -> function.exec(NodeValue.makeNode(NodeFactory.createURI("urn:not-a-literal")))); + } + + @Test + public void numericArgumentRaisesExpressionError() { + assertThrows(ExprEvalException.class, () -> function.exec(NodeValue.makeInteger(42))); + } + + @Test + public void stringWithoutGeometryDatatypeRaisesExpressionError() { + assertThrows(ExprEvalException.class, () -> function.exec(NodeValue.makeString("POINT (1 2)"))); + } + + @Test + public void malformedWktRaisesExpressionError() { + NodeValue malformed = NodeValue.makeNode("invalid", WKTDatatype.INSTANCE); + assertThrows(ExprEvalException.class, () -> function.exec(malformed)); + } + + @Test + public void iriArgumentLeavesBindUnbound() { + assertUnbound(""); + } + + @Test + public void numericArgumentLeavesBindUnbound() { + assertUnbound("42"); + } + + @Test + public void stringWithoutGeometryDatatypeLeavesBindUnbound() { + assertUnbound("'POINT (1 2)'"); + } + + @Test + public void malformedWktLeavesBindUnbound() { + assertUnbound("'invalid'^^geo:wktLiteral"); + } + + @Test + public void unboundArgumentLeavesBindUnbound() { + assertUnbound("?missing"); + } + + @Test + public void missingArgumentIsRejectedAtQueryBuild() { + assertThrows(QueryBuildException.class, () -> evaluate("geof:" + name + "()")); + } + + @Test + public void extraArgumentIsRejectedAtQueryBuild() { + assertThrows(QueryBuildException.class, + () -> evaluate("geof:" + name + "('POINT EMPTY'^^geo:wktLiteral, 1)")); + } + + private static Node evaluate(String expression) { + String query = """ + PREFIX geof: + PREFIX geo: + SELECT ?result WHERE { BIND(%s AS ?result) } + """.formatted(expression); + try (QueryExecution execution = QueryExecution.create(query, ModelFactory.createDefaultModel())) { + ResultSet results = execution.execSelect(); + assertTrue("Expected one solution for " + expression, results.hasNext()); + QuerySolution solution = results.next(); + assertFalse("Expected only one solution for " + expression, results.hasNext()); + return solution.contains("result") ? solution.get("result").asNode() : null; + } + } + + private void assertUnbound(String argument) { + String expression = "geof:" + name + "(" + argument + ")"; + assertNull(expression, evaluate(expression)); + } +} diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryMetadataFFTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryMetadataFFTest.java new file mode 100644 index 00000000000..31120b6798e --- /dev/null +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryMetadataFFTest.java @@ -0,0 +1,220 @@ +/* + * 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.topological.filter_functions.geometry_property; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.apache.jena.datatypes.xsd.XSDDatatype; +import org.apache.jena.geosparql.configuration.GeoSPARQLConfig; +import org.apache.jena.graph.Node; +import org.apache.jena.graph.NodeFactory; +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; +import org.junit.BeforeClass; +import org.junit.Test; + +public class GeometryMetadataFFTest { + private static final String PREFIXES = """ + PREFIX geof: + PREFIX geo: + """; + + @BeforeClass + public static void setup() { + GeoSPARQLConfig.setupNoIndex(); + } + + @Test + public void geometryTypeReturnsAnyUriLiteral() { + assertResult("geof:geometryType('POINT (1 2)'^^geo:wktLiteral)", + "http://www.opengis.net/ont/sf#Point", XSDDatatype.XSDanyURI); + } + + @Test + public void geometryTypeRecognizesLinearRingWktExtension() { + assertResult("geof:geometryType('LINEARRING (0 0, 1 0, 1 1, 0 0)'^^geo:wktLiteral)", + "http://www.opengis.net/ont/sf#LinearRing", XSDDatatype.XSDanyURI); + assertResult("geof:geometryType('LINEARRING EMPTY'^^geo:wktLiteral)", + "http://www.opengis.net/ont/sf#LinearRing", XSDDatatype.XSDanyURI); + } + + @Test + public void geometryTypeDoesNotInferTriangleFromPolygonCoordinates() { + assertResult("geof:geometryType('POLYGON ((0 0, 1 0, 0 1, 0 0))'^^geo:wktLiteral)", + "http://www.opengis.net/ont/sf#Polygon", XSDDatatype.XSDanyURI); + } + + @Test + public void geometryTypeRetainsTheTypeOfEmptyGeometries() { + assertResult("geof:geometryType('POINT EMPTY'^^geo:wktLiteral)", + "http://www.opengis.net/ont/sf#Point", XSDDatatype.XSDanyURI); + assertResult("geof:geometryType('POLYGON EMPTY'^^geo:wktLiteral)", + "http://www.opengis.net/ont/sf#Polygon", XSDDatatype.XSDanyURI); + assertResult("geof:geometryType('GEOMETRYCOLLECTION EMPTY'^^geo:wktLiteral)", + "http://www.opengis.net/ont/sf#GeometryCollection", XSDDatatype.XSDanyURI); + } + + @Test + public void is3DDistinguishesZFromM() { + assertResult("geof:is3D('POINT Z (1 2 3)'^^geo:wktLiteral)", "true", XSDDatatype.XSDboolean); + assertResult("geof:is3D('POINT M (1 2 3)'^^geo:wktLiteral)", "false", XSDDatatype.XSDboolean); + } + + @Test + public void isMeasuredDistinguishesMFromZ() { + assertResult("geof:isMeasured('POINT M (1 2 3)'^^geo:wktLiteral)", "true", XSDDatatype.XSDboolean); + assertResult("geof:isMeasured('POINT Z (1 2 3)'^^geo:wktLiteral)", "false", XSDDatatype.XSDboolean); + } + + @Test + public void emptyPointsRetainZAndMFlags() { + assertFlags("POINT EMPTY", false, false); + assertFlags("POINT Z EMPTY", true, false); + assertFlags("POINT M EMPTY", false, true); + assertFlags("POINT ZM EMPTY", true, true); + } + + @Test + public void emptyCollectionsRetainDeclaredZAndMFlags() { + assertFlags("GEOMETRYCOLLECTION EMPTY", false, false); + assertFlags("GEOMETRYCOLLECTION Z EMPTY", true, false); + assertFlags("GEOMETRYCOLLECTION M EMPTY", false, true); + assertFlags("GEOMETRYCOLLECTION ZM EMPTY", true, true); + } + + @Test + public void permissiveWktCollectionLayoutDoesNotAggregateMemberLayouts() { + // Mixed member layouts are a Jena WKT extension, not an SFA conformance case. + assertFlags("GEOMETRYCOLLECTION (POINT Z (1 2 3), POINT M (4 5 6))", false, false); + } + + @Test + public void numGeometriesCountsMembersAsAnInteger() { + assertResult("geof:numGeometries('MULTIPOINT ((1 2), (3 4))'^^geo:wktLiteral)", + "2", XSDDatatype.XSDinteger); + } + + @Test + public void numGeometriesDistinguishesEmptyAtomsFromCollections() { + assertResult("geof:numGeometries('POINT EMPTY'^^geo:wktLiteral)", "1", XSDDatatype.XSDinteger); + assertResult("geof:numGeometries('GEOMETRYCOLLECTION EMPTY'^^geo:wktLiteral)", "0", XSDDatatype.XSDinteger); + assertResult("geof:numGeometries('MULTIPOINT EMPTY'^^geo:wktLiteral)", "0", XSDDatatype.XSDinteger); + assertResult("geof:numGeometries('GEOMETRYCOLLECTION (POINT EMPTY)'^^geo:wktLiteral)", + "1", XSDDatatype.XSDinteger); + } + + @Test + public void gmlPointUsesTheSameMetadataFunctions() { + String point = """ + ' + 1 2 + '^^geo:gmlLiteral + """.replace("\n", " "); + assertResult("geof:geometryType(" + point + ")", "http://www.opengis.net/ont/gml#Point", XSDDatatype.XSDanyURI); + assertResult("geof:numGeometries(" + point + ")", "1", XSDDatatype.XSDinteger); + assertResult("geof:is3D(" + point + ")", "false", XSDDatatype.XSDboolean); + assertResult("geof:isMeasured(" + point + ")", "false", XSDDatatype.XSDboolean); + assertResult("geof:isEmpty(" + point + ")", "false", XSDDatatype.XSDboolean); + } + + @Test + public void threeDimensionalGmlPointHasZButNoMeasure() { + String point = """ + ' + 2 1 3 + '^^geo:gmlLiteral + """.replace("\n", " "); + assertResult("geof:is3D(" + point + ")", "true", XSDDatatype.XSDboolean); + assertResult("geof:isMeasured(" + point + ")", "false", XSDDatatype.XSDboolean); + assertResult("geof:isEmpty(" + point + ")", "false", XSDDatatype.XSDboolean); + } + + @Test + public void metadataFunctionsComposeWithGeometryFunctions() { + assertResult("geof:geometryType(geof:envelope('LINESTRING (0 0, 1 1)'^^geo:wktLiteral))", + "http://www.opengis.net/ont/sf#Polygon", XSDDatatype.XSDanyURI); + } + + @Test + public void gmlSubtypesSurviveJtsApproximation() { + assertGmlType("Curve", "5 0 0 5 -5 0"); + assertGmlType("Surface", "" + polygonRing() + ""); + assertGmlType("MultiCurve", "0 0 1 1"); + assertGmlType("MultiSurface", "" + polygonRing() + ""); + } + + @Test + public void gmlEnvelopeReportsItsOwnType() { + String curve = gml("Curve", "0 0 10 10 20 0"); + assertResult("geof:geometryType(geof:envelope('" + curve + "'^^geo:gmlLiteral))", + "http://www.opengis.net/ont/gml#Polygon", XSDDatatype.XSDanyURI); + } + + @Test + public void permissiveGmlEmptyElementsRetainTheirTypes() { + // Exercises Jena's acceptance of empty elements, not GML-schema conformance. + // For example, Curve without segments is accepted as an empty geometry. + for (String type : new String[] { "Point", "LineString", "Curve", "MultiCurve", "MultiSurface", "MultiGeometry" }) { + assertGmlType(type, ""); + } + } + + private static String polygonRing() { + return "0 0 10 0 10 10 0 0"; + } + + private static String gml(String type, String contents) { + return "" + contents + ""; + } + + private static void assertGmlType(String type, String contents) { + assertResult("geof:geometryType('" + gml(type, contents) + "'^^geo:gmlLiteral)", + "http://www.opengis.net/ont/gml#" + type, XSDDatatype.XSDanyURI); + } + + private static void assertFlags(String wkt, boolean hasZ, boolean hasM) { + assertResult("geof:is3D('" + wkt + "'^^geo:wktLiteral)", Boolean.toString(hasZ), XSDDatatype.XSDboolean); + assertResult("geof:isMeasured('" + wkt + "'^^geo:wktLiteral)", Boolean.toString(hasM), XSDDatatype.XSDboolean); + } + + private static void assertResult(String expression, String lexicalForm, XSDDatatype datatype) { + Node expected = NodeFactory.createLiteralDT(lexicalForm, datatype); + assertEquals(expression, expected, evaluate(expression)); + } + + private static Node evaluate(String expression) { + String query = PREFIXES + "SELECT ?result WHERE { BIND(" + expression + " AS ?result) }"; + try (QueryExecution execution = QueryExecution.create(query, ModelFactory.createDefaultModel())) { + ResultSet results = execution.execSelect(); + assertTrue("Expected one solution for " + expression, results.hasNext()); + QuerySolution solution = results.next(); + assertFalse("Expected only one solution for " + expression, results.hasNext()); + return solution.contains("result") ? solution.get("result").asNode() : null; + } + } +} diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryMetadataTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryMetadataTest.java new file mode 100644 index 00000000000..ecb6d9cab9d --- /dev/null +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryMetadataTest.java @@ -0,0 +1,148 @@ +/* + * 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 static org.junit.Assert.assertEquals; + +import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; +import org.apache.jena.geosparql.implementation.vocabulary.GeoSPARQL_URI; +import org.junit.Test; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; + +public class GeometryMetadataTest { + + @Test + public void geometryTypesIncludeTypedEmpties() { + String[] types = { "Point", "LineString", "LinearRing", "Polygon", "MultiPoint", + "MultiLineString", "MultiPolygon", "GeometryCollection" }; + for (String type : types) { + GeometryWrapper geometry = geometry(type.toUpperCase(java.util.Locale.ROOT) + " EMPTY"); + assertEquals(type, GeoSPARQL_URI.SF_URI + type, geometry.getGeometryTypeURI()); + } + } + + @Test + public void geometryTypesIncludeNonemptyGeometries() { + String[][] cases = { + { "POINT (1 2)", "Point" }, + { "LINESTRING (0 0, 1 1)", "LineString" }, + { "LINEARRING (0 0, 1 0, 1 1, 0 0)", "LinearRing" }, + { "POLYGON ((0 0, 1 0, 0 1, 0 0))", "Polygon" }, + { "MULTIPOINT ((1 2))", "MultiPoint" }, + { "MULTILINESTRING ((0 0, 1 1))", "MultiLineString" }, + { "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)))", "MultiPolygon" }, + { "GEOMETRYCOLLECTION (POINT (1 2))", "GeometryCollection" } + }; + for (String[] example : cases) { + assertEquals(example[0], GeoSPARQL_URI.SF_URI + example[1], geometry(example[0]).getGeometryTypeURI()); + } + } + + @Test + public void directlyConstructedLinearRingHasSimpleFeaturesType() { + GeometryWrapper ring = new GeometryWrapper(new GeometryFactory().createLinearRing(), WKTDatatype.URI); + assertEquals(GeoSPARQL_URI.SF_URI + "LinearRing", ring.getGeometryTypeURI()); + } + + @Test + public void coordinateLayoutsDistinguishZAndM() { + assertLayout("POINT (1 2)", false, false); + assertLayout("POINT Z (1 2 3)", true, false); + assertLayout("POINT M (1 2 3)", false, true); + assertLayout("POINT ZM (1 2 3 4)", true, true); + assertLayout(" POINT Z (2 1 3)", true, false); + } + + @Test + public void emptyPointsRetainDeclaredCoordinateLayouts() { + assertLayout("POINT EMPTY", false, false); + assertLayout("POINT Z EMPTY", true, false); + assertLayout("POINT M EMPTY", false, true); + assertLayout("POINT ZM EMPTY", true, true); + } + + @Test + public void collectionsUseTheirDeclaredCoordinateLayout() { + assertLayout("GEOMETRYCOLLECTION Z (POINT Z (1 2 3), LINESTRING Z (1 2 3, 4 5 6))", true, false); + assertLayout("GEOMETRYCOLLECTION M (POINT M (1 2 3))", false, true); + assertLayout("GEOMETRYCOLLECTION ZM (POINT ZM (1 2 3 4))", true, true); + } + + @Test + public void permissiveWktCollectionLayoutDoesNotAggregateMemberLayouts() { + // Mixed member layouts are a Jena WKT extension, not an SFA conformance case. + String wkt = "GEOMETRYCOLLECTION (POINT Z (1 2 3), POINT M (4 5 6))"; + // WKT collection metadata comes from the collection marker, not its members. + GeometryWrapper geometry = geometry(wkt); + assertEquals(2, geometry.getCoordinateDimension()); + assertEquals(2, geometry.getSpatialDimension()); + assertLayout(wkt, false, false); + } + + @Test + public void atomicGeometriesCountAsOneIncludingEmpties() { + assertCount("POINT (1 2)", 1); + assertCount("POINT EMPTY", 1); + assertCount("LINESTRING EMPTY", 1); + assertCount("POLYGON EMPTY", 1); + } + + @Test + public void collectionsWithoutMembersCountAsZero() { + assertCount("MULTIPOINT EMPTY", 0); + assertCount("MULTILINESTRING EMPTY", 0); + assertCount("MULTIPOLYGON EMPTY", 0); + assertCount("GEOMETRYCOLLECTION EMPTY", 0); + } + + @Test + public void collectionsCountDirectMembersIncludingEmptyMembers() { + assertCount("MULTIPOINT ((1 2), (3 4))", 2); + assertCount("MULTILINESTRING ((0 0, 1 1), (2 2, 3 3))", 2); + assertCount("MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)))", 1); + assertCount("GEOMETRYCOLLECTION (POINT EMPTY)", 1); + } + + @Test + public void nestedCollectionsAreNotFlattened() { + GeometryFactory factory = new GeometryFactory(); + Geometry point = geometry("POINT (1 2)").getParsingGeometry(); + Geometry nested = factory.createGeometryCollection(new Geometry[] { point, point }); + Geometry collection = factory.createGeometryCollection(new Geometry[] { point, nested }); + GeometryWrapper wrapper = new GeometryWrapper(collection, WKTDatatype.URI); + assertEquals(2, wrapper.getNumGeometries()); + } + + private static GeometryWrapper geometry(String wkt) { + return GeometryWrapper.extract(wkt, WKTDatatype.URI); + } + + private static void assertLayout(String wkt, boolean hasZ, boolean hasM) { + GeometryWrapper geometry = geometry(wkt); + assertEquals(wkt, hasZ, geometry.is3D()); + assertEquals(wkt, hasM, geometry.isMeasured()); + } + + private static void assertCount(String wkt, int expected) { + assertEquals(wkt, expected, geometry(wkt).getNumGeometries()); + } +} diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryTypeSerializationTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryTypeSerializationTest.java new file mode 100644 index 00000000000..39bb1453c2d --- /dev/null +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryTypeSerializationTest.java @@ -0,0 +1,84 @@ +/* + * 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 static org.junit.Assert.assertEquals; + +import org.apache.jena.geosparql.implementation.datatype.GMLDatatype; +import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; +import org.junit.Test; + +public class GeometryTypeSerializationTest { + private static final String GML = "http://www.opengis.net/ont/gml#"; + private static final String SF = "http://www.opengis.net/ont/sf#"; + + @Test + public void copyRetainsSourceSubtypeWithDefaultXmlNamespace() { + GeometryWrapper source = curve(); + assertEquals(GML + "Curve", type(source)); + assertEquals(GML + "Curve", type(new GeometryWrapper(source))); + } + + @Test + public void conversionToWktUsesTheConvertedTypeWithoutChangingSource() { + GeometryWrapper source = curve(); + GeometryWrapper converted = GeometryWrapper.extract(source.asLiteral(WKTDatatype.URI)); + assertEquals(SF + "LineString", type(converted)); + assertEquals(GML + "Curve", type(source)); + } + + @Test + public void constructedGmlUsesWriterElementNames() { + String[][] cases = { + { "POINT (1 2)", "Point" }, + { "LINESTRING (0 0, 1 1)", "LineString" }, + { "MULTILINESTRING ((0 0, 1 1))", "MultiCurve" }, + { "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)))", "MultiSurface" }, + { "GEOMETRYCOLLECTION (POINT (1 2))", "MultiGeometry" }, + { "MULTIPOLYGON EMPTY", "MultiSurface" } + }; + for (String[] example : cases) { + GeometryWrapper wkt = GeometryWrapper.extract(example[0], WKTDatatype.URI); + GeometryWrapper constructed = new GeometryWrapper(wkt.getParsingGeometry(), wkt.getSrsURI(), + GMLDatatype.URI, wkt.getDimensionInfo()); + assertEquals(example[0], GML + example[1], type(constructed)); + assertEquals(example[0], GML + example[1], type(GeometryWrapper.extract(constructed.asLiteral()))); + } + } + + @Test + public void emptyGmlTextUsesJenasPointInterpretation() { + assertEquals(GML + "Point", type(GeometryWrapper.extract("", GMLDatatype.URI))); + } + + private static GeometryWrapper curve() { + return GeometryWrapper.extract(""" + + 0 0 10 10 20 0 + + """, GMLDatatype.URI); + } + + private static String type(GeometryWrapper geometry) { + return geometry.getGeometryTypeURI(); + } +} From 990c66e7965fa587ba82d531b80d6189710ed037 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Thu, 10 Sep 2026 15:38:01 +1000 Subject: [PATCH 2/2] GH-4204: Avoid GML reparsing for geometryType Preserve source GML types during parsing and resolve constructed types through a mapping shared with the GML writer. Delegate type resolution to geometry datatypes and cache the result, avoiding XML round trips while retaining source subtypes. --- .../geometry_property/GeometryTypeFF.java | 2 +- .../geometry_property/Is3DFF.java | 2 +- .../geometry_property/IsMeasuredFF.java | 2 +- .../geometry_property/NumGeometriesFF.java | 2 +- .../implementation/GeometryWrapper.java | 71 ++++++--- .../implementation/datatype/GMLDatatype.java | 20 ++- .../datatype/GeometryDatatype.java | 13 ++ .../implementation/datatype/WKTDatatype.java | 11 ++ .../parsers/gml/GMLGeometryTypes.java | 51 ++++++ .../implementation/parsers/gml/GMLReader.java | 26 ++- .../implementation/parsers/gml/GMLWriter.java | 13 +- .../GeometryTypeSerializationTest.java | 148 +++++++++++++++++- .../parsers/gml/GMLWriterTest.java | 18 +++ 13 files changed, 340 insertions(+), 39 deletions(-) create mode 100644 jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLGeometryTypes.java diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryTypeFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryTypeFF.java index 0d2adf5290b..000766d1eec 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryTypeFF.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryTypeFF.java @@ -27,7 +27,7 @@ import org.apache.jena.sparql.expr.NodeValue; import org.apache.jena.sparql.function.FunctionBase1; -/** Returns the serialization-specific geometry subtype as an xsd:anyURI literal. */ +/** Implements geof:geometryType. */ public class GeometryTypeFF extends FunctionBase1 { @Override diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/Is3DFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/Is3DFF.java index 81a80003b77..d043dd3547e 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/Is3DFF.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/Is3DFF.java @@ -26,7 +26,7 @@ import org.apache.jena.sparql.expr.NodeValue; import org.apache.jena.sparql.function.FunctionBase1; -/** Reports whether the geometry coordinate layout includes Z. */ +/** Implements geof:is3D. */ public class Is3DFF extends FunctionBase1 { @Override diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/IsMeasuredFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/IsMeasuredFF.java index 711978787f2..bede084228d 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/IsMeasuredFF.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/IsMeasuredFF.java @@ -26,7 +26,7 @@ import org.apache.jena.sparql.expr.NodeValue; import org.apache.jena.sparql.function.FunctionBase1; -/** Reports whether the geometry coordinate layout includes M. */ +/** Implements geof:isMeasured. */ public class IsMeasuredFF extends FunctionBase1 { @Override diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/NumGeometriesFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/NumGeometriesFF.java index d7590e669d0..a7ce3b3bd93 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/NumGeometriesFF.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/NumGeometriesFF.java @@ -26,7 +26,7 @@ import org.apache.jena.sparql.expr.NodeValue; import org.apache.jena.sparql.function.FunctionBase1; -/** Counts direct geometry members without flattening nested collections. */ +/** Implements geof:numGeometries. */ public class NumGeometriesFF extends FunctionBase1 { @Override 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 aa0a0e034be..6d2f36a13af 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 @@ -20,7 +20,6 @@ */ package org.apache.jena.geosparql.implementation; -import java.io.IOException; import java.io.Serializable; import java.util.Objects; @@ -36,11 +35,9 @@ import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions; import org.apache.jena.geosparql.implementation.jts.CustomCoordinateSequence; import org.apache.jena.geosparql.implementation.jts.CustomGeometryFactory; -import org.apache.jena.geosparql.implementation.parsers.gml.GMLReader; import org.apache.jena.geosparql.implementation.registry.MathTransformRegistry; import org.apache.jena.geosparql.implementation.registry.SRSRegistry; import org.apache.jena.geosparql.implementation.registry.UnitsRegistry; -import org.apache.jena.geosparql.implementation.vocabulary.GeoSPARQL_URI; import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI; import org.apache.jena.geosparql.implementation.vocabulary.Unit_URI; import org.apache.jena.graph.Node; @@ -48,7 +45,6 @@ import org.apache.jena.rdf.model.ResourceFactory; import org.apache.jena.sparql.expr.NodeValue; import org.apache.sis.geometry.DirectPosition2D; -import org.jdom2.JDOMException; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; @@ -82,6 +78,8 @@ public class GeometryWrapper implements Serializable { private final String geometryDatatypeURI; private GeometryDatatype geometryDatatype; private String lexicalForm; + private final String sourceLexicalForm; + private String geometryTypeURI; private String utmURI = null; private Double latitude = null; @@ -108,6 +106,26 @@ public GeometryWrapper(Geometry geometry, String srsURI, String geometryDatatype this(geometry, GeometryReverse.check(geometry, srsURI.isEmpty() ? SRS_URI.DEFAULT_WKT_CRS84 : srsURI), srsURI.isEmpty() ? SRS_URI.DEFAULT_WKT_CRS84 : srsURI, geometryDatatypeURI, dimensionInfo, geometryLiteral); } + /** + * Constructs a wrapper for the supplied geometry, with optional serialized + * text and an optional geometry subtype URI. + * + * @param geometry In X/Y or Y/X coordinate order of the SRS URI. + * @param srsURI The spatial reference system URI; an empty string uses CRS84. + * @param geometryDatatypeURI The geometry serialization datatype URI. + * @param dimensionInfo The geometry's coordinate layout and spatial and topological dimensions. + * @param geometryLiteral The serialized text representing this geometry, such as WKT or GML, + * in the format identified by geometryDatatypeURI. If null, the text + * is generated from the supplied geometry when serialization is requested. + * @param geometryTypeURI The subtype URI for this geometry in the specified datatype. + * If null, the datatype determines it when getGeometryTypeURI() is first called. + */ + public GeometryWrapper(Geometry geometry, String srsURI, String geometryDatatypeURI, DimensionInfo dimensionInfo, + String geometryLiteral, String geometryTypeURI) { + this(geometry, srsURI, geometryDatatypeURI, dimensionInfo, geometryLiteral); + this.geometryTypeURI = geometryTypeURI; + } + protected GeometryWrapper(Geometry parsingGeometry, Geometry xyGeometry, String srsURI, String geometryDatatypeURI, DimensionInfo dimensionInfo) { this(parsingGeometry, xyGeometry, srsURI, geometryDatatypeURI, dimensionInfo, null); } @@ -130,6 +148,7 @@ protected GeometryWrapper(Geometry parsingGeometry, Geometry xyGeometry, String this.dimensionInfo = dimensionInfo; this.lexicalForm = lexicalForm; //If not Initialised then required by asLiteral() etc. + this.sourceLexicalForm = lexicalForm; } /** @@ -194,6 +213,8 @@ public GeometryWrapper(GeometryWrapper geometryWrapper) { this.srsInfo = geometryWrapper.srsInfo; this.dimensionInfo = geometryWrapper.dimensionInfo; this.lexicalForm = geometryWrapper.lexicalForm; + this.sourceLexicalForm = geometryWrapper.sourceLexicalForm; + this.geometryTypeURI = geometryWrapper.geometryTypeURI; } /** @@ -392,29 +413,20 @@ public String getGeometryType() { } /** - * Returns the geometry subtype URI appropriate to this serialization, - * including for typed empty geometries. Specialized subtypes are not - * inferred from coordinates. + * Returns the geometry subtype URI defined by this wrapper's datatype, + * including for typed empty geometries. The URI is supplied at construction + * or resolved by the datatype and cached on the first successful lookup. + * Specialized subtypes are not inferred from the shape of the coordinates. + * + * @return The subtype URI as a string. * - * @throws IllegalArgumentException if the geometry type has no Simple Features mapping. - * @throws DatatypeFormatException if the GML literal cannot be read. + * @throws DatatypeFormatException if the datatype cannot resolve the geometry type. */ public String getGeometryTypeURI() { - if (GMLDatatype.URI.equals(geometryDatatypeURI)) { - // Retained GML distinguishes source types such as Curve and Surface - // from their JTS approximations. Constructed geometries use generated GML. - try { - return GeoSPARQL_URI.GML_URI + GMLReader.readGeometryType(getLexicalForm()); - } catch (JDOMException | IOException ex) { - throw new DatatypeFormatException("Unable to read GML geometry type", ex); - } + if (geometryTypeURI == null) { + geometryTypeURI = getGeometryDatatype().getGeometryTypeURI(this); } - String type = getGeometryType(); - return switch (type) { - case "Point", "LineString", "LinearRing", "Polygon", "MultiPoint", "MultiLineString", - "MultiPolygon", "GeometryCollection" -> GeoSPARQL_URI.SF_URI + type; - default -> throw new IllegalArgumentException("Unsupported Simple Features geometry type: " + type); - }; + return geometryTypeURI; } /** @@ -1084,6 +1096,19 @@ public DimensionInfo getDimensionInfo() { return dimensionInfo; } + /** + * Returns the serialized geometry text supplied to the constructor, such as + * WKT or GML. Unlike {@link #getLexicalForm()}, this method does not generate + * text when none was supplied. Calling serialization methods does not change + * the returned value. + * + * @return The supplied text, or null if none was supplied. An empty string + * means that an empty literal was supplied; it is distinct from null. + */ + public String getSourceLexicalForm() { + return sourceLexicalForm; + } + /** * * @return String literal of Geometry Wrapper. diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GMLDatatype.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GMLDatatype.java index 4863c78f2c7..94bd69cfccf 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GMLDatatype.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GMLDatatype.java @@ -25,9 +25,11 @@ import org.apache.jena.datatypes.DatatypeFormatException; import org.apache.jena.geosparql.implementation.DimensionInfo; import org.apache.jena.geosparql.implementation.GeometryWrapper; +import org.apache.jena.geosparql.implementation.parsers.gml.GMLGeometryTypes; import org.apache.jena.geosparql.implementation.parsers.gml.GMLReader; import org.apache.jena.geosparql.implementation.parsers.gml.GMLWriter; import org.apache.jena.geosparql.implementation.vocabulary.Geo; +import org.apache.jena.geosparql.implementation.vocabulary.GeoSPARQL_URI; import org.jdom2.JDOMException; import org.locationtech.jts.geom.Geometry; @@ -85,12 +87,28 @@ public GeometryWrapper read(String geometryLiteral) { String srsURI = gmlReader.getSrsURI(); DimensionInfo dimensionInfo = gmlReader.getDimensionInfo(); - return new GeometryWrapper(geometry, srsURI, URI, dimensionInfo, geometryLiteral); + String geometryTypeURI = GeoSPARQL_URI.GML_URI + gmlReader.getGmlGeometryType(); + return new GeometryWrapper(geometry, srsURI, URI, dimensionInfo, geometryLiteral, geometryTypeURI); } catch (JDOMException | IOException ex) { throw new DatatypeFormatException("Illegal GML literal:" + geometryLiteral + ". " + ex.getMessage()); } } + @Override + public String getGeometryTypeURI(GeometryWrapper geometry) { + String source = geometry.getSourceLexicalForm(); + if (source != null) { + // Constructors accepting source text without a type URI use this fallback. + // read() prepopulates the wrapper's type, so normal lookups skip this path. + try { + return GeoSPARQL_URI.GML_URI + GMLReader.readGeometryType(source); + } catch (JDOMException | IOException ex) { + throw new DatatypeFormatException("Unable to read GML geometry type", ex); + } + } + return GeoSPARQL_URI.GML_URI + GMLGeometryTypes.fromJts(geometry.getParsingGeometry()); + } + @Override public String toString() { return "GMLDatatype{" + URI + '}'; diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GeometryDatatype.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GeometryDatatype.java index df0ba9d900d..3189167900e 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GeometryDatatype.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/GeometryDatatype.java @@ -37,6 +37,19 @@ public GeometryDatatype(String uri) { public abstract GeometryWrapper read(String geometryLiteral); + /** + * Resolves a geometry subtype URI using this datatype's type system. + * Datatypes supporting type resolution override this operation to define + * their type mapping. The default implementation throws a datatype error. + * + * @param geometry A wrapper whose geometry datatype is this datatype. + * @return The subtype URI as a non-null string. + * @throws DatatypeFormatException if type resolution is unsupported for this datatype or geometry. + */ + public String getGeometryTypeURI(GeometryWrapper geometry) { + throw new DatatypeFormatException("Geometry type resolution is not supported for datatype: " + getURI()); + } + /** * This method Parses the Geometry Literal to the JTS Geometry * diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/WKTDatatype.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/WKTDatatype.java index 7be20c1421f..7668d67a185 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/WKTDatatype.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/datatype/WKTDatatype.java @@ -26,6 +26,7 @@ import org.apache.jena.geosparql.implementation.parsers.wkt.WKTReader; import org.apache.jena.geosparql.implementation.parsers.wkt.WKTWriter; import org.apache.jena.geosparql.implementation.vocabulary.Geo; +import org.apache.jena.geosparql.implementation.vocabulary.GeoSPARQL_URI; import org.locationtech.jts.geom.Geometry; /** @@ -93,6 +94,16 @@ public GeometryWrapper read(String geometryLiteral) { return new GeometryWrapper(geometry, srsURI, URI, dimensionInfo, geometryLiteral); } + @Override + public String getGeometryTypeURI(GeometryWrapper geometry) { + String type = geometry.getGeometryType(); + return switch (type) { + case "Point", "LineString", "LinearRing", "Polygon", "MultiPoint", "MultiLineString", + "MultiPolygon", "GeometryCollection" -> GeoSPARQL_URI.SF_URI + type; + default -> throw new DatatypeFormatException("Unsupported Simple Features geometry type: " + type); + }; + } + @Override public String toString() { return "WKTDatatype{" + URI + '}'; diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLGeometryTypes.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLGeometryTypes.java new file mode 100644 index 00000000000..7a3c7cb8cfd --- /dev/null +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLGeometryTypes.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.implementation.parsers.gml; + +import org.apache.jena.datatypes.DatatypeFormatException; +import org.locationtech.jts.geom.Geometry; + +/** GML element names used when serializing JTS geometries. */ +public final class GMLGeometryTypes { + private GMLGeometryTypes() {} + + /** + * Returns the GML element name that the writer uses for the supplied JTS + * geometry, for example {@code MultiCurve} for a JTS MultiLineString. + * This mapping does not recover the type of an original GML literal. + * + * @param geometry The JTS geometry to represent in GML. + * @return The element's local name, without a namespace prefix or URI. + * @throws DatatypeFormatException if the writer does not support the geometry type. + */ + public static String fromJts(Geometry geometry) { + return switch (geometry.getGeometryType()) { + case "Point" -> "Point"; + case "LineString" -> "LineString"; + case "Polygon" -> "Polygon"; + case "MultiPoint" -> "MultiPoint"; + case "MultiLineString" -> "MultiCurve"; + case "MultiPolygon" -> "MultiSurface"; + case "GeometryCollection" -> "MultiGeometry"; + default -> throw new DatatypeFormatException("Geometry type not supported: " + geometry.getGeometryType()); + }; + } +} diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLReader.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLReader.java index 179c2e22e8b..f84965c6c8b 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLReader.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLReader.java @@ -63,6 +63,7 @@ public class GMLReader implements ParserReader { //Geometry attributes private final Geometry geometry; + private final String gmlGeometryType; private final String srsURI; private final CoordinateSequenceDimensions dims; private final DimensionInfo dimensionInfo; @@ -109,6 +110,7 @@ protected GMLReader(Element gmlElement) throws DatatypeFormatException, SRSInfoE int srsDimension = crs.getCoordinateSystem().getDimension(); this.dims = CoordinateSequenceDimensions.find(srsDimension); String geometryType = gmlElement.getName(); + this.gmlGeometryType = geometryType; this.geometry = buildGeometry(geometryType, gmlElement, dims, srsInfo); this.dimensionInfo = new DimensionInfo(dims, geometry.getDimension()); @@ -117,6 +119,7 @@ protected GMLReader(Element gmlElement) throws DatatypeFormatException, SRSInfoE protected GMLReader(Geometry geometry, int srsDimension, String srsURI) { this.srsURI = srsURI; this.geometry = geometry; + this.gmlGeometryType = null; this.dims = CoordinateSequenceDimensions.find(srsDimension); this.dimensionInfo = new DimensionInfo(dims, geometry.getDimension()); } @@ -125,6 +128,18 @@ protected GMLReader(Geometry geometry, int srsDimension) { this(geometry, srsDimension, SRS_URI.DEFAULT_WKT_CRS84); } + /** + * Returns the local name of the root XML element read from the GML literal, + * such as {@code Curve}, without a namespace prefix or URI. An empty GML + * literal is interpreted as an empty Point and returns {@code Point}. + * + * @return The root element's local name, or null if this reader was created + * directly from a JTS geometry rather than XML. + */ + public String getGmlGeometryType() { + return gmlGeometryType; + } + @Override public Geometry getGeometry() { return geometry; @@ -701,7 +716,16 @@ public static GMLReader extract(String gmlText) throws JDOMException, IOExceptio return new GMLReader(readRootElement(gmlText)); } - /** Returns the source GML element type before conversion to JTS geometry. */ + /** + * Parses the XML and returns its root element's local name, such as + * {@code Curve}, without a namespace prefix or URI. This method does not + * construct a JTS geometry or validate the XML against the GML schema. + * + * @param gmlText The serialized GML text; an empty string represents an empty Point. + * @return The root element's local name, or {@code Point} for an empty string. + * @throws JDOMException if the XML cannot be parsed. + * @throws IOException if an I/O error occurs while reading the XML. + */ public static String readGeometryType(String gmlText) throws JDOMException, IOException { return readRootElement(gmlText).getName(); } diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLWriter.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLWriter.java index 5e25164c8d8..5db2bc1508a 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLWriter.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLWriter.java @@ -139,7 +139,7 @@ private static Element buildLineString(final CoordinateSequence coordSeq, final private static Element buildPolygon(final Polygon polygon, final String srsName) { - Element gmlRoot = new Element(polygon.getGeometryType(), GML_NAMESPACE); + Element gmlRoot = new Element(GMLGeometryTypes.fromJts(polygon), GML_NAMESPACE); gmlRoot.setAttribute("srsName", srsName); if (!polygon.isEmpty()) { @@ -179,7 +179,7 @@ private static Element buildPolygon(final Polygon polygon, final String srsName) private static Element buildMultiPoint(final MultiPoint multiPoint, final String srsName) { - Element gmlRoot = new Element(multiPoint.getGeometryType(), GML_NAMESPACE); + Element gmlRoot = new Element(GMLGeometryTypes.fromJts(multiPoint), GML_NAMESPACE); gmlRoot.setAttribute("srsName", srsName); if (!multiPoint.isEmpty()) { @@ -202,8 +202,7 @@ private static Element buildMultiPoint(final MultiPoint multiPoint, final String private static Element buildMultiLineString(final MultiLineString multiLineString, final String srsName) { - //Element gmlRoot = new Element(multiLineString.getGeometryType(), GML_NAMESPACE); - Element gmlRoot = new Element("MultiCurve", GML_NAMESPACE); + Element gmlRoot = new Element(GMLGeometryTypes.fromJts(multiLineString), GML_NAMESPACE); gmlRoot.setAttribute("srsName", srsName); if (!multiLineString.isEmpty()) { @@ -227,8 +226,7 @@ private static Element buildMultiLineString(final MultiLineString multiLineStrin private static Element buildMultiPolygon(final MultiPolygon multiPolygon, final String dimensionString, final String srsName) { - //Element gmlRoot = new Element(multiPolygon.getGeometryType(), GML_NAMESPACE); - Element gmlRoot = new Element("MultiSurface", GML_NAMESPACE); + Element gmlRoot = new Element(GMLGeometryTypes.fromJts(multiPolygon), GML_NAMESPACE); gmlRoot.setAttribute("srsName", srsName); if (!multiPolygon.isEmpty()) { @@ -252,8 +250,7 @@ private static Element buildMultiPolygon(final MultiPolygon multiPolygon, final private static Element buildMultiGeometry(final GeometryCollection geometryCollection, final CoordinateSequenceDimensions dimensions, final String srsName) { - //Element gmlRoot = new Element(geometryCollection.getGeometryType(), GML_NAMESPACE); - Element gmlRoot = new Element("MultiGeometry", GML_NAMESPACE); + Element gmlRoot = new Element(GMLGeometryTypes.fromJts(geometryCollection), GML_NAMESPACE); gmlRoot.setAttribute("srsName", srsName); if (!geometryCollection.isEmpty()) { diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryTypeSerializationTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryTypeSerializationTest.java index 39bb1453c2d..a13f1be8ebe 100644 --- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryTypeSerializationTest.java +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryTypeSerializationTest.java @@ -21,7 +21,12 @@ package org.apache.jena.geosparql.implementation; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import org.apache.jena.datatypes.DatatypeFormatException; +import org.apache.jena.datatypes.TypeMapper; +import org.apache.jena.geosparql.implementation.datatype.GeometryDatatype; import org.apache.jena.geosparql.implementation.datatype.GMLDatatype; import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; import org.junit.Test; @@ -30,6 +35,23 @@ public class GeometryTypeSerializationTest { private static final String GML = "http://www.opengis.net/ont/gml#"; private static final String SF = "http://www.opengis.net/ont/sf#"; + @Test + public void parsedTypeQueriesDoNotReadGmlAgain() { + CountingGeometryWrapper geometry = new CountingGeometryWrapper(curve()); + assertEquals(GML + "Curve", type(geometry)); + assertEquals(GML + "Curve", type(geometry)); + assertEquals(0, geometry.lexicalReads); + assertEquals(0, geometry.sourceReads); + } + + @Test + public void derivedGeometryDoesNotInheritTheCachedSourceType() { + GeometryWrapper source = curve(); + assertEquals(GML + "Curve", type(source)); + assertEquals(GML + "Polygon", type(source.envelope())); + assertEquals(GML + "Curve", type(source)); + } + @Test public void copyRetainsSourceSubtypeWithDefaultXmlNamespace() { GeometryWrapper source = curve(); @@ -40,6 +62,7 @@ public void copyRetainsSourceSubtypeWithDefaultXmlNamespace() { @Test public void conversionToWktUsesTheConvertedTypeWithoutChangingSource() { GeometryWrapper source = curve(); + assertEquals(GML + "Curve", type(source)); GeometryWrapper converted = GeometryWrapper.extract(source.asLiteral(WKTDatatype.URI)); assertEquals(SF + "LineString", type(converted)); assertEquals(GML + "Curve", type(source)); @@ -50,6 +73,8 @@ public void constructedGmlUsesWriterElementNames() { String[][] cases = { { "POINT (1 2)", "Point" }, { "LINESTRING (0 0, 1 1)", "LineString" }, + { "POLYGON ((0 0, 1 0, 0 1, 0 0))", "Polygon" }, + { "MULTIPOINT ((1 2), (3 4))", "MultiPoint" }, { "MULTILINESTRING ((0 0, 1 1))", "MultiCurve" }, { "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)))", "MultiSurface" }, { "GEOMETRYCOLLECTION (POINT (1 2))", "MultiGeometry" }, @@ -61,6 +86,11 @@ public void constructedGmlUsesWriterElementNames() { GMLDatatype.URI, wkt.getDimensionInfo()); assertEquals(example[0], GML + example[1], type(constructed)); assertEquals(example[0], GML + example[1], type(GeometryWrapper.extract(constructed.asLiteral()))); + GeometryWrapper serializedFirst = new GeometryWrapper(wkt.getParsingGeometry(), wkt.getSrsURI(), + GMLDatatype.URI, wkt.getDimensionInfo()); + serializedFirst.asLiteral(); + assertNull(serializedFirst.getSourceLexicalForm()); + assertEquals(example[0], GML + example[1], type(serializedFirst)); } } @@ -69,16 +99,130 @@ public void emptyGmlTextUsesJenasPointInterpretation() { assertEquals(GML + "Point", type(GeometryWrapper.extract("", GMLDatatype.URI))); } + @Test + public void constructedTypeIsMemoizedAndCopied() { + GeometryWrapper wkt = WKTDatatype.INSTANCE.read("MULTILINESTRING ((0 0, 1 1))"); + CountingGeometryWrapper geometry = new CountingGeometryWrapper(new GeometryWrapper( + wkt.getParsingGeometry(), wkt.getSrsURI(), GMLDatatype.URI, wkt.getDimensionInfo())); + assertEquals(GML + "MultiCurve", type(geometry)); + assertEquals(GML + "MultiCurve", type(geometry)); + assertEquals(1, geometry.sourceReads); + assertEquals(0, geometry.lexicalReads); + CountingGeometryWrapper copy = new CountingGeometryWrapper(geometry); + assertEquals(GML + "MultiCurve", type(copy)); + assertEquals(0, copy.sourceReads); + assertEquals(0, copy.lexicalReads); + } + + @Test + public void publicLexicalConstructorPreservesSourceSubtype() { + GeometryWrapper source = curve(); + GeometryWrapper geometry = new GeometryWrapper(source.getParsingGeometry(), source.getSrsURI(), + GMLDatatype.URI, source.getDimensionInfo(), source.getLexicalForm()); + geometry.asLiteral(); + assertEquals(GML + "Curve", type(geometry)); + assertEquals(GML + "Curve", type(new GeometryWrapper(geometry))); + } + + @Test + public void protectedLexicalConstructorPreservesSourceSubtype() { + GeometryWrapper source = curve(); + GeometryWrapper geometry = new GeometryWrapper(source.getParsingGeometry(), source.getXYGeometry(), + source.getSrsURI(), GMLDatatype.URI, source.getDimensionInfo(), source.getLexicalForm()); + assertEquals(GML + "Curve", type(new GeometryWrapper(geometry))); + } + + @Test + public void constructedGmlRejectsTypesTheWriterCannotRepresent() { + GeometryWrapper ring = WKTDatatype.INSTANCE.read("LINEARRING (0 0, 1 0, 1 1, 0 0)"); + GeometryWrapper gml = new GeometryWrapper(ring.getParsingGeometry(), ring.getSrsURI(), + GMLDatatype.URI, ring.getDimensionInfo()); + assertThrows(DatatypeFormatException.class, gml::getGeometryTypeURI); + assertThrows(DatatypeFormatException.class, gml::asLiteral); + } + + @Test + public void unsupportedSimpleFeaturesTypeRaisesDatatypeError() { + GeometryWrapper geometry = new GeometryWrapper(WKTDatatype.INSTANCE.read("POINT (1 2)")) { + @Override + public String getGeometryType() { + return "UnsupportedGeometry"; + } + }; + assertThrows(DatatypeFormatException.class, geometry::getGeometryTypeURI); + } + + @Test + public void datatypeExtensionsWithoutTypeResolutionRaiseDatatypeError() { + GeometryDatatype datatype = new GeometryDatatype("urn:test:geometry-type:default") { + @Override + public GeometryWrapper read(String text) { + GeometryWrapper wkt = WKTDatatype.INSTANCE.read(text); + return new GeometryWrapper(wkt.getParsingGeometry(), wkt.getSrsURI(), getURI(), wkt.getDimensionInfo()); + } + }; + TypeMapper.getInstance().registerDatatype(datatype); + try { + GeometryWrapper geometry = datatype.read("POINT (1 2)"); + assertThrows(DatatypeFormatException.class, geometry::getGeometryTypeURI); + } finally { + TypeMapper.getInstance().unregisterDatatype(datatype); + } + } + + @Test + public void datatypeExtensionsCanResolveTheirOwnTypes() { + GeometryDatatype datatype = new GeometryDatatype("urn:test:geometry-type:custom") { + @Override + public GeometryWrapper read(String text) { + GeometryWrapper wkt = WKTDatatype.INSTANCE.read(text); + return new GeometryWrapper(wkt.getParsingGeometry(), wkt.getSrsURI(), getURI(), wkt.getDimensionInfo()); + } + + @Override + public String getGeometryTypeURI(GeometryWrapper geometry) { + return "urn:test:Point"; + } + }; + TypeMapper.getInstance().registerDatatype(datatype); + try { + assertEquals("urn:test:Point", type(datatype.read("POINT (1 2)"))); + } finally { + TypeMapper.getInstance().unregisterDatatype(datatype); + } + } + private static GeometryWrapper curve() { - return GeometryWrapper.extract(""" + return GMLDatatype.INSTANCE.read(""" 0 0 10 10 20 0 - """, GMLDatatype.URI); + """); } private static String type(GeometryWrapper geometry) { return geometry.getGeometryTypeURI(); } + + private static class CountingGeometryWrapper extends GeometryWrapper { + private int lexicalReads; + private int sourceReads; + + CountingGeometryWrapper(GeometryWrapper geometry) { + super(geometry); + } + + @Override + public String getSourceLexicalForm() { + sourceReads++; + return super.getSourceLexicalForm(); + } + + @Override + public String getLexicalForm() { + lexicalReads++; + return super.getLexicalForm(); + } + } } diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLWriterTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLWriterTest.java index 2dcce2b5603..b5aa039aa32 100644 --- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLWriterTest.java +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLWriterTest.java @@ -20,8 +20,11 @@ */ package org.apache.jena.geosparql.implementation.parsers.gml; +import java.util.List; + import org.apache.jena.geosparql.implementation.DimensionInfo; import org.apache.jena.geosparql.implementation.GeometryWrapper; +import org.apache.jena.geosparql.implementation.GeometryWrapperFactory; import org.apache.jena.geosparql.implementation.datatype.GMLDatatype; import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions; import org.apache.jena.geosparql.implementation.jts.CustomCoordinateSequence; @@ -68,6 +71,21 @@ public void tearDown() { private static final GeometryFactory GEOMETRY_FACTORY = CustomGeometryFactory.theInstance(); + @Test + public void multiLineStringWritesLinearRingMemberAsLineString() { + LinearRing ring = GEOMETRY_FACTORY.createLinearRing( + new CustomCoordinateSequence(CoordinateSequenceDimensions.XY, "0 0,1 0,1 1,0 0")); + GeometryWrapper geometry = GeometryWrapperFactory.createMultiLineString(List.of(ring), GMLDatatype.URI); + + String result = geometry.asLiteral().getLexicalForm(); + String expected = "" + + "" + + "0 0 1 0 1 1 0 0" + + ""; + assertEquals(expected, result); + } + @Test public void testWritePoint() {