Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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')"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -893,6 +894,28 @@ private static Predicate<SchemaSql> 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;
Expand Down Expand Up @@ -1724,6 +1747,14 @@ public String visit(BinaryArrayOperation arrayOperation, List<String> 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) {
Expand Down Expand Up @@ -3066,6 +3097,14 @@ public String visit(BinaryArrayOperation arrayOperation, List<String> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<SpatialFunction, String> SPATIAL_OPERATORS_3D =
Expand Down Expand Up @@ -227,12 +231,25 @@ public String applyToDatetime(String column, Optional<String> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading