From c4bb374f19225f286b99c5113b429a36f8d2fc64 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Mon, 24 Aug 2026 12:28:57 +0200 Subject: [PATCH] features: fix the geometry of a column a sub-decoder derives from an expression SqlQueryColumnOperations told the dialect whether the expression yields a geometry by passing "the column has the WKT operation". A geometry column carries exactly one of the WKT and WKB operations, chosen by queryGeneration.geometryEncoding, so with the default encoding of WKB the dialect was told the expression is not a geometry and returned it unwrapped. Every other consumer of these operations looks at both. Three defects followed from that, in both the PostGIS and the GeoPackage dialect: - a geometry was returned as the raw value of the database instead of the requested encoding - the spatial extent query excludes the WKT and WKB operations because it wraps the raw geometry itself, but the exclusion was not honoured, so the geometry was wrapped twice - forcePolygonCCW and linearizeCurves were not applied, so the same geometry came back differently depending on whether it was reached through an expression The dialect is now given the operation instead of a flag, along with the two geometry options. It cannot hold the encoding itself: it is shared by all providers of its DBMS, while the encoding is set per provider, which is why the field that used to be consulted for it was never assigned. Both dialects implemented the method identically, so it moves to the interface, where it calls the WKT and WKB rendering of the dialect. --- .../features/sql/app/FilterEncoderSql.java | 3 +- .../sql/app/SqlQueryColumnOperations.java | 35 ++++- .../features/sql/domain/SqlDialect.java | 35 ++++- .../features/sql/domain/SqlDialectGpkg.java | 20 --- .../features/sql/domain/SqlDialectPgis.java | 14 -- .../FilterEncoderSqlSpatialIndexSpec.groovy | 47 +++++++ .../app/SqlQueryColumnOperationsSpec.groovy | 127 ++++++++++++++++++ .../sql/domain/SqlDialectGpkgSpec.groovy | 55 ++++++++ 8 files changed, 292 insertions(+), 44 deletions(-) create mode 100644 xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlQueryColumnOperationsSpec.groovy 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 0c896998c..2cf05d4d3 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 @@ -1947,7 +1947,8 @@ private String mapToSubDecoder( .filter("EXPRESSION"::equals) .isPresent()) { String columnResolved = - SqlQueryColumnOperations.getQualifiedColumnResolved(alias, column, sqlDialect); + SqlQueryColumnOperations.getQualifiedColumnResolved( + alias, column, sqlDialect, Set.of(Operation.WKB, Operation.WKT), false); return columnResolved.replace("AS " + column.getName(), ""); } diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryColumnOperations.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryColumnOperations.java index 946e8e4df..a7bbe9208 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryColumnOperations.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryColumnOperations.java @@ -14,6 +14,7 @@ import de.ii.xtraplatform.features.sql.domain.SqlQueryColumn; import de.ii.xtraplatform.features.sql.domain.SqlQueryColumn.Operation; import java.util.Map; +import java.util.Optional; import java.util.Set; public interface SqlQueryColumnOperations { @@ -45,6 +46,10 @@ static String getQualifiedColumnResolved( return "'%s' AS %s" .formatted(column.getOperationParameter(Operation.CONSTANT, ""), column.getName()); } + boolean isForcePolygonCCW = ops.containsKey(Operation.FORCE_POLYGON_CCW); + boolean shouldLinearizeCurves = + ops.containsKey(Operation.LINEARIZE_CURVES) || forceLinearizeCurves; + if (ops.containsKey(Operation.EXPRESSION) && !excludeOperations.contains(Operation.EXPRESSION)) { final int[] i = {0}; @@ -54,18 +59,14 @@ static String getQualifiedColumnResolved( column.getOperationParameters(Operation.EXPRESSION).stream() .map(param -> Map.entry("" + i[0]++, param)) .collect(ImmutableMap.toImmutableMap(Map.Entry::getKey, Map.Entry::getValue)), - ops.containsKey(Operation.WKT)); + geometryOperation(ops, excludeOperations), + isForcePolygonCCW, + shouldLinearizeCurves); } if (ops.containsKey(Operation.WKT) && !excludeOperations.contains(Operation.WKT)) { - boolean isForcePolygonCCW = ops.containsKey(Operation.FORCE_POLYGON_CCW); - boolean shouldLinearizeCurves = - ops.containsKey(Operation.LINEARIZE_CURVES) || forceLinearizeCurves; return sqlDialect.applyToWkt(name, isForcePolygonCCW, shouldLinearizeCurves); } if (ops.containsKey(Operation.WKB) && !excludeOperations.contains(Operation.WKB)) { - boolean isForcePolygonCCW = ops.containsKey(Operation.FORCE_POLYGON_CCW); - boolean shouldLinearizeCurves = - ops.containsKey(Operation.LINEARIZE_CURVES) || forceLinearizeCurves; return sqlDialect.applyToWkb(name, isForcePolygonCCW, shouldLinearizeCurves); } if (ops.containsKey(Operation.DATE) && !excludeOperations.contains(Operation.DATE)) { @@ -77,6 +78,26 @@ static String getQualifiedColumnResolved( return name; } + /** + * The encoding a geometry column has to be wrapped in for output, {@link Operation#WKB} or {@link + * Operation#WKT}, or empty when the column is not a geometry or the caller excluded the wrapping + * because it wraps the raw geometry itself. + * + *

A geometry column carries exactly one of the two, chosen by {@code + * queryGeneration.geometryEncoding}, so both have to be looked at — as every other consumer of + * these operations does. + */ + private static Optional geometryOperation( + Map ops, Set excludeOperations) { + if (ops.containsKey(Operation.WKB) && !excludeOperations.contains(Operation.WKB)) { + return Optional.of(Operation.WKB); + } + if (ops.containsKey(Operation.WKT) && !excludeOperations.contains(Operation.WKT)) { + return Optional.of(Operation.WKT); + } + return Optional.empty(); + } + static SqlQueryColumn dateToDatetime(SqlQueryColumn column) { if (!column.getOperations().containsKey(Operation.DATE)) { return column; diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlDialect.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlDialect.java index 2c3733e1b..addbfaa60 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlDialect.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlDialect.java @@ -169,9 +169,40 @@ default Set getTemporalOperators() { return ImmutableSet.of(); } + /** + * Renders a column that a sub-decoder produces from an expression: the {@code $T$} placeholders + * of the expression are resolved to {@code table}, a geometry is wrapped for output, and the + * result is aliased to {@code name}. + * + * @param geometryOperation {@link SqlQueryColumn.Operation#WKB} or {@link + * SqlQueryColumn.Operation#WKT} when the expression yields a geometry that has to be wrapped + * for output in that encoding, empty when it does not. The encoding is a per-provider setting + * ({@code queryGeneration.geometryEncoding}) and a dialect is shared by all providers of its + * DBMS, so it has to be passed in rather than held by the dialect. + * @param forcePolygonCCW whether the geometry has to be returned with counter-clockwise polygons + * @param linearizeCurves whether curves in the geometry have to be linearized + */ default String applyToExpression( - String table, String name, Map subDecoderPaths, boolean spatial) { - return name; + String table, + String name, + Map subDecoderPaths, + Optional geometryOperation, + boolean forcePolygonCCW, + boolean linearizeCurves) { + if (subDecoderPaths.isEmpty()) { + return name; + } + + String expression = + subDecoderPaths.values().iterator().next().replaceAll("\\$(?:t|T|table)\\$", table); + + if (geometryOperation.filter(SqlQueryColumn.Operation.WKB::equals).isPresent()) { + expression = applyToWkb(expression, forcePolygonCCW, linearizeCurves); + } else if (geometryOperation.filter(SqlQueryColumn.Operation.WKT::equals).isPresent()) { + expression = applyToWkt(expression, forcePolygonCCW, linearizeCurves); + } + + return String.format("(%s) AS %s", expression, name); } Map SPATIAL_OPERATORS = diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlDialectGpkg.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlDialectGpkg.java index 797bb8554..a2e67ff2d 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlDialectGpkg.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlDialectGpkg.java @@ -11,7 +11,6 @@ import de.ii.xtraplatform.crs.domain.BoundingBox; import de.ii.xtraplatform.crs.domain.EpsgCrs; import de.ii.xtraplatform.features.domain.SchemaBase.Type; -import de.ii.xtraplatform.features.sql.domain.FeatureProviderSqlData.QueryGeneratorSettings; import de.ii.xtraplatform.features.sql.domain.SchemaSql.PropertyTypeInfo; import de.ii.xtraplatform.features.sql.infra.db.SqlDbmsAdapterGpkg; import java.time.Instant; @@ -19,7 +18,6 @@ import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Optional; import org.threeten.extra.Interval; @@ -31,8 +29,6 @@ public String getId() { return SqlDbmsAdapterGpkg.ID; } - private QueryGeneratorSettings settings; - private static final Splitter BBOX_SPLITTER = Splitter.onPattern("[(), ]").omitEmptyStrings().trimResults(); @@ -242,20 +238,4 @@ private static String toNumericLiteral(double value) { // Double.toString is independent of the default locale, unlike String.format("%f", ...) return Double.toString(value); } - - @Override - public String applyToExpression( - String table, String name, Map subDecoderPaths, boolean spatial) { - if (!subDecoderPaths.isEmpty()) { - String expression = - subDecoderPaths.values().iterator().next().replaceAll("\\$(?:t|T|table)\\$", table); - if (spatial && settings.getGeometryAsWkb()) { - expression = applyToWkb(expression, false, false); - } else if (spatial) { - expression = applyToWkt(expression, false, false); - } - return String.format("(%s) AS %s", expression, name); - } - return SqlDialect.super.applyToExpression(table, name, subDecoderPaths, spatial); - } } 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 97a5e22cd..bdc7fb4bc 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 @@ -349,18 +349,4 @@ public String applyToJsonArrayOp( public String escapeString(String value) { return value.replaceAll("'", "''"); } - - @Override - public String applyToExpression( - String table, String name, Map subDecoderPaths, boolean spatial) { - if (!subDecoderPaths.isEmpty()) { - String expression = - subDecoderPaths.values().iterator().next().replaceAll("\\$(?:t|T|table)\\$", table); - if (spatial) { - expression = applyToWkt(expression, false, false); - } - return String.format("(%s) AS %s", expression, name); - } - return SqlDialect.super.applyToExpression(table, name, subDecoderPaths, spatial); - } } diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlSpatialIndexSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlSpatialIndexSpec.groovy index fd554ebe6..db1a99854 100644 --- a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlSpatialIndexSpec.groovy +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlSpatialIndexSpec.groovy @@ -20,6 +20,7 @@ import de.ii.xtraplatform.cql.domain.SWithin import de.ii.xtraplatform.cql.domain.SpatialLiteral import de.ii.xtraplatform.crs.domain.OgcCrs import de.ii.xtraplatform.features.domain.FeatureSchemaFixtures +import de.ii.xtraplatform.features.domain.ImmutableFeatureSchema import de.ii.xtraplatform.features.domain.MappingOperationResolver import de.ii.xtraplatform.features.domain.MappingRuleFixtures import de.ii.xtraplatform.features.json.app.DecoderFactoryJson @@ -27,9 +28,13 @@ import de.ii.xtraplatform.features.domain.SchemaBase import de.ii.xtraplatform.features.sql.domain.ImmutableQueryGeneratorSettings import de.ii.xtraplatform.features.sql.domain.ImmutableSchemaSql import de.ii.xtraplatform.features.sql.domain.ImmutableSqlPathDefaults +import de.ii.xtraplatform.features.sql.domain.ImmutableSqlQueryColumn +import de.ii.xtraplatform.features.sql.domain.ImmutableSqlQueryMapping +import de.ii.xtraplatform.features.sql.domain.ImmutableSqlQuerySchema import de.ii.xtraplatform.features.sql.domain.SqlDialectGpkg import de.ii.xtraplatform.features.sql.domain.SqlDialectPgis import de.ii.xtraplatform.features.sql.domain.SqlPathParser +import de.ii.xtraplatform.features.sql.domain.SqlQueryColumn import de.ii.xtraplatform.features.sql.domain.SqlQueryMapping import spock.lang.Shared import spock.lang.Specification @@ -203,4 +208,46 @@ class FilterEncoderSqlSpatialIndexSpec extends Specification { actual == "(A.\"fid\" IN (SELECT id FROM \"rtree_unfaelle_point_geom\" WHERE maxx >= 7.0 AND minx <= 7.1 AND maxy >= 50.0 AND miny <= 50.1) AND " + "ST_Intersects(A.geom, ST_GeomFromText('POLYGON((7.0 50.0,7.1 50.0,7.1 50.1,7.0 50.1,7.0 50.0))',4326)))" } + + def 'a geometry expression stays a geometry in a spatial predicate'() { + + given: 'a WKB-encoded geometry that is derived from an expression' + def geometry = new ImmutableSqlQueryColumn.Builder() + .name("geom") + .pathSegment("geom") + .type(SchemaBase.Type.GEOMETRY) + .role(SchemaBase.Role.PRIMARY_GEOMETRY) + .operations(Map.of( + SqlQueryColumn.Operation.CONNECTOR, ["EXPRESSION"] as String[], + SqlQueryColumn.Operation.EXPRESSION, ["ST_Force2D(\$T\$.geom)"] as String[], + SqlQueryColumn.Operation.WKB, [] as String[])) + .schemaIndex(0) + .build() + def table = new ImmutableSqlQuerySchema.Builder() + .name("airports") + .pathSegment("airports") + .addColumns(geometry) + .build() + def geometrySchema = new ImmutableFeatureSchema.Builder() + .name("geom") + .type(SchemaBase.Type.GEOMETRY) + .role(SchemaBase.Role.PRIMARY_GEOMETRY) + .build() + def mapping = new ImmutableSqlQueryMapping.Builder() + .addTables(table) + .putValueTables("geom", table) + .putValueColumns("geom", geometry) + .putValueSchemas("geom", geometrySchema) + .build() + def filterEncoder = encoder(new SqlDialectPgis(), Map.of()) + def filter = SIntersects.of(Property.of("geom"), + SpatialLiteral.of(Bbox.of(7.0, 50.0, 7.1, 50.1, OgcCrs.CRS84))) + + when: + String actual = filterEncoder.encode(filter, mapping) + + then: 'the output-only WKB wrapper is not passed to ST_Intersects' + actual == "ST_Intersects((ST_Force2D(A.geom)) , " + + "ST_GeomFromText('POLYGON((7.0 50.0,7.1 50.0,7.1 50.1,7.0 50.1,7.0 50.0))',4326))" + } } diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlQueryColumnOperationsSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlQueryColumnOperationsSpec.groovy new file mode 100644 index 000000000..41bd01520 --- /dev/null +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlQueryColumnOperationsSpec.groovy @@ -0,0 +1,127 @@ +/* + * 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.sql.app + +import de.ii.xtraplatform.features.domain.SchemaBase +import de.ii.xtraplatform.features.sql.domain.ImmutableSqlQueryColumn +import de.ii.xtraplatform.features.sql.domain.SqlDialectGpkg +import de.ii.xtraplatform.features.sql.domain.SqlDialectPgis +import de.ii.xtraplatform.features.sql.domain.SqlQueryColumn.Operation +import spock.lang.Specification + +class SqlQueryColumnOperationsSpec extends Specification { + + static def column(String name, SchemaBase.Type type, Map operations) { + return new ImmutableSqlQueryColumn.Builder() + .name(name) + .pathSegment(name) + .type(type) + .schemaIndex(0) + .operations(operations) + .build() + } + + static def geometryExpression(Operation encoding) { + return column("geom", SchemaBase.Type.GEOMETRY, Map.of( + Operation.EXPRESSION, ['ST_GeomFromText($T$.wkt)'] as String[], + encoding, [] as String[])) + } + + def 'a geometry expression is wrapped in the encoding the column carries'() { + + given: 'a sub-decoder expression column whose geometry is encoded as #encoding' + def col = geometryExpression(encoding) + + when: + String actual = SqlQueryColumnOperations.getQualifiedColumnResolved("A", col, dialect) + + then: + actual == expected + + where: + dialect | encoding || expected + new SqlDialectGpkg() | Operation.WKB || "(ST_AsBinary(ST_GeomFromText(A.wkt))) AS geom" + new SqlDialectGpkg() | Operation.WKT || "(ST_AsText(ST_GeomFromText(A.wkt))) AS geom" + new SqlDialectPgis() | Operation.WKB || "(ST_AsBinary(ST_GeomFromText(A.wkt))) AS geom" + new SqlDialectPgis() | Operation.WKT || "(ST_AsText(ST_GeomFromText(A.wkt))) AS geom" + } + + def 'a caller that excludes the geometry wrapping gets the raw expression'() { + + given: 'the spatial extent query wraps the raw geometry itself, so it excludes WKB and WKT' + def col = geometryExpression(Operation.WKB) + + when: + String actual = SqlQueryColumnOperations.getQualifiedColumnResolved( + "A", col, new SqlDialectGpkg(), Set.of(Operation.WKB, Operation.WKT), false) + + then: 'the expression is not wrapped, so it is not wrapped twice' + actual == "(ST_GeomFromText(A.wkt)) AS geom" + } + + def 'FORCE_POLYGON_CCW on the column reaches a geometry expression'() { + + given: 'a geometry expression column that also carries FORCE_POLYGON_CCW' + def col = column("geom", SchemaBase.Type.GEOMETRY, Map.of( + Operation.EXPRESSION, ['ST_GeomFromText($T$.wkt)'] as String[], + Operation.WKB, [] as String[], + Operation.FORCE_POLYGON_CCW, [] as String[])) + + when: + String actual = SqlQueryColumnOperations.getQualifiedColumnResolved("A", col, new SqlDialectGpkg()) + + then: 'the expression is wrapped the same way a plain geometry column would be' + actual == "(ST_AsBinary(ST_ForcePolygonCCW(ST_GeomFromText(A.wkt)))) AS geom" + } + + def 'a plain geometry column and a geometry expression agree on the flags'() { + + given: 'the same operations, once as a plain column and once via an expression' + def ops = Map.of(Operation.WKB, [] as String[], Operation.FORCE_POLYGON_CCW, [] as String[]) + def plain = column("geom", SchemaBase.Type.GEOMETRY, ops) + def viaExpression = column("geom", SchemaBase.Type.GEOMETRY, + ops + [(Operation.EXPRESSION): ['$T$.geom'] as String[]]) + def dialect = new SqlDialectGpkg() + + when: + String plainSql = SqlQueryColumnOperations.getQualifiedColumnResolved("A", plain, dialect) + String expressionSql = SqlQueryColumnOperations.getQualifiedColumnResolved("A", viaExpression, dialect) + + then: 'both force counter-clockwise polygons' + plainSql == "ST_AsBinary(ST_ForcePolygonCCW(A.geom))" + expressionSql == "(ST_AsBinary(ST_ForcePolygonCCW(A.geom))) AS geom" + } + + def 'forceLinearizeCurves from the caller reaches a geometry expression'() { + + given: + def col = column("geom", SchemaBase.Type.GEOMETRY, Map.of( + Operation.EXPRESSION, ['$T$.geom'] as String[], + Operation.WKB, [] as String[])) + + when: 'the caller asks for curves to be linearized' + String actual = SqlQueryColumnOperations.getQualifiedColumnResolved( + "A", col, new SqlDialectPgis(), Set.of(), true) + + then: 'the dialect that linearizes does so for the expression too' + actual == "(ST_AsBinary(ST_CurveToLine(A.geom,32,0,1))) AS geom" + } + + def 'a non-geometry expression is never wrapped'() { + + given: + def col = column("name", SchemaBase.Type.STRING, Map.of( + Operation.EXPRESSION, ['json_extract($T$.props, \'$.name\')'] as String[])) + + when: + String actual = SqlQueryColumnOperations.getQualifiedColumnResolved("A", col, new SqlDialectGpkg()) + + then: + actual == "(json_extract(A.props, '\$.name')) AS name" + } +} diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/domain/SqlDialectGpkgSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/domain/SqlDialectGpkgSpec.groovy index b694030aa..defd52a09 100644 --- a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/domain/SqlDialectGpkgSpec.groovy +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/domain/SqlDialectGpkgSpec.groovy @@ -19,6 +19,61 @@ class SqlDialectGpkgSpec extends Specification { dialect = new SqlDialectGpkg() } + def 'a sub-decoder expression without a geometry is passed through'() { + + given: + def paths = Map.of("0", 'json_extract($T$.props, \'$.name\')') + + when: + String actual = dialect.applyToExpression("A", "name", paths, Optional.empty(), false, false) + + then: 'the table placeholder is resolved and the expression is aliased' + actual == "(json_extract(A.props, '\$.name')) AS name" + } + + def 'a geometry expression is wrapped in the configured encoding'() { + + given: + def paths = Map.of("0", 'ST_GeomFromText($T$.wkt)') + + when: + String actual = dialect.applyToExpression("A", "geom", paths, Optional.of(operation), false, false) + + then: + actual == expected + + where: + operation || expected + SqlQueryColumn.Operation.WKB || "(ST_AsBinary(ST_GeomFromText(A.wkt))) AS geom" + SqlQueryColumn.Operation.WKT || "(ST_AsText(ST_GeomFromText(A.wkt))) AS geom" + } + + def 'forcePolygonCCW reaches the wrapping of a geometry expression'() { + + given: + def paths = Map.of("0", 'ST_GeomFromText($T$.wkt)') + + when: + String actual = dialect.applyToExpression("A", "geom", paths, Optional.of(operation), true, false) + + then: + actual == expected + + where: + operation || expected + SqlQueryColumn.Operation.WKB || "(ST_AsBinary(ST_ForcePolygonCCW(ST_GeomFromText(A.wkt)))) AS geom" + SqlQueryColumn.Operation.WKT || "(ST_AsText(ST_ForcePolygonCCW(ST_GeomFromText(A.wkt)))) AS geom" + } + + def 'without sub-decoder paths the column name is returned unchanged'() { + + when: + String actual = dialect.applyToExpression("A", "name", Map.of(), Optional.empty(), false, false) + + then: + actual == "name" + } + def 'the spatial index predicate names the r-tree and the key column'() { when: