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/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/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" + } +} 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')" 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() 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/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/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: 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'" + } } 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"] + } +}