From 7cc90fee8fc131909b21b063d4b3d4aa59ef9dd6 Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Thu, 17 Sep 2026 20:51:54 +1000 Subject: [PATCH 1/2] GH-4230: Add GeoSPARQL 1.1 geometryN function Add one-based member selection through geof:geometryN, retaining the selected member's datatype, SRS and layout. Accept only xsd:integer indexes, including integer-derived types such as xsd:short. Round-trip empty members in homogeneous WKT Multi geometries, including empty aggregates whose Z/M/ZM marker differs from the enclosing collection, and empty GML polygons whose CRS is 3D. --- .../geometry_property/GeometryNFF.java | 51 +++ .../implementation/DimensionInfo.java | 30 ++ .../implementation/GeometryWrapper.java | 21 ++ .../GeometryProperty.java | 6 +- .../implementation/parsers/gml/GMLReader.java | 4 +- .../implementation/parsers/gml/GMLWriter.java | 8 +- .../implementation/parsers/wkt/WKTReader.java | 132 +++++--- .../implementation/parsers/wkt/WKTWriter.java | 101 +++--- .../implementation/vocabulary/Geof.java | 3 + .../geometry_property/GeometryNFFTest.java | 283 ++++++++++++++++ .../implementation/GeometryMemberTest.java | 311 ++++++++++++++++++ 11 files changed, 845 insertions(+), 105 deletions(-) create mode 100644 jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryNFF.java create mode 100644 jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryNFFTest.java create mode 100644 jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryMemberTest.java diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryNFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryNFF.java new file mode 100644 index 00000000000..9807ab6119a --- /dev/null +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryNFF.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.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.ExprEvalTypeException; +import org.apache.jena.sparql.expr.NodeValue; +import org.apache.jena.sparql.function.FunctionBase2; + +/** Implements geof:geometryN. */ +public class GeometryNFF extends FunctionBase2 { + + @Override + public NodeValue exec(NodeValue value, NodeValue index) { + try { + GeometryWrapper geometry = GeometryWrapper.extract(value); + return geometry.getGeometryN(memberIndex(index)).asNodeValue(); + } catch (DatatypeFormatException ex) { + throw new ExprEvalException(ex.getMessage(), ex); + } catch (IllegalArgumentException | ArithmeticException ex) { + throw new ExprEvalException(ex.getMessage(), ex); + } + } + + private static int memberIndex(NodeValue index) { + if (!index.isInteger()) { + throw new ExprEvalTypeException("Geometry member index must be an xsd:integer: " + index); + } + return index.getInteger().intValueExact(); + } +} diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/DimensionInfo.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/DimensionInfo.java index d06566742b5..cac4b14ba5a 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/DimensionInfo.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/DimensionInfo.java @@ -28,6 +28,11 @@ import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions; import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.GeometryCollection; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.geom.CoordinateXY; import org.locationtech.jts.geom.Geometry; @@ -90,6 +95,31 @@ public static int findCoordinateDimension(CoordinateSequenceDimensions dims) { } } + /** + * Finds dimension metadata from coordinate sequences, preserving Z/M layouts + * even when the sequence is empty. Collections use their first member's + * layout, layout retained on an empty aggregate, or the fallback when they + * have no members. + */ + public static DimensionInfo find(Geometry geometry, CoordinateSequenceDimensions fallback) { + CoordinateSequence sequence = null; + if (geometry instanceof Point point) { + sequence = point.getCoordinateSequence(); + } else if (geometry instanceof LineString line) { + sequence = line.getCoordinateSequence(); + } else if (geometry instanceof Polygon polygon) { + sequence = polygon.getExteriorRing().getCoordinateSequence(); + } else if (geometry instanceof GeometryCollection collection && collection.getNumGeometries() > 0) { + fallback = find(collection.getGeometryN(0), fallback).getDimensions(); + } else if (geometry.getUserData() instanceof CoordinateSequenceDimensions retained) { + // WKTReader stores parsed layout here; empty aggregates have no sequence. + fallback = retained; + } + CoordinateSequenceDimensions dimensions = sequence == null ? fallback + : findCoordinateSequenceDimensions(sequence.getDimension(), sequence.getDimension() - sequence.getMeasures()); + return new DimensionInfo(dimensions, geometry.getDimension()); + } + public static DimensionInfo find(Coordinate coordinate, Geometry geometry) { // Empty geometries have no coordinate; use the existing XY default. CoordinateSequenceDimensions coordDims = CoordinateSequenceDimensions.find( 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 d609982e5e8..8978171e540 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 @@ -49,6 +49,7 @@ import org.locationtech.jts.geom.CoordinateXY; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryCollection; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.IntersectionMatrix; import org.locationtech.jts.geom.Point; @@ -475,6 +476,26 @@ public double getMaxZ() { return GeometryCoordinateExtrema.maxZ(this); } + /** + * Selects a direct geometry member using a one-based index, without flattening + * nested collections. Index 1 selects an atomic geometry itself, including + * an empty atomic geometry. Retains the source datatype and SRS and uses the + * selected member's coordinate layout. + * + * @throws IllegalArgumentException if the index is outside the member range. + */ + public GeometryWrapper getGeometryN(int index) { + if (index < 1 || index > parsingGeometry.getNumGeometries()) { + throw new IllegalArgumentException("Geometry member index is out of range: " + index); + } + if (!(parsingGeometry instanceof GeometryCollection)) { + return this; + } + Geometry member = parsingGeometry.getGeometryN(index - 1); + DimensionInfo dimensions = DimensionInfo.find(member, dimensionInfo.getDimensions()); + return new GeometryWrapper(member, getSrsURI(), geometryDatatypeURI, dimensions); + } + /** * * @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 3985e407170..b595cbadd60 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,6 +28,7 @@ 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.GeometryNFF; 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; @@ -70,7 +71,7 @@ public static void loadPropertyFunctions(PropertyFunctionRegistry registry) { /** * Registers {@code geof:} expression functions with the supplied registry. * - * @param registry - the FunctionRegistry to be used + * @param registry the FunctionRegistry to use */ public static void loadFilterFunctions(FunctionRegistry registry) { @@ -83,13 +84,14 @@ public static void loadFilterFunctions(FunctionRegistry registry) { registry.put(Geof.IS_SIMPLE, IsSimpleFF.class); registry.put(Geof.IS_EMPTY, IsEmptyFF.class); registry.put(Geof.IS_VALID, IsValidFF.class); - registry.put(Geof.MIN_X, MinXFF.class); registry.put(Geof.MIN_Y, MinYFF.class); registry.put(Geof.MIN_Z, MinZFF.class); registry.put(Geof.MAX_X, MaxXFF.class); registry.put(Geof.MAX_Y, MaxYFF.class); registry.put(Geof.MAX_Z, MaxZFF.class); + + registry.put(Geof.GEOMETRY_N, GeometryNFF.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..e47909359ae 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 @@ -481,7 +481,7 @@ private static Polygon buildPolygon(Element gmlElement, CoordinateSequenceDimens Element exteriorLinearRingElement = exteriorElement.getChild("LinearRing", GML_NAMESPACE); exteriorLinearRing = buildLinearRing(exteriorLinearRingElement, dims); } else { - exteriorLinearRing = GEOMETRY_FACTORY.createLinearRing(); + exteriorLinearRing = GEOMETRY_FACTORY.createLinearRing(new CustomCoordinateSequence(dims)); } //Interior shell - [0..*] List interiorElements = gmlElement.getChildren("interior", GML_NAMESPACE); @@ -536,7 +536,7 @@ private static Polygon buildSurface(Element gmlElement, CoordinateSequenceDimens Geometry exteriorGeom = buildSurfacePatch(exteriorElement, dims, srsInfo); exteriorLinearRing = GEOMETRY_FACTORY.createLinearRing(exteriorGeom.getCoordinates()); } else { - exteriorLinearRing = GEOMETRY_FACTORY.createLinearRing(); + exteriorLinearRing = GEOMETRY_FACTORY.createLinearRing(new CustomCoordinateSequence(dims)); } //Interior shell - [0..*] 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..3278a8746bb 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 @@ -182,7 +182,7 @@ private static Element buildMultiPoint(final MultiPoint multiPoint, final String Element gmlRoot = new Element(multiPoint.getGeometryType(), GML_NAMESPACE); gmlRoot.setAttribute("srsName", srsName); - if (!multiPoint.isEmpty()) { + if (multiPoint.getNumGeometries() > 0) { int geomCount = multiPoint.getNumGeometries(); for (int i = 0; i < geomCount; i++) { @@ -206,7 +206,7 @@ private static Element buildMultiLineString(final MultiLineString multiLineStrin Element gmlRoot = new Element("MultiCurve", GML_NAMESPACE); gmlRoot.setAttribute("srsName", srsName); - if (!multiLineString.isEmpty()) { + if (multiLineString.getNumGeometries() > 0) { int geomCount = multiLineString.getNumGeometries(); for (int i = 0; i < geomCount; i++) { @@ -231,7 +231,7 @@ private static Element buildMultiPolygon(final MultiPolygon multiPolygon, final Element gmlRoot = new Element("MultiSurface", GML_NAMESPACE); gmlRoot.setAttribute("srsName", srsName); - if (!multiPolygon.isEmpty()) { + if (multiPolygon.getNumGeometries() > 0) { int geomCount = multiPolygon.getNumGeometries(); for (int i = 0; i < geomCount; i++) { @@ -256,7 +256,7 @@ private static Element buildMultiGeometry(final GeometryCollection geometryColle Element gmlRoot = new Element("MultiGeometry", GML_NAMESPACE); gmlRoot.setAttribute("srsName", srsName); - if (!geometryCollection.isEmpty()) { + if (geometryCollection.getNumGeometries() > 0) { int geomCount = geometryCollection.getNumGeometries(); for (int i = 0; i < geomCount; i++) { diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTReader.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTReader.java index 1069230c210..009ea9b4dab 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTReader.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTReader.java @@ -20,7 +20,9 @@ */ package org.apache.jena.geosparql.implementation.parsers.wkt; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Objects; import org.apache.jena.datatypes.DatatypeFormatException; import org.apache.jena.geosparql.implementation.DimensionInfo; @@ -30,9 +32,11 @@ import org.apache.jena.geosparql.implementation.parsers.ParserReader; import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI; import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryCollection; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; /** @@ -51,6 +55,7 @@ public class WKTReader implements ParserReader { protected WKTReader(String geometryType, String dimensionString, String coordinates, String srsURI) { this.dims = convertDimensionString(dimensionString); this.geometry = buildGeometry(geometryType, coordinates); + retainEmptyAggregateLayout(geometry, dims); this.dimensionInfo = new DimensionInfo(dims, geometry.getDimension()); this.srsURI = srsURI; } @@ -125,8 +130,7 @@ private Geometry buildGeometry(String geometryType, String coordinates) throws D geo = buildPolygon(coordinates); break; case "multipoint": - CustomCoordinateSequence multiPointSequence = new CustomCoordinateSequence(dims, clean(coordinates)); - geo = GEOMETRY_FACTORY.createMultiPoint(multiPointSequence); + geo = buildMultiPoint(coordinates); break; case "multilinestring": geo = buildMultiLineString(coordinates); @@ -146,55 +150,121 @@ private Geometry buildGeometry(String geometryType, String coordinates) throws D return geo; } + /** Empty Multi/collection has no sequence for Z/M/ZM; stash the parsed layout for DimensionInfo.find. */ + private static void retainEmptyAggregateLayout(Geometry geometry, CoordinateSequenceDimensions dims) { + if (geometry instanceof GeometryCollection collection && collection.getNumGeometries() == 0) { + geometry.setUserData(dims); + } + } + private String clean(String unclean) { return unclean.replace(")", "").replace("(", "").trim(); } private Geometry buildGeometryCollection(String coordinates) throws DatatypeFormatException { - if (coordinates.isEmpty()) { return GEOMETRY_FACTORY.createGeometryCollection(new Geometry[0]); } - - //Split coordinates - String tidied = coordinates.substring(1, coordinates.length() - 1); - tidied = tidied.replaceAll("[\\ ]?,[\\ ]?", ","); //Remove spaces around commas - String[] partCoordinates = tidied.split("\\),(?=[^\\(])"); //Split whenever there is a ), but not ),( - - Geometry[] geometries = new Geometry[partCoordinates.length]; - - for (int i = 0; i < partCoordinates.length; i++) { - WKTReader partWKTInfo = extract(partCoordinates[i]); + String[] members = splitMembers(coordinates); + Geometry[] geometries = new Geometry[members.length]; + for (int i = 0; i < members.length; i++) { + WKTReader partWKTInfo = extract(members[i]); geometries[i] = partWKTInfo.geometry; } return GEOMETRY_FACTORY.createGeometryCollection(geometries); } - private Geometry buildMultiLineString(String coordinates) { + private Geometry buildMultiPoint(String coordinates) { + if (coordinates.isEmpty()) { + return GEOMETRY_FACTORY.createMultiPoint(new Point[0]); + } + String[] members = splitMembers(coordinates); + Point[] points = new Point[members.length]; + for (int i = 0; i < members.length; i++) { + points[i] = GEOMETRY_FACTORY.createPoint(memberSequence(members[i])); + } + return GEOMETRY_FACTORY.createMultiPoint(points); + } + private Geometry buildMultiLineString(String coordinates) { if (coordinates.isEmpty()) { return GEOMETRY_FACTORY.createMultiLineString(new LineString[0]); } - - String[] splitCoordinates = splitCoordinates(coordinates); - LineString[] lineStrings = splitLineStrings(splitCoordinates); + String[] members = splitMembers(coordinates); + LineString[] lineStrings = new LineString[members.length]; + for (int i = 0; i < members.length; i++) { + lineStrings[i] = GEOMETRY_FACTORY.createLineString(memberSequence(members[i])); + } return GEOMETRY_FACTORY.createMultiLineString(lineStrings); } private Geometry buildMultiPolygon(String coordinates) { - if (coordinates.isEmpty()) { return GEOMETRY_FACTORY.createMultiPolygon(new Polygon[0]); } + String[] members = splitMembers(coordinates); + Polygon[] polygons = new Polygon[members.length]; + for (int i = 0; i < members.length; i++) { + polygons[i] = isEmptyMember(members[i]) + ? GEOMETRY_FACTORY.createPolygon(emptySequence()) + : buildPolygon(members[i]); + } + return GEOMETRY_FACTORY.createMultiPolygon(polygons); + } + + private CustomCoordinateSequence memberSequence(String member) { + return isEmptyMember(member) ? emptySequence() : new CustomCoordinateSequence(dims, clean(member)); + } - String trimmed = coordinates.replace(")) ,", ")),"); - String[] multiCoordinates = trimmed.split("\\)\\),"); - Polygon[] polygons = new Polygon[multiCoordinates.length]; - for (int i = 0; i < multiCoordinates.length; i++) { - polygons[i] = buildPolygon(multiCoordinates[i]); + private CustomCoordinateSequence emptySequence() { + return new CustomCoordinateSequence(dims); + } + + private static boolean isEmptyMember(String member) { + return "empty".equals(member.trim()); + } + + private static String[] splitMembers(String coordinates) { + String inner = coordinates.trim(); + if (inner.startsWith("(") && inner.endsWith(")")) { + inner = inner.substring(1, inner.length() - 1); } + List parts = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + int depth = 0; + for (int i = 0; i < inner.length(); i++) { + char c = inner.charAt(i); + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + if (depth < 0) { + throw new DatatypeFormatException("Unbalanced parentheses in WKT: " + coordinates); + } + } + if (c == ',' && depth == 0) { + addMember(parts, current.toString()); + current.setLength(0); + } else { + current.append(c); + } + } + if (depth != 0) { + throw new DatatypeFormatException("Unbalanced parentheses in WKT: " + coordinates); + } + String last = current.toString(); + if (!last.isEmpty() || !parts.isEmpty()) { + addMember(parts, last); + } + return parts.toArray(String[]::new); + } - return GEOMETRY_FACTORY.createMultiPolygon(polygons); + private static void addMember(List parts, String raw) { + String member = raw.trim(); + if (member.isEmpty()) { + throw new DatatypeFormatException("Missing WKT member; use EMPTY for an empty geometry."); + } + parts.add(member); } private Polygon buildPolygon(String coordinates) { @@ -227,20 +297,6 @@ private String[] splitCoordinates(String coordinates) { } - private LineString[] splitLineStrings(String[] splitCoordinates) { - - LineString[] lineStrings = new LineString[splitCoordinates.length]; - - for (int i = 0; i < splitCoordinates.length; i++) { - CustomCoordinateSequence sequence = new CustomCoordinateSequence(dims, clean(splitCoordinates[i])); - LineString lineString = GEOMETRY_FACTORY.createLineString(sequence); - lineStrings[i] = lineString; - } - - return lineStrings; - - } - private LinearRing[] splitLinearRings(CoordinateSequenceDimensions dims, String[] splitCoordinates) { LinearRing[] linearRings = new LinearRing[splitCoordinates.length]; diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTWriter.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTWriter.java index 1889f994a83..77e6cd13de2 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTWriter.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTWriter.java @@ -20,6 +20,7 @@ */ package org.apache.jena.geosparql.implementation.parsers.wkt; +import org.apache.jena.geosparql.implementation.DimensionInfo; import org.apache.jena.geosparql.implementation.GeometryWrapper; import org.apache.jena.geosparql.implementation.SRSInfo; import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions; @@ -121,10 +122,6 @@ private static String convertToWKTText(CustomCoordinateSequence coordSequence) { return sb.toString(); } - private static String buildWKT(final String geometryType, final CoordinateSequence coordSeq) { - return buildWKT(geometryType, coordSeq, ""); - } - private static String buildWKT(final String geometryType, final CoordinateSequence coordSeq, final String dimensionString) { CustomCoordinateSequence coordSequence = (CustomCoordinateSequence) coordSeq; @@ -132,9 +129,7 @@ private static String buildWKT(final String geometryType, final CoordinateSequen StringBuilder sb = new StringBuilder(geometryType); - if (!wktText.equals(" EMPTY")) { - sb.append(dimensionString); - } + sb.append(dimensionString); sb.append(wktText); @@ -147,12 +142,10 @@ private static String buildPolygon(final Polygon polygon, final boolean isInclud if (isIncludeGeometryType) { sb.append("POLYGON"); + sb.append(dimensionString); } if (!polygon.isEmpty()) { - if (isIncludeGeometryType) { - sb.append(dimensionString); - } sb.append("("); //Find exterior shell @@ -172,31 +165,33 @@ private static String buildPolygon(final Polygon polygon, final boolean isInclud sb.append(")"); } else { - sb.append(" EMPTY"); + sb.append(isIncludeGeometryType ? " EMPTY" : "EMPTY"); } return sb.toString(); } + private static String memberText(final CoordinateSequence coordSeq) { + CustomCoordinateSequence coordSequence = (CustomCoordinateSequence) coordSeq; + if (coordSequence.getSize() == 0) { + return "EMPTY"; + } + return convertToWKTText(coordSequence); + } + private static String buildMultiPoint(final MultiPoint multiPoint, final String dimensionString) { StringBuilder sb = new StringBuilder("MULTIPOINT"); + sb.append(dimensionString); - if (!multiPoint.isEmpty()) { - - sb.append(dimensionString); + int geomCount = multiPoint.getNumGeometries(); + if (geomCount > 0) { sb.append("("); - //Find first point - Point point = (Point) multiPoint.getGeometryN(0); - CustomCoordinateSequence coordSequence = (CustomCoordinateSequence) point.getCoordinateSequence(); - - sb.append(buildWKT("", coordSequence)); - //Encode remaining points - int geomCount = multiPoint.getNumGeometries(); - for (int i = 1; i < geomCount; i++) { - sb.append(", "); - point = (Point) multiPoint.getGeometryN(i); - coordSequence = (CustomCoordinateSequence) point.getCoordinateSequence(); - sb.append(buildWKT("", coordSequence)); + for (int i = 0; i < geomCount; i++) { + if (i > 0) { + sb.append(", "); + } + Point point = (Point) multiPoint.getGeometryN(i); + sb.append(memberText(point.getCoordinateSequence())); } sb.append(")"); } else { @@ -209,23 +204,17 @@ private static String buildMultiPoint(final MultiPoint multiPoint, final String private static String buildMultiLineString(final MultiLineString multiLineString, final String dimensionString) { StringBuilder sb = new StringBuilder("MULTILINESTRING"); + sb.append(dimensionString); - if (!multiLineString.isEmpty()) { - sb.append(dimensionString); + int geomCount = multiLineString.getNumGeometries(); + if (geomCount > 0) { sb.append("("); - - //Find first linestring - LineString lineString = (LineString) multiLineString.getGeometryN(0); - CustomCoordinateSequence coordSequence = (CustomCoordinateSequence) lineString.getCoordinateSequence(); - - sb.append(buildWKT("", coordSequence)); - //Encode remaining points - int geomCount = multiLineString.getNumGeometries(); - for (int i = 1; i < geomCount; i++) { - sb.append(", "); - lineString = (LineString) multiLineString.getGeometryN(i); - coordSequence = (CustomCoordinateSequence) lineString.getCoordinateSequence(); - sb.append(buildWKT("", coordSequence)); + for (int i = 0; i < geomCount; i++) { + if (i > 0) { + sb.append(", "); + } + LineString lineString = (LineString) multiLineString.getGeometryN(i); + sb.append(memberText(lineString.getCoordinateSequence())); } sb.append(")"); } else { @@ -238,21 +227,16 @@ private static String buildMultiLineString(final MultiLineString multiLineString private static String buildMultiPolygon(final MultiPolygon multiPolygon, final String dimensionString) { StringBuilder sb = new StringBuilder("MULTIPOLYGON"); + sb.append(dimensionString); - if (!multiPolygon.isEmpty()) { - sb.append(dimensionString); + int geomCount = multiPolygon.getNumGeometries(); + if (geomCount > 0) { sb.append("("); - - //Find first polygon - Polygon polygon = (Polygon) multiPolygon.getGeometryN(0); - - sb.append(buildPolygon(polygon, false, dimensionString)); - //Encode remaining points - int geomCount = multiPolygon.getNumGeometries(); - for (int i = 1; i < geomCount; i++) { - sb.append(", "); - polygon = (Polygon) multiPolygon.getGeometryN(i); - + for (int i = 0; i < geomCount; i++) { + if (i > 0) { + sb.append(", "); + } + Polygon polygon = (Polygon) multiPolygon.getGeometryN(i); sb.append(buildPolygon(polygon, false, dimensionString)); } sb.append(")"); @@ -266,21 +250,20 @@ private static String buildMultiPolygon(final MultiPolygon multiPolygon, final S private static String buildGeometryCollection(final GeometryCollection geometryCollection, final CoordinateSequenceDimensions dimensions) { StringBuilder sb = new StringBuilder("GEOMETRYCOLLECTION"); + sb.append(CoordinateSequenceDimensions.convertDimensions(dimensions)); - if (!geometryCollection.isEmpty()) { - String dimensionString = CoordinateSequenceDimensions.convertDimensions(dimensions); - sb.append(dimensionString); + if (geometryCollection.getNumGeometries() > 0) { Geometry geometry = geometryCollection.getGeometryN(0); sb.append("("); - sb.append(expand(geometry, dimensions)); + sb.append(expand(geometry, DimensionInfo.find(geometry, dimensions).getDimensions())); int geomCount = geometryCollection.getNumGeometries(); for (int i = 1; i < geomCount; i++) { sb.append(", "); geometry = geometryCollection.getGeometryN(i); - sb.append(expand(geometry, dimensions)); + sb.append(expand(geometry, DimensionInfo.find(geometry, dimensions).getDimensions())); } sb.append(")"); } else { 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 00d45ecf9bb..abcb958a999 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 @@ -94,6 +94,9 @@ public interface Geof { public static final String MAX_Y = GEOF_URI + "maxY"; public static final String MAX_Z = GEOF_URI + "maxZ"; + //GeoSPARQL 1.1 geometry member access: + public static final String GEOMETRY_N = GEOF_URI + "geometryN"; + //upcoming GeoSPARQL 1.1 Datatype transformation functions: public static final String AS_GEOJSON = GEOF_URI + "asGeoJSON"; } diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryNFFTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryNFFTest.java new file mode 100644 index 00000000000..3d70b249178 --- /dev/null +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryNFFTest.java @@ -0,0 +1,283 @@ +/* + * 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.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.apache.jena.geosparql.configuration.GeoSPARQLConfig; +import org.apache.jena.geosparql.implementation.GeometryWrapper; +import org.apache.jena.geosparql.implementation.datatype.GMLDatatype; +import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; +import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions; +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.ExprEvalTypeException; +import org.apache.jena.sparql.expr.NodeValue; +import org.junit.BeforeClass; +import org.junit.Test; + +public class GeometryNFFTest { + @BeforeClass + public static void setup() { + GeoSPARQLConfig.setupNoIndex(); + } + + @Test + public void selectsFirstAndLastMembersUsingOneBasedIndices() { + String wkt = "MULTIPOINT ((1 2), (3 4), (5 6))"; + assertGeometry("POINT (1 2)", select(wkt, "1")); + assertGeometry("POINT (5 6)", select(wkt, "3")); + } + + @Test + public void acceptsIntegerAndIntegerDerivedIndexTypes() { + String wkt = "MULTIPOINT ((1 2), (3 4))"; + assertGeometry("POINT (3 4)", select(wkt, "2")); + assertGeometry("POINT (3 4)", select(wkt, "'2'^^xsd:short")); + } + + @Test + public void nonIntegerNumericIndexesRaiseExpressionErrorsAndLeaveBindUnbound() { + String wkt = "MULTIPOINT ((1 2), (3 4))"; + for (String index : new String[] { "2.0", "2e0", "'2'^^xsd:float" }) { + assertNull(index, select(wkt, index)); + } + GeometryNFF function = new GeometryNFF(); + NodeValue geometry = NodeValue.makeNode(wkt, WKTDatatype.INSTANCE); + for (NodeValue index : new NodeValue[] { + NodeValue.makeDecimal("2.0"), NodeValue.makeDouble(2e0), NodeValue.makeFloat(2f) }) { + assertThrows(index.toString(), ExprEvalTypeException.class, () -> function.exec(geometry, index)); + } + } + + @Test + public void atomicIndexOnePreservesOriginalLiteral() { + for (String wkt : new String[] { "POINT (1 2)", "LINESTRING (0 0, 1 1)", + "POLYGON ((0 0, 1 0, 0 1, 0 0))", "POINT Z EMPTY", "POINT M EMPTY", "POINT ZM EMPTY" }) { + assertEquals(wkt, NodeValue.makeNode(wkt, WKTDatatype.INSTANCE).asNode(), select(wkt, "1")); + } + } + + @Test + public void selectedMemberHasItsOwnTopologicalDimension() { + assertGeometry("LINESTRING (1 2, 3 4)", select( + "GEOMETRYCOLLECTION (POINT (9 9), LINESTRING (1 2, 3 4))", "2")); + } + + @Test + public void selectsMembersOfMultiLinesAndMultiPolygons() { + assertGeometry("LINESTRING (3 4, 5 6)", select("MULTILINESTRING ((0 0, 1 1), (3 4, 5 6))", "2")); + assertGeometry("POLYGON ((0 0, 1 0, 0 1, 0 0))", select("MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)))", "1")); + } + + @Test + public void selectedMembersRetainZAndM() { + for (String marker : new String[] { "Z", "M", "ZM" }) { + String coordinates = marker.equals("ZM") ? "1 2 3 4" : "1 2 3"; + assertGeometry("POINT " + marker + " (" + coordinates + ")", + select("GEOMETRYCOLLECTION (POINT " + marker + " (" + coordinates + "))", "1")); + } + } + + @Test + public void selectedMultiPointMemberRetainsMeasureWithoutInventingZ() { + assertGeometry("POINT M (3 4 9)", select("MULTIPOINT M ((1 2 7), (3 4 9))", "2")); + } + + @Test + public void selectedMultiPointMemberRetainsZAndMeasure() { + assertGeometry("POINT ZM (3 4 9 11)", select("MULTIPOINT ZM ((1 2 7 8), (3 4 9 11))", "2")); + } + + @Test + public void emptySelectedMembersRetainCoordinateMarkers() { + for (String marker : new String[] { "Z", "M", "ZM" }) { + for (String type : new String[] { "POINT", "LINESTRING", "POLYGON", "MULTIPOINT", "MULTILINESTRING", "MULTIPOLYGON", "GEOMETRYCOLLECTION" }) { + String member = type + " " + marker + " EMPTY"; + assertGeometry(member, select("GEOMETRYCOLLECTION (" + member + ", POINT (1 2))", "1")); + } + } + } + + @Test + public void authorityAxisOrderAndZSurviveSerialization() { + String crs = " "; + assertGeometry(crs + "POINT Z (10 100 7)", select(crs + "MULTIPOINT Z ((10 100 7), (20 120 9))", "1")); + } + + @Test + public void gmlResultRetainsSourceDatatypeAndCrs() { + String gml = """ + ' + 10 100 7 + '^^geo:gmlLiteral + """.replace("\n", " "); + Node result = evaluate("geof:geometryN(" + gml + ", 1)"); + assertGeometry(" POINT Z (10 100 7)", result, GMLDatatype.URI); + } + + @Test + public void selectedEmptyGmlCollectionRetainsItsMember() { + String gml = """ + ' + + + + '^^geo:gmlLiteral + """.replace("\n", " "); + Node result = evaluate("geof:geometryN(" + gml + ", 1)"); + assertNotNull(result); + GeometryWrapper selected = GeometryWrapper.extract(result); + assertEquals(1, selected.getParsingGeometry().getNumGeometries()); + assertEquals("Point", selected.getGeometryN(1).getGeometryType()); + assertEquals(3, selected.getGeometryN(1).getCoordinateDimension()); + } + + @Test + public void selectedEmptyGmlMultiGeometriesRetainAllMembers() { + String[][] types = { + { "MultiPoint", "pointMember", "Point", "MultiPoint" }, + { "MultiCurve", "curveMember", "LineString", "MultiLineString" }, + { "MultiSurface", "surfaceMember", "Polygon", "MultiPolygon" } + }; + for (String[] type : types) { + String member = ""; + String gml = """ + ' + %s%s + '^^geo:gmlLiteral + """.formatted(type[0], member, member, type[0]).replace("\n", " "); + GeometryWrapper source = GeometryWrapper.extract(gml.substring(1, gml.indexOf("'^^")), GMLDatatype.URI); + GeometryWrapper expected = source.getGeometryN(1); + assertEquals(type[0], 2, expected.getParsingGeometry().getNumGeometries()); + Node result = evaluate("geof:geometryN(" + gml + ", 1)"); + assertNotNull(type[0], result); + assertEquals("http://www.opengis.net/ont/geosparql#gmlLiteral", result.getLiteralDatatypeURI()); + GeometryWrapper selected = GeometryWrapper.extract(result); + assertEquals(type[3], selected.getGeometryType()); + assertEquals("http://www.opengis.net/def/crs/EPSG/0/4979", selected.getSrsURI()); + assertEquals(type[0], 2, selected.getParsingGeometry().getNumGeometries()); + for (int index = 1; index <= 2; index++) { + GeometryWrapper child = selected.getGeometryN(index); + assertEquals(type[2], child.getGeometryType()); + assertTrue(child.isEmpty()); + assertEquals(type[0], CoordinateSequenceDimensions.XYZ, child.getCoordinateSequenceDimensions()); + } + } + } + + @Test + public void invalidIndicesRaiseExpressionErrorsAndLeaveBindUnbound() { + for (String index : new String[] { "0", "-1", "3", "1.5", "1.00000000000000000001", + "2147483648", "999999999999999999999999999999", "'NaN'^^xsd:double", + "'INF'^^xsd:double", "'-INF'^^xsd:float", "'1'", "true", "" }) { + assertNull(index, select("MULTIPOINT ((1 2), (3 4))", index)); + } + GeometryNFF function = new GeometryNFF(); + NodeValue geometry = NodeValue.makeNode("POINT (1 2)", WKTDatatype.INSTANCE); + for (NodeValue index : new NodeValue[] { NodeValue.makeInteger(0), NodeValue.makeInteger(2) }) { + assertThrows(ExprEvalException.class, () -> function.exec(geometry, index)); + } + for (NodeValue index : new NodeValue[] { NodeValue.makeDecimal("1.5"), NodeValue.makeDouble(Double.NaN), + NodeValue.makeString("1") }) { + assertThrows(ExprEvalTypeException.class, () -> function.exec(geometry, index)); + } + } + + @Test + public void emptyCollectionsHaveNoMemberAtIndexOne() { + for (String wkt : new String[] { "MULTIPOINT EMPTY", "MULTILINESTRING EMPTY", "MULTIPOLYGON EMPTY", "GEOMETRYCOLLECTION EMPTY" }) { + assertNull(wkt, select(wkt, "1")); + } + } + + @Test + public void invalidGeometriesRaiseExpressionErrors() { + GeometryNFF function = new GeometryNFF(); + for (NodeValue value : new NodeValue[] { NodeValue.makeString("POINT (1 2)"), NodeValue.makeInteger(1), + NodeValue.makeNode(NodeFactory.createURI("urn:geometry")), NodeValue.makeNode("invalid", WKTDatatype.INSTANCE) }) { + assertThrows(ExprEvalException.class, () -> function.exec(value, NodeValue.makeInteger(1))); + } + for (String value : new String[] { "'POINT (1 2)'", "42", "", "'invalid'^^geo:wktLiteral", "?missing" }) { + assertNull(evaluate("geof:geometryN(" + value + ", 1)")); + } + assertNull(select("POINT (1 2)", "?missing")); + } + + @Test + public void wrongArityIsRejectedAtQueryBuild() { + for (String arguments : new String[] { "", "'POINT EMPTY'^^geo:wktLiteral", "'POINT EMPTY'^^geo:wktLiteral, 1, 2" }) { + assertThrows(QueryBuildException.class, () -> evaluate("geof:geometryN(" + arguments + ")")); + } + } + + private static Node select(String wkt, String index) { + return evaluate("geof:geometryN('" + wkt + "'^^geo:wktLiteral, " + index + ")"); + } + + private static void assertGeometry(String expectedWkt, Node result) { + assertGeometry(expectedWkt, result, WKTDatatype.URI); + } + + private static void assertGeometry(String expectedWkt, Node result, String datatypeURI) { + assertNotNull(expectedWkt, result); + assertEquals(expectedWkt, datatypeURI, result.getLiteralDatatypeURI()); + GeometryWrapper expected = GeometryWrapper.extract(expectedWkt, WKTDatatype.URI); + GeometryWrapper actual = GeometryWrapper.extract(result); + assertEquals(expected.getSrsURI(), actual.getSrsURI()); + assertEquals(expected.getCoordinateSequenceDimensions(), actual.getCoordinateSequenceDimensions()); + assertEquals(expected.getTopologicalDimension(), actual.getTopologicalDimension()); + assertEquals(expected.getParsingGeometry().toText(), actual.getParsingGeometry().toText()); + // Serialize both through WKT to compare Z/M ordinates across input datatypes. + assertEquals(org.apache.jena.geosparql.implementation.parsers.wkt.WKTWriter.write(expected), + org.apache.jena.geosparql.implementation.parsers.wkt.WKTWriter.write(actual)); + } + + private static Node evaluate(String expression) { + String query = """ + PREFIX geof: + PREFIX geo: + PREFIX xsd: + 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/implementation/GeometryMemberTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryMemberTest.java new file mode 100644 index 00000000000..8903927b46d --- /dev/null +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryMemberTest.java @@ -0,0 +1,311 @@ +/* + * 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 static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +import org.apache.jena.datatypes.DatatypeFormatException; +import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; +import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions; +import org.apache.jena.geosparql.implementation.jts.CustomGeometryFactory; +import org.apache.jena.geosparql.implementation.parsers.wkt.WKTWriter; +import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI; +import org.junit.Test; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.Point; + +public class GeometryMemberTest { + @Test + public void atomicIndexOnePreservesTheAtomicGeometry() { + GeometryWrapper point = geometry("POINT Z EMPTY"); + GeometryWrapper selected = point.getGeometryN(1); + assertEquals(point.getGeometryDatatypeURI(), selected.getGeometryDatatypeURI()); + assertEquals(point.getSrsURI(), selected.getSrsURI()); + assertEquals(point.getCoordinateSequenceDimensions(), selected.getCoordinateSequenceDimensions()); + assertEquals(point.getGeometryType(), selected.getGeometryType()); + assertEquals(WKTWriter.write(point), WKTWriter.write(selected)); + assertThrows(IllegalArgumentException.class, () -> point.getGeometryN(0)); + assertThrows(IllegalArgumentException.class, () -> point.getGeometryN(2)); + } + + @Test + public void nestedCollectionsAreSelectedWithoutFlattening() { + GeometryWrapper nested = collection(CoordinateSequenceDimensions.XY, + geometry("POINT (1 2)").getParsingGeometry(), geometry("POINT (3 4)").getParsingGeometry()); + GeometryWrapper source = collection(CoordinateSequenceDimensions.XY, + geometry("POINT (9 9)").getParsingGeometry(), nested.getParsingGeometry()); + GeometryWrapper selected = source.getGeometryN(2); + assertEquals("GeometryCollection", selected.getGeometryType()); + assertEquals(2, selected.getParsingGeometry().getNumGeometries()); + assertEquals(3, selected.getGeometryN(2).getXYGeometry().getCoordinate().getX(), 0); + GeometryWrapper reparsed = GeometryWrapper.extract(selected.asNodeValue()); + assertEquals(2, reparsed.getParsingGeometry().getNumGeometries()); + assertEquals(3, reparsed.getGeometryN(2).getXYGeometry().getCoordinate().getX(), 0); + } + + @Test + public void allEmptyCollectionMembersSurviveSerialization() { + GeometryWrapper nested = collection(CoordinateSequenceDimensions.XYZ, + geometry("POINT Z EMPTY").getParsingGeometry()); + GeometryWrapper source = collection(CoordinateSequenceDimensions.XYZ, nested.getParsingGeometry()); + GeometryWrapper selected = GeometryWrapper.extract(source.getGeometryN(1).asNodeValue()); + assertEquals(1, selected.getParsingGeometry().getNumGeometries()); + assertEquals(CoordinateSequenceDimensions.XYZ, selected.getGeometryN(1).getCoordinateSequenceDimensions()); + } + + @Test + public void selectedMixedCollectionRetainsEachMemberLayout() { + GeometryWrapper nested = collection(CoordinateSequenceDimensions.XY, + geometry("POINT Z (1 2 3)").getParsingGeometry(), geometry("POINT M (4 5 6)").getParsingGeometry()); + GeometryWrapper source = collection(CoordinateSequenceDimensions.XY, nested.getParsingGeometry()); + GeometryWrapper selected = GeometryWrapper.extract(source.getGeometryN(1).asNodeValue()); + assertEquals(CoordinateSequenceDimensions.XYZ, selected.getGeometryN(1).getCoordinateSequenceDimensions()); + assertEquals(CoordinateSequenceDimensions.XYM, selected.getGeometryN(2).getCoordinateSequenceDimensions()); + } + + @Test + public void memberMetadataComesFromTheSelectedCoordinateSequence() { + GeometryWrapper source = geometry("GEOMETRYCOLLECTION (POINT Z (1 2 3), LINESTRING M (1 2 7, 3 4 9))"); + GeometryWrapper point = source.getGeometryN(1); + GeometryWrapper line = source.getGeometryN(2); + assertEquals(CoordinateSequenceDimensions.XYZ, point.getCoordinateSequenceDimensions()); + assertEquals(CoordinateSequenceDimensions.XYM, line.getCoordinateSequenceDimensions()); + assertEquals(0, point.getTopologicalDimension()); + assertEquals(1, line.getTopologicalDimension()); + } + + @Test + public void emptyAggregateMembersRetainExplicitLayoutWhenParentIsXY() { + String[] members = { + "MULTIPOINT Z EMPTY", + "MULTILINESTRING M EMPTY", + "MULTIPOLYGON ZM EMPTY", + "GEOMETRYCOLLECTION Z EMPTY" + }; + for (String member : members) { + GeometryWrapper selected = geometry("GEOMETRYCOLLECTION (" + member + ", POINT (1 2))").getGeometryN(1); + GeometryWrapper expected = geometry(member); + assertEquals(member, expected.getCoordinateSequenceDimensions(), selected.getCoordinateSequenceDimensions()); + assertEquals(member, expected.getGeometryType(), selected.getGeometryType()); + assertEquals(member, WKTWriter.write(expected), WKTWriter.write(selected)); + } + } + + @Test + public void selectedAuthorityAxisPointRetainsZInBothCoordinateOrders() { + GeometryWrapper selected = geometry( + " MULTIPOINT Z ((10 100 7), (20 120 9))") + .getGeometryN(2); + Point parsing = (Point)selected.getParsingGeometry(); + Point normalized = (Point)selected.getXYGeometry(); + assertEquals(CoordinateSequenceDimensions.XYZ, selected.getCoordinateSequenceDimensions()); + assertEquals(20, parsing.getX(), 0); + assertEquals(120, parsing.getY(), 0); + assertEquals(9, parsing.getCoordinateSequence().getZ(0), 0); + assertEquals(120, normalized.getX(), 0); + assertEquals(20, normalized.getY(), 0); + assertEquals(9, normalized.getCoordinateSequence().getZ(0), 0); + } + + @Test + public void emptyCollectionsRejectEveryIndex() { + GeometryWrapper empty = geometry("GEOMETRYCOLLECTION EMPTY"); + assertThrows(IllegalArgumentException.class, () -> empty.getGeometryN(1)); + assertThrows(IllegalArgumentException.class, () -> empty.getGeometryN(-1)); + assertThrows(IllegalArgumentException.class, () -> empty.getGeometryN(Integer.MAX_VALUE)); + } + + @Test + public void emptyMultiMembersSurviveSerialization() { + var factory = CustomGeometryFactory.theInstance(); + for (String marker : new String[] { "", "Z", "M", "ZM" }) { + CoordinateSequenceDimensions layout = layout(marker); + Point point = (Point)geometry("POINT " + marker + " EMPTY").getParsingGeometry(); + LineString line = (LineString)geometry("LINESTRING " + marker + " EMPTY").getParsingGeometry(); + Polygon polygon = (Polygon)geometry("POLYGON " + marker + " EMPTY").getParsingGeometry(); + Geometry[] multis = { + factory.createMultiPoint(new Point[] { point, point }), + factory.createMultiLineString(new LineString[] { line, line }), + factory.createMultiPolygon(new Polygon[] { polygon, polygon }) + }; + for (Geometry multi : multis) { + GeometryWrapper selected = collection(layout, multi).getGeometryN(1); + assertEmptyMultiRoundTrip(multi.getGeometryType() + " " + marker, selected, layout, true, true); + } + } + } + + @Test + public void mixedEmptyMultiMembersSurviveSerialization() { + var factory = CustomGeometryFactory.theInstance(); + Geometry[] multis = { + factory.createMultiPoint(new Point[] { + (Point)geometry("POINT (1 2)").getParsingGeometry(), + (Point)geometry("POINT EMPTY").getParsingGeometry() }), + factory.createMultiLineString(new LineString[] { + (LineString)geometry("LINESTRING (0 0, 1 1)").getParsingGeometry(), + (LineString)geometry("LINESTRING EMPTY").getParsingGeometry() }), + factory.createMultiPolygon(new Polygon[] { + (Polygon)geometry("POLYGON ((0 0, 1 0, 0 1, 0 0))").getParsingGeometry(), + (Polygon)geometry("POLYGON EMPTY").getParsingGeometry() }) + }; + for (Geometry multi : multis) { + GeometryWrapper selected = collection(CoordinateSequenceDimensions.XY, multi).getGeometryN(1); + assertFalse(multi.getGeometryType(), selected.isEmpty()); + assertEmptyMultiRoundTrip(multi.getGeometryType(), selected, CoordinateSequenceDimensions.XY, false, true); + } + } + + @Test + public void emptyMultiMemberWktRoundTripsMemberCountOrderAndLayout() { + String[] wkts = { + "MULTIPOINT (EMPTY, EMPTY)", + "MULTIPOINT ((1 2), EMPTY)", + "MULTILINESTRING (EMPTY, EMPTY)", + "MULTILINESTRING ((0 0, 1 1), EMPTY)", + "MULTIPOLYGON (EMPTY, EMPTY)", + "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)), EMPTY)" + }; + boolean[][] emptiness = { + { true, true }, + { false, true }, + { true, true }, + { false, true }, + { true, true }, + { false, true } + }; + for (int i = 0; i < wkts.length; i++) { + GeometryWrapper original = geometry(wkts[i]); + assertEmptyMultiRoundTrip(wkts[i], original, CoordinateSequenceDimensions.XY, + emptiness[i][0], emptiness[i][1]); + } + } + + @Test + public void blankMultiMembersAreRejected() { + for (String wkt : new String[] { + "MULTIPOINT ((1 2),, (3 4))", + "MULTIPOINT (, (1 2))", + "MULTIPOINT ((1 2),)", + "MULTILINESTRING ((0 0, 1 1),, (2 2, 3 3))", + "MULTILINESTRING (, (0 0, 1 1))", + "MULTILINESTRING ((0 0, 1 1),)", + "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)),, ((2 2, 3 2, 2 3, 2 2)))", + "MULTIPOLYGON (, ((0 0, 1 0, 0 1, 0 0)))", + "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)),)" }) { + assertThrows(wkt, DatatypeFormatException.class, () -> geometry(wkt)); + } + } + + @Test + public void unbalancedMultiParenthesesAreRejected() { + for (String wkt : new String[] { + "MULTIPOINT ((1 2)", + "MULTIPOINT ((1 2)))", + "MULTILINESTRING ((0 0, 1 1)", + "MULTILINESTRING ((0 0, 1 1)))", + "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0))", + "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0))))" }) { + assertThrows(wkt, DatatypeFormatException.class, () -> geometry(wkt)); + } + } + + @Test + public void nestedMultiGeometriesInCollectionsRoundTrip() { + String[][] cases = { + { "GEOMETRYCOLLECTION (MULTIPOINT ((1 2), (3 4)), POINT (5 6))", "MultiPoint", "false", "false" }, + { "GEOMETRYCOLLECTION (MULTIPOINT (EMPTY, EMPTY), POINT (5 6))", "MultiPoint", "true", "true" }, + { "GEOMETRYCOLLECTION (MULTIPOINT ((1 2), EMPTY), POINT (5 6))", "MultiPoint", "false", "true" }, + { "GEOMETRYCOLLECTION (MULTILINESTRING ((0 0, 1 1), (2 2, 3 3)), POINT (5 6))", "MultiLineString", "false", "false" }, + { "GEOMETRYCOLLECTION (MULTILINESTRING (EMPTY, EMPTY), POINT (5 6))", "MultiLineString", "true", "true" }, + { "GEOMETRYCOLLECTION (MULTILINESTRING ((0 0, 1 1), EMPTY), POINT (5 6))", "MultiLineString", "false", "true" }, + { "GEOMETRYCOLLECTION (MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)), ((2 2, 3 2, 2 3, 2 2))), POINT (5 6))", "MultiPolygon", "false", "false" }, + { "GEOMETRYCOLLECTION (MULTIPOLYGON (EMPTY, EMPTY), POINT (5 6))", "MultiPolygon", "true", "true" }, + { "GEOMETRYCOLLECTION (MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)), EMPTY), POINT (5 6))", "MultiPolygon", "false", "true" } + }; + for (String[] test : cases) { + GeometryWrapper original = geometry(test[0]); + assertEquals(test[0], 2, original.getParsingGeometry().getNumGeometries()); + assertEquals(test[0], test[1], original.getGeometryN(1).getGeometryType()); + assertEquals(test[0], "Point", original.getGeometryN(2).getGeometryType()); + assertEmptyMultiRoundTrip(test[0], original.getGeometryN(1), CoordinateSequenceDimensions.XY, + Boolean.parseBoolean(test[2]), Boolean.parseBoolean(test[3])); + GeometryWrapper reparsed = reparse(original); + assertEquals(test[0], 2, reparsed.getParsingGeometry().getNumGeometries()); + assertEquals(test[0], test[1], reparsed.getGeometryN(1).getGeometryType()); + assertEquals(test[0], 5, reparsed.getGeometryN(2).getXYGeometry().getCoordinate().getX(), 0); + assertEquals(test[0], 6, reparsed.getGeometryN(2).getXYGeometry().getCoordinate().getY(), 0); + assertEmptyMultiRoundTrip(test[0] + " reparsed", reparsed.getGeometryN(1), + CoordinateSequenceDimensions.XY, Boolean.parseBoolean(test[2]), Boolean.parseBoolean(test[3])); + } + } + + private static void assertEmptyMultiRoundTrip(String label, GeometryWrapper original, + CoordinateSequenceDimensions layout, boolean firstEmpty, boolean secondEmpty) { + assertEquals(label, 2, original.getParsingGeometry().getNumGeometries()); + GeometryWrapper reparsed = reparse(original); + assertEquals(label, 2, reparsed.getParsingGeometry().getNumGeometries()); + assertEquals(label, layout, reparsed.getCoordinateSequenceDimensions()); + for (int index = 1; index <= 2; index++) { + GeometryWrapper expectedMember = original.getGeometryN(index); + GeometryWrapper actualMember = reparsed.getGeometryN(index); + boolean expectEmpty = index == 1 ? firstEmpty : secondEmpty; + assertEquals(label + " type " + index, expectedMember.getGeometryType(), actualMember.getGeometryType()); + assertEquals(label + " empty " + index, expectEmpty, actualMember.isEmpty()); + assertEquals(label + " layout " + index, layout, actualMember.getCoordinateSequenceDimensions()); + if (!expectEmpty) { + assertEquals(label + " member " + index, expectedMember.getParsingGeometry().toText(), + actualMember.getParsingGeometry().toText()); + } + } + } + + private static CoordinateSequenceDimensions layout(String marker) { + switch (marker) { + case "Z": + return CoordinateSequenceDimensions.XYZ; + case "M": + return CoordinateSequenceDimensions.XYM; + case "ZM": + return CoordinateSequenceDimensions.XYZM; + default: + return CoordinateSequenceDimensions.XY; + } + } + + private static GeometryWrapper reparse(GeometryWrapper original) { + return GeometryWrapper.extract(WKTWriter.write(original), WKTDatatype.URI); + } + + private static GeometryWrapper geometry(String wkt) { + return GeometryWrapper.extract(wkt, WKTDatatype.URI); + } + + private static GeometryWrapper collection(CoordinateSequenceDimensions dimensions, Geometry... members) { + Geometry collection = CustomGeometryFactory.theInstance().createGeometryCollection(members); + return new GeometryWrapper(collection, SRS_URI.DEFAULT_WKT_CRS84, WKTDatatype.URI, + new DimensionInfo(dimensions, collection.getDimension())); + } +} From 92a34e20a4fc4a415a6296898c0c2f81722d43fd Mon Sep 17 00:00:00 2001 From: Edmond Chuc Date: Sat, 26 Sep 2026 11:25:06 +1000 Subject: [PATCH 2/2] GH-4230: Move geometry member tests into the relevant test classes Put WKT and GML regression tests in their reader and writer test classes, and move dimension and geometry selection tests into their existing test classes. Preserve the coordinate layout of all-empty GML Surface patches. --- .../implementation/parsers/gml/GMLReader.java | 6 + .../implementation/DimensionInfoTest.java | 57 ++++ .../implementation/GeometryMemberTest.java | 311 ------------------ .../implementation/GeometryWrapperTest.java | 87 +++++ .../parsers/gml/GMLReaderTest.java | 18 + .../parsers/gml/GMLWriterTest.java | 54 +++ .../parsers/wkt/WKTReaderTest.java | 84 +++++ .../parsers/wkt/WKTWriterTest.java | 100 ++++++ 8 files changed, 406 insertions(+), 311 deletions(-) delete mode 100644 jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryMemberTest.java 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 e47909359ae..085f32a5804 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 @@ -559,6 +559,12 @@ private static Polygon buildSurface(Element gmlElement, CoordinateSequenceDimens polys.add(polygon); } + // The union of empty patches is a GeometryCollection in JTS. + // Return an empty patch directly so its coordinate layout is retained. + if (polys.stream().allMatch(Polygon::isEmpty)) { + return polys.get(0); + } + //Unionise all the polygons on the surface together. Geometry unionGeom = CascadedPolygonUnion.union(polys); Polygon unionPolygon; diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/DimensionInfoTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/DimensionInfoTest.java index a169a359218..8abc3dd4921 100644 --- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/DimensionInfoTest.java +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/DimensionInfoTest.java @@ -22,6 +22,10 @@ import java.util.Arrays; import java.util.List; +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.wkt.WKTReader; import org.junit.After; import org.junit.AfterClass; import static org.junit.Assert.*; @@ -102,4 +106,57 @@ public void testFindCollection() { assertEquals(expResult, result); } + @Test + public void testFindGeometryPreservesEmptyCoordinateSequenceLayout() { + GeometryFactory factory = CustomGeometryFactory.theInstance(); + Geometry[] geometries = { + factory.createPoint(new CustomCoordinateSequence(CoordinateSequenceDimensions.XYZ)), + factory.createLineString(new CustomCoordinateSequence(CoordinateSequenceDimensions.XYM)), + factory.createPolygon(new CustomCoordinateSequence(CoordinateSequenceDimensions.XYZM)) + }; + CoordinateSequenceDimensions[] expected = { + CoordinateSequenceDimensions.XYZ, + CoordinateSequenceDimensions.XYM, + CoordinateSequenceDimensions.XYZM + }; + + for (int i = 0; i < geometries.length; i++) { + assertEquals(geometries[i].getGeometryType(), expected[i], + DimensionInfo.find(geometries[i], CoordinateSequenceDimensions.XY).getDimensions()); + } + } + + @Test + public void testFindGeometryUsesLayoutRetainedOnEmptyAggregate() { + String[] wkts = { + "MULTIPOINT Z EMPTY", "MULTILINESTRING M EMPTY", + "MULTIPOLYGON ZM EMPTY", "GEOMETRYCOLLECTION Z EMPTY" + }; + CoordinateSequenceDimensions[] expected = { + CoordinateSequenceDimensions.XYZ, + CoordinateSequenceDimensions.XYM, + CoordinateSequenceDimensions.XYZM, + CoordinateSequenceDimensions.XYZ + }; + + for (int i = 0; i < wkts.length; i++) { + Geometry geometry = WKTReader.extract(wkts[i]).getGeometry(); + assertEquals(wkts[i], expected[i], + DimensionInfo.find(geometry, CoordinateSequenceDimensions.XY).getDimensions()); + } + } + + @Test + public void testFindGeometryUsesFirstCollectionMemberLayout() { + GeometryFactory factory = CustomGeometryFactory.theInstance(); + Point measured = factory.createPoint(new CustomCoordinateSequence(CoordinateSequenceDimensions.XYM, "1 2 3")); + Point elevated = factory.createPoint(new CustomCoordinateSequence(CoordinateSequenceDimensions.XYZ, "4 5 6")); + Geometry collection = factory.createGeometryCollection(new Geometry[] { measured, elevated }); + + assertEquals(CoordinateSequenceDimensions.XYM, + DimensionInfo.find(collection, CoordinateSequenceDimensions.XY).getDimensions()); + assertEquals(CoordinateSequenceDimensions.XYZ, + DimensionInfo.find(elevated, CoordinateSequenceDimensions.XY).getDimensions()); + } + } diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryMemberTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryMemberTest.java deleted file mode 100644 index 8903927b46d..00000000000 --- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryMemberTest.java +++ /dev/null @@ -1,311 +0,0 @@ -/* - * 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 static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertThrows; - -import org.apache.jena.datatypes.DatatypeFormatException; -import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; -import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions; -import org.apache.jena.geosparql.implementation.jts.CustomGeometryFactory; -import org.apache.jena.geosparql.implementation.parsers.wkt.WKTWriter; -import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI; -import org.junit.Test; -import org.locationtech.jts.geom.Geometry; -import org.locationtech.jts.geom.LineString; -import org.locationtech.jts.geom.Polygon; -import org.locationtech.jts.geom.Point; - -public class GeometryMemberTest { - @Test - public void atomicIndexOnePreservesTheAtomicGeometry() { - GeometryWrapper point = geometry("POINT Z EMPTY"); - GeometryWrapper selected = point.getGeometryN(1); - assertEquals(point.getGeometryDatatypeURI(), selected.getGeometryDatatypeURI()); - assertEquals(point.getSrsURI(), selected.getSrsURI()); - assertEquals(point.getCoordinateSequenceDimensions(), selected.getCoordinateSequenceDimensions()); - assertEquals(point.getGeometryType(), selected.getGeometryType()); - assertEquals(WKTWriter.write(point), WKTWriter.write(selected)); - assertThrows(IllegalArgumentException.class, () -> point.getGeometryN(0)); - assertThrows(IllegalArgumentException.class, () -> point.getGeometryN(2)); - } - - @Test - public void nestedCollectionsAreSelectedWithoutFlattening() { - GeometryWrapper nested = collection(CoordinateSequenceDimensions.XY, - geometry("POINT (1 2)").getParsingGeometry(), geometry("POINT (3 4)").getParsingGeometry()); - GeometryWrapper source = collection(CoordinateSequenceDimensions.XY, - geometry("POINT (9 9)").getParsingGeometry(), nested.getParsingGeometry()); - GeometryWrapper selected = source.getGeometryN(2); - assertEquals("GeometryCollection", selected.getGeometryType()); - assertEquals(2, selected.getParsingGeometry().getNumGeometries()); - assertEquals(3, selected.getGeometryN(2).getXYGeometry().getCoordinate().getX(), 0); - GeometryWrapper reparsed = GeometryWrapper.extract(selected.asNodeValue()); - assertEquals(2, reparsed.getParsingGeometry().getNumGeometries()); - assertEquals(3, reparsed.getGeometryN(2).getXYGeometry().getCoordinate().getX(), 0); - } - - @Test - public void allEmptyCollectionMembersSurviveSerialization() { - GeometryWrapper nested = collection(CoordinateSequenceDimensions.XYZ, - geometry("POINT Z EMPTY").getParsingGeometry()); - GeometryWrapper source = collection(CoordinateSequenceDimensions.XYZ, nested.getParsingGeometry()); - GeometryWrapper selected = GeometryWrapper.extract(source.getGeometryN(1).asNodeValue()); - assertEquals(1, selected.getParsingGeometry().getNumGeometries()); - assertEquals(CoordinateSequenceDimensions.XYZ, selected.getGeometryN(1).getCoordinateSequenceDimensions()); - } - - @Test - public void selectedMixedCollectionRetainsEachMemberLayout() { - GeometryWrapper nested = collection(CoordinateSequenceDimensions.XY, - geometry("POINT Z (1 2 3)").getParsingGeometry(), geometry("POINT M (4 5 6)").getParsingGeometry()); - GeometryWrapper source = collection(CoordinateSequenceDimensions.XY, nested.getParsingGeometry()); - GeometryWrapper selected = GeometryWrapper.extract(source.getGeometryN(1).asNodeValue()); - assertEquals(CoordinateSequenceDimensions.XYZ, selected.getGeometryN(1).getCoordinateSequenceDimensions()); - assertEquals(CoordinateSequenceDimensions.XYM, selected.getGeometryN(2).getCoordinateSequenceDimensions()); - } - - @Test - public void memberMetadataComesFromTheSelectedCoordinateSequence() { - GeometryWrapper source = geometry("GEOMETRYCOLLECTION (POINT Z (1 2 3), LINESTRING M (1 2 7, 3 4 9))"); - GeometryWrapper point = source.getGeometryN(1); - GeometryWrapper line = source.getGeometryN(2); - assertEquals(CoordinateSequenceDimensions.XYZ, point.getCoordinateSequenceDimensions()); - assertEquals(CoordinateSequenceDimensions.XYM, line.getCoordinateSequenceDimensions()); - assertEquals(0, point.getTopologicalDimension()); - assertEquals(1, line.getTopologicalDimension()); - } - - @Test - public void emptyAggregateMembersRetainExplicitLayoutWhenParentIsXY() { - String[] members = { - "MULTIPOINT Z EMPTY", - "MULTILINESTRING M EMPTY", - "MULTIPOLYGON ZM EMPTY", - "GEOMETRYCOLLECTION Z EMPTY" - }; - for (String member : members) { - GeometryWrapper selected = geometry("GEOMETRYCOLLECTION (" + member + ", POINT (1 2))").getGeometryN(1); - GeometryWrapper expected = geometry(member); - assertEquals(member, expected.getCoordinateSequenceDimensions(), selected.getCoordinateSequenceDimensions()); - assertEquals(member, expected.getGeometryType(), selected.getGeometryType()); - assertEquals(member, WKTWriter.write(expected), WKTWriter.write(selected)); - } - } - - @Test - public void selectedAuthorityAxisPointRetainsZInBothCoordinateOrders() { - GeometryWrapper selected = geometry( - " MULTIPOINT Z ((10 100 7), (20 120 9))") - .getGeometryN(2); - Point parsing = (Point)selected.getParsingGeometry(); - Point normalized = (Point)selected.getXYGeometry(); - assertEquals(CoordinateSequenceDimensions.XYZ, selected.getCoordinateSequenceDimensions()); - assertEquals(20, parsing.getX(), 0); - assertEquals(120, parsing.getY(), 0); - assertEquals(9, parsing.getCoordinateSequence().getZ(0), 0); - assertEquals(120, normalized.getX(), 0); - assertEquals(20, normalized.getY(), 0); - assertEquals(9, normalized.getCoordinateSequence().getZ(0), 0); - } - - @Test - public void emptyCollectionsRejectEveryIndex() { - GeometryWrapper empty = geometry("GEOMETRYCOLLECTION EMPTY"); - assertThrows(IllegalArgumentException.class, () -> empty.getGeometryN(1)); - assertThrows(IllegalArgumentException.class, () -> empty.getGeometryN(-1)); - assertThrows(IllegalArgumentException.class, () -> empty.getGeometryN(Integer.MAX_VALUE)); - } - - @Test - public void emptyMultiMembersSurviveSerialization() { - var factory = CustomGeometryFactory.theInstance(); - for (String marker : new String[] { "", "Z", "M", "ZM" }) { - CoordinateSequenceDimensions layout = layout(marker); - Point point = (Point)geometry("POINT " + marker + " EMPTY").getParsingGeometry(); - LineString line = (LineString)geometry("LINESTRING " + marker + " EMPTY").getParsingGeometry(); - Polygon polygon = (Polygon)geometry("POLYGON " + marker + " EMPTY").getParsingGeometry(); - Geometry[] multis = { - factory.createMultiPoint(new Point[] { point, point }), - factory.createMultiLineString(new LineString[] { line, line }), - factory.createMultiPolygon(new Polygon[] { polygon, polygon }) - }; - for (Geometry multi : multis) { - GeometryWrapper selected = collection(layout, multi).getGeometryN(1); - assertEmptyMultiRoundTrip(multi.getGeometryType() + " " + marker, selected, layout, true, true); - } - } - } - - @Test - public void mixedEmptyMultiMembersSurviveSerialization() { - var factory = CustomGeometryFactory.theInstance(); - Geometry[] multis = { - factory.createMultiPoint(new Point[] { - (Point)geometry("POINT (1 2)").getParsingGeometry(), - (Point)geometry("POINT EMPTY").getParsingGeometry() }), - factory.createMultiLineString(new LineString[] { - (LineString)geometry("LINESTRING (0 0, 1 1)").getParsingGeometry(), - (LineString)geometry("LINESTRING EMPTY").getParsingGeometry() }), - factory.createMultiPolygon(new Polygon[] { - (Polygon)geometry("POLYGON ((0 0, 1 0, 0 1, 0 0))").getParsingGeometry(), - (Polygon)geometry("POLYGON EMPTY").getParsingGeometry() }) - }; - for (Geometry multi : multis) { - GeometryWrapper selected = collection(CoordinateSequenceDimensions.XY, multi).getGeometryN(1); - assertFalse(multi.getGeometryType(), selected.isEmpty()); - assertEmptyMultiRoundTrip(multi.getGeometryType(), selected, CoordinateSequenceDimensions.XY, false, true); - } - } - - @Test - public void emptyMultiMemberWktRoundTripsMemberCountOrderAndLayout() { - String[] wkts = { - "MULTIPOINT (EMPTY, EMPTY)", - "MULTIPOINT ((1 2), EMPTY)", - "MULTILINESTRING (EMPTY, EMPTY)", - "MULTILINESTRING ((0 0, 1 1), EMPTY)", - "MULTIPOLYGON (EMPTY, EMPTY)", - "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)), EMPTY)" - }; - boolean[][] emptiness = { - { true, true }, - { false, true }, - { true, true }, - { false, true }, - { true, true }, - { false, true } - }; - for (int i = 0; i < wkts.length; i++) { - GeometryWrapper original = geometry(wkts[i]); - assertEmptyMultiRoundTrip(wkts[i], original, CoordinateSequenceDimensions.XY, - emptiness[i][0], emptiness[i][1]); - } - } - - @Test - public void blankMultiMembersAreRejected() { - for (String wkt : new String[] { - "MULTIPOINT ((1 2),, (3 4))", - "MULTIPOINT (, (1 2))", - "MULTIPOINT ((1 2),)", - "MULTILINESTRING ((0 0, 1 1),, (2 2, 3 3))", - "MULTILINESTRING (, (0 0, 1 1))", - "MULTILINESTRING ((0 0, 1 1),)", - "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)),, ((2 2, 3 2, 2 3, 2 2)))", - "MULTIPOLYGON (, ((0 0, 1 0, 0 1, 0 0)))", - "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)),)" }) { - assertThrows(wkt, DatatypeFormatException.class, () -> geometry(wkt)); - } - } - - @Test - public void unbalancedMultiParenthesesAreRejected() { - for (String wkt : new String[] { - "MULTIPOINT ((1 2)", - "MULTIPOINT ((1 2)))", - "MULTILINESTRING ((0 0, 1 1)", - "MULTILINESTRING ((0 0, 1 1)))", - "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0))", - "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0))))" }) { - assertThrows(wkt, DatatypeFormatException.class, () -> geometry(wkt)); - } - } - - @Test - public void nestedMultiGeometriesInCollectionsRoundTrip() { - String[][] cases = { - { "GEOMETRYCOLLECTION (MULTIPOINT ((1 2), (3 4)), POINT (5 6))", "MultiPoint", "false", "false" }, - { "GEOMETRYCOLLECTION (MULTIPOINT (EMPTY, EMPTY), POINT (5 6))", "MultiPoint", "true", "true" }, - { "GEOMETRYCOLLECTION (MULTIPOINT ((1 2), EMPTY), POINT (5 6))", "MultiPoint", "false", "true" }, - { "GEOMETRYCOLLECTION (MULTILINESTRING ((0 0, 1 1), (2 2, 3 3)), POINT (5 6))", "MultiLineString", "false", "false" }, - { "GEOMETRYCOLLECTION (MULTILINESTRING (EMPTY, EMPTY), POINT (5 6))", "MultiLineString", "true", "true" }, - { "GEOMETRYCOLLECTION (MULTILINESTRING ((0 0, 1 1), EMPTY), POINT (5 6))", "MultiLineString", "false", "true" }, - { "GEOMETRYCOLLECTION (MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)), ((2 2, 3 2, 2 3, 2 2))), POINT (5 6))", "MultiPolygon", "false", "false" }, - { "GEOMETRYCOLLECTION (MULTIPOLYGON (EMPTY, EMPTY), POINT (5 6))", "MultiPolygon", "true", "true" }, - { "GEOMETRYCOLLECTION (MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)), EMPTY), POINT (5 6))", "MultiPolygon", "false", "true" } - }; - for (String[] test : cases) { - GeometryWrapper original = geometry(test[0]); - assertEquals(test[0], 2, original.getParsingGeometry().getNumGeometries()); - assertEquals(test[0], test[1], original.getGeometryN(1).getGeometryType()); - assertEquals(test[0], "Point", original.getGeometryN(2).getGeometryType()); - assertEmptyMultiRoundTrip(test[0], original.getGeometryN(1), CoordinateSequenceDimensions.XY, - Boolean.parseBoolean(test[2]), Boolean.parseBoolean(test[3])); - GeometryWrapper reparsed = reparse(original); - assertEquals(test[0], 2, reparsed.getParsingGeometry().getNumGeometries()); - assertEquals(test[0], test[1], reparsed.getGeometryN(1).getGeometryType()); - assertEquals(test[0], 5, reparsed.getGeometryN(2).getXYGeometry().getCoordinate().getX(), 0); - assertEquals(test[0], 6, reparsed.getGeometryN(2).getXYGeometry().getCoordinate().getY(), 0); - assertEmptyMultiRoundTrip(test[0] + " reparsed", reparsed.getGeometryN(1), - CoordinateSequenceDimensions.XY, Boolean.parseBoolean(test[2]), Boolean.parseBoolean(test[3])); - } - } - - private static void assertEmptyMultiRoundTrip(String label, GeometryWrapper original, - CoordinateSequenceDimensions layout, boolean firstEmpty, boolean secondEmpty) { - assertEquals(label, 2, original.getParsingGeometry().getNumGeometries()); - GeometryWrapper reparsed = reparse(original); - assertEquals(label, 2, reparsed.getParsingGeometry().getNumGeometries()); - assertEquals(label, layout, reparsed.getCoordinateSequenceDimensions()); - for (int index = 1; index <= 2; index++) { - GeometryWrapper expectedMember = original.getGeometryN(index); - GeometryWrapper actualMember = reparsed.getGeometryN(index); - boolean expectEmpty = index == 1 ? firstEmpty : secondEmpty; - assertEquals(label + " type " + index, expectedMember.getGeometryType(), actualMember.getGeometryType()); - assertEquals(label + " empty " + index, expectEmpty, actualMember.isEmpty()); - assertEquals(label + " layout " + index, layout, actualMember.getCoordinateSequenceDimensions()); - if (!expectEmpty) { - assertEquals(label + " member " + index, expectedMember.getParsingGeometry().toText(), - actualMember.getParsingGeometry().toText()); - } - } - } - - private static CoordinateSequenceDimensions layout(String marker) { - switch (marker) { - case "Z": - return CoordinateSequenceDimensions.XYZ; - case "M": - return CoordinateSequenceDimensions.XYM; - case "ZM": - return CoordinateSequenceDimensions.XYZM; - default: - return CoordinateSequenceDimensions.XY; - } - } - - private static GeometryWrapper reparse(GeometryWrapper original) { - return GeometryWrapper.extract(WKTWriter.write(original), WKTDatatype.URI); - } - - private static GeometryWrapper geometry(String wkt) { - return GeometryWrapper.extract(wkt, WKTDatatype.URI); - } - - private static GeometryWrapper collection(CoordinateSequenceDimensions dimensions, Geometry... members) { - Geometry collection = CustomGeometryFactory.theInstance().createGeometryCollection(members); - return new GeometryWrapper(collection, SRS_URI.DEFAULT_WKT_CRS84, WKTDatatype.URI, - new DimensionInfo(dimensions, collection.getDimension())); - } -} diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryWrapperTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryWrapperTest.java index 79d46b36030..066ba388022 100644 --- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryWrapperTest.java +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryWrapperTest.java @@ -22,6 +22,7 @@ import org.apache.jena.geosparql.implementation.datatype.GMLDatatype; import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; +import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions; import org.apache.jena.geosparql.implementation.jts.CustomGeometryFactory; import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI; import org.apache.jena.geosparql.implementation.vocabulary.Unit_URI; @@ -32,12 +33,15 @@ import org.junit.After; import org.junit.AfterClass; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; import org.opengis.geometry.MismatchedDimensionException; import org.opengis.referencing.crs.CoordinateReferenceSystem; import org.opengis.referencing.operation.TransformException; @@ -526,5 +530,88 @@ public void testGetUTMZoneURI_crs84() throws FactoryException, MismatchedDimensi String result = instance.getUTMZoneURI(); assertEquals(expResult, result); } + + @Test + public void testGetGeometryNOnAtomicGeometry() { + GeometryWrapper point = GeometryWrapper.extract("POINT Z EMPTY", WKTDatatype.URI); + GeometryWrapper selected = point.getGeometryN(1); + + assertEquals(point.getGeometryDatatypeURI(), selected.getGeometryDatatypeURI()); + assertEquals(point.getSrsURI(), selected.getSrsURI()); + assertEquals(point.getCoordinateSequenceDimensions(), selected.getCoordinateSequenceDimensions()); + assertEquals("Point", selected.getGeometryType()); + assertTrue(selected.isEmpty()); + assertThrows(IllegalArgumentException.class, () -> point.getGeometryN(0)); + assertThrows(IllegalArgumentException.class, () -> point.getGeometryN(2)); + } + + @Test + public void testGetGeometryNSelectsDirectNestedMember() { + GeometryWrapper source = GeometryWrapper.extract( + "GEOMETRYCOLLECTION (POINT (9 9), GEOMETRYCOLLECTION (POINT (1 2), POINT (3 4)))", + WKTDatatype.URI); + GeometryWrapper selected = source.getGeometryN(2); + + assertEquals("GeometryCollection", selected.getGeometryType()); + assertEquals(2, selected.getParsingGeometry().getNumGeometries()); + assertEquals(3.0, selected.getGeometryN(2).getParsingGeometry().getCoordinate().getX(), 0.0); + assertThrows(IllegalArgumentException.class, () -> source.getGeometryN(3)); + } + + @Test + public void testGetGeometryNUsesSelectedMemberLayoutAndTopology() { + GeometryWrapper source = GeometryWrapper.extract( + "GEOMETRYCOLLECTION (POINT Z (1 2 3), LINESTRING M (1 2 7, 3 4 9))", + WKTDatatype.URI); + GeometryWrapper point = source.getGeometryN(1); + GeometryWrapper line = source.getGeometryN(2); + + assertEquals(CoordinateSequenceDimensions.XYZ, point.getCoordinateSequenceDimensions()); + assertEquals(CoordinateSequenceDimensions.XYM, line.getCoordinateSequenceDimensions()); + assertEquals(0, point.getTopologicalDimension()); + assertEquals(1, line.getTopologicalDimension()); + assertEquals(source.getGeometryDatatypeURI(), line.getGeometryDatatypeURI()); + assertEquals(source.getSrsURI(), line.getSrsURI()); + } + + @Test + public void testGetGeometryNRetainsExplicitLayoutOfEmptyAggregate() { + String[] members = { + "MULTIPOINT Z EMPTY", "MULTILINESTRING M EMPTY", + "MULTIPOLYGON ZM EMPTY", "GEOMETRYCOLLECTION Z EMPTY" + }; + for (String member : members) { + GeometryWrapper selected = GeometryWrapper.extract( + "GEOMETRYCOLLECTION (" + member + ", POINT (1 2))", WKTDatatype.URI).getGeometryN(1); + GeometryWrapper expected = GeometryWrapper.extract(member, WKTDatatype.URI); + assertEquals(member, expected.getCoordinateSequenceDimensions(), selected.getCoordinateSequenceDimensions()); + assertEquals(member, expected.getGeometryType(), selected.getGeometryType()); + } + } + + @Test + public void testGetGeometryNPreservesZWhenNormalizingAuthorityAxisOrder() { + GeometryWrapper selected = GeometryWrapper.extract( + " MULTIPOINT Z ((10 100 7), (20 120 9))", + WKTDatatype.URI).getGeometryN(2); + Point parsing = (Point) selected.getParsingGeometry(); + Point normalized = (Point) selected.getXYGeometry(); + + assertEquals(CoordinateSequenceDimensions.XYZ, selected.getCoordinateSequenceDimensions()); + assertEquals(20.0, parsing.getX(), 0.0); + assertEquals(120.0, parsing.getY(), 0.0); + assertEquals(9.0, parsing.getCoordinateSequence().getZ(0), 0.0); + assertEquals(120.0, normalized.getX(), 0.0); + assertEquals(20.0, normalized.getY(), 0.0); + assertEquals(9.0, normalized.getCoordinateSequence().getZ(0), 0.0); + } + + @Test + public void testGetGeometryNRejectsIndicesForEmptyCollection() { + GeometryWrapper empty = GeometryWrapper.extract("GEOMETRYCOLLECTION EMPTY", WKTDatatype.URI); + assertThrows(IllegalArgumentException.class, () -> empty.getGeometryN(1)); + assertThrows(IllegalArgumentException.class, () -> empty.getGeometryN(-1)); + assertThrows(IllegalArgumentException.class, () -> empty.getGeometryN(Integer.MAX_VALUE)); + } } diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLReaderTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLReaderTest.java index 97e397b49e7..3b467e070c2 100644 --- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLReaderTest.java +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/gml/GMLReaderTest.java @@ -32,6 +32,7 @@ import org.junit.After; import org.junit.AfterClass; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; @@ -615,6 +616,23 @@ public void testBuildPolygonEmpty() throws JDOMException, IOException { assertEquals(expResult, result); } + @Test + public void testEmptyPolygonRetainsThreeDimensionalCrsLayout() throws JDOMException, IOException { + String[] gml = { + "", + "", + "" + }; + + for (String literal : gml) { + Geometry geometry = GMLReader.extract(literal).getGeometry(); + assertEquals(literal, "Polygon", geometry.getGeometryType()); + assertTrue(literal, geometry.isEmpty()); + assertEquals(literal, CoordinateSequenceDimensions.XYZ, + DimensionInfo.find(geometry, CoordinateSequenceDimensions.XY).getDimensions()); + } + } + /** * Test of buildMultiPointEmpty method, of class GMLReader. * 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..db7ef964e4c 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,6 +20,7 @@ */ package org.apache.jena.geosparql.implementation.parsers.gml; +import java.io.IOException; import org.apache.jena.geosparql.implementation.DimensionInfo; import org.apache.jena.geosparql.implementation.GeometryWrapper; import org.apache.jena.geosparql.implementation.datatype.GMLDatatype; @@ -27,9 +28,12 @@ import org.apache.jena.geosparql.implementation.jts.CustomCoordinateSequence; import org.apache.jena.geosparql.implementation.jts.CustomGeometryFactory; import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI; +import org.jdom2.JDOMException; import org.junit.After; import org.junit.AfterClass; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; @@ -37,6 +41,7 @@ import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; /** @@ -292,4 +297,53 @@ public void testWriteMultiGeometryEmpty() { assertEquals(expResult, result); } + @Test + public void testWriteMultiGeometriesContainingOnlyEmptyMembers() throws JDOMException, IOException { + Point emptyPoint = GEOMETRY_FACTORY.createPoint(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY)); + LineString emptyLine = GEOMETRY_FACTORY.createLineString(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY)); + Polygon emptyPolygon = GEOMETRY_FACTORY.createPolygon(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY)); + Geometry[] geometries = { + GEOMETRY_FACTORY.createMultiPoint(new Point[] { emptyPoint, emptyPoint }), + GEOMETRY_FACTORY.createMultiLineString(new LineString[] { emptyLine, emptyLine }), + GEOMETRY_FACTORY.createMultiPolygon(new Polygon[] { emptyPolygon, emptyPolygon }), + GEOMETRY_FACTORY.createGeometryCollection(new Geometry[] { emptyPoint, emptyLine }) + }; + + for (Geometry geometry : geometries) { + GeometryWrapper wrapper = new GeometryWrapper(geometry, SRS_URI.WGS84_CRS, GMLDatatype.URI, + new DimensionInfo(2, 2, geometry.getDimension())); + Geometry reparsed = GMLReader.extract(GMLWriter.write(wrapper)).getGeometry(); + assertEquals(geometry.getGeometryType(), 2, reparsed.getNumGeometries()); + assertEquals(geometry.getGeometryType(), geometry.getGeometryType(), reparsed.getGeometryType()); + assertTrue(geometry.getGeometryType(), reparsed.getGeometryN(0).isEmpty()); + assertTrue(geometry.getGeometryType(), reparsed.getGeometryN(1).isEmpty()); + } + } + + @Test + public void testWriteMultiGeometriesWithMixedEmptyMembers() throws JDOMException, IOException { + Point emptyPoint = GEOMETRY_FACTORY.createPoint(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY)); + LineString emptyLine = GEOMETRY_FACTORY.createLineString(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY)); + Polygon emptyPolygon = GEOMETRY_FACTORY.createPolygon(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY)); + Geometry[] geometries = { + GEOMETRY_FACTORY.createMultiPoint(new Point[] { + GEOMETRY_FACTORY.createPoint(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY, "1 2")), emptyPoint }), + GEOMETRY_FACTORY.createMultiLineString(new LineString[] { + GEOMETRY_FACTORY.createLineString(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY, "0 0, 1 1")), emptyLine }), + GEOMETRY_FACTORY.createMultiPolygon(new Polygon[] { + GEOMETRY_FACTORY.createPolygon(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY, + "0 0, 1 0, 0 1, 0 0")), emptyPolygon }) + }; + + for (Geometry geometry : geometries) { + GeometryWrapper wrapper = new GeometryWrapper(geometry, SRS_URI.WGS84_CRS, GMLDatatype.URI, + new DimensionInfo(2, 2, geometry.getDimension())); + Geometry reparsed = GMLReader.extract(GMLWriter.write(wrapper)).getGeometry(); + assertEquals(geometry.getGeometryType(), 2, reparsed.getNumGeometries()); + assertEquals(geometry.getGeometryType(), geometry.getGeometryType(), reparsed.getGeometryType()); + assertFalse(geometry.getGeometryType(), reparsed.getGeometryN(0).isEmpty()); + assertTrue(geometry.getGeometryType(), reparsed.getGeometryN(1).isEmpty()); + } + } + } diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTReaderTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTReaderTest.java index 7e02359666b..78cd465cb58 100644 --- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTReaderTest.java +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTReaderTest.java @@ -20,6 +20,7 @@ */ package org.apache.jena.geosparql.implementation.parsers.wkt; +import org.apache.jena.datatypes.DatatypeFormatException; import org.apache.jena.geosparql.implementation.DimensionInfo; import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions; import org.apache.jena.geosparql.implementation.jts.CustomCoordinateSequence; @@ -27,6 +28,9 @@ import org.junit.After; import org.junit.AfterClass; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; @@ -363,6 +367,86 @@ public void testExtractGeometryCollection() { assertEquals(expResult, result); } + @Test + public void testExtractMultiGeometriesWithEmptyMembers() { + String[] wkts = { + "MULTIPOINT ZM ((1 2 3 4), EMPTY, (5 6 7 8))", + "MULTILINESTRING ZM ((1 2 3 4, 2 3 4 5), EMPTY, (5 6 7 8, 6 7 8 9))", + "MULTIPOLYGON ZM (((1 2 3 4, 2 2 3 4, 1 3 3 4, 1 2 3 4)), EMPTY, ((5 6 7 8, 6 6 7 8, 5 7 7 8, 5 6 7 8)))" + }; + + for (String wkt : wkts) { + Geometry multi = WKTReader.extract(wkt).getGeometry(); + assertEquals(wkt, 3, multi.getNumGeometries()); + assertFalse(wkt, multi.getGeometryN(0).isEmpty()); + assertTrue(wkt, multi.getGeometryN(1).isEmpty()); + assertFalse(wkt, multi.getGeometryN(2).isEmpty()); + assertEquals(wkt, 1.0, multi.getGeometryN(0).getCoordinate().getX(), 0.0); + assertEquals(wkt, 5.0, multi.getGeometryN(2).getCoordinate().getX(), 0.0); + assertEquals(wkt, CoordinateSequenceDimensions.XYZM, + DimensionInfo.find(multi.getGeometryN(1), CoordinateSequenceDimensions.XY).getDimensions()); + } + } + + @Test + public void testExtractNestedCollectionWithoutFlatteningMembers() { + Geometry collection = WKTReader.extract( + "GEOMETRYCOLLECTION (MULTIPOINT (EMPTY, (1 2)), POINT (3 4))").getGeometry(); + + assertEquals(2, collection.getNumGeometries()); + Geometry multi = collection.getGeometryN(0); + assertEquals("MultiPoint", multi.getGeometryType()); + assertEquals(2, multi.getNumGeometries()); + assertTrue(multi.getGeometryN(0).isEmpty()); + assertEquals(1.0, multi.getGeometryN(1).getCoordinate().getX(), 0.0); + assertEquals(3.0, collection.getGeometryN(1).getCoordinate().getX(), 0.0); + } + + @Test + public void testExtractAllEmptyMembersRetainsCoordinateMarkers() { + String[] markers = { "Z", "M", "ZM" }; + CoordinateSequenceDimensions[] layouts = { + CoordinateSequenceDimensions.XYZ, + CoordinateSequenceDimensions.XYM, + CoordinateSequenceDimensions.XYZM + }; + for (int i = 0; i < markers.length; i++) { + for (String type : new String[] { "MULTIPOINT", "MULTILINESTRING", "MULTIPOLYGON" }) { + Geometry multi = WKTReader.extract(type + " " + markers[i] + " (EMPTY, EMPTY)").getGeometry(); + assertEquals(type, 2, multi.getNumGeometries()); + for (int member = 0; member < 2; member++) { + assertTrue(type, multi.getGeometryN(member).isEmpty()); + assertEquals(type, layouts[i], DimensionInfo.find(multi.getGeometryN(member), + CoordinateSequenceDimensions.XY).getDimensions()); + } + } + } + } + + @Test + public void testRejectMissingMultiGeometryMembers() { + for (String wkt : new String[] { + "MULTIPOINT ((1 2),, (3 4))", "MULTIPOINT (, (1 2))", "MULTIPOINT ((1 2),)", + "MULTILINESTRING ((0 0, 1 1),, (2 2, 3 3))", "MULTILINESTRING (, (0 0, 1 1))", + "MULTILINESTRING ((0 0, 1 1),)", + "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)),, ((2 2, 3 2, 2 3, 2 2)))", + "MULTIPOLYGON (, ((0 0, 1 0, 0 1, 0 0)))", + "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0)),)" }) { + assertThrows(wkt, DatatypeFormatException.class, () -> WKTReader.extract(wkt)); + } + } + + @Test + public void testRejectUnbalancedMultiGeometryParentheses() { + for (String wkt : new String[] { + "MULTIPOINT ((1 2)", "MULTIPOINT ((1 2)))", + "MULTILINESTRING ((0 0, 1 1)", "MULTILINESTRING ((0 0, 1 1)))", + "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0))", + "MULTIPOLYGON (((0 0, 1 0, 0 1, 0 0))))" }) { + assertThrows(wkt, DatatypeFormatException.class, () -> WKTReader.extract(wkt)); + } + } + /** * Test of buildPointEmpty method, of class WKTReader. */ diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTWriterTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTWriterTest.java index 80044d1b29f..c87d92b33cb 100644 --- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTWriterTest.java +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/parsers/wkt/WKTWriterTest.java @@ -37,6 +37,7 @@ import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; /** @@ -418,4 +419,103 @@ public void testWriteGeometryCollectionEmpty() { assertEquals(expResult, result); } + @Test + public void testWriteMultiGeometriesContainingOnlyEmptyMembers() { + Point emptyPoint = GEOMETRY_FACTORY.createPoint(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY)); + LineString emptyLine = GEOMETRY_FACTORY.createLineString(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY)); + Polygon emptyPolygon = GEOMETRY_FACTORY.createPolygon(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY)); + Geometry[] geometries = { + GEOMETRY_FACTORY.createMultiPoint(new Point[] { emptyPoint, emptyPoint }), + GEOMETRY_FACTORY.createMultiLineString(new LineString[] { emptyLine, emptyLine }), + GEOMETRY_FACTORY.createMultiPolygon(new Polygon[] { emptyPolygon, emptyPolygon }), + GEOMETRY_FACTORY.createGeometryCollection(new Geometry[] { emptyPoint, emptyLine }) + }; + String[] expected = { + "MULTIPOINT(EMPTY, EMPTY)", + "MULTILINESTRING(EMPTY, EMPTY)", + "MULTIPOLYGON(EMPTY, EMPTY)", + "GEOMETRYCOLLECTION(POINT EMPTY, LINESTRING EMPTY)" + }; + + for (int i = 0; i < geometries.length; i++) { + GeometryWrapper wrapper = new GeometryWrapper(geometries[i], SRS_URI.DEFAULT_WKT_CRS84, + WKTDatatype.URI, new DimensionInfo(2, 2, geometries[i].getDimension())); + assertEquals(expected[i], WKTWriter.write(wrapper)); + } + } + + @Test + public void testWriteEmptyGeometryRetainsCoordinateMarker() { + Point emptyPoint = GEOMETRY_FACTORY.createPoint(new CustomCoordinateSequence(CoordinateSequenceDimensions.XYZM)); + GeometryWrapper wrapper = new GeometryWrapper(emptyPoint, SRS_URI.DEFAULT_WKT_CRS84, + WKTDatatype.URI, new DimensionInfo(CoordinateSequenceDimensions.XYZM, 0)); + + assertEquals("POINT ZM EMPTY", WKTWriter.write(wrapper)); + } + + @Test + public void testWriteAllEmptyMultiGeometriesRetainsCoordinateMarkers() { + String[] markers = { "Z", "M", "ZM" }; + CoordinateSequenceDimensions[] layouts = { + CoordinateSequenceDimensions.XYZ, + CoordinateSequenceDimensions.XYM, + CoordinateSequenceDimensions.XYZM + }; + for (int i = 0; i < markers.length; i++) { + Point point = GEOMETRY_FACTORY.createPoint(new CustomCoordinateSequence(layouts[i])); + LineString line = GEOMETRY_FACTORY.createLineString(new CustomCoordinateSequence(layouts[i])); + Polygon polygon = GEOMETRY_FACTORY.createPolygon(new CustomCoordinateSequence(layouts[i])); + Geometry[] geometries = { + GEOMETRY_FACTORY.createMultiPoint(new Point[] { point, point }), + GEOMETRY_FACTORY.createMultiLineString(new LineString[] { line, line }), + GEOMETRY_FACTORY.createMultiPolygon(new Polygon[] { polygon, polygon }) + }; + String[] types = { "MULTIPOINT", "MULTILINESTRING", "MULTIPOLYGON" }; + for (int j = 0; j < geometries.length; j++) { + Geometry geometry = geometries[j]; + GeometryWrapper wrapper = new GeometryWrapper(geometry, SRS_URI.DEFAULT_WKT_CRS84, + WKTDatatype.URI, new DimensionInfo(layouts[i], geometry.getDimension())); + assertEquals(types[j], types[j] + " " + markers[i] + "(EMPTY, EMPTY)", WKTWriter.write(wrapper)); + } + } + } + + @Test + public void testWriteMixedEmptyMultiGeometryMembers() { + Point emptyPoint = GEOMETRY_FACTORY.createPoint(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY)); + LineString emptyLine = GEOMETRY_FACTORY.createLineString(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY)); + Polygon emptyPolygon = GEOMETRY_FACTORY.createPolygon(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY)); + Geometry[] geometries = { + GEOMETRY_FACTORY.createMultiPoint(new Point[] { + GEOMETRY_FACTORY.createPoint(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY, "1 2")), emptyPoint }), + GEOMETRY_FACTORY.createMultiLineString(new LineString[] { + GEOMETRY_FACTORY.createLineString(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY, "0 0, 1 1")), emptyLine }), + GEOMETRY_FACTORY.createMultiPolygon(new Polygon[] { + GEOMETRY_FACTORY.createPolygon(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY, + "0 0, 1 0, 0 1, 0 0")), emptyPolygon }) + }; + String[] expected = { + "MULTIPOINT((1 2), EMPTY)", + "MULTILINESTRING((0 0, 1 1), EMPTY)", + "MULTIPOLYGON(((0 0, 1 0, 0 1, 0 0)), EMPTY)" + }; + for (int i = 0; i < geometries.length; i++) { + GeometryWrapper wrapper = new GeometryWrapper(geometries[i], SRS_URI.DEFAULT_WKT_CRS84, + WKTDatatype.URI, new DimensionInfo(2, 2, geometries[i].getDimension())); + assertEquals(expected[i], WKTWriter.write(wrapper)); + } + } + + @Test + public void testWriteCollectionRetainsMemberSpecificLayouts() { + Geometry emptyMeasuredMulti = WKTReader.extract("MULTILINESTRING M EMPTY").getGeometry(); + Point elevated = GEOMETRY_FACTORY.createPoint(new CustomCoordinateSequence( + CoordinateSequenceDimensions.XYZ, "1 2 3")); + Geometry collection = GEOMETRY_FACTORY.createGeometryCollection(new Geometry[] { emptyMeasuredMulti, elevated }); + GeometryWrapper wrapper = new GeometryWrapper(collection, SRS_URI.DEFAULT_WKT_CRS84, + WKTDatatype.URI, new DimensionInfo(CoordinateSequenceDimensions.XY, collection.getDimension())); + + assertEquals("GEOMETRYCOLLECTION(MULTILINESTRING M EMPTY, POINT Z(1 2 3))", WKTWriter.write(wrapper)); + } + }