Skip to content
Open
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 @@ -14,6 +14,8 @@
import static de.ii.xtraplatform.cql.domain.In.ID_PLACEHOLDER;
import static de.ii.xtraplatform.features.domain.SchemaBase.Type.DATE;
import static de.ii.xtraplatform.features.domain.SchemaBase.Type.DATETIME;
import static de.ii.xtraplatform.features.domain.SchemaBase.Type.FEATURE_REF_ARRAY;
import static de.ii.xtraplatform.features.domain.SchemaBase.Type.VALUE_ARRAY;

import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
Expand Down Expand Up @@ -2289,6 +2291,11 @@ public String visit(de.ii.xtraplatform.cql.domain.Accenti accenti, List<String>

@Override
public String visit(de.ii.xtraplatform.cql.domain.Function function, List<String> children) {
Optional<String> jsonArrayLike = encodeLikeInJsonArray(function, children);
if (jsonArrayLike.isPresent()) {
return jsonArrayLike.get();
}

Optional<String> customExpression = renderCustomFunction(function, children);
if (customExpression.isPresent()) {
return customExpression.get();
Expand All @@ -2297,6 +2304,43 @@ public String visit(de.ii.xtraplatform.cql.domain.Function function, List<String
return super.visit(function, children);
}

// the ALIKE template matches the pattern against the column value, which for an array in a
// JSON document is the JSON text of the whole array; match each array item instead
private Optional<String> encodeLikeInJsonArray(
de.ii.xtraplatform.cql.domain.Function function, List<String> children) {
if (!"ALIKE".equalsIgnoreCase(function.getName())
|| children.size() != 2
|| !(function.getArgs().get(0) instanceof Property)
|| !operandHasSelect(children.get(0))) {
return Optional.empty();
}

String propertyName =
((Property) function.getArgs().get(0)).getName().replaceAll("^\"|\"$", "");
SqlQueryColumn column =
getTableColumn(propertyName, false, !propertyName.contains(".")).second();
if (Objects.isNull(column)
|| column.getOperationParameter(Operation.CONNECTOR).filter("JSON"::equals).isEmpty()) {
return Optional.empty();
}

boolean isArray =
mapping
.getSchemaForValue(propertyName)
.filter(
schema ->
schema.getType() == VALUE_ARRAY
|| schema.getType() == FEATURE_REF_ARRAY
|| mapping.isInConnectedArray(schema))
.isPresent();
if (!isArray) {
return Optional.empty();
}

return Optional.of(
sqlDialect.applyToJsonArrayLike(children.get(0), reduceSelectToColumn(children.get(1))));
}

private String reduceToColumn(String expression) {
return expression.substring(expression.indexOf("%1$s") + 4, expression.indexOf("%2$s"));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ default String applyToJsonArrayOp(
"Arrays as queryables are not supported for this feature provider.");
}

/**
* @param mainExpression the filter expression for the array in the JSON document, with the
* placeholders {@code %1$s} and {@code %2$s} around the array value
* @param pattern the LIKE pattern
* @return a predicate that is true, if one of the array items matches the pattern
*/
default String applyToJsonArrayLike(String mainExpression, String pattern) {
throw new IllegalArgumentException(
"Arrays as queryables are not supported for this feature provider.");
}

default String applyToAsIds() {
return " AS IDS";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,14 @@ public String applyToJsonArrayOp(
throw new IllegalStateException("unexpected array operator: " + op);
}

@Override
public String applyToJsonArrayLike(String mainExpression, String pattern) {
return String.format(
mainExpression,
"EXISTS (SELECT 1 FROM jsonb_array_elements_text((",
String.format(")::jsonb) AS alike_item WHERE alike_item LIKE %s)", pattern));
}

@Override
public String escapeString(String value) {
return value.replaceAll("'", "''");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/*
* 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.cql.app.CqlImpl
import de.ii.xtraplatform.cql.domain.Function
import de.ii.xtraplatform.cql.domain.Not
import de.ii.xtraplatform.cql.domain.Property
import de.ii.xtraplatform.cql.domain.ScalarLiteral
import de.ii.xtraplatform.crs.domain.OgcCrs
import de.ii.xtraplatform.features.domain.FeatureSchema
import de.ii.xtraplatform.features.domain.ImmutableFeatureSchema
import de.ii.xtraplatform.features.domain.MappingOperationResolver
import de.ii.xtraplatform.features.domain.MappingRulesDeriver
import de.ii.xtraplatform.features.domain.SchemaBase
import de.ii.xtraplatform.features.domain.TypesResolver
import de.ii.xtraplatform.features.domain.transform.ImplicitMappingResolver
import de.ii.xtraplatform.features.json.app.DecoderFactoryJson
import de.ii.xtraplatform.features.sql.domain.ImmutableQueryGeneratorSettings
import de.ii.xtraplatform.features.sql.domain.ImmutableSqlPathDefaults
import de.ii.xtraplatform.features.sql.domain.SqlDialectPgis
import de.ii.xtraplatform.features.sql.domain.SqlPathParser
import de.ii.xtraplatform.features.sql.domain.SqlQueryMapping
import spock.lang.Shared
import spock.lang.Specification

/**
* ALIKE matches a LIKE pattern against the items of an array. For an array in a JSON document the
* pattern must be matched against each item, not against the JSON text of the whole array.
*/
class FilterEncoderSqlJsonArrayLikeSpec extends Specification {

@Shared
SqlQueryMapping mapping

@Shared
FilterEncoderSql filterEncoder

def setupSpec() {
def defaults = new ImmutableSqlPathDefaults.Builder().primaryKey("pk").sortKey("pk").build()
def cql = new CqlImpl()
def pathParser = new SqlPathParser(defaults, cql,
Map.of("JSON", new DecoderFactoryJson(), "EXPRESSION", new DecoderFactorySqlExpression()))
def mappingDeriver = new SqlMappingDeriver(pathParser, new ImmutableQueryGeneratorSettings.Builder().build())

def resolved = resolve(featureType())
mapping = mappingDeriver.derive(resolved.accept(new MappingRulesDeriver()), resolved).get(0)
filterEncoder = new FilterEncoderSql(OgcCrs.CRS84, new SqlDialectPgis(), null, null, cql, null)
}

static FeatureSchema featureType() {
return new ImmutableFeatureSchema.Builder()
.name("WP_Grossverbraucher")
.type(SchemaBase.Type.OBJECT)
.sourcePath("/coretable{filter=featuretype='WP_Grossverbraucher'}")
.putProperties2("oid", new ImmutableFeatureSchema.Builder()
.type(SchemaBase.Type.STRING)
.sourcePath("id")
.role(SchemaBase.Role.ID))
.putProperties2("name", new ImmutableFeatureSchema.Builder()
.type(SchemaBase.Type.STRING)
.sourcePath("[JSON]properties/name"))
.putProperties2("versorgungsmedium", new ImmutableFeatureSchema.Builder()
.type(SchemaBase.Type.VALUE_ARRAY)
.valueType(SchemaBase.Type.STRING)
.sourcePath("[JSON]properties/versorgungsmedium"))
.putProperties2("waermeErzeuger", new ImmutableFeatureSchema.Builder()
.type(SchemaBase.Type.OBJECT_ARRAY)
.sourcePath("[JSON]properties/waermeErzeuger")
.putProperties2("art", new ImmutableFeatureSchema.Builder()
.type(SchemaBase.Type.STRING)
.sourcePath("art")))
.putProperties2("kennung", new ImmutableFeatureSchema.Builder()
.type(SchemaBase.Type.VALUE_ARRAY)
.valueType(SchemaBase.Type.STRING)
.sourcePath("[pk=coretable_pk]kennungen/kennung"))
.build()
}

static FeatureSchema resolve(FeatureSchema type) {
Map<String, FeatureSchema> types = Map.of(type.getName(), type)
List<TypesResolver> resolvers = List.of(
new MappingOperationResolver(true),
new ImplicitMappingResolver(),
new MappingOperationResolver())

for (TypesResolver resolver : resolvers) {
int rounds = 0
while (resolver.needsResolving(types) && rounds < resolver.maxRounds()) {
types = resolver.resolve(types)
rounds++
}
}

return types.get(type.getName())
}

static Function alike(String property, String pattern) {
return Function.of("ALIKE", [Property.of(property), ScalarLiteral.of(pattern)])
}

def 'ALIKE on a value array in a JSON document matches each array item'() {

when:
def actual = filterEncoder.encode(alike("versorgungsmedium", "10%"), mapping)

then:
actual == "EXISTS (SELECT 1 FROM jsonb_array_elements_text(((A.properties -> 'versorgungsmedium'))::jsonb)" +
" AS alike_item WHERE alike_item LIKE '10%')"
}

def 'ALIKE on a value in an array of objects in a JSON document matches each array item'() {

when:
def actual = filterEncoder.encode(alike("waermeErzeuger.art", "10%"), mapping)

then:
actual == "EXISTS (SELECT 1 FROM jsonb_array_elements_text((jsonb_path_query_array(A.properties::jsonb,'\$.waermeErzeuger.art'))::jsonb)" +
" AS alike_item WHERE alike_item LIKE '10%')"
}

def 'NOT ALIKE on a value array in a JSON document negates the item match'() {

when:
def actual = filterEncoder.encode(Not.of(alike("versorgungsmedium", "10%")), mapping)

then:
actual == "NOT (EXISTS (SELECT 1 FROM jsonb_array_elements_text(((A.properties -> 'versorgungsmedium'))::jsonb)" +
" AS alike_item WHERE alike_item LIKE '10%'))"
}

def 'ALIKE on a value array in a joined table matches the joined rows'() {

when:
def actual = filterEncoder.encode(alike("kennung", "10%"), mapping)

then:
actual == "A.pk IN (SELECT AA.pk FROM coretable AA JOIN kennungen AB ON (AA.pk=AB.coretable_pk)" +
" WHERE AB.kennung::varchar LIKE '10%')"
}

def 'ALIKE on a single value in a JSON document matches the value'() {

when:
def actual = filterEncoder.encode(alike("name", "10%"), mapping)

then:
actual == "(A.properties ->> 'name')::varchar::varchar LIKE '10%'"
}
}
Loading