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: