diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlAbsentPropertiesMapper.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlAbsentPropertiesMapper.java
new file mode 100644
index 000000000..cd2870710
--- /dev/null
+++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlAbsentPropertiesMapper.java
@@ -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.
+ *
+ *
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 absentProperties;
+
+ public CqlAbsentPropertiesMapper(Set 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 args = mapArgs(((And) expression).getArgs(), negated);
+ return negated ? Or.of(args) : And.of(args);
+ }
+ if (expression instanceof Or) {
+ List args = mapArgs(((Or) expression).getArgs(), negated);
+ return negated ? And.of(args) : Or.of(args);
+ }
+
+ return BooleanValue2.of(expression instanceof IsNull && !negated);
+ }
+
+ private List mapArgs(List 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 {
+
+ private boolean found;
+
+ @Override
+ public Boolean postProcess(CqlNode node, Boolean ignore) {
+ return found;
+ }
+
+ @Override
+ public Boolean visit(Property property, List children) {
+ String propertyName = property.getName().replaceAll("^\"|\"$", "");
+ found |= absentProperties.contains(propertyName);
+ return found;
+ }
+ }
+}
diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlImpl.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlImpl.java
index b67aa056e..8e3e61278 100644
--- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlImpl.java
+++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlImpl.java
@@ -224,6 +224,16 @@ public Cql2Expression mapNots(Cql2Expression cqlFilter) {
return (Cql2Expression) cqlFilter.accept(visitor, true);
}
+ @Override
+ public Cql2Expression mapAbsentProperties(
+ Cql2Expression cqlFilter, Set absentProperties) {
+ if (absentProperties.isEmpty()) {
+ return cqlFilter;
+ }
+
+ return new CqlAbsentPropertiesMapper(absentProperties).map(cqlFilter);
+ }
+
static class IntervalConverter extends StdConverter> {
@Override
diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/Cql.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/Cql.java
index 9b75fa80d..0184badc7 100644
--- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/Cql.java
+++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/Cql.java
@@ -57,4 +57,6 @@ Cql2Expression mapTemporalOperators(
Cql2Expression mapEnvelopes(Cql2Expression cqlFilter, CrsInfo crsInfo);
Cql2Expression mapNots(Cql2Expression cqlFilter);
+
+ Cql2Expression mapAbsentProperties(Cql2Expression cqlFilter, Set absentProperties);
}
diff --git a/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlAbsentPropertiesMapperSpec.groovy b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlAbsentPropertiesMapperSpec.groovy
new file mode 100644
index 000000000..22dca1ec0
--- /dev/null
+++ b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlAbsentPropertiesMapperSpec.groovy
@@ -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))
+ }
+}
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 679129f41..4a800eb3a 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
@@ -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;
@@ -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) {
@@ -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));
}
@@ -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 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 absentProperty(
+ Optional> column, Stream 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(
diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQueryMapping.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQueryMapping.java
index 55cde094f..00b338818 100644
--- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQueryMapping.java
+++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQueryMapping.java
@@ -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;
@@ -54,8 +55,7 @@ default SqlQuerySchema getMainTable() {
Map getValueSchemas();
default Optional getTableForObject(String propertyName) {
- // System.out.println("getObjectTables: " + propertyName);
- return Optional.ofNullable(getObjectTables().get(propertyName));
+ return findProperty(getObjectTables(), propertyName);
}
default Optional getTableForValue(String propertyName) {
@@ -64,10 +64,10 @@ default Optional getTableForValue(String propertyName) {
default Optional 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> getColumnForValue(String propertyName) {
@@ -83,10 +83,10 @@ default Optional> 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> getColumnForId() {
@@ -180,7 +180,7 @@ default Optional getSchemaForRole(Role role) {
}
default Optional getSchemaForObject(String propertyName) {
- return Optional.ofNullable(getObjectSchemas().get(propertyName));
+ return findProperty(getObjectSchemas(), propertyName);
}
default Optional getSchemaForValue(String propertyName) {
@@ -188,7 +188,28 @@ default Optional getSchemaForValue(String propertyName) {
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 Optional findProperty(Map 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";
diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlColumnNameSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlColumnNameSpec.groovy
new file mode 100644
index 000000000..c60c0a10e
--- /dev/null
+++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlColumnNameSpec.groovy
@@ -0,0 +1,130 @@
+/*
+ * 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.Cql
+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.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
+
+/**
+ * A filter on a property must use the column of its source path, which may differ from the
+ * property name. For a type with multiple main tables, each table may use a different column.
+ */
+class FilterEncoderSqlColumnNameSpec extends Specification {
+
+ @Shared
+ Cql cql
+
+ @Shared
+ SqlMappingDeriver mappingDeriver
+
+ @Shared
+ FilterEncoderSql filterEncoder
+
+ def setupSpec() {
+ def defaults = new ImmutableSqlPathDefaults.Builder().primaryKey("id").sortKey("id").build()
+ cql = new CqlImpl()
+ def pathParser = new SqlPathParser(defaults, cql, Map.of())
+ mappingDeriver = new SqlMappingDeriver(pathParser, new ImmutableQueryGeneratorSettings.Builder().build())
+ filterEncoder = new FilterEncoderSql(OgcCrs.CRS84, new SqlDialectPgis(), null, null, cql, null)
+ }
+
+ static ImmutableFeatureSchema.Builder table(String name, String datetimeColumn, String labelColumn) {
+ return new ImmutableFeatureSchema.Builder()
+ .name(name)
+ .sourcePath("/" + name)
+ .putProperties2("id", new ImmutableFeatureSchema.Builder()
+ .sourcePath("id")
+ .type(SchemaBase.Type.INTEGER)
+ .role(SchemaBase.Role.ID))
+ .putProperties2("datetime", new ImmutableFeatureSchema.Builder()
+ .sourcePath(datetimeColumn)
+ .type(SchemaBase.Type.DATETIME)
+ .role(SchemaBase.Role.PRIMARY_INSTANT))
+ .putProperties2("label", new ImmutableFeatureSchema.Builder()
+ .sourcePath(labelColumn)
+ .type(SchemaBase.Type.STRING))
+ }
+
+ static FeatureSchema singleTable() {
+ return table("single", "ts", "name").type(SchemaBase.Type.OBJECT).build()
+ }
+
+ static FeatureSchema concat() {
+ return new ImmutableFeatureSchema.Builder()
+ .name("test")
+ .type(SchemaBase.Type.OBJECT)
+ .concat([
+ table("first", "ts", "name").build(),
+ table("second", "created", "title").build()])
+ .build()
+ }
+
+ List mappings(FeatureSchema type) {
+ def resolved = resolve(type)
+ return mappingDeriver.derive(resolved.accept(new MappingRulesDeriver()), resolved)
+ }
+
+ 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())
+ }
+
+ String encode(String filter, SqlQueryMapping mapping) {
+ filterEncoder.encode(cql.read(filter, Cql.Format.TEXT), mapping)
+ }
+
+ def 'A filter uses the column of the property in a single main table'() {
+ expect:
+ encode(filter, mappings(singleTable()).get(0)) == expected
+
+ where:
+ filter | expected
+ "datetime > TIMESTAMP('2020-01-01T00:00:00Z')" | "A.ts::timestamp(0) > TIMESTAMP '2020-01-01T00:00:00Z'"
+ "label = 'foo'" | "A.name = 'foo'"
+ }
+
+ def 'A filter uses the column of the property in each of multiple main tables'() {
+ expect:
+ encode(filter, mappings(concat()).get(table)) == expected
+
+ where:
+ filter | table | expected
+ "datetime > TIMESTAMP('2020-01-01T00:00:00Z')" | 0 | "A.ts::timestamp(0) > TIMESTAMP '2020-01-01T00:00:00Z'"
+ "datetime > TIMESTAMP('2020-01-01T00:00:00Z')" | 1 | "A.created::timestamp(0) > TIMESTAMP '2020-01-01T00:00:00Z'"
+ "label = 'foo'" | 0 | "A.name = 'foo'"
+ "label = 'foo'" | 1 | "A.title = 'foo'"
+ }
+}
diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlConcatPrimaryInstantSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlConcatPrimaryInstantSpec.groovy
new file mode 100644
index 000000000..90558304a
--- /dev/null
+++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlConcatPrimaryInstantSpec.groovy
@@ -0,0 +1,126 @@
+/*
+ * 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.Cql
+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.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
+
+/**
+ * In a type with multiple main tables, only some of them may have the primary instant. Filters
+ * treat it as null in the others.
+ */
+class FilterEncoderSqlConcatPrimaryInstantSpec extends Specification {
+
+ @Shared
+ Cql cql
+
+ @Shared
+ List mappings
+
+ @Shared
+ FilterEncoderSql filterEncoder
+
+ def setupSpec() {
+ def defaults = new ImmutableSqlPathDefaults.Builder().primaryKey("id").sortKey("id").build()
+ cql = new CqlImpl()
+ def pathParser = new SqlPathParser(defaults, cql, Map.of())
+ def mappingDeriver = new SqlMappingDeriver(pathParser, new ImmutableQueryGeneratorSettings.Builder().build())
+
+ def resolved = resolve(featureType())
+ mappings = mappingDeriver.derive(resolved.accept(new MappingRulesDeriver()), resolved)
+ filterEncoder = new FilterEncoderSql(OgcCrs.CRS84, new SqlDialectPgis(), null, null, cql, null)
+ }
+
+ static FeatureSchema featureType() {
+ def withInstant = new ImmutableFeatureSchema.Builder()
+ .name("with_instant")
+ .sourcePath("/with_instant")
+ .putProperties2("id", new ImmutableFeatureSchema.Builder()
+ .sourcePath("id")
+ .type(SchemaBase.Type.INTEGER)
+ .role(SchemaBase.Role.ID))
+ .putProperties2("datetime", new ImmutableFeatureSchema.Builder()
+ .sourcePath("datetime")
+ .type(SchemaBase.Type.DATETIME)
+ .role(SchemaBase.Role.PRIMARY_INSTANT))
+ .build()
+ def withoutInstant = new ImmutableFeatureSchema.Builder()
+ .name("without_instant")
+ .sourcePath("/without_instant")
+ .putProperties2("id", new ImmutableFeatureSchema.Builder()
+ .sourcePath("id")
+ .type(SchemaBase.Type.INTEGER)
+ .role(SchemaBase.Role.ID))
+ .build()
+
+ return new ImmutableFeatureSchema.Builder()
+ .name("test")
+ .type(SchemaBase.Type.OBJECT)
+ .concat([withInstant, withoutInstant])
+ .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())
+ }
+
+ String encode(String filter, int member) {
+ filterEncoder.encode(cql.read(filter, Cql.Format.TEXT), mappings.get(member))
+ }
+
+ def 'Each main table gets its own mapping'() {
+ expect:
+ mappings.size() == 2
+ }
+
+ def 'A datetime filter applies to the main table that has the primary instant'() {
+ expect:
+ encode("datetime > TIMESTAMP('2020-01-01T00:00:00Z')", 0) == "A.datetime::timestamp(0) > TIMESTAMP '2020-01-01T00:00:00Z'"
+ }
+
+ def 'A datetime filter matches nothing in the main table without the primary instant'() {
+ expect:
+ encode(filter, 1) == expected
+
+ where:
+ filter | expected
+ "datetime > TIMESTAMP('2020-01-01T00:00:00Z')" | "1=0"
+ "NOT (datetime > TIMESTAMP('2020-01-01T00:00:00Z'))" | "1=0"
+ "datetime IS NULL" | "1=1"
+ "datetime > TIMESTAMP('2020-01-01T00:00:00Z') OR id = 1" | "(1=0 OR A.id = 1)"
+ }
+}
diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchema.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchema.java
index b3d79d298..691c5b3a2 100644
--- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchema.java
+++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchema.java
@@ -905,12 +905,6 @@ default void concatConstraints() {
getPrimaryInstant()
.ifPresent(
first -> {
- Preconditions.checkState(
- getPrimaryInstants().size() == getConcat().size(),
- "The number of primary instants must match the number of concatenated objects, but found only %s properties for %s concatenated objects in type '%s'.",
- getPrimaryInstants().size(),
- getConcat().size(),
- getName());
Preconditions.checkState(
getPrimaryInstants().stream()
.map(FeatureSchema::getFullPathAsString)
@@ -938,12 +932,6 @@ default void concatConstraints() {
getPrimaryInterval()
.ifPresent(
first -> {
- Preconditions.checkState(
- getPrimaryIntervals().size() == getConcat().size(),
- "The number of primary intervals must match the number of concatenated objects, but found only %s properties for %s concatenated objects in type '%s'.",
- getPrimaryIntervals().size(),
- getConcat().size(),
- getName());
Preconditions.checkState(
getPrimaryIntervals().stream()
.map(Tuple::first)