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
@@ -0,0 +1,86 @@
/*
* 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.cql.app;

import de.ii.xtraplatform.cql.domain.And;
import de.ii.xtraplatform.cql.domain.BooleanValue2;
import de.ii.xtraplatform.cql.domain.Cql2Expression;
import de.ii.xtraplatform.cql.domain.CqlNode;
import de.ii.xtraplatform.cql.domain.CqlVisitorBase;
import de.ii.xtraplatform.cql.domain.IsNull;
import de.ii.xtraplatform.cql.domain.Not;
import de.ii.xtraplatform.cql.domain.Or;
import de.ii.xtraplatform.cql.domain.Property;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

/**
* Rewrites a filter so that the given properties behave as if they were null in every row.
*
* <p>Predicates on such a property are resolved to a boolean literal following SQL semantics: `IS
* NULL` holds, every other predicate is unknown. A WHERE clause treats unknown like false, but a
* negation of unknown stays unknown, so negations are pushed down to the predicates before
* resolving them.
*/
public class CqlAbsentPropertiesMapper {

private final Set<String> absentProperties;

public CqlAbsentPropertiesMapper(Set<String> absentProperties) {
this.absentProperties = absentProperties;
}

public Cql2Expression map(Cql2Expression expression) {
return map(expression, false);
}

private Cql2Expression map(Cql2Expression expression, boolean negated) {
if (!referencesAbsentProperty(expression)) {
return negated ? Not.of(expression) : expression;
}
if (expression instanceof Not) {
return map(((Not) expression).getArgs().get(0), !negated);
}
if (expression instanceof And) {
List<Cql2Expression> args = mapArgs(((And) expression).getArgs(), negated);
return negated ? Or.of(args) : And.of(args);
}
if (expression instanceof Or) {
List<Cql2Expression> args = mapArgs(((Or) expression).getArgs(), negated);
return negated ? And.of(args) : Or.of(args);
}

return BooleanValue2.of(expression instanceof IsNull && !negated);
}

private List<Cql2Expression> mapArgs(List<Cql2Expression> args, boolean negated) {
return args.stream().map(arg -> map(arg, negated)).collect(Collectors.toUnmodifiableList());
}

private boolean referencesAbsentProperty(Cql2Expression expression) {
return expression.accept(new AbsentPropertyFinder(), true);
}

private class AbsentPropertyFinder extends CqlVisitorBase<Boolean> {

private boolean found;

@Override
public Boolean postProcess(CqlNode node, Boolean ignore) {
return found;
}

@Override
public Boolean visit(Property property, List<Boolean> children) {
String propertyName = property.getName().replaceAll("^\"|\"$", "");
found |= absentProperties.contains(propertyName);
return found;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,16 @@ public Cql2Expression mapNots(Cql2Expression cqlFilter) {
return (Cql2Expression) cqlFilter.accept(visitor, true);
}

@Override
public Cql2Expression mapAbsentProperties(
Cql2Expression cqlFilter, Set<String> absentProperties) {
if (absentProperties.isEmpty()) {
return cqlFilter;
}

return new CqlAbsentPropertiesMapper(absentProperties).map(cqlFilter);
}

static class IntervalConverter extends StdConverter<Interval, List<String>> {

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,6 @@ Cql2Expression mapTemporalOperators(
Cql2Expression mapEnvelopes(Cql2Expression cqlFilter, CrsInfo crsInfo);

Cql2Expression mapNots(Cql2Expression cqlFilter);

Cql2Expression mapAbsentProperties(Cql2Expression cqlFilter, Set<String> absentProperties);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* 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.cql.app

import de.ii.xtraplatform.cql.domain.And
import de.ii.xtraplatform.cql.domain.BooleanValue2
import de.ii.xtraplatform.cql.domain.Cql
import de.ii.xtraplatform.cql.domain.Cql2Expression
import de.ii.xtraplatform.cql.domain.Not
import de.ii.xtraplatform.cql.domain.Or
import spock.lang.Shared
import spock.lang.Specification

class CqlAbsentPropertiesMapperSpec extends Specification {

static final String DATETIME = "T_INTERSECTS(datetime, INTERVAL('2020-01-01T00:00:00Z','2021-01-01T00:00:00Z'))"
static final String NAME = "name = 'foo'"

static final Cql2Expression TRUE = BooleanValue2.of(true)
static final Cql2Expression FALSE = BooleanValue2.of(false)

@Shared
Cql cql

def setupSpec() {
cql = new CqlImpl()
}

Cql2Expression map(String filter) {
cql.mapAbsentProperties(read(filter), Set.of("datetime"))
}

Cql2Expression read(String filter) {
cql.read(filter, Cql.Format.TEXT)
}

def 'Filters without absent properties are unchanged'() {
expect:
map("NOT (${NAME})") == read("NOT (${NAME})")
}

def 'Predicates on an absent property do not match'() {
expect:
map(DATETIME) == FALSE
map("NOT ${DATETIME}") == FALSE
}

def 'IS NULL on an absent property matches'() {
expect:
map("datetime IS NULL") == TRUE
map("datetime IS NOT NULL") == FALSE
}

def 'Negations are pushed down to predicates on absent properties'() {
expect:
map("NOT (${DATETIME} AND ${NAME})") == Or.of(FALSE, Not.of(read(NAME)))
map("NOT (${DATETIME} OR ${NAME})") == And.of(FALSE, Not.of(read(NAME)))
map("NOT (NOT ${DATETIME} AND ${NAME})") == Or.of(FALSE, Not.of(read(NAME)))
}

def 'Other branches of a logical operation are kept'() {
expect:
map("${DATETIME} OR ${NAME}") == Or.of(FALSE, read(NAME))
map("datetime IS NULL AND ${NAME}") == And.of(TRUE, read(NAME))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.threeten.extra.Interval;
Expand Down Expand Up @@ -708,7 +709,7 @@ public String encode(Cql2Expression cqlFilter, SchemaSql schema) {
}

public String encode(Cql2Expression cqlFilter, SqlQueryMapping mapping) {
return prepareExpression(cqlFilter).accept(new CqlToSql2(mapping));
return prepareExpression(cqlFilter, mapping).accept(new CqlToSql2(mapping));
}

public String encode(String cqlFilter, SchemaSql schema) {
Expand All @@ -721,7 +722,7 @@ public String encodeNested(Cql2Expression cqlFilter, SchemaSql schema, boolean i

public String encodeNested(
Cql2Expression cqlFilter, SqlQueryMapping mapping, boolean isUserFilter) {
return prepareExpression(cqlFilter)
return prepareExpression(cqlFilter, mapping)
.accept(new CqlToSqlNested2(mapping, mapping.getMainTable().asTablePath(), isUserFilter));
}

Expand All @@ -730,10 +731,52 @@ private String encodeNested(
Cql2Expression cqlFilter,
List<? extends SqlQueryTable> tablePath,
boolean isUserFilter) {
return prepareExpression(cqlFilter)
return prepareExpression(cqlFilter, mapping)
.accept(new CqlToSqlNested2(mapping, tablePath, isUserFilter));
}

private CqlNode prepareExpression(Cql2Expression cqlFilter, SqlQueryMapping mapping) {
return prepareExpression(
cql.mapAbsentProperties(cqlFilter, getAbsentPrimaryTemporals(mapping)));
}

/**
* In a type with multiple main tables, the primary instant or interval may be missing in some of
* them. Filters treat it as null in those tables.
*/
private static Set<String> getAbsentPrimaryTemporals(SqlQueryMapping mapping) {
if (Objects.isNull(mapping)
|| Objects.isNull(mapping.getMainSchema())
|| mapping.getMainSchema().getConcat().isEmpty()) {
return Set.of();
}
FeatureSchema type = mapping.getMainSchema();

return Stream.of(
absentProperty(
mapping.getColumnForPrimaryInstant(), type.getPrimaryInstants().stream()),
absentProperty(
mapping.getColumnForPrimaryIntervalStart(),
type.getPrimaryIntervals().stream().map(Tuple::first)),
absentProperty(
mapping.getColumnForPrimaryIntervalEnd(),
type.getPrimaryIntervals().stream().map(Tuple::second)))
.flatMap(Optional::stream)
.collect(Collectors.toUnmodifiableSet());
}

private static Optional<String> absentProperty(
Optional<?> column, Stream<FeatureSchema> propertiesInOtherTables) {
if (column.isPresent()) {
return Optional.empty();
}

return propertiesInOtherTables
.filter(Objects::nonNull)
.findFirst()
.map(FeatureSchema::getFullPathAsString);
}

private CqlNode prepareExpression(Cql2Expression cqlFilter) {
return cql.mapNots(
cql.mapTemporalOperators(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import de.ii.xtraplatform.base.domain.util.Tuple;
import de.ii.xtraplatform.features.domain.FeatureSchema;
import de.ii.xtraplatform.features.domain.MappingOperationResolver;
import de.ii.xtraplatform.features.domain.MappingRule;
import de.ii.xtraplatform.features.domain.SchemaBase.Role;
import java.util.List;
Expand Down Expand Up @@ -54,8 +55,7 @@ default SqlQuerySchema getMainTable() {
Map<String, FeatureSchema> getValueSchemas();

default Optional<SqlQuerySchema> getTableForObject(String propertyName) {
// System.out.println("getObjectTables: " + propertyName);
return Optional.ofNullable(getObjectTables().get(propertyName));
return findProperty(getObjectTables(), propertyName);
}

default Optional<SqlQuerySchema> getTableForValue(String propertyName) {
Expand All @@ -64,10 +64,10 @@ default Optional<SqlQuerySchema> getTableForValue(String propertyName) {

default Optional<SqlQuerySchema> getTableForValue(String propertyName, MappingRule.Scope scope) {
if (scope == MappingRule.Scope.W) {
return Optional.ofNullable(getWritableTables().get(propertyName));
return findProperty(getWritableTables(), propertyName);
}

return Optional.ofNullable(getValueTables().get(propertyName));
return findProperty(getValueTables(), propertyName);
}

default Optional<Tuple<SqlQuerySchema, SqlQueryColumn>> getColumnForValue(String propertyName) {
Expand All @@ -83,10 +83,10 @@ default Optional<Tuple<SqlQuerySchema, SqlQueryColumn>> getColumnForValue(
return getTableForValue(propertyName, scope)
.flatMap(
table ->
Optional.ofNullable(
(scope == MappingRule.Scope.W ? getWritableColumns() : getValueColumns())
.get(propertyName))
.map(index -> Tuple.of(table, index)));
findProperty(
scope == MappingRule.Scope.W ? getWritableColumns() : getValueColumns(),
propertyName)
.map(column -> Tuple.of(table, column)));
}

default Optional<Tuple<SqlQuerySchema, SqlQueryColumn>> getColumnForId() {
Expand Down Expand Up @@ -180,15 +180,36 @@ default Optional<FeatureSchema> getSchemaForRole(Role role) {
}

default Optional<FeatureSchema> getSchemaForObject(String propertyName) {
return Optional.ofNullable(getObjectSchemas().get(propertyName));
return findProperty(getObjectSchemas(), propertyName);
}

default Optional<FeatureSchema> getSchemaForValue(String propertyName) {
if (Objects.equals(propertyName, ID_PLACEHOLDER)) {
return getMainSchema().getIdProperty();
}

return Optional.ofNullable(getValueSchemas().get(propertyName));
return findProperty(getValueSchemas(), propertyName);
}

/**
* In a type with multiple main tables, the properties of each table are keyed with the index of
* the table as prefix, e.g. `0_name`, while filters and sort keys use the plain name.
*/
private <T> Optional<T> findProperty(Map<String, T> properties, String propertyName) {
if (properties.containsKey(propertyName)
|| Objects.isNull(getMainSchema())
|| getMainSchema().getConcat().isEmpty()) {
return Optional.ofNullable(properties.get(propertyName));
}

return properties.entrySet().stream()
.filter(
property ->
MappingOperationResolver.isConcatPath(property.getKey())
&& Objects.equals(
MappingOperationResolver.cleanConcatPath(property.getKey()), propertyName))
.map(Map.Entry::getValue)
.findFirst();
}

String IN_CONNECTED_ARRAY = "IN_CONNECTED_ARRAY";
Expand Down
Loading
Loading