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
Expand Up @@ -163,6 +163,10 @@ private void addToMapping(
schemas.add(querySchema);
mapping.addTables(querySchema);

// the schema node that this table is mapped from; a sub-decoder column of the table holds the
// values of the properties of that node, which is the root schema only for the main table
FeatureSchema tableOwner = includeSchema ? getTableOwner(schema, tableRule) : null;

if (!columnRules.isEmpty() || !writableColumnRules.isEmpty()) {
previous.add(pathParser.parseTablePath(tableRule.getSource()).getFullPath());
}
Expand All @@ -189,6 +193,7 @@ private void addToMapping(

addToMapping(
schema,
tableOwner,
mapping,
seenProperties,
seenWritableProperties,
Expand All @@ -208,6 +213,7 @@ private void addToMapping(

addToMapping(
schema,
tableOwner,
mapping,
seenProperties,
seenWritableProperties,
Expand All @@ -231,6 +237,7 @@ private void addToMapping(

addToMapping(
schema,
tableOwner,
mapping,
seenProperties,
seenWritableProperties,
Expand All @@ -244,6 +251,7 @@ private void addToMapping(

private void addToMapping(
FeatureSchema schema,
FeatureSchema tableOwner,
ImmutableSqlQueryMapping.Builder mapping,
List<String> seenProperties,
List<String> seenWritableProperties,
Expand All @@ -255,12 +263,12 @@ private void addToMapping(
if ("$".equals(column.getTarget())) {
if (column1.hasOperation(SqlQueryColumn.Operation.CONNECTOR)) {
List<FeatureSchema> connectedSchemas =
includeSchema
? getConnectedSchemas(schema, column1.getPathSegment(), "", false)
includeSchema && Objects.nonNull(tableOwner)
? getConnectedSchemas(tableOwner, column1.getPathSegment(), "", false)
: List.of();

for (FeatureSchema p : connectedSchemas) {
if (isWritable) {
if (isWritable && !seenWritableProperties.contains(p.getFullPathAsString())) {
mapping.putWritableTables(p.getFullPathAsString(), querySchema);
mapping.putWritableColumns(p.getFullPathAsString(), column1);
seenWritableProperties.add(p.getFullPathAsString());
Expand Down Expand Up @@ -289,7 +297,7 @@ private void addToMapping(

target = applyRename(column.getTarget(), propertySchema);

if (!seenProperties.contains(target)) {
if (!seenProperties.contains(target) && propertySchema.isValue()) {
mapping.putValueSchemas(target, propertySchema);
}

Expand All @@ -309,14 +317,29 @@ private void addToMapping(
mapping.putWritableColumns(target, column1);
seenWritableProperties.add(target);
}
if (!seenProperties.contains(target)) {
// only values can be filtered or sorted on; an object (e.g. a feature reference) must never
// claim a value target, otherwise it shadows the value that legitimately owns that target -
// the queryable of a feature reference, which is filtered against the id of the reference
if (!seenProperties.contains(target)
&& (Objects.isNull(propertySchema) || propertySchema.isValue())) {
mapping.putValueTables(target, querySchema);
mapping.putValueColumns(target, column1);
seenProperties.add(target);
}
}
}

private static FeatureSchema getTableOwner(FeatureSchema schema, MappingRule tableRule) {
if (Objects.equals(tableRule.getTarget(), ROOT_TARGET)) {
return schema;
}

return schema.getAllNestedProperties().stream()
.filter(property -> Objects.equals(property.getFullPathAsString(), tableRule.getTarget()))
.findFirst()
.orElse(schema);
}

private static String applyRename(String target, FeatureSchema schema) {
if (Objects.nonNull(schema)) {
Optional<String> rename =
Expand Down Expand Up @@ -361,14 +384,16 @@ private List<FeatureSchema> getConnectedSchemas(
? path.replace(connector + "/", "").replace(connector, "")
: pathInConnector + "." + path;

// always record the path, never rely on the name fallback in
// SqlQueryMapping.getPathInConnector: the property name is only the same as the path
// in the connector by convention (e.g. a feature reference title mapped to
// "[JSON]properties/name" is named "title")
FeatureSchema schema =
!inArray && pathInConnector.isEmpty()
? p
: new ImmutableFeatureSchema.Builder()
.from(p)
.putAdditionalInfo(IN_CONNECTED_ARRAY, String.valueOf(inArray))
.putAdditionalInfo(PATH_IN_CONNECTOR, newPathInConnector)
.build();
new ImmutableFeatureSchema.Builder()
.from(p)
.putAdditionalInfo(IN_CONNECTED_ARRAY, String.valueOf(inArray))
.putAdditionalInfo(PATH_IN_CONNECTOR, newPathInConnector)
.build();

if (p.isValue()) {
return Stream.of(schema);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/*
* 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.Eq
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.ConstantsResolver
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.DefaultRolesResolver
import de.ii.xtraplatform.features.domain.transform.FeatureRefResolver
import de.ii.xtraplatform.features.domain.transform.ImplicitMappingResolver
import de.ii.xtraplatform.features.domain.transform.LabelTemplateResolver
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

/**
* A queryable for a feature reference must always be filtered against the id property of the
* reference, no matter how the other properties of the reference are mapped.
*/
class FeatureRefQueryableSpec extends Specification {

static final String REF_PATH = "[id=related_id]refs{filter=rel_inv='gehoertZuPlan'}/[base_id=id]coretable"

static final String JOINED = "A.pk IN (SELECT AA.pk FROM coretable AA" +
" JOIN refs AB ON (AA.id=AB.related_id AND (AB.rel_inv = 'gehoertZuPlan'))" +
" JOIN coretable AC ON (AB.base_id=AC.id)"

@Shared
SqlMappingDeriver mappingDeriver

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

mappingDeriver = new SqlMappingDeriver(pathParser, new ImmutableQueryGeneratorSettings.Builder().build())
filterEncoder = new FilterEncoderSql(OgcCrs.CRS84, new SqlDialectPgis(), null, null, cql, null)
}

/**
* a feature type on a JSON document column, with a feature reference over a junction table;
* the title of the reference is optionally mapped to a value inside the JSON document of the
* referenced feature
*/
static FeatureSchema bereich(Optional<String> titleSourcePath) {
def ref = new ImmutableFeatureSchema.Builder()
.name("gehoertZuPlan")
.type(SchemaBase.Type.FEATURE_REF)
.sourcePath(REF_PATH)
.refType("WP_Plan")
.putProperties2("id", new ImmutableFeatureSchema.Builder()
.type(SchemaBase.Type.STRING)
.sourcePath("id"))

titleSourcePath.ifPresent(path -> ref.putProperties2("title",
new ImmutableFeatureSchema.Builder().type(SchemaBase.Type.STRING).sourcePath(path)))

return new ImmutableFeatureSchema.Builder()
.name("WP_Bereich")
.type(SchemaBase.Type.OBJECT)
.sourcePath("/coretable{filter=featuretype='WP_Bereich'}")
.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("gehoertZuPlan", ref)
.build()
}

static FeatureSchema resolve(FeatureSchema type) {
Map<String, FeatureSchema> types = Map.of("WP_Bereich", type)
List<TypesResolver> resolvers = List.of(
new MappingOperationResolver(true),
new FeatureRefResolver(Set.of("JSON", "EXPRESSION")),
new ImplicitMappingResolver(),
new ConstantsResolver(),
new LabelTemplateResolver(Optional.empty()),
new DefaultRolesResolver(),
new MappingOperationResolver())

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

return types.get("WP_Bereich")
}

SqlQueryMapping mapping(Optional<String> titleSourcePath) {
def resolved = resolve(bereich(titleSourcePath))

return mappingDeriver.derive(resolved.accept(new MappingRulesDeriver()), resolved).get(0)
}

String encode(SqlQueryMapping mapping, String property) {
return filterEncoder.encode(Eq.of(Property.of(property), ScalarLiteral.of("X")), mapping)
}

def 'feature reference queryable filters against the id column: title in the JSON document'() {

given: "a reference whose title is mapped into the JSON document of the referenced feature"
def mapping = mapping(Optional.of("[JSON]properties/name"))

when: "the reference is used as a queryable"
def actual = encode(mapping, "gehoertZuPlan")

then: "the id column of the referenced table is filtered, not a value in the JSON document"
actual == JOINED + " WHERE AC.id = 'X')"
}

def 'feature reference queryable filters against the id column: no title'() {

given: "a reference without a title"
def mapping = mapping(Optional.empty())

when: "the reference is used as a queryable"
def actual = encode(mapping, "gehoertZuPlan")

then: "the same filter is generated as with a title"
actual == JOINED + " WHERE AC.id = 'X')"
}

def 'feature reference queryable filters against the id column: title in a column'() {

given: "a reference whose title is mapped to a column of the referenced table"
def mapping = mapping(Optional.of("label"))

when: "the reference is used as a queryable"
def actual = encode(mapping, "gehoertZuPlan")

then: "the id column of the referenced table is filtered"
actual == JOINED + " WHERE AC.id = 'X')"
}

def 'no object ever claims a value target: title #titleSourcePath'() {

given: "a feature type with a feature reference"
def mapping = mapping(titleSourcePath)

when: "the value targets are resolved"
def objectValues = mapping.getValueSchemas().findAll { target, schema -> !schema.isValue() }

then: "none of them is an object, only values can be filtered or sorted on"
objectValues.isEmpty()

and: "the reference itself resolves to the id column of the referenced table"
mapping.getValueColumns().get("gehoertZuPlan").getName() == "id"

where:
titleSourcePath << [Optional.of("[JSON]properties/name"), Optional.of("label"), Optional.empty()]
}

def 'a value in the JSON document of a referenced feature keeps its path in the document'() {

given: "a reference whose title is mapped to properties/name of the referenced feature"
def mapping = mapping(Optional.of("[JSON]properties/name"))

when: "the title is resolved"
def schema = mapping.getSchemaForValue("gehoertZuPlan.title")

then: "the path in the connector is the path in the document, not the property name"
schema.isPresent()
mapping.getPathInConnector(schema.get()) == "name"
mapping.getValueColumns().get("gehoertZuPlan.title").getName() == "properties"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,17 @@ static String maskPathAttributes(String path) {
.matcher(path)
.replaceAll(m -> "{" + "_".repeat(m.group(1).length()) + "}");
}

/**
* Whether the last segment of the given source path is a sub-decoder column, that is {@code
* [CONNECTOR]column} (a join is {@code [sourceField=targetField]table} and has an {@code =}
* inside the brackets).
*/
static boolean endsWithConnector(String path) {
String masked = maskPathAttributes(path);
String segment = path.substring(masked.lastIndexOf('/') + 1);
int end = segment.indexOf(']');

return segment.startsWith("[") && end > 1 && !segment.substring(1, end).contains("=");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,16 @@ public List<MappingRule> finalize(FeatureSchema featureSchema, List<MappingRule>
MappingRule tableRule =
new ImmutableMappingRule.Builder()
.source(rule.getSourceParent())
// a sub-decoder column (e.g. [JSON]properties) is not a property of the target
// schema, it is the container whose values the following rules address. It must
// always be ROOT_TARGET, otherwise it is taken for the enclosing object itself
// and that object gets mapped to the container column.
.target(
rule.getTarget().contains(".")
? rule.getTarget().substring(0, rule.getTarget().lastIndexOf("."))
: ROOT_TARGET)
MappingRule.endsWithConnector(rule.getSourceParent())
? ROOT_TARGET
: rule.getTarget().contains(".")
? rule.getTarget().substring(0, rule.getTarget().lastIndexOf("."))
: ROOT_TARGET)
.type(
rule.getTarget().endsWith(VALUE_ARRAY_VALUE_SUFFIX)
? Type.VALUE_ARRAY
Expand Down
Loading