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 @@ -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(), "");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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};
Expand All @@ -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)) {
Expand All @@ -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.
*
* <p>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<Operation> geometryOperation(
Map<Operation, String[]> ops, Set<Operation> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,40 @@ default Set<TemporalFunction> 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<String, String> subDecoderPaths, boolean spatial) {
return name;
String table,
String name,
Map<String, String> subDecoderPaths,
Optional<SqlQueryColumn.Operation> 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<SpatialFunction, String> SPATIAL_OPERATORS =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,13 @@
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;
import java.time.ZoneId;
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;
Expand All @@ -31,8 +29,6 @@ public String getId() {
return SqlDbmsAdapterGpkg.ID;
}

private QueryGeneratorSettings settings;

private static final Splitter BBOX_SPLITTER =
Splitter.onPattern("[(), ]").omitEmptyStrings().trimResults();

Expand Down Expand Up @@ -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<String, String> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -349,18 +349,4 @@ public String applyToJsonArrayOp(
public String escapeString(String value) {
return value.replaceAll("'", "''");
}

@Override
public String applyToExpression(
String table, String name, Map<String, String> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,21 @@ 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
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
Expand Down Expand Up @@ -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))"
}
}
Original file line number Diff line number Diff line change
@@ -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<Operation, String[]> 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"
}
}
Loading
Loading