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 498a52647..a8151ed74 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 @@ -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; @@ -2289,6 +2291,11 @@ public String visit(de.ii.xtraplatform.cql.domain.Accenti accenti, List @Override public String visit(de.ii.xtraplatform.cql.domain.Function function, List children) { + Optional jsonArrayLike = encodeLikeInJsonArray(function, children); + if (jsonArrayLike.isPresent()) { + return jsonArrayLike.get(); + } + Optional customExpression = renderCustomFunction(function, children); if (customExpression.isPresent()) { return customExpression.get(); @@ -2297,6 +2304,43 @@ public String visit(de.ii.xtraplatform.cql.domain.Function function, List encodeLikeInJsonArray( + de.ii.xtraplatform.cql.domain.Function function, List 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")); } 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 addbfaa60..0c9790849 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 @@ -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"; } 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 bdc7fb4bc..8c8d5515c 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 @@ -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("'", "''"); diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlJsonArrayLikeSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlJsonArrayLikeSpec.groovy new file mode 100644 index 000000000..0103948a4 --- /dev/null +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlJsonArrayLikeSpec.groovy @@ -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 types = Map.of(type.getName(), type) + List 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%'" + } +}