From c2f77b9e15aed2b86682e44baf3fa96de51721b4 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Wed, 14 Oct 2026 19:15:19 +0200 Subject: [PATCH 1/6] fix internal error message for functions in spatial predicates A function as an argument of a spatial predicate in CQL2 text, e.g. S_INTERSECTS(geometry, ENVELOPE(...)), failed with a ClassCastException, whose message was returned to the client. Such filters are now rejected with a parse error that names the function. --- .../cql/infra/CqlTextVisitor.java | 20 +++++++++++++++++-- .../xtraplatform/cql/app/CqlTextSpec.groovy | 14 +++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/infra/CqlTextVisitor.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/infra/CqlTextVisitor.java index bbedd5d98..473887482 100644 --- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/infra/CqlTextVisitor.java +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/infra/CqlTextVisitor.java @@ -285,12 +285,28 @@ public CqlNode visitSpatialPredicate(CqlParser.SpatialPredicateContext ctx) { SpatialFunction spatialFunction = SpatialFunction.valueOf(ctx.SpatialFunction().getText().toUpperCase(Locale.ROOT)); - Spatial spatial1 = (Spatial) ctx.geomExpression().get(0).accept(this); - Spatial spatial2 = (Spatial) ctx.geomExpression().get(1).accept(this); + Spatial spatial1 = toSpatial(ctx.geomExpression().get(0).accept(this), spatialFunction); + Spatial spatial2 = toSpatial(ctx.geomExpression().get(1).accept(this), spatialFunction); return BinarySpatialOperation.of(spatialFunction, spatial1, spatial2); } + // the grammar accepts a function as a geometry expression, but functions are not supported as + // arguments of spatial predicates + private static Spatial toSpatial(CqlNode node, SpatialFunction spatialFunction) { + if (node instanceof Spatial spatial) { + return spatial; + } + if (node instanceof Function function) { + throw new CqlParseException( + String.format( + "Functions are not supported as arguments of %s, found: %s.", + spatialFunction.name(), function.getName())); + } + throw new CqlParseException( + String.format("Unsupported argument of %s: %s", spatialFunction.name(), node)); + } + @Override public CqlNode visitArrayPredicate(CqlParser.ArrayPredicateContext ctx) { diff --git a/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlTextSpec.groovy b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlTextSpec.groovy index 4ece2830f..ac4a7104a 100644 --- a/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlTextSpec.groovy +++ b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlTextSpec.groovy @@ -1557,6 +1557,20 @@ class CqlTextSpec extends Specification { actual2 == cqlText } + def 'Function as argument of a spatial predicate'() { + when: 'reading text' + cql.read(cqlText, Cql.Format.TEXT) + + then: + CqlParseException e = thrown() + e.getMessage() == message + + where: + cqlText || message + "S_INTERSECTS(geometry, ENVELOPE(6.5, 49.5, 7.0, 50.0))" || "Functions are not supported as arguments of S_INTERSECTS, found: ENVELOPE." + "S_WITHIN(foo(geometry), BBOX(6.5, 49.5, 7.0, 50.0))" || "Functions are not supported as arguments of S_WITHIN, found: foo." + } + def 'BETWEEN with temporal arguments'() { given: String cqlText = "updated BETWEEN TIMESTAMP('2017-06-10T07:30:00Z') AND TIMESTAMP('2017-06-11T10:30:00Z')" From 0a583f2136b624fe3a46bb3187676be7c7ca87aa Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Wed, 14 Oct 2026 19:28:32 +0200 Subject: [PATCH 2/6] fix feature id removed by a property selection A selection of properties, e.g. from the properties or exclude-properties parameter of an API, also removed the id of the feature, if it was not selected. The id identifies the feature and is now always kept. Ids of nested objects are not affected. --- .../domain/transform/WithoutProperties.java | 2 + .../transform/WithoutPropertiesSpec.groovy | 87 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/WithoutPropertiesSpec.groovy diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/WithoutProperties.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/WithoutProperties.java index a7c67c40d..312b8f88f 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/WithoutProperties.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/WithoutProperties.java @@ -32,8 +32,10 @@ public FeatureSchema visit( return null; } + // the id of the feature identifies the feature, it is not a property that can be deselected if (!fields.isEmpty() && !parents.isEmpty() + && !(parents.size() == 1 && schema.isId()) && !(fields.contains(getEffectiveSchema(schema, parents).getFullPathAsString()) || visitedProperties.stream().anyMatch(Objects::nonNull))) { return null; diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/WithoutPropertiesSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/WithoutPropertiesSpec.groovy new file mode 100644 index 000000000..76236f588 --- /dev/null +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/transform/WithoutPropertiesSpec.groovy @@ -0,0 +1,87 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.features.domain.transform + +import de.ii.xtraplatform.features.domain.FeatureSchema +import de.ii.xtraplatform.features.domain.ImmutableFeatureSchema +import de.ii.xtraplatform.features.domain.SchemaBase +import spock.lang.Specification + +class WithoutPropertiesSpec extends Specification { + + static FeatureSchema property(String name, SchemaBase.Role role = null) { + def b = new ImmutableFeatureSchema.Builder() + .name(name) + .type(SchemaBase.Type.STRING) + .sourcePath(name) + if (role != null) { + b.role(role) + } + return b.build() + } + + static FeatureSchema object(String name, FeatureSchema... properties) { + def b = new ImmutableFeatureSchema.Builder() + .name(name) + .type(SchemaBase.Type.OBJECT) + .sourcePath(name == "test" ? "/test" : name) + properties.each { b.putPropertyMap(it.getName(), it) } + return b.build() + } + + static List names(FeatureSchema schema) { + return schema.getProperties().collect { it.getName() } + } + + def 'the feature id is kept, if it is not selected'() { + given: + FeatureSchema schema = object("test", property("id", SchemaBase.Role.ID), property("name"), property("area")) + + when: + FeatureSchema result = schema.accept(new WithoutProperties(["area"], false)) + + then: + names(result) == ["id", "area"] + } + + def 'the feature id is kept, if all other properties are selected'() { + given: + FeatureSchema schema = object("test", property("id", SchemaBase.Role.ID), property("name"), property("area")) + + when: 'exclude-properties=id is expressed as the selection of all other properties' + FeatureSchema result = schema.accept(new WithoutProperties(["name", "area"], false)) + + then: + names(result) == ["id", "name", "area"] + } + + def 'an id of a nested object does not keep the object'() { + given: + FeatureSchema schema = object("test", + property("id", SchemaBase.Role.ID), + property("area"), + object("nested", property("nestedId", SchemaBase.Role.ID), property("value"))) + + when: + FeatureSchema result = schema.accept(new WithoutProperties(["area"], false)) + + then: + names(result) == ["id", "area"] + } + + def 'without a selection all properties are kept'() { + given: + FeatureSchema schema = object("test", property("id", SchemaBase.Role.ID), property("name")) + + when: + FeatureSchema result = schema.accept(new WithoutProperties(["*"], false)) + + then: + names(result) == ["id", "name"] + } +} From 863d7320697146ebe83657c39b2167c383fa908f Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 15 Oct 2026 13:41:52 +0200 Subject: [PATCH 3/6] fix requests hanging for an array predicate with an empty array An array predicate with an empty array, e.g. A_CONTAINS(names, ()) or a queryable parameter of an array property with an empty value, was encoded as `IN ()`. The database rejects that statement, and since the error of a streamed read is not propagated, the request did not complete. An empty array is now encoded according to its meaning, consistent with the other array predicates where a property without values does not match: a property with values contains the empty array, and no property overlaps with it, is contained by it or is equal to it. --- .../features/sql/app/FilterEncoderSql.java | 39 +++++++++++++++++++ .../sql/app/FilterEncoderSqlSpec.groovy | 35 +++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FilterEncoderSql.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FilterEncoderSql.java index a8151ed74..6944c6ca5 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FilterEncoderSql.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FilterEncoderSql.java @@ -21,6 +21,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import de.ii.xtraplatform.cql.domain.And; +import de.ii.xtraplatform.cql.domain.ArrayFunction; import de.ii.xtraplatform.cql.domain.ArrayLiteral; import de.ii.xtraplatform.cql.domain.Bbox; import de.ii.xtraplatform.cql.domain.Between; @@ -893,6 +894,28 @@ private static Predicate getPropertyNameMatcher( && Objects.equals(propertyName, property.getSourcePath().get())); } + private static boolean isEmptyArray(Operand operand) { + return operand instanceof ArrayLiteral arrayLiteral + && arrayLiteral.getValue() instanceof List values + && values.isEmpty(); + } + + // As in the other array predicates, a property without values (no row in the joined table) does + // not match. So only a property with values contains the empty array, and no property overlaps + // with it, is contained by it or is equal to it. The SQL `IN ()` would be invalid. + private static String encodeEmptyArray( + ArrayFunction operator, + boolean notInverse, + String mainExpression, + String alias, + String sortKey) { + if (notInverse ? operator == A_CONTAINS : operator == A_CONTAINEDBY) { + return String.format( + mainExpression, "", String.format(" IS NOT NULL GROUP BY %s.%s", alias, sortKey)); + } + return "1=0"; + } + private class CqlToSql extends CqlToText { private final SchemaSql rootSchema; @@ -1724,6 +1747,14 @@ public String visit(BinaryArrayOperation arrayOperation, List children) getQualifiedColumn(table, propertyName, aliases.get(aliases.size() - 1), false); if (qualifiedColumn.second().isEmpty()) { + if (isEmptyArray(arrayOperation.getArgs().get(notInverse ? 1 : 0))) { + return encodeEmptyArray( + arrayOperation.getArrayOperator(), + notInverse, + mainExpression, + aliases.get(0), + rootSchema.getSortKey().get()); + } if (notInverse ? arrayOperation.getArrayOperator() == A_CONTAINS : arrayOperation.getArrayOperator() == A_CONTAINEDBY) { @@ -3066,6 +3097,14 @@ public String visit(BinaryArrayOperation arrayOperation, List children) table.first(), table.second(), propertyName, aliases.get(aliases.size() - 1), false); if (qualifiedColumn.second().isEmpty()) { + if (isEmptyArray(arrayOperation.getArgs().get(notInverse ? 1 : 0))) { + return encodeEmptyArray( + arrayOperation.getArrayOperator(), + notInverse, + mainExpression, + aliases.get(0), + mapping.getMainTable().getSortKey()); + } if (notInverse ? arrayOperation.getArrayOperator() == A_CONTAINS : arrayOperation.getArrayOperator() == A_CONTAINEDBY) { diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlSpec.groovy index 0235f341e..36e200368 100644 --- a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlSpec.groovy +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlSpec.groovy @@ -7,6 +7,7 @@ */ package de.ii.xtraplatform.features.sql.app +import com.google.common.collect.ImmutableList import de.ii.xtraplatform.base.domain.resiliency.VolatileRegistry import de.ii.xtraplatform.cql.domain.CustomFunction import de.ii.xtraplatform.cql.domain.Cql2FunctionArgument @@ -21,6 +22,11 @@ import de.ii.xtraplatform.cql.app.CqlImpl import de.ii.xtraplatform.cql.domain.ScalarLiteral import de.ii.xtraplatform.cql.domain.IsNull import de.ii.xtraplatform.cql.domain.Not +import de.ii.xtraplatform.cql.domain.AContainedBy +import de.ii.xtraplatform.cql.domain.AContains +import de.ii.xtraplatform.cql.domain.AEquals +import de.ii.xtraplatform.cql.domain.AOverlaps +import de.ii.xtraplatform.cql.domain.ArrayLiteral import de.ii.xtraplatform.crs.domain.CrsTransformerFactory import de.ii.xtraplatform.crs.domain.OgcCrs import de.ii.xtraplatform.crs.infra.CrsTransformerFactoryProj @@ -816,6 +822,35 @@ class FilterEncoderSqlSpec extends Specification { } + def 'array predicates with an empty array'() { + + given: + def instanceContainer = QuerySchemaFixtures.JOINED_GEOMETRY + def property = Property.of("location") + def empty = ArrayLiteral.of(ImmutableList.of()) + def operators = [ + A_CONTAINS : { a, b -> AContains.of(a, b) }, + A_CONTAINEDBY: { a, b -> AContainedBy.of(a, b) }, + A_OVERLAPS : { a, b -> AOverlaps.of(a, b) }, + A_EQUALS : { a, b -> AEquals.of(a, b) }] + + when: + def filter = literalFirst ? operators[operator](empty, property) : operators[operator](property, empty) + String actual = filterEncoder.encode(filter, instanceContainer) + + then: + actual == expected + + where: + operator | literalFirst || expected + "A_CONTAINS" | false || "A.id IN (SELECT AA.id FROM building AA JOIN geometry AB ON (AA.id=AB.id) WHERE AB.location IS NOT NULL GROUP BY AA.id)" + "A_CONTAINEDBY" | true || "A.id IN (SELECT AA.id FROM building AA JOIN geometry AB ON (AA.id=AB.id) WHERE AB.location IS NOT NULL GROUP BY AA.id)" + "A_OVERLAPS" | false || "1=0" + "A_EQUALS" | false || "1=0" + "A_CONTAINEDBY" | false || "1=0" + "A_CONTAINS" | true || "1=0" + } + def 'interval list test'() { given: From b59ba1e275b4826d58286f67b534e9a0764fe662 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 15 Oct 2026 13:43:19 +0200 Subject: [PATCH 4/6] fix requests failing for dates before the year 1 in PostgreSQL A date or timestamp in the year 0000, e.g. `datetime=0000-01-01`, is valid in ISO 8601 and RFC 3339, but PostgreSQL has no year 0 and rejected the literal. The request failed with an internal error or, for a streamed read, did not complete. Dates and timestamps before the year 1 are now written in the notation of PostgreSQL: the ISO year 0 is 1 BC, the year -1 is 2 BC etc. --- .../features/sql/domain/SqlDialectPgis.java | 21 +++++++++++++++++-- .../sql/domain/SqlDialectPostGisSpec.groovy | 17 +++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlDialectPgis.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlDialectPgis.java index 8c8d5515c..103d7b042 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlDialectPgis.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlDialectPgis.java @@ -31,6 +31,8 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.threeten.extra.Interval; public class SqlDialectPgis implements SqlDialect { @@ -71,6 +73,8 @@ public String dropResultSetTable(String name) { return String.format("DROP TABLE IF EXISTS %s", name); } + // the year of an ISO 8601 date, optionally signed, followed by the rest of the value + private static final Pattern ISO_YEAR = Pattern.compile("^([+-]?\\d{4,})(-.*)$"); private static final Splitter BBOX_SPLITTER = Splitter.onPattern("[(), ]").omitEmptyStrings().trimResults(); private static final Map SPATIAL_OPERATORS_3D = @@ -227,12 +231,25 @@ public String applyToDatetime(String column, Optional format) { @Override public String applyToDateLiteral(String date) { - return String.format("DATE '%s'", date); + return String.format("DATE '%s'", withEra(date)); } @Override public String applyToDatetimeLiteral(String datetime) { - return String.format("TIMESTAMP '%s'", datetime); + return String.format("TIMESTAMP '%s'", withEra(datetime)); + } + + // PostgreSQL has no year 0 and no negative years: the ISO 8601 year 0 is 1 BC, -1 is 2 BC etc. + private static String withEra(String value) { + Matcher matcher = ISO_YEAR.matcher(value); + if (!matcher.matches()) { + return value; + } + long year = Long.parseLong(matcher.group(1)); + if (year > 0) { + return value; + } + return String.format("%04d%s BC", 1 - year, matcher.group(2)); } @Override diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/domain/SqlDialectPostGisSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/domain/SqlDialectPostGisSpec.groovy index 09630b0fb..fba35b54c 100644 --- a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/domain/SqlDialectPostGisSpec.groovy +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/domain/SqlDialectPostGisSpec.groovy @@ -157,4 +157,21 @@ class SqlDialectPostGisSpec extends Specification { extent.isEmpty() } + + def 'a date or timestamp literal before the year 1 is written with the era'() { + expect: + sqlDialectPostGis.applyToDatetimeLiteral(value) == expected + sqlDialectPostGis.applyToDateLiteral(value) == expected.replace("TIMESTAMP", "DATE") + + where: + value || expected + "2020-01-01T10:20:30Z" || "TIMESTAMP '2020-01-01T10:20:30Z'" + "0001-01-01T00:00:00Z" || "TIMESTAMP '0001-01-01T00:00:00Z'" + "0000-01-01T00:00:00Z" || "TIMESTAMP '0001-01-01T00:00:00Z BC'" + "-0001-12-31T23:59:59Z" || "TIMESTAMP '0002-12-31T23:59:59Z BC'" + "+10000-01-01T00:00:00Z" || "TIMESTAMP '+10000-01-01T00:00:00Z'" + "0000-01-01" || "TIMESTAMP '0001-01-01 BC'" + "-infinity" || "TIMESTAMP '-infinity'" + "infinity" || "TIMESTAMP 'infinity'" + } } From bad7511df76f0488c31d98a06ae1e78cdf9ac42d Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 15 Oct 2026 13:43:21 +0200 Subject: [PATCH 5/6] fix server error for a geocentric CRS of 2D data Transforming 2D coordinates to a geocentric CRS, e.g. EPSG:4936 (ETRS89 X-Y-Z), failed with an internal error. The transformation returns three coordinates per position, but the result was sized for two. A geocentric target now has three dimensions, also for a 2D source, and the result of a transformation is sized for the coordinates it returns. If a transformation returns more coordinates than the target uses, only the first coordinates of each position are kept. --- .../crs/infra/CrsTransformerFactoryProj.java | 10 +++++++++- .../crs/infra/CrsTransformerProj.java | 18 +++++++++++++++--- .../crs/infra/CrsTransformerProjSpec.groovy | 15 +++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerFactoryProj.java b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerFactoryProj.java index 739ad0d8a..60a63f15d 100644 --- a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerFactoryProj.java +++ b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerFactoryProj.java @@ -58,6 +58,7 @@ import org.opengis.referencing.crs.GeographicCRS; import org.opengis.referencing.crs.SingleCRS; import org.opengis.referencing.cs.AxisDirection; +import org.opengis.referencing.cs.CartesianCS; import org.opengis.referencing.cs.CoordinateSystem; import org.opengis.referencing.cs.EllipsoidalCS; import org.opengis.referencing.cs.RangeMeaning; @@ -469,6 +470,12 @@ private boolean isCrs3d(CoordinateReferenceSystem crs) { return crs.getCoordinateSystem().getDimension() == 3; } + private boolean isGeocentric(CoordinateReferenceSystem crs) { + return isCrs3d(crs) + && !(crs instanceof CompoundCRS) + && crs.getCoordinateSystem() instanceof CartesianCS; + } + private EpsgCrs applyWorkarounds(EpsgCrs crs) { // ArcGIS still uses code 102100 instead of 3857, but proj does not support it anymore if (crs.getCode() == 102_100) { @@ -553,7 +560,8 @@ private CrsTransformer createCrsTransformer( try { boolean is3dTo3d = isCrs3d(sourceProjCrs) && isCrs3d(targetProjCrs); int sourceDimension = isCrs3d(sourceProjCrs) ? 3 : 2; - int targetDimension = is3dTo3d ? 3 : 2; + // a geocentric position needs all three coordinates, also for a 2D source + int targetDimension = is3dTo3d || isGeocentric(targetProjCrs) ? 3 : 2; CoordinateOperationContext coordinateOperationContext = new CoordinateOperationContext(); coordinateOperationContext.setAuthority("EPSG"); diff --git a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerProj.java b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerProj.java index a01df0b36..b2bc67124 100644 --- a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerProj.java +++ b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerProj.java @@ -106,10 +106,22 @@ public double[] transform(double[] coordinates, int numberOfPoints, int dimensio sourceDimension, dimension)); } - double[] target = new double[dimension * numberOfPoints]; - try { - getMathTransform(dimension).transform(coordinates, 0, target, 0, numberOfPoints); + MathTransform mathTransform = getMathTransform(dimension); + // the operation may return more coordinates than the source has, e.g. for a geocentric CRS + int operationDimension = mathTransform.getTargetDimensions(); + double[] target = new double[Math.max(dimension, operationDimension) * numberOfPoints]; + mathTransform.transform(coordinates, 0, target, 0, numberOfPoints); + + if (operationDimension > targetDimension) { + // only the horizontal coordinates of a 3D target are used + double[] reduced = new double[dimension * numberOfPoints]; + for (int i = 0; i < numberOfPoints; i++) { + System.arraycopy( + target, i * operationDimension, reduced, i * targetDimension, targetDimension); + } + return reduced; + } return target; } catch (TransformException | IllegalArgumentException | IllegalStateException ex) { diff --git a/xtraplatform-crs/src/test/groovy/de/ii/xtraplatform/crs/infra/CrsTransformerProjSpec.groovy b/xtraplatform-crs/src/test/groovy/de/ii/xtraplatform/crs/infra/CrsTransformerProjSpec.groovy index b3db6b202..7fa8ab190 100644 --- a/xtraplatform-crs/src/test/groovy/de/ii/xtraplatform/crs/infra/CrsTransformerProjSpec.groovy +++ b/xtraplatform-crs/src/test/groovy/de/ii/xtraplatform/crs/infra/CrsTransformerProjSpec.groovy @@ -160,6 +160,21 @@ class CrsTransformerProjSpec extends Specification { } + def 'CRS transformer - 2D to a geocentric CRS'() { + given: + CrsTransformerProj gct = (CrsTransformerProj) transformerFactory.getTransformer(EpsgCrs.of(25832), EpsgCrs.of(4936)).get() + + when: + double[] result = gct.transform([500000.0, 5550000.0] as double[], 1, 2) + + then: + gct.getTargetDimension() == 3 + result.length == 3 + Math.abs(result[0] - 4046000) < 10000 + Math.abs(result[1] - 641000) < 10000 + Math.abs(result[2] - 4869000) < 10000 + } + def 'CRS transformer - dimension mismatch'() { when: CrsTransformerProj gct = (CrsTransformerProj) transformerFactory.getTransformer(EpsgCrs.of(25832), EpsgCrs.of(5555)).get() From 4a85f7a4b6385802a10d1e388bc9c8215c3260e1 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 15 Oct 2026 16:32:42 +0200 Subject: [PATCH 6/6] fix requests hanging for a filter with a NUL character in a string A character string in a filter that contains the character U+0000, e.g. in a CQL2-JSON filter of a search request, was passed to the database. PostgreSQL rejects such a string, and since the error of a streamed read is not propagated, the request did not complete. A character string with U+0000 is now rejected when the filter is parsed. --- .../cql/domain/ScalarLiteral.java | 4 ++ .../cql/app/CqlNulCharacterSpec.groovy | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlNulCharacterSpec.groovy diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/ScalarLiteral.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/ScalarLiteral.java index f3fbdcea3..e5a2128bc 100644 --- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/ScalarLiteral.java +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/ScalarLiteral.java @@ -79,6 +79,10 @@ public Builder(String literal) { public Builder(String literal, boolean determineType) { super(); + // a character string cannot contain NUL in a database, e.g. PostgreSQL rejects it + if (literal.indexOf('\u0000') >= 0) { + throw new CqlParseException("A character string must not contain the character U+0000."); + } if (determineType) { Object castedLiteral = castToType(literal); value(castedLiteral); diff --git a/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlNulCharacterSpec.groovy b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlNulCharacterSpec.groovy new file mode 100644 index 000000000..1066c9c05 --- /dev/null +++ b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlNulCharacterSpec.groovy @@ -0,0 +1,46 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.cql.app + +import de.ii.xtraplatform.cql.domain.Cql +import de.ii.xtraplatform.cql.domain.CqlParseException +import de.ii.xtraplatform.cql.domain.ScalarLiteral +import spock.lang.Shared +import spock.lang.Specification + +class CqlNulCharacterSpec extends Specification { + + @Shared + Cql cql = new CqlImpl() + + def 'a character string with U+0000 is not a valid literal'() { + when: + ScalarLiteral.of("a\u0000b") + + then: + thrown(CqlParseException) + } + + def 'a filter with U+0000 in a character string is rejected (#format)'() { + when: + cql.read(filter, format) + + then: + thrown(CqlParseException) + + where: + format | filter + Cql.Format.TEXT | "name = 'a\u0000b'" + Cql.Format.JSON | '{"op":"=","args":[{"property":"name"},"a\\u0000b"]}' + } + + def 'other control characters are accepted'() { + expect: + ScalarLiteral.of("a\u0001b").getValue() == "a\u0001b" + } +}