From 49e6ef8de7f77f269c15341fdb046679b05ecec3 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Fri, 5 Jun 2026 15:25:15 +0200 Subject: [PATCH 01/25] transactions: property-level partial update via session.patchFeature Adds FeatureTransactions.PropertyUpdate and Session.patchFeature with a default unsupported implementation. SqlMutationSession implements it as native SQL on the session's open connection so partial updates see prior writes from the same transaction: - main-table SET for scalar/datetime/boolean columns - geometry via the existing toWkt path: GeoJSON in -> Geometry -> ST_GeomFromText (with ST_ForcePolygonCW for POLYGON / MULTI_POLYGON), matching the encoding the INSERT path emits - VALUE_ARRAY and OBJECT_ARRAY junctions: DELETE existing rows by parent_pk + INSERT new rows on the same session connection OBJECT_ARRAY elements with nested OBJECT children, M:N junctions and FEATURE_REF arrays remain unsupported and are rejected with a clear error. --- .../features/sql/app/SqlMutationSession.java | 512 ++++++++++++++++++ .../features/domain/FeatureTransactions.java | 26 + 2 files changed, 538 insertions(+) diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java index ab248a9ab..d070f54ff 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java @@ -12,6 +12,7 @@ import de.ii.xtraplatform.base.domain.LogContext; import de.ii.xtraplatform.crs.domain.CrsTransformerFactory; import de.ii.xtraplatform.crs.domain.EpsgCrs; +import de.ii.xtraplatform.features.domain.FeatureSchema; import de.ii.xtraplatform.features.domain.FeatureTokenSource; import de.ii.xtraplatform.features.domain.FeatureTransactions; import de.ii.xtraplatform.features.domain.ImmutableMutationResult; @@ -173,6 +174,517 @@ public FeatureTransactions.MutationResult deleteFeature(String featureType, Stri return builder.build(); } + // Property-level partial update. Routes each PropertyUpdate to either the main-table SET path + // (`UPDATE main SET col = lit WHERE id_col = ''`) or, for VALUE_ARRAY / one-to-many + // junction-backed properties, a DELETE-existing + INSERT-new pair against the junction table + // (`DELETE FROM junction WHERE fk IN (SELECT parent_pk FROM main WHERE id_col = ...); INSERT + // INTO junction (fk, value) SELECT parent_pk, v FROM main, (VALUES ...) v(value) WHERE + // id_col = ...`). Runs synchronously on the session's connection so prior writes within the + // same transaction are visible. Geometry properties on the main table are supported via the + // GeoJSON-to-WKT codec (ST_GeomFromText). M:N junctions, OBJECT_ARRAY/object-FK paths, and + // FEATURE_REF arrays are not yet supported and rejected with a clear error. + @Override + public FeatureTransactions.MutationResult patchFeature( + String featureType, + String featureId, + List updates, + EpsgCrs crs) { + SqlQueryMapping mapping = requireMapping(featureType); + ImmutableMutationResult.Builder builder = + ImmutableMutationResult.builder() + .type(FeatureTransactions.MutationResult.Type.UPDATE) + .hasFeatures(false); + if (updates.isEmpty()) { + return builder.build(); + } + + Optional< + de.ii.xtraplatform.base.domain.util.Tuple< + de.ii.xtraplatform.features.sql.domain.SqlQuerySchema, + de.ii.xtraplatform.features.sql.domain.SqlQueryColumn>> + idColumn = mapping.getColumnForId(); + if (idColumn.isEmpty()) { + return builder + .error( + new IllegalStateException( + "Feature type '" + featureType + "' has no id column; cannot patch in place.")) + .build(); + } + de.ii.xtraplatform.features.sql.domain.SqlQuerySchema mainTable = mapping.getMainTable(); + String mainTableName = mainTable.getName(); + String idColumnName = idColumn.get().second().getName(); + String idLiteral = sqlString(featureId); + + List setClauses = new ArrayList<>(); + // Junction-backed updates: ordered by first-touch path so deterministic SQL ordering. + java.util.LinkedHashMap junctionPatches = + new java.util.LinkedHashMap<>(); + + for (FeatureTransactions.PropertyUpdate update : updates) { + String joined = String.join(".", update.getPath()); + try { + // Try column lookup first: scalar/datetime/geometry on the main table, or VALUE_ARRAY's + // value column on a junction, all surface as a single column. + Optional< + de.ii.xtraplatform.base.domain.util.Tuple< + de.ii.xtraplatform.features.sql.domain.SqlQuerySchema, + de.ii.xtraplatform.features.sql.domain.SqlQueryColumn>> + resolved = + mapping.getColumnForValue( + joined, de.ii.xtraplatform.features.domain.MappingRule.Scope.W); + if (resolved.isPresent()) { + de.ii.xtraplatform.features.sql.domain.SqlQuerySchema table = resolved.get().first(); + de.ii.xtraplatform.features.sql.domain.SqlQueryColumn column = resolved.get().second(); + if (Objects.equals(table.getName(), mainTableName)) { + String literal = encodeLiteral(column, update.getValue(), crs); + setClauses.add(column.getName() + " = " + literal); + } else if (table.isOne2N()) { + JunctionPatch patch = + junctionPatches.computeIfAbsent( + table.getName(), k -> JunctionPatch.valueArray(table, column)); + patch.appendValues(update.getValue()); + } else { + return builder + .error( + new IllegalArgumentException( + "Property '" + + joined + + "' is backed by an M:N junction; not yet supported.")) + .build(); + } + continue; + } + // Not a column. May be an OBJECT_ARRAY parent (its children's columns live on a junction). + // SqlQueryMapping doesn't populate object-schemas, so resolve the parent FeatureSchema by + // walking the canonical path from the main schema. + Optional objectTable = + mapping.getTableForObject(joined); + FeatureSchema objectSchema = resolveSchemaByPath(mapping.getMainSchema(), update.getPath()); + if (objectTable.isPresent() + && objectSchema != null + && objectSchema.getType() + == de.ii.xtraplatform.features.domain.SchemaBase.Type.OBJECT_ARRAY + && objectTable.get().isOne2N()) { + JunctionPatch patch = + junctionPatches.computeIfAbsent( + objectTable.get().getName(), + k -> JunctionPatch.objectArray(objectTable.get(), objectSchema, mapping, joined)); + patch.appendObjectValues(update.getValue()); + continue; + } + return builder + .error( + new IllegalArgumentException( + "Property '" + + joined + + "' is not a writable column of feature type '" + + featureType + + "'.")) + .build(); + } catch (IllegalArgumentException e) { + return builder.error(e).build(); + } + } + + try { + if (!setClauses.isEmpty()) { + String sql = + "UPDATE " + + mainTableName + + " SET " + + String.join(", ", setClauses) + + " WHERE " + + idColumnName + + " = " + + idLiteral + + " RETURNING " + + idColumnName + + ";"; + List returned = sqlSession.runReturning(sql); + if (returned.isEmpty()) { + return builder + .error( + new IllegalArgumentException( + "No feature with id '" + + featureId + + "' in collection '" + + featureType + + "'.")) + .build(); + } + for (String id : returned) { + builder.addIds(id); + } + } + + for (JunctionPatch patch : junctionPatches.values()) { + runJunctionPatch(patch, mainTableName, idColumnName, idLiteral, crs); + } + + // No main-table SET ran but at least one junction was patched: confirm the feature exists. + if (setClauses.isEmpty() && !junctionPatches.isEmpty()) { + List exists = + sqlSession.runReturning( + "SELECT " + + idColumnName + + " FROM " + + mainTableName + + " WHERE " + + idColumnName + + " = " + + idLiteral + + ";"); + if (exists.isEmpty()) { + return builder + .error( + new IllegalArgumentException( + "No feature with id '" + + featureId + + "' in collection '" + + featureType + + "'.")) + .build(); + } + builder.addIds(featureId); + } + } catch (RuntimeException e) { + builder.error(e); + } + return builder.build(); + } + + private void runJunctionPatch( + JunctionPatch patch, + String mainTableName, + String idColumnName, + String idLiteral, + EpsgCrs crs) { + // In SqlQueryJoin (read from the child/junction's perspective): `sourceField` is on the + // PARENT (its primary/sort key) and `targetField` is on the CHILD (the FK back to parent). + // See SqlInsertGenerator2 line ~132 (`parent.sourceField` used as the parent sort key) and + // line ~133 (`targetField` added to the child's column list). + de.ii.xtraplatform.features.sql.domain.SqlQueryJoin join = patch.junction.getRelations().get(0); + String junctionTable = patch.junction.getName(); + String junctionFk = join.getTargetField(); + String parentPk = join.getSourceField(); + + String deleteSql = + "DELETE FROM " + + junctionTable + + " WHERE " + + junctionFk + + " IN (SELECT " + + parentPk + + " FROM " + + mainTableName + + " WHERE " + + idColumnName + + " = " + + idLiteral + + ");"; + sqlSession.runReturning(deleteSql); + + if (patch.values.isEmpty()) { + return; + } + + if (patch.objectChildColumns == null) { + // VALUE_ARRAY: single value column, one literal per element. + String valueCol = patch.valueColumn.getName(); + StringBuilder valuesList = new StringBuilder(); + for (int i = 0; i < patch.values.size(); i++) { + if (i > 0) valuesList.append(", "); + valuesList + .append("(") + .append(encodeLiteral(patch.valueColumn, Optional.of(patch.values.get(i)), crs)) + .append(")"); + } + String insertSql = + "INSERT INTO " + + junctionTable + + " (" + + junctionFk + + ", " + + valueCol + + ") SELECT m." + + parentPk + + ", v.val FROM " + + mainTableName + + " m, (VALUES " + + valuesList + + ") AS v(val) WHERE m." + + idColumnName + + " = " + + idLiteral + + ";"; + sqlSession.runReturning(insertSql); + return; + } + + // OBJECT_ARRAY: multi-column INSERT per element. Each JSON object value contributes one row + // with literals for the child columns it sets; unset child columns get SQL NULL. + List childKeys = new ArrayList<>(patch.objectChildColumns.keySet()); + StringBuilder cols = new StringBuilder(junctionFk); + for (String childKey : childKeys) { + cols.append(", ").append(patch.objectChildColumns.get(childKey).getName()); + } + for (com.fasterxml.jackson.databind.JsonNode element : patch.values) { + if (!element.isObject()) { + throw new IllegalArgumentException( + "Object-array element for property '" + + patch.objectPath + + "' must be a JSON object, got: " + + element.getNodeType()); + } + StringBuilder selectLits = new StringBuilder("m.").append(parentPk); + for (String childKey : childKeys) { + com.fasterxml.jackson.databind.JsonNode v = element.get(childKey); + selectLits.append(", "); + selectLits.append( + encodeLiteral(patch.objectChildColumns.get(childKey), Optional.ofNullable(v), crs)); + } + String insertSql = + "INSERT INTO " + + junctionTable + + " (" + + cols + + ") SELECT " + + selectLits + + " FROM " + + mainTableName + + " m WHERE m." + + idColumnName + + " = " + + idLiteral + + ";"; + sqlSession.runReturning(insertSql); + } + } + + // Patch state for a junction-backed property. Two modes are encoded in the same record so the + // executor's per-path map can hold both kinds: + // VALUE_ARRAY: `valueColumn` is the single value column; `objectChildColumns == null`. + // OBJECT_ARRAY: `valueColumn == null`; `objectChildColumns` maps child schema-ids to their + // columns on the junction (in declaration order so SQL output is deterministic). + private static final class JunctionPatch { + final de.ii.xtraplatform.features.sql.domain.SqlQuerySchema junction; + final de.ii.xtraplatform.features.sql.domain.SqlQueryColumn valueColumn; + final java.util.LinkedHashMap + objectChildColumns; + final String objectPath; + final List values = new ArrayList<>(); + + private JunctionPatch( + de.ii.xtraplatform.features.sql.domain.SqlQuerySchema junction, + de.ii.xtraplatform.features.sql.domain.SqlQueryColumn valueColumn, + java.util.LinkedHashMap + objectChildColumns, + String objectPath) { + this.junction = junction; + this.valueColumn = valueColumn; + this.objectChildColumns = objectChildColumns; + this.objectPath = objectPath; + } + + static JunctionPatch valueArray( + de.ii.xtraplatform.features.sql.domain.SqlQuerySchema junction, + de.ii.xtraplatform.features.sql.domain.SqlQueryColumn valueColumn) { + return new JunctionPatch(junction, valueColumn, null, null); + } + + static JunctionPatch objectArray( + de.ii.xtraplatform.features.sql.domain.SqlQuerySchema junction, + FeatureSchema objectSchema, + SqlQueryMapping mapping, + String path) { + java.util.LinkedHashMap cols = + new java.util.LinkedHashMap<>(); + for (FeatureSchema child : objectSchema.getProperties()) { + if (child.getType() == de.ii.xtraplatform.features.domain.SchemaBase.Type.OBJECT + || child.getType() == de.ii.xtraplatform.features.domain.SchemaBase.Type.OBJECT_ARRAY) { + // Skip nested objects — only flat scalar children are supported in this phase. The + // caller will see a NULL for those keys, or an error if the user sets them. + continue; + } + String childPath = path + "." + child.getName(); + mapping + .getColumnForValue(childPath, de.ii.xtraplatform.features.domain.MappingRule.Scope.W) + .ifPresent(t -> cols.put(child.getName(), t.second())); + } + if (cols.isEmpty()) { + throw new IllegalArgumentException( + "Object-array property '" + + path + + "' has no writable scalar child columns; nested objects are not yet supported" + + " by partial updates."); + } + return new JunctionPatch(junction, null, cols, path); + } + + void appendValues(Optional value) { + if (value.isEmpty() || value.get().isNull()) { + // Empty value means "clear" — discard any earlier-accumulated values for this path. + values.clear(); + return; + } + com.fasterxml.jackson.databind.JsonNode node = value.get(); + if (node.isArray()) { + for (com.fasterxml.jackson.databind.JsonNode element : node) { + if (!element.isNull()) { + values.add(element); + } + } + } else { + values.add(node); + } + } + + void appendObjectValues(Optional value) { + if (value.isEmpty() || value.get().isNull()) { + values.clear(); + return; + } + com.fasterxml.jackson.databind.JsonNode node = value.get(); + if (node.isArray()) { + for (com.fasterxml.jackson.databind.JsonNode element : node) { + if (!element.isNull()) { + values.add(element); + } + } + } else if (node.isObject()) { + values.add(node); + } else { + throw new IllegalArgumentException( + "Object-array property '" + + objectPath + + "' requires a JSON object (or array of objects) as the value, got: " + + node.getNodeType()); + } + } + } + + // SQL literal for a typed column value. Scalar/datetime/boolean become a quoted/raw literal. + // Geometry columns (WKT/WKB operations) decode the JsonNode as a GeoJSON geometry, apply CRS + // transform from the request CRS to the provider's native CRS, and emit + // `ST_GeomFromText('', )` (with `ST_ForcePolygonCW` for polygons, matching + // the encoding the INSERT path uses in FeatureEncoderSql.toWkt). + private String encodeLiteral( + de.ii.xtraplatform.features.sql.domain.SqlQueryColumn column, + Optional valueOpt, + EpsgCrs crs) { + if (valueOpt.isEmpty() || valueOpt.get().isNull()) { + return "NULL"; + } + com.fasterxml.jackson.databind.JsonNode value = valueOpt.get(); + if (column.hasOperation(de.ii.xtraplatform.features.sql.domain.SqlQueryColumn.Operation.WKT) + || column.hasOperation( + de.ii.xtraplatform.features.sql.domain.SqlQueryColumn.Operation.WKB)) { + return encodeGeometryLiteral(column, value, crs); + } + de.ii.xtraplatform.features.domain.SchemaBase.Type type = column.getType(); + if (type == de.ii.xtraplatform.features.domain.SchemaBase.Type.STRING + || type == de.ii.xtraplatform.features.domain.SchemaBase.Type.DATETIME + || type == de.ii.xtraplatform.features.domain.SchemaBase.Type.DATE) { + return sqlString(value.asText()); + } + if (type == de.ii.xtraplatform.features.domain.SchemaBase.Type.BOOLEAN) { + return value.asBoolean() ? "TRUE" : "FALSE"; + } + if (type == de.ii.xtraplatform.features.domain.SchemaBase.Type.INTEGER + || type == de.ii.xtraplatform.features.domain.SchemaBase.Type.FLOAT) { + return value.asText(); + } + // Fallback — treat as string. Avoids generating invalid SQL on niche column types we haven't + // wired up explicitly yet (FEATURE_REF, etc.). + return sqlString(value.asText()); + } + + private String encodeGeometryLiteral( + de.ii.xtraplatform.features.sql.domain.SqlQueryColumn column, + com.fasterxml.jackson.databind.JsonNode value, + EpsgCrs crs) { + if (!value.isObject()) { + throw new IllegalArgumentException( + "Geometry property '" + + column.getName() + + "' requires a GeoJSON geometry object as the value, got: " + + value.getNodeType()); + } + de.ii.xtraplatform.geometries.domain.Geometry geometry; + try { + geometry = + new de.ii.xtraplatform.geometries.domain.transcode.json.GeometryDecoderJson(true) + .decode(value, Optional.ofNullable(crs), Optional.empty()); + } catch (java.io.IOException e) { + throw new IllegalArgumentException( + "Could not parse GeoJSON geometry for property '" + + column.getName() + + "': " + + e.getMessage(), + e); + } + if (crs != null && !Objects.equals(crs, nativeCrs)) { + Optional transformer = + crsTransformerFactory.getTransformer(crs, nativeCrs); + if (transformer.isPresent()) { + geometry = + geometry.accept( + new de.ii.xtraplatform.geometries.domain.transform.CoordinatesTransformer( + de.ii.xtraplatform.geometries.domain.transform.ImmutableCrsTransform.of( + Optional.empty(), transformer.get()))); + } + } + String wkt; + try { + wkt = + new de.ii.xtraplatform.geometries.domain.transcode.wktwkb.GeometryEncoderWkt() + .encode(geometry); + } catch (java.io.IOException e) { + throw new IllegalStateException( + "Could not encode geometry as WKT for property '" + + column.getName() + + "': " + + e.getMessage(), + e); + } + String result = String.format("ST_GeomFromText('%s',%s)", wkt, nativeCrs.getCode()); + if (geometry.getType() == de.ii.xtraplatform.geometries.domain.GeometryType.POLYGON + || geometry.getType() == de.ii.xtraplatform.geometries.domain.GeometryType.MULTI_POLYGON) { + result = String.format("ST_ForcePolygonCW(%s)", result); + } + return result; + } + + // Walk a canonical schema-id path (e.g. ["mat"], or ["lzi","beg"]) from the feature's main + // schema. Returns the last matched FeatureSchema, or null if any segment doesn't resolve. + private static FeatureSchema resolveSchemaByPath(FeatureSchema root, List path) { + if (root == null || path == null || path.isEmpty()) { + return null; + } + FeatureSchema cursor = root; + for (String segment : path) { + FeatureSchema next = null; + for (FeatureSchema child : cursor.getProperties()) { + if (Objects.equals(child.getName(), segment)) { + next = child; + break; + } + } + if (next == null) { + return null; + } + cursor = next; + } + return cursor; + } + + private static String sqlString(String value) { + if (value == null) { + return "NULL"; + } + return "'" + value.replace("'", "''") + "'"; + } + @Override public void commit() { sqlSession.commit(); diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java index faee145e5..96d66e775 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java @@ -7,6 +7,7 @@ */ package de.ii.xtraplatform.features.domain; +import com.fasterxml.jackson.databind.JsonNode; import de.ii.xtraplatform.crs.domain.BoundingBox; import de.ii.xtraplatform.crs.domain.EpsgCrs; import de.ii.xtraplatform.features.domain.FeatureStream.ResultBase; @@ -58,6 +59,19 @@ MutationResult updateFeature( MutationResult deleteFeature(String featureType, String id); + /** + * Property-level partial update. {@code path} is a list of schema property identifiers naming the + * target property (one element for a top-level property, more for nested ones). An empty {@code + * value} clears the property (translates to SQL {@code NULL} or the {@link #PATCH_NULL_VALUE} + * sentinel for value-array entries). + */ + @Value.Immutable + interface PropertyUpdate { + List getPath(); + + Optional getValue(); + } + /** * Opens a session that executes a sequence of mutator calls against a single underlying SQL * transaction. {@link Session#commit()} makes the changes durable; {@link Session#close()} @@ -117,6 +131,18 @@ MutationResult updateFeature( MutationResult deleteFeature(String featureType, String id); + /** + * Applies a property-level partial update to a single existing feature, in place, on this + * session's open transaction (so the update sees prior writes against the same session). The + * default implementation throws {@link UnsupportedOperationException}; the SQL provider's + * session implements it as a native {@code UPDATE} statement on the feature's main table. + */ + default MutationResult patchFeature( + String featureType, String featureId, List updates, EpsgCrs crs) { + throw new UnsupportedOperationException( + "Property-level updates are not supported by this feature provider session"); + } + /** Commits all mutations performed against this session. Throws if already finalised. */ void commit(); From 84101651384b743ea53475c89f116e8cb1df53fd Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Sun, 7 Jun 2026 16:46:09 +0200 Subject: [PATCH 02/25] transactions: session API for versioned-features mutations Adds the Session-level extensions the executor needs to drive retire-and-insert (Replace), retire-in-place / clone-and-patch (Update), and retire-only (Delete) flows on versioned collections: - createFeatures(featureType, sources, crs, roleOverrides) - retireFeature(featureType, id, ts[, expectedStart]) - patchOpenVersion(featureType, id, updates, idFilter[, expectedStart]) - assertNoConflictingVersion(featureType, id, ts) - getOpenVersionStart(featureType, id) SqlMutationSession implements these as hand-built UPDATE / SELECT statements on the session's JDBC connection so they share the atomic transaction with the executor's pre-existing patch/insert flows. retireFeature and patchOpenVersion accept an optional expectedStart predicate (If-Unmodified-Since-style guard for composite-id flows). createFeatures gains a post-INSERT pass that emits a follow-up UPDATE for role-bound columns the encoder can't reach because the property is scoped read+filter but not write (e.g. denorm predecessor pointers populated by the session, not by clients). Two new SchemaBase roles, PREDECESSOR_INTERVAL_START and SUCCESSOR_INTERVAL_START, let versioned collections declare their denorm pointer columns to the same lookup machinery the existing interval roles use. --- .../features/sql/app/SqlMutationSession.java | 655 +++++++++++++++--- .../sql/app/SqlMutationSessionSpec.groovy | 53 ++ .../sql/app/VersionedMutationSqlSpec.groovy | 174 +++++ .../features/domain/FeatureTransactions.java | 125 ++++ .../features/domain/SchemaBase.java | 13 + 5 files changed, 934 insertions(+), 86 deletions(-) create mode 100644 xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/VersionedMutationSqlSpec.groovy diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java index d070f54ff..000bc4954 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java @@ -10,20 +10,33 @@ import com.fasterxml.jackson.core.JsonParseException; import com.google.common.collect.ImmutableList; import de.ii.xtraplatform.base.domain.LogContext; +import de.ii.xtraplatform.crs.domain.CrsTransformer; import de.ii.xtraplatform.crs.domain.CrsTransformerFactory; import de.ii.xtraplatform.crs.domain.EpsgCrs; import de.ii.xtraplatform.features.domain.FeatureSchema; import de.ii.xtraplatform.features.domain.FeatureTokenSource; import de.ii.xtraplatform.features.domain.FeatureTransactions; import de.ii.xtraplatform.features.domain.ImmutableMutationResult; +import de.ii.xtraplatform.features.domain.MappingRule; +import de.ii.xtraplatform.features.domain.SchemaBase; import de.ii.xtraplatform.features.domain.Tuple; import de.ii.xtraplatform.features.sql.domain.FeatureTokenStatsCollector; +import de.ii.xtraplatform.features.sql.domain.SqlQueryColumn; +import de.ii.xtraplatform.features.sql.domain.SqlQueryJoin; import de.ii.xtraplatform.features.sql.domain.SqlQueryMapping; +import de.ii.xtraplatform.features.sql.domain.SqlQuerySchema; import de.ii.xtraplatform.features.sql.domain.SqlSession; +import de.ii.xtraplatform.geometries.domain.Geometry; +import de.ii.xtraplatform.geometries.domain.GeometryType; +import de.ii.xtraplatform.geometries.domain.transcode.json.GeometryDecoderJson; +import de.ii.xtraplatform.geometries.domain.transcode.wktwkb.GeometryEncoderWkt; +import de.ii.xtraplatform.geometries.domain.transform.CoordinatesTransformer; +import de.ii.xtraplatform.geometries.domain.transform.ImmutableCrsTransform; import de.ii.xtraplatform.streams.domain.Reactive; import de.ii.xtraplatform.streams.domain.Reactive.Sink; import de.ii.xtraplatform.streams.domain.Reactive.Source; import de.ii.xtraplatform.streams.domain.Reactive.Transformer; +import java.time.Instant; import java.time.ZoneId; import java.util.ArrayList; import java.util.List; @@ -94,6 +107,21 @@ public FeatureTransactions.MutationResult createFeatures( @Override public FeatureTransactions.MutationResult createFeatures( String featureType, Iterable featureTokenSources, EpsgCrs crs) { + return createFeatures(featureType, featureTokenSources, crs, Map.of()); + } + + // Same as the 3-arg overload, but after every source is drained the per-role column overrides + // are applied row-by-row to the collected FeatureDataSql instances: an entry whose value is + // non-null forces the role-bearing column to that value (already SQL-literal-formatted, e.g. + // quoted for DATETIME); a null value clears the column so it lands as SQL NULL. Roles that the + // type's schema mapping does not bind to a column are silently ignored — the override map is + // a hint, not a requirement. + @Override + public FeatureTransactions.MutationResult createFeatures( + String featureType, + Iterable featureTokenSources, + EpsgCrs crs, + Map roleOverrides) { SqlQueryMapping mapping = requireMapping(featureType); ImmutableMutationResult.Builder builder = ImmutableMutationResult.builder() @@ -113,14 +141,17 @@ public FeatureTransactions.MutationResult createFeatures( return builder.build(); } + if (!roleOverrides.isEmpty()) { + for (FeatureDataSql feature : collected) { + applyRoleOverrides(feature, roleOverrides); + } + } + RowCursor rowCursor = new RowCursor(mapping.getMainTable().getFullPath()); - Optional< - de.ii.xtraplatform.base.domain.util.Tuple< - de.ii.xtraplatform.features.sql.domain.SqlQuerySchema, - de.ii.xtraplatform.features.sql.domain.SqlQueryColumn>> + Optional> roleIdColumn = mapping.getColumnForId(); String roleIdColumnName = roleIdColumn.map(t -> t.second().getName()).orElse(null); - de.ii.xtraplatform.features.sql.domain.SqlQuerySchema roleIdTable = + SqlQuerySchema roleIdTable = roleIdColumn.map(de.ii.xtraplatform.base.domain.util.Tuple::first).orElse(null); try { @@ -130,7 +161,132 @@ public FeatureTransactions.MutationResult createFeatures( builder.error(e); } - return builder.build(); + FeatureTransactions.MutationResult result = builder.build(); + if (!roleOverrides.isEmpty() && result.getError().isEmpty()) { + applyPostInsertRoleOverrides(mapping, result.getIds(), roleOverrides); + } + return result; + } + + // Role overrides whose target column is in the read scope but NOT in the writable scope + // (e.g. PREDECESSOR_INTERVAL_START on a versioned collection whose denorm property is + // configured `excludedScopes: [RECEIVABLE, SORTABLE]` → MappingRule.Scope.RC) are silently + // dropped by the INSERT generator because it only emits columns in `getWritableColumns()`. + // Mirror the retire-side hand-built UPDATE: for every just-inserted row, emit + // `UPDATE main SET = [, …] WHERE = '' AND IS NULL`, where + // the end-col predicate narrows to the just-inserted open version (versioned collections + // bind `PRIMARY_INTERVAL_END`; plain collections skip the predicate). Role overrides whose + // column IS writable have already landed via the INSERT path and are skipped here. + private void applyPostInsertRoleOverrides( + SqlQueryMapping mapping, List ids, Map overrides) { + if (ids.isEmpty()) { + return; + } + List> deferred = + new ArrayList<>(); + SqlQuerySchema postUpdateTable = null; + for (Map.Entry entry : overrides.entrySet()) { + Optional> resolved = + mapping.getColumnForRole(entry.getKey()); + if (resolved.isEmpty()) { + continue; + } + SqlQuerySchema table = resolved.get().first(); + SqlQueryColumn column = resolved.get().second(); + boolean writable = + table.getWritableColumns().stream().anyMatch(c -> c.getName().equals(column.getName())); + if (writable) { + continue; + } + if (postUpdateTable == null) { + postUpdateTable = table; + } else if (postUpdateTable != table) { + // All deferred overrides for a single feature must target the same table (typically the + // main table). A future role binding on a sub-table would need a separate UPDATE. + continue; + } + deferred.add(de.ii.xtraplatform.base.domain.util.Tuple.of(column, entry.getValue())); + } + if (deferred.isEmpty() || postUpdateTable == null) { + return; + } + Optional> idColumn = + mapping.getColumnForId(); + if (idColumn.isEmpty()) { + return; + } + Optional> endColumn = + mapping.getColumnForPrimaryIntervalEnd(); + StringBuilder setClause = new StringBuilder(); + for (de.ii.xtraplatform.base.domain.util.Tuple d : deferred) { + if (setClause.length() > 0) { + setClause.append(", "); + } + setClause + .append(d.first().getName()) + .append(" = ") + .append(d.second() == null ? "NULL" : formatRoleOverrideValue(d.first(), d.second())); + } + String tableName = postUpdateTable.getName(); + String idColName = idColumn.get().second().getName(); + String endPredicate = + endColumn.map(t -> " AND " + t.second().getName() + " IS NULL").orElse(""); + for (String id : ids) { + String sql = + "UPDATE " + + tableName + + " SET " + + setClause + + " WHERE " + + idColName + + " = " + + sqlString(id) + + endPredicate + + ";"; + sqlSession.runReturning(sql); + } + } + + // For each role in `overrides`, look up the (table, column) on the mapping and either overwrite + // (non-null value) or clear (null value) the entry in the matching row's `values` map. Values + // are stored in the same SQL-literal form the encoder uses (single-quoted strings/datetimes, + // bare numerics), so the caller is expected to pre-format. Roles whose column the mapping does + // not resolve are ignored. + private static void applyRoleOverrides( + FeatureDataSql feature, Map overrides) { + SqlQueryMapping mapping = feature.getMapping(); + for (Map.Entry entry : overrides.entrySet()) { + Optional> resolved = + mapping.getColumnForRole(entry.getKey()); + if (resolved.isEmpty()) { + continue; + } + SqlQuerySchema table = resolved.get().first(); + SqlQueryColumn column = resolved.get().second(); + for (de.ii.xtraplatform.base.domain.util.Tuple row : + feature.getRows()) { + if (Objects.equals(row.first(), table)) { + if (entry.getValue() == null) { + row.second().getValues().remove(column.getName()); + } else { + row.second() + .putValues(column.getName(), formatRoleOverrideValue(column, entry.getValue())); + } + break; + } + } + } + } + + private static String formatRoleOverrideValue(SqlQueryColumn column, Object value) { + SchemaBase.Type type = column.getType(); + String raw = value.toString(); + if (type == SchemaBase.Type.STRING + || type == SchemaBase.Type.DATETIME + || type == SchemaBase.Type.DATE) { + return "'" + raw.replace("'", "''") + "'"; + } + return raw; } @Override @@ -151,6 +307,268 @@ public FeatureTransactions.MutationResult updateFeature( partial); } + // Versioned retirement: closes the open version of `featureId` by setting its + // PRIMARY_INTERVAL_END column to `retirementTimestamp`, gated by `PRIMARY_INTERVAL_END IS NULL` + // so a concurrent retirement loses without corrupting the timeline. Returns the role-id of the + // retired row; an empty result means no open version matched and is mapped by the caller to a + // 409-style conflict. When `expectedStart` is present, the WHERE also requires `startCol = + // expectedStart` — an If-Unmodified-Since-style check that the caller maps to a 412 on miss. + @Override + public FeatureTransactions.MutationResult retireFeature( + String featureType, + String featureId, + Instant retirementTimestamp, + Optional expectedStart) { + SqlQueryMapping mapping = requireMapping(featureType); + ImmutableMutationResult.Builder builder = + ImmutableMutationResult.builder() + .type(FeatureTransactions.MutationResult.Type.UPDATE) + .hasFeatures(false); + + Optional> endColumn = + mapping.getColumnForRole(SchemaBase.Role.PRIMARY_INTERVAL_END); + if (endColumn.isEmpty()) { + return builder + .error( + new IllegalStateException( + "Feature type '" + + featureType + + "' has no PRIMARY_INTERVAL_END role column; cannot retire.")) + .build(); + } + Optional> + startColumn = mapping.getColumnForRole(SchemaBase.Role.PRIMARY_INTERVAL_START); + if (startColumn.isEmpty()) { + return builder + .error( + new IllegalStateException( + "Feature type '" + + featureType + + "' has no PRIMARY_INTERVAL_START role column; cannot enforce" + + " no-backdating during retire.")) + .build(); + } + Optional> idColumn = + mapping.getColumnForId(); + if (idColumn.isEmpty()) { + return builder + .error( + new IllegalStateException( + "Feature type '" + featureType + "' has no id column; cannot retire.")) + .build(); + } + + SqlQuerySchema endTable = endColumn.get().first(); + SqlQuerySchema idTable = idColumn.get().first(); + SqlQuerySchema startTable = startColumn.get().first(); + if (!Objects.equals(endTable.getName(), idTable.getName()) + || !Objects.equals(startTable.getName(), idTable.getName())) { + return builder + .error( + new IllegalStateException( + "Feature type '" + + featureType + + "' has id / PRIMARY_INTERVAL_START / PRIMARY_INTERVAL_END on more" + + " than one table; retirement requires all three on the main table.")) + .build(); + } + + String mainTableName = endTable.getName(); + String endColumnName = endColumn.get().second().getName(); + String startColumnName = startColumn.get().second().getName(); + String idColumnName = idColumn.get().second().getName(); + String tsLiteral = sqlString(retirementTimestamp.toString()); + + // Denorm SUCCESSOR_INTERVAL_START (plan §1.6, option (i)): if the schema mapping binds the + // role to a column on the main table, set it to the retirement timestamp — which is also + // the new version's start in retire-and-insert flows. Opt-in: no SUCCESSOR_INTERVAL_START + // role on the schema means no SET clause is added. + StringBuilder setClause = new StringBuilder(endColumnName).append(" = ").append(tsLiteral); + Optional> + successorColumn = mapping.getColumnForRole(SchemaBase.Role.SUCCESSOR_INTERVAL_START); + if (successorColumn.isPresent() + && Objects.equals(successorColumn.get().first().getName(), mainTableName)) { + setClause + .append(", ") + .append(successorColumn.get().second().getName()) + .append(" = ") + .append(tsLiteral); + } + + // Optimistic-concurrency + no-backdating in one atomic UPDATE: the row must be the open + // version (endCol IS NULL) AND its start must be strictly before the retirement timestamp + // (no-backdating, plan §1.5). A backdated or non-existent retire matches 0 rows; the caller + // surfaces that as a 409. When `expectedStart` is present, an additional `startCol = + // expectedStart` predicate is appended — an If-Unmodified-Since-style check that maps to a + // 412 on miss (plan §1.8 composite-id convention). + StringBuilder where = + new StringBuilder(idColumnName) + .append(" = ") + .append(sqlString(featureId)) + .append(" AND ") + .append(endColumnName) + .append(" IS NULL AND ") + .append(startColumnName) + .append(" < ") + .append(tsLiteral); + if (expectedStart.isPresent()) { + where + .append(" AND ") + .append(startColumnName) + .append(" = ") + .append(sqlString(expectedStart.get().toString())); + } + String sql = + "UPDATE " + + mainTableName + + " SET " + + setClause + + " WHERE " + + where + + " RETURNING " + + idColumnName + + ";"; + try { + List returned = sqlSession.runReturning(sql); + for (String id : returned) { + builder.addIds(id); + } + } catch (RuntimeException e) { + builder.error(e); + } + return builder.build(); + } + + // Versioned-Insert pre-flight (plan §1.5 Part A.insert): refuses to write a new version if any + // existing row for the same role-id would conflict — another open version (end IS NULL), an + // overlapping closed version (end > insertTimestamp), or a backdating violation (start >= + // insertTimestamp). The check runs as a single SELECT on the main table and returns an error + // result the caller maps to a 409. + @Override + public FeatureTransactions.MutationResult assertNoConflictingVersion( + String featureType, String featureId, Instant insertTimestamp) { + SqlQueryMapping mapping = requireMapping(featureType); + ImmutableMutationResult.Builder builder = + ImmutableMutationResult.builder() + .type(FeatureTransactions.MutationResult.Type.CREATE) + .hasFeatures(false); + Optional> idColumn = + mapping.getColumnForId(); + Optional> + startColumn = mapping.getColumnForRole(SchemaBase.Role.PRIMARY_INTERVAL_START); + Optional> endColumn = + mapping.getColumnForRole(SchemaBase.Role.PRIMARY_INTERVAL_END); + if (idColumn.isEmpty() || startColumn.isEmpty() || endColumn.isEmpty()) { + return builder + .error( + new IllegalStateException( + "Feature type '" + + featureType + + "' is missing ID / PRIMARY_INTERVAL_START / PRIMARY_INTERVAL_END role" + + " columns; cannot run the versioned-insert pre-flight.")) + .build(); + } + String mainTableName = idColumn.get().first().getName(); + if (!Objects.equals(startColumn.get().first().getName(), mainTableName) + || !Objects.equals(endColumn.get().first().getName(), mainTableName)) { + return builder + .error( + new IllegalStateException( + "Feature type '" + + featureType + + "' has id / PRIMARY_INTERVAL_START / PRIMARY_INTERVAL_END on more than" + + " one table; pre-flight requires all three on the main table.")) + .build(); + } + String idCol = idColumn.get().second().getName(); + String startCol = startColumn.get().second().getName(); + String endCol = endColumn.get().second().getName(); + String tsLit = sqlString(insertTimestamp.toString()); + String sql = + "SELECT 1 FROM " + + mainTableName + + " WHERE " + + idCol + + " = " + + sqlString(featureId) + + " AND (" + + endCol + + " IS NULL OR " + + endCol + + " > " + + tsLit + + " OR " + + startCol + + " >= " + + tsLit + + ") LIMIT 1;"; + try { + List hit = sqlSession.runReturning(sql); + if (!hit.isEmpty()) { + return builder + .error( + new IllegalArgumentException( + "Cannot create a new version of feature id '" + + featureId + + "' in collection '" + + featureType + + "' at " + + insertTimestamp + + ": a conflicting version exists (another open version, an overlapping" + + " closed version, or a version at or after this timestamp).")) + .build(); + } + } catch (RuntimeException e) { + builder.error(e); + } + return builder.build(); + } + + // Reads the open version's PRIMARY_INTERVAL_START value for `featureId`. Used by versioned + // retire-and-insert flows (plan §1.6) to populate the new row's PREDECESSOR_INTERVAL_START + // denorm column. Returns empty when no open version exists or the type lacks the required + // columns — the caller treats the value as "no predecessor info available" and omits the + // override. + @Override + public Optional getOpenVersionStart(String featureType, String featureId) { + SqlQueryMapping mapping = requireMapping(featureType); + Optional> idColumn = + mapping.getColumnForId(); + Optional> + startColumn = mapping.getColumnForRole(SchemaBase.Role.PRIMARY_INTERVAL_START); + Optional> endColumn = + mapping.getColumnForRole(SchemaBase.Role.PRIMARY_INTERVAL_END); + if (idColumn.isEmpty() || startColumn.isEmpty() || endColumn.isEmpty()) { + return Optional.empty(); + } + String mainTableName = idColumn.get().first().getName(); + if (!Objects.equals(startColumn.get().first().getName(), mainTableName) + || !Objects.equals(endColumn.get().first().getName(), mainTableName)) { + return Optional.empty(); + } + String sql = + "SELECT " + + startColumn.get().second().getName() + + " FROM " + + mainTableName + + " WHERE " + + idColumn.get().second().getName() + + " = " + + sqlString(featureId) + + " AND " + + endColumn.get().second().getName() + + " IS NULL LIMIT 1;"; + try { + List rows = sqlSession.runReturning(sql); + if (rows.isEmpty()) { + return Optional.empty(); + } + return Optional.ofNullable(rows.get(0)); + } catch (RuntimeException e) { + return Optional.empty(); + } + } + @Override public FeatureTransactions.MutationResult deleteFeature(String featureType, String id) { SqlQueryMapping mapping = requireMapping(featureType); @@ -189,6 +607,81 @@ public FeatureTransactions.MutationResult patchFeature( String featureId, List updates, EpsgCrs crs) { + return patchInternal(featureType, featureId, updates, crs, "", "feature"); + } + + // Same as patchFeature but additionally constrains the target row to the open version (the row + // whose PRIMARY_INTERVAL_END column is currently NULL). The same predicate is propagated into + // every junction patch's subquery so junction rows are only touched on the open parent. + // + // No-backdating (plan §1.5): when one of the `updates` sets the PRIMARY_INTERVAL_END column to + // a value V, also require `startCol < V` in the WHERE so a retire-in-place Update that would + // produce a zero-or-negative interval matches 0 rows and surfaces as a 409. We scan the + // updates upfront, find the end-setting one, format the value as the same SQL literal the + // patch would have written, and inject it into the extra predicate. + @Override + public FeatureTransactions.MutationResult patchOpenVersion( + String featureType, + String featureId, + List updates, + EpsgCrs crs, + Optional expectedStart) { + SqlQueryMapping mapping = requireMapping(featureType); + ImmutableMutationResult.Builder builder = + ImmutableMutationResult.builder() + .type(FeatureTransactions.MutationResult.Type.UPDATE) + .hasFeatures(false); + Optional> endColumn = + mapping.getColumnForRole(SchemaBase.Role.PRIMARY_INTERVAL_END); + if (endColumn.isEmpty()) { + return builder + .error( + new IllegalStateException( + "Feature type '" + + featureType + + "' has no PRIMARY_INTERVAL_END role column; cannot patch open version.")) + .build(); + } + String endColumnName = endColumn.get().second().getName(); + String extra = " AND " + endColumnName + " IS NULL"; + + Optional> + startColumn = mapping.getColumnForRole(SchemaBase.Role.PRIMARY_INTERVAL_START); + if (startColumn.isPresent()) { + String startColumnName = startColumn.get().second().getName(); + for (FeatureTransactions.PropertyUpdate u : updates) { + if (u.getValue().isEmpty()) { + continue; + } + String joined = String.join(".", u.getPath()); + Optional> + resolved = mapping.getColumnForValue(joined, MappingRule.Scope.W); + if (resolved.isPresent() + && Objects.equals(resolved.get().second().getName(), endColumnName)) { + String endLiteral = encodeLiteral(resolved.get().second(), u.getValue(), crs); + extra = extra + " AND " + startColumnName + " < " + endLiteral; + break; + } + } + // Composite-id If-Unmodified-Since predicate (plan §1.8): the open version's start must + // equal the value the client encoded in the rid's suffix. Otherwise the UPDATE matches 0 + // rows and the caller maps that to a 412 Precondition Failed. + if (expectedStart.isPresent()) { + extra = + extra + " AND " + startColumnName + " = " + sqlString(expectedStart.get().toString()); + } + } + + return patchInternal(featureType, featureId, updates, crs, extra, "open version of feature"); + } + + private FeatureTransactions.MutationResult patchInternal( + String featureType, + String featureId, + List updates, + EpsgCrs crs, + String extraWherePredicate, + String missingTargetLabel) { SqlQueryMapping mapping = requireMapping(featureType); ImmutableMutationResult.Builder builder = ImmutableMutationResult.builder() @@ -198,11 +691,8 @@ public FeatureTransactions.MutationResult patchFeature( return builder.build(); } - Optional< - de.ii.xtraplatform.base.domain.util.Tuple< - de.ii.xtraplatform.features.sql.domain.SqlQuerySchema, - de.ii.xtraplatform.features.sql.domain.SqlQueryColumn>> - idColumn = mapping.getColumnForId(); + Optional> idColumn = + mapping.getColumnForId(); if (idColumn.isEmpty()) { return builder .error( @@ -210,7 +700,7 @@ public FeatureTransactions.MutationResult patchFeature( "Feature type '" + featureType + "' has no id column; cannot patch in place.")) .build(); } - de.ii.xtraplatform.features.sql.domain.SqlQuerySchema mainTable = mapping.getMainTable(); + SqlQuerySchema mainTable = mapping.getMainTable(); String mainTableName = mainTable.getName(); String idColumnName = idColumn.get().second().getName(); String idLiteral = sqlString(featureId); @@ -225,16 +715,11 @@ public FeatureTransactions.MutationResult patchFeature( try { // Try column lookup first: scalar/datetime/geometry on the main table, or VALUE_ARRAY's // value column on a junction, all surface as a single column. - Optional< - de.ii.xtraplatform.base.domain.util.Tuple< - de.ii.xtraplatform.features.sql.domain.SqlQuerySchema, - de.ii.xtraplatform.features.sql.domain.SqlQueryColumn>> - resolved = - mapping.getColumnForValue( - joined, de.ii.xtraplatform.features.domain.MappingRule.Scope.W); + Optional> + resolved = mapping.getColumnForValue(joined, MappingRule.Scope.W); if (resolved.isPresent()) { - de.ii.xtraplatform.features.sql.domain.SqlQuerySchema table = resolved.get().first(); - de.ii.xtraplatform.features.sql.domain.SqlQueryColumn column = resolved.get().second(); + SqlQuerySchema table = resolved.get().first(); + SqlQueryColumn column = resolved.get().second(); if (Objects.equals(table.getName(), mainTableName)) { String literal = encodeLiteral(column, update.getValue(), crs); setClauses.add(column.getName() + " = " + literal); @@ -257,13 +742,11 @@ public FeatureTransactions.MutationResult patchFeature( // Not a column. May be an OBJECT_ARRAY parent (its children's columns live on a junction). // SqlQueryMapping doesn't populate object-schemas, so resolve the parent FeatureSchema by // walking the canonical path from the main schema. - Optional objectTable = - mapping.getTableForObject(joined); + Optional objectTable = mapping.getTableForObject(joined); FeatureSchema objectSchema = resolveSchemaByPath(mapping.getMainSchema(), update.getPath()); if (objectTable.isPresent() && objectSchema != null - && objectSchema.getType() - == de.ii.xtraplatform.features.domain.SchemaBase.Type.OBJECT_ARRAY + && objectSchema.getType() == SchemaBase.Type.OBJECT_ARRAY && objectTable.get().isOne2N()) { JunctionPatch patch = junctionPatches.computeIfAbsent( @@ -297,6 +780,7 @@ public FeatureTransactions.MutationResult patchFeature( + idColumnName + " = " + idLiteral + + extraWherePredicate + " RETURNING " + idColumnName + ";"; @@ -305,7 +789,9 @@ public FeatureTransactions.MutationResult patchFeature( return builder .error( new IllegalArgumentException( - "No feature with id '" + "No " + + missingTargetLabel + + " with id '" + featureId + "' in collection '" + featureType @@ -318,7 +804,7 @@ public FeatureTransactions.MutationResult patchFeature( } for (JunctionPatch patch : junctionPatches.values()) { - runJunctionPatch(patch, mainTableName, idColumnName, idLiteral, crs); + runJunctionPatch(patch, mainTableName, idColumnName, idLiteral, extraWherePredicate, crs); } // No main-table SET ran but at least one junction was patched: confirm the feature exists. @@ -333,12 +819,15 @@ public FeatureTransactions.MutationResult patchFeature( + idColumnName + " = " + idLiteral + + extraWherePredicate + ";"); if (exists.isEmpty()) { return builder .error( new IllegalArgumentException( - "No feature with id '" + "No " + + missingTargetLabel + + " with id '" + featureId + "' in collection '" + featureType @@ -358,12 +847,13 @@ private void runJunctionPatch( String mainTableName, String idColumnName, String idLiteral, + String extraWherePredicate, EpsgCrs crs) { // In SqlQueryJoin (read from the child/junction's perspective): `sourceField` is on the // PARENT (its primary/sort key) and `targetField` is on the CHILD (the FK back to parent). // See SqlInsertGenerator2 line ~132 (`parent.sourceField` used as the parent sort key) and // line ~133 (`targetField` added to the child's column list). - de.ii.xtraplatform.features.sql.domain.SqlQueryJoin join = patch.junction.getRelations().get(0); + SqlQueryJoin join = patch.junction.getRelations().get(0); String junctionTable = patch.junction.getName(); String junctionFk = join.getTargetField(); String parentPk = join.getSourceField(); @@ -381,6 +871,7 @@ private void runJunctionPatch( + idColumnName + " = " + idLiteral + + extraWherePredicate + ");"; sqlSession.runReturning(deleteSql); @@ -416,6 +907,7 @@ private void runJunctionPatch( + idColumnName + " = " + idLiteral + + qualifyAliasPredicate(extraWherePredicate, "m") + ";"; sqlSession.runReturning(insertSql); return; @@ -456,29 +948,40 @@ private void runJunctionPatch( + idColumnName + " = " + idLiteral + + qualifyAliasPredicate(extraWherePredicate, "m") + ";"; sqlSession.runReturning(insertSql); } } + // The junction subqueries alias the main table as `m`, so the extra predicate's bare column + // references must be qualified with that alias. Single-pass replace works because the predicate + // is generated by us (`" AND IS NULL"`); not robust against arbitrary user input. + private static String qualifyAliasPredicate(String extraPredicate, String alias) { + if (extraPredicate.isEmpty()) return extraPredicate; + // Strip leading " AND " and prefix every word-start with the alias. + int and = extraPredicate.indexOf("AND "); + if (and < 0) return extraPredicate; + String rest = extraPredicate.substring(and + 4); + return " AND " + alias + "." + rest; + } + // Patch state for a junction-backed property. Two modes are encoded in the same record so the // executor's per-path map can hold both kinds: // VALUE_ARRAY: `valueColumn` is the single value column; `objectChildColumns == null`. // OBJECT_ARRAY: `valueColumn == null`; `objectChildColumns` maps child schema-ids to their // columns on the junction (in declaration order so SQL output is deterministic). private static final class JunctionPatch { - final de.ii.xtraplatform.features.sql.domain.SqlQuerySchema junction; - final de.ii.xtraplatform.features.sql.domain.SqlQueryColumn valueColumn; - final java.util.LinkedHashMap - objectChildColumns; + final SqlQuerySchema junction; + final SqlQueryColumn valueColumn; + final java.util.LinkedHashMap objectChildColumns; final String objectPath; final List values = new ArrayList<>(); private JunctionPatch( - de.ii.xtraplatform.features.sql.domain.SqlQuerySchema junction, - de.ii.xtraplatform.features.sql.domain.SqlQueryColumn valueColumn, - java.util.LinkedHashMap - objectChildColumns, + SqlQuerySchema junction, + SqlQueryColumn valueColumn, + java.util.LinkedHashMap objectChildColumns, String objectPath) { this.junction = junction; this.valueColumn = valueColumn; @@ -486,29 +989,23 @@ private JunctionPatch( this.objectPath = objectPath; } - static JunctionPatch valueArray( - de.ii.xtraplatform.features.sql.domain.SqlQuerySchema junction, - de.ii.xtraplatform.features.sql.domain.SqlQueryColumn valueColumn) { + static JunctionPatch valueArray(SqlQuerySchema junction, SqlQueryColumn valueColumn) { return new JunctionPatch(junction, valueColumn, null, null); } static JunctionPatch objectArray( - de.ii.xtraplatform.features.sql.domain.SqlQuerySchema junction, - FeatureSchema objectSchema, - SqlQueryMapping mapping, - String path) { - java.util.LinkedHashMap cols = - new java.util.LinkedHashMap<>(); + SqlQuerySchema junction, FeatureSchema objectSchema, SqlQueryMapping mapping, String path) { + java.util.LinkedHashMap cols = new java.util.LinkedHashMap<>(); for (FeatureSchema child : objectSchema.getProperties()) { - if (child.getType() == de.ii.xtraplatform.features.domain.SchemaBase.Type.OBJECT - || child.getType() == de.ii.xtraplatform.features.domain.SchemaBase.Type.OBJECT_ARRAY) { + if (child.getType() == SchemaBase.Type.OBJECT + || child.getType() == SchemaBase.Type.OBJECT_ARRAY) { // Skip nested objects — only flat scalar children are supported in this phase. The // caller will see a NULL for those keys, or an error if the user sets them. continue; } String childPath = path + "." + child.getName(); mapping - .getColumnForValue(childPath, de.ii.xtraplatform.features.domain.MappingRule.Scope.W) + .getColumnForValue(childPath, MappingRule.Scope.W) .ifPresent(t -> cols.put(child.getName(), t.second())); } if (cols.isEmpty()) { @@ -569,29 +1066,27 @@ void appendObjectValues(Optional value) // `ST_GeomFromText('', )` (with `ST_ForcePolygonCW` for polygons, matching // the encoding the INSERT path uses in FeatureEncoderSql.toWkt). private String encodeLiteral( - de.ii.xtraplatform.features.sql.domain.SqlQueryColumn column, + SqlQueryColumn column, Optional valueOpt, EpsgCrs crs) { if (valueOpt.isEmpty() || valueOpt.get().isNull()) { return "NULL"; } com.fasterxml.jackson.databind.JsonNode value = valueOpt.get(); - if (column.hasOperation(de.ii.xtraplatform.features.sql.domain.SqlQueryColumn.Operation.WKT) - || column.hasOperation( - de.ii.xtraplatform.features.sql.domain.SqlQueryColumn.Operation.WKB)) { + if (column.hasOperation(SqlQueryColumn.Operation.WKT) + || column.hasOperation(SqlQueryColumn.Operation.WKB)) { return encodeGeometryLiteral(column, value, crs); } - de.ii.xtraplatform.features.domain.SchemaBase.Type type = column.getType(); - if (type == de.ii.xtraplatform.features.domain.SchemaBase.Type.STRING - || type == de.ii.xtraplatform.features.domain.SchemaBase.Type.DATETIME - || type == de.ii.xtraplatform.features.domain.SchemaBase.Type.DATE) { + SchemaBase.Type type = column.getType(); + if (type == SchemaBase.Type.STRING + || type == SchemaBase.Type.DATETIME + || type == SchemaBase.Type.DATE) { return sqlString(value.asText()); } - if (type == de.ii.xtraplatform.features.domain.SchemaBase.Type.BOOLEAN) { + if (type == SchemaBase.Type.BOOLEAN) { return value.asBoolean() ? "TRUE" : "FALSE"; } - if (type == de.ii.xtraplatform.features.domain.SchemaBase.Type.INTEGER - || type == de.ii.xtraplatform.features.domain.SchemaBase.Type.FLOAT) { + if (type == SchemaBase.Type.INTEGER || type == SchemaBase.Type.FLOAT) { return value.asText(); } // Fallback — treat as string. Avoids generating invalid SQL on niche column types we haven't @@ -600,9 +1095,7 @@ private String encodeLiteral( } private String encodeGeometryLiteral( - de.ii.xtraplatform.features.sql.domain.SqlQueryColumn column, - com.fasterxml.jackson.databind.JsonNode value, - EpsgCrs crs) { + SqlQueryColumn column, com.fasterxml.jackson.databind.JsonNode value, EpsgCrs crs) { if (!value.isObject()) { throw new IllegalArgumentException( "Geometry property '" @@ -610,11 +1103,10 @@ private String encodeGeometryLiteral( + "' requires a GeoJSON geometry object as the value, got: " + value.getNodeType()); } - de.ii.xtraplatform.geometries.domain.Geometry geometry; + Geometry geometry; try { geometry = - new de.ii.xtraplatform.geometries.domain.transcode.json.GeometryDecoderJson(true) - .decode(value, Optional.ofNullable(crs), Optional.empty()); + new GeometryDecoderJson(true).decode(value, Optional.ofNullable(crs), Optional.empty()); } catch (java.io.IOException e) { throw new IllegalArgumentException( "Could not parse GeoJSON geometry for property '" @@ -624,21 +1116,17 @@ private String encodeGeometryLiteral( e); } if (crs != null && !Objects.equals(crs, nativeCrs)) { - Optional transformer = - crsTransformerFactory.getTransformer(crs, nativeCrs); + Optional transformer = crsTransformerFactory.getTransformer(crs, nativeCrs); if (transformer.isPresent()) { geometry = geometry.accept( - new de.ii.xtraplatform.geometries.domain.transform.CoordinatesTransformer( - de.ii.xtraplatform.geometries.domain.transform.ImmutableCrsTransform.of( - Optional.empty(), transformer.get()))); + new CoordinatesTransformer( + ImmutableCrsTransform.of(Optional.empty(), transformer.get()))); } } String wkt; try { - wkt = - new de.ii.xtraplatform.geometries.domain.transcode.wktwkb.GeometryEncoderWkt() - .encode(geometry); + wkt = new GeometryEncoderWkt().encode(geometry); } catch (java.io.IOException e) { throw new IllegalStateException( "Could not encode geometry as WKT for property '" @@ -648,8 +1136,8 @@ private String encodeGeometryLiteral( e); } String result = String.format("ST_GeomFromText('%s',%s)", wkt, nativeCrs.getCode()); - if (geometry.getType() == de.ii.xtraplatform.geometries.domain.GeometryType.POLYGON - || geometry.getType() == de.ii.xtraplatform.geometries.domain.GeometryType.MULTI_POLYGON) { + if (geometry.getType() == GeometryType.POLYGON + || geometry.getType() == GeometryType.MULTI_POLYGON) { result = String.format("ST_ForcePolygonCW(%s)", result); } return result; @@ -727,13 +1215,10 @@ private FeatureTransactions.MutationResult writeFeatures( // Role-id column on the main table — its value in the inserted feature is the externally // visible feature id (e.g. ALKIS gml:id stored in 'objid'); fall back to the surrogate PK only // when no role-id column / no value is present. - Optional< - de.ii.xtraplatform.base.domain.util.Tuple< - de.ii.xtraplatform.features.sql.domain.SqlQuerySchema, - de.ii.xtraplatform.features.sql.domain.SqlQueryColumn>> + Optional> roleIdColumn = mapping.getColumnForId(); String roleIdColumnName = roleIdColumn.map(t -> t.second().getName()).orElse(null); - de.ii.xtraplatform.features.sql.domain.SqlQuerySchema roleIdTable = + SqlQuerySchema roleIdTable = roleIdColumn.map(de.ii.xtraplatform.base.domain.util.Tuple::first).orElse(null); try { @@ -802,7 +1287,7 @@ private void writeFeaturesPerFeature( Optional featureId, EpsgCrs crs, boolean deleteFirst, - de.ii.xtraplatform.features.sql.domain.SqlQuerySchema roleIdTable, + SqlQuerySchema roleIdTable, String roleIdColumnName, ImmutableMutationResult.Builder builder) { for (FeatureDataSql feature : collected) { @@ -846,7 +1331,7 @@ private void writeFeaturesBatched( RowCursor rowCursor, Optional featureId, EpsgCrs crs, - de.ii.xtraplatform.features.sql.domain.SqlQuerySchema roleIdTable, + SqlQuerySchema roleIdTable, String roleIdColumnName, ImmutableMutationResult.Builder builder) { int n = collected.size(); @@ -999,9 +1484,7 @@ private static final class MainInsertParts { } private static Optional extractRoleId( - FeatureDataSql feature, - de.ii.xtraplatform.features.sql.domain.SqlQuerySchema roleIdTable, - String roleIdColumnName) { + FeatureDataSql feature, SqlQuerySchema roleIdTable, String roleIdColumnName) { if (roleIdTable == null || roleIdColumnName == null) { return Optional.empty(); } diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlMutationSessionSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlMutationSessionSpec.groovy index 6bba391d8..515b5e7be 100644 --- a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlMutationSessionSpec.groovy +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlMutationSessionSpec.groovy @@ -92,4 +92,57 @@ class SqlMutationSessionSpec extends Specification { ex.message.contains('unknown_type') 0 * sqlSession.run(_, _, _) } + + def 'retireFeature for an unknown feature type fails fast with IAE'() { + given: + def session = buildSession([:]) + + when: + session.retireFeature('unknown_type', '42', java.time.Instant.parse('2026-06-06T10:00:00Z')) + + then: + def ex = thrown(IllegalArgumentException) + ex.message.contains('unknown_type') + 0 * sqlSession.runReturning(_) + } + + def 'patchOpenVersion for an unknown feature type fails fast with IAE'() { + given: + def session = buildSession([:]) + + when: + session.patchOpenVersion('unknown_type', '42', [], null) + + then: + def ex = thrown(IllegalArgumentException) + ex.message.contains('unknown_type') + 0 * sqlSession.runReturning(_) + } + + def 'assertNoConflictingVersion for an unknown feature type fails fast with IAE'() { + given: + def session = buildSession([:]) + + when: + session.assertNoConflictingVersion( + 'unknown_type', '42', java.time.Instant.parse('2026-06-06T10:00:00Z')) + + then: + def ex = thrown(IllegalArgumentException) + ex.message.contains('unknown_type') + 0 * sqlSession.runReturning(_) + } + + def 'getOpenVersionStart for an unknown feature type fails fast with IAE'() { + given: + def session = buildSession([:]) + + when: + session.getOpenVersionStart('unknown_type', '42') + + then: + def ex = thrown(IllegalArgumentException) + ex.message.contains('unknown_type') + 0 * sqlSession.runReturning(_) + } } diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/VersionedMutationSqlSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/VersionedMutationSqlSpec.groovy new file mode 100644 index 000000000..6d0b5f55e --- /dev/null +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/VersionedMutationSqlSpec.groovy @@ -0,0 +1,174 @@ +/* + * 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.features.domain.FeatureTransactions +import de.ii.xtraplatform.features.domain.ImmutablePropertyUpdate +import de.ii.xtraplatform.features.domain.MappingRule +import de.ii.xtraplatform.features.domain.SchemaBase +import de.ii.xtraplatform.features.sql.domain.ImmutableSqlQueryColumn +import de.ii.xtraplatform.features.sql.domain.ImmutableSqlQueryMapping +import de.ii.xtraplatform.features.sql.domain.ImmutableSqlQuerySchema +import de.ii.xtraplatform.features.sql.domain.SqlQueryColumn +import de.ii.xtraplatform.features.sql.domain.SqlQueryMapping +import de.ii.xtraplatform.features.sql.domain.SqlQuerySchema +import de.ii.xtraplatform.features.sql.domain.SqlSession +import spock.lang.Specification + +import java.time.Instant + +/** + * Drives the three SQL provider Session methods that an atomic + * Insert → Replace → Update transaction, and asserts the generated + * SQL contains every predicate the versioning semantics require: + * + *
    + *
  • {@code assertNoConflictingVersion} (Insert pre-flight, §1.5): + * SELECT gated by {@code endCol IS NULL OR endCol > ts OR startCol >= ts}. + *
  • {@code retireFeature} (Replace's retire half, §1.3/§1.5/§1.6/§1.8): + * SET adds {@code _nachfolger_lzi_beg = ts}; WHERE adds + * {@code endCol IS NULL AND startCol < ts AND startCol = expectedStart}. + *
  • {@code patchOpenVersion} (Update's RETIRE_IN_PLACE, §1.3/§1.5/§1.8): + * UPDATE's WHERE adds {@code endCol IS NULL AND startCol < newEnd AND + * startCol = expectedStart}. + *
+ * + * The actual Insert / Replace-insert SQL flows through + * {@code FeatureMutationsSql} + {@code FeatureEncoderSql} which need a + * full schema fixture; those paths are exercised end-to-end via the + * gvd/alkis transactions smoke harness, not here. + */ +class VersionedMutationSqlSpec extends Specification { + + static final String TABLE = 'o02340' // AP_PTO main table + static final String COL_ID = 'objid' + static final String COL_START = 'lzi__beg' + static final String COL_END = 'lzi__endx' + static final String COL_SUCC = '_nachfolger_lzi_beg' + static final String FEATURE_TYPE = 'ap_pto' + + SqlSession sqlSession + SqlMutationSession session + + def setup() { + sqlSession = Mock(SqlSession) + Map> mappings = [(FEATURE_TYPE): [buildMapping()]] + session = new SqlMutationSession( + sqlSession, mappings, null, null, null, Optional.empty(), null) + } + + def 'Insert pre-flight: assertNoConflictingVersion SQL includes all three conflict predicates'() { + when: + session.assertNoConflictingVersion( + FEATURE_TYPE, 'DEABCDEF12345678', Instant.parse('2025-10-21T05:24:49Z')) + + then: + 1 * sqlSession.runReturning({ String sql -> + sql.contains("SELECT 1 FROM ${TABLE}") && + sql.contains("${COL_ID} = 'DEABCDEF12345678'") && + sql.contains("${COL_END} IS NULL") && + sql.contains("${COL_END} > '2025-10-21T05:24:49Z'") && + sql.contains("${COL_START} >= '2025-10-21T05:24:49Z'") && + sql.contains('LIMIT 1') + }) >> [] + } + + def 'Replace retire: retireFeature SQL sets end + successor and gates on the composite expectedStart'() { + given: + // retire v1 (started at 05:24:49Z) using v2's start (05:46:11Z) as + // both the retirement timestamp AND the successor pointer. + Instant retireTs = Instant.parse('2025-10-21T05:46:11Z') + Instant expectedStart = Instant.parse('2025-10-21T05:24:49Z') + + when: + session.retireFeature( + FEATURE_TYPE, 'DEABCDEF12345678', retireTs, Optional.of(expectedStart)) + + then: + 1 * sqlSession.runReturning({ String sql -> + sql.startsWith("UPDATE ${TABLE} SET ") && + sql.contains("${COL_END} = '2025-10-21T05:46:11Z'") && + sql.contains("${COL_SUCC} = '2025-10-21T05:46:11Z'") && + sql.contains("${COL_ID} = 'DEABCDEF12345678'") && + sql.contains("${COL_END} IS NULL") && + sql.contains("${COL_START} < '2025-10-21T05:46:11Z'") && + sql.contains("${COL_START} = '2025-10-21T05:24:49Z'") && + sql.contains("RETURNING ${COL_ID}") + }) >> ['DEABCDEF12345678'] + } + + def 'Update retire-in-place: patchOpenVersion SQL sets end and gates on expectedStart + startCol < newEnd'() { + given: + // Update closes v2 (open, started at 05:46:11Z) at 05:46:20Z. The + // composite rid suffix carries v2's start as the If-Unmodified-Since expectation. + FeatureTransactions.PropertyUpdate setEnd = + new ImmutablePropertyUpdate.Builder() + .path(['lzi', 'end']) + .value(Optional.of( + com.fasterxml.jackson.databind.node.TextNode.valueOf( + '2025-10-21T05:46:20Z'))) + .build() + + when: + session.patchOpenVersion( + FEATURE_TYPE, + 'DEABCDEF12345678', + [setEnd], + null, + Optional.of(Instant.parse('2025-10-21T05:46:11Z'))) + + then: + 1 * sqlSession.runReturning({ String sql -> + sql.startsWith("UPDATE ${TABLE} SET ") && + sql.contains("${COL_END} = '2025-10-21T05:46:20Z'") && + sql.contains("${COL_ID} = 'DEABCDEF12345678'") && + sql.contains("${COL_END} IS NULL") && + sql.contains("${COL_START} < '2025-10-21T05:46:20Z'") && + sql.contains("${COL_START} = '2025-10-21T05:46:11Z'") && + sql.contains("RETURNING ${COL_ID}") + }) >> ['DEABCDEF12345678'] + } + + // ── fixture ──────────────────────────────────────────────────────────── + + private static SqlQueryMapping buildMapping() { + SqlQueryColumn id = column(COL_ID, SchemaBase.Type.STRING, SchemaBase.Role.ID) + SqlQueryColumn start = column( + COL_START, SchemaBase.Type.DATETIME, SchemaBase.Role.PRIMARY_INTERVAL_START) + SqlQueryColumn end = column( + COL_END, SchemaBase.Type.DATETIME, SchemaBase.Role.PRIMARY_INTERVAL_END) + SqlQueryColumn succ = column( + COL_SUCC, + SchemaBase.Type.DATETIME, + SchemaBase.Role.SUCCESSOR_INTERVAL_START) + SqlQuerySchema main = new ImmutableSqlQuerySchema.Builder() + .name(TABLE) + .pathSegment(TABLE) + .columns([id, start, end, succ]) + .writableColumns([id, start, end, succ]) + .build() + return new ImmutableSqlQueryMapping.Builder() + .addTables(main) + // patchInternal resolves the end-setting update via getColumnForValue('lzi.end', + // Scope.W); populate writableTables/writableColumns for the property paths + // patchOpenVersion needs to walk. + .putWritableTables('lzi.end', main) + .putWritableColumns('lzi.end', end) + .build() + } + + private static SqlQueryColumn column(String name, SchemaBase.Type type, SchemaBase.Role role) { + return new ImmutableSqlQueryColumn.Builder() + .name(name) + .pathSegment(name) + .type(type) + .role(role) + .schemaIndex(0) + .build() + } +} diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java index 96d66e775..d0d049ef2 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java @@ -12,6 +12,7 @@ import de.ii.xtraplatform.crs.domain.EpsgCrs; import de.ii.xtraplatform.features.domain.FeatureStream.ResultBase; import java.util.List; +import java.util.Map; import java.util.Optional; import org.immutables.value.Value; @@ -122,6 +123,24 @@ default MutationResult createFeatures( return result.build(); } + /** + * Cross-feature batched CREATE with per-role column overrides. After each feature is decoded, + * the provider applies {@code roleOverrides} to the corresponding row: an entry with a non-null + * value forces the role-bearing column to that value (the caller pre-formats it as the SQL + * provider would expect — e.g. an RFC 3339 string for {@code DATETIME}); an entry with a {@code + * null} value clears the column so it lands as SQL {@code NULL}. Roles whose column cannot be + * resolved from the type's schema mapping are ignored. The default implementation drops {@code + * roleOverrides} and delegates to {@link #createFeatures(String, Iterable, EpsgCrs)} for + * providers that have not yet adopted the API. + */ + default MutationResult createFeatures( + String featureType, + Iterable featureTokenSources, + EpsgCrs crs, + Map roleOverrides) { + return createFeatures(featureType, featureTokenSources, crs); + } + MutationResult updateFeature( String type, String id, @@ -143,6 +162,112 @@ default MutationResult patchFeature( "Property-level updates are not supported by this feature provider session"); } + /** + * Sets the {@code PRIMARY_INTERVAL_END} role-bearing column of the open version of {@code + * featureId} to {@code retirementTimestamp}, on this session's open transaction. Optimistic + * concurrency: the {@code UPDATE} matches only the row whose end column is currently {@code + * NULL}. Returns a result whose {@link MutationResult#getIds()} is the retired feature's + * role-id (i.e. the value the {@code ID} role column held); empty when no open version was + * found — the caller maps that to a 409-style conflict. Roles that the type's schema mapping + * does not bind to a column produce an error in the result. + * + *

The default implementation throws {@link UnsupportedOperationException} for providers that + * have not yet adopted the API. + */ + default MutationResult retireFeature( + String featureType, String featureId, java.time.Instant retirementTimestamp) { + return retireFeature(featureType, featureId, retirementTimestamp, Optional.empty()); + } + + /** + * Variant of {@link #retireFeature(String, String, java.time.Instant)} that adds an + * If-Unmodified-Since-style predicate: the open version's {@code PRIMARY_INTERVAL_START} must + * equal {@code expectedStart}, otherwise the {@code UPDATE} matches 0 rows and the caller maps + * that to a 412 Precondition Failed. Empty {@code expectedStart} keeps the three-arg semantics. + */ + default MutationResult retireFeature( + String featureType, + String featureId, + java.time.Instant retirementTimestamp, + Optional expectedStart) { + throw new UnsupportedOperationException( + "Feature retirement is not supported by this feature provider session"); + } + + /** + * Reject an insert that would create a conflicting version for {@code featureId} at {@code + * insertTimestamp}: an existing row matches when its {@code PRIMARY_INTERVAL_END} is {@code + * NULL} (another open version), when its {@code end} is later than {@code insertTimestamp} + * (overlap), or when its {@code start} is at or after {@code insertTimestamp} (no-backdating). + * Returns a result with {@code error} set when a conflict is found; an empty success result + * otherwise. The default implementation returns success (no check) for providers that have not + * adopted the API. + */ + default MutationResult assertNoConflictingVersion( + String featureType, String featureId, java.time.Instant insertTimestamp) { + return ImmutableMutationResult.builder() + .type(MutationResult.Type.CREATE) + .hasFeatures(false) + .build(); + } + + /** + * Capture the {@code PRIMARY_INTERVAL_START} value of the open version of {@code featureId}, as + * the same string the encoder would store. Empty when no open version exists. Used by versioned + * mutation paths to populate the {@code PREDECESSOR_INTERVAL_START} denorm column on the new + * version before the retire/insert pair runs. The default returns empty for providers that have + * not adopted the API. + */ + default Optional getOpenVersionStart(String featureType, String featureId) { + return Optional.empty(); + } + + /** + * Same as {@link #patchFeature(String, String, List, EpsgCrs)} but additionally constrains the + * target to the row whose {@code PRIMARY_INTERVAL_END} role-bearing column is currently {@code + * NULL} — i.e. the currently-open version of {@code featureId}. The {@code updates} may include + * setting the end column itself (the retire-with-modifications case). Returns an empty result + * when no open version matches, which the caller maps to a 409-style conflict. + */ + default MutationResult patchOpenVersion( + String featureType, String featureId, List updates, EpsgCrs crs) { + return patchOpenVersion(featureType, featureId, updates, crs, Optional.empty()); + } + + /** + * Variant of {@link #patchOpenVersion(String, String, List, EpsgCrs)} that adds an + * If-Unmodified-Since-style predicate: the open version's {@code PRIMARY_INTERVAL_START} must + * equal {@code expectedStart}, otherwise the {@code UPDATE} matches 0 rows and the caller maps + * that to a 412 Precondition Failed. + */ + default MutationResult patchOpenVersion( + String featureType, + String featureId, + List updates, + EpsgCrs crs, + Optional expectedStart) { + throw new UnsupportedOperationException( + "Open-version patching is not supported by this feature provider session"); + } + + /** + * Clones the open version of {@code featureId} into a new row (forcing {@code + * PRIMARY_INTERVAL_START} to {@code mutationTimestamp} and {@code PRIMARY_INTERVAL_END} to + * {@code NULL}), applies {@code updates} to the new row, and retires the old row by setting its + * {@code PRIMARY_INTERVAL_END} to {@code mutationTimestamp}. Optimistic concurrency matches + * only the row whose {@code PRIMARY_INTERVAL_END} is currently {@code NULL}; an empty result + * signals 409-style conflict. + */ + default MutationResult cloneAndPatchFeature( + String featureType, + String featureId, + List updates, + java.time.Instant mutationTimestamp, + EpsgCrs crs) { + throw new UnsupportedOperationException( + "Clone-and-patch is not supported by this feature provider session"); + } + /** Commits all mutations performed against this session. Throws if already finalised. */ void commit(); diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java index 7fd323be8..3c8183209 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java @@ -33,6 +33,19 @@ enum Role { PRIMARY_INSTANT, PRIMARY_INTERVAL_START, PRIMARY_INTERVAL_END, + /** + * Denormalised pointer to the predecessor version's PRIMARY_INTERVAL_START value. On versioned + * collections, write paths maintain this so a feature can be walked backwards through its + * version chain without an extra join. The strategy's mutation pipeline populates it (see + * {@code MutationStrategy.insertRoleOverrides}). + */ + PREDECESSOR_INTERVAL_START, + /** + * Denormalised pointer to the successor version's PRIMARY_INTERVAL_START value. Set on the + * retired row by the mutation pipeline at retire time so a feature can be walked forwards + * through its version chain. + */ + SUCCESSOR_INTERVAL_START, SECONDARY_GEOMETRY, FILTER_GEOMETRY, EMBEDDED_FEATURE, From 8ee2e85351110bd6d6c2bfa522ed4ca51f28f737 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Tue, 9 Jun 2026 09:29:11 +0200 Subject: [PATCH 03/25] feature schema: remember the original object type per property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each FeatureSchema property now carries an optional originObjectType: the objectType of the schema fragment that originally listed the property. LocalSchemaFragmentResolver tags every property contributed by a merged fragment with the fragment's objectType (recursively, with outer-fragment tagging winning over inner). The tag is @JsonIgnore + @DocIgnore: it is populated only by schema resolution, not from YAML. FeatureTokenDecoderGml's namespace-expectation chain now reads the property's own originObjectType first, then — only when no origin is set and the parent is a NESTED object (not the feature root) — falls back to the parent's objectType. The feature root's objectType no longer propagates down to property children, which is what lets a feature in a domain namespace nest standard properties inherited from a base fragment in the application's default namespace without dragging the feature's prefix onto them. Existing nested-object behaviour is unchanged: a property declared inline under, say, an ISO 19115 metadata object still inherits the nested object's namespace via the parent walk. --- .../gml/domain/FeatureTokenDecoderGml.java | 35 ++++++++---- .../app/LocalSchemaFragmentResolver.java | 53 ++++++++++++++++--- .../features/domain/FeatureSchema.java | 23 +++++--- .../features/domain/FeatureSchemaBase.java | 2 + 4 files changed, 91 insertions(+), 22 deletions(-) diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java index e95fb1c39..ba757f3ea 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java @@ -1033,7 +1033,7 @@ private Optional lookupChild( if (!wireLocalName.equals(stripPrefix(key))) { continue; } - String expectedUri = expectedNamespaceUri(parent, key); + String expectedUri = expectedNamespaceUri(p, parent, key); if (expectedUri == null || expectedUri.equals(wireUri)) { return Optional.of(p); } @@ -1062,15 +1062,24 @@ private static String stripPrefix(String key) { } /** - * Resolves the XML namespace URI expected for a property of {@code parent} whose schema - * name/alias is {@code key}. Mirrors the encoder's qualification chain: + * Resolves the XML namespace URI expected for {@code property}, whose schema name/alias is {@code + * key}. Mirrors the encoder's qualification chain: * *

    *
  1. An explicit {@code prefix:name} in the schema name/alias takes precedence. The prefix is * resolved against the constructor's namespace map (predefined + {@code * applicationNamespaces}). - *
  2. Otherwise the parent's {@code objectType} is looked up in {@code objectTypeNamespaces}; - * its prefix resolves to the URI. + *
  3. Otherwise the property's {@code originObjectType} — the object type from the fragment + * that originally listed this property — is looked up in {@code objectTypeNamespaces}; its + * prefix resolves to the URI. The property's own origin always wins over the parent's + * {@code objectType}; setting it deliberately falls through to the default namespace if the + * type isn't mapped, suppressing the parent walk. + *
  4. Otherwise the parent's {@code objectType} is looked up in {@code objectTypeNamespaces} — + * but only when the parent is a NESTED OBJECT, not the feature root. The feature root's + * {@code objectType} pins the namespace of the feature element itself; it must not + * propagate down to property children that inherited from a different schema fragment. + * Nested OBJECTs (e.g. ISO 19115 {@code LI_Lineage}) do propagate, since their inline child + * properties belong to the nested object's own namespace. *
  5. Otherwise the input profile's {@code defaultNamespace} prefix resolves to the URI. *
* @@ -1078,17 +1087,25 @@ private static String stripPrefix(String key) { * then matches by local name only, preserving the decoder's behaviour when no namespace data is * provided in the input profile. */ - private String expectedNamespaceUri(FeatureSchema parent, String key) { + private String expectedNamespaceUri(FeatureSchema property, FeatureSchema parent, String key) { int colon = key.indexOf(':'); if (colon > 0) { return namespaceNormalizer.getNamespaceURI(key.substring(0, colon)); } - Optional parentObjectType = parent.getObjectType(); - if (parentObjectType.isPresent()) { - String prefix = inputProfile.getObjectTypeNamespaces().get(parentObjectType.get()); + Optional originObjectType = property.getOriginObjectType(); + if (originObjectType.isPresent()) { + String prefix = inputProfile.getObjectTypeNamespaces().get(originObjectType.get()); if (prefix != null) { return namespaceNormalizer.getNamespaceURI(prefix); } + } else if (parent != featureSchema) { + Optional parentObjectType = parent.getObjectType(); + if (parentObjectType.isPresent()) { + String prefix = inputProfile.getObjectTypeNamespaces().get(parentObjectType.get()); + if (prefix != null) { + return namespaceNormalizer.getNamespaceURI(prefix); + } + } } String defaultPrefix = inputProfile.getDefaultNamespace(); if (defaultPrefix != null && !defaultPrefix.isEmpty()) { diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/app/LocalSchemaFragmentResolver.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/app/LocalSchemaFragmentResolver.java index 0f33497b2..9af3fdd6a 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/app/LocalSchemaFragmentResolver.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/app/LocalSchemaFragmentResolver.java @@ -61,8 +61,9 @@ private FeatureSchema merge(FeatureSchema original, FeatureSchema resolved) { return null; } + String resolvedOrigin = resolved.getObjectType().orElse(null); Map properties = - merge(original.getPropertyMap(), resolved.getPropertyMap()); + merge(original.getPropertyMap(), resolved.getPropertyMap(), resolvedOrigin); Builder builder = new Builder() @@ -85,8 +86,9 @@ private FeatureSchema merge(FeatureSchema original, FeatureSchema resolved) { } private PartialObjectSchema merge(PartialObjectSchema original, FeatureSchema resolved) { + String resolvedOrigin = resolved.getObjectType().orElse(null); Map properties = - merge(original.getPropertyMap(), resolved.getPropertyMap()); + merge(original.getPropertyMap(), resolved.getPropertyMap(), resolvedOrigin); return new ImmutablePartialObjectSchema.Builder() .from(original) @@ -97,8 +99,10 @@ private PartialObjectSchema merge(PartialObjectSchema original, FeatureSchema re } private PartialObjectSchema merge(FeatureSchema original, PartialObjectSchema resolved) { + // PartialObjectSchema doesn't carry an objectType — its properties keep whatever origin tag + // they already had (set when their own fragment was resolved earlier in the chain). Map properties = - merge(original.getPropertyMap(), resolved.getPropertyMap()); + merge(original.getPropertyMap(), resolved.getPropertyMap(), null); return new ImmutablePartialObjectSchema.Builder() .from(resolved) @@ -107,19 +111,22 @@ private PartialObjectSchema merge(FeatureSchema original, PartialObjectSchema re } private Map merge( - Map original, Map resolved) { + Map original, + Map resolved, + String resolvedOrigin) { Map properties = new LinkedHashMap<>(); resolved.forEach( (key, property) -> { + FeatureSchema tagged = tagOrigin(property, resolvedOrigin); if (original.containsKey(key)) { - FeatureSchema merged = merge(original.get(key), property); + FeatureSchema merged = merge(original.get(key), tagged); if (Objects.nonNull(merged)) { properties.put(key, merged); } } else { - properties.put(key, property); + properties.put(key, tagged); } }); original.forEach( @@ -132,6 +139,40 @@ private Map merge( return properties; } + // Tags `property` (and recursively its sub-properties) with `originObjectType = originType` — + // but only where the tag is missing. An existing tag wins so an outer fragment's listing + // doesn't get overwritten by a base fragment further down the chain. Codecs that qualify + // property element names per object type (notably GML with `objectTypeNamespaces`) read this + // tag at runtime; without it they'd inherit the containing feature's objectType, which is + // wrong for properties that come from a different schema fragment than the feature itself. + private static FeatureSchema tagOrigin(FeatureSchema property, String originType) { + if (originType == null) { + return property; + } + boolean missing = property.getOriginObjectType().isEmpty(); + Map taggedChildren = null; + for (Map.Entry e : property.getPropertyMap().entrySet()) { + FeatureSchema childTagged = tagOrigin(e.getValue(), originType); + if (childTagged != e.getValue()) { + if (taggedChildren == null) { + taggedChildren = new LinkedHashMap<>(property.getPropertyMap()); + } + taggedChildren.put(e.getKey(), childTagged); + } + } + if (!missing && taggedChildren == null) { + return property; + } + Builder b = new Builder().from(property); + if (missing) { + b.originObjectType(originType); + } + if (taggedChildren != null) { + b.propertyMap(taggedChildren); + } + return b.build(); + } + private FeatureSchema resolve(String ref, FeatureProviderDataV2 data) { String key = getKey(ref); 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 93cba3fe9..f4d0a27a7 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 @@ -199,16 +199,25 @@ default Type getType() { Optional getGeometryType(); /** - * @langEn Optional name for an object type, used for example in JSON Schema. For properties that - * should be mapped as links according to *RFC 8288*, use `Link`. - * @langDe Optional kann ein Name für den Typ spezifiziert werden. Der Name hat i.d.R. nur - * informativen Charakter und wird z.B. bei der Erzeugung von JSON-Schemas verwendet. Bei - * Eigenschaften, die als Web-Links nach RFC 8288 abgebildet werden sollen, ist immer "Link" - * anzugeben. - * @default + * @langEn Optional name for an object type, used for example in JSON Schema. + * @langDe Optional kann ein Name für den Typ spezifiziert werden. Der Name wird z.B. bei der + * Erzeugung von JSON-Schemas verwendet. + * @default null */ Optional getObjectType(); + /** + * The object type whose schema fragment originally listed this property — set by the fragment + * resolver when a property is merged in from a fragment that declares {@code objectType}. Codecs + * that qualify property element names per object type (for example, XML with namespaces) use the + * origin's object type instead of the containing object's, so a property defined in a base + * fragment retains the base fragment's context even when nested under an object that declares a + * different {@code objectType}. + */ + @JsonIgnore + @DocIgnore + Optional getOriginObjectType(); + /** * @langEn Label for the schema object, used for example in HTML representations. * @langDe Eine Bezeichnung des Schemaobjekts, z.B. für die Angabe in der HTML-Ausgabe. diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchemaBase.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchemaBase.java index 63dff0d2c..6e3c7da24 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchemaBase.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchemaBase.java @@ -15,6 +15,8 @@ public interface FeatureSchemaBase> extends Schem Optional getObjectType(); + Optional getOriginObjectType(); + Optional getUnit(); Optional getConstantValue(); From 0f2f9d834eccb72e74def230cbe6475496e0ddf1 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Tue, 9 Jun 2026 15:12:52 +0200 Subject: [PATCH 04/25] transactions: fix check on insert, complete update support The versioned-insert pre-flight is now a plain id-existence check (SELECT 1 FROM main WHERE idCol = ? LIMIT 1). The previous three-predicate SQL silently allowed Insert on a retired feature id. Clients add new versions through Replace / Update / Delete; Insert is reserved for brand-new ids. cloneAndPatchFeature ships (was throwing UnsupportedOperationException): capture the open row's PK + start, clone the main row with inline overrides (start = ts, end = NULL, predecessor, successor) and main-table scalar patches, clone each junction table's rows redirecting the FK to the new PK, retire the old row with the same startCol < ts guard as retireFeature, then apply junction-backed patches via the existing patchInternal path. An expectedStart overload threads the composite-id If-Unmodified-Since predicate through; empty OLD result surfaces as 409 or 412. Specs cover the new SQL shape and fail-fast contracts. --- .../features/sql/app/SqlMutationSession.java | 431 ++++++++++++++++-- .../sql/app/SqlMutationSessionSpec.groovy | 3 +- .../sql/app/VersionedMutationSqlSpec.groovy | 120 ++++- .../features/domain/FeatureTransactions.java | 33 +- 4 files changed, 522 insertions(+), 65 deletions(-) diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java index 000bc4954..74c55e73e 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java @@ -439,14 +439,14 @@ public FeatureTransactions.MutationResult retireFeature( return builder.build(); } - // Versioned-Insert pre-flight (plan §1.5 Part A.insert): refuses to write a new version if any - // existing row for the same role-id would conflict — another open version (end IS NULL), an - // overlapping closed version (end > insertTimestamp), or a backdating violation (start >= - // insertTimestamp). The check runs as a single SELECT on the main table and returns an error - // result the caller maps to a 409. + // Versioned-Insert pre-flight (plan §1.5 Part A.insert): refuses to write a new feature row when + // any version of the same role-id already exists (open or retired). Clients add new versions of + // an existing feature through Replace / Update / Delete; Insert is reserved for brand-new ids. + // The check runs as a single SELECT on the main table and returns an error result the caller + // maps to a 409. @Override public FeatureTransactions.MutationResult assertNoConflictingVersion( - String featureType, String featureId, Instant insertTimestamp) { + String featureType, String featureId) { SqlQueryMapping mapping = requireMapping(featureType); ImmutableMutationResult.Builder builder = ImmutableMutationResult.builder() @@ -454,36 +454,17 @@ public FeatureTransactions.MutationResult assertNoConflictingVersion( .hasFeatures(false); Optional> idColumn = mapping.getColumnForId(); - Optional> - startColumn = mapping.getColumnForRole(SchemaBase.Role.PRIMARY_INTERVAL_START); - Optional> endColumn = - mapping.getColumnForRole(SchemaBase.Role.PRIMARY_INTERVAL_END); - if (idColumn.isEmpty() || startColumn.isEmpty() || endColumn.isEmpty()) { + if (idColumn.isEmpty()) { return builder .error( new IllegalStateException( "Feature type '" + featureType - + "' is missing ID / PRIMARY_INTERVAL_START / PRIMARY_INTERVAL_END role" - + " columns; cannot run the versioned-insert pre-flight.")) + + "' has no id column; cannot run the versioned-insert pre-flight.")) .build(); } String mainTableName = idColumn.get().first().getName(); - if (!Objects.equals(startColumn.get().first().getName(), mainTableName) - || !Objects.equals(endColumn.get().first().getName(), mainTableName)) { - return builder - .error( - new IllegalStateException( - "Feature type '" - + featureType - + "' has id / PRIMARY_INTERVAL_START / PRIMARY_INTERVAL_END on more than" - + " one table; pre-flight requires all three on the main table.")) - .build(); - } String idCol = idColumn.get().second().getName(); - String startCol = startColumn.get().second().getName(); - String endCol = endColumn.get().second().getName(); - String tsLit = sqlString(insertTimestamp.toString()); String sql = "SELECT 1 FROM " + mainTableName @@ -491,31 +472,19 @@ public FeatureTransactions.MutationResult assertNoConflictingVersion( + idCol + " = " + sqlString(featureId) - + " AND (" - + endCol - + " IS NULL OR " - + endCol - + " > " - + tsLit - + " OR " - + startCol - + " >= " - + tsLit - + ") LIMIT 1;"; + + " LIMIT 1;"; try { List hit = sqlSession.runReturning(sql); if (!hit.isEmpty()) { return builder .error( new IllegalArgumentException( - "Cannot create a new version of feature id '" + "Cannot create feature id '" + featureId + "' in collection '" + featureType - + "' at " - + insertTimestamp - + ": a conflicting version exists (another open version, an overlapping" - + " closed version, or a version at or after this timestamp).")) + + "': a version of this feature already exists (use Replace or Update to" + + " add a new version).")) .build(); } } catch (RuntimeException e) { @@ -675,6 +644,382 @@ public FeatureTransactions.MutationResult patchOpenVersion( return patchInternal(featureType, featureId, updates, crs, extra, "open version of feature"); } + // Versioned Update CLONE_AND_PATCH (plan §1.3): create a new version of the open row, carry + // forward every column, apply the property updates, and retire the previous open version. The + // sequence is: + // 1. SELECT the open row's surrogate PK ([+ start when the predecessor role is bound]). + // 2. INSERT INTO main (cols) SELECT … FROM main m WHERE m.pk = OLD_PK, with role-driven + // overrides (start=ts, end=NULL, predecessor=OLD_start, successor=NULL); RETURNING NEW_PK. + // 3. For each writable junction table, INSERT … SELECT carrying every column forward except + // the FK, which is redirected to NEW_PK. + // 4. Retire OLD: UPDATE main SET endCol=ts [, successorCol=ts] WHERE pk=OLD_PK AND endCol IS + // NULL AND startCol < ts. (Same guards as retireFeature.) + // 5. Apply property patches to the now-only open row via patchInternal — main-table scalar + // patches land as a single UPDATE; VALUE_ARRAY / OBJECT_ARRAY patches reuse the existing + // DELETE+INSERT junction path. + // An empty result on step 1 → caller maps to 409 (or 412 when `expectedStart` was present). + @Override + public FeatureTransactions.MutationResult cloneAndPatchFeature( + String featureType, + String featureId, + List updates, + Instant mutationTimestamp, + EpsgCrs crs, + Optional expectedStart) { + SqlQueryMapping mapping = requireMapping(featureType); + ImmutableMutationResult.Builder builder = + ImmutableMutationResult.builder() + .type(FeatureTransactions.MutationResult.Type.UPDATE) + .hasFeatures(false); + + Optional> idColumn = + mapping.getColumnForId(); + Optional> + startColumn = mapping.getColumnForRole(SchemaBase.Role.PRIMARY_INTERVAL_START); + Optional> endColumn = + mapping.getColumnForRole(SchemaBase.Role.PRIMARY_INTERVAL_END); + if (idColumn.isEmpty() || startColumn.isEmpty() || endColumn.isEmpty()) { + return builder + .error( + new IllegalStateException( + "Feature type '" + + featureType + + "' is missing ID / PRIMARY_INTERVAL_START / PRIMARY_INTERVAL_END role" + + " columns; cannot clone-and-patch.")) + .build(); + } + SqlQuerySchema mainTable = idColumn.get().first(); + String mainTableName = mainTable.getName(); + if (!Objects.equals(startColumn.get().first().getName(), mainTableName) + || !Objects.equals(endColumn.get().first().getName(), mainTableName)) { + return builder + .error( + new IllegalStateException( + "Feature type '" + + featureType + + "' has id / PRIMARY_INTERVAL_START / PRIMARY_INTERVAL_END on more than" + + " one table; clone-and-patch requires all three on the main table.")) + .build(); + } + String idColumnName = idColumn.get().second().getName(); + String startColumnName = startColumn.get().second().getName(); + String endColumnName = endColumn.get().second().getName(); + String pkColumnName = mainTable.getSortKey(); + String tsLiteral = sqlString(mutationTimestamp.toString()); + + Optional> + predecessorColumn = mapping.getColumnForRole(SchemaBase.Role.PREDECESSOR_INTERVAL_START); + boolean predecessorOnMain = + predecessorColumn.isPresent() + && Objects.equals(predecessorColumn.get().first().getName(), mainTableName); + Optional> + successorColumn = mapping.getColumnForRole(SchemaBase.Role.SUCCESSOR_INTERVAL_START); + boolean successorOnMain = + successorColumn.isPresent() + && Objects.equals(successorColumn.get().first().getName(), mainTableName); + + // Step 1: capture the open row's surrogate PK (and reject early if no open row matches). + StringBuilder findPk = + new StringBuilder("SELECT ") + .append(pkColumnName) + .append(" FROM ") + .append(mainTableName) + .append(" WHERE ") + .append(idColumnName) + .append(" = ") + .append(sqlString(featureId)) + .append(" AND ") + .append(endColumnName) + .append(" IS NULL"); + if (expectedStart.isPresent()) { + findPk + .append(" AND ") + .append(startColumnName) + .append(" = ") + .append(sqlString(expectedStart.get().toString())); + } + findPk.append(" LIMIT 1;"); + List oldPkRows; + try { + oldPkRows = sqlSession.runReturning(findPk.toString()); + } catch (RuntimeException e) { + return builder.error(e).build(); + } + if (oldPkRows.isEmpty()) { + // No open version (concurrent retirement / unknown id) — or expectedStart mismatch (412). + // Caller distinguishes by the presence of expectedStart. + return builder.build(); + } + String oldPkLit = sqlLiteralForPk(oldPkRows.get(0)); + + // Step 1b: capture the open row's start, when needed for the predecessor denorm column. + Optional oldStart = Optional.empty(); + if (predecessorOnMain) { + try { + List startRows = + sqlSession.runReturning( + "SELECT " + + startColumnName + + " FROM " + + mainTableName + + " WHERE " + + pkColumnName + + " = " + + oldPkLit + + ";"); + if (!startRows.isEmpty()) { + oldStart = Optional.of(startRows.get(0)); + } + } catch (RuntimeException e) { + return builder.error(e).build(); + } + } + + // Resolve which property updates land on the main table — those become inline overrides on the + // clone INSERT, so the patches don't need a second UPDATE round-trip. + Map mainColumnPatches = new java.util.LinkedHashMap<>(); + for (FeatureTransactions.PropertyUpdate u : updates) { + String joined = String.join(".", u.getPath()); + Optional> resolved = + mapping.getColumnForValue(joined, MappingRule.Scope.W); + if (resolved.isPresent() && Objects.equals(resolved.get().first().getName(), mainTableName)) { + SqlQueryColumn col = resolved.get().second(); + mainColumnPatches.put(col.getName(), encodeLiteral(col, u.getValue(), crs)); + } + } + + // Step 2: clone the main row with literal overrides for role-bearing columns and inline + // main-column patches; carry-forward everything else. + List insertCols = new ArrayList<>(); + List selectExprs = new ArrayList<>(); + for (SqlQueryColumn col : mainTable.getColumns()) { + String name = col.getName(); + if (Objects.equals(name, pkColumnName)) { + continue; // auto-PK — let the DB generate the new value + } + insertCols.add(name); + Optional role = col.getRole(); + if (role.isPresent()) { + if (role.get() == SchemaBase.Role.PRIMARY_INTERVAL_START) { + selectExprs.add(tsLiteral); + continue; + } + if (role.get() == SchemaBase.Role.PRIMARY_INTERVAL_END) { + selectExprs.add("NULL"); + continue; + } + if (role.get() == SchemaBase.Role.PREDECESSOR_INTERVAL_START) { + selectExprs.add(oldStart.map(SqlMutationSession::sqlString).orElse("NULL")); + continue; + } + if (role.get() == SchemaBase.Role.SUCCESSOR_INTERVAL_START) { + // The new row is open — no successor yet. + selectExprs.add("NULL"); + continue; + } + } + String patchLit = mainColumnPatches.get(name); + selectExprs.add(patchLit != null ? patchLit : "m." + name); + } + String cloneMainSql = + "INSERT INTO " + + mainTableName + + " (" + + String.join(", ", insertCols) + + ") SELECT " + + String.join(", ", selectExprs) + + " FROM " + + mainTableName + + " m WHERE m." + + pkColumnName + + " = " + + oldPkLit + + " RETURNING " + + pkColumnName + + ";"; + List newPkRows; + try { + newPkRows = sqlSession.runReturning(cloneMainSql); + } catch (RuntimeException e) { + return builder.error(e).build(); + } + if (newPkRows.isEmpty()) { + return builder + .error( + new IllegalStateException( + "Clone-and-patch of feature id '" + + featureId + + "' in collection '" + + featureType + + "' did not return a new row PK; clone INSERT must have inserted 0 rows.")) + .build(); + } + String newPkLit = sqlLiteralForPk(newPkRows.get(0)); + + // Step 3: clone every writable junction table's rows for OLD_PK, redirecting the FK to NEW_PK. + // Deduplicate junctions across writable-table entries (multiple property paths can map to one + // junction). + java.util.Set clonedJunctions = new java.util.LinkedHashSet<>(); + for (SqlQuerySchema junction : mapping.getWritableTables().values()) { + if (junction == null || Objects.equals(junction.getName(), mainTableName)) { + continue; + } + if (!clonedJunctions.add(junction.getName())) { + continue; + } + try { + cloneJunctionRows(junction, oldPkLit, newPkLit); + } catch (RuntimeException e) { + return builder.error(e).build(); + } + } + + // Step 4: retire OLD. Same guard as retireFeature — no-backdating + must be the open row. + StringBuilder retireSet = new StringBuilder(endColumnName).append(" = ").append(tsLiteral); + if (successorOnMain) { + retireSet + .append(", ") + .append(successorColumn.get().second().getName()) + .append(" = ") + .append(tsLiteral); + } + String retireSql = + "UPDATE " + + mainTableName + + " SET " + + retireSet + + " WHERE " + + pkColumnName + + " = " + + oldPkLit + + " AND " + + endColumnName + + " IS NULL AND " + + startColumnName + + " < " + + tsLiteral + + " RETURNING " + + pkColumnName + + ";"; + List retiredRows; + try { + retiredRows = sqlSession.runReturning(retireSql); + } catch (RuntimeException e) { + return builder.error(e).build(); + } + if (retiredRows.isEmpty()) { + return builder + .error( + new IllegalStateException( + "Clone-and-patch of feature id '" + + featureId + + "' in collection '" + + featureType + + "': failed to retire the previous open version (concurrent modification or" + + " no-backdating violation).")) + .build(); + } + + // Step 5: patches on the new (now only) open row. Main-column patches already applied inline + // during the clone (Step 2); only junction-backed patches need post-clone DELETE+INSERT. The + // junction patch path uses `idCol = ? AND endCol IS NULL` to find the parent PK — that resolves + // to NEW_PK now that OLD has been retired. + List junctionUpdates = new ArrayList<>(); + for (FeatureTransactions.PropertyUpdate u : updates) { + String joined = String.join(".", u.getPath()); + Optional> resolved = + mapping.getColumnForValue(joined, MappingRule.Scope.W); + boolean onMain = + resolved.isPresent() && Objects.equals(resolved.get().first().getName(), mainTableName); + if (!onMain) { + junctionUpdates.add(u); + } + } + if (!junctionUpdates.isEmpty()) { + FeatureTransactions.MutationResult patchResult = + patchInternal( + featureType, + featureId, + junctionUpdates, + crs, + " AND " + endColumnName + " IS NULL", + "open version of feature"); + if (patchResult.getError().isPresent()) { + return builder.error(patchResult.getError().get()).build(); + } + } + + builder.addIds(featureId); + return builder.build(); + } + + // Clone every row of `junction` whose FK = oldPkLit into a new row whose FK = newPkLit. Carries + // every other column forward. The junction's own auto-PK column (sortKey) is omitted so the DB + // generates fresh values per cloned row. + private void cloneJunctionRows(SqlQuerySchema junction, String oldPkLit, String newPkLit) { + if (junction.getRelations().isEmpty()) { + return; + } + SqlQueryJoin join = junction.getRelations().get(0); + String fkColumn = join.getTargetField(); + String junctionPk = junction.getSortKey(); + List insertCols = new ArrayList<>(); + List selectExprs = new ArrayList<>(); + boolean fkSeen = false; + for (SqlQueryColumn col : junction.getColumns()) { + String name = col.getName(); + if (Objects.equals(name, junctionPk)) { + continue; // auto-PK on the junction itself + } + insertCols.add(name); + if (Objects.equals(name, fkColumn)) { + selectExprs.add(newPkLit); + fkSeen = true; + } else { + selectExprs.add(name); + } + } + if (!fkSeen) { + // The FK column may live outside getColumns() when the schema mapping doesn't expose it as a + // data column (it's only the relation key). Add it explicitly so the cloned row is reachable. + insertCols.add(fkColumn); + selectExprs.add(newPkLit); + } + if (insertCols.isEmpty()) { + return; + } + String sql = + "INSERT INTO " + + junction.getName() + + " (" + + String.join(", ", insertCols) + + ") SELECT " + + String.join(", ", selectExprs) + + " FROM " + + junction.getName() + + " WHERE " + + fkColumn + + " = " + + oldPkLit + + ";"; + sqlSession.runReturning(sql); + } + + // Format a captured PK value (returned by SqlSession.runReturning as a String) for inlining as + // a SQL literal. Surrogate PKs are typically auto-increment integers; fall back to quoting for + // anything that isn't a plain integer. + private static String sqlLiteralForPk(String raw) { + if (raw == null) { + return "NULL"; + } + try { + Long.parseLong(raw); + return raw; + } catch (NumberFormatException ignored) { + return sqlString(raw); + } + } + private FeatureTransactions.MutationResult patchInternal( String featureType, String featureId, diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlMutationSessionSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlMutationSessionSpec.groovy index 515b5e7be..9edba8670 100644 --- a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlMutationSessionSpec.groovy +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlMutationSessionSpec.groovy @@ -124,8 +124,7 @@ class SqlMutationSessionSpec extends Specification { def session = buildSession([:]) when: - session.assertNoConflictingVersion( - 'unknown_type', '42', java.time.Instant.parse('2026-06-06T10:00:00Z')) + session.assertNoConflictingVersion('unknown_type', '42') then: def ex = thrown(IllegalArgumentException) diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/VersionedMutationSqlSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/VersionedMutationSqlSpec.groovy index 6d0b5f55e..2a3d1357f 100644 --- a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/VersionedMutationSqlSpec.groovy +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/VersionedMutationSqlSpec.groovy @@ -29,7 +29,8 @@ import java.time.Instant * *
    *
  • {@code assertNoConflictingVersion} (Insert pre-flight, §1.5): - * SELECT gated by {@code endCol IS NULL OR endCol > ts OR startCol >= ts}. + * SELECT gated by an id-existence check — any version (open or retired) of + * the same id rejects the Insert. *
  • {@code retireFeature} (Replace's retire half, §1.3/§1.5/§1.6/§1.8): * SET adds {@code _nachfolger_lzi_beg = ts}; WHERE adds * {@code endCol IS NULL AND startCol < ts AND startCol = expectedStart}. @@ -62,19 +63,17 @@ class VersionedMutationSqlSpec extends Specification { sqlSession, mappings, null, null, null, Optional.empty(), null) } - def 'Insert pre-flight: assertNoConflictingVersion SQL includes all three conflict predicates'() { + def 'Insert pre-flight: assertNoConflictingVersion SQL is a plain id-existence check'() { when: - session.assertNoConflictingVersion( - FEATURE_TYPE, 'DEABCDEF12345678', Instant.parse('2025-10-21T05:24:49Z')) + session.assertNoConflictingVersion(FEATURE_TYPE, 'DEABCDEF12345678') then: 1 * sqlSession.runReturning({ String sql -> sql.contains("SELECT 1 FROM ${TABLE}") && sql.contains("${COL_ID} = 'DEABCDEF12345678'") && - sql.contains("${COL_END} IS NULL") && - sql.contains("${COL_END} > '2025-10-21T05:24:49Z'") && - sql.contains("${COL_START} >= '2025-10-21T05:24:49Z'") && - sql.contains('LIMIT 1') + sql.contains('LIMIT 1') && + !sql.contains(COL_END) && + !sql.contains(COL_START) }) >> [] } @@ -102,6 +101,91 @@ class VersionedMutationSqlSpec extends Specification { }) >> ['DEABCDEF12345678'] } + def 'Update clone-and-patch: cloneAndPatchFeature emits SELECT pk, INSERT clone, retire UPDATE'() { + given: + // The fixture has no PREDECESSOR_INTERVAL_START role, so no second OLD-start SELECT runs. + // `text` is a regular scalar; we patch it to verify the literal lands inline in the clone's + // SELECT (rather than as a separate UPDATE round-trip after retire). + FeatureTransactions.PropertyUpdate setText = + new ImmutablePropertyUpdate.Builder() + .path(['text']) + .value(Optional.of( + com.fasterxml.jackson.databind.node.TextNode.valueOf('updated'))) + .build() + + when: + session.cloneAndPatchFeature( + FEATURE_TYPE, + 'DEABCDEF12345678', + [setText], + Instant.parse('2025-10-21T05:46:11Z'), + null, + Optional.empty()) + + then: + // (1) Capture OLD_PK. + 1 * sqlSession.runReturning({ String sql -> + sql.startsWith('SELECT id FROM ') && + sql.contains(TABLE) && + sql.contains("${COL_ID} = 'DEABCDEF12345678'") && + sql.contains("${COL_END} IS NULL") + }) >> ['42'] + + then: + // (2) Clone main row with role-driven overrides + inline patch literal. + 1 * sqlSession.runReturning({ String sql -> + sql.startsWith("INSERT INTO ${TABLE} (") && + sql.contains(COL_ID) && + sql.contains(COL_START) && + sql.contains(COL_END) && + sql.contains(COL_SUCC) && + sql.contains('text') && + sql.contains('SELECT') && + sql.contains("m.${COL_ID}") && + sql.contains("'2025-10-21T05:46:11Z'") && // start override + sql.contains('NULL') && // end + successor + sql.contains("'updated'") && // patch literal inline + sql.contains("WHERE m.id = 42") && + sql.contains('RETURNING id') + }) >> ['43'] + + then: + // (3) Retire OLD by surrogate PK with the no-backdating guard. + 1 * sqlSession.runReturning({ String sql -> + sql.startsWith("UPDATE ${TABLE} SET ") && + sql.contains("${COL_END} = '2025-10-21T05:46:11Z'") && + sql.contains("${COL_SUCC} = '2025-10-21T05:46:11Z'") && + sql.contains('WHERE id = 42') && + sql.contains("${COL_END} IS NULL") && + sql.contains("${COL_START} < '2025-10-21T05:46:11Z'") && + sql.contains('RETURNING id') + }) >> ['42'] + } + + def 'Update clone-and-patch: empty OLD_PK SELECT returns no ids (caller maps to 409/412)'() { + when: + def result = session.cloneAndPatchFeature( + FEATURE_TYPE, + 'UNKNOWN_ID', + [], + Instant.parse('2025-10-21T05:46:11Z'), + null, + Optional.of(Instant.parse('2025-10-21T05:24:49Z'))) + + then: + // The OLD-PK SELECT carries the expectedStart predicate — its emptiness is the 412 signal. + 1 * sqlSession.runReturning({ String sql -> + sql.contains('SELECT id FROM ') && + sql.contains("${COL_ID} = 'UNKNOWN_ID'") && + sql.contains("${COL_END} IS NULL") && + sql.contains("${COL_START} = '2025-10-21T05:24:49Z'") + }) >> [] + // No subsequent SQL runs. + 0 * sqlSession.runReturning(_) + result.ids.isEmpty() + result.error.isEmpty() + } + def 'Update retire-in-place: patchOpenVersion SQL sets end and gates on expectedStart + startCol < newEnd'() { given: // Update closes v2 (open, started at 05:46:11Z) at 05:46:20Z. The @@ -146,19 +230,24 @@ class VersionedMutationSqlSpec extends Specification { COL_SUCC, SchemaBase.Type.DATETIME, SchemaBase.Role.SUCCESSOR_INTERVAL_START) + // Regular scalar column — no role — used by the clone-and-patch test to verify both + // carry-forward (`m.text`) and inline patch literal (`'updated'`). + SqlQueryColumn text = plainColumn('text', SchemaBase.Type.STRING) SqlQuerySchema main = new ImmutableSqlQuerySchema.Builder() .name(TABLE) .pathSegment(TABLE) - .columns([id, start, end, succ]) - .writableColumns([id, start, end, succ]) + .columns([id, start, end, succ, text]) + .writableColumns([id, start, end, succ, text]) .build() return new ImmutableSqlQueryMapping.Builder() .addTables(main) // patchInternal resolves the end-setting update via getColumnForValue('lzi.end', // Scope.W); populate writableTables/writableColumns for the property paths - // patchOpenVersion needs to walk. + // patchOpenVersion / cloneAndPatchFeature need to walk. .putWritableTables('lzi.end', main) .putWritableColumns('lzi.end', end) + .putWritableTables('text', main) + .putWritableColumns('text', text) .build() } @@ -171,4 +260,13 @@ class VersionedMutationSqlSpec extends Specification { .schemaIndex(0) .build() } + + private static SqlQueryColumn plainColumn(String name, SchemaBase.Type type) { + return new ImmutableSqlQueryColumn.Builder() + .name(name) + .pathSegment(name) + .type(type) + .schemaIndex(0) + .build() + } } diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java index d0d049ef2..c71f8d660 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java @@ -195,16 +195,14 @@ default MutationResult retireFeature( } /** - * Reject an insert that would create a conflicting version for {@code featureId} at {@code - * insertTimestamp}: an existing row matches when its {@code PRIMARY_INTERVAL_END} is {@code - * NULL} (another open version), when its {@code end} is later than {@code insertTimestamp} - * (overlap), or when its {@code start} is at or after {@code insertTimestamp} (no-backdating). - * Returns a result with {@code error} set when a conflict is found; an empty success result - * otherwise. The default implementation returns success (no check) for providers that have not - * adopted the API. + * Reject an insert that targets a {@code featureId} which already exists in any version — open + * or retired. Versioned-collection clients add new versions of an existing feature through + * {@code Replace} / {@code Update} / {@code Delete}, not through {@code Insert}, so any + * existing row for the same id is a conflict regardless of its interval state. Returns a result + * with {@code error} set when a row is found; an empty success result otherwise. The default + * implementation returns success (no check) for providers that have not adopted the API. */ - default MutationResult assertNoConflictingVersion( - String featureType, String featureId, java.time.Instant insertTimestamp) { + default MutationResult assertNoConflictingVersion(String featureType, String featureId) { return ImmutableMutationResult.builder() .type(MutationResult.Type.CREATE) .hasFeatures(false) @@ -264,6 +262,23 @@ default MutationResult cloneAndPatchFeature( List updates, java.time.Instant mutationTimestamp, EpsgCrs crs) { + return cloneAndPatchFeature( + featureType, featureId, updates, mutationTimestamp, crs, Optional.empty()); + } + + /** + * Variant of {@link #cloneAndPatchFeature(String, String, List, java.time.Instant, EpsgCrs)} + * that adds an If-Unmodified-Since-style predicate: the open version's {@code + * PRIMARY_INTERVAL_START} must equal {@code expectedStart}, otherwise the open-version lookup + * matches 0 rows and the caller maps that to a 412 Precondition Failed. + */ + default MutationResult cloneAndPatchFeature( + String featureType, + String featureId, + List updates, + java.time.Instant mutationTimestamp, + EpsgCrs crs, + Optional expectedStart) { throw new UnsupportedOperationException( "Clone-and-patch is not supported by this feature provider session"); } From 7661bc93b65ecdba82a853a7eeb3901b932b9808 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Wed, 10 Jun 2026 15:47:17 +0200 Subject: [PATCH 05/25] features: pipeline infrastructure for versioned features - SchemaBase.Role.getLinkRelation() with PREDECESSOR_INTERVAL_START and SUCCESSOR_INTERVAL_START mapped to the predecessor-version / successor-version link relations. - FeatureTokenTransformerLinkRoles strips role-as-link values from the token stream and surfaces them via Result.getRoleLinks() / context. - FeatureTokenTransformerVersionIntervals captures the (PRIMARY_INTERVAL_START, PRIMARY_INTERVAL_END) tuples per feature for the Time Map endpoint. - FeatureTokenTransformerExtension SPI lets a FeatureQueryExtension contribute a token-stream transformer; FeatureStreamImpl wires contributed transformers in the pre-format slot alongside LinkRoles. - FeatureEventHandler ModifiableContext gains roleLinks() and canonicalFeatureId() (with the mirroring setters on FeatureEventHandlerSimple). - FeatureStream Result and ResultReduced expose getRoleLinks() and getVersionIntervals() so the queries handler can build HTTP Link headers without re-decoding the response. - FeatureTokenTransformerMappings propagates the per-feature context state (roleLinks, canonicalFeatureId) into its newContext before flushing the buffer; without that propagation upstream transformer state was dropped at the format-transformation boundary. - DeterminePipelineStepsThatCannotBeSkipped keeps MAPPING_VALUES when a schema property carries a versioned-features role (ID, PRIMARY_INTERVAL_START/END, or a role that declares a link relation) so the default DATETIME_FORMAT transformer runs and the captured timestamps reach the new transformers in ISO 8601. --- ...rminePipelineStepsThatCannotBeSkipped.java | 13 +++ .../features/domain/FeatureEventHandler.java | 21 ++++ .../features/domain/FeatureStream.java | 8 ++ .../features/domain/FeatureStreamImpl.java | 45 +++++++-- .../FeatureTokenTransformerExtension.java | 21 ++++ .../FeatureTokenTransformerLinkRoles.java | 95 +++++++++++++++++++ .../FeatureTokenTransformerMappings.java | 13 +++ ...atureTokenTransformerVersionIntervals.java | 80 ++++++++++++++++ .../features/domain/SchemaBase.java | 30 +++++- .../pipeline/FeatureEventHandlerSimple.java | 4 + 10 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerExtension.java create mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java create mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerVersionIntervals.java diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkipped.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkipped.java index bdefb173a..b14523384 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkipped.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkipped.java @@ -164,6 +164,19 @@ public Set visit( if (schema.getConstraints().filter(SchemaConstraints::isRequired).isEmpty()) { steps.add(PipelineSteps.CLEAN); } + + // Versioned-features roles: keep value mappings so the default DATETIME_FORMAT + // transformer runs and the captured timestamps are ISO 8601 before reaching the + // FeatureTokenTransformerLinkRoles / FeatureTokenTransformerCompositeId / Memento-Datetime + // header construction. Without this the raw PostgreSQL text (or whatever the SQL provider + // emits) would flow through, and the role-link headers / composite-id rewrite would have + // to do their own parsing. + if (schema.isId() + || schema.isPrimaryIntervalStart() + || schema.isPrimaryIntervalEnd() + || schema.getRole().flatMap(SchemaBase.Role::getLinkRelation).isPresent()) { + steps.add(PipelineSteps.MAPPING_VALUES); + } } return steps.build(); diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureEventHandler.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureEventHandler.java index 3e3827f69..091796007 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureEventHandler.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureEventHandler.java @@ -82,6 +82,23 @@ default boolean isUseTargetPaths() { Map additionalInfo(); + /** + * Captured (linkRelation, value) pairs for the current feature, populated by {@link + * FeatureTokenTransformerLinkRoles} from schema properties whose {@link SchemaBase.Role role + * declares a link relation}. Encoders use it to emit per-feature link entries. + */ + Map roleLinks(); + + /** + * Canonical (untransformed) feature id, captured when a profile rewrites the id token (e.g. + * {@code versions-as-features-unique-ids} produces a composite {@code id.}). + * Encoders that need the stable id rather than the composite — most notably {@code + * gml:identifier} — read this; when null, {@code context.value()} on the id property is the + * canonical id and should be used. + */ + @Nullable + String canonicalFeatureId(); + @Value.Lazy default Optional schema() { if (Objects.isNull(mapping())) { @@ -304,6 +321,10 @@ default boolean isRequired(T schema, List parentSchemas) { ModifiableContext setIsUseTargetPaths(boolean isUseTargetPaths); ModifiableContext putAdditionalInfo(String key, String value); + + ModifiableContext setRoleLinks(Map roleLinks); + + ModifiableContext setCanonicalFeatureId(@Nullable String canonicalFeatureId); } // T createContext(); diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStream.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStream.java index bdaf88659..87b546e1a 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStream.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStream.java @@ -99,6 +99,10 @@ abstract class Builder extends ResultBase.Builder {} Optional getSpatialExtent(); Optional> getTemporalExtent(); + + Map getRoleLinks(); + + java.util.List> getVersionIntervals(); } @Value.Immutable @@ -119,6 +123,10 @@ abstract class Builder extends ResultBase.Builder, Builder getSpatialExtent(); Optional> getTemporalExtent(); + + Map getRoleLinks(); + + java.util.List> getVersionIntervals(); } interface ResultBase { diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java index 8464b52ff..908fbb74d 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java @@ -99,11 +99,25 @@ public CompletionStage runWith( BiFunction, Stream> stream = (tokenSource, virtualTables) -> { + ImmutableResult.Builder resultBuilder = ImmutableResult.builder(); + // LinkRoles must run before the per-format value-transformation step so it captures + // the raw ISO timestamp, not a locale-formatted variant used in the body FeatureTokenSource source = + tokenSource.via(new FeatureTokenTransformerLinkRoles(resultBuilder)); + // FeatureTokenTransformerExtension query-extensions (e.g. composite-id rewrite) run in + // the same pre-format slot so they see raw provider values and can mutate tokens before + // any format-specific transformation + if (query instanceof FeatureQuery) { + for (FeatureQueryExtension ext : ((FeatureQuery) query).getExtensions()) { + if (ext instanceof FeatureTokenTransformerExtension) { + source = source.via(((FeatureTokenTransformerExtension) ext).createTransformer()); + } + } + } + source = doTransform - ? getFeatureTokenSourceTransformed(tokenSource, mergedTransformations) - : tokenSource; - ImmutableResult.Builder resultBuilder = ImmutableResult.builder(); + ? getFeatureTokenSourceTransformed(source, mergedTransformations) + : source; final ETag.Incremental eTag = ETag.incremental(); final boolean strongETag = query instanceof FeatureQuery @@ -125,6 +139,8 @@ public CompletionStage runWith( source = source.via(new FeatureTokenTransformerMetadata(resultBuilder)); } + source = source.via(new FeatureTokenTransformerVersionIntervals(resultBuilder)); + source = source.via(new FeatureTokenTransformerHooks(resultBuilder, onCollectionMetadata)); @@ -166,11 +182,25 @@ public CompletionStage> runWith( BiFunction, Reactive.Stream>> stream = (tokenSource, virtualTables) -> { + ImmutableResultReduced.Builder resultBuilder = ImmutableResultReduced.builder(); + // LinkRoles must run before the per-format value-transformation step so it captures + // the raw ISO timestamp, not a locale-formatted variant used in the body FeatureTokenSource source = + tokenSource.via(new FeatureTokenTransformerLinkRoles(resultBuilder)); + // FeatureTokenTransformerExtension query-extensions (e.g. composite-id rewrite) run in + // the same pre-format slot so they see raw provider values and can mutate tokens before + // any format-specific transformation + if (query instanceof FeatureQuery) { + for (FeatureQueryExtension ext : ((FeatureQuery) query).getExtensions()) { + if (ext instanceof FeatureTokenTransformerExtension) { + source = source.via(((FeatureTokenTransformerExtension) ext).createTransformer()); + } + } + } + source = doTransform - ? getFeatureTokenSourceTransformed(tokenSource, mergedTransformations) - : tokenSource; - ImmutableResultReduced.Builder resultBuilder = ImmutableResultReduced.builder(); + ? getFeatureTokenSourceTransformed(source, mergedTransformations) + : source; final ETag.Incremental eTag = ETag.incremental(); final boolean strongETag = query instanceof FeatureQuery @@ -190,6 +220,9 @@ public CompletionStage> runWith( if (stepMetadata) { source = source.via(new FeatureTokenTransformerMetadata(resultBuilder)); } + + source = source.via(new FeatureTokenTransformerVersionIntervals(resultBuilder)); + source = source.via(new FeatureTokenTransformerHooks(resultBuilder, onCollectionMetadata)); diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerExtension.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerExtension.java new file mode 100644 index 000000000..83950c053 --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerExtension.java @@ -0,0 +1,21 @@ +/* + * 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.domain; + +/** + * A {@link FeatureQueryExtension} that contributes a {@link FeatureTokenTransformer} to the feature + * stream pipeline. Attached to a {@link FeatureQuery} by upstream code (e.g. a profile's {@code + * transformFeatureQuery}); {@link FeatureStreamImpl} discovers all such extensions on the query and + * wires their transformers in immediately after {@code FeatureTokenTransformerLinkRoles}, before + * the per-format value-transformation step. This is the right slot for transformers that need to + * see raw provider values (pre-format) and rewrite tokens in-place. + */ +public interface FeatureTokenTransformerExtension extends FeatureQueryExtension { + + FeatureTokenTransformer createTransformer(); +} diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java new file mode 100644 index 000000000..f0549f091 --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java @@ -0,0 +1,95 @@ +/* + * 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.domain; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; + +/** + * Strips property values whose schema role declares a {@link SchemaBase.Role#getLinkRelation() link + * relation} (e.g. {@code PREDECESSOR_INTERVAL_START}, {@code SUCCESSOR_INTERVAL_START}) from the + * token stream so downstream encoders do not emit them as inline feature properties. The captured + * {@code (linkRelation, value)} pairs are exposed on the {@link FeatureStream.Result} / {@link + * FeatureStream.ResultReduced} via {@code getRoleLinks()} so the response handler can build HTTP + * {@code Link} headers and per-feature link items in the response body. + * + *

    The result-level map captures the most recent feature's role values; for the single-feature + * endpoint that is the only feature, which is the primary use case. + */ +public class FeatureTokenTransformerLinkRoles extends FeatureTokenTransformer { + + private static final DateTimeFormatter FLEXIBLE_PARSER = + DateTimeFormatter.ofPattern("yyyy-MM-dd[['T'][' ']HH:mm:ss[.SSS]][X]"); + + private final Consumer> roleLinksSetter; + private Map current = new LinkedHashMap<>(); + + public FeatureTokenTransformerLinkRoles(ImmutableResult.Builder resultBuilder) { + this.roleLinksSetter = resultBuilder::putAllRoleLinks; + } + + public FeatureTokenTransformerLinkRoles(ImmutableResultReduced.Builder resultBuilder) { + this.roleLinksSetter = resultBuilder::putAllRoleLinks; + } + + @Override + public void onFeatureStart(ModifiableContext context) { + this.current = new LinkedHashMap<>(); + context.setRoleLinks(Map.of()); + super.onFeatureStart(context); + } + + @Override + public void onValue(ModifiableContext context) { + Optional linkRelation = + context.schema().flatMap(SchemaBase::getRole).flatMap(SchemaBase.Role::getLinkRelation); + if (linkRelation.isPresent() && Objects.nonNull(context.value())) { + current.put(linkRelation.get(), normalizeToIso(context.value())); + return; + } + super.onValue(context); + } + + static String normalizeToIso(String value) { + try { + TemporalAccessor ta = + FLEXIBLE_PARSER.parseBest( + value, OffsetDateTime::from, LocalDateTime::from, LocalDate::from); + OffsetDateTime odt; + if (ta instanceof OffsetDateTime) { + odt = ((OffsetDateTime) ta).withOffsetSameInstant(ZoneOffset.UTC); + } else if (ta instanceof LocalDateTime) { + odt = ((LocalDateTime) ta).atZone(ZoneId.of("UTC")).toOffsetDateTime(); + } else { + odt = ((LocalDate) ta).atStartOfDay(ZoneId.of("UTC")).toOffsetDateTime(); + } + return DateTimeFormatter.ISO_INSTANT.format(odt.toInstant()); + } catch (Throwable ignore) { + return value; + } + } + + @Override + public void onFeatureEnd(ModifiableContext context) { + if (!current.isEmpty()) { + roleLinksSetter.accept(current); + context.setRoleLinks(current); + } + super.onFeatureEnd(context); + } +} diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java index 27049e2b4..5d98d76dd 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java @@ -156,6 +156,11 @@ public void onEnd(ModifiableContext context) { public void onFeatureStart(ModifiableContext context) { newContext.pathTracker().track(List.of()); newContext.setType(context.type()); + // Propagate per-feature state set by upstream transformers (e.g. role-as-link captures + // from FeatureTokenTransformerLinkRoles, canonical id captured by + // FeatureTokenTransformerCompositeId) into the rebuilt context that downstream sees. + newContext.setRoleLinks(context.roleLinks()); + newContext.setCanonicalFeatureId(context.canonicalFeatureId()); downstream.onFeatureStart(newContext); downstream.bufferStart(); @@ -168,6 +173,14 @@ public void onFeatureStart(ModifiableContext conte public void onFeatureEnd(ModifiableContext context) { applyTokenSliceTransformers(context.type()); + // Upstream transformers (LinkRoles, CompositeId) may have populated per-feature context + // state during/after the property events; propagate it to newContext before the buffer + // is flushed so encoders that read these slots during the buffered playback (e.g. + // GmlWriterId reading context.canonicalFeatureId() to override the gml:identifier + // placeholder) see the up-to-date values. + newContext.setRoleLinks(context.roleLinks()); + newContext.setCanonicalFeatureId(context.canonicalFeatureId()); + downstream.bufferStop(true); newContext.pathTracker().track(List.of()); diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerVersionIntervals.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerVersionIntervals.java new file mode 100644 index 000000000..2dc3314ba --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerVersionIntervals.java @@ -0,0 +1,80 @@ +/* + * 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.domain; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Captures per-feature {@code (PRIMARY_INTERVAL_START, PRIMARY_INTERVAL_END)} tuples and surfaces + * them via {@link FeatureStream.Result#getVersionIntervals()} / {@link + * FeatureStream.ResultReduced#getVersionIntervals()}. Used by the Time Map endpoint to enumerate + * the versions of a feature without having to decode the full feature payload. + * + *

    {@link Tuple#second()} is {@code null} for the open version (no end timestamp). + */ +public class FeatureTokenTransformerVersionIntervals extends FeatureTokenTransformer { + + private final Consumer>> versionsSetter; + private final List> versions = new ArrayList<>(); + private String currentStart; + private String currentEnd; + + public FeatureTokenTransformerVersionIntervals(ImmutableResult.Builder resultBuilder) { + this.versionsSetter = resultBuilder::addAllVersionIntervals; + } + + public FeatureTokenTransformerVersionIntervals( + ImmutableResultReduced.Builder resultBuilder) { + this.versionsSetter = resultBuilder::addAllVersionIntervals; + } + + @Override + public void onFeatureStart(ModifiableContext context) { + this.currentStart = null; + this.currentEnd = null; + super.onFeatureStart(context); + } + + @Override + public void onValue(ModifiableContext context) { + if (Objects.nonNull(context.value())) { + if (context.schema().filter(SchemaBase::isPrimaryIntervalStart).isPresent()) { + currentStart = context.value(); + } else if (context.schema().filter(SchemaBase::isPrimaryIntervalEnd).isPresent()) { + currentEnd = context.value(); + } + } + super.onValue(context); + } + + @Override + public void onFeatureEnd(ModifiableContext context) { + if (Objects.nonNull(currentStart)) { + try { + Instant start = Instant.parse(currentStart); + Instant end = Objects.nonNull(currentEnd) ? Instant.parse(currentEnd) : null; + versions.add(Tuple.of(start, end)); + } catch (Throwable ignore) { + // skip rows with non-parseable timestamps + } + } + super.onFeatureEnd(context); + } + + @Override + public void onEnd(ModifiableContext context) { + if (!versions.isEmpty()) { + versionsSetter.accept(versions); + } + super.onEnd(context); + } +} diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java index 3c8183209..442898085 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaBase.java @@ -37,19 +37,39 @@ enum Role { * Denormalised pointer to the predecessor version's PRIMARY_INTERVAL_START value. On versioned * collections, write paths maintain this so a feature can be walked backwards through its * version chain without an extra join. The strategy's mutation pipeline populates it (see - * {@code MutationStrategy.insertRoleOverrides}). + * {@code MutationStrategy.insertRoleOverrides}). At query time the value is not emitted inline; + * it is surfaced as a link with the rel {@code predecessor-version}. */ - PREDECESSOR_INTERVAL_START, + PREDECESSOR_INTERVAL_START("predecessor-version"), /** * Denormalised pointer to the successor version's PRIMARY_INTERVAL_START value. Set on the * retired row by the mutation pipeline at retire time so a feature can be walked forwards - * through its version chain. + * through its version chain. At query time the value is not emitted inline; it is surfaced as a + * link with the rel {@code successor-version}. */ - SUCCESSOR_INTERVAL_START, + SUCCESSOR_INTERVAL_START("successor-version"), SECONDARY_GEOMETRY, FILTER_GEOMETRY, EMBEDDED_FEATURE, - FEATURE_REF + FEATURE_REF; + + private final String linkRelation; + + Role() { + this(null); + } + + Role(String linkRelation) { + this.linkRelation = linkRelation; + } + + /** + * Returns the link relation type for roles that should be surfaced as link relations rather + * than inline property values, or empty for ordinary roles. + */ + public Optional getLinkRelation() { + return Optional.ofNullable(linkRelation); + } } enum Type { diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/pipeline/FeatureEventHandlerSimple.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/pipeline/FeatureEventHandlerSimple.java index 6d378efe4..3cc9449af 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/pipeline/FeatureEventHandlerSimple.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/pipeline/FeatureEventHandlerSimple.java @@ -91,6 +91,8 @@ default boolean isUseTargetPaths() { } Map additionalInfo(); + + Map roleLinks(); } interface ModifiableContext extends Context { @@ -164,6 +166,8 @@ default String pathAsString() { ModifiableContext setIsUseTargetPaths(boolean isUseTargetPaths); ModifiableContext putAdditionalInfo(String key, String value); + + ModifiableContext setRoleLinks(Map roleLinks); } @Modifiable From e8adbeb10b32a04f2fe05897885aae812fcab3f4 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Wed, 10 Jun 2026 15:50:44 +0200 Subject: [PATCH 06/25] =?UTF-8?q?features-sql:=20containsIdFilter=20recogn?= =?UTF-8?q?izes=20And(In(=5FID=5F),=20=E2=80=A6)=20for=20single-id=20bound?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SqlQueryTemplatesDeriver only treated a bare In(_ID_, …) as id-bounded and computed a separate surrogate-key range guard otherwise. When a single-feature query carries an extra predicate (e.g. TIntersects for the datetime parameter), the filter becomes And(In, TIntersects) and the id-filter check missed it, so meta-skip mode emitted "A.id >= 0 AND A.id <= 0" — a closed empty range — and returned no rows. Recurse into And's args so the id-list short-circuits even when combined with other predicates. --- .../sql/app/SqlQueryTemplatesDeriver.java | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryTemplatesDeriver.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryTemplatesDeriver.java index 05292219e..e2dba67b4 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryTemplatesDeriver.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryTemplatesDeriver.java @@ -165,11 +165,7 @@ ValueQueryTemplate createValueQueryTemplate(SqlQuerySchema schema, SqlQueryMappi forceSimpleFeatures, minMaxKeys, virtualTables) -> { - boolean isIdFilter = - filter - .filter( - cql2Predicate -> cql2Predicate instanceof In && ((In) cql2Predicate).isIdFilter()) - .isPresent(); + boolean isIdFilter = filter.filter(SqlQueryTemplatesDeriver::containsIdFilter).isPresent(); List aliases = AliasGenerator.getAliases(schema); SqlQueryTable main = schema.getRelations().isEmpty() ? schema : schema.getRelations().get(0); @@ -285,6 +281,24 @@ private String getTableQuery( columns, mainTable, join.isEmpty() ? "" : " ", join, where, orderBy, paging); } + /** + * Recognises an id-bounded filter even when it is buried inside conjunctions, e.g. {@code + * In(_ID_, [...])} on its own or {@code And(In(_ID_, [...]), )} — both treat the row-set + * as constrained by the id list and let the SQL generator skip the surrogate-key range guard. + */ + private static boolean containsIdFilter(Cql2Expression expr) { + if (expr instanceof In && ((In) expr).isIdFilter()) { + return true; + } + if (expr instanceof And) { + return ((And) expr) + .getArgs().stream() + .anyMatch( + arg -> arg instanceof Cql2Expression && containsIdFilter((Cql2Expression) arg)); + } + return false; + } + private Optional toWhereClause( String alias, String keyField, From d37444f137272157ceb37aa52aea556ee548c787 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Wed, 10 Jun 2026 18:07:42 +0200 Subject: [PATCH 07/25] fix exception for feature with multiple versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LinkRoles transformer fed every feature's role values into the result builder via `putAllRoleLinks`, which is backed by `ImmutableMap.Builder`. For a multi-version single-feature stream, repeated keys (`predecessor-version` etc.) collided at `.build()` time and the whole query aborted with `IllegalArgumentException: Multiple entries with same key …` — the response handler then surfaced a generic 404. Switch the setter to `roleLinks(map)` (replace) and choose explicitly which feature's roles drive the result-level map: the one with the greatest `PRIMARY_INTERVAL_START`, i.e. the latest version. For non-versioned single-feature responses (no start) the only feature wins. The result-level emission is gated on `context.metadata().isSingleFeature()` so list responses no longer pollute `Result.getRoleLinks()` with arbitrary per-feature data. Per-feature roles on the context (`context.setRoleLinks`) — what writers use for per-feature link items — are unchanged. --- .../FeatureTokenTransformerLinkRoles.java | 67 +++++++++++++++++-- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java index f0549f091..43860cae5 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java @@ -7,6 +7,7 @@ */ package de.ii.xtraplatform.features.domain; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.OffsetDateTime; @@ -28,8 +29,15 @@ * FeatureStream.ResultReduced} via {@code getRoleLinks()} so the response handler can build HTTP * {@code Link} headers and per-feature link items in the response body. * - *

    The result-level map captures the most recent feature's role values; for the single-feature - * endpoint that is the only feature, which is the primary use case. + *

    Per-feature role values are written to {@link ModifiableContext#setRoleLinks(java.util.Map)} + * on every feature so writers can emit per-feature link items. + * + *

    The result-level map drives the response's HTTP {@code Link} headers and is only meaningful + * for the single-feature endpoint. Within a single-feature response that streams several versions, + * the feature with the greatest {@code PRIMARY_INTERVAL_START} wins — that is the latest version, + * and its predecessor/successor links describe the navigation that applies to the response as a + * whole. For non-versioned single-feature responses (no {@code PRIMARY_INTERVAL_START} on any + * feature) the only feature in the stream wins. */ public class FeatureTokenTransformerLinkRoles extends FeatureTokenTransformer { @@ -38,18 +46,29 @@ public class FeatureTokenTransformerLinkRoles extends FeatureTokenTransformer { private final Consumer> roleLinksSetter; private Map current = new LinkedHashMap<>(); + private boolean isSingleFeature = false; + private Instant currentStart; + private Instant latestStart; + private Map latestRoleLinks; public FeatureTokenTransformerLinkRoles(ImmutableResult.Builder resultBuilder) { - this.roleLinksSetter = resultBuilder::putAllRoleLinks; + this.roleLinksSetter = resultBuilder::roleLinks; } public FeatureTokenTransformerLinkRoles(ImmutableResultReduced.Builder resultBuilder) { - this.roleLinksSetter = resultBuilder::putAllRoleLinks; + this.roleLinksSetter = resultBuilder::roleLinks; + } + + @Override + public void onStart(ModifiableContext context) { + this.isSingleFeature = context.metadata().isSingleFeature(); + super.onStart(context); } @Override public void onFeatureStart(ModifiableContext context) { this.current = new LinkedHashMap<>(); + this.currentStart = null; context.setRoleLinks(Map.of()); super.onFeatureStart(context); } @@ -62,6 +81,10 @@ public void onValue(ModifiableContext context) { current.put(linkRelation.get(), normalizeToIso(context.value())); return; } + if (Objects.nonNull(context.value()) + && context.schema().filter(SchemaBase::isPrimaryIntervalStart).isPresent()) { + currentStart = parseToInstant(context.value()); + } super.onValue(context); } @@ -84,12 +107,46 @@ static String normalizeToIso(String value) { } } + static Instant parseToInstant(String value) { + try { + TemporalAccessor ta = + FLEXIBLE_PARSER.parseBest( + value, OffsetDateTime::from, LocalDateTime::from, LocalDate::from); + if (ta instanceof OffsetDateTime) { + return ((OffsetDateTime) ta).toInstant(); + } else if (ta instanceof LocalDateTime) { + return ((LocalDateTime) ta).atZone(ZoneId.of("UTC")).toInstant(); + } else { + return ((LocalDate) ta).atStartOfDay(ZoneId.of("UTC")).toInstant(); + } + } catch (Throwable ignore) { + return null; + } + } + @Override public void onFeatureEnd(ModifiableContext context) { if (!current.isEmpty()) { - roleLinksSetter.accept(current); context.setRoleLinks(current); + // Pick the feature with the greatest PRIMARY_INTERVAL_START for the result-level map. + // For non-versioned single-feature responses there is no start, so the only feature wins. + if (currentStart != null) { + if (latestStart == null || currentStart.isAfter(latestStart)) { + latestStart = currentStart; + latestRoleLinks = current; + } + } else if (latestStart == null) { + latestRoleLinks = current; + } } super.onFeatureEnd(context); } + + @Override + public void onEnd(ModifiableContext context) { + if (isSingleFeature && Objects.nonNull(latestRoleLinks)) { + roleLinksSetter.accept(latestRoleLinks); + } + super.onEnd(context); + } } From 3f097891691c539c3a0e953c281cbafca5114621 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 11 Jun 2026 10:24:31 +0200 Subject: [PATCH 08/25] features: drop the version-intervals side channel The Time Map endpoint (in ldproxy) now owns per-feature decoding via a dedicated FeatureEncoderTimeMap, so the result-level versionIntervals accessor and its feeder transformer are no longer used. - delete FeatureTokenTransformerVersionIntervals - unwire it from FeatureStreamImpl on both runWith branches - drop getVersionIntervals from Result and ResultReduced --- .../features/domain/FeatureStream.java | 4 - .../features/domain/FeatureStreamImpl.java | 4 - ...atureTokenTransformerVersionIntervals.java | 80 ------------------- 3 files changed, 88 deletions(-) delete mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerVersionIntervals.java diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStream.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStream.java index 87b546e1a..ccf5fe0be 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStream.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStream.java @@ -101,8 +101,6 @@ abstract class Builder extends ResultBase.Builder {} Optional> getTemporalExtent(); Map getRoleLinks(); - - java.util.List> getVersionIntervals(); } @Value.Immutable @@ -125,8 +123,6 @@ abstract class Builder extends ResultBase.Builder, Builder> getTemporalExtent(); Map getRoleLinks(); - - java.util.List> getVersionIntervals(); } interface ResultBase { diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java index 908fbb74d..09b247efb 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java @@ -139,8 +139,6 @@ public CompletionStage runWith( source = source.via(new FeatureTokenTransformerMetadata(resultBuilder)); } - source = source.via(new FeatureTokenTransformerVersionIntervals(resultBuilder)); - source = source.via(new FeatureTokenTransformerHooks(resultBuilder, onCollectionMetadata)); @@ -221,8 +219,6 @@ public CompletionStage> runWith( source = source.via(new FeatureTokenTransformerMetadata(resultBuilder)); } - source = source.via(new FeatureTokenTransformerVersionIntervals(resultBuilder)); - source = source.via(new FeatureTokenTransformerHooks(resultBuilder, onCollectionMetadata)); diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerVersionIntervals.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerVersionIntervals.java deleted file mode 100644 index 2dc3314ba..000000000 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerVersionIntervals.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * 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.domain; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.function.Consumer; - -/** - * Captures per-feature {@code (PRIMARY_INTERVAL_START, PRIMARY_INTERVAL_END)} tuples and surfaces - * them via {@link FeatureStream.Result#getVersionIntervals()} / {@link - * FeatureStream.ResultReduced#getVersionIntervals()}. Used by the Time Map endpoint to enumerate - * the versions of a feature without having to decode the full feature payload. - * - *

    {@link Tuple#second()} is {@code null} for the open version (no end timestamp). - */ -public class FeatureTokenTransformerVersionIntervals extends FeatureTokenTransformer { - - private final Consumer>> versionsSetter; - private final List> versions = new ArrayList<>(); - private String currentStart; - private String currentEnd; - - public FeatureTokenTransformerVersionIntervals(ImmutableResult.Builder resultBuilder) { - this.versionsSetter = resultBuilder::addAllVersionIntervals; - } - - public FeatureTokenTransformerVersionIntervals( - ImmutableResultReduced.Builder resultBuilder) { - this.versionsSetter = resultBuilder::addAllVersionIntervals; - } - - @Override - public void onFeatureStart(ModifiableContext context) { - this.currentStart = null; - this.currentEnd = null; - super.onFeatureStart(context); - } - - @Override - public void onValue(ModifiableContext context) { - if (Objects.nonNull(context.value())) { - if (context.schema().filter(SchemaBase::isPrimaryIntervalStart).isPresent()) { - currentStart = context.value(); - } else if (context.schema().filter(SchemaBase::isPrimaryIntervalEnd).isPresent()) { - currentEnd = context.value(); - } - } - super.onValue(context); - } - - @Override - public void onFeatureEnd(ModifiableContext context) { - if (Objects.nonNull(currentStart)) { - try { - Instant start = Instant.parse(currentStart); - Instant end = Objects.nonNull(currentEnd) ? Instant.parse(currentEnd) : null; - versions.add(Tuple.of(start, end)); - } catch (Throwable ignore) { - // skip rows with non-parseable timestamps - } - } - super.onFeatureEnd(context); - } - - @Override - public void onEnd(ModifiableContext context) { - if (!versions.isEmpty()) { - versionsSetter.accept(versions); - } - super.onEnd(context); - } -} From a6fe7fc4f59d829fa408b4d1d60b24d8c72fd9e2 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 11 Jun 2026 11:25:51 +0200 Subject: [PATCH 09/25] features: wire link-role processing only where it applies - Wire FeatureTokenTransformerLinkRoles into the pipeline only when the resolved schema of a queried type has a property whose role declares a link relation; for all other types the transformer was a per-token no-op in every feature stream. - Drop the latest-version arbitration from the transformer: a single-feature response carries exactly one feature version (the datetime parameter resolves to a single instant), so the links of the only feature in the stream are the links of the response. - Clarify in the javadoc that the per-feature captures serve every response that streams features of a versioned type, while the result-level map only drives the Link headers of the single-feature endpoint. --- .../features/domain/FeatureStreamImpl.java | 33 +++++++++- .../FeatureTokenTransformerLinkRoles.java | 66 +++++-------------- 2 files changed, 47 insertions(+), 52 deletions(-) diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java index 09b247efb..83c07c8c1 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java @@ -28,6 +28,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -49,6 +50,7 @@ public class FeatureStreamImpl implements FeatureStream { private final boolean stepClean; private final boolean stepEtag; private final boolean stepMetadata; + private final boolean hasLinkRoles; public FeatureStreamImpl( Query query, @@ -86,6 +88,29 @@ public FeatureStreamImpl( this.stepMetadata = !query.skipPipelineSteps().contains(PipelineSteps.METADATA) && !query.skipPipelineSteps().contains(PipelineSteps.ALL); + this.hasLinkRoles = hasLinkRelationRoles(query, data); + } + + // Only versioned types have properties whose role declares a link relation + // (PREDECESSOR_INTERVAL_START / SUCCESSOR_INTERVAL_START), so for all other types the + // LinkRoles transformer would be a per-token no-op and is not wired at all. + private static boolean hasLinkRelationRoles(Query query, FeatureProviderDataV2 data) { + return getTypes(query).stream() + .map(type -> data.getTypes().get(type)) + .filter(Objects::nonNull) + .flatMap(schema -> schema.getAllNestedProperties().stream()) + .anyMatch( + property -> property.getRole().flatMap(SchemaBase.Role::getLinkRelation).isPresent()); + } + + private static List getTypes(Query query) { + if (query instanceof FeatureQuery) { + return List.of(((FeatureQuery) query).getType()); + } + if (query instanceof MultiFeatureQuery) { + return ((MultiFeatureQuery) query).getQueries().stream().map(TypeQuery::getType).toList(); + } + return List.of(); } @Override @@ -103,7 +128,9 @@ public CompletionStage runWith( // LinkRoles must run before the per-format value-transformation step so it captures // the raw ISO timestamp, not a locale-formatted variant used in the body FeatureTokenSource source = - tokenSource.via(new FeatureTokenTransformerLinkRoles(resultBuilder)); + hasLinkRoles + ? tokenSource.via(new FeatureTokenTransformerLinkRoles(resultBuilder)) + : tokenSource; // FeatureTokenTransformerExtension query-extensions (e.g. composite-id rewrite) run in // the same pre-format slot so they see raw provider values and can mutate tokens before // any format-specific transformation @@ -184,7 +211,9 @@ public CompletionStage> runWith( // LinkRoles must run before the per-format value-transformation step so it captures // the raw ISO timestamp, not a locale-formatted variant used in the body FeatureTokenSource source = - tokenSource.via(new FeatureTokenTransformerLinkRoles(resultBuilder)); + hasLinkRoles + ? tokenSource.via(new FeatureTokenTransformerLinkRoles(resultBuilder)) + : tokenSource; // FeatureTokenTransformerExtension query-extensions (e.g. composite-id rewrite) run in // the same pre-format slot so they see raw provider values and can mutate tokens before // any format-specific transformation diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java index 43860cae5..06be4c2dd 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java @@ -7,7 +7,6 @@ */ package de.ii.xtraplatform.features.domain; -import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.OffsetDateTime; @@ -24,20 +23,20 @@ /** * Strips property values whose schema role declares a {@link SchemaBase.Role#getLinkRelation() link * relation} (e.g. {@code PREDECESSOR_INTERVAL_START}, {@code SUCCESSOR_INTERVAL_START}) from the - * token stream so downstream encoders do not emit them as inline feature properties. The captured - * {@code (linkRelation, value)} pairs are exposed on the {@link FeatureStream.Result} / {@link - * FeatureStream.ResultReduced} via {@code getRoleLinks()} so the response handler can build HTTP - * {@code Link} headers and per-feature link items in the response body. + * token stream so downstream encoders do not emit them as inline feature properties. This applies + * to every response that streams features of a versioned type — single feature, items, search. * - *

    Per-feature role values are written to {@link ModifiableContext#setRoleLinks(java.util.Map)} - * on every feature so writers can emit per-feature link items. + *

    The captured {@code (linkRelation, value)} pairs are surfaced twice: * - *

    The result-level map drives the response's HTTP {@code Link} headers and is only meaningful - * for the single-feature endpoint. Within a single-feature response that streams several versions, - * the feature with the greatest {@code PRIMARY_INTERVAL_START} wins — that is the latest version, - * and its predecessor/successor links describe the navigation that applies to the response as a - * whole. For non-versioned single-feature responses (no {@code PRIMARY_INTERVAL_START} on any - * feature) the only feature in the stream wins. + *

      + *
    • Per feature, via {@link ModifiableContext#setRoleLinks(java.util.Map)}, on every feature in + * any response, so format writers can emit per-feature link items in the response body. + *
    • Per result, via {@code getRoleLinks()} on {@link FeatureStream.Result} / {@link + * FeatureStream.ResultReduced}, which drives the response's HTTP {@code Link} headers. This + * is only meaningful for the single-feature endpoint, where the response carries exactly one + * feature version (the {@code datetime} parameter resolves to a single instant), so the links + * of the only feature in the stream are the links of the response. + *
    */ public class FeatureTokenTransformerLinkRoles extends FeatureTokenTransformer { @@ -47,9 +46,7 @@ public class FeatureTokenTransformerLinkRoles extends FeatureTokenTransformer { private final Consumer> roleLinksSetter; private Map current = new LinkedHashMap<>(); private boolean isSingleFeature = false; - private Instant currentStart; - private Instant latestStart; - private Map latestRoleLinks; + private Map resultRoleLinks; public FeatureTokenTransformerLinkRoles(ImmutableResult.Builder resultBuilder) { this.roleLinksSetter = resultBuilder::roleLinks; @@ -68,7 +65,6 @@ public void onStart(ModifiableContext context) { @Override public void onFeatureStart(ModifiableContext context) { this.current = new LinkedHashMap<>(); - this.currentStart = null; context.setRoleLinks(Map.of()); super.onFeatureStart(context); } @@ -81,10 +77,6 @@ public void onValue(ModifiableContext context) { current.put(linkRelation.get(), normalizeToIso(context.value())); return; } - if (Objects.nonNull(context.value()) - && context.schema().filter(SchemaBase::isPrimaryIntervalStart).isPresent()) { - currentStart = parseToInstant(context.value()); - } super.onValue(context); } @@ -107,45 +99,19 @@ static String normalizeToIso(String value) { } } - static Instant parseToInstant(String value) { - try { - TemporalAccessor ta = - FLEXIBLE_PARSER.parseBest( - value, OffsetDateTime::from, LocalDateTime::from, LocalDate::from); - if (ta instanceof OffsetDateTime) { - return ((OffsetDateTime) ta).toInstant(); - } else if (ta instanceof LocalDateTime) { - return ((LocalDateTime) ta).atZone(ZoneId.of("UTC")).toInstant(); - } else { - return ((LocalDate) ta).atStartOfDay(ZoneId.of("UTC")).toInstant(); - } - } catch (Throwable ignore) { - return null; - } - } - @Override public void onFeatureEnd(ModifiableContext context) { if (!current.isEmpty()) { context.setRoleLinks(current); - // Pick the feature with the greatest PRIMARY_INTERVAL_START for the result-level map. - // For non-versioned single-feature responses there is no start, so the only feature wins. - if (currentStart != null) { - if (latestStart == null || currentStart.isAfter(latestStart)) { - latestStart = currentStart; - latestRoleLinks = current; - } - } else if (latestStart == null) { - latestRoleLinks = current; - } + this.resultRoleLinks = current; } super.onFeatureEnd(context); } @Override public void onEnd(ModifiableContext context) { - if (isSingleFeature && Objects.nonNull(latestRoleLinks)) { - roleLinksSetter.accept(latestRoleLinks); + if (isSingleFeature && Objects.nonNull(resultRoleLinks)) { + roleLinksSetter.accept(resultRoleLinks); } super.onEnd(context); } From 2239232504351786ed41c7a9b1c485f497ab8e4d Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 11 Jun 2026 13:10:56 +0200 Subject: [PATCH 10/25] features: represent properties as web links via schema configuration A property can now declare a `link` object with a mandatory link relation type and URI template: link: rel: related uriTemplate: 'https://example.com/register/{{value}}' Such a property is not emitted as an inline feature property; it is captured per feature so the API layer can represent it as a web link. The template parameters {{value}}, {{featureUri}}, {{collectionUri}} and {{serviceUri}} are resolved in the API layer, which knows the request URIs. - The roles PREDECESSOR_INTERVAL_START and SUCCESSOR_INTERVAL_START derive a default link ({{featureUri}}?datetime={{value}} with the rel of the role); an explicit `link` overrides the default. The previously hard-wired role-to-link behavior is now a special case of the generic mechanism. - The capture is a list of structured PropertyLink entries (rel, URI template, value, label) instead of a rel-to-value map, so arrays and repeated rels are supported; only DATETIME-typed values are normalized to ISO instants, DATE values stay dates. - FeatureTokenTransformerLinkRoles is renamed to FeatureTokenTransformerPropertyLinks; it is only wired into the pipeline when a queried type has a property with an effective link. - New documentation page for the `link` object, including the boundary to feature references; the two versioning roles are now covered by the user documentation of `role`. --- ...rminePipelineStepsThatCannotBeSkipped.java | 13 +- .../features/domain/FeatureEventHandler.java | 11 +- .../features/domain/FeatureSchema.java | 44 +++++- .../features/domain/FeatureStream.java | 5 +- .../features/domain/FeatureStreamImpl.java | 31 ++-- .../FeatureTokenTransformerExtension.java | 6 +- .../FeatureTokenTransformerMappings.java | 10 +- ...FeatureTokenTransformerPropertyLinks.java} | 73 ++++++---- .../features/domain/PropertyLink.java | 42 ++++++ .../features/domain/SchemaLink.java | 137 ++++++++++++++++++ .../pipeline/FeatureEventHandlerSimple.java | 5 +- .../features/domain/SchemaLinkSpec.groovy | 92 ++++++++++++ 12 files changed, 397 insertions(+), 72 deletions(-) rename xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/{FeatureTokenTransformerLinkRoles.java => FeatureTokenTransformerPropertyLinks.java} (50%) create mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/PropertyLink.java create mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaLink.java create mode 100644 xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/SchemaLinkSpec.groovy diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkipped.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkipped.java index b14523384..a2fd8e3c8 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkipped.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/DeterminePipelineStepsThatCannotBeSkipped.java @@ -165,16 +165,15 @@ public Set visit( steps.add(PipelineSteps.CLEAN); } - // Versioned-features roles: keep value mappings so the default DATETIME_FORMAT - // transformer runs and the captured timestamps are ISO 8601 before reaching the - // FeatureTokenTransformerLinkRoles / FeatureTokenTransformerCompositeId / Memento-Datetime - // header construction. Without this the raw PostgreSQL text (or whatever the SQL provider - // emits) would flow through, and the role-link headers / composite-id rewrite would have - // to do their own parsing. + // Keep value mappings so the default DATETIME_FORMAT transformer runs and the captured + // timestamps are ISO 8601 before reaching the FeatureTokenTransformerPropertyLinks / + // FeatureTokenTransformerCompositeId / Memento-Datetime header construction. Without this + // the raw PostgreSQL text (or whatever the SQL provider emits) would flow through, and the + // link headers / composite-id rewrite would have to do their own parsing. if (schema.isId() || schema.isPrimaryIntervalStart() || schema.isPrimaryIntervalEnd() - || schema.getRole().flatMap(SchemaBase.Role::getLinkRelation).isPresent()) { + || schema.getEffectiveLink().isPresent()) { steps.add(PipelineSteps.MAPPING_VALUES); } } diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureEventHandler.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureEventHandler.java index 091796007..e5bd082a0 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureEventHandler.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureEventHandler.java @@ -83,11 +83,12 @@ default boolean isUseTargetPaths() { Map additionalInfo(); /** - * Captured (linkRelation, value) pairs for the current feature, populated by {@link - * FeatureTokenTransformerLinkRoles} from schema properties whose {@link SchemaBase.Role role - * declares a link relation}. Encoders use it to emit per-feature link entries. + * Captured links for the current feature, populated by {@link + * FeatureTokenTransformerPropertyLinks} from schema properties with an {@link + * FeatureSchema#getEffectiveLink() effective link}. Encoders use it to emit per-feature link + * entries. */ - Map roleLinks(); + List propertyLinks(); /** * Canonical (untransformed) feature id, captured when a profile rewrites the id token (e.g. @@ -322,7 +323,7 @@ default boolean isRequired(T schema, List parentSchemas) { ModifiableContext putAdditionalInfo(String key, String value); - ModifiableContext setRoleLinks(Map roleLinks); + ModifiableContext setPropertyLinks(Iterable propertyLinks); ModifiableContext setCanonicalFeatureId(@Nullable String canonicalFeatureId); } 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 f4d0a27a7..afd695283 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 @@ -61,6 +61,7 @@ "excludedScopes", "transformations", "constraints", + "link", "properties" }) public interface FeatureSchema @@ -148,7 +149,12 @@ default Type getType() { * be used for `datetime` queries, provided that a time instant describes the temporal extent * of the features. If, on the other hand, the temporal extent is a time interval, then * `PRIMARY_INTERVAL_START` and `PRIMARY_INTERVAL_END` should be specified at the respective - * temporal properties. + * temporal properties. If the dataset contains multiple versions of the features, + * `PREDECESSOR_INTERVAL_START` and `SUCCESSOR_INTERVAL_START` can be specified at the + * temporal properties that contain the start of the primary interval of the previous and next + * version of the feature. These properties are not represented as feature properties, but as + * links with the link relation types `predecessor-version` and `successor-version` (see + * [Links](../details/links.md); the default link can be overridden with `link`). * @langDe Kennzeichnet besondere Bedeutungen der Eigenschaft. `ID` ist bei der Eigenschaft eines * Objekts anzugeben, die für die `featureId` in der API zu verwenden ist. Diese Eigenschaft * ist typischerweise die erste Eigenschaft im `properties`-Objekt. Erlaubte Zeichen in diesen @@ -163,7 +169,13 @@ default Type getType() { * angegeben werden, die für `datetime`-Abfragen verwendet werden soll, sofern ein Zeitpunkt * die zeitliche Ausdehnung der Features beschreibt. Ist die zeitliche Ausdehnung hingegen ein * Zeitintervall, dann sind `PRIMARY_INTERVAL_START` und `PRIMARY_INTERVAL_END` bei den - * jeweiligen zeitlichen Eigenschaften anzugeben. + * jeweiligen zeitlichen Eigenschaften anzugeben. Enthält der Datensatz mehrere Versionen der + * Features, dann können `PREDECESSOR_INTERVAL_START` und `SUCCESSOR_INTERVAL_START` bei den + * zeitlichen Eigenschaften angegeben werden, die den Beginn des primären Zeitintervalls der + * vorherigen bzw. nächsten Version des Features enthalten. Diese Eigenschaften werden nicht + * als Feature-Eigenschaften repräsentiert, sondern als Links mit den Linkrelationen + * `predecessor-version` und `successor-version` (siehe [Links](../details/links.md); der + * Standard-Link kann mit `link` überschrieben werden). * @default null */ @Override @@ -420,6 +432,34 @@ default boolean getIgnore() { @Override Optional getConstraints(); + /** + * @langEn Option to represent the property as a web link instead of an inline value, see + * [Links](../details/links.md). Only meaningful for value properties. + * @langDe Option um die Eigenschaft als Web-Link statt als Wert in den Feature-Eigenschaften zu + * repräsentieren, siehe [Links](../details/links.md). Nur bei Werteigenschaften sinnvoll. + * @default null + */ + Optional getLink(); + + /** + * The link of the property: the configured {@link #getLink() link}, or for properties whose + * {@link SchemaBase.Role role} declares a link relation (e.g. {@code + * PREDECESSOR_INTERVAL_START}), a default link to the feature version that starts at the property + * value. + */ + @JsonIgnore + @Value.Derived + @Value.Auxiliary + default Optional getEffectiveLink() { + if (getLink().isPresent()) { + return getLink(); + } + return getRole() + .flatMap(SchemaBase.Role::getLinkRelation) + .filter(rel -> "predecessor-version".equals(rel) || "successor-version".equals(rel)) + .map(rel -> SchemaLink.of(rel, SchemaLink.FEATURE_URI + "?datetime=" + SchemaLink.VALUE)); + } + /** * @langEn Option to disable enforcement of counter-clockwise orientation for exterior rings and a * clockwise orientation for interior rings (only for SQL). diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStream.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStream.java index ccf5fe0be..7e507d977 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStream.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStream.java @@ -17,6 +17,7 @@ import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.EntityTag; import java.time.Instant; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -100,7 +101,7 @@ abstract class Builder extends ResultBase.Builder {} Optional> getTemporalExtent(); - Map getRoleLinks(); + List getPropertyLinks(); } @Value.Immutable @@ -122,7 +123,7 @@ abstract class Builder extends ResultBase.Builder, Builder> getTemporalExtent(); - Map getRoleLinks(); + List getPropertyLinks(); } interface ResultBase { diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java index 83c07c8c1..e24bb38db 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java @@ -50,7 +50,7 @@ public class FeatureStreamImpl implements FeatureStream { private final boolean stepClean; private final boolean stepEtag; private final boolean stepMetadata; - private final boolean hasLinkRoles; + private final boolean hasPropertyLinks; public FeatureStreamImpl( Query query, @@ -88,19 +88,18 @@ public FeatureStreamImpl( this.stepMetadata = !query.skipPipelineSteps().contains(PipelineSteps.METADATA) && !query.skipPipelineSteps().contains(PipelineSteps.ALL); - this.hasLinkRoles = hasLinkRelationRoles(query, data); + this.hasPropertyLinks = hasPropertyLinks(query, data); } - // Only versioned types have properties whose role declares a link relation - // (PREDECESSOR_INTERVAL_START / SUCCESSOR_INTERVAL_START), so for all other types the - // LinkRoles transformer would be a per-token no-op and is not wired at all. - private static boolean hasLinkRelationRoles(Query query, FeatureProviderDataV2 data) { + // For types without properties that are represented as links (an explicit `link` in the + // schema or a role that declares a link relation) the PropertyLinks transformer would be a + // per-token no-op and is not wired at all. + private static boolean hasPropertyLinks(Query query, FeatureProviderDataV2 data) { return getTypes(query).stream() .map(type -> data.getTypes().get(type)) .filter(Objects::nonNull) .flatMap(schema -> schema.getAllNestedProperties().stream()) - .anyMatch( - property -> property.getRole().flatMap(SchemaBase.Role::getLinkRelation).isPresent()); + .anyMatch(property -> property.getEffectiveLink().isPresent()); } private static List getTypes(Query query) { @@ -125,11 +124,11 @@ public CompletionStage runWith( BiFunction, Stream> stream = (tokenSource, virtualTables) -> { ImmutableResult.Builder resultBuilder = ImmutableResult.builder(); - // LinkRoles must run before the per-format value-transformation step so it captures - // the raw ISO timestamp, not a locale-formatted variant used in the body + // PropertyLinks must run before the per-format value-transformation step so it + // captures the raw ISO timestamp, not a locale-formatted variant used in the body FeatureTokenSource source = - hasLinkRoles - ? tokenSource.via(new FeatureTokenTransformerLinkRoles(resultBuilder)) + hasPropertyLinks + ? tokenSource.via(new FeatureTokenTransformerPropertyLinks(resultBuilder)) : tokenSource; // FeatureTokenTransformerExtension query-extensions (e.g. composite-id rewrite) run in // the same pre-format slot so they see raw provider values and can mutate tokens before @@ -208,11 +207,11 @@ public CompletionStage> runWith( BiFunction, Reactive.Stream>> stream = (tokenSource, virtualTables) -> { ImmutableResultReduced.Builder resultBuilder = ImmutableResultReduced.builder(); - // LinkRoles must run before the per-format value-transformation step so it captures - // the raw ISO timestamp, not a locale-formatted variant used in the body + // PropertyLinks must run before the per-format value-transformation step so it + // captures the raw ISO timestamp, not a locale-formatted variant used in the body FeatureTokenSource source = - hasLinkRoles - ? tokenSource.via(new FeatureTokenTransformerLinkRoles(resultBuilder)) + hasPropertyLinks + ? tokenSource.via(new FeatureTokenTransformerPropertyLinks(resultBuilder)) : tokenSource; // FeatureTokenTransformerExtension query-extensions (e.g. composite-id rewrite) run in // the same pre-format slot so they see raw provider values and can mutate tokens before diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerExtension.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerExtension.java index 83950c053..830edbbe7 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerExtension.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerExtension.java @@ -11,9 +11,9 @@ * A {@link FeatureQueryExtension} that contributes a {@link FeatureTokenTransformer} to the feature * stream pipeline. Attached to a {@link FeatureQuery} by upstream code (e.g. a profile's {@code * transformFeatureQuery}); {@link FeatureStreamImpl} discovers all such extensions on the query and - * wires their transformers in immediately after {@code FeatureTokenTransformerLinkRoles}, before - * the per-format value-transformation step. This is the right slot for transformers that need to - * see raw provider values (pre-format) and rewrite tokens in-place. + * wires their transformers in immediately after {@code FeatureTokenTransformerPropertyLinks}, + * before the per-format value-transformation step. This is the right slot for transformers that + * need to see raw provider values (pre-format) and rewrite tokens in-place. */ public interface FeatureTokenTransformerExtension extends FeatureQueryExtension { diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java index 5d98d76dd..511c857eb 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappings.java @@ -156,10 +156,10 @@ public void onEnd(ModifiableContext context) { public void onFeatureStart(ModifiableContext context) { newContext.pathTracker().track(List.of()); newContext.setType(context.type()); - // Propagate per-feature state set by upstream transformers (e.g. role-as-link captures - // from FeatureTokenTransformerLinkRoles, canonical id captured by + // Propagate per-feature state set by upstream transformers (e.g. link captures + // from FeatureTokenTransformerPropertyLinks, canonical id captured by // FeatureTokenTransformerCompositeId) into the rebuilt context that downstream sees. - newContext.setRoleLinks(context.roleLinks()); + newContext.setPropertyLinks(context.propertyLinks()); newContext.setCanonicalFeatureId(context.canonicalFeatureId()); downstream.onFeatureStart(newContext); @@ -173,12 +173,12 @@ public void onFeatureStart(ModifiableContext conte public void onFeatureEnd(ModifiableContext context) { applyTokenSliceTransformers(context.type()); - // Upstream transformers (LinkRoles, CompositeId) may have populated per-feature context + // Upstream transformers (PropertyLinks, CompositeId) may have populated per-feature context // state during/after the property events; propagate it to newContext before the buffer // is flushed so encoders that read these slots during the buffered playback (e.g. // GmlWriterId reading context.canonicalFeatureId() to override the gml:identifier // placeholder) see the up-to-date values. - newContext.setRoleLinks(context.roleLinks()); + newContext.setPropertyLinks(context.propertyLinks()); newContext.setCanonicalFeatureId(context.canonicalFeatureId()); downstream.bufferStop(true); diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerPropertyLinks.java similarity index 50% rename from xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java rename to xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerPropertyLinks.java index 06be4c2dd..c57e14846 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerLinkRoles.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerPropertyLinks.java @@ -14,46 +14,52 @@ import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.temporal.TemporalAccessor; -import java.util.LinkedHashMap; -import java.util.Map; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; /** - * Strips property values whose schema role declares a {@link SchemaBase.Role#getLinkRelation() link - * relation} (e.g. {@code PREDECESSOR_INTERVAL_START}, {@code SUCCESSOR_INTERVAL_START}) from the - * token stream so downstream encoders do not emit them as inline feature properties. This applies - * to every response that streams features of a versioned type — single feature, items, search. + * Strips property values whose schema declares an {@link FeatureSchema#getEffectiveLink() effective + * link} — an explicit {@link SchemaLink} or a {@link SchemaBase.Role role} with a link relation + * (e.g. {@code PREDECESSOR_INTERVAL_START}, {@code SUCCESSOR_INTERVAL_START}) — from the token + * stream so downstream encoders do not emit them as inline feature properties. This applies to + * every response that streams features of a type with such properties — single feature, items, + * search. * - *

    The captured {@code (linkRelation, value)} pairs are surfaced twice: + *

    The captured {@link PropertyLink}s are surfaced twice: * *

      - *
    • Per feature, via {@link ModifiableContext#setRoleLinks(java.util.Map)}, on every feature in + *
    • Per feature, via {@link ModifiableContext#setPropertyLinks(Iterable)}, on every feature in * any response, so format writers can emit per-feature link items in the response body. - *
    • Per result, via {@code getRoleLinks()} on {@link FeatureStream.Result} / {@link + *
    • Per result, via {@code getPropertyLinks()} on {@link FeatureStream.Result} / {@link * FeatureStream.ResultReduced}, which drives the response's HTTP {@code Link} headers. This * is only meaningful for the single-feature endpoint, where the response carries exactly one - * feature version (the {@code datetime} parameter resolves to a single instant), so the links - * of the only feature in the stream are the links of the response. + * feature, so the links of the only feature in the stream are the links of the response. *
    + * + *

    The URI templates are not resolved here — the provider does not know the request URIs; the API + * layer resolves them against the service, collection and feature URIs. */ -public class FeatureTokenTransformerLinkRoles extends FeatureTokenTransformer { +public class FeatureTokenTransformerPropertyLinks extends FeatureTokenTransformer { private static final DateTimeFormatter FLEXIBLE_PARSER = DateTimeFormatter.ofPattern("yyyy-MM-dd[['T'][' ']HH:mm:ss[.SSS]][X]"); - private final Consumer> roleLinksSetter; - private Map current = new LinkedHashMap<>(); - private boolean isSingleFeature = false; - private Map resultRoleLinks; + private final Consumer> propertyLinksSetter; + private List current = new ArrayList<>(); + private boolean isSingleFeature; + private List resultPropertyLinks; - public FeatureTokenTransformerLinkRoles(ImmutableResult.Builder resultBuilder) { - this.roleLinksSetter = resultBuilder::roleLinks; + public FeatureTokenTransformerPropertyLinks(ImmutableResult.Builder resultBuilder) { + super(); + this.propertyLinksSetter = resultBuilder::propertyLinks; } - public FeatureTokenTransformerLinkRoles(ImmutableResultReduced.Builder resultBuilder) { - this.roleLinksSetter = resultBuilder::roleLinks; + public FeatureTokenTransformerPropertyLinks(ImmutableResultReduced.Builder resultBuilder) { + super(); + this.propertyLinksSetter = resultBuilder::propertyLinks; } @Override @@ -64,17 +70,23 @@ public void onStart(ModifiableContext context) { @Override public void onFeatureStart(ModifiableContext context) { - this.current = new LinkedHashMap<>(); - context.setRoleLinks(Map.of()); + this.current = new ArrayList<>(); + context.setPropertyLinks(List.of()); super.onFeatureStart(context); } @Override public void onValue(ModifiableContext context) { - Optional linkRelation = - context.schema().flatMap(SchemaBase::getRole).flatMap(SchemaBase.Role::getLinkRelation); - if (linkRelation.isPresent() && Objects.nonNull(context.value())) { - current.put(linkRelation.get(), normalizeToIso(context.value())); + Optional link = context.schema().flatMap(FeatureSchema::getEffectiveLink); + if (link.isPresent() && Objects.nonNull(context.value())) { + FeatureSchema schema = context.schema().get(); + String value = + schema.getType() == SchemaBase.Type.DATETIME + ? normalizeToIso(context.value()) + : context.value(); + current.add( + PropertyLink.of( + link.get().getRel(), link.get().getUriTemplate(), value, schema.getLabel())); return; } super.onValue(context); @@ -102,16 +114,17 @@ static String normalizeToIso(String value) { @Override public void onFeatureEnd(ModifiableContext context) { if (!current.isEmpty()) { - context.setRoleLinks(current); - this.resultRoleLinks = current; + List links = List.copyOf(current); + context.setPropertyLinks(links); + this.resultPropertyLinks = links; } super.onFeatureEnd(context); } @Override public void onEnd(ModifiableContext context) { - if (isSingleFeature && Objects.nonNull(resultRoleLinks)) { - roleLinksSetter.accept(resultRoleLinks); + if (isSingleFeature && Objects.nonNull(resultPropertyLinks)) { + propertyLinksSetter.accept(resultPropertyLinks); } super.onEnd(context); } diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/PropertyLink.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/PropertyLink.java new file mode 100644 index 000000000..edd67544c --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/PropertyLink.java @@ -0,0 +1,42 @@ +/* + * 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.domain; + +import java.util.Optional; +import org.immutables.value.Value; + +/** + * A feature property captured for representation as a web link instead of an inline value, see + * {@link SchemaLink}. Captured by {@code FeatureTokenTransformerPropertyLinks} from properties with + * an {@link FeatureSchema#getEffectiveLink() effective link}; the URI template is resolved against + * the request URIs in the API layer, which knows the service, collection and feature URIs. + */ +@Value.Immutable +public interface PropertyLink { + + /** The link relation type. */ + String getRel(); + + /** The URI template with unresolved parameters, see {@link SchemaLink#getUriTemplate()}. */ + String getUriTemplate(); + + /** The property value; DATETIME values are normalized to ISO 8601 instants. */ + String getValue(); + + /** The label of the property, if configured; used as the link title. */ + Optional getTitle(); + + static PropertyLink of(String rel, String uriTemplate, String value, Optional title) { + return ImmutablePropertyLink.builder() + .rel(rel) + .uriTemplate(uriTemplate) + .value(value) + .title(title) + .build(); + } +} diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaLink.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaLink.java new file mode 100644 index 000000000..5b02c257d --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SchemaLink.java @@ -0,0 +1,137 @@ +/* + * 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.domain; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import de.ii.xtraplatform.docs.DocFile; +import de.ii.xtraplatform.docs.DocStep; +import de.ii.xtraplatform.docs.DocStep.Step; +import de.ii.xtraplatform.docs.DocTable; +import de.ii.xtraplatform.docs.DocTable.ColumnSet; +import org.immutables.value.Value; + +/** + * # Links + * + * @langEn A property can be mapped to a web link instead of an inline value. The property value is + * then not included in the feature properties of a response; instead, each format represents + * the feature property as a link with the configured link relation type — for example, as an + * entry in the `links` array of a GeoJSON feature. On responses for a single feature, the link + * is also added as an HTTP `Link` header. + *

    The URI of the link is constructed from the URI template, where the following parameters + * are replaced when the link is built: + *

    + * - `{{value}}`: the value of the property, percent-encoded; + * - `{{featureUri}}`: the canonical URI of the feature; + * - `{{collectionUri}}`: the URI of the collection; + * - `{{serviceUri}}`: the URI of the landing page. + * + *

    Links differ from feature references (`FEATURE_REF` properties): feature references + * represent relationships to other features and are included in the feature properties, while + * links are represented outside of the feature properties in the header or `links` array of the + * format. + *

    {@docTable:properties} + *

    Example: a property whose value is an entry in an external register: + *

    + * ```yaml + * types: + * observation: + * type: OBJECT + * sourcePath: /observation + * properties: + * observedProperty: + * type: STRING + * sourcePath: property_code + * link: + * rel: related + * uriTemplate: 'https://example.com/register/{{value}}' + * ``` + * + * @langDe Eine Eigenschaft kann statt auf einen Wert in den Feature-Eigenschaften auf einen + * Web-Link abgebildet werden. Der Wert der Eigenschaft wird dann nicht in den + * Feature-Eigenschaften einer Antwort ausgegeben; stattdessen repräsentiert jedes Format die + * Eigenschaft als Link mit der konfigurierten Linkrelation — zum Beispiel als Eintrag im + * `links`-Array eines GeoJSON-Features. Bei Antworten für ein einzelnes Feature wird der Link + * zusätzlich als HTTP-`Link`-Header gesetzt. + *

    Die URI des Links wird aus dem URI-Template gebildet, wobei die folgenden Parameter beim + * Erzeugen des Links ersetzt werden: + *

    + * - `{{value}}`: der Wert der Eigenschaft, prozent-kodiert; + * - `{{featureUri}}`: die kanonische URI des Features; + * - `{{collectionUri}}`: die URI der Collection; + * - `{{serviceUri}}`: die URI der Landing Page. + * + *

    Links unterscheiden sich von Feature-Referenzen (`FEATURE_REF`-Eigenschaften): + * Feature-Referenzen repräsentieren Beziehungen zu anderen Features und sind Teil der + * Feature-Eigenschaften, während Links außerhalb der Feature-Eigenschaften im Header bzw. + * `links`-Array des Formats repräsentiert werden. + *

    {@docTable:properties} + *

    Beispiel: eine Eigenschaft, deren Wert ein Eintrag in einem externen Register ist: + *

    + * ```yaml + * types: + * observation: + * type: OBJECT + * sourcePath: /observation + * properties: + * observedProperty: + * type: STRING + * sourcePath: property_code + * link: + * rel: related + * uriTemplate: 'https://example.com/register/{{value}}' + * ``` + * + * @ref:properties {@link de.ii.xtraplatform.features.domain.ImmutableSchemaLink} + */ +@DocFile( + path = "providers/details", + name = "links.md", + tables = { + @DocTable( + name = "properties", + rows = { + @DocStep(type = Step.TAG_REFS, params = "{@ref:properties}"), + @DocStep(type = Step.JSON_PROPERTIES) + }, + columnSet = ColumnSet.JSON_PROPERTIES), + }) +@Value.Immutable +@Value.Style(builder = "new") +@JsonDeserialize(builder = ImmutableSchemaLink.Builder.class) +public interface SchemaLink { + + String VALUE = "{{value}}"; + String FEATURE_URI = "{{featureUri}}"; + String COLLECTION_URI = "{{collectionUri}}"; + String SERVICE_URI = "{{serviceUri}}"; + + /** + * @langEn The link relation type, see [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288.html), + * for example a [registered relation + * type](https://www.iana.org/assignments/link-relations/link-relations.xhtml). + * @langDe Die Linkrelation, siehe [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288.html), zum + * Beispiel eine [registrierte + * Linkrelation](https://www.iana.org/assignments/link-relations/link-relations.xhtml). + */ + String getRel(); + + /** + * @langEn The template for the URI of the link. The parameters {@code {{value}}}, {@code + * {{featureUri}}}, {@code {{collectionUri}}} and {@code {{serviceUri}}} are replaced when the + * link is built. + * @langDe Das Template für die URI des Links. Die Parameter {@code {{value}}}, {@code + * {{featureUri}}}, {@code {{collectionUri}}} und {@code {{serviceUri}}} werden beim Erzeugen + * des Links ersetzt. + */ + String getUriTemplate(); + + static SchemaLink of(String rel, String uriTemplate) { + return new ImmutableSchemaLink.Builder().rel(rel).uriTemplate(uriTemplate).build(); + } +} diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/pipeline/FeatureEventHandlerSimple.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/pipeline/FeatureEventHandlerSimple.java index 3cc9449af..e95909709 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/pipeline/FeatureEventHandlerSimple.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/pipeline/FeatureEventHandlerSimple.java @@ -10,6 +10,7 @@ import de.ii.xtraplatform.features.domain.FeaturePathTracker; import de.ii.xtraplatform.features.domain.FeatureSchema; import de.ii.xtraplatform.features.domain.ModifiableCollectionMetadata; +import de.ii.xtraplatform.features.domain.PropertyLink; import de.ii.xtraplatform.features.domain.Query; import de.ii.xtraplatform.features.domain.SchemaBase.Type; import de.ii.xtraplatform.features.domain.SchemaMapping; @@ -92,7 +93,7 @@ default boolean isUseTargetPaths() { Map additionalInfo(); - Map roleLinks(); + List propertyLinks(); } interface ModifiableContext extends Context { @@ -167,7 +168,7 @@ default String pathAsString() { ModifiableContext putAdditionalInfo(String key, String value); - ModifiableContext setRoleLinks(Map roleLinks); + ModifiableContext setPropertyLinks(Iterable propertyLinks); } @Modifiable diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/SchemaLinkSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/SchemaLinkSpec.groovy new file mode 100644 index 000000000..fc69419cc --- /dev/null +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/SchemaLinkSpec.groovy @@ -0,0 +1,92 @@ +/* + * 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.domain + +import spock.lang.Specification + +class SchemaLinkSpec extends Specification { + + static ImmutableFeatureSchema.Builder property(String name, SchemaBase.Type type) { + return new ImmutableFeatureSchema.Builder() + .name(name) + .type(type) + .sourcePath(name) + } + + def 'an explicit link is the effective link'() { + given: + def schema = property("code", SchemaBase.Type.STRING) + .link(SchemaLink.of("related", 'https://example.com/register/{{value}}')) + .build() + + when: + def effective = schema.getEffectiveLink() + + then: + effective.isPresent() + effective.get().getRel() == "related" + effective.get().getUriTemplate() == 'https://example.com/register/{{value}}' + } + + def 'a role with a link relation derives the default link'() { + given: + def schema = property("vg", SchemaBase.Type.DATETIME) + .role(role) + .build() + + when: + def effective = schema.getEffectiveLink() + + then: + effective.isPresent() + effective.get().getRel() == rel + effective.get().getUriTemplate() == '{{featureUri}}?datetime={{value}}' + + where: + role | rel + SchemaBase.Role.PREDECESSOR_INTERVAL_START | "predecessor-version" + SchemaBase.Role.SUCCESSOR_INTERVAL_START | "successor-version" + } + + def 'an explicit link overrides the role-derived default'() { + given: + def schema = property("vg", SchemaBase.Type.DATETIME) + .role(SchemaBase.Role.PREDECESSOR_INTERVAL_START) + .link(SchemaLink.of("predecessor-version", '{{featureUri}}?version={{value}}')) + .build() + + expect: + schema.getEffectiveLink().get().getUriTemplate() == '{{featureUri}}?version={{value}}' + } + + def 'properties without a link or link-relation role have no effective link'() { + expect: + property("name", SchemaBase.Type.STRING).build().getEffectiveLink().isEmpty() + property("beg", SchemaBase.Type.DATETIME) + .role(SchemaBase.Role.PRIMARY_INTERVAL_START) + .build() + .getEffectiveLink() + .isEmpty() + } + + def 'values of DATETIME-typed properties are normalized to ISO instants'() { + // normalizeToIso is only applied to properties of type DATETIME; values of DATE-typed + // properties bypass it and stay dates. The bare-date row covers the defensive case of a + // DATETIME-typed property carrying a date-only value. + expect: + FeatureTokenTransformerPropertyLinks.normalizeToIso(input) == expected + + where: + input | expected + "2026-05-12T11:46:39Z" | "2026-05-12T11:46:39Z" + "2026-05-12 11:46:39+02" | "2026-05-12T09:46:39Z" + "2026-05-12 11:46:39" | "2026-05-12T11:46:39Z" + "2026-05-12" | "2026-05-12T00:00:00Z" + "not-a-date" | "not-a-date" + } +} From b839185c90c25ff82b480d4b0675f69dc23739a3 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 11 Jun 2026 13:27:56 +0200 Subject: [PATCH 11/25] remove references to internal planning documents from comments Comments and javadoc referenced section numbers and phase labels of internal planning documents that are meaningless to readers of the code. The comments now state the rules themselves (no-backdating, composite-id convention, denorm pointer maintenance) or describe pending work without a roadmap position. --- .../gml/domain/FeatureTokenDecoderGml.java | 7 +++---- .../GeometryDecoderGmlRoundtripSpec.groovy | 4 ++-- .../features/sql/app/SqlMutationSession.java | 16 ++++++++-------- .../sql/app/VersionedMutationSqlSpec.groovy | 6 +++--- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java index ba757f3ea..5e5dad317 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGml.java @@ -83,8 +83,7 @@ * a {@code VALUE_PROPERTY} is treated as a {@code VALUE_WRAPPER} around the property's scalar text, * no explicit {@code valueWrap} entry required. Wrappers in other namespaces (e.g. {@code * }) still need explicit {@code valueWrap} - * configuration on the input profile. To be documented as a Phase 5 restriction of the GML building - * block. + * configuration on the input profile. A known restriction of the GML building block. */ public class FeatureTokenDecoderGml extends FeatureTokenDecoderSimple< @@ -102,8 +101,8 @@ public class FeatureTokenDecoderGml * }, {@code }). When such an element appears as a child of * a scalar property element the decoder treats it as a value wrapper around the scalar text, even * without an explicit {@code valueWrap} entry. Any other external namespace that follows the same - * convention needs a similar entry here when the need arises — documented as a known restriction - * in Phase 5 of the GML building block. + * convention needs a similar entry here when the need arises — a known restriction of the GML + * building block. */ private static final String GMD_NS = "http://www.isotc211.org/2005/gmd"; diff --git a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGmlRoundtripSpec.groovy b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGmlRoundtripSpec.groovy index f07b69d57..86d34d2cc 100644 --- a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGmlRoundtripSpec.groovy +++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/GeometryDecoderGmlRoundtripSpec.groovy @@ -240,9 +240,9 @@ class GeometryDecoderGmlRoundtripSpec extends Specification { // curveMembers after decode, not 5. // // CRS handling: srsName="urn:adv:crs:ETRS89_UTM32" is an ADV URN form. The decoder does - // not yet resolve these — that is Phase 1c (srsNameMappings). The current contract is + // not yet resolve these (srsNameMappings support is pending). The current contract is // therefore "unresolved srsName → no CRS captured"; this assertion locks that behaviour - // and will need to flip to Optional.of(EpsgCrs.of(25832)) when Phase 1c lands. + // and will need to flip to Optional.of(EpsgCrs.of(25832)) when srsName mappings land. roundtrip( ''' diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java index 74c55e73e..d50157ce6 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java @@ -379,7 +379,7 @@ public FeatureTransactions.MutationResult retireFeature( String idColumnName = idColumn.get().second().getName(); String tsLiteral = sqlString(retirementTimestamp.toString()); - // Denorm SUCCESSOR_INTERVAL_START (plan §1.6, option (i)): if the schema mapping binds the + // Denorm SUCCESSOR_INTERVAL_START: if the schema mapping binds the // role to a column on the main table, set it to the retirement timestamp — which is also // the new version's start in retire-and-insert flows. Opt-in: no SUCCESSOR_INTERVAL_START // role on the schema means no SET clause is added. @@ -397,10 +397,10 @@ public FeatureTransactions.MutationResult retireFeature( // Optimistic-concurrency + no-backdating in one atomic UPDATE: the row must be the open // version (endCol IS NULL) AND its start must be strictly before the retirement timestamp - // (no-backdating, plan §1.5). A backdated or non-existent retire matches 0 rows; the caller + // (no-backdating). A backdated or non-existent retire matches 0 rows; the caller // surfaces that as a 409. When `expectedStart` is present, an additional `startCol = // expectedStart` predicate is appended — an If-Unmodified-Since-style check that maps to a - // 412 on miss (plan §1.8 composite-id convention). + // 412 on miss (composite-id convention). StringBuilder where = new StringBuilder(idColumnName) .append(" = ") @@ -439,7 +439,7 @@ public FeatureTransactions.MutationResult retireFeature( return builder.build(); } - // Versioned-Insert pre-flight (plan §1.5 Part A.insert): refuses to write a new feature row when + // Versioned-Insert pre-flight: refuses to write a new feature row when // any version of the same role-id already exists (open or retired). Clients add new versions of // an existing feature through Replace / Update / Delete; Insert is reserved for brand-new ids. // The check runs as a single SELECT on the main table and returns an error result the caller @@ -494,7 +494,7 @@ public FeatureTransactions.MutationResult assertNoConflictingVersion( } // Reads the open version's PRIMARY_INTERVAL_START value for `featureId`. Used by versioned - // retire-and-insert flows (plan §1.6) to populate the new row's PREDECESSOR_INTERVAL_START + // retire-and-insert flows to populate the new row's PREDECESSOR_INTERVAL_START // denorm column. Returns empty when no open version exists or the type lacks the required // columns — the caller treats the value as "no predecessor info available" and omits the // override. @@ -583,7 +583,7 @@ public FeatureTransactions.MutationResult patchFeature( // whose PRIMARY_INTERVAL_END column is currently NULL). The same predicate is propagated into // every junction patch's subquery so junction rows are only touched on the open parent. // - // No-backdating (plan §1.5): when one of the `updates` sets the PRIMARY_INTERVAL_END column to + // No-backdating: when one of the `updates` sets the PRIMARY_INTERVAL_END column to // a value V, also require `startCol < V` in the WHERE so a retire-in-place Update that would // produce a zero-or-negative interval matches 0 rows and surfaces as a 409. We scan the // updates upfront, find the end-setting one, format the value as the same SQL literal the @@ -632,7 +632,7 @@ public FeatureTransactions.MutationResult patchOpenVersion( break; } } - // Composite-id If-Unmodified-Since predicate (plan §1.8): the open version's start must + // Composite-id If-Unmodified-Since predicate: the open version's start must // equal the value the client encoded in the rid's suffix. Otherwise the UPDATE matches 0 // rows and the caller maps that to a 412 Precondition Failed. if (expectedStart.isPresent()) { @@ -644,7 +644,7 @@ public FeatureTransactions.MutationResult patchOpenVersion( return patchInternal(featureType, featureId, updates, crs, extra, "open version of feature"); } - // Versioned Update CLONE_AND_PATCH (plan §1.3): create a new version of the open row, carry + // Versioned Update CLONE_AND_PATCH: create a new version of the open row, carry // forward every column, apply the property updates, and retire the previous open version. The // sequence is: // 1. SELECT the open row's surrogate PK ([+ start when the predecessor role is bound]). diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/VersionedMutationSqlSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/VersionedMutationSqlSpec.groovy index 2a3d1357f..13a3a3a45 100644 --- a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/VersionedMutationSqlSpec.groovy +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/VersionedMutationSqlSpec.groovy @@ -28,13 +28,13 @@ import java.time.Instant * SQL contains every predicate the versioning semantics require: * *

      - *
    • {@code assertNoConflictingVersion} (Insert pre-flight, §1.5): + *
    • {@code assertNoConflictingVersion} (Insert pre-flight): * SELECT gated by an id-existence check — any version (open or retired) of * the same id rejects the Insert. - *
    • {@code retireFeature} (Replace's retire half, §1.3/§1.5/§1.6/§1.8): + *
    • {@code retireFeature} (Replace's retire half): * SET adds {@code _nachfolger_lzi_beg = ts}; WHERE adds * {@code endCol IS NULL AND startCol < ts AND startCol = expectedStart}. - *
    • {@code patchOpenVersion} (Update's RETIRE_IN_PLACE, §1.3/§1.5/§1.8): + *
    • {@code patchOpenVersion} (Update's RETIRE_IN_PLACE): * UPDATE's WHERE adds {@code endCol IS NULL AND startCol < newEnd AND * startCol = expectedStart}. *
    From d3d22bc0f252d3a18112605f2963ccf954b542cb Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 11 Jun 2026 15:41:11 +0200 Subject: [PATCH 12/25] features: fix missing temporal extent of results for DATE-typed properties The temporal extent of a query result was populated via Instant.parse, which throws on date-only values; the exception was silently swallowed, so results from collections with a DATE-typed primary instant or interval never carried a temporal extent. Dates are now interpreted as start of day UTC, consistent with the temporal handling elsewhere in the pipeline. --- .../FeatureTokenTransformerMetadata.java | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMetadata.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMetadata.java index dd93d7ea3..4aa5a6fc1 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMetadata.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMetadata.java @@ -12,6 +12,12 @@ import de.ii.xtraplatform.crs.domain.OgcCrs; import de.ii.xtraplatform.geometries.domain.transform.MinMaxDeriver; import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; @@ -74,11 +80,11 @@ public void onEnd(ModifiableContext context) { try { if (!start.isEmpty() && !end.isEmpty()) { - temporalExtentSetter.accept(Tuple.of(Instant.parse(start), Instant.parse(end))); + temporalExtentSetter.accept(Tuple.of(parseTemporal(start), parseTemporal(end))); } else if (!start.isEmpty()) { - temporalExtentSetter.accept(Tuple.of(Instant.parse(start), null)); + temporalExtentSetter.accept(Tuple.of(parseTemporal(start), null)); } else if (!end.isEmpty()) { - temporalExtentSetter.accept(Tuple.of(null, Instant.parse(end))); + temporalExtentSetter.accept(Tuple.of(null, parseTemporal(end))); } } catch (Throwable ignore) { } @@ -93,6 +99,20 @@ public void onEnd(ModifiableContext context) { super.onEnd(context); } + // The primary instant/interval properties may be DATETIME or DATE; a date is interpreted as + // start of day UTC. + private static Instant parseTemporal(String value) { + TemporalAccessor ta = + DateTimeFormatter.ofPattern("yyyy-MM-dd[['T'][' ']HH:mm:ss[.SSS]][X]") + .parseBest(value, OffsetDateTime::from, LocalDateTime::from, LocalDate::from); + if (ta instanceof OffsetDateTime) { + return ((OffsetDateTime) ta).toInstant(); + } else if (ta instanceof LocalDateTime) { + return ((LocalDateTime) ta).toInstant(ZoneOffset.UTC); + } + return ((LocalDate) ta).atStartOfDay(ZoneOffset.UTC).toInstant(); + } + @Override public void onGeometry(ModifiableContext context) { if (context.schema().filter(SchemaBase::isPrimaryGeometry).isPresent() From a71102630139e858c088091cf756e48a134ffb57 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Fri, 12 Jun 2026 12:52:34 +0200 Subject: [PATCH 13/25] features: add option to declare globally unique feature ids New provider option `globallyUniqueFeatureIds` (default false), documented for the generated configuration docs. It is exposed through `FeatureInfo.featureIdsAreGloballyUnique()` and implemented in `AbstractFeatureProvider` from the provider data. This lets consumers that merge features from several feature types into one response keep feature ids unchanged instead of qualifying them with the collection to avoid collisions. --- .../domain/AbstractFeatureProvider.java | 5 +++++ .../features/domain/FeatureInfo.java | 9 +++++++++ .../features/domain/FeatureProviderDataV2.java | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/AbstractFeatureProvider.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/AbstractFeatureProvider.java index 076431621..1745acf50 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/AbstractFeatureProvider.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/AbstractFeatureProvider.java @@ -332,6 +332,11 @@ public Set getGeometryTypes() { return Set.copyOf(types); } + @Override + public boolean featureIdsAreGloballyUnique() { + return getData().getGloballyUniqueFeatureIds(); + } + private boolean softClosePrevious( Runner previousRunner, FeatureProviderConnector previousConnector) { if (Objects.nonNull(previousConnector) || Objects.nonNull(previousRunner)) { diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureInfo.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureInfo.java index 0d1625255..e690bc8f5 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureInfo.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureInfo.java @@ -29,4 +29,13 @@ public interface FeatureInfo { default boolean hasGeneratedId(String featureType) { return true; } + + /** + * Whether feature ids are unique across all feature types of the provider, not just within a + * single type. Consumers that merge features from multiple types into one response can rely on + * this to keep ids unqualified. + */ + default boolean featureIdsAreGloballyUnique() { + return false; + } } diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureProviderDataV2.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureProviderDataV2.java index 454670de1..ec1a1f339 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureProviderDataV2.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureProviderDataV2.java @@ -135,6 +135,24 @@ default long getEntitySchemaVersion() { */ List getProvidesGeometryTypes(); + /** + * @langEn If `true`, feature ids are unique across all feature types of the provider, not just + * within a single type. Consumers that combine features from multiple types into one response + * (for example the *Search* building block) can then keep the ids unchanged instead of + * qualifying them with the collection to avoid collisions. + * @langDe Bei `true` sind Feature-Ids über alle Objektarten des Providers hinweg eindeutig, nicht + * nur innerhalb einer Objektart. Komponenten, die Features aus mehreren Objektarten in einer + * Antwort zusammenführen (zum Beispiel der Baustein *Search*), können die Ids dann + * unverändert lassen, anstatt sie zur Vermeidung von Kollisionen mit der Collection zu + * qualifizieren. + * @default false + * @since v4.8 + */ + @Value.Default + default boolean getGloballyUniqueFeatureIds() { + return false; + } + /** * @langEn Optional custom CQL2 functions that can be used in filters. See [Custom CQL2 * Functions](#custom-cql2-functions) for details and examples. From 27731a5fe796433635aa7aea663dbe1a6909c104 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Fri, 12 Jun 2026 12:53:17 +0200 Subject: [PATCH 14/25] features-sql: avoid redundant meta queries for large result limits A query with a large limit is split into limit/chunkSize query sets, each carrying its own meta query. Previously every chunk re-ran the full numberMatched count and, with computeNumberMatched enabled, meta queries kept being issued even after the result was exhausted - producing hundreds of identical counts for a single page. numberMatched is now computed only on the first chunk of each collection (it is invariant across chunks); later chunks reuse that value. Meta queries also stop once the limit is reached or a collection is exhausted, independent of computeNumberMatched. MetaQueryTemplate gains a withNumberMatched flag; the now-obsolete allowSkipMetaQueries flag is removed. --- .../sql/app/FeatureQueryEncoderSql.java | 8 ++++---- .../features/sql/app/SqlQueryTemplates.java | 3 ++- .../sql/app/SqlQueryTemplatesDeriver.java | 5 +++-- .../features/sql/domain/SqlConnector.java | 18 +++++++----------- .../features/sql/domain/SqlQueryBatch.java | 5 ----- .../app/SqlQueryTemplatesDeriverSpec.groovy | 2 +- 6 files changed, 17 insertions(+), 24 deletions(-) diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureQueryEncoderSql.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureQueryEncoderSql.java index 2db99d644..9d4aeb2b0 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureQueryEncoderSql.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureQueryEncoderSql.java @@ -53,7 +53,6 @@ public class FeatureQueryEncoderSql implements FeatureQueryEncoder> allQueryTemplates; private final Map> allQueryTemplatesMutations; private final int chunkSize; - private final boolean skipRedundantMetaQueries; private final SqlDialect sqlDialect; private final boolean geometryAsWkb; @@ -65,7 +64,6 @@ public FeatureQueryEncoderSql( this.allQueryTemplates = allQueryTemplates; this.allQueryTemplatesMutations = allQueryTemplatesMutations; this.chunkSize = queryGeneratorSettings.getChunkSize(); - this.skipRedundantMetaQueries = !queryGeneratorSettings.getComputeNumberMatched(); this.geometryAsWkb = queryGeneratorSettings.getGeometryAsWkb(); this.sqlDialect = sqlDialect; } @@ -126,7 +124,6 @@ private SqlQueryBatch encode(FeatureQuery query, Map additionalQ .offset(query.getOffset()) .chunkSize(chunkSize) .isSingleFeature(query.returnsSingleFeature()) - .isAllowSkipMetaQueries(skipRedundantMetaQueries) .build() .withQuerySets(querySets); } @@ -200,7 +197,10 @@ private SqlQuerySet createQuerySet( additionalQueryParameters, query.getOffset() > 0, maxLimit > 0 && !query.hitsOnly(), - query.hitsOnly())); + query.hitsOnly(), + // numberMatched is invariant across chunks, so compute it only on the + // first chunk of each collection; later chunks reuse that value + chunk == 0)); TriFunction> valueQueries = (metaResult, maxLimit, skipped) -> diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryTemplates.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryTemplates.java index 445c2cab4..7084952a5 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryTemplates.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryTemplates.java @@ -36,7 +36,8 @@ String generateMetaQuery( Map virtualTables, boolean withNumberSkipped, boolean withNumberReturned, - boolean forceNumberMatched); + boolean forceNumberMatched, + boolean withNumberMatched); } @FunctionalInterface diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryTemplatesDeriver.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryTemplatesDeriver.java index e2dba67b4..9e81376a6 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryTemplatesDeriver.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlQueryTemplatesDeriver.java @@ -96,7 +96,8 @@ MetaQueryTemplate createMetaQueryTemplate(SqlQuerySchema schema, SqlQueryMapping virtualTables, withNumberSkipped, withNumberReturned, - forceNumberMatched) -> { + forceNumberMatched, + withNumberMatched) -> { String limitAndOffsetSql = sqlDialect.applyToLimitAndOffset(limit, offset); String skipOffsetSql = skipOffset > 0 ? sqlDialect.applyToOffset(skipOffset) : ""; String asIds = sqlDialect.applyToAsIds(); @@ -130,7 +131,7 @@ MetaQueryTemplate createMetaQueryTemplate(SqlQuerySchema schema, SqlQueryMapping sqlDialect.castToBigInt(0))); String numberMatched = - computeNumberMatched || forceNumberMatched + (computeNumberMatched || forceNumberMatched) && withNumberMatched ? String.format( "SELECT count(*) AS numberMatched FROM (SELECT A.%2$s AS %4$s FROM %1$s A%3$s ORDER BY 1)%5$s", tableName, schema.getSortKey(), where, SKEY, asIds) diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlConnector.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlConnector.java index b6cf01e9d..8de0dea3a 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlConnector.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlConnector.java @@ -51,7 +51,6 @@ class Paging { private final long limit; private final long offset; private final long chunkSize; - private final boolean allowSkipMetaQueries; private long featureCountdown; private long numberSkipped; private String lastTable; @@ -59,11 +58,10 @@ class Paging { private long lastNumberSkipped; private boolean noOffset; - public Paging(long limit, long offset, long chunkSize, boolean allowSkipMetaQueries) { + public Paging(long limit, long offset, long chunkSize) { this.limit = limit; this.offset = offset; this.chunkSize = chunkSize; - this.allowSkipMetaQueries = allowSkipMetaQueries; this.featureCountdown = limit; this.numberSkipped = 0L; @@ -76,8 +74,11 @@ public Paging(long limit, long offset, long chunkSize, boolean allowSkipMetaQuer Optional> get(String currentTable) { long found = lastNumberReturned + lastNumberSkipped; + // Once the limit is reached or the current collection is exhausted (its last chunk returned + // fewer rows than the chunk size), no further meta query is needed: there are no more rows to + // read and numberMatched was already computed on the collection's first chunk. if (featureCountdown <= 0 || (Objects.equals(lastTable, currentTable) && found < chunkSize)) { - return allowSkipMetaQueries ? Optional.empty() : Optional.of(Tuple.of(0L, offset)); + return Optional.empty(); } long ns = numberSkipped; @@ -108,11 +109,7 @@ void register(String currentTable, SqlRowMeta metaResult) { default Reactive.Source getSourceStream( SqlQueryBatch queryBatch, SqlQueryOptions options) { Paging paging = - new Paging( - queryBatch.getLimit(), - queryBatch.getOffset(), - queryBatch.getChunkSize(), - queryBatch.isAllowSkipMetaQueries()); + new Paging(queryBatch.getLimit(), queryBatch.getOffset(), queryBatch.getChunkSize()); Source sqlRowSource1 = Source.iterable(queryBatch.getQuerySets()) @@ -245,8 +242,7 @@ default Reactive.Source getSourceStream( new Paging( queryBatch.getLimit(), queryBatch.getOffset(), - queryBatch.getChunkSize(), - queryBatch.isAllowSkipMetaQueries()); + queryBatch.getChunkSize()); int[] i = {0}; if (options.isHitsOnly()) { diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQueryBatch.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQueryBatch.java index 8029159bc..9763e666e 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQueryBatch.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQueryBatch.java @@ -24,10 +24,5 @@ default boolean isSingleFeature() { return false; } - @Value.Default - default boolean isAllowSkipMetaQueries() { - return false; - } - List getQuerySets(); } diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlQueryTemplatesDeriverSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlQueryTemplatesDeriverSpec.groovy index 29bcee432..cebdf7779 100644 --- a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlQueryTemplatesDeriverSpec.groovy +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/SqlQueryTemplatesDeriverSpec.groovy @@ -124,7 +124,7 @@ class SqlQueryTemplatesDeriverSpec extends Specification { static String meta(List templates, List sortBy, Optional userFilter) { - return templates.stream().map(t -> t.getMetaQueryTemplate().generateMetaQuery(10, 10, 0, sortBy, userFilter, ImmutableMap.of(), false, true, false)).collect(Collectors.joining("\n")) + return templates.stream().map(t -> t.getMetaQueryTemplate().generateMetaQuery(10, 10, 0, sortBy, userFilter, ImmutableMap.of(), false, true, false, true)).collect(Collectors.joining("\n")) } static List values(List templates, int limit, int offset, List sortBy, Cql2Expression filter) { From 7346e05471ed63ce9a78268a34965883f52efc5d Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Fri, 12 Jun 2026 17:28:33 +0200 Subject: [PATCH 15/25] cql: support result-set references in filters via inResultSet A query expression of the OGC API Search extension can name the selection of a query as a result set; later queries can then restrict their selection to features that are related to that set. This commit adds the cross-module machinery for evaluating such references: - New CQL2 operation inResultSet(property, setName), encoded as {"op": "inResultSet", "args": [...]} in CQL2 JSON and as INRESULTSET(property, 'setName') in CQL2 Text. The predicate behaves like IN (single values) or A_OVERLAPS (arrays) against the object ids of the named result set. - The service resolves a reference before encoding by attaching the producing query's feature type and effective filter to the node; the resolved context is not part of the JSON or text encodings. - FilterEncoderSql compiles the predicate to an IN subquery that selects the producer's id column, with the producer's filter encoded against the producer's own mapping. Chained references nest recursively; alias scoping in nested subqueries relies on SQL scope shadowing, pinned down by the new golden-SQL spec. - FeatureProviderSql supplies the feature-type-to-mapping resolver; only types with a single source path can produce a result set. - Projected result sets (the ids referenced by a property of the selected features) are rejected for now. --- .../cql/app/CqlTypeAndFunctionChecker.java | 6 + .../ii/xtraplatform/cql/domain/CqlToText.java | 3 + .../cql/domain/CqlVisitorCopy.java | 12 ++ .../xtraplatform/cql/domain/InResultSet.java | 90 +++++++++++ .../ii/xtraplatform/cql/domain/Operation.java | 1 + .../cql/infra/CqlTextVisitor.java | 10 ++ .../cql/app/InResultSetSpec.groovy | 126 +++++++++++++++ .../features/sql/app/FilterEncoderSql.java | 103 ++++++++++++ .../sql/domain/FeatureProviderSql.java | 6 +- .../FilterEncoderSqlInResultSetSpec.groovy | 147 ++++++++++++++++++ 10 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/InResultSet.java create mode 100644 xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/InResultSetSpec.groovy create mode 100644 xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlTypeAndFunctionChecker.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlTypeAndFunctionChecker.java index a00aaa990..0fe3baefc 100644 --- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlTypeAndFunctionChecker.java +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlTypeAndFunctionChecker.java @@ -36,6 +36,7 @@ import de.ii.xtraplatform.cql.domain.ImmutableLte; import de.ii.xtraplatform.cql.domain.ImmutableNeq; import de.ii.xtraplatform.cql.domain.In; +import de.ii.xtraplatform.cql.domain.InResultSet; import de.ii.xtraplatform.cql.domain.Interval; import de.ii.xtraplatform.cql.domain.IsNull; import de.ii.xtraplatform.cql.domain.Like; @@ -149,6 +150,11 @@ public Type visit(IsNull isNull, List children) { @Override public Type visit(BinaryScalarOperation scalarOperation, List children) { + if (scalarOperation instanceof InResultSet) { + // the first argument may be of any queryable type, the second is always the + // name of a result set + return Type.Boolean; + } checkOperation(scalarOperation, children); return Type.Boolean; } diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CqlToText.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CqlToText.java index 9a1d5dd03..1a64645b4 100644 --- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CqlToText.java +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CqlToText.java @@ -203,6 +203,9 @@ public String visit(Not not, List children) { @Override public String visit(BinaryScalarOperation scalarOperation, List children) { + if (scalarOperation instanceof InResultSet) { + return String.format("INRESULTSET(%s, %s)", children.get(0), children.get(1)); + } String operator = SCALAR_OPERATORS.get(scalarOperation.getClass()); return String.format("%s %s %s", children.get(0), operator, children.get(1)); } diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CqlVisitorCopy.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CqlVisitorCopy.java index 8c40397c2..31fbd66b7 100644 --- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CqlVisitorCopy.java +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CqlVisitorCopy.java @@ -33,6 +33,18 @@ public CqlNode visit(Not not, List children) { @Override public CqlNode visit(BinaryScalarOperation scalarOperation, List children) { + if (scalarOperation instanceof InResultSet) { + // keep the resolved producer context, only the args are copied + return new ImmutableInResultSet.Builder() + .from((InResultSet) scalarOperation) + .args( + children.stream() + .filter(child -> child instanceof Scalar) + .map(child -> (Scalar) child) + .toList()) + .build(); + } + BinaryScalarOperation.Builder builder = null; if (scalarOperation instanceof Eq) { diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/InResultSet.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/InResultSet.java new file mode 100644 index 000000000..1499d50d8 --- /dev/null +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/InResultSet.java @@ -0,0 +1,90 @@ +/* + * 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.domain; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import java.util.Optional; +import org.immutables.value.Value; + +/** + * Predicate that tests whether a property value (or the feature id) is contained in a named result + * set that is defined by another query of the same query expression. + * + *

    The two arguments are the property and the name of the result set (a string literal). The + * predicate behaves like an IN expression (single values) or an A_OVERLAPS expression (arrays) + * against the object ids in the result set. + * + *

    The result-set reference is resolved by the service before the filter is encoded: the + * producing query's feature type and its effective filter are attached to this node. They are not + * part of the JSON or text encoding. + */ +@Value.Immutable +@JsonDeserialize(builder = ImmutableInResultSet.Builder.class) +public interface InResultSet extends BinaryScalarOperation { + + String TYPE = "inResultSet"; + + @Override + @Value.Derived + default String getOp() { + return TYPE; + } + + /** Feature type of the query that defines the result set. */ + @JsonIgnore + Optional getProducerType(); + + /** Effective filter of the query that defines the result set. */ + @JsonIgnore + Optional getProducerFilter(); + + /** + * Property of the producing feature type whose values form the result set (projected result set). + * If empty, the result set consists of the ids of the selected features. + */ + @JsonIgnore + Optional getProducerValues(); + + @JsonIgnore + @Value.Lazy + default String getSetName() { + return String.valueOf(((ScalarLiteral) getArgs().get(1)).getValue()); + } + + @Value.Check + default void checkArgs() { + Preconditions.checkState( + getArgs().size() == 2 && getArgs().get(0) instanceof Property, + "the first argument of %s must be a property, found: %s", + TYPE, + getArgs()); + Preconditions.checkState( + getArgs().get(1) instanceof ScalarLiteral + && ((ScalarLiteral) getArgs().get(1)).getType() == String.class, + "the second argument of %s must be the name of a result set, found: %s", + TYPE, + getArgs().get(1)); + } + + static InResultSet of(String property, String setName) { + return new ImmutableInResultSet.Builder() + .args(ImmutableList.of(Property.of(property), ScalarLiteral.of(setName))) + .build(); + } + + static InResultSet of(Property property, String setName) { + return new ImmutableInResultSet.Builder() + .args(ImmutableList.of(property, ScalarLiteral.of(setName))) + .build(); + } + + abstract class Builder extends BinaryScalarOperation.Builder {} +} diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/Operation.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/Operation.java index 3e1fce04c..53d0b85e3 100644 --- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/Operation.java +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/Operation.java @@ -29,6 +29,7 @@ @Type(value = Or.class, name = Or.TYPE), @Type(value = IsNull.class, name = IsNull.TYPE), @Type(value = In.class, name = In.TYPE), + @Type(value = InResultSet.class, name = InResultSet.TYPE), @Type(value = Between.class, name = Between.TYPE), @Type(value = TAfter.class, name = TAfter.TYPE), @Type(value = TBefore.class, name = TBefore.TYPE), diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/infra/CqlTextVisitor.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/infra/CqlTextVisitor.java index c4f682131..bbedd5d98 100644 --- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/infra/CqlTextVisitor.java +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/infra/CqlTextVisitor.java @@ -38,6 +38,7 @@ import de.ii.xtraplatform.cql.domain.ImmutableLte; import de.ii.xtraplatform.cql.domain.ImmutableNeq; import de.ii.xtraplatform.cql.domain.In; +import de.ii.xtraplatform.cql.domain.InResultSet; import de.ii.xtraplatform.cql.domain.IsNull; import de.ii.xtraplatform.cql.domain.Like; import de.ii.xtraplatform.cql.domain.Not; @@ -602,6 +603,15 @@ public CqlNode visitFunction(CqlParser.FunctionContext ctx) { ctx.argumentList().positionalArgument().argument().stream() .map(arg -> (Operand) arg.accept(this)) .collect(Collectors.toList()); + + if ("INRESULTSET".equalsIgnoreCase(functionName) + && args.size() == 2 + && args.get(0) instanceof Property + && args.get(1) instanceof ScalarLiteral) { + return InResultSet.of( + (Property) args.get(0), String.valueOf(((ScalarLiteral) args.get(1)).getValue())); + } + return Function.of(functionName, args); } diff --git a/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/InResultSetSpec.groovy b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/InResultSetSpec.groovy new file mode 100644 index 000000000..245cc5419 --- /dev/null +++ b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/InResultSetSpec.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.cql.app + +import de.ii.xtraplatform.cql.domain.And +import de.ii.xtraplatform.cql.domain.Cql +import de.ii.xtraplatform.cql.domain.Cql2Expression +import de.ii.xtraplatform.cql.domain.Eq +import de.ii.xtraplatform.cql.domain.ImmutableInResultSet +import de.ii.xtraplatform.cql.domain.InResultSet +import de.ii.xtraplatform.cql.domain.Property +import de.ii.xtraplatform.cql.domain.ScalarLiteral +import org.skyscreamer.jsonassert.JSONAssert +import spock.lang.Shared +import spock.lang.Specification + +class InResultSetSpec extends Specification { + + @Shared + Cql cql + + def setupSpec() { + cql = new CqlImpl() + } + + def 'cql2-json round-trip'() { + + given: + String cqlJson = """ + { + "op": "inResultSet", + "args": [ { "property": "id" }, "flst" ] + } + """ + + when: 'reading json' + Cql2Expression actual = cql.read(cqlJson, Cql.Format.JSON) + + then: + actual == InResultSet.of("id", "flst") + ((InResultSet) actual).getSetName() == "flst" + + and: + + when: 'writing json' + String actual2 = cql.write(InResultSet.of("id", "flst"), Cql.Format.JSON) + + then: + JSONAssert.assertEquals(cqlJson, actual2, true) + } + + def 'cql2-json in a conjunction'() { + + given: + String cqlJson = """ + { + "op": "and", + "args": [ + { "op": "inResultSet", "args": [ { "property": "istBestandteilVon" }, "bb" ] }, + { "op": "=", "args": [ { "property": "name" }, "foo" ] } + ] + } + """ + + when: 'reading json' + Cql2Expression actual = cql.read(cqlJson, Cql.Format.JSON) + + then: + actual == And.of( + InResultSet.of("istBestandteilVon", "bb"), + Eq.of(Property.of("name"), ScalarLiteral.of("foo"))) + } + + def 'cql2-text round-trip'() { + + when: 'writing text' + String text = cql.write(InResultSet.of("id", "flst"), Cql.Format.TEXT) + + then: + text == "INRESULTSET(id, 'flst')" + + and: + + when: 'reading text' + Cql2Expression actual = cql.read(text, Cql.Format.TEXT) + + then: + actual == InResultSet.of("id", "flst") + } + + def 'resolved producer context is not part of the json encoding'() { + + given: + InResultSet resolved = new ImmutableInResultSet.Builder() + .from(InResultSet.of("id", "flst")) + .producerType("ax_flurstueck") + .producerFilter(Eq.of(Property.of("name"), ScalarLiteral.of("foo"))) + .build() + + when: + String json = cql.write(resolved, Cql.Format.JSON) + + then: + JSONAssert.assertEquals("""{ "op": "inResultSet", "args": [ { "property": "id" }, "flst" ] }""", json, true) + } + + def 'invalid arguments are rejected'() { + + when: 'the second argument is not a string' + cql.read("""{ "op": "inResultSet", "args": [ { "property": "id" }, 5 ] }""", Cql.Format.JSON) + + then: + thrown Exception + + when: 'the first argument is not a property' + cql.read("""{ "op": "inResultSet", "args": [ "id", "flst" ] }""", Cql.Format.JSON) + + then: + thrown Exception + } +} 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 a9d3a0bbf..c723cbdec 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 @@ -37,6 +37,7 @@ import de.ii.xtraplatform.cql.domain.Function; import de.ii.xtraplatform.cql.domain.GeometryNode; import de.ii.xtraplatform.cql.domain.In; +import de.ii.xtraplatform.cql.domain.InResultSet; import de.ii.xtraplatform.cql.domain.IsNull; import de.ii.xtraplatform.cql.domain.Like; import de.ii.xtraplatform.cql.domain.LogicalOperation; @@ -100,6 +101,7 @@ public class FilterEncoderSql { private final Cql cql; private final String accentiCollation; private final Map customFunctions; + private final java.util.function.Function> mappingResolver; BiFunction, Optional, Geometry> coordinatesTransformer; public FilterEncoderSql( @@ -120,12 +122,33 @@ public FilterEncoderSql( Cql cql, List customFunctions, String accentiCollation) { + this( + nativeCrs, + sqlDialect, + crsTransformerFactory, + crsInfo, + cql, + customFunctions, + accentiCollation, + type -> Optional.empty()); + } + + public FilterEncoderSql( + EpsgCrs nativeCrs, + SqlDialect sqlDialect, + CrsTransformerFactory crsTransformerFactory, + CrsInfo crsInfo, + Cql cql, + List customFunctions, + String accentiCollation, + java.util.function.Function> mappingResolver) { this.nativeCrs = nativeCrs; this.sqlDialect = sqlDialect; this.crsTransformerFactory = crsTransformerFactory; this.crsInfo = crsInfo; this.cql = cql; this.accentiCollation = accentiCollation; + this.mappingResolver = mappingResolver; this.customFunctions = ImmutableMap.copyOf( CqlBuiltInFunctions.prependBuiltInFunctions(customFunctions).stream() @@ -784,6 +807,11 @@ private boolean has3dOperand(List operands) { @Override public String visit(BinaryScalarOperation scalarOperation, List children) { + if (scalarOperation instanceof InResultSet) { + throw new IllegalArgumentException( + String.format("Filter is invalid. %s is not supported here.", InResultSet.TYPE)); + } + String operator = SCALAR_OPERATORS.get(scalarOperation.getClass()); List expressions = processBinary(scalarOperation.getArgs(), children); @@ -1765,6 +1793,10 @@ private boolean has3dOperand(List operands) { @Override public String visit(BinaryScalarOperation scalarOperation, List children) { + if (scalarOperation instanceof InResultSet) { + return encodeInResultSet((InResultSet) scalarOperation, children.get(0)); + } + String operator = SCALAR_OPERATORS.get(scalarOperation.getClass()); List expressions = processBinary(scalarOperation.getArgs(), children); @@ -1773,6 +1805,77 @@ public String visit(BinaryScalarOperation scalarOperation, List children return String.format(expressions.get(0), "", operation); } + private String encodeInResultSet(InResultSet inResultSet, String mainExpression) { + if (!operandHasSelect(mainExpression)) { + throw new IllegalArgumentException( + String.format( + "Filter is invalid. The first argument of %s must be a queryable.", + InResultSet.TYPE)); + } + + String setName = inResultSet.getSetName(); + String producerType = + inResultSet + .getProducerType() + .orElseThrow( + () -> + new IllegalArgumentException( + String.format("Filter is invalid. Unknown result set: '%s'.", setName))); + + if (inResultSet.getProducerValues().isPresent()) { + throw new IllegalArgumentException( + String.format( + "Filter is invalid. Result set '%s' is a projected result set, which is not supported.", + setName)); + } + + SqlQueryMapping producerMapping = + mappingResolver + .apply(producerType) + .orElseThrow( + () -> + new IllegalArgumentException( + String.format( + "Filter is invalid. Result set '%s' cannot be resolved for feature type '%s'.", + setName, producerType))); + + SqlQuerySchema producerTable = producerMapping.getMainTable(); + de.ii.xtraplatform.base.domain.util.Tuple idColumn = + producerMapping + .getColumnForId() + .orElseThrow( + () -> + new IllegalArgumentException( + String.format( + "Filter is invalid. Feature type '%s' has no id property for result set '%s'.", + producerType, setName))); + + if (!idColumn.first().getRelations().isEmpty()) { + throw new IllegalArgumentException( + String.format( + "Filter is invalid. The id property of feature type '%s' is not a column of the main table, result set '%s' is not supported.", + producerType, setName)); + } + + Optional tableFilter = + producerTable.getFilter().map(filter -> (Cql2Expression) filter); + Optional producerFilter = inResultSet.getProducerFilter(); + Optional effectiveFilter = + tableFilter.isPresent() && producerFilter.isPresent() + ? Optional.of(And.of(tableFilter.get(), producerFilter.get())) + : tableFilter.isPresent() ? tableFilter : producerFilter; + + String where = + effectiveFilter.map(filter -> " WHERE " + encode(filter, producerMapping)).orElse(""); + + String subquery = + String.format( + "SELECT A.%2$s FROM %1$s A%3$s", + producerTable.getName(), idColumn.second().getName(), where); + + return String.format(mainExpression, "", String.format(" IN (%s)", subquery)); + } + @Override public String visit(Like like, List children) { String operator = SCALAR_OPERATORS.get(like.getClass()); diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java index 83d6d0eac..28412bbff 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java @@ -592,7 +592,11 @@ protected boolean onStartup() throws InterruptedException { crsInfo, cql, getCql2Functions(), - accentiCollation); + accentiCollation, + type -> + Optional.ofNullable(queryMappings.get(type)) + .filter(mappings -> mappings.size() == 1) + .map(mappings -> mappings.get(0))); AggregateStatsQueryGenerator queryGeneratorSql = new AggregateStatsQueryGenerator(sqlDialect, filterEncoder); diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy new file mode 100644 index 000000000..dbf7f01a8 --- /dev/null +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy @@ -0,0 +1,147 @@ +/* + * 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.ImmutableInResultSet +import de.ii.xtraplatform.cql.domain.InResultSet +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.FeatureSchemaFixtures +import de.ii.xtraplatform.features.domain.MappingOperationResolver +import de.ii.xtraplatform.features.domain.MappingRuleFixtures +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 + +import java.util.function.Function + +class FilterEncoderSqlInResultSetSpec extends Specification { + + @Shared + Map mappings = [:] + @Shared + FilterEncoderSql filterEncoder + + def setupSpec() { + def defaults = new ImmutableSqlPathDefaults.Builder().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 mappingOperationResolver = new MappingOperationResolver() + + ["simple", "value_array"].each { name -> + def schema = FeatureSchemaFixtures.fromYaml(name) + def resolved = schema.accept(mappingOperationResolver, List.of()) + def rules = MappingRuleFixtures.fromYaml(name) + mappings[name] = mappingDeriver.derive(rules, resolved).get(0) + } + + filterEncoder = new FilterEncoderSql(OgcCrs.CRS84, new SqlDialectPgis(), null, null, cql, List.of(), null, + { type -> Optional.ofNullable(mappings[type]) } as Function) + } + + static InResultSet resolved(InResultSet inResultSet, String producerType, de.ii.xtraplatform.cql.domain.Cql2Expression producerFilter) { + def builder = new ImmutableInResultSet.Builder() + .from(inResultSet) + .producerType(producerType) + if (producerFilter != null) { + builder.producerFilter(producerFilter) + } + return builder.build() + } + + def 'plain id set, consumer matches its id queryable'() { + given: 'a result set over type simple, consumed by a filter on the id queryable' + def filter = resolved(InResultSet.of("id", "s1"), "simple", + Eq.of(Property.of("id"), ScalarLiteral.of("foo"))) + + when: + def sql = filterEncoder.encode(filter, mappings["value_array"]) + + then: + sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT A.id FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id = 'foo')))" + } + + def 'plain id set without a producer filter'() { + given: + def filter = resolved(InResultSet.of("id", "s1"), "simple", null) + + when: + def sql = filterEncoder.encode(filter, mappings["value_array"]) + + then: + sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT A.id FROM externalprovider A))" + } + + def 'plain id set, consumer matches an array property in a junction table'() { + given: 'the consumer property is multi-valued, the semantics are like A_OVERLAPS' + def filter = resolved(InResultSet.of("externalprovidername", "s1"), "simple", + Eq.of(Property.of("id"), ScalarLiteral.of("foo"))) + + when: + def sql = filterEncoder.encode(filter, mappings["value_array"]) + + then: + sql == "A.id IN (SELECT AA.id FROM externalprovider AA JOIN externalprovider_externalprovidername AB ON (AA.id=AB.externalprovider_fk) WHERE AB.externalprovidername IN (SELECT A.id FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id = 'foo')))" + } + + def 'chained result sets nest recursively'() { + given: 'the producer filter itself consumes another result set' + def inner = resolved(InResultSet.of("id", "s1"), "simple", + Eq.of(Property.of("id"), ScalarLiteral.of("foo"))) + def outer = resolved(InResultSet.of("id", "s2"), "value_array", inner) + + when: + def sql = filterEncoder.encode(outer, mappings["value_array"]) + + then: + sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT A.id FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT A.id FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id = 'foo')))))" + } + + def 'an unresolved result set reference is rejected'() { + when: + filterEncoder.encode(InResultSet.of("id", "s1"), mappings["value_array"]) + + then: + def e = thrown IllegalArgumentException + e.message.contains("s1") + } + + def 'an unknown producer type is rejected'() { + when: + filterEncoder.encode(resolved(InResultSet.of("id", "s1"), "unknown", null), mappings["value_array"]) + + then: + def e = thrown IllegalArgumentException + e.message.contains("unknown") + } + + def 'a projected result set is rejected'() { + given: + def filter = new ImmutableInResultSet.Builder() + .from(InResultSet.of("id", "s1")) + .producerType("simple") + .producerValues("ref") + .build() + + when: + filterEncoder.encode(filter, mappings["value_array"]) + + then: + def e = thrown IllegalArgumentException + e.message.contains("projected") + } +} From 931b4dccef3f0a88d84826679e350b6d280597fc Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Fri, 12 Jun 2026 17:49:23 +0200 Subject: [PATCH 16/25] cql: support projected result sets in inResultSet A result set so far consisted of the ids of the features selected by the producing query. A projected result set instead consists of the ids referenced by a property of the selected features, declared by the service as the projected property on the resolved inResultSet node. FilterEncoderSql resolves the projected property against the producer's mapping and selects its value column, joining from the producer's main table along the property's table path (for example a junction table for multi-valued relations). The traversal follows the storage direction of the relation. The plain id set is now the degenerate case of the same code path, which also lifts the earlier restriction that the id column must be on the main table. --- .../features/sql/app/FilterEncoderSql.java | 61 +++++++++++-------- .../FilterEncoderSqlInResultSetSpec.groovy | 53 +++++++++++++--- 2 files changed, 80 insertions(+), 34 deletions(-) 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 c723cbdec..c34e6073b 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 @@ -1822,13 +1822,6 @@ private String encodeInResultSet(InResultSet inResultSet, String mainExpression) new IllegalArgumentException( String.format("Filter is invalid. Unknown result set: '%s'.", setName))); - if (inResultSet.getProducerValues().isPresent()) { - throw new IllegalArgumentException( - String.format( - "Filter is invalid. Result set '%s' is a projected result set, which is not supported.", - setName)); - } - SqlQueryMapping producerMapping = mappingResolver .apply(producerType) @@ -1839,26 +1832,37 @@ private String encodeInResultSet(InResultSet inResultSet, String mainExpression) "Filter is invalid. Result set '%s' cannot be resolved for feature type '%s'.", setName, producerType))); - SqlQuerySchema producerTable = producerMapping.getMainTable(); - de.ii.xtraplatform.base.domain.util.Tuple idColumn = - producerMapping - .getColumnForId() - .orElseThrow( - () -> - new IllegalArgumentException( - String.format( - "Filter is invalid. Feature type '%s' has no id property for result set '%s'.", - producerType, setName))); + // a projected result set consists of the ids referenced by a property of the selected + // features, a plain result set of the ids of the selected features + de.ii.xtraplatform.base.domain.util.Tuple setColumn = + inResultSet.getProducerValues().isPresent() + ? producerMapping + .getColumnForValue(inResultSet.getProducerValues().get()) + .orElseThrow( + () -> + new IllegalArgumentException( + String.format( + "Filter is invalid. Result set '%s' projects the property '%s', which is unknown for feature type '%s'.", + setName, inResultSet.getProducerValues().get(), producerType))) + : producerMapping + .getColumnForId() + .orElseThrow( + () -> + new IllegalArgumentException( + String.format( + "Filter is invalid. Feature type '%s' has no id property for result set '%s'.", + producerType, setName))); - if (!idColumn.first().getRelations().isEmpty()) { - throw new IllegalArgumentException( - String.format( - "Filter is invalid. The id property of feature type '%s' is not a column of the main table, result set '%s' is not supported.", - producerType, setName)); - } + SqlQuerySchema valueTable = setColumn.first(); + List aliases = AliasGenerator.getAliases(valueTable); + SqlQueryTable producerMain = + valueTable.getRelations().isEmpty() ? valueTable : valueTable.getRelations().get(0); + String join = JoinGenerator.getJoins(valueTable, aliases, FilterEncoderSql.this); + String valueColumn = + String.format("%s.%s", aliases.get(aliases.size() - 1), setColumn.second().getName()); Optional tableFilter = - producerTable.getFilter().map(filter -> (Cql2Expression) filter); + producerMapping.getMainTable().getFilter().map(filter -> (Cql2Expression) filter); Optional producerFilter = inResultSet.getProducerFilter(); Optional effectiveFilter = tableFilter.isPresent() && producerFilter.isPresent() @@ -1870,8 +1874,13 @@ private String encodeInResultSet(InResultSet inResultSet, String mainExpression) String subquery = String.format( - "SELECT A.%2$s FROM %1$s A%3$s", - producerTable.getName(), idColumn.second().getName(), where); + "SELECT %2$s FROM %1$s %3$s%4$s%5$s%6$s", + producerMain.getName(), + valueColumn, + aliases.get(0), + join.isEmpty() ? "" : " ", + join, + where); return String.format(mainExpression, "", String.format(" IN (%s)", subquery)); } diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy index dbf7f01a8..2b5ded8c8 100644 --- a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy @@ -42,7 +42,7 @@ class FilterEncoderSqlInResultSetSpec extends Specification { def mappingDeriver = new SqlMappingDeriver(pathParser, new ImmutableQueryGeneratorSettings.Builder().build()) def mappingOperationResolver = new MappingOperationResolver() - ["simple", "value_array"].each { name -> + ["simple", "value_array", "simple_filter"].each { name -> def schema = FeatureSchemaFixtures.fromYaml(name) def resolved = schema.accept(mappingOperationResolver, List.of()) def rules = MappingRuleFixtures.fromYaml(name) @@ -54,12 +54,19 @@ class FilterEncoderSqlInResultSetSpec extends Specification { } static InResultSet resolved(InResultSet inResultSet, String producerType, de.ii.xtraplatform.cql.domain.Cql2Expression producerFilter) { + return resolved(inResultSet, producerType, producerFilter, null) + } + + static InResultSet resolved(InResultSet inResultSet, String producerType, de.ii.xtraplatform.cql.domain.Cql2Expression producerFilter, String values) { def builder = new ImmutableInResultSet.Builder() .from(inResultSet) .producerType(producerType) if (producerFilter != null) { builder.producerFilter(producerFilter) } + if (values != null) { + builder.producerValues(values) + } return builder.build() } @@ -129,19 +136,49 @@ class FilterEncoderSqlInResultSetSpec extends Specification { e.message.contains("unknown") } - def 'a projected result set is rejected'() { + def 'projected result set over a junction table'() { + given: 'the set consists of the values referenced by an array property of the selected features' + def filter = resolved(InResultSet.of("id", "s1"), "value_array", + Eq.of(Property.of("id"), ScalarLiteral.of("foo")), "externalprovidername") + + when: + def sql = filterEncoder.encode(filter, mappings["simple"]) + + then: + sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT B.externalprovidername FROM externalprovider A JOIN externalprovider_externalprovidername B ON (A.id=B.externalprovider_fk) WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id = 'foo')))" + } + + def 'projected result set over a column of the main table'() { + given: + def filter = resolved(InResultSet.of("id", "s1"), "simple", null, "id") + + when: + def sql = filterEncoder.encode(filter, mappings["value_array"]) + + then: + sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT A.id FROM externalprovider A))" + } + + def 'the filter of the producer main table is applied to the result set'() { + given: + def filter = resolved(InResultSet.of("id", "s1"), "simple_filter", null) + + when: + def sql = filterEncoder.encode(filter, mappings["simple"]) + + then: + sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT A.id FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.type = 1)))" + } + + def 'an unknown projected property is rejected'() { given: - def filter = new ImmutableInResultSet.Builder() - .from(InResultSet.of("id", "s1")) - .producerType("simple") - .producerValues("ref") - .build() + def filter = resolved(InResultSet.of("id", "s1"), "simple", null, "nosuchproperty") when: filterEncoder.encode(filter, mappings["value_array"]) then: def e = thrown IllegalArgumentException - e.message.contains("projected") + e.message.contains("nosuchproperty") } } From 06c1569e51514cd1a4271ee694865181002e868a Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Fri, 12 Jun 2026 18:45:02 +0200 Subject: [PATCH 17/25] features: fix multi-queries losing results when queries share a feature type A multi-query with two or more queries on the same feature type returned only the results of the first such query, or failed with "Multiple entries with same key". Several places assumed that the feature type, or the main table, identifies a query of a multi-query: - the chunked SQL execution tracked paging per main table, so a second query set on the same table was mistaken for an exhausted chunk of the previous query and silently skipped; query sets now carry the index of their query and paging is tracked per query - numberMatched of a later query on the same table was dropped by the meta-result aggregation for the same reason - the feature decoder only starts a new feature when the id or type changes, so the same feature selected by two adjacent queries was merged into one feature with duplicated tokens; rows now carry the query index and the decoder includes it in the boundary check - the type-keyed maps for schema mappings and property transformations failed on duplicate keys; same-type queries are now merged, with the union of the requested properties - the property-inclusion check during decoding only considered the first query with a matching type; it now includes a property if any query with that type requests it --- .../features/sql/app/FeatureDecoderSql.java | 6 ++- .../sql/app/FeatureQueryEncoderSql.java | 16 ++++--- .../features/sql/domain/SqlConnector.java | 27 +++++++++--- .../features/sql/domain/SqlQueryOptions.java | 6 +++ .../features/sql/domain/SqlQuerySet.java | 9 ++++ .../features/sql/domain/SqlRow.java | 5 +++ .../features/sql/infra/db/SqlRowVals.java | 7 ++++ .../domain/AbstractFeatureProvider.java | 42 +++++++++++++++---- .../features/domain/FeatureEventHandler.java | 29 ++++++++----- .../features/domain/FeatureStreamImpl.java | 5 ++- 10 files changed, 120 insertions(+), 32 deletions(-) diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureDecoderSql.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureDecoderSql.java index 8fe4593bf..f246e7a7d 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureDecoderSql.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureDecoderSql.java @@ -57,6 +57,7 @@ public class FeatureDecoderSql private boolean started; private boolean featureStarted; private Object currentId; + private int currentQueryIndex; private boolean isAtLeastOneFeatureWritten; private ModifiableContext context; @@ -189,7 +190,9 @@ private void handleValueRow(SqlRow sqlRow) { schemaIndexes.clear(); } - if (!Objects.equals(currentId, featureId) || !Objects.equals(context.type(), featureType)) { + if (!Objects.equals(currentId, featureId) + || !Objects.equals(context.type(), featureType) + || currentQueryIndex != sqlRow.getQueryIndex()) { if (featureStarted) { getDownstream().onFeatureEnd(context); this.featureStarted = false; @@ -202,6 +205,7 @@ private void handleValueRow(SqlRow sqlRow) { getDownstream().onFeatureStart(context); this.featureStarted = true; this.currentId = featureId; + this.currentQueryIndex = sqlRow.getQueryIndex(); } List multiplicitiesForPath = diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureQueryEncoderSql.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureQueryEncoderSql.java index 9d4aeb2b0..245ec560b 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureQueryEncoderSql.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureQueryEncoderSql.java @@ -113,7 +113,8 @@ private SqlQueryBatch encode(FeatureQuery query, Map additionalQ query, query, additionalQueryParameters, - query.returnsSingleFeature()))) + query.returnsSingleFeature(), + 0))) .flatMap(s -> s) .collect(Collectors.toList()); @@ -133,9 +134,11 @@ private SqlQueryBatch encode( int chunks = (query.getLimit() / chunkSize) + (query.getLimit() % chunkSize > 0 ? 1 : 0); List querySets = - query.getQueries().stream() + IntStream.range(0, query.getQueries().size()) + .boxed() .flatMap( - typeQuery -> { + queryIndex -> { + TypeQuery typeQuery = query.getQueries().get(queryIndex); List queryTemplates = allQueryTemplates.get(typeQuery.getType()); @@ -153,7 +156,8 @@ private SqlQueryBatch encode( typeQuery, query, additionalQueryParameters, - false))) + false, + queryIndex))) .flatMap(s -> s); }) .collect(Collectors.toList()); @@ -176,7 +180,8 @@ private SqlQuerySet createQuerySet( TypeQuery typeQuery, Query query, Map additionalQueryParameters, - boolean skipMetaQuery) { + boolean skipMetaQuery, + int queryIndex) { List sortKeys = transformSortKeys(typeQuery.getSortKeys(), queryTemplates.getMapping()); boolean useMinMaxKeys = queryTemplates.getMapping().getMainTable().isSortKeyUnique(); @@ -228,6 +233,7 @@ private SqlQuerySet createQuerySet( .metaQuery(metaQuery) .valueQueries(valueQueries) .options(getOptions(typeQuery, query)) + .queryIndex(queryIndex) .build() .withTableSchemas(queryTemplates.getMapping().getTables()); } diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlConnector.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlConnector.java index 8de0dea3a..778c4d04d 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlConnector.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlConnector.java @@ -116,7 +116,12 @@ default Reactive.Source getSourceStream( .via( Transformer.flatMap( querySet -> { - String currentTable = querySet.getTableSchemas().get(0).getFullPathAsString(); + // multiple queries may use the same feature type, so the key includes the + // query index + String currentTable = + querySet.getQueryIndex() + + "_" + + querySet.getTableSchemas().get(0).getFullPathAsString(); Optional> maxLimitAndSkipped = paging.get(currentTable); @@ -226,6 +231,10 @@ default Reactive.Source getSourceStream( .getOptions() .getType()) .containerPriority(i[0]++) + .queryIndex( + querySets + .get(index) + .getQueryIndex()) .build())) .toArray( (IntFunction[]>) Source[]::new); @@ -257,11 +266,13 @@ default Reactive.Source getSourceStream( Transformer.flatMap( index -> { String currentTable = - querySets - .get(index) - .getTableSchemas() - .get(0) - .getFullPathAsString(); + querySets.get(index).getQueryIndex() + + "_" + + querySets + .get(index) + .getTableSchemas() + .get(0) + .getFullPathAsString(); int[] j = {0}; if (metaResults.get(index).getNumberReturned() <= 0) { @@ -298,6 +309,10 @@ default Reactive.Source getSourceStream( .getOptions() .getType()) .containerPriority(i[0]++) + .queryIndex( + querySets + .get(index) + .getQueryIndex()) .build())) .toArray((IntFunction[]>) Source[]::new); diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQueryOptions.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQueryOptions.java index 46ae95378..30e949302 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQueryOptions.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQueryOptions.java @@ -68,6 +68,12 @@ default int getContainerPriority() { return 0; } + /** Index of the query in a multi-query that this query belongs to. */ + @Value.Default + default int getQueryIndex() { + return 0; + } + @Value.Default default int getChunkSize() { return 1000; diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQuerySet.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQuerySet.java index 23d3e2301..40b68ff84 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQuerySet.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlQuerySet.java @@ -24,4 +24,13 @@ public interface SqlQuerySet { List getTableSchemas(); SqlQueryOptions getOptions(); + + /** + * Index of the query in a multi-query that this query set belongs to. Multiple queries may use + * the same feature type, so the main table alone does not identify the query. + */ + @Value.Default + default int getQueryIndex() { + return 0; + } } diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlRow.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlRow.java index bb52ae8a8..d010a3ae0 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlRow.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlRow.java @@ -43,6 +43,11 @@ default int getPriority() { Optional getType(); + /** Index of the query in a multi-query that produced this row. */ + default int getQueryIndex() { + return 0; + } + default List> getColumnPaths() { return ImmutableList.of(); } diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/SqlRowVals.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/SqlRowVals.java index 46e81e5fb..a2ab58816 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/SqlRowVals.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/SqlRowVals.java @@ -44,6 +44,7 @@ class SqlRowVals implements SqlRow { private List sortKeyDirections; private final List values; private int priority; + private int queryIndex; private SqlQuerySchema tableSchema; private Optional type; @Nullable private final Collator collator; @@ -106,6 +107,11 @@ public Optional getType() { return type; } + @Override + public int getQueryIndex() { + return queryIndex; + } + @Override public List> getColumnPaths() { if (Objects.nonNull(tableSchema)) { @@ -156,6 +162,7 @@ public int getSchemaIndex(int i) { // TODO: use result.nextObject when column type info is supported SqlRow read(ResultSet result, SqlQueryOptions queryOptions) { this.priority = queryOptions.getContainerPriority(); + this.queryIndex = queryOptions.getQueryIndex(); List> columnTypes; int cursor = 1; diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/AbstractFeatureProvider.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/AbstractFeatureProvider.java index 1745acf50..05c4a4547 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/AbstractFeatureProvider.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/AbstractFeatureProvider.java @@ -28,6 +28,7 @@ import de.ii.xtraplatform.features.domain.FeatureQueriesExtension.LIFECYCLE_HOOK; import de.ii.xtraplatform.features.domain.FeatureStream.ResultBase; import de.ii.xtraplatform.features.domain.ImmutableSchemaMapping.Builder; +import de.ii.xtraplatform.features.domain.MultiFeatureQuery.SubQuery; import de.ii.xtraplatform.features.domain.SchemaBase.Scope; import de.ii.xtraplatform.features.domain.transform.PropertyTransformations; import de.ii.xtraplatform.features.domain.transform.SchemaTransformerChain; @@ -41,6 +42,7 @@ import java.io.IOException; import java.util.EnumSet; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -605,19 +607,43 @@ private Map createMapping( } if (query instanceof MultiFeatureQuery) { - return ((MultiFeatureQuery) query) - .getQueries().stream() - .map( - typeQuery -> - Map.entry( - typeQuery.getType(), - createMapping(typeQuery, WITH_SCOPE_RETURNABLE, propertyTransformations))) - .collect(ImmutableMap.toImmutableMap(Entry::getKey, Entry::getValue)); + // multiple queries may use the same feature type; the mapping is per type, so the + // projections of such queries are merged + Map queriesByType = new LinkedHashMap<>(); + for (SubQuery subQuery : ((MultiFeatureQuery) query).getQueries()) { + queriesByType.merge( + subQuery.getType(), subQuery, AbstractFeatureProvider::mergeProjections); + } + + return queriesByType.entrySet().stream() + .map( + entry -> + Map.entry( + entry.getKey(), + createMapping( + entry.getValue(), WITH_SCOPE_RETURNABLE, propertyTransformations))) + .collect(ImmutableMap.toImmutableMap(Entry::getKey, Entry::getValue)); } return Map.of(); } + private static TypeQuery mergeProjections(TypeQuery query1, TypeQuery query2) { + List fields = + query1.getFields().contains("*") || query2.getFields().contains("*") + ? List.of("*") + : java.util.stream.Stream.concat( + query1.getFields().stream(), query2.getFields().stream()) + .distinct() + .toList(); + + return ImmutableSubQuery.builder() + .from((SubQuery) query1) + .fields(fields) + .skipGeometry(query1.skipGeometry() && query2.skipGeometry()) + .build(); + } + private SchemaMapping createMapping( TypeQuery query, WithScope withScope, diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureEventHandler.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureEventHandler.java index e5bd082a0..0343c3fb2 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureEventHandler.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureEventHandler.java @@ -261,30 +261,37 @@ default boolean shouldSkip() { private boolean shouldInclude(T schema, List parentSchemas, String path) { return schema.isId() - || (schema.isSpatial() && (Objects.isNull(typeQuery()) || !typeQuery().skipGeometry())) + || (schema.isSpatial() + && (typeQueries().isEmpty() + || typeQueries().stream().anyMatch(typeQuery -> !typeQuery.skipGeometry()))) // TODO: enable if projected output needs to be schema valid // || isRequired(schema, parentSchemas) || (!schema.isId() && propertyIsInFields(path)); } - private TypeQuery typeQuery() { + // multiple queries of a multi-query may use the same feature type, the projections of such + // queries are merged + private List typeQueries() { return query() instanceof FeatureQuery - ? (FeatureQuery) query() + ? List.of((FeatureQuery) query()) : query() instanceof MultiFeatureQuery ? ((MultiFeatureQuery) query()) .getQueries().stream() .filter(subQuery -> Objects.equals(subQuery.getType(), type())) - .findFirst() - .orElse(null) - : null; + .toList() + : List.of(); } default boolean propertyIsInFields(String property) { - TypeQuery typeQuery = typeQuery(); - return Objects.nonNull(typeQuery) - && (typeQuery.getFields().isEmpty() - || typeQuery.getFields().contains("*") - || typeQuery.getFields().stream().anyMatch(field -> field.startsWith(property))); + List typeQueries = typeQueries(); + return !typeQueries.isEmpty() + && typeQueries.stream() + .anyMatch( + typeQuery -> + typeQuery.getFields().isEmpty() + || typeQuery.getFields().contains("*") + || typeQuery.getFields().stream() + .anyMatch(field -> field.startsWith(property))); } default boolean isRequired(T schema, List parentSchemas) { diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java index e24bb38db..656fbca5c 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java @@ -349,6 +349,8 @@ static Map getMergedTransformations( } if (query instanceof MultiFeatureQuery multiFeatureQuery) { + // multiple queries may use the same feature type, the transformations only depend on the + // type return multiFeatureQuery.getQueries().stream() .map( typeQuery -> @@ -358,7 +360,8 @@ static Map getMergedTransformations( featureSchemas, typeQuery, Optional.ofNullable(propertyTransformations.get(typeQuery.getType()))))) - .collect(ImmutableMap.toImmutableMap(Map.Entry::getKey, Map.Entry::getValue)); + .collect( + ImmutableMap.toImmutableMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a)); } return ImmutableMap.of(); From 5866aba98b6a2b340707964736c887bf688662cd Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Fri, 12 Jun 2026 18:46:51 +0200 Subject: [PATCH 18/25] features: support deduplication of multi-query results A feature that is selected by more than one query of a multi-query appears once per selection in the merged result. With the new deduplicate flag on the multi-query, a feature whose id has already been emitted is dropped from the result. Deduplication is a streaming transformer at the start of the token pipeline: the tokens of a feature are buffered until its id property arrives, then the feature is either replayed or discarded as a whole. The emitted ids are kept in a memory-efficient set (ids of up to 20 characters are packed losslessly into 128 bits, longer or unusual ids fall back to a 128-bit hash; about 32 bytes per id). The number of ids is bounded; on overflow the request fails with a clear error. Unless the provider declares globally unique feature ids, the feature type is part of the deduplication key. Note that numberReturned and numberMatched are computed in SQL before duplicates are dropped, so both can overcount when deduplication is enabled. --- .../features/domain/FeatureStreamImpl.java | 23 +- .../FeatureTokenTransformerDeduplicate.java | 262 ++++++++++++++++++ .../features/domain/MultiFeatureQuery.java | 9 + .../features/domain/PackedIdSet.java | 190 +++++++++++++ ...tureTokenTransformerDeduplicateSpec.groovy | 115 ++++++++ .../features/domain/PackedIdSetSpec.groovy | 80 ++++++ 6 files changed, 675 insertions(+), 4 deletions(-) create mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerDeduplicate.java create mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/PackedIdSet.java create mode 100644 xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerDeduplicateSpec.groovy create mode 100644 xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/PackedIdSetSpec.groovy diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java index 656fbca5c..13d6d6e36 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureStreamImpl.java @@ -51,6 +51,8 @@ public class FeatureStreamImpl implements FeatureStream { private final boolean stepEtag; private final boolean stepMetadata; private final boolean hasPropertyLinks; + private final boolean deduplicate; + private final boolean idsArePerType; public FeatureStreamImpl( Query query, @@ -89,6 +91,9 @@ public FeatureStreamImpl( !query.skipPipelineSteps().contains(PipelineSteps.METADATA) && !query.skipPipelineSteps().contains(PipelineSteps.ALL); this.hasPropertyLinks = hasPropertyLinks(query, data); + this.deduplicate = + query instanceof MultiFeatureQuery && ((MultiFeatureQuery) query).getDeduplicate(); + this.idsArePerType = !data.getGloballyUniqueFeatureIds(); } // For types without properties that are represented as links (an explicit `link` in the @@ -124,12 +129,17 @@ public CompletionStage runWith( BiFunction, Stream> stream = (tokenSource, virtualTables) -> { ImmutableResult.Builder resultBuilder = ImmutableResult.builder(); + // duplicates are dropped first so that no downstream step sees them + FeatureTokenSource deduplicated = + deduplicate + ? tokenSource.via(new FeatureTokenTransformerDeduplicate(idsArePerType)) + : tokenSource; // PropertyLinks must run before the per-format value-transformation step so it // captures the raw ISO timestamp, not a locale-formatted variant used in the body FeatureTokenSource source = hasPropertyLinks - ? tokenSource.via(new FeatureTokenTransformerPropertyLinks(resultBuilder)) - : tokenSource; + ? deduplicated.via(new FeatureTokenTransformerPropertyLinks(resultBuilder)) + : deduplicated; // FeatureTokenTransformerExtension query-extensions (e.g. composite-id rewrite) run in // the same pre-format slot so they see raw provider values and can mutate tokens before // any format-specific transformation @@ -207,12 +217,17 @@ public CompletionStage> runWith( BiFunction, Reactive.Stream>> stream = (tokenSource, virtualTables) -> { ImmutableResultReduced.Builder resultBuilder = ImmutableResultReduced.builder(); + // duplicates are dropped first so that no downstream step sees them + FeatureTokenSource deduplicated = + deduplicate + ? tokenSource.via(new FeatureTokenTransformerDeduplicate(idsArePerType)) + : tokenSource; // PropertyLinks must run before the per-format value-transformation step so it // captures the raw ISO timestamp, not a locale-formatted variant used in the body FeatureTokenSource source = hasPropertyLinks - ? tokenSource.via(new FeatureTokenTransformerPropertyLinks(resultBuilder)) - : tokenSource; + ? deduplicated.via(new FeatureTokenTransformerPropertyLinks(resultBuilder)) + : deduplicated; // FeatureTokenTransformerExtension query-extensions (e.g. composite-id rewrite) run in // the same pre-format slot so they see raw provider values and can mutate tokens before // any format-specific transformation diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerDeduplicate.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerDeduplicate.java new file mode 100644 index 000000000..16bf7e5f7 --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerDeduplicate.java @@ -0,0 +1,262 @@ +/* + * 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.domain; + +import de.ii.xtraplatform.features.domain.SchemaBase.Type; +import de.ii.xtraplatform.geometries.domain.Geometry; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Queue; + +/** + * Drops features whose id has already been emitted. The tokens of a feature are buffered until its + * id property arrives; the feature is then either replayed or discarded as a whole. Duplicates can + * only arise when multiple queries of a multi-query select the same feature. + */ +public class FeatureTokenTransformerDeduplicate extends FeatureTokenTransformer { + + public static final int MAX_FEATURES = 16_777_216; + + private final PackedIdSet seen; + private final boolean idsArePerType; + + private final Queue tokenQueue; + private final Queue> pathQueue; + private final Queue schemaIndexQueue; + private final Queue> indexesQueue; + private final Queue valueQueue; + private final Queue valueTypeQueue; + private final Queue> geoQueue; + private final Queue inArrayQueue; + private final Queue inObjectQueue; + + private boolean buffering; + private boolean dropping; + private String currentType; + + public FeatureTokenTransformerDeduplicate(boolean idsArePerType) { + this.seen = new PackedIdSet(MAX_FEATURES); + this.idsArePerType = idsArePerType; + this.tokenQueue = new LinkedList<>(); + this.pathQueue = new LinkedList<>(); + this.schemaIndexQueue = new LinkedList<>(); + this.indexesQueue = new LinkedList<>(); + this.valueQueue = new LinkedList<>(); + this.valueTypeQueue = new LinkedList<>(); + this.geoQueue = new LinkedList<>(); + this.inArrayQueue = new LinkedList<>(); + this.inObjectQueue = new LinkedList<>(); + this.buffering = false; + this.dropping = false; + } + + @Override + public void onFeatureStart(ModifiableContext context) { + this.currentType = + Objects.nonNull(context.mapping()) ? context.mapping().getTargetSchema().getName() : null; + this.buffering = true; + this.dropping = false; + + buffer(context, FeatureTokenType.FEATURE); + } + + @Override + public void onFeatureEnd(ModifiableContext context) { + if (dropping) { + this.dropping = false; + return; + } + if (buffering) { + // a feature without an id is always emitted + flush(context); + } + + super.onFeatureEnd(context); + } + + @Override + public void onObjectStart(ModifiableContext context) { + if (dropping) { + return; + } + if (buffering) { + buffer(context, FeatureTokenType.OBJECT); + return; + } + super.onObjectStart(context); + } + + @Override + public void onObjectEnd(ModifiableContext context) { + if (dropping) { + return; + } + if (buffering) { + buffer(context, FeatureTokenType.OBJECT_END); + return; + } + super.onObjectEnd(context); + } + + @Override + public void onArrayStart(ModifiableContext context) { + if (dropping) { + return; + } + if (buffering) { + buffer(context, FeatureTokenType.ARRAY); + return; + } + super.onArrayStart(context); + } + + @Override + public void onArrayEnd(ModifiableContext context) { + if (dropping) { + return; + } + if (buffering) { + buffer(context, FeatureTokenType.ARRAY_END); + return; + } + super.onArrayEnd(context); + } + + @Override + public void onGeometry(ModifiableContext context) { + if (dropping) { + return; + } + if (buffering) { + buffer(context, FeatureTokenType.GEOMETRY); + return; + } + super.onGeometry(context); + } + + @Override + public void onValue(ModifiableContext context) { + if (dropping) { + return; + } + if (buffering) { + buffer(context, FeatureTokenType.VALUE); + + if (context.schema().filter(SchemaBase::isId).isPresent() + && Objects.nonNull(context.value())) { + String key = + idsArePerType && Objects.nonNull(currentType) + ? currentType + ":" + context.value() + : context.value(); + + if (seen.add(key)) { + flush(context); + } else { + clear(); + this.dropping = true; + this.buffering = false; + } + } + return; + } + super.onValue(context); + } + + private void buffer( + ModifiableContext context, FeatureTokenType token) { + tokenQueue.add(token); + pathQueue.add(List.copyOf(context.path())); + schemaIndexQueue.add(context.schemaIndex()); + indexesQueue.add(new ArrayList<>(context.indexes())); + valueQueue.add(context.value()); + valueTypeQueue.add(context.valueType()); + geoQueue.add(context.geometry()); + inArrayQueue.add(context.inArray()); + inObjectQueue.add(context.inObject()); + } + + private void clear() { + tokenQueue.clear(); + pathQueue.clear(); + schemaIndexQueue.clear(); + indexesQueue.clear(); + valueQueue.clear(); + valueTypeQueue.clear(); + geoQueue.clear(); + inArrayQueue.clear(); + inObjectQueue.clear(); + } + + private void flush(ModifiableContext context) { + this.buffering = false; + + List path = context.path(); + int schemaIndex = context.schemaIndex(); + List indexes = new ArrayList<>(context.indexes()); + String value = context.value(); + Type valueType = context.valueType(); + Geometry geometry = context.geometry(); + boolean inArray = context.inArray(); + boolean inObject = context.inObject(); + + while (!tokenQueue.isEmpty()) { + FeatureTokenType token = tokenQueue.remove(); + + context.pathTracker().track(pathQueue.remove()); + context.setSchemaIndex(schemaIndexQueue.remove()); + context.setIndexes(indexesQueue.remove()); + context.setValue(valueQueue.remove()); + context.setValueType(valueTypeQueue.remove()); + context.setGeometry(geoQueue.remove()); + context.setInArray(inArrayQueue.remove()); + context.setInObject(inObjectQueue.remove()); + + push(context, token); + } + + context.pathTracker().track(path); + context.setSchemaIndex(schemaIndex); + context.setIndexes(indexes); + context.setValue(value); + context.setValueType(valueType); + context.setGeometry(geometry); + context.setInArray(inArray); + context.setInObject(inObject); + } + + private void push( + ModifiableContext context, FeatureTokenType token) { + switch (token) { + case FEATURE: + super.onFeatureStart(context); + break; + case VALUE: + super.onValue(context); + break; + case GEOMETRY: + super.onGeometry(context); + break; + case OBJECT: + super.onObjectStart(context); + break; + case OBJECT_END: + super.onObjectEnd(context); + break; + case ARRAY: + super.onArrayStart(context); + break; + case ARRAY_END: + super.onArrayEnd(context); + break; + default: + break; + } + } +} diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/MultiFeatureQuery.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/MultiFeatureQuery.java index 48f7085ad..7f88f8e3a 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/MultiFeatureQuery.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/MultiFeatureQuery.java @@ -19,4 +19,13 @@ interface SubQuery extends TypeQuery { } List getQueries(); + + /** + * If enabled, a feature that is selected by more than one query is only included in the response + * once. + */ + @Value.Default + default boolean getDeduplicate() { + return false; + } } diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/PackedIdSet.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/PackedIdSet.java new file mode 100644 index 000000000..c864fd114 --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/PackedIdSet.java @@ -0,0 +1,190 @@ +/* + * 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.domain; + +import com.google.common.hash.HashCode; +import com.google.common.hash.Hashing; +import java.nio.charset.StandardCharsets; + +/** + * A memory-efficient set of feature ids. Ids are not stored as strings: an id with up to 20 + * characters from {@code [0-9A-Za-z_-]} is packed losslessly into 128 bits, any other id is + * represented by its 128-bit hash (collisions are negligible). The set is an open-addressing hash + * table of long pairs, so the memory footprint is 32 bytes per id at the default load factor. + * + *

    The number of entries is bounded; adding an id beyond the bound throws an {@link + * IllegalStateException}. + */ +public class PackedIdSet { + + private static final long PACKED_MARKER = 0x8000_0000_0000_0000L; + private static final int MAX_PACKED_LENGTH = 20; + private static final int INITIAL_CAPACITY = 1024; + + private final int maxEntries; + private long[] table; + private int capacity; + private int size; + private boolean containsZero; + + public PackedIdSet(int maxEntries) { + this.maxEntries = maxEntries; + this.capacity = INITIAL_CAPACITY; + this.table = new long[2 * capacity]; + this.size = 0; + this.containsZero = false; + } + + /** + * Adds an id to the set. + * + * @param id the feature id + * @return {@code true} if the id was not in the set + */ + public boolean add(String id) { + long hi; + long lo; + + long[] packed = pack(id); + if (packed != null) { + hi = packed[0]; + lo = packed[1]; + } else { + HashCode hash = Hashing.murmur3_128().hashString(id, StandardCharsets.UTF_8); + byte[] bytes = hash.asBytes(); + hi = toLong(bytes, 0) & ~PACKED_MARKER; + lo = toLong(bytes, 8); + } + + if (hi == 0 && lo == 0) { + if (containsZero) { + return false; + } + checkBound(); + this.containsZero = true; + this.size++; + return true; + } + + int index = findSlot(table, capacity, hi, lo); + if (table[index] == hi && table[index + 1] == lo) { + return false; + } + + checkBound(); + table[index] = hi; + table[index + 1] = lo; + this.size++; + + if (size > capacity / 2 && capacity < Integer.MAX_VALUE / 4) { + grow(); + } + + return true; + } + + public int size() { + return size; + } + + private void checkBound() { + if (size >= maxEntries) { + throw new IllegalStateException( + String.format( + "The response exceeds the maximum number of features that can be deduplicated (%d).", + maxEntries)); + } + } + + private void grow() { + int newCapacity = capacity * 2; + long[] newTable = new long[2 * newCapacity]; + + for (int i = 0; i < table.length; i += 2) { + long hi = table[i]; + long lo = table[i + 1]; + if (hi != 0 || lo != 0) { + int index = findSlot(newTable, newCapacity, hi, lo); + newTable[index] = hi; + newTable[index + 1] = lo; + } + } + + this.table = newTable; + this.capacity = newCapacity; + } + + private static int findSlot(long[] table, int capacity, long hi, long lo) { + int mask = capacity - 1; + int slot = spread(hi, lo) & mask; + + while (true) { + int index = 2 * slot; + if ((table[index] == 0 && table[index + 1] == 0) + || (table[index] == hi && table[index + 1] == lo)) { + return index; + } + slot = (slot + 1) & mask; + } + } + + private static int spread(long hi, long lo) { + long mixed = (hi ^ (hi >>> 32)) * 0x9E3779B97F4A7C15L + (lo ^ (lo >>> 32)); + return (int) (mixed ^ (mixed >>> 32)) & Integer.MAX_VALUE; + } + + // 6 bits per character plus the length, so that ids with leading zero-characters stay distinct + private static long[] pack(String id) { + int length = id.length(); + if (length == 0 || length > MAX_PACKED_LENGTH) { + return null; + } + + long hi = 0; + long lo = 0; + for (int i = 0; i < length; i++) { + int bits = toBits(id.charAt(i)); + if (bits < 0) { + return null; + } + hi = (hi << 6) | (lo >>> 58); + lo = (lo << 6) | bits; + } + hi |= ((long) length) << 56; + hi |= PACKED_MARKER; + + return new long[] {hi, lo}; + } + + private static int toBits(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'A' && c <= 'Z') { + return c - 'A' + 10; + } + if (c >= 'a' && c <= 'z') { + return c - 'a' + 36; + } + if (c == '-') { + return 62; + } + if (c == '_') { + return 63; + } + return -1; + } + + private static long toLong(byte[] bytes, int offset) { + long result = 0; + for (int i = 0; i < 8; i++) { + result = (result << 8) | (bytes[offset + i] & 0xFF); + } + return result; + } +} diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerDeduplicateSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerDeduplicateSpec.groovy new file mode 100644 index 000000000..a87c656b4 --- /dev/null +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerDeduplicateSpec.groovy @@ -0,0 +1,115 @@ +/* + * 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.domain + +import de.ii.xtraplatform.features.domain.SchemaBase.Type +import spock.lang.Specification + +class FeatureTokenTransformerDeduplicateSpec extends Specification { + + FeatureTokenReader tokenReader + List tokens + + def setup() { + FeatureTokenTransformerDeduplicate mapper = new FeatureTokenTransformerDeduplicate(false) + FeatureQuery query = ImmutableFeatureQuery.builder().type("test").build() + FeatureEventHandler.ModifiableContext context = mapper.createContext() + .setQuery(query) + .setMappings([test: FeatureSchemaFixtures.BIOTOP_MAPPING]) + .setType('test') + .setIsUseTargetPaths(true) + + tokenReader = new FeatureTokenReader(mapper, context) + tokens = [] + mapper.init(token -> tokens.add(token)) + } + + static List feature(String id, String kennung) { + return [ + FeatureTokenType.FEATURE, + FeatureTokenType.VALUE, + ["id"], + id, + Type.STRING, + FeatureTokenType.VALUE, + ["kennung"], + kennung, + Type.STRING, + FeatureTokenType.FEATURE_END + ] + } + + static List collection(List... features) { + List result = [FeatureTokenType.INPUT, true] + features.each { result.addAll(it) } + result.add(FeatureTokenType.INPUT_END) + return result + } + + def 'distinct features pass through unchanged'() { + given: + def input = collection(feature("24", "611320001-1"), feature("25", "611320001-2")) + + when: + input.forEach(token -> tokenReader.onToken(token)) + + then: + tokens == input + } + + def 'a feature with an already emitted id is dropped'() { + given: + def input = collection( + feature("24", "611320001-1"), + feature("25", "611320001-2"), + feature("24", "611320001-1")) + + when: + input.forEach(token -> tokenReader.onToken(token)) + + then: + tokens == collection(feature("24", "611320001-1"), feature("25", "611320001-2")) + } + + def 'consecutive duplicates collapse to one feature'() { + given: + def input = collection( + feature("24", "611320001-1"), + feature("24", "611320001-1"), + feature("24", "611320001-1")) + + when: + input.forEach(token -> tokenReader.onToken(token)) + + then: + tokens == collection(feature("24", "611320001-1")) + } + + def 'properties before the id are kept on the first occurrence'() { + given: 'kennung arrives before id' + def feature24 = [ + FeatureTokenType.FEATURE, + FeatureTokenType.VALUE, + ["kennung"], + "611320001-1", + Type.STRING, + FeatureTokenType.VALUE, + ["id"], + "24", + Type.STRING, + FeatureTokenType.FEATURE_END + ] + def input = collection(feature24, feature24) + + when: + input.forEach(token -> tokenReader.onToken(token)) + + then: + tokens == collection(feature24) + } +} diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/PackedIdSetSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/PackedIdSetSpec.groovy new file mode 100644 index 000000000..3891d7268 --- /dev/null +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/PackedIdSetSpec.groovy @@ -0,0 +1,80 @@ +/* + * 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.domain + +import spock.lang.Specification + +class PackedIdSetSpec extends Specification { + + def 'packable ids are deduplicated'() { + given: + def set = new PackedIdSet(1000) + + expect: + set.add("DEHE862010014MLB") + set.add("DEHE862010014MLC") + !set.add("DEHE862010014MLB") + !set.add("DEHE862010014MLC") + set.size() == 2 + } + + def 'ids with leading zero-characters stay distinct'() { + given: + def set = new PackedIdSet(1000) + + expect: + set.add("A") + set.add("0A") + set.add("00A") + set.size() == 3 + !set.add("0A") + } + + def 'ids that cannot be packed fall back to a hash'() { + given: + def set = new PackedIdSet(1000) + + expect: + set.add("urn:adv:oid:DEHE862010014MLB") + !set.add("urn:adv:oid:DEHE862010014MLB") + set.add("a-very-long-identifier-that-exceeds-the-packing-limit") + !set.add("a-very-long-identifier-that-exceeds-the-packing-limit") + set.size() == 2 + } + + def 'the set grows beyond the initial capacity'() { + given: + def set = new PackedIdSet(100000) + + when: + def added = (0..<50000).count { set.add("ID" + it) } + def readded = (0..<50000).count { set.add("ID" + it) } + + then: + added == 50000 + readded == 0 + set.size() == 50000 + } + + def 'exceeding the maximum number of entries fails'() { + given: + def set = new PackedIdSet(3) + set.add("A") + set.add("B") + set.add("C") + + when: + set.add("D") + + then: + thrown IllegalStateException + + and: 'duplicates are still detected' + !set.add("A") + } +} From f9b0cea10cab6cfd155c717e347c19bff8fe294a Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Sat, 13 Jun 2026 10:32:05 +0200 Subject: [PATCH 19/25] features-sql: skip inResultSet hops with an incompatible target type When the value type of a result set is not among the valid target types of the property consuming it, the inResultSet predicate cannot match; it is now compiled to an always-false predicate and a warning is logged, instead of producing a subquery that can only match by id collision. The valid target types of a property are determined from its refType, from the union over the members of a concat/coalesce, or from a constant or enum on its type sub-property. When none of these constrains the target type, no check is applied. The value type of a result set is the producing query's feature type for a plain set, or the valid target types of the projected property for a projected set. --- .../features/sql/app/FilterEncoderSql.java | 90 ++++++++++++++ .../FilterEncoderSqlTargetTypesSpec.groovy | 113 ++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlTargetTypesSpec.groovy 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 c34e6073b..1c927ac59 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 @@ -55,6 +55,7 @@ import de.ii.xtraplatform.crs.domain.CrsTransformerFactory; import de.ii.xtraplatform.crs.domain.EpsgCrs; import de.ii.xtraplatform.features.domain.FeatureSchema; +import de.ii.xtraplatform.features.domain.SchemaConstraints; import de.ii.xtraplatform.features.domain.Tuple; import de.ii.xtraplatform.features.sql.domain.SchemaSql; import de.ii.xtraplatform.features.sql.domain.SchemaSql.PropertyTypeInfo; @@ -74,12 +75,15 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.function.BiFunction; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -384,6 +388,66 @@ public Optional encodeRelationFilter2( return Optional.of(encodeNested(null, mergedFilter, table.get(), true)); } + private static final String DYNAMIC_REF_TYPE = "DYNAMIC"; + + /** The valid target types of a property in a mapping, or empty if they are not constrained. */ + private Optional> targetTypes(SqlQueryMapping mapping, String propertyName) { + FeatureSchema schema = + mapping + .getSchemaForObject(propertyName) + .or(() -> mapping.getSchemaForValue(propertyName)) + .orElse(null); + return validTargetTypes(schema); + } + + /** + * The valid target types of a feature-reference property: from its {@code refType} (case 1), the + * union over its {@code concat}/{@code coalesce} members (case 2), or a constant or enum on its + * {@code type} sub-property (case 3). Empty when the target type is not constrained (case 4) or + * any branch is open. + */ + Optional> validTargetTypes(FeatureSchema schema) { + if (Objects.isNull(schema)) { + return Optional.empty(); + } + + List members = + !schema.getConcat().isEmpty() + ? schema.getConcat() + : !schema.getCoalesce().isEmpty() ? schema.getCoalesce() : List.of(); + if (!members.isEmpty()) { + Set types = new LinkedHashSet<>(); + for (FeatureSchema member : members) { + Optional> memberTypes = validTargetTypes(member); + if (memberTypes.isEmpty()) { + return Optional.empty(); // an open branch leaves the overall target type unconstrained + } + types.addAll(memberTypes.get()); + } + return types.isEmpty() ? Optional.empty() : Optional.of(types); + } + + if (schema.getRefType().filter(type -> !DYNAMIC_REF_TYPE.equals(type)).isPresent()) { + return Optional.of(Set.of(schema.getRefType().get())); + } + + Optional typeProperty = + schema.getProperties().stream().filter(FeatureSchema::isType).findFirst(); + if (typeProperty.isPresent()) { + FeatureSchema type = typeProperty.get(); + if (type.getConstantValue().isPresent()) { + return Optional.of(Set.of(type.getConstantValue().get())); + } + List enumValues = + type.getConstraints().map(SchemaConstraints::getEnumValues).orElse(List.of()); + if (!enumValues.isEmpty()) { + return Optional.of(new LinkedHashSet<>(enumValues)); + } + } + + return Optional.empty(); + } + private static Predicate getPropertyNameMatcher( String propertyName, boolean includeObjects) { return property -> @@ -1832,6 +1896,32 @@ private String encodeInResultSet(InResultSet inResultSet, String mainExpression) "Filter is invalid. Result set '%s' cannot be resolved for feature type '%s'.", setName, producerType))); + // type compatibility: the value type of the result set must be a valid target type of the + // consumed property. The target types of a property are known when it has a refType, a + // concat/coalesce, or a type sub-property with a constant or enum; otherwise they are + // unconstrained and the check is skipped (no false negatives). + String consumerProperty = + ((Property) inResultSet.getArgs().get(0)).getName().replaceAll("^\"|\"$", ""); + Optional> consumerTargets = targetTypes(mapping, consumerProperty); + Optional> setTypes = + inResultSet.getProducerValues().isPresent() + ? targetTypes(producerMapping, inResultSet.getProducerValues().get()) + : Optional.of(Set.of(producerType)); + if (consumerTargets.isPresent() + && setTypes.isPresent() + && Collections.disjoint(consumerTargets.get(), setTypes.get())) { + if (LOGGER.isWarnEnabled()) { + LOGGER.warn( + "inResultSet: the value type(s) {} of result set '{}' are not among the valid target" + + " types {} of property '{}'; the relation cannot match and the hop is skipped.", + setTypes.get(), + setName, + consumerTargets.get(), + consumerProperty); + } + return "1 = 0"; + } + // a projected result set consists of the ids referenced by a property of the selected // features, a plain result set of the ids of the selected features de.ii.xtraplatform.base.domain.util.Tuple setColumn = diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlTargetTypesSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlTargetTypesSpec.groovy new file mode 100644 index 000000000..801aa866d --- /dev/null +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlTargetTypesSpec.groovy @@ -0,0 +1,113 @@ +/* + * 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.crs.domain.OgcCrs +import de.ii.xtraplatform.features.domain.ImmutableFeatureSchema +import de.ii.xtraplatform.features.domain.ImmutableSchemaConstraints +import de.ii.xtraplatform.features.domain.SchemaBase +import de.ii.xtraplatform.features.sql.domain.SqlDialectPgis +import spock.lang.Shared +import spock.lang.Specification + +class FilterEncoderSqlTargetTypesSpec extends Specification { + + @Shared + FilterEncoderSql encoder = new FilterEncoderSql(OgcCrs.CRS84, new SqlDialectPgis(), null, null, new CqlImpl(), null) + + static ImmutableFeatureSchema.Builder ref() { + new ImmutableFeatureSchema.Builder().name("ref").type(SchemaBase.Type.FEATURE_REF) + } + + def 'case 1: a single refType is the only valid target type'() { + given: + def schema = ref().refType("target_a").build() + + expect: + encoder.validTargetTypes(schema) == Optional.of(["target_a"] as Set) + } + + def 'case 1: refType DYNAMIC is treated as unconstrained'() { + given: + def schema = ref().refType("DYNAMIC").build() + + expect: + encoder.validTargetTypes(schema) == Optional.empty() + } + + def 'case 2: concat target types are the union of the members'() { + given: + def schema = new ImmutableFeatureSchema.Builder() + .name("ref") + .type(SchemaBase.Type.FEATURE_REF_ARRAY) + .concat([ + ref().name("a").refType("target_a").build(), + ref().name("b").refType("target_b").build() + ]) + .build() + + expect: + encoder.validTargetTypes(schema) == Optional.of(["target_a", "target_b"] as Set) + } + + def 'case 2: an open concat member leaves the whole reference unconstrained'() { + given: + def schema = new ImmutableFeatureSchema.Builder() + .name("ref") + .type(SchemaBase.Type.FEATURE_REF_ARRAY) + .concat([ + ref().name("a").refType("target_a").build(), + ref().name("b").refType("DYNAMIC").build() + ]) + .build() + + expect: + encoder.validTargetTypes(schema) == Optional.empty() + } + + def 'case 3: a constant on the type sub-property defines the target type'() { + given: + def schema = ref() + .putProperties2("type", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.TYPE) + .constantValue("target_c")) + .build() + + expect: + encoder.validTargetTypes(schema) == Optional.of(["target_c"] as Set) + } + + def 'case 3: an enum on the type sub-property defines the valid target types'() { + given: + def schema = ref() + .putProperties2("type", new ImmutableFeatureSchema.Builder() + .type(SchemaBase.Type.STRING) + .role(SchemaBase.Role.TYPE) + .constraints(new ImmutableSchemaConstraints.Builder() + .enumValues(["target_d", "target_e"]).build())) + .build() + + expect: + encoder.validTargetTypes(schema) == Optional.of(["target_d", "target_e"] as Set) + } + + def 'case 4: no refType, concat/coalesce or type constraint is unconstrained'() { + given: + def schema = ref().build() + + expect: + encoder.validTargetTypes(schema) == Optional.empty() + } + + def 'a null schema is unconstrained'() { + expect: + encoder.validTargetTypes(null) == Optional.empty() + } +} From 8f2bd0da23dc1a1647c59d37bb63210c077ce277 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Sat, 13 Jun 2026 10:59:53 +0200 Subject: [PATCH 20/25] features: let a multi-query skip computing numberMatched A MultiFeatureQuery can disable numberMatched via computeNumberMatched. When disabled, the SQL query encoder omits the count query for each sub-query, which avoids running a count over the full (possibly deeply nested) filter once per sub-query. The provider-level computeNumberMatched still applies; this is an additional per-query control. --- .../features/sql/app/FeatureQueryEncoderSql.java | 6 +++++- .../xtraplatform/features/domain/MultiFeatureQuery.java | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureQueryEncoderSql.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureQueryEncoderSql.java index 245ec560b..086cac6a3 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureQueryEncoderSql.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/FeatureQueryEncoderSql.java @@ -185,6 +185,10 @@ private SqlQuerySet createQuerySet( List sortKeys = transformSortKeys(typeQuery.getSortKeys(), queryTemplates.getMapping()); boolean useMinMaxKeys = queryTemplates.getMapping().getMainTable().isSortKeyUnique(); + // a multi-query may opt out of computing numberMatched to avoid a count query per sub-query + boolean computeNumberMatched = + !(query instanceof MultiFeatureQuery) + || ((MultiFeatureQuery) query).getComputeNumberMatched(); BiFunction> metaQuery = (maxLimit, skipped) -> @@ -205,7 +209,7 @@ private SqlQuerySet createQuerySet( query.hitsOnly(), // numberMatched is invariant across chunks, so compute it only on the // first chunk of each collection; later chunks reuse that value - chunk == 0)); + chunk == 0 && computeNumberMatched)); TriFunction> valueQueries = (metaResult, maxLimit, skipped) -> diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/MultiFeatureQuery.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/MultiFeatureQuery.java index 7f88f8e3a..7700c0de7 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/MultiFeatureQuery.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/MultiFeatureQuery.java @@ -28,4 +28,13 @@ interface SubQuery extends TypeQuery { default boolean getDeduplicate() { return false; } + + /** + * If disabled, {@code numberMatched} is not computed. For a multi-query this avoids a count query + * per sub-query, each of which carries the full (possibly deeply nested) filter. + */ + @Value.Default + default boolean getComputeNumberMatched() { + return true; + } } From 6826bd903d497045a017836d98003253efe1784b Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Sun, 14 Jun 2026 14:31:10 +0200 Subject: [PATCH 21/25] fix filter CRS ignored for CQL2-JSON spatial filters The CQL2-JSON operand deserializer read the injected filter CRS with an `instanceof EpsgCrs` check, but the CRS is injected wrapped in an Optional, so geometry literals always defaulted to CRS84 regardless of the filter CRS. Unwrap the Optional, still accepting a bare value. --- .../src/main/java/de/ii/xtraplatform/cql/domain/Operand.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/Operand.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/Operand.java index 3199b7b3f..31494ee7f 100644 --- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/Operand.java +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/Operand.java @@ -177,6 +177,11 @@ private Optional getFilterCrs(ObjectCodec oc) throws JsonMappingExcepti if (Objects.nonNull(iv)) { try { Object value = iv.findInjectableValue("filterCrs", null, null, null); + // the value is injected as an Optional (see CqlImpl#read), but a bare EpsgCrs is + // also accepted + if (value instanceof Optional) { + value = ((Optional) value).orElse(null); + } if (value instanceof EpsgCrs) { return Optional.of((EpsgCrs) value); } From 8983da19eb0113a301efa2c314d919715ea53ed9 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Sun, 14 Jun 2026 16:20:04 +0200 Subject: [PATCH 22/25] features-sql: materialize result sets to fix multi-query fan-out performance A query expression that uses result sets fans out into one SQL statement per sub-query. Each statement re-embedded the producer of every result set it consumes, recursively, so a result set shared by many statements was re-derived once per statement and a deep chain nested (and re-evaluated) at every level. A large fan-out (~180 statements) did not complete in reasonable time. Two changes remove the redundant work: - Within a statement, each result set is hoisted into a WITH ... AS MATERIALIZED CTE and referenced, so it is evaluated once instead of at every nesting level. A new SqlDialect.materializedCte controls the syntax; PostgreSQL needs the explicit MATERIALIZED to keep a single-reference CTE from being inlined. - Across statements, the result sets of a multi-query are materialized once per request: each producer runs a single time (its dependencies already inlined as literal id lists), and the collected values are attached to the InResultSet nodes so the consumers encode them as a literal IN list. A result set larger than queryGeneration.resultSetMaterializationMaxSize falls back to the inline CTE encoding. The reference fan-out drops from a timeout to ~2 s. --- .../xtraplatform/cql/domain/InResultSet.java | 10 + .../features/sql/app/FilterEncoderSql.java | 232 +++++++++++++---- .../sql/app/ResultSetMaterializer.java | 242 ++++++++++++++++++ .../sql/domain/FeatureProviderSql.java | 55 ++-- .../sql/domain/FeatureProviderSqlData.java | 16 ++ .../features/sql/domain/SqlDialect.java | 9 + .../features/sql/domain/SqlDialectPgis.java | 8 + .../FilterEncoderSqlInResultSetSpec.groovy | 44 +++- .../sql/app/ResultSetMaterializerSpec.groovy | 132 ++++++++++ 9 files changed, 672 insertions(+), 76 deletions(-) create mode 100644 xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/ResultSetMaterializer.java create mode 100644 xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/ResultSetMaterializerSpec.groovy diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/InResultSet.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/InResultSet.java index 1499d50d8..f1b04483c 100644 --- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/InResultSet.java +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/InResultSet.java @@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; +import java.util.List; import java.util.Optional; import org.immutables.value.Value; @@ -53,6 +54,15 @@ default String getOp() { @JsonIgnore Optional getProducerValues(); + /** + * Values of the result set, materialized by the service before the filter is encoded. When + * present, the predicate is encoded as a literal {@code IN} list rather than a nested subquery; + * an empty list means the result set has no members. When absent, the result set is re-derived + * inline (subquery / CTE). + */ + @JsonIgnore + Optional> getMaterializedValues(); + @JsonIgnore @Value.Lazy default String getSetName() { 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 1c927ac59..e9d8bad03 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 @@ -76,6 +76,7 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -86,6 +87,7 @@ import java.util.Set; import java.util.function.BiFunction; import java.util.function.Predicate; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -264,6 +266,149 @@ private String reduceSelectToColumnForTemplate(String expression) { return expression; } + // output column alias of every result-set CTE; consumers reference it as `SELECT FROM + // ` + private static final String CTE_VALUE_COL = "rs_value"; + + /** + * Collects the result-set subqueries of one top-level {@code inResultSet} predicate as named, + * materialized CTEs so that each result set is evaluated exactly once instead of being + * re-embedded (and re-evaluated) at every nesting level. CTEs are stored in dependency order — a + * nested set is registered while building the body of its parent, so it is inserted first and may + * be referenced by the parent. + */ + private static final class CteCollector { + private final LinkedHashMap ctes = new LinkedHashMap<>(); + private final Map namesBySet = new java.util.HashMap<>(); + private int counter; + + String register(String setName, Supplier bodySupplier) { + String existing = namesBySet.get(setName); + if (existing != null) { + return existing; + } + String name = "_rs_" + (counter++) + "_" + setName.replaceAll("[^A-Za-z0-9_]", "_"); + namesBySet.put(setName, name); + // building the body registers any nested result sets first, preserving dependency order + String body = bodySupplier.get(); + ctes.put(name, body); + return name; + } + + String renderWith(SqlDialect dialect) { + return "WITH " + + ctes.entrySet().stream() + .map(e -> dialect.materializedCte(e.getKey(), e.getValue())) + .collect(Collectors.joining(", ")); + } + } + + private static String renderInlineLiteral(Object value) { + if (value instanceof String) { + return "'" + ((String) value).replace("'", "''") + "'"; + } + return String.valueOf(value); + } + + /** + * Build the producer SELECT of a result set: {@code SELECT FROM + * WHERE }. With {@code withValueAlias} the value column is aliased as + * the CTE value column (for use inside a CTE); without it the bare value column is selected (for + * the materializer, which reads that single column). The optional collector lets nested result + * sets in the producer filter hoist into the same WITH clause. + */ + String resultSetProducerSelect( + InResultSet inResultSet, boolean withValueAlias, CteCollector collector) { + String setName = inResultSet.getSetName(); + String producerType = + inResultSet + .getProducerType() + .orElseThrow( + () -> + new IllegalArgumentException( + String.format("Filter is invalid. Unknown result set: '%s'.", setName))); + SqlQueryMapping producerMapping = + mappingResolver + .apply(producerType) + .orElseThrow( + () -> + new IllegalArgumentException( + String.format( + "Filter is invalid. Result set '%s' cannot be resolved for feature type '%s'.", + setName, producerType))); + de.ii.xtraplatform.base.domain.util.Tuple setColumn = + inResultSet.getProducerValues().isPresent() + ? producerMapping + .getColumnForValue(inResultSet.getProducerValues().get()) + .orElseThrow( + () -> + new IllegalArgumentException( + String.format( + "Filter is invalid. Result set '%s' projects the property '%s', which is unknown for feature type '%s'.", + setName, inResultSet.getProducerValues().get(), producerType))) + : producerMapping + .getColumnForId() + .orElseThrow( + () -> + new IllegalArgumentException( + String.format( + "Filter is invalid. Feature type '%s' has no id property for result set '%s'.", + producerType, setName))); + + SqlQuerySchema valueTable = setColumn.first(); + List aliases = AliasGenerator.getAliases(valueTable); + SqlQueryTable producerMain = + valueTable.getRelations().isEmpty() ? valueTable : valueTable.getRelations().get(0); + String join = JoinGenerator.getJoins(valueTable, aliases, this); + String valueColumn = + String.format("%s.%s", aliases.get(aliases.size() - 1), setColumn.second().getName()); + + Optional tableFilter = + producerMapping.getMainTable().getFilter().map(filter -> (Cql2Expression) filter); + Optional producerFilter = inResultSet.getProducerFilter(); + Optional effectiveFilter = + tableFilter.isPresent() && producerFilter.isPresent() + ? Optional.of(And.of(tableFilter.get(), producerFilter.get())) + : tableFilter.isPresent() ? tableFilter : producerFilter; + String where = + effectiveFilter + .map( + filter -> + " WHERE " + + prepareExpression(filter) + .accept(new CqlToSql2(producerMapping, collector))) + .orElse(""); + + String valueExpr = + withValueAlias ? String.format("%s AS %s", valueColumn, CTE_VALUE_COL) : valueColumn; + return String.format( + "SELECT %2$s FROM %1$s %3$s%4$s%5$s%6$s", + producerMain.getName(), valueExpr, aliases.get(0), join.isEmpty() ? "" : " ", join, where); + } + + /** + * Producer SELECT of a result set for up-front materialization: returns the bare value column so + * the caller can run it once and collect the values. Any nested result sets referenced by the + * producer filter that already carry materialized values are inlined as literals. + */ + public String encodeResultSetProducer(InResultSet inResultSet) { + return resultSetProducerSelect(inResultSet, false, null); + } + + /** + * Type of the value column of a result set, used to coerce and render its materialized values. + */ + public de.ii.xtraplatform.features.domain.SchemaBase.Type resultSetValueType( + InResultSet inResultSet) { + String producerType = inResultSet.getProducerType().orElseThrow(); + SqlQueryMapping producerMapping = mappingResolver.apply(producerType).orElseThrow(); + return (inResultSet.getProducerValues().isPresent() + ? producerMapping.getColumnForValue(inResultSet.getProducerValues().get()) + : producerMapping.getColumnForId()) + .map(column -> column.second().getType()) + .orElse(de.ii.xtraplatform.features.domain.SchemaBase.Type.STRING); + } + public String encode(Cql2Expression cqlFilter, SchemaSql schema) { return prepareExpression(cqlFilter).accept(new CqlToSql(schema)); } @@ -1418,10 +1563,18 @@ public String visit(Property property, List children) { private class CqlToSql2 extends CqlToText { private final SqlQueryMapping mapping; + // non-null while encoding the producer filter of an enclosing inResultSet, so that nested + // result sets register their CTEs with the same collector instead of nesting inline + private final CteCollector collector; private CqlToSql2(SqlQueryMapping mapping) { + this(mapping, null); + } + + private CqlToSql2(SqlQueryMapping mapping, CteCollector collector) { super(coordinatesTransformer); this.mapping = mapping; + this.collector = collector; } protected FeatureSchema getSchema( @@ -1922,57 +2075,34 @@ private String encodeInResultSet(InResultSet inResultSet, String mainExpression) return "1 = 0"; } - // a projected result set consists of the ids referenced by a property of the selected - // features, a plain result set of the ids of the selected features - de.ii.xtraplatform.base.domain.util.Tuple setColumn = - inResultSet.getProducerValues().isPresent() - ? producerMapping - .getColumnForValue(inResultSet.getProducerValues().get()) - .orElseThrow( - () -> - new IllegalArgumentException( - String.format( - "Filter is invalid. Result set '%s' projects the property '%s', which is unknown for feature type '%s'.", - setName, inResultSet.getProducerValues().get(), producerType))) - : producerMapping - .getColumnForId() - .orElseThrow( - () -> - new IllegalArgumentException( - String.format( - "Filter is invalid. Feature type '%s' has no id property for result set '%s'.", - producerType, setName))); - - SqlQuerySchema valueTable = setColumn.first(); - List aliases = AliasGenerator.getAliases(valueTable); - SqlQueryTable producerMain = - valueTable.getRelations().isEmpty() ? valueTable : valueTable.getRelations().get(0); - String join = JoinGenerator.getJoins(valueTable, aliases, FilterEncoderSql.this); - String valueColumn = - String.format("%s.%s", aliases.get(aliases.size() - 1), setColumn.second().getName()); - - Optional tableFilter = - producerMapping.getMainTable().getFilter().map(filter -> (Cql2Expression) filter); - Optional producerFilter = inResultSet.getProducerFilter(); - Optional effectiveFilter = - tableFilter.isPresent() && producerFilter.isPresent() - ? Optional.of(And.of(tableFilter.get(), producerFilter.get())) - : tableFilter.isPresent() ? tableFilter : producerFilter; - - String where = - effectiveFilter.map(filter -> " WHERE " + encode(filter, producerMapping)).orElse(""); - - String subquery = - String.format( - "SELECT %2$s FROM %1$s %3$s%4$s%5$s%6$s", - producerMain.getName(), - valueColumn, - aliases.get(0), - join.isEmpty() ? "" : " ", - join, - where); - - return String.format(mainExpression, "", String.format(" IN (%s)", subquery)); + // if the result set has been materialized up front, inline its values as a literal IN list + if (inResultSet.getMaterializedValues().isPresent()) { + List values = inResultSet.getMaterializedValues().get(); + if (values.isEmpty()) { + return "1 = 0"; + } + String list = + values.stream() + .map(FilterEncoderSql::renderInlineLiteral) + .collect(Collectors.joining(", ")); + return String.format(mainExpression, "", String.format(" IN (%s)", list)); + } + + // otherwise re-derive the result set inline; hoist it (and any nested result sets referenced + // by its producer filter) into materialized CTEs so each is evaluated once within the + // statement. CTEs are emitted in dependency order; the outermost predicate prepends the WITH. + boolean outermost = collector == null; + CteCollector coll = outermost ? new CteCollector() : collector; + String cteName = + coll.register(setName, () -> resultSetProducerSelect(inResultSet, true, coll)); + + String reference = + outermost + ? String.format( + " IN (%s SELECT %s FROM %s)", coll.renderWith(sqlDialect), CTE_VALUE_COL, cteName) + : String.format(" IN (SELECT %s FROM %s)", CTE_VALUE_COL, cteName); + + return String.format(mainExpression, "", reference); } @Override diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/ResultSetMaterializer.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/ResultSetMaterializer.java new file mode 100644 index 000000000..2a80d0123 --- /dev/null +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/ResultSetMaterializer.java @@ -0,0 +1,242 @@ +/* + * 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.domain.BinaryScalarOperation; +import de.ii.xtraplatform.cql.domain.Cql2Expression; +import de.ii.xtraplatform.cql.domain.CqlNode; +import de.ii.xtraplatform.cql.domain.CqlVisitorCopy; +import de.ii.xtraplatform.cql.domain.ImmutableInResultSet; +import de.ii.xtraplatform.cql.domain.InResultSet; +import de.ii.xtraplatform.features.domain.ImmutableMultiFeatureQuery; +import de.ii.xtraplatform.features.domain.ImmutableSubQuery; +import de.ii.xtraplatform.features.domain.MultiFeatureQuery; +import de.ii.xtraplatform.features.domain.MultiFeatureQuery.SubQuery; +import de.ii.xtraplatform.features.domain.SchemaBase; +import de.ii.xtraplatform.features.sql.domain.SqlClient; +import de.ii.xtraplatform.features.sql.domain.SqlQueryOptions; +import de.ii.xtraplatform.features.sql.domain.SqlRow; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Materializes the result sets of a multi-query once per response. Each result set's producer is + * run a single time (its dependencies already materialized as literal id lists), and the collected + * values are attached to the {@link InResultSet} nodes of the consuming filters so that they are + * encoded as a literal {@code IN} list instead of a per-statement nested subquery. A result set + * that exceeds the configured cap is left unmaterialized and falls back to the inline (CTE) + * encoding. + */ +public class ResultSetMaterializer { + + private static final Logger LOGGER = LoggerFactory.getLogger(ResultSetMaterializer.class); + + private final Supplier sqlClient; + private final FilterEncoderSql filterEncoder; + private final int maxSetSize; + + public ResultSetMaterializer( + Supplier sqlClient, FilterEncoderSql filterEncoder, int maxSetSize) { + this.sqlClient = sqlClient; + this.filterEncoder = filterEncoder; + this.maxSetSize = maxSetSize; + } + + /** + * Returns a copy of the query with every materializable result set computed and inlined. If the + * query uses no result sets, it is returned unchanged. + */ + public MultiFeatureQuery materialize(MultiFeatureQuery query) { + Map sets = new LinkedHashMap<>(); + for (SubQuery subQuery : query.getQueries()) { + for (Cql2Expression filter : subQuery.getFilters()) { + collect(filter, sets); + } + } + if (sets.isEmpty()) { + return query; + } + + Map> materialized = new HashMap<>(); + for (String name : topologicalOrder(sets)) { + InResultSet node = sets.get(name); + InResultSet prepared = + node.getProducerFilter().isPresent() + ? new ImmutableInResultSet.Builder() + .from(node) + .producerFilter(applyMaterialized(node.getProducerFilter().get(), materialized)) + .build() + : node; + + // bound the fetch to one past the cap so an oversized set is detected without loading it all + String producerQuery = + filterEncoder.encodeResultSetProducer(prepared) + " LIMIT " + (maxSetSize + 1); + SchemaBase.Type valueType = filterEncoder.resultSetValueType(node); + Collection rows = sqlClient.get().run(producerQuery, SqlQueryOptions.single()).join(); + + if (rows.size() > maxSetSize) { + if (LOGGER.isWarnEnabled()) { + LOGGER.warn( + "Result set '{}' has {} members, exceeding the materialization cap of {}; falling back" + + " to inline evaluation for this set.", + name, + rows.size(), + maxSetSize); + } + continue; + } + + List values = + rows.stream() + .map(row -> coerce(row.getValues().get(0), valueType)) + .distinct() + .collect(Collectors.toList()); + materialized.put(name, values); + } + + List rewritten = + query.getQueries().stream() + .map( + subQuery -> + (SubQuery) + ImmutableSubQuery.builder() + .from(subQuery) + .filters( + subQuery.getFilters().stream() + .map(filter -> applyMaterialized(filter, materialized)) + .collect(Collectors.toList())) + .build()) + .collect(Collectors.toList()); + + return ImmutableMultiFeatureQuery.builder().from(query).queries(rewritten).build(); + } + + /** True if the query contains at least one result-set reference. */ + public static boolean hasResultSets(MultiFeatureQuery query) { + Map sets = new LinkedHashMap<>(); + for (SubQuery subQuery : query.getQueries()) { + for (Cql2Expression filter : subQuery.getFilters()) { + collect(filter, sets); + if (!sets.isEmpty()) { + return true; + } + } + } + return false; + } + + private static void collect(Cql2Expression expression, Map sets) { + Collector collector = new Collector(); + expression.accept(collector); + for (InResultSet node : collector.found) { + if (!sets.containsKey(node.getSetName())) { + sets.put(node.getSetName(), node); + node.getProducerFilter().ifPresent(filter -> collect(filter, sets)); + } + } + } + + private List topologicalOrder(Map sets) { + List order = new ArrayList<>(); + Set visited = new HashSet<>(); + for (String name : sets.keySet()) { + visit(name, sets, visited, order); + } + return order; + } + + private void visit( + String name, Map sets, Set visited, List order) { + if (!visited.add(name)) { + return; + } + InResultSet node = sets.get(name); + if (node != null && node.getProducerFilter().isPresent()) { + Collector collector = new Collector(); + node.getProducerFilter().get().accept(collector); + for (InResultSet dependency : collector.found) { + if (sets.containsKey(dependency.getSetName())) { + visit(dependency.getSetName(), sets, visited, order); + } + } + } + order.add(name); + } + + private static Cql2Expression applyMaterialized( + Cql2Expression expression, Map> materialized) { + return (Cql2Expression) expression.accept(new ApplyMaterialized(materialized)); + } + + private static Object coerce(Object value, SchemaBase.Type type) { + if (!(value instanceof String)) { + return value; + } + String string = (String) value; + try { + switch (type) { + case INTEGER: + return Long.parseLong(string); + case FLOAT: + return Double.parseDouble(string); + case BOOLEAN: + return Boolean.parseBoolean(string); + default: + return string; + } + } catch (NumberFormatException e) { + return string; + } + } + + /** Records the {@link InResultSet} nodes encountered while traversing a filter. */ + private static class Collector extends CqlVisitorCopy { + private final List found = new ArrayList<>(); + + @Override + public CqlNode visit(BinaryScalarOperation scalarOperation, List children) { + CqlNode copy = super.visit(scalarOperation, children); + if (copy instanceof InResultSet) { + found.add((InResultSet) copy); + } + return copy; + } + } + + /** Attaches materialized values to the {@link InResultSet} nodes that have them. */ + private static class ApplyMaterialized extends CqlVisitorCopy { + private final Map> materialized; + + ApplyMaterialized(Map> materialized) { + this.materialized = materialized; + } + + @Override + public CqlNode visit(BinaryScalarOperation scalarOperation, List children) { + CqlNode copy = super.visit(scalarOperation, children); + if (copy instanceof InResultSet) { + InResultSet node = (InResultSet) copy; + List values = materialized.get(node.getSetName()); + if (values != null) { + return new ImmutableInResultSet.Builder().from(node).materializedValues(values).build(); + } + } + return copy; + } + } +} diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java index 28412bbff..0d901f149 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java @@ -94,6 +94,7 @@ import de.ii.xtraplatform.features.sql.app.MutationSchemaDeriver; import de.ii.xtraplatform.features.sql.app.PathParserSql; import de.ii.xtraplatform.features.sql.app.QuerySchemaDeriver; +import de.ii.xtraplatform.features.sql.app.ResultSetMaterializer; import de.ii.xtraplatform.features.sql.app.SqlInsertGenerator2; import de.ii.xtraplatform.features.sql.app.SqlMappingDeriver; import de.ii.xtraplatform.features.sql.app.SqlMutationSession; @@ -468,6 +469,7 @@ public class FeatureProviderSql private PathParserSql pathParser2; private SqlPathParser pathParser3; private FilterEncoderSql filterEncoder; + private ResultSetMaterializer resultSetMaterializer; private SourceSchemaValidator sourceSchemaValidator; private Map> tableSchemas; private Map> queryMappings; @@ -623,6 +625,12 @@ protected boolean onStartup() throws InterruptedException { new FeatureQueryEncoderSql( allQueryTemplates, allQueryTemplates, getData().getQueryGeneration(), sqlDialect); + this.resultSetMaterializer = + new ResultSetMaterializer( + this::getSqlClient, + filterEncoder, + getData().getQueryGeneration().getResultSetMaterializationMaxSize()); + this.aggregateStatsReader = new AggregateStatsReaderSql( this::getSqlClient, @@ -1430,29 +1438,40 @@ protected Query preprocessQuery(Query query) { // - disable optimized paging as soon as a sort key is specified for at least one subquery // - fix a bug in SqlRowVals or transformations, it seems that the same number of columns is // expected for all queries - List queries = ((MultiFeatureQuery) query).getQueries(); + MultiFeatureQuery multiQuery = (MultiFeatureQuery) query; + List queries = multiQuery.getQueries(); OptionalInt maxSortKeys = queries.stream().mapToInt(subQuery -> subQuery.getSortKeys().size()).max(); if (maxSortKeys.orElse(0) > 0) { - return ImmutableMultiFeatureQuery.builder() - .from(query) - .queries( - queries.stream() - .map( - subQuery -> - ImmutableSubQuery.builder() - .from(subQuery) - .sortKeys( - subQuery.getSortKeys().size() < maxSortKeys.getAsInt() - ? IntStream.range(0, maxSortKeys.getAsInt()) - .mapToObj(i -> SortKey.of(ID_PLACEHOLDER)) - .collect(Collectors.toList()) - : subQuery.getSortKeys()) - .build()) - .collect(Collectors.toList())) - .build(); + multiQuery = + ImmutableMultiFeatureQuery.builder() + .from(multiQuery) + .queries( + queries.stream() + .map( + subQuery -> + ImmutableSubQuery.builder() + .from(subQuery) + .sortKeys( + subQuery.getSortKeys().size() < maxSortKeys.getAsInt() + ? IntStream.range(0, maxSortKeys.getAsInt()) + .mapToObj(i -> SortKey.of(ID_PLACEHOLDER)) + .collect(Collectors.toList()) + : subQuery.getSortKeys()) + .build()) + .collect(Collectors.toList())) + .build(); } + + // materialize the result sets of a multi-query once and reuse them across its queries, + // instead + // of re-deriving each shared result set in every sub-query statement + if (ResultSetMaterializer.hasResultSets(multiQuery)) { + multiQuery = resultSetMaterializer.materialize(multiQuery); + } + + return multiQuery; } return query; diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSqlData.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSqlData.java index b42b60e7b..04f6f1e24 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSqlData.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSqlData.java @@ -165,6 +165,22 @@ default int getChunkSize() { return 10_000; } + /** + * @langEn Maximum number of members a result set of a multi-query may have to be materialized + * once and reused as a literal list across the queries of the request. Larger result sets + * fall back to inline (re-evaluated) subqueries. + * @langDe Maximale Anzahl von Elementen einer Ergebnismenge einer Multi-Query, bis zu der diese + * einmal materialisiert und als Literalliste über die Abfragen der Anfrage hinweg + * wiederverwendet wird. Größere Ergebnismengen werden auf eingebettete (neu ausgewertete) + * Unterabfragen zurückgesetzt. + * @default 100000 + */ + @DocIgnore + @Value.Default + default int getResultSetMaterializationMaxSize() { + return 100_000; + } + /** * @langEn Option to disable computation of the number of selected features for performance * reasons that are returned in `numberMatched`. As a general rule this should be disabled 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 b28da310a..a09ee0674 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 @@ -86,6 +86,15 @@ default String applyToNoTable(String select) { return select; } + /** + * Render a common table expression that is guaranteed to be evaluated only once. The default is a + * plain {@code name AS (query)}; dialects that would otherwise inline a single-reference CTE + * (PostgreSQL 12+) must force materialization. + */ + default String materializedCte(String name, String query) { + return name + " AS (" + query + ")"; + } + String castToBigInt(int value); Optional parseExtent(String extent, EpsgCrs crs); 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 0c39a8def..a8159237e 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 @@ -40,6 +40,14 @@ public String getId() { return SqlDbmsPgis.ID; } + @Override + public String materializedCte(String name, String query) { + // PostgreSQL 12+ inlines a CTE that is referenced only once; MATERIALIZED forces a single + // evaluation, which is what lets a result set be computed once instead of re-evaluated per + // nesting level. + return name + " AS MATERIALIZED (" + query + ")"; + } + private static final Splitter BBOX_SPLITTER = Splitter.onPattern("[(), ]").omitEmptyStrings().trimResults(); private static final Map SPATIAL_OPERATORS_3D = diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy index 2b5ded8c8..d88f7b04c 100644 --- a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/FilterEncoderSqlInResultSetSpec.groovy @@ -79,7 +79,7 @@ class FilterEncoderSqlInResultSetSpec extends Specification { def sql = filterEncoder.encode(filter, mappings["value_array"]) then: - sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT A.id FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id = 'foo')))" + sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (WITH _rs_0_s1 AS MATERIALIZED (SELECT A.id AS rs_value FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id = 'foo')) SELECT rs_value FROM _rs_0_s1))" } def 'plain id set without a producer filter'() { @@ -90,7 +90,7 @@ class FilterEncoderSqlInResultSetSpec extends Specification { def sql = filterEncoder.encode(filter, mappings["value_array"]) then: - sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT A.id FROM externalprovider A))" + sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (WITH _rs_0_s1 AS MATERIALIZED (SELECT A.id AS rs_value FROM externalprovider A) SELECT rs_value FROM _rs_0_s1))" } def 'plain id set, consumer matches an array property in a junction table'() { @@ -102,7 +102,7 @@ class FilterEncoderSqlInResultSetSpec extends Specification { def sql = filterEncoder.encode(filter, mappings["value_array"]) then: - sql == "A.id IN (SELECT AA.id FROM externalprovider AA JOIN externalprovider_externalprovidername AB ON (AA.id=AB.externalprovider_fk) WHERE AB.externalprovidername IN (SELECT A.id FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id = 'foo')))" + sql == "A.id IN (SELECT AA.id FROM externalprovider AA JOIN externalprovider_externalprovidername AB ON (AA.id=AB.externalprovider_fk) WHERE AB.externalprovidername IN (WITH _rs_0_s1 AS MATERIALIZED (SELECT A.id AS rs_value FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id = 'foo')) SELECT rs_value FROM _rs_0_s1))" } def 'chained result sets nest recursively'() { @@ -115,7 +115,7 @@ class FilterEncoderSqlInResultSetSpec extends Specification { def sql = filterEncoder.encode(outer, mappings["value_array"]) then: - sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT A.id FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT A.id FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id = 'foo')))))" + sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (WITH _rs_1_s1 AS MATERIALIZED (SELECT A.id AS rs_value FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id = 'foo')), _rs_0_s2 AS MATERIALIZED (SELECT A.id AS rs_value FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT rs_value FROM _rs_1_s1))) SELECT rs_value FROM _rs_0_s2))" } def 'an unresolved result set reference is rejected'() { @@ -145,7 +145,7 @@ class FilterEncoderSqlInResultSetSpec extends Specification { def sql = filterEncoder.encode(filter, mappings["simple"]) then: - sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT B.externalprovidername FROM externalprovider A JOIN externalprovider_externalprovidername B ON (A.id=B.externalprovider_fk) WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id = 'foo')))" + sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (WITH _rs_0_s1 AS MATERIALIZED (SELECT B.externalprovidername AS rs_value FROM externalprovider A JOIN externalprovider_externalprovidername B ON (A.id=B.externalprovider_fk) WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id = 'foo')) SELECT rs_value FROM _rs_0_s1))" } def 'projected result set over a column of the main table'() { @@ -156,7 +156,7 @@ class FilterEncoderSqlInResultSetSpec extends Specification { def sql = filterEncoder.encode(filter, mappings["value_array"]) then: - sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT A.id FROM externalprovider A))" + sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (WITH _rs_0_s1 AS MATERIALIZED (SELECT A.id AS rs_value FROM externalprovider A) SELECT rs_value FROM _rs_0_s1))" } def 'the filter of the producer main table is applied to the result set'() { @@ -167,7 +167,37 @@ class FilterEncoderSqlInResultSetSpec extends Specification { def sql = filterEncoder.encode(filter, mappings["simple"]) then: - sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (SELECT A.id FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.type = 1)))" + sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN (WITH _rs_0_s1 AS MATERIALIZED (SELECT A.id AS rs_value FROM externalprovider A WHERE A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.type = 1)) SELECT rs_value FROM _rs_0_s1))" + } + + def 'a materialized result set is inlined as a literal IN list'() { + given: + def filter = new ImmutableInResultSet.Builder() + .from(InResultSet.of("id", "s1")) + .producerType("simple") + .materializedValues(["foo", "bar"]) + .build() + + when: + def sql = filterEncoder.encode(filter, mappings["value_array"]) + + then: + sql == "A.id IN (SELECT AA.id FROM externalprovider AA WHERE AA.id IN ('foo', 'bar'))" + } + + def 'an empty materialized result set yields a false predicate'() { + given: + def filter = new ImmutableInResultSet.Builder() + .from(InResultSet.of("id", "s1")) + .producerType("simple") + .materializedValues([]) + .build() + + when: + def sql = filterEncoder.encode(filter, mappings["value_array"]) + + then: + sql == "1 = 0" } def 'an unknown projected property is rejected'() { diff --git a/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/ResultSetMaterializerSpec.groovy b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/ResultSetMaterializerSpec.groovy new file mode 100644 index 000000000..701b6cbce --- /dev/null +++ b/xtraplatform-features-sql/src/test/groovy/de/ii/xtraplatform/features/sql/app/ResultSetMaterializerSpec.groovy @@ -0,0 +1,132 @@ +/* + * 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.Cql2Expression +import de.ii.xtraplatform.cql.domain.Eq +import de.ii.xtraplatform.cql.domain.ImmutableInResultSet +import de.ii.xtraplatform.cql.domain.InResultSet +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.FeatureSchemaFixtures +import de.ii.xtraplatform.features.domain.ImmutableMultiFeatureQuery +import de.ii.xtraplatform.features.domain.ImmutableSubQuery +import de.ii.xtraplatform.features.domain.MappingOperationResolver +import de.ii.xtraplatform.features.domain.MappingRuleFixtures +import de.ii.xtraplatform.features.domain.MultiFeatureQuery +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.SqlClient +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 de.ii.xtraplatform.features.sql.domain.SqlRow +import spock.lang.Shared +import spock.lang.Specification + +import java.util.concurrent.CompletableFuture +import java.util.function.Function +import java.util.function.Supplier + +class ResultSetMaterializerSpec extends Specification { + + @Shared + Map mappings = [:] + @Shared + FilterEncoderSql filterEncoder + + def setupSpec() { + def defaults = new ImmutableSqlPathDefaults.Builder().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 mappingOperationResolver = new MappingOperationResolver() + + ["simple", "value_array"].each { name -> + def schema = FeatureSchemaFixtures.fromYaml(name) + def resolved = schema.accept(mappingOperationResolver, List.of()) + def rules = MappingRuleFixtures.fromYaml(name) + mappings[name] = mappingDeriver.derive(rules, resolved).get(0) + } + + filterEncoder = new FilterEncoderSql(OgcCrs.CRS84, new SqlDialectPgis(), null, null, cql, List.of(), null, + { type -> Optional.ofNullable(mappings[type]) } as Function) + } + + static InResultSet resolved(String setName, String producerType, Cql2Expression producerFilter) { + return new ImmutableInResultSet.Builder() + .from(InResultSet.of("id", setName)) + .producerType(producerType) + .producerFilter(producerFilter) + .build() + } + + SqlRow row(Object value) { + return Stub(SqlRow) { + getValues() >> [value] + } + } + + static MultiFeatureQuery query(Cql2Expression filter) { + return ImmutableMultiFeatureQuery.builder() + .addQueries( + ImmutableSubQuery.builder() + .collectionId("c") + .type("value_array") + .addFilters(filter) + .build()) + .build() + } + + def 'a result set is materialized once and its values are attached to the consumer'() { + given: + def filter = resolved("s1", "simple", Eq.of(Property.of("id"), ScalarLiteral.of("foo"))) + def sqlClient = Mock(SqlClient) + def materializer = new ResultSetMaterializer({ -> sqlClient } as Supplier, filterEncoder, 100000) + + when: + def result = materializer.materialize(query(filter)) + + then: + 1 * sqlClient.run(_, _) >> CompletableFuture.completedFuture([row("x"), row("y")]) + def node = (InResultSet) result.getQueries().get(0).getFilters().get(0) + node.getMaterializedValues().get() == ["x", "y"] + } + + def 'a result set exceeding the cap is left unmaterialized'() { + given: + def filter = resolved("s1", "simple", Eq.of(Property.of("id"), ScalarLiteral.of("foo"))) + def sqlClient = Mock(SqlClient) + def materializer = new ResultSetMaterializer({ -> sqlClient } as Supplier, filterEncoder, 1) + + when: + def result = materializer.materialize(query(filter)) + + then: + 1 * sqlClient.run(_, _) >> CompletableFuture.completedFuture([row("x"), row("y")]) + def node = (InResultSet) result.getQueries().get(0).getFilters().get(0) + node.getMaterializedValues().isEmpty() + } + + def 'a query without result sets is returned unchanged'() { + given: + def filter = Eq.of(Property.of("id"), ScalarLiteral.of("foo")) + def sqlClient = Mock(SqlClient) + def materializer = new ResultSetMaterializer({ -> sqlClient } as Supplier, filterEncoder, 100000) + + when: + def result = materializer.materialize(query(filter)) + + then: + 0 * sqlClient.run(_, _) + result == query(filter) + } +} From c6d5d406accf1cc1971fec8b1a234a4e29f74fd0 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Sun, 14 Jun 2026 18:04:36 +0200 Subject: [PATCH 23/25] features-sql: materialize independent result sets concurrently Group the result sets of a multi-query into dependency levels and run the producers of each level concurrently (futures joined per level), bounded by the connection pool. SQL is still built single-threaded between levels, so the filter encoder is not invoked concurrently and the materialized map is mutated only on the calling thread. Dependent producers stay ordered across levels. Shaves the materialization pre-phase of the reference fan-out from ~1.8 s to ~1.4 s; the remaining cost is the serial per-sub-query statement execution. --- .../sql/app/ResultSetMaterializer.java | 130 +++++++++++------- 1 file changed, 80 insertions(+), 50 deletions(-) diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/ResultSetMaterializer.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/ResultSetMaterializer.java index 2a80d0123..ee47050bd 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/ResultSetMaterializer.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/ResultSetMaterializer.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -73,40 +74,51 @@ public MultiFeatureQuery materialize(MultiFeatureQuery query) { } Map> materialized = new HashMap<>(); - for (String name : topologicalOrder(sets)) { - InResultSet node = sets.get(name); - InResultSet prepared = - node.getProducerFilter().isPresent() - ? new ImmutableInResultSet.Builder() - .from(node) - .producerFilter(applyMaterialized(node.getProducerFilter().get(), materialized)) - .build() - : node; - - // bound the fetch to one past the cap so an oversized set is detected without loading it all - String producerQuery = - filterEncoder.encodeResultSetProducer(prepared) + " LIMIT " + (maxSetSize + 1); - SchemaBase.Type valueType = filterEncoder.resultSetValueType(node); - Collection rows = sqlClient.get().run(producerQuery, SqlQueryOptions.single()).join(); - - if (rows.size() > maxSetSize) { - if (LOGGER.isWarnEnabled()) { - LOGGER.warn( - "Result set '{}' has {} members, exceeding the materialization cap of {}; falling back" - + " to inline evaluation for this set.", - name, - rows.size(), - maxSetSize); - } - continue; + // materialize level by level: within a level the producers are independent and run concurrently + // (bounded by the connection pool). SQL is built single-threaded between levels, so the filter + // encoder is never invoked concurrently and the materialized map is only mutated on this + // thread. + for (List level : topologicalLevels(sets)) { + Map>> running = new LinkedHashMap<>(); + Map valueTypes = new HashMap<>(); + for (String name : level) { + InResultSet node = sets.get(name); + InResultSet prepared = + node.getProducerFilter().isPresent() + ? new ImmutableInResultSet.Builder() + .from(node) + .producerFilter(applyMaterialized(node.getProducerFilter().get(), materialized)) + .build() + : node; + + // bound the fetch to one past the cap so an oversized set is detected without loading it + // all + String producerQuery = + filterEncoder.encodeResultSetProducer(prepared) + " LIMIT " + (maxSetSize + 1); + valueTypes.put(name, filterEncoder.resultSetValueType(node)); + running.put(name, sqlClient.get().run(producerQuery, SqlQueryOptions.single())); } - List values = - rows.stream() - .map(row -> coerce(row.getValues().get(0), valueType)) - .distinct() - .collect(Collectors.toList()); - materialized.put(name, values); + for (Map.Entry>> entry : running.entrySet()) { + String name = entry.getKey(); + Collection rows = entry.getValue().join(); + if (rows.size() > maxSetSize) { + if (LOGGER.isWarnEnabled()) { + LOGGER.warn( + "Result set '{}' has more than the materialization cap of {} members; falling back" + + " to inline evaluation for this set.", + name, + maxSetSize); + } + continue; + } + List values = + rows.stream() + .map(row -> coerce(row.getValues().get(0), valueTypes.get(name))) + .distinct() + .collect(Collectors.toList()); + materialized.put(name, values); + } } List rewritten = @@ -151,31 +163,49 @@ private static void collect(Cql2Expression expression, Map } } - private List topologicalOrder(Map sets) { - List order = new ArrayList<>(); - Set visited = new HashSet<>(); + /** + * Groups the result sets into dependency levels: level 0 has no dependencies, level n depends + * only on sets in earlier levels. Sets within a level are independent and may be materialized + * concurrently. + */ + private List> topologicalLevels(Map sets) { + Map> deps = new HashMap<>(); for (String name : sets.keySet()) { - visit(name, sets, visited, order); + deps.put(name, dependenciesOf(sets.get(name), sets)); + } + List> levels = new ArrayList<>(); + Set done = new HashSet<>(); + while (done.size() < sets.size()) { + List level = + sets.keySet().stream() + .filter(name -> !done.contains(name) && done.containsAll(deps.get(name))) + .collect(Collectors.toList()); + if (level.isEmpty()) { + // no progress (should not happen for forward-only references) — emit the rest as one level + level = + sets.keySet().stream() + .filter(name -> !done.contains(name)) + .collect(Collectors.toList()); + } + levels.add(level); + done.addAll(level); } - return order; + return levels; } - private void visit( - String name, Map sets, Set visited, List order) { - if (!visited.add(name)) { - return; + private static Set dependenciesOf(InResultSet node, Map sets) { + if (node.getProducerFilter().isEmpty()) { + return Set.of(); } - InResultSet node = sets.get(name); - if (node != null && node.getProducerFilter().isPresent()) { - Collector collector = new Collector(); - node.getProducerFilter().get().accept(collector); - for (InResultSet dependency : collector.found) { - if (sets.containsKey(dependency.getSetName())) { - visit(dependency.getSetName(), sets, visited, order); - } + Collector collector = new Collector(); + node.getProducerFilter().get().accept(collector); + Set deps = new HashSet<>(); + for (InResultSet dependency : collector.found) { + if (sets.containsKey(dependency.getSetName())) { + deps.add(dependency.getSetName()); } } - order.add(name); + return deps; } private static Cql2Expression applyMaterialized( From c3edd4b54d0d4efa3346c076afb9ff55afc04a9c Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Mon, 15 Jun 2026 11:37:28 +0200 Subject: [PATCH 24/25] cql: type-check filters that contain unbound parameters The type checker treated an unbound parameter as a null type, which broke checkTypes whenever a parameter appeared as an operand (e.g. validating a stored query filter before its parameters are bound). - CqlTypeAndFunctionChecker.visit(Parameter) returns Type.UNKNOWN, so a parameter is a wildcard and the concrete operands are still checked. - CqlTypeCheckerSpec: cover a parameter operand in spatial/array operations and a scalar comparison on an array property. --- .../cql/app/CqlTypeAndFunctionChecker.java | 8 +++++++ .../cql/app/CqlTypeCheckerSpec.groovy | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlTypeAndFunctionChecker.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlTypeAndFunctionChecker.java index 0fe3baefc..51a33d212 100644 --- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlTypeAndFunctionChecker.java +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/app/CqlTypeAndFunctionChecker.java @@ -42,6 +42,7 @@ import de.ii.xtraplatform.cql.domain.Like; import de.ii.xtraplatform.cql.domain.LogicalOperation; import de.ii.xtraplatform.cql.domain.Not; +import de.ii.xtraplatform.cql.domain.Parameter; import de.ii.xtraplatform.cql.domain.Property; import de.ii.xtraplatform.cql.domain.Scalar; import de.ii.xtraplatform.cql.domain.ScalarLiteral; @@ -255,6 +256,13 @@ public Type visit(Property property, List children) { return Type.UNKNOWN; } + @Override + public Type visit(Parameter parameter, List children) { + // an unbound parameter (e.g. in a stored query validated before invocation) has no known type; + // treat it as a wildcard so the concrete operands are still checked + return Type.UNKNOWN; + } + @Override public Type visit(ScalarLiteral scalarLiteral, List children) { return Type.valueOf(scalarLiteral.getType().getSimpleName()); diff --git a/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlTypeCheckerSpec.groovy b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlTypeCheckerSpec.groovy index 4fa192d21..e83ead580 100644 --- a/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlTypeCheckerSpec.groovy +++ b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/app/CqlTypeCheckerSpec.groovy @@ -29,6 +29,8 @@ import de.ii.xtraplatform.cql.domain.Lte import de.ii.xtraplatform.cql.domain.Neq import de.ii.xtraplatform.cql.domain.Not import de.ii.xtraplatform.cql.domain.Or +import de.ii.xtraplatform.cql.domain.Parameter +import de.ii.xtraplatform.jsonschema.domain.ImmutableJsonSchemaString import de.ii.xtraplatform.cql.domain.Lt import de.ii.xtraplatform.cql.domain.Gt import de.ii.xtraplatform.cql.domain.Property @@ -117,6 +119,26 @@ class CqlTypeCheckerSpec extends Specification { noExceptionThrown() } + def 'an unbound parameter is treated as a wildcard'() { + given: 'a parameter operand, e.g. in a stored query validated before invocation' + def param = Parameter.of("aoi", new ImmutableJsonSchemaString.Builder().build()) + + when: 'a parameter is an operand of a spatial or array operation' + BinarySpatialOperation.of(SpatialFunction.S_INTERSECTS, Property.of("location_geometry"), param).accept(visitor3) + BinaryArrayOperation.of(ArrayFunction.A_CONTAINS, Property.of("seats_per_class"), param).accept(visitor3) + + then: 'the concrete operand is checked, the parameter is not constrained' + noExceptionThrown() + } + + def 'a scalar comparison on an array property is rejected'() { + when: 'a scalar = is used on an array-valued property (the modellart case)' + Eq.of(Property.of("seats_per_class"), ScalarLiteral.of("DLKM")).accept(visitor3) + + then: + thrown CqlIncompatibleTypes + } + def 'Test the predicate-checking visitor'() { given: // run the test on 2 different queries to make sure that old reports are removed From b91ad93796b65fb00cbc7b9c061646ecee4aa6f2 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Tue, 16 Jun 2026 09:27:29 +0200 Subject: [PATCH 25/25] cql: publish inResultSet as a built-in function inResultSet could be used in a filter but was not advertised anywhere, so clients had no way to discover it. Register it in CqlBuiltInFunctions so it is published under /functions like every other CQL2 function. The CQL2 encoding is unchanged - functions use op/args, the same shape as operators. Because its SQL is a cross-collection semi-join built from the producing query's context, it cannot be expressed as a SQL template like an ordinary custom function, and it is only meaningful inside a query expression. - CustomFunction: new queryExpressionOnly flag (and ofQueryExpressionOnly factory) for a built-in that is encoded by a dedicated handler and therefore defines neither expression nor expressions; the invariant is relaxed to allow that and to reject a template on such a function. - features-sql: the dialect-aware template list skips query-expression-only functions, so a provider no longer logs a spurious "no expression defined" error on load; the standalone-filter guard now states the restriction. - CustomFunctionSpec covers the new flag and the registry entry. --- .../cql/domain/CqlBuiltInFunctions.java | 16 ++++++- .../cql/domain/CustomFunction.java | 36 +++++++++++++++ .../cql/domain/CustomFunctionSpec.groovy | 44 +++++++++++++++++++ .../features/sql/app/FilterEncoderSql.java | 4 +- .../sql/domain/FeatureProviderSql.java | 5 +++ 5 files changed, 103 insertions(+), 2 deletions(-) diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CqlBuiltInFunctions.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CqlBuiltInFunctions.java index 4c0d97231..b3c3d099d 100644 --- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CqlBuiltInFunctions.java +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CqlBuiltInFunctions.java @@ -73,7 +73,21 @@ public final class CqlBuiltInFunctions { ImmutableList.of(TYPE_BOOLEAN), Map.of( "SQL/PGIS", "$values::varchar LIKE $pattern", - "SQL/GPKG", "cast($values as text) LIKE $pattern"))); + "SQL/GPKG", "cast($values as text) LIKE $pattern")), + CustomFunction.ofQueryExpressionOnly( + InResultSet.TYPE, + "Tests whether the value of a property, or the feature id, is contained in a named " + + "result set. Result sets are defined by other queries of the same query " + + "expression; this function can therefore only be used within a query " + + "expression, not in a standalone CQL2 filter.", + ImmutableList.of( + argument( + "value", + "Property to test: a feature reference, a value property holding feature " + + "ids, or the feature id.", + TYPE_STRING), + argument("resultSet", "Name of the result set.", TYPE_STRING)), + ImmutableList.of(TYPE_BOOLEAN))); private CqlBuiltInFunctions() {} diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CustomFunction.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CustomFunction.java index 76845fa1e..5ccda2723 100644 --- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CustomFunction.java +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/domain/CustomFunction.java @@ -173,11 +173,33 @@ default Map getExpressions() { return Map.of(); } + /** + * A built-in function that is only meaningful within a query expression (a semi-join against a + * result set defined by another query) and is encoded by a dedicated handler rather than a SQL + * template. Such a function therefore defines neither {@code expression} nor {@code expressions}. + * Not intended for user-defined functions. + */ + @Value.Default + default boolean getQueryExpressionOnly() { + return false; + } + @Value.Check default void checkExpressionDefinition() { boolean hasExpression = getExpression() != null && !getExpression().isBlank(); boolean hasExpressions = !getExpressions().isEmpty(); + if (getQueryExpressionOnly()) { + if (hasExpression || hasExpressions) { + throw new IllegalStateException( + String.format( + "Custom function '%s' is restricted to query expressions and must not define a SQL" + + " expression", + getName())); + } + return; + } + if (hasExpression == hasExpressions) { throw new IllegalStateException( String.format( @@ -224,4 +246,18 @@ static CustomFunction of( .expressions(expressions) .build(); } + + static CustomFunction ofQueryExpressionOnly( + String name, + @Nullable String description, + List arguments, + List returns) { + return new ImmutableCustomFunction.Builder() + .name(name) + .description(description) + .arguments(arguments) + .returns(returns) + .queryExpressionOnly(true) + .build(); + } } diff --git a/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/domain/CustomFunctionSpec.groovy b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/domain/CustomFunctionSpec.groovy index 8e7f09edf..f808f3b93 100644 --- a/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/domain/CustomFunctionSpec.groovy +++ b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/domain/CustomFunctionSpec.groovy @@ -66,4 +66,48 @@ class CustomFunctionSpec extends Specification { function.getExpression() == null function.getExpressions().get('SQL/PGIS') == 'UPPER($value) LIKE $pattern' } + + def 'accepts a query-expression-only function without an expression'() { + when: + def function = new ImmutableCustomFunction.Builder() + .name('OK_QE') + .arguments([]) + .returns(['BOOLEAN']) + .queryExpressionOnly(true) + .build() + + then: + function.getQueryExpressionOnly() + function.getExpression() == null + function.getExpressions().isEmpty() + } + + def 'rejects a query-expression-only function that defines an expression'() { + when: + new ImmutableCustomFunction.Builder() + .name('BAD_QE') + .arguments([]) + .returns(['BOOLEAN']) + .queryExpressionOnly(true) + .expression('UPPER($value)') + .build() + + then: + def ex = thrown(IllegalStateException) + ex.message.contains('restricted to query expressions') + } + + def 'inResultSet is a registered built-in function restricted to query expressions'() { + when: + def function = CqlBuiltInFunctions.FUNCTIONS.find { it.name == InResultSet.TYPE } + + then: + function != null + function.getQueryExpressionOnly() + function.getExpression() == null + function.getExpressions().isEmpty() + function.getReturns() == ['BOOLEAN'] + // each argument declares a single value type, so /functions renders no duplicate types + function.getArguments().every { it.getType().size() == 1 } + } } 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 e9d8bad03..3401117b9 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 @@ -1018,7 +1018,9 @@ private boolean has3dOperand(List operands) { public String visit(BinaryScalarOperation scalarOperation, List children) { if (scalarOperation instanceof InResultSet) { throw new IllegalArgumentException( - String.format("Filter is invalid. %s is not supported here.", InResultSet.TYPE)); + String.format( + "Filter is invalid. %s can only be used within a query expression.", + InResultSet.TYPE)); } String operator = SCALAR_OPERATORS.get(scalarOperation.getClass()); diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java index 0d901f149..6e7ba7402 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java @@ -1536,6 +1536,11 @@ private List getDialectAwareCustomFunctions(SqlDialect sqlDialec return getData().getCql2Functions().stream() .filter( function -> { + if (function.getQueryExpressionOnly()) { + // encoded by a dedicated handler, not a SQL template; not template-renderable + return false; + } + if (Objects.nonNull(function.getExpression()) && !function.getExpression().isBlank()) { return true;