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 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)