From 0e8e41c9eeb8a1999de430352da3ad6183801c4c Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 18 Jun 2026 19:59:57 +0200 Subject: [PATCH 1/4] fix feature property order to follow the provider schema Properties backed by a joined table (objects, object arrays, value arrays, feature references) were emitted after the main-table columns even when declared before them, because the per-feature event buffer placed each property where its tokens first arrive - the provider's per-table order, not the schema order. For XML/GML this produced output that did not match the application schema's element order. FeatureEventBuffer now re-sorts each buffered feature at flush into the declared schema order: object (and feature-root) children are ordered by their schema position; array elements keep their data order, and the children inside each element are ordered like any other object. The pass runs after the slice transformers, so transform behaviour is unchanged. Object and array element children are consequently emitted in schema order too (previously source/arrival order); the affected token fixtures are updated. Adds FeatureEventBufferOrderSpec and re-enables the previously disabled "joined value array between main columns" mapping case. --- .../domain/transform/FeatureEventBuffer.java | 120 +++++++++++++++++- .../domain/FeatureEventBufferOrderSpec.groovy | 73 +++++++++++ .../FeatureTokenTransformerMappingSpec.groovy | 2 +- .../domain/FeatureTokenFixtures.groovy | 32 ++--- .../feature-tokens/pfs_plan-hatObjekt.yml | 48 +++---- 5 files changed, 232 insertions(+), 43 deletions(-) create mode 100644 xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferOrderSpec.groovy diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/FeatureEventBuffer.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/FeatureEventBuffer.java index 61e1f8fa5..770d7192c 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/FeatureEventBuffer.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/FeatureEventBuffer.java @@ -16,8 +16,11 @@ import de.ii.xtraplatform.features.domain.SchemaMapping; import de.ii.xtraplatform.features.domain.SchemaMappingBase; import de.ii.xtraplatform.geometries.domain.GeometryType; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; +import java.util.Deque; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -198,11 +201,124 @@ public void bufferStop(boolean flush) { } public void bufferFlush() { - buffer.add(FeatureTokenType.FLUSH); - buffer.forEach(bufferOut::onToken); + List ordered = orderedBySchema(buffer); + ordered.add(FeatureTokenType.FLUSH); + ordered.forEach(bufferOut::onToken); buffer.clear(); } + /** + * Re-sorts the buffered feature so that properties are emitted in the order declared in the + * schema, regardless of the order in which the provider produced them. The incremental buffer + * accounting places a property where its tokens first arrive, which is the SQL provider's + * per-table order, not the schema order: a property backed by a joined table (object, object + * array, value array, feature reference) is produced after the columns of the main table even + * when it is declared before them. Running after the slice transformers, this pass rebuilds the + * token stream as a tree and serialises each object's children in schema-position order. Array + * elements keep their (data) order; the children inside each element are ordered by schema + * position like any other object. + */ + private List orderedBySchema(List tokens) { + SchemaMapping mapping = Objects.isNull(lastType) ? null : mappings.get(lastType); + + if (Objects.isNull(mapping) || tokens.isEmpty()) { + return new ArrayList<>(tokens); + } + + Node root = new Node(null, List.of()); + buildTree(tokens, root); + orderChildren(root, mapping); + + List ordered = new ArrayList<>(tokens.size()); + for (Node child : root.children) { + child.flattenInto(ordered); + } + return ordered; + } + + private static void buildTree(List tokens, Node root) { + Deque stack = new ArrayDeque<>(); + stack.push(root); + + int i = 0; + while (i < tokens.size()) { + // a token group is a marker (FeatureTokenType) followed by its context tokens up to the next + // marker + int j = i + 1; + while (j < tokens.size() && !(tokens.get(j) instanceof FeatureTokenType)) { + j++; + } + List group = new ArrayList<>(tokens.subList(i, j)); + FeatureTokenType type = + tokens.get(i) instanceof FeatureTokenType ? (FeatureTokenType) tokens.get(i) : null; + + if (type == FeatureTokenType.OBJECT || type == FeatureTokenType.ARRAY) { + Node node = new Node(type, group); + stack.peek().children.add(node); + stack.push(node); + } else if (type == FeatureTokenType.OBJECT_END || type == FeatureTokenType.ARRAY_END) { + if (stack.size() > 1) { + stack.pop().close = group; + } + } else { + stack.peek().children.add(new Node(type, group)); + } + + i = j; + } + } + + private static void orderChildren(Node node, SchemaMapping mapping) { + // array elements keep their data order; the children of any object (including each array + // element) are ordered by their schema position + if (node.type != FeatureTokenType.ARRAY && node.children.size() > 1) { + node.children.sort(Comparator.comparingInt(child -> positionOf(child, mapping))); + } + for (Node child : node.children) { + orderChildren(child, mapping); + } + } + + private static int positionOf(Node node, SchemaMapping mapping) { + List path = node.path(); + if (path.isEmpty()) { + return Integer.MAX_VALUE; + } + List positions = mapping.getPositionsForTargetPath(path); + int position = positions.isEmpty() ? -1 : positions.get(0); + // keep paths without a known position at the end, in their original (stable) order + return position < 0 ? Integer.MAX_VALUE : position; + } + + private static final class Node { + private final FeatureTokenType type; + private final List open; + private final List children = new ArrayList<>(); + private List close; + + private Node(FeatureTokenType type, List open) { + this.type = type; + this.open = open; + } + + @SuppressWarnings("unchecked") + private List path() { + return open.size() > 1 && open.get(1) instanceof List + ? (List) open.get(1) + : List.of(); + } + + private void flattenInto(List out) { + out.addAll(open); + for (Node child : children) { + child.flattenInto(out); + } + if (Objects.nonNull(close)) { + out.addAll(close); + } + } + } + public boolean isBuffering() { return doBuffer; } diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferOrderSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferOrderSpec.groovy new file mode 100644 index 000000000..6d746d4c8 --- /dev/null +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferOrderSpec.groovy @@ -0,0 +1,73 @@ +/* + * 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 + +// Regression for the FeatureEventBuffer schema-order reordering: a property backed by a joined +// table is produced by the provider after the main-table columns even when it is declared before +// them. The buffer must emit it at its schema position, not where its tokens first arrive — +// otherwise an object property declared before its scalar siblings ends up after them, producing +// XML/GML that does not match the application schema's element order. +class FeatureEventBufferOrderSpec extends Specification { + + // id, child (OBJECT on a joined table), a, b (scalar columns on the main table) + static FeatureSchema SCHEMA = new ImmutableFeatureSchema.Builder() + .name("t") + .sourcePath("/t") + .type(Type.OBJECT) + .putProperties2("id", new ImmutableFeatureSchema.Builder() + .sourcePath("objid").type(Type.STRING).role(SchemaBase.Role.ID)) + .putProperties2("child", new ImmutableFeatureSchema.Builder() + .sourcePath("[id=rid]t__child") + .type(Type.OBJECT) + .putProperties2("value", new ImmutableFeatureSchema.Builder() + .sourcePath("value").type(Type.STRING))) + .putProperties2("a", new ImmutableFeatureSchema.Builder() + .sourcePath("a").type(Type.STRING)) + .putProperties2("b", new ImmutableFeatureSchema.Builder() + .sourcePath("b").type(Type.STRING)) + .build() + + // provider order: the main-table scalars (a, b) arrive before the joined object (child) + static List SOURCE = [ + FeatureTokenType.INPUT, true, FeatureTokenType.FEATURE, + FeatureTokenType.VALUE, ["t", "objid"], "f1", Type.STRING, + FeatureTokenType.VALUE, ["t", "a"], "2000", Type.STRING, + FeatureTokenType.VALUE, ["t", "b"], "1200", Type.STRING, + FeatureTokenType.OBJECT, ["t", "[id=rid]t__child"], + FeatureTokenType.VALUE, ["t", "[id=rid]t__child", "value"], "1000", Type.STRING, + FeatureTokenType.OBJECT_END, ["t", "[id=rid]t__child"], + FeatureTokenType.FEATURE_END, FeatureTokenType.INPUT_END + ] + + // schema order: child is emitted before a/b, with its object markers and child value intact + static List EXPECTED = [ + FeatureTokenType.INPUT, true, FeatureTokenType.FEATURE, + FeatureTokenType.VALUE, ["id"], "f1", Type.STRING, + FeatureTokenType.OBJECT, ["child"], + FeatureTokenType.VALUE, ["child", "value"], "1000", Type.STRING, + FeatureTokenType.OBJECT_END, ["child"], + FeatureTokenType.VALUE, ["a"], "2000", Type.STRING, + FeatureTokenType.VALUE, ["b"], "1200", Type.STRING, + FeatureTokenType.FEATURE_END, FeatureTokenType.INPUT_END + ] + + def "a joined object declared before main-table columns is emitted at its schema position"() { + given: + List actual = [] + FeatureTokenReader reader = Util.createReader(SCHEMA, actual) + + when: + SOURCE.forEach(token -> reader.onToken(token)) + + then: + actual == EXPECTED + } +} diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappingSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappingSpec.groovy index ed8b2381d..39986d174 100644 --- a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappingSpec.groovy +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerMappingSpec.groovy @@ -34,7 +34,7 @@ class FeatureTokenTransformerMappingSpec extends Specification { where: casename | schema | source | expected - //TODO "joined value array between main columns" | FeatureSchemaFixtures.BIOTOP | FeatureTokenFixtures.SINGLE_FEATURE_VALUE_ARRAY_AT_END | FeatureTokenFixtures.SINGLE_FEATURE_VALUE_ARRAY_IN_ORDER_MAPPED + "joined value array between main columns" | FeatureSchemaFixtures.BIOTOP | FeatureTokenFixtures.SINGLE_FEATURE_VALUE_ARRAY_AT_END | FeatureTokenFixtures.SINGLE_FEATURE_VALUE_ARRAY_IN_ORDER_MAPPED "joined object array" | FeatureSchemaFixtures.OBJECT_ARRAY | FeatureTokenFixtures.EXPLORATION_SITE_OBJECT_ARRAY | FeatureTokenFixtures.EXPLORATION_SITE_OBJECT_ARRAY_MAPPED //"object without source path" | FeatureSchemaFixtures.OBJECT_WITHOUT_SOURCE_PATH | FeatureTokenFixtures.OBJECT_WITHOUT_SOURCE_PATH | FeatureTokenFixtures.OBJECT_WITHOUT_SOURCE_PATH_MAPPED diff --git a/xtraplatform-features/src/testFixtures/groovy/de/ii/xtraplatform/features/domain/FeatureTokenFixtures.groovy b/xtraplatform-features/src/testFixtures/groovy/de/ii/xtraplatform/features/domain/FeatureTokenFixtures.groovy index e4a60a6d9..29ba870b4 100644 --- a/xtraplatform-features/src/testFixtures/groovy/de/ii/xtraplatform/features/domain/FeatureTokenFixtures.groovy +++ b/xtraplatform-features/src/testFixtures/groovy/de/ii/xtraplatform/features/domain/FeatureTokenFixtures.groovy @@ -310,7 +310,7 @@ class FeatureTokenFixtures { "611320001-1", Type.STRING, FeatureTokenType.ARRAY, - ["biotop", "[eid=id]erfasser"], + ["biotop", "[eid=id]erfasser", "name"], FeatureTokenType.VALUE, ["biotop", "[eid=id]erfasser", "name"], "John Doe", @@ -320,7 +320,7 @@ class FeatureTokenFixtures { "Jane Doe", Type.STRING, FeatureTokenType.ARRAY_END, - ["biotop", "[eid=id]erfasser"], + ["biotop", "[eid=id]erfasser", "name"], FeatureTokenType.FEATURE_END, FeatureTokenType.INPUT_END ] @@ -334,7 +334,7 @@ class FeatureTokenFixtures { "24", Type.STRING, FeatureTokenType.ARRAY, - ["biotop", "[eid=id]erfasser"], + ["biotop", "[eid=id]erfasser", "name"], FeatureTokenType.VALUE, ["biotop", "[eid=id]erfasser", "name"], "John Doe", @@ -344,7 +344,7 @@ class FeatureTokenFixtures { "Jane Doe", Type.STRING, FeatureTokenType.ARRAY_END, - ["biotop", "[eid=id]erfasser"], + ["biotop", "[eid=id]erfasser", "name"], FeatureTokenType.VALUE, ["biotop", "kennung"], "611320001-1", @@ -572,37 +572,37 @@ class FeatureTokenFixtures { FeatureTokenType.OBJECT, ["task"], FeatureTokenType.VALUE, - ["task", "title"], - "11", - Type.STRING, - FeatureTokenType.VALUE, ["task", "id"], "34", Type.STRING, + FeatureTokenType.VALUE, + ["task", "title"], + "11", + Type.STRING, FeatureTokenType.OBJECT_END, ["task"], FeatureTokenType.OBJECT, ["task"], FeatureTokenType.VALUE, - ["task", "title"], - "35", - Type.STRING, - FeatureTokenType.VALUE, ["task", "id"], "36", Type.STRING, + FeatureTokenType.VALUE, + ["task", "title"], + "35", + Type.STRING, FeatureTokenType.OBJECT_END, ["task"], FeatureTokenType.OBJECT, ["task"], FeatureTokenType.VALUE, - ["task", "title"], - "12", - Type.STRING, - FeatureTokenType.VALUE, ["task", "id"], "37", Type.STRING, + FeatureTokenType.VALUE, + ["task", "title"], + "12", + Type.STRING, FeatureTokenType.OBJECT_END, ["task"], FeatureTokenType.ARRAY_END, diff --git a/xtraplatform-features/src/testFixtures/resources/feature-tokens/pfs_plan-hatObjekt.yml b/xtraplatform-features/src/testFixtures/resources/feature-tokens/pfs_plan-hatObjekt.yml index 8b292fb49..c21ea8d77 100644 --- a/xtraplatform-features/src/testFixtures/resources/feature-tokens/pfs_plan-hatObjekt.yml +++ b/xtraplatform-features/src/testFixtures/resources/feature-tokens/pfs_plan-hatObjekt.yml @@ -16,18 +16,18 @@ tokens: source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_erdgasleitung target: hatObjekt onlyIf: source,concat,coalesce,mapped - - type: VALUE - source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_erdgasleitung/_id - target: hatObjekt.1_id - value: 1 - valueType: INTEGER - onlyIf: source,concat,coalesce - type: VALUE source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_erdgasleitung/constant_hatObjekt_37 target: hatObjekt.1_type value: bst_erdgasleitung valueType: STRING onlyIf: source,concat,coalesce + - type: VALUE + source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_erdgasleitung/_id + target: hatObjekt.1_id + value: 1 + valueType: INTEGER + onlyIf: source,concat,coalesce - type: VALUE source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_erdgasleitung/text target: hatObjekt.1_title @@ -58,18 +58,18 @@ tokens: source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_erdgasleitung target: hatObjekt onlyIf: source,concat,mapped - - type: VALUE - source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_erdgasleitung/_id - target: hatObjekt.1_id - value: 2 - valueType: INTEGER - onlyIf: source,concat - type: VALUE source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_erdgasleitung/constant_hatObjekt_37 target: hatObjekt.1_type value: bst_erdgasleitung valueType: STRING onlyIf: source,concat + - type: VALUE + source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_erdgasleitung/_id + target: hatObjekt.1_id + value: 2 + valueType: INTEGER + onlyIf: source,concat - type: VALUE source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_erdgasleitung/text target: hatObjekt.1_title @@ -108,18 +108,18 @@ tokens: source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_speicher target: hatObjekt onlyIf: source,concat - - type: VALUE - source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_speicher/_id - target: hatObjekt.2_id - value: null - valueType: INTEGER - onlyIf: source,concat - type: VALUE source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_speicher/constant_hatObjekt_38 target: hatObjekt.2_type value: bst_speicher valueType: STRING onlyIf: source,concat + - type: VALUE + source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_speicher/_id + target: hatObjekt.2_id + value: null + valueType: INTEGER + onlyIf: source,concat - type: VALUE source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_speicher/text target: hatObjekt.2_title @@ -140,18 +140,18 @@ tokens: source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_speicher target: hatObjekt onlyIf: source,concat,mapped - - type: VALUE - source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_speicher/_id - target: hatObjekt.2_id - value: 2 - valueType: INTEGER - onlyIf: source,concat - type: VALUE source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_speicher/constant_hatObjekt_38 target: hatObjekt.2_type value: bst_speicher valueType: STRING onlyIf: source,concat + - type: VALUE + source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_speicher/_id + target: hatObjekt.2_id + value: 2 + valueType: INTEGER + onlyIf: source,concat - type: VALUE source: /pfs_plan/[_id=gehoertzuplan_pfs_plan_fk]bst_speicher/text target: hatObjekt.2_title From a8f8c8df12fa71f8f42944ceb04d7c31eeb39fb1 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Tue, 23 Jun 2026 19:02:16 +0200 Subject: [PATCH 2/4] features: order feature properties with a single schema-order pass The per-feature event buffer ordered properties in two places: an incremental, cursor-driven placement in append, and a flush-time pass that re-sorted the feature into schema order. The cursor placement only got top-level properties right - a property nested in another object and backed by a joined table was left in production order - so the second pass was layered on top, leaving two overlapping ordering mechanisms. Keep a single mechanism. append now stores tokens in the order the provider produces them, and orderedBySchema is the only pass that applies schema order. It runs in place, lazily and once per feature: before the in-buffer slice transformers read the buffer, and at the latest at flush when no transformer ran. Ordering before the slice transformers is required, not incidental: a property produced from several tables arrives as several fragments, and a transformer reads a property as a contiguous buffer range. Only once the feature is in schema order are a property's fragments contiguous; without it, an unrelated property emitted between two fragments is swallowed into the slice (e.g. a separate array nested inside a concatenated object array). The transformers rewrite within a property and preserve schema order, so the pass is not repeated after they run. The position-addressable slice index that getSlice/replaceSlice need is built once per feature by a single pass over the buffered tokens, only when a slice is first accessed, and then updated in place as slices are rewritten - because the buffer is in schema-position order, a slice that changes size simply shifts every later position by the same amount, so the index is not rebuilt per rewrite. The schema-order pass also coalesces the per-table fragments of a single-valued object into one object: a provider produces an object backed by more than one table as several OBJECT[path]..OBJECT_END[path] blocks at the same position, which the previous per-position accrual merged implicitly. Object-array elements are wrapped in an array and are never affected. Remove the now-unused cursor plumbing (next, the current/currentEnclosing state, the increase/propagate accounting) and the dead source-path reorder transformer FeatureTokenTransformerSorting, which was only referenced from a commented-out, empty getDecoderTransformers override. Regression specs cover a nested joined object declared before its scalar siblings, an object produced as two per-table fragments coalescing into one, and a property whose fragments are split around an unrelated property keeping that property out of its slice. --- .../sql/domain/FeatureProviderSql.java | 7 - .../FeatureTokenTransformerMappings.java | 7 - .../FeatureTokenTransformerSorting.java | 347 ------------------ .../domain/transform/FeatureEventBuffer.java | 264 +++++++++---- .../domain/FeatureEventBufferOrderSpec.groovy | 115 +++++- .../domain/FeatureEventBufferSliceSpec.groovy | 63 ++++ .../FeatureTokenTransformerSortingSpec.groovy | 49 --- 7 files changed, 355 insertions(+), 497 deletions(-) delete mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerSorting.java create mode 100644 xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferSliceSpec.groovy delete mode 100644 xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerSortingSpec.groovy 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 6e7ba7402..ece94e873 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 @@ -10,7 +10,6 @@ import static de.ii.xtraplatform.cql.domain.In.ID_PLACEHOLDER; import com.fasterxml.jackson.core.JsonParseException; -import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import dagger.assisted.Assisted; import dagger.assisted.AssistedInject; @@ -56,7 +55,6 @@ import de.ii.xtraplatform.features.domain.FeatureStreamImpl; import de.ii.xtraplatform.features.domain.FeatureTokenDecoder; import de.ii.xtraplatform.features.domain.FeatureTokenSource; -import de.ii.xtraplatform.features.domain.FeatureTokenTransformer; import de.ii.xtraplatform.features.domain.FeatureTransactions; import de.ii.xtraplatform.features.domain.FeatureTransactions.MutationResult.Builder; import de.ii.xtraplatform.features.domain.FeatureTransactions.MutationResult.Type; @@ -936,11 +934,6 @@ protected WkbDialect getWkbDialect() { return WkbDialect.SQL_MM; } - @Override - protected List getDecoderTransformers() { - return ImmutableList.of(); // new FeatureTokenTransformerSorting()); - } - @Override public FeatureProviderSqlData getData() { return (FeatureProviderSqlData) super.getData(); 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 511c857eb..8151db4b0 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 @@ -164,7 +164,6 @@ public void onFeatureStart(ModifiableContext conte downstream.onFeatureStart(newContext); downstream.bufferStart(); - downstream.next(0); this.currentValueTransformerChain = valueTransformerChains.get(context.type()); } @@ -205,7 +204,6 @@ private void applyTokenSliceTransformers(String type) { public void onObjectStart(ModifiableContext context) { int pos = context.pos(); if (pos > -1) { - downstream.next(pos, context.parentPos()); if (context.schema().filter(schema -> schema.isObject() || schema.isSpatial()).isPresent()) { FeatureSchema schema = context.schema().get(); @@ -219,7 +217,6 @@ public void onObjectStart(ModifiableContext contex public void onObjectEnd(ModifiableContext context) { int pos = context.pos(); if (pos > -1) { - downstream.next(pos, context.parentPos()); if (context.schema().filter(schema -> schema.isObject() || schema.isSpatial()).isPresent()) { FeatureSchema schema = context.schema().get(); @@ -232,7 +229,6 @@ public void onObjectEnd(ModifiableContext context) public void onArrayStart(ModifiableContext context) { int pos = context.pos(); if (pos > -1) { - downstream.next(pos, context.parentPos()); if (context.schema().filter(schema -> schema.isArray() || schema.isSpatial()).isPresent()) { FeatureSchema schema = context.schema().get(); @@ -246,7 +242,6 @@ public void onArrayStart(ModifiableContext context public void onArrayEnd(ModifiableContext context) { int pos = context.pos(); if (pos > -1) { - downstream.next(pos, context.parentPos()); if (context.schema().filter(schema -> schema.isArray() || schema.isSpatial()).isPresent()) { FeatureSchema schema = context.schema().get(); @@ -259,7 +254,6 @@ public void onArrayEnd(ModifiableContext context) public void onGeometry(ModifiableContext context) { int pos = context.pos(); if (pos > -1) { - downstream.next(pos, context.parentPos()); if (context.schema().filter(FeatureSchema::isSpatial).isPresent()) { FeatureSchema schema = context.schema().get(); @@ -273,7 +267,6 @@ public void onGeometry(ModifiableContext context) public void onValue(ModifiableContext context) { int pos = context.pos(); if (pos > -1) { - downstream.next(pos, context.parentPos()); if (context.schema().filter(FeatureSchema::isValue).isPresent()) { FeatureSchema schema = context.schema().get(); diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerSorting.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerSorting.java deleted file mode 100644 index 7e6438027..000000000 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTokenTransformerSorting.java +++ /dev/null @@ -1,347 +0,0 @@ -/* - * Copyright 2022 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.LinkedHashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Objects; -import java.util.Queue; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/* -NOTE: while this works, it is cumbersome and hard to maintain - a much cleaner solution would use FeatureTokenEmitter and FeatureTokenReader for buffering - but these are out of sync with Context and have to be adjusted (geoDim, in(Geo|Array|Object)) -*/ -public class FeatureTokenTransformerSorting extends FeatureTokenTransformer { - - private static final Logger LOGGER = - LoggerFactory.getLogger(FeatureTokenTransformerSorting.class); - - private final Map, Integer>> pathIndex; - private final Map, Integer>> rearrange; - private final Queue indexQueue; - private final Queue> pathQueue; - private final Queue schemaIndexQueue; - private final Queue> indexesQueue; - private final Queue valueQueue; - private final Queue> geoQueue; - private final Queue inArrayQueue; - private final Queue inObjectQueue; - private final Queue tokenQueue; - private final Queue valueTypeQueue; - /*private FeatureTokenBuffer< - FeatureSchema, SchemaMapping, ModifiableContext> - downstream;*/ - - private String currentType; - private int bufferIndex; - private List bufferParent; - - public FeatureTokenTransformerSorting() { - this.pathIndex = new LinkedHashMap<>(); - this.rearrange = new LinkedHashMap<>(); - this.indexQueue = new LinkedList<>(); - this.pathQueue = new LinkedList<>(); - this.schemaIndexQueue = new LinkedList<>(); - this.indexesQueue = new LinkedList<>(); - this.valueQueue = new LinkedList<>(); - this.geoQueue = new LinkedList<>(); - this.inArrayQueue = new LinkedList<>(); - this.inObjectQueue = new LinkedList<>(); - this.tokenQueue = new LinkedList<>(); - this.valueTypeQueue = new LinkedList<>(); - this.currentType = null; - this.bufferIndex = 0; - this.bufferParent = new ArrayList<>(); - } - - @Override - protected void init() { - // this.downstream = new FeatureTokenBuffer<>(getDownstream(), getContext()); - - super.init(); - } - - @Override - public void onStart(ModifiableContext context) { - analyzeMapping(context.mapping()); - - super.onStart(context); - } - - @Override - public void onFeatureStart(ModifiableContext context) { - analyzeMapping(context.mapping()); - - super.onFeatureStart(context); - } - - @Override - public void onFeatureEnd(ModifiableContext context) { - if (bufferIndex > 0) { - emptyBuffer(context, Integer.MAX_VALUE); - // downstream.bufferStop(true); - } - - super.onFeatureEnd(context); - } - - @Override - public void onObjectStart(ModifiableContext context) { - int index = getIndex(context.path()); - - checkBuffer(context, FeatureTokenType.OBJECT, index, index > bufferIndex); - } - - @Override - public void onObjectEnd(ModifiableContext context) { - boolean isBufferParent = startsWith(bufferParent, context.path()); - int triggerIndex = 0; - if (isBufferParent) { - triggerIndex = findLastStartsWith(context.path()) + 1; - } - - checkBuffer(context, FeatureTokenType.OBJECT_END, triggerIndex, isBufferParent); - } - - @Override - public void onArrayStart(ModifiableContext context) { - int index = getIndex(context.path()); - - checkBuffer(context, FeatureTokenType.ARRAY, index, index > bufferIndex); - } - - @Override - public void onArrayEnd(ModifiableContext context) { - int index = getIndex(context.path()); - - checkBuffer(context, FeatureTokenType.ARRAY_END, index, false); - } - - @Override - public void onGeometry(ModifiableContext context) { - int index = getIndex(context.path()); - - checkBuffer(context, FeatureTokenType.GEOMETRY, index, index > bufferIndex); - } - - @Override - public void onValue(ModifiableContext context) { - int index = getIndex(context.path()); - - checkBuffer(context, FeatureTokenType.VALUE, index, index > bufferIndex); - } - - private int getIndex(List path) { - if (Objects.nonNull(currentType)) { - return Objects.requireNonNullElse(pathIndex.get(currentType).get(path), -1); - } - return -1; - } - - // TODO: identify out of order elements, on element after ooe start buffer and mark 0, on ooe - // insert 0 and flush - // TODO: multiple ooes following each other, multiple marks - // TODO: test with xleit, create unit tests - private void analyzeMapping(SchemaMapping mapping) { - if (Objects.isNull(mapping)) { - return; - } - - this.currentType = mapping.getTargetSchema().getName(); - - if (pathIndex.containsKey(currentType) || rearrange.containsKey(currentType)) { - return; - } - - pathIndex.put(currentType, new LinkedHashMap<>()); - rearrange.put(currentType, new LinkedHashMap<>()); - - int index = 0; - List lastParent = null; - boolean doRearrange = false; - - for (List path : mapping.getSchemasByTargetPath().keySet()) { - if (path.size() > 1) { - if (path.stream().anyMatch(elem -> elem.matches("\\[[^=\\]]+].+"))) { - continue; - } - boolean isNested = path.size() > 2 || path.get(path.size() - 1).startsWith("["); - List parent = path.subList(0, path.size() - 1); - if (lastParent == null) { - lastParent = parent; - } - - if (!doRearrange && !Objects.equals(parent, lastParent) && !isNested) { - doRearrange = true; - } else if (doRearrange && (!Objects.equals(parent, lastParent) || isNested)) { - doRearrange = false; - } - - lastParent = parent; - - if (doRearrange) { - this.rearrange.get(currentType).put(path, index); - } - } - - if (LOGGER.isTraceEnabled()) { - LOGGER.trace("{}: {}{}", index, doRearrange ? "QUEUE " : "", path); - } - - this.pathIndex.get(currentType).put(path, index); - index++; - } - } - - private void checkBuffer( - ModifiableContext context, - FeatureTokenType token, - int triggerIndex, - boolean doEmptyBuffer) { - int index = getIndex(context.path()); - boolean doRearrange = - rearrange.containsKey(currentType) - && rearrange.get(currentType).containsKey(context.path()); - - if (doRearrange) { - buffer(context, index, token); - /*if (!downstream.isBuffering()) { - if (bufferIndex <= 0) { - this.bufferIndex = index; - this.bufferParent = context.path().subList(0, context.path().size() - 1); - } - downstream.bufferStart(); - downstream.bufferMark(); - push(context, token); - }*/ - } else { - if (bufferIndex > 0 && doEmptyBuffer) { - emptyBuffer(context, triggerIndex); - // downstream.bufferFlush(); - } - // downstream.bufferStop(false); - push(context, token); - } - } - - private static boolean startsWith(List a, List b) { - return Objects.equals(a, b) - || (a.size() > b.size() && Objects.equals(a.subList(0, b.size()), b)); - } - - private int findLastStartsWith(List parent) { - if (!pathIndex.containsKey(currentType)) { - return -1; - } - return pathIndex.get(currentType).entrySet().stream() - .filter(entry -> startsWith(entry.getKey(), parent)) - .mapToInt(Entry::getValue) - .max() - .orElse(-1); - } - - private void buffer( - ModifiableContext context, int index, FeatureTokenType token) { - if (bufferIndex <= 0) { - this.bufferIndex = index; - this.bufferParent = context.path().subList(0, context.path().size() - 1); - } - - indexQueue.add(index); - tokenQueue.add(token); - pathQueue.add(context.path()); - schemaIndexQueue.add(context.schemaIndex()); - indexesQueue.add(new ArrayList<>(context.indexes())); - valueQueue.add(context.value()); - geoQueue.add(context.geometry()); - inArrayQueue.add(context.inArray()); - inObjectQueue.add(context.inObject()); - valueTypeQueue.add(context.valueType()); - } - - private void emptyBuffer( - ModifiableContext context, int triggerIndex) { - - List path = context.path(); - int schemaIndex = context.schemaIndex(); - ArrayList indexes = new ArrayList<>(context.indexes()); - String value = context.value(); - Geometry geometry = context.geometry(); - boolean inArray = context.inArray(); - boolean inObject = context.inObject(); - - while (!indexQueue.isEmpty() && indexQueue.peek() < triggerIndex) { - int index = indexQueue.remove(); - 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.setGeometry(geometry); - context.setInArray(inArray); - context.setInObject(inObject); - - this.bufferIndex = Objects.requireNonNullElse(indexQueue.peek(), 0); - this.bufferParent = - Objects.nonNull(pathQueue.peek()) && pathQueue.peek().size() > 1 - ? pathQueue.peek().subList(0, pathQueue.peek().size() - 1) - : new ArrayList<>(); - } - - private void push( - ModifiableContext context, FeatureTokenType token) { - switch (token) { - case VALUE: - // downstream.onValue(context); - super.onValue(context); - break; - case GEOMETRY: - // downstream.onGeometry(context); - super.onGeometry(context); - break; - case OBJECT: - // downstream.onObjectStart(context); - super.onObjectStart(context); - break; - case OBJECT_END: - // downstream.onObjectEnd(context); - super.onObjectEnd(context); - break; - case ARRAY: - // downstream.onArrayStart(context); - super.onArrayStart(context); - break; - case ARRAY_END: - // downstream.onArrayEnd(context); - super.onArrayEnd(context); - break; - } - } -} diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/FeatureEventBuffer.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/FeatureEventBuffer.java index 09d728ae4..09bc8bc1d 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/FeatureEventBuffer.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/FeatureEventBuffer.java @@ -29,6 +29,28 @@ import java.util.Vector; import java.util.stream.Collectors; +/** + * Per-feature token buffer with two distinct responsibilities: + * + *
    + *
  1. A position-addressable slice index for the in-buffer token-slice transformers + * (flatten, concat, codelist, …): {@link #getSlice(int)}/{@link #replaceSlice(int, List)} + * read and rewrite a property - or an enclosing object together with its descendants - by its + * schema position. The index ({@link #start(int)}/{@link #length(int)}) is derived on demand + * from the buffered tokens by {@link #computeIndex()}; the transformers rewrite within a + * property and do not reorder across properties. + *
  2. Emission order, applied by the single {@link #orderedBySchema(List)} pass. + *
+ * + *

{@link #append(Object)} stores tokens in the order the provider produces them, which is the + * provider's per-table order, not the schema order: a property backed by a joined table (object, + * object array, value array, feature reference) is produced after the main table's columns even + * when it is declared before them, and a property produced from several tables arrives as several + * fragments. {@link #ensureOrdered()} applies the schema-order pass in place, lazily and exactly + * once per feature: before the slice transformers read the buffer - so each property's fragments + * are contiguous and a transformer's slice cannot swallow an unrelated property emitted between + * them - and at the latest at flush when no transformer ran. + */ public class FeatureEventBuffer< U extends SchemaBase, V extends SchemaMappingBase, W extends ModifiableContext> implements FeatureTokenEmitter2 { @@ -42,8 +64,8 @@ public class FeatureEventBuffer< private final Vector> enclosings; private final Map mappings; private boolean doBuffer; - public int current; - public List currentEnclosing; + private boolean indexStale; + private boolean schemaOrdered; private String lastType; public FeatureEventBuffer( @@ -56,8 +78,8 @@ public FeatureEventBuffer( this.mappings = mappings; this.doBuffer = false; - this.current = 0; - this.currentEnclosing = List.of(); + this.indexStale = true; + this.schemaOrdered = false; int maxEvents = mappings.values().stream() @@ -74,15 +96,6 @@ public FeatureTokenEmitter2 getBuffer() { return bufferIn; } - public void next(int pos) { - next(pos, List.of()); - } - - public void next(int pos, List enclosing) { - this.current = pos; - this.currentEnclosing = enclosing; - } - /** * An event consists of 1 to n tokens and is saved in the buffer. An event has a desired position * that must not match the order of occurrence. events contains the buffer start index and token @@ -112,36 +125,16 @@ private int end(int pos) { } /** - * Increase length for given event position in buffer. - * - * @param pos event position + * Updates the index after a slice changed size: grows the length of {@code pos} by {@code delta} + * and shifts the start of every later position by the same amount. The buffer is in + * schema-position order when this runs (see {@link #ensureOrdered()}), so a resized slice moves + * every position after it by {@code delta} - this keeps the index correct without a full {@link + * #computeIndex()} rebuild per slice rewrite. */ - private void increase(int pos) { - plus(pos, 1); - } - - private void increase(int pos, List enclosing) { - plus(pos, 1); - - for (int pos2 : enclosing) { - plus(pos2, 1, false); - } - } - private void plus(int pos, int delta) { - plus(pos, delta, true); - } - - private void plus(int pos, int delta, boolean propagate) { - // increase length of pos - int lenPos = (pos * 2) + 1; - events[lenPos] += delta; - - // increase start of following pos - if (propagate) { - for (int i = (pos + 1) * 2; i < events.length; i += 2) { - events[i] += delta; - } + events[(pos * 2) + 1] += delta; + for (int i = (pos + 1) * 2; i < events.length; i += 2) { + events[i] += delta; } } @@ -160,17 +153,110 @@ private int minPos(int pos, List enclosing) { return enclosing.get(enclosing.size() - 1); } + /** + * Buffers a token in the order the provider produces it. No ordering is attempted here; {@link + * #orderedBySchema(List)} applies schema order later, on demand. The buffer is marked unordered + * and its slice index stale, both rebuilt lazily the next time a slice is read, written, or + * flushed. + */ void append(Object token) { - int end = end(current); - buffer.add(end, token); + buffer.add(token); + schemaOrdered = false; + indexStale = true; + } + + /** + * Re-sorts the buffer into schema order in place, once. This must run before the in-buffer slice + * transformers read or rewrite a slice: a property produced by the provider as several per-table + * fragments (e.g. a concatenated object array) is contiguous only after sorting, and {@link + * #getSlice(int)} hands a transformer a contiguous buffer range - without this, an unrelated + * property emitted between two fragments would be swallowed into the slice. It is also the single + * place schema order is applied for the final emission. The slice transformers preserve schema + * order (they rewrite within a property), so it is not repeated after they run. + */ + private void ensureOrdered() { + if (schemaOrdered) { + return; + } + List ordered = orderedBySchema(buffer); + buffer.clear(); + buffer.addAll(ordered); + schemaOrdered = true; + indexStale = true; + } + + /** + * Rebuilds the position-addressable slice index from the buffered tokens in a single pass. Each + * token group is accrued to its outermost enclosing position ({@link #minPos(int, List)} of the + * token's position and its ancestors, or the token's own position when it is top-level) - the + * property whose slice encloses it. An enclosing object's range therefore spans all of its + * descendants even when the object's own marker is not emitted (e.g. a flattened source object), + * which is exactly the slice {@link #getSlice(int)} hands to a transformer. Runs only when a + * slice is actually accessed, so features without slice transformers never pay for it. + */ + private void computeIndex() { + Arrays.fill(events, 0); + indexStale = false; + + SchemaMapping mapping = Objects.isNull(lastType) ? null : mappings.get(lastType); + if (Objects.isNull(mapping) || buffer.isEmpty()) { + return; + } + + int i = 0; + while (i < buffer.size()) { + int j = i + 1; + while (j < buffer.size() && !(buffer.get(j) instanceof FeatureTokenType)) { + j++; + } + List path = + i + 1 < buffer.size() && buffer.get(i + 1) instanceof List + ? (List) buffer.get(i + 1) + : List.of(); + int pos = positionForPath(mapping, path); + if (pos >= 0) { + setSpan(minPos(pos, enclosings.get(pos)), i, j); + } - int minPos = minPos(current, currentEnclosing); + i = j; + } + } - increase(minPos); + /** + * Records the buffer range [start, end) for a schema position. A position can occur more than + * once at the same level - object-array elements share the array's position, and several + * consecutive arrays at the same path are merged by the concat transformer - so repeated spans + * are unioned into the enclosing range rather than overwritten. The occurrences are contiguous in + * production order, so the union is the full span the transformer expects. + */ + private void setSpan(int pos, int start, int end) { + if (pos < 0) { + return; + } + int length = length(pos); + if (length == 0) { + events[pos * 2] = start; + events[(pos * 2) + 1] = end - start; + } else { + int unionStart = Math.min(start(pos), start); + int unionEnd = Math.max(end(pos), end); + events[pos * 2] = unionStart; + events[(pos * 2) + 1] = unionEnd - unionStart; + } + } + + private int positionForPath(SchemaMapping mapping, List path) { + if (path.isEmpty()) { + return -1; + } + List positions = mapping.getPositionsForTargetPath(path); + return positions.isEmpty() ? -1 : positions.get(0); } void reset(String type) { Arrays.fill(events, 0); + this.indexStale = true; + this.schemaOrdered = false; if (!Objects.equals(lastType, type)) { Collections.fill(enclosings, List.of()); @@ -201,22 +287,22 @@ public void bufferStop(boolean flush) { } public void bufferFlush() { - List ordered = orderedBySchema(buffer); - ordered.add(FeatureTokenType.FLUSH); - ordered.forEach(bufferOut::onToken); + ensureOrdered(); + buffer.forEach(bufferOut::onToken); + bufferOut.onToken(FeatureTokenType.FLUSH); buffer.clear(); } /** * Re-sorts the buffered feature so that properties are emitted in the order declared in the - * schema, regardless of the order in which the provider produced them. The incremental buffer - * accounting places a property where its tokens first arrive, which is the SQL provider's - * per-table order, not the schema order: a property backed by a joined table (object, object - * array, value array, feature reference) is produced after the columns of the main table even - * when it is declared before them. Running after the slice transformers, this pass rebuilds the - * token stream as a tree and serialises each object's children in schema-position order. Array - * elements keep their (data) order; the children inside each element are ordered by schema - * position like any other object. + * schema, regardless of the order in which the provider produced them. The buffer holds tokens in + * the provider's per-table order, not the schema order: a property backed by a joined table + * (object, object array, value array, feature reference) is produced after the columns of the + * main table even when it is declared before them. The pass rebuilds the token stream as a tree + * and serialises each object's children in schema-position order; array elements keep their + * (data) order, and the children inside each element are ordered by schema position like any + * other object. {@link #ensureOrdered()} applies it in place, before the slice transformers read + * the buffer (so each property's fragments are contiguous) and at the latest by flush. */ private List orderedBySchema(List tokens) { SchemaMapping mapping = Objects.isNull(lastType) ? null : mappings.get(lastType); @@ -273,12 +359,40 @@ private static void orderChildren(Node node, SchemaMapping mapping) { // element) are ordered by their schema position if (node.type != FeatureTokenType.ARRAY && node.children.size() > 1) { node.children.sort(Comparator.comparingInt(child -> positionOf(child, mapping))); + coalesceSplitObjects(node, mapping); } for (Node child : node.children) { orderChildren(child, mapping); } } + /** + * Coalesces adjacent OBJECT children that share a schema position into one object. The provider + * produces a single-valued object backed by more than one table as several per-table fragments - + * separate {@code OBJECT[path]…OBJECT_END[path]} blocks at the same position - which must become + * one object. Object-array elements are never affected: they are children of an ARRAY node, which + * is excluded from sorting and coalescing by the caller. Runs after the sort, so the fragments + * are already adjacent; the merged children are ordered by the recursive {@link #orderChildren} + * pass. + */ + private static void coalesceSplitObjects(Node node, SchemaMapping mapping) { + List coalesced = new ArrayList<>(node.children.size()); + for (Node child : node.children) { + Node previous = coalesced.isEmpty() ? null : coalesced.get(coalesced.size() - 1); + if (Objects.nonNull(previous) + && previous.type == FeatureTokenType.OBJECT + && child.type == FeatureTokenType.OBJECT + && positionOf(child, mapping) != Integer.MAX_VALUE + && positionOf(previous, mapping) == positionOf(child, mapping)) { + previous.children.addAll(child.children); + } else { + coalesced.add(child); + } + } + node.children.clear(); + node.children.addAll(coalesced); + } + private static int positionOf(Node node, SchemaMapping mapping) { List path = node.path(); if (path.isEmpty()) { @@ -327,23 +441,21 @@ public List getSlice(int pos) { if (pos < 0) { return List.of(); } + + ensureOrdered(); + if (pos == 0) { return Collections.unmodifiableList(buffer); } + if (indexStale) { + computeIndex(); + } + int enclosing = minPos(pos, enclosings.get(pos)); List slice = buffer.subList(start(enclosing), end(enclosing)); - /*if (slice.isEmpty() && !enclosings.get(pos).isEmpty()) { - for (int pos2: enclosings.get(pos)) { - slice = buffer.subList(start(pos2), end(pos2)); - if (!slice.isEmpty()) { - break; - } - } - }*/ - return Collections.unmodifiableList(slice); } @@ -352,19 +464,35 @@ public boolean replaceSlice(int pos, List replacement) { return false; } - int enclosing = minPos(pos, enclosings.get(pos)); + ensureOrdered(); + + if (pos == 0) { + if (Objects.equals(buffer, replacement)) { + return false; + } + buffer.clear(); + buffer.addAll(replacement); + // the whole buffer was replaced; rebuild the index before the next slice access + indexStale = true; + return true; + } - List slice = pos == 0 ? buffer : buffer.subList(start(enclosing), end(enclosing)); + if (indexStale) { + computeIndex(); + } + + int enclosing = minPos(pos, enclosings.get(pos)); + List slice = buffer.subList(start(enclosing), end(enclosing)); if (Objects.equals(slice, replacement)) { return false; } int delta = replacement.size() - slice.size(); - slice.clear(); slice.addAll(replacement); + // keep the index correct in place instead of rebuilding it for every slice rewrite if (delta != 0) { plus(enclosing, delta); } diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferOrderSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferOrderSpec.groovy index 6d746d4c8..4515248fe 100644 --- a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferOrderSpec.groovy +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferOrderSpec.groovy @@ -12,31 +12,33 @@ import spock.lang.Specification // Regression for the FeatureEventBuffer schema-order reordering: a property backed by a joined // table is produced by the provider after the main-table columns even when it is declared before -// them. The buffer must emit it at its schema position, not where its tokens first arrive — -// otherwise an object property declared before its scalar siblings ends up after them, producing +// them. The buffer stores tokens in provider order and the flush pass re-sorts them into schema +// order — otherwise a property declared before its scalar siblings ends up after them, producing // XML/GML that does not match the application schema's element order. class FeatureEventBufferOrderSpec extends Specification { + static List run(FeatureSchema schema, List source) { + List actual = [] + FeatureTokenReader reader = Util.createReader(schema, actual) + source.forEach(token -> reader.onToken(token)) + return actual + } + // id, child (OBJECT on a joined table), a, b (scalar columns on the main table) - static FeatureSchema SCHEMA = new ImmutableFeatureSchema.Builder() - .name("t") - .sourcePath("/t") - .type(Type.OBJECT) + static FeatureSchema TOPLEVEL = new ImmutableFeatureSchema.Builder() + .name("t").sourcePath("/t").type(Type.OBJECT) .putProperties2("id", new ImmutableFeatureSchema.Builder() .sourcePath("objid").type(Type.STRING).role(SchemaBase.Role.ID)) .putProperties2("child", new ImmutableFeatureSchema.Builder() - .sourcePath("[id=rid]t__child") - .type(Type.OBJECT) + .sourcePath("[id=rid]t__child").type(Type.OBJECT) .putProperties2("value", new ImmutableFeatureSchema.Builder() .sourcePath("value").type(Type.STRING))) - .putProperties2("a", new ImmutableFeatureSchema.Builder() - .sourcePath("a").type(Type.STRING)) - .putProperties2("b", new ImmutableFeatureSchema.Builder() - .sourcePath("b").type(Type.STRING)) + .putProperties2("a", new ImmutableFeatureSchema.Builder().sourcePath("a").type(Type.STRING)) + .putProperties2("b", new ImmutableFeatureSchema.Builder().sourcePath("b").type(Type.STRING)) .build() // provider order: the main-table scalars (a, b) arrive before the joined object (child) - static List SOURCE = [ + static List TOPLEVEL_SOURCE = [ FeatureTokenType.INPUT, true, FeatureTokenType.FEATURE, FeatureTokenType.VALUE, ["t", "objid"], "f1", Type.STRING, FeatureTokenType.VALUE, ["t", "a"], "2000", Type.STRING, @@ -48,7 +50,7 @@ class FeatureEventBufferOrderSpec extends Specification { ] // schema order: child is emitted before a/b, with its object markers and child value intact - static List EXPECTED = [ + static List TOPLEVEL_EXPECTED = [ FeatureTokenType.INPUT, true, FeatureTokenType.FEATURE, FeatureTokenType.VALUE, ["id"], "f1", Type.STRING, FeatureTokenType.OBJECT, ["child"], @@ -60,14 +62,89 @@ class FeatureEventBufferOrderSpec extends Specification { ] def "a joined object declared before main-table columns is emitted at its schema position"() { - given: - List actual = [] - FeatureTokenReader reader = Util.createReader(SCHEMA, actual) + when: + List actual = run(TOPLEVEL, TOPLEVEL_SOURCE) + + then: + actual == TOPLEVEL_EXPECTED + } + + // outer object q; inner object dpl (on a joined table) declared before the inner scalars gst, vwl + static FeatureSchema NESTED = new ImmutableFeatureSchema.Builder() + .name("t").sourcePath("/t").type(Type.OBJECT) + .putProperties2("id", new ImmutableFeatureSchema.Builder() + .sourcePath("objid").type(Type.STRING).role(SchemaBase.Role.ID)) + .putProperties2("q", new ImmutableFeatureSchema.Builder() + .sourcePath("q").type(Type.OBJECT) + .putProperties2("dpl", new ImmutableFeatureSchema.Builder() + .sourcePath("[id=rid]q__dpl").type(Type.OBJECT) + .putProperties2("h", new ImmutableFeatureSchema.Builder() + .sourcePath("h").type(Type.STRING))) + .putProperties2("gst", new ImmutableFeatureSchema.Builder().sourcePath("gst").type(Type.STRING)) + .putProperties2("vwl", new ImmutableFeatureSchema.Builder().sourcePath("vwl").type(Type.STRING))) + .build() + + // provider order: the inner main-table scalars (gst, vwl) arrive before the joined object (dpl) + static List NESTED_SOURCE = [ + FeatureTokenType.INPUT, true, FeatureTokenType.FEATURE, + FeatureTokenType.VALUE, ["t", "objid"], "f1", Type.STRING, + FeatureTokenType.OBJECT, ["t", "q"], + FeatureTokenType.VALUE, ["t", "q", "gst"], "G", Type.STRING, + FeatureTokenType.VALUE, ["t", "q", "vwl"], "V", Type.STRING, + FeatureTokenType.OBJECT, ["t", "q", "[id=rid]q__dpl"], + FeatureTokenType.VALUE, ["t", "q", "[id=rid]q__dpl", "h"], "H", Type.STRING, + FeatureTokenType.OBJECT_END, ["t", "q", "[id=rid]q__dpl"], + FeatureTokenType.OBJECT_END, ["t", "q"], + FeatureTokenType.FEATURE_END, FeatureTokenType.INPUT_END + ] + + // schema order: dpl is emitted before gst/vwl inside q (the case the cursor placement alone gets + // wrong — it leaves dpl after gst/vwl; only the flush pass corrects it) + static List NESTED_EXPECTED = [ + FeatureTokenType.INPUT, true, FeatureTokenType.FEATURE, + FeatureTokenType.VALUE, ["id"], "f1", Type.STRING, + FeatureTokenType.OBJECT, ["q"], + FeatureTokenType.OBJECT, ["q", "dpl"], + FeatureTokenType.VALUE, ["q", "dpl", "h"], "H", Type.STRING, + FeatureTokenType.OBJECT_END, ["q", "dpl"], + FeatureTokenType.VALUE, ["q", "gst"], "G", Type.STRING, + FeatureTokenType.VALUE, ["q", "vwl"], "V", Type.STRING, + FeatureTokenType.OBJECT_END, ["q"], + FeatureTokenType.FEATURE_END, FeatureTokenType.INPUT_END + ] + + def "a nested joined object declared before its scalar siblings is emitted at its schema position"() { + when: + List actual = run(NESTED, NESTED_SOURCE) + + then: + actual == NESTED_EXPECTED + } + + // the provider produces a single-valued object backed by more than one table as several + // per-table fragments: q first with its main-table scalars (gst, vwl), then again with the + // joined object (dpl) - two OBJECT[q]..OBJECT_END[q] blocks + static List SPLIT_SOURCE = [ + FeatureTokenType.INPUT, true, FeatureTokenType.FEATURE, + FeatureTokenType.VALUE, ["t", "objid"], "f1", Type.STRING, + FeatureTokenType.OBJECT, ["t", "q"], + FeatureTokenType.VALUE, ["t", "q", "gst"], "G", Type.STRING, + FeatureTokenType.VALUE, ["t", "q", "vwl"], "V", Type.STRING, + FeatureTokenType.OBJECT_END, ["t", "q"], + FeatureTokenType.OBJECT, ["t", "q"], + FeatureTokenType.OBJECT, ["t", "q", "[id=rid]q__dpl"], + FeatureTokenType.VALUE, ["t", "q", "[id=rid]q__dpl", "h"], "H", Type.STRING, + FeatureTokenType.OBJECT_END, ["t", "q", "[id=rid]q__dpl"], + FeatureTokenType.OBJECT_END, ["t", "q"], + FeatureTokenType.FEATURE_END, FeatureTokenType.INPUT_END + ] + // the fragments coalesce into one q object, its children in schema order (dpl, gst, vwl) + def "object fragments split across tables coalesce into a single object"() { when: - SOURCE.forEach(token -> reader.onToken(token)) + List actual = run(NESTED, SPLIT_SOURCE) then: - actual == EXPECTED + actual == NESTED_EXPECTED } } diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferSliceSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferSliceSpec.groovy new file mode 100644 index 000000000..66ade03cf --- /dev/null +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferSliceSpec.groovy @@ -0,0 +1,63 @@ +/* + * 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.features.domain.transform.FeatureEventBuffer +import spock.lang.Specification + +// A property produced from more than one table arrives as several fragments. When an unrelated +// property is produced between those fragments (provider order), the slice handed to an in-buffer +// transformer (e.g. concat) must still contain only the property's own tokens - otherwise the +// transformer would swallow the unrelated property into it (e.g. nest a separate array inside a +// concatenated object array). The buffer guarantees this by ordering itself before a slice is read. +class FeatureEventBufferSliceSpec extends Specification { + + // arrA and arrB are both top-level object arrays; arrA is declared first + static FeatureSchema SCHEMA = new ImmutableFeatureSchema.Builder() + .name("t").sourcePath("/t").type(Type.OBJECT) + .putProperties2("id", new ImmutableFeatureSchema.Builder().sourcePath("objid").type(Type.STRING).role(SchemaBase.Role.ID)) + .putProperties2("arrA", new ImmutableFeatureSchema.Builder().sourcePath("[id=a]t__arrA").type(Type.OBJECT_ARRAY) + .putProperties2("x", new ImmutableFeatureSchema.Builder().sourcePath("x").type(Type.STRING))) + .putProperties2("arrB", new ImmutableFeatureSchema.Builder().sourcePath("[id=b]t__arrB").type(Type.OBJECT_ARRAY) + .putProperties2("y", new ImmutableFeatureSchema.Builder().sourcePath("y").type(Type.STRING))) + .build() + + // provider order: arrA fragment 1, then arrB, then arrA fragment 2 (arrA split around arrB) + static List SOURCE = [ + FeatureTokenType.VALUE, ["id"], "f1", Type.STRING, + FeatureTokenType.ARRAY, ["arrA"], + FeatureTokenType.OBJECT, ["arrA"], FeatureTokenType.VALUE, ["arrA", "x"], "1", Type.STRING, FeatureTokenType.OBJECT_END, ["arrA"], + FeatureTokenType.ARRAY_END, ["arrA"], + FeatureTokenType.ARRAY, ["arrB"], + FeatureTokenType.OBJECT, ["arrB"], FeatureTokenType.VALUE, ["arrB", "y"], "b", Type.STRING, FeatureTokenType.OBJECT_END, ["arrB"], + FeatureTokenType.ARRAY_END, ["arrB"], + FeatureTokenType.ARRAY, ["arrA"], + FeatureTokenType.OBJECT, ["arrA"], FeatureTokenType.VALUE, ["arrA", "x"], "2", Type.STRING, FeatureTokenType.OBJECT_END, ["arrA"], + FeatureTokenType.ARRAY_END, ["arrA"] + ] + + def "a slice spans only its own property when its fragments are split around another"() { + given: + FeatureEventBuffer buffer = Util.createBuffer(SCHEMA, []) + buffer.reset("test") + SOURCE.forEach(token -> buffer.push(token)) + int posA = SchemaMapping.of(SCHEMA).getPositionsForTargetPath(["arrA"]).get(0) + + when: + List slice = buffer.getSlice(posA) + + then: + // both arrA fragments are present (the slice gathers the whole property) ... + slice.contains("1") + slice.contains("2") + // ... and the unrelated arrB is NOT swallowed into arrA's slice + !slice.contains(["arrB"]) + !slice.contains("b") + } +} diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerSortingSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerSortingSpec.groovy deleted file mode 100644 index 3bc2668ac..000000000 --- a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureTokenTransformerSortingSpec.groovy +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2022 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.Ignore -import spock.lang.Specification - -/** - * @author zahnen - */ -@Ignore -class FeatureTokenTransformerSortingSpec extends Specification { - - FeatureTokenReader tokenReader - List tokens - - def setup() { - FeatureTokenTransformerSorting mapper = new FeatureTokenTransformerSorting() - FeatureQuery query = ImmutableFeatureQuery.builder().type("test").build() - FeatureEventHandler.ModifiableContext context = mapper.createContext() - .setQuery(query) - .setMappings([test: FeatureSchemaFixtures.BIOTOP_MAPPING]) - .setType('test') - - tokenReader = new FeatureTokenReader(mapper, context) - tokens = [] - mapper.init(token -> tokens.add(token)) - } - - def 'single feature join before column value'() { - - given: - - when: - - FeatureTokenFixtures.SINGLE_FEATURE_VALUE_ARRAY_AT_END.forEach(token -> tokenReader.onToken(token)) - - then: - - tokens == FeatureTokenFixtures.SINGLE_FEATURE_VALUE_ARRAY_IN_ORDER - - } - -} From e4c3ee99f7a1197327c2aaf3114fa8c9eb2dd0f2 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 25 Jun 2026 09:38:10 +0200 Subject: [PATCH 3/4] fix feature response crash when an absent property follows a shrunk slice getSlice threw IndexOutOfBoundsException (fromIndex < 0) on features where an in-buffer slice transformer shrinks a property's slice and a later property is absent from the feature. computeIndex fills the slice index from scratch and recorded a span only for positions that have tokens, leaving every absent property at start 0. replaceSlice shrinking a present slice shifts the start of every later position by the negative size delta, driving an absent position after it below zero; the next getSlice then called buffer.subList with a negative fromIndex. The previous incremental index maintenance kept a valid offset for every position, including empty ones; the single-pass rebuild dropped that. After computing spans, a forward scan now stamps every empty position with a valid buffer offset (the boundary between its occupied neighbours). The buffer is in schema-position order at this point, so the offsets are monotonic and non-negative, and only top-level enclosing positions carry a non-zero length. --- .../domain/transform/FeatureEventBuffer.java | 19 ++++++++++ .../domain/FeatureEventBufferSliceSpec.groovy | 35 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/FeatureEventBuffer.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/FeatureEventBuffer.java index 09bc8bc1d..f28e0bcd7 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/FeatureEventBuffer.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/transform/FeatureEventBuffer.java @@ -220,6 +220,25 @@ private void computeIndex() { i = j; } + + // Positions without tokens (an absent property, or a nested position whose tokens were accrued + // to its enclosing) keep length 0, but still need a valid buffer offset as their start: + // getSlice + // hands out buffer.subList(start, end) and replaceSlice inserts at start, and the incremental + // plus() shift after a slice shrinks moves every later position - including these - by the same + // delta. Left at 0, an empty position after a shrunk slice is driven negative and subList + // throws. + // The buffer is in schema-position order here, so a forward scan yields monotonic, non-negative + // starts; only top-level (enclosing) positions carry a non-zero length, so nextStart advances + // exactly across the buffer. + int nextStart = 0; + for (int pos = 0; pos < events.length / 2; pos++) { + if (length(pos) == 0) { + events[pos * 2] = nextStart; + } else { + nextStart = end(pos); + } + } } /** diff --git a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferSliceSpec.groovy b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferSliceSpec.groovy index 66ade03cf..61c4162f2 100644 --- a/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferSliceSpec.groovy +++ b/xtraplatform-features/src/test/groovy/de/ii/xtraplatform/features/domain/FeatureEventBufferSliceSpec.groovy @@ -42,6 +42,41 @@ class FeatureEventBufferSliceSpec extends Specification { FeatureTokenType.ARRAY_END, ["arrA"] ] + // a schema with a present property followed by an absent one. Shrinking the present property's + // slice shifts every later position by the (negative) size delta; an absent position left at + // start 0 would be driven negative and a subsequent getSlice would throw fromIndex < 0. + static FeatureSchema SCHEMA_ABSENT = new ImmutableFeatureSchema.Builder() + .name("t").sourcePath("/t").type(Type.OBJECT) + .putProperties2("id", new ImmutableFeatureSchema.Builder().sourcePath("objid").type(Type.STRING).role(SchemaBase.Role.ID)) + .putProperties2("a", new ImmutableFeatureSchema.Builder().sourcePath("a").type(Type.STRING)) + .putProperties2("b", new ImmutableFeatureSchema.Builder().sourcePath("b").type(Type.STRING)) + .build() + + def "getSlice on an absent position after a shrunk slice does not go negative"() { + given: + FeatureEventBuffer buffer = Util.createBuffer(SCHEMA_ABSENT, []) + buffer.reset("test") + // id and a are present, b is absent + [ + FeatureTokenType.VALUE, ["id"], "x", Type.STRING, + FeatureTokenType.VALUE, ["a"], "longvalue", Type.STRING + ].forEach(token -> buffer.push(token)) + def mapping = SchemaMapping.of(SCHEMA_ABSENT) + int posA = mapping.getPositionsForTargetPath(["a"]).get(0) + int posB = mapping.getPositionsForTargetPath(["b"]).get(0) + + when: + // read then shrink a's slice (delta < 0), which shifts every later position + buffer.getSlice(posA) + buffer.replaceSlice(posA, []) + // b is absent; its slice must still resolve to an empty, in-range range + List sliceB = buffer.getSlice(posB) + + then: + noExceptionThrown() + sliceB.isEmpty() + } + def "a slice spans only its own property when its fragments are split around another"() { given: FeatureEventBuffer buffer = Util.createBuffer(SCHEMA, []) From 077d45de918fd3d36f3f3100ddc775283fa4a9e7 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Fri, 26 Jun 2026 11:34:30 +0200 Subject: [PATCH 4/4] features: replace the pos guard in mapping handlers with schema checks After the schema-order rework, the per-token position in FeatureTokenTransformerMappings was used only by the `if (pos > -1)` guard, which filtered exactly one thing: the path-less feature-root object. A path-less marker resolves to the root schema (an object) but has no schema position, so pos() returns -1 while schema() is present; for every other token pos and schema agree. Drop pos from all handlers. The object handlers now exclude the root explicitly with `!schema.isFeature()` (isObject and empty parent path) - self-documenting and provably the same set as pos > -1, since the only token with pos == -1 and a present schema is that root. The array, value and geometry handlers need no such check: the object root fails their type checks already. --- .../FeatureTokenTransformerMappings.java | 89 ++++++++----------- 1 file changed, 36 insertions(+), 53 deletions(-) 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 8151db4b0..80477d102 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 @@ -202,89 +202,72 @@ private void applyTokenSliceTransformers(String type) { @Override public void onObjectStart(ModifiableContext context) { - int pos = context.pos(); - if (pos > -1) { - - if (context.schema().filter(schema -> schema.isObject() || schema.isSpatial()).isPresent()) { - FeatureSchema schema = context.schema().get(); - - downstream.onObjectStart(schema.getFullPath()); - } + // emit for object/spatial properties but not the feature root itself: a path-less marker + // resolves to the root schema (an object), which the type check alone would not exclude + if (context + .schema() + .filter(schema -> (schema.isObject() || schema.isSpatial()) && !schema.isFeature()) + .isPresent()) { + FeatureSchema schema = context.schema().get(); + + downstream.onObjectStart(schema.getFullPath()); } } @Override public void onObjectEnd(ModifiableContext context) { - int pos = context.pos(); - if (pos > -1) { - - if (context.schema().filter(schema -> schema.isObject() || schema.isSpatial()).isPresent()) { - FeatureSchema schema = context.schema().get(); - downstream.onObjectEnd(schema.getFullPath()); - } + // not the feature root itself (see onObjectStart) + if (context + .schema() + .filter(schema -> (schema.isObject() || schema.isSpatial()) && !schema.isFeature()) + .isPresent()) { + FeatureSchema schema = context.schema().get(); + downstream.onObjectEnd(schema.getFullPath()); } } @Override public void onArrayStart(ModifiableContext context) { - int pos = context.pos(); - if (pos > -1) { - - if (context.schema().filter(schema -> schema.isArray() || schema.isSpatial()).isPresent()) { - FeatureSchema schema = context.schema().get(); + if (context.schema().filter(schema -> schema.isArray() || schema.isSpatial()).isPresent()) { + FeatureSchema schema = context.schema().get(); - downstream.onArrayStart(schema.getFullPath()); - } + downstream.onArrayStart(schema.getFullPath()); } } @Override public void onArrayEnd(ModifiableContext context) { - int pos = context.pos(); - if (pos > -1) { - - if (context.schema().filter(schema -> schema.isArray() || schema.isSpatial()).isPresent()) { - FeatureSchema schema = context.schema().get(); - downstream.onArrayEnd(schema.getFullPath()); - } + if (context.schema().filter(schema -> schema.isArray() || schema.isSpatial()).isPresent()) { + FeatureSchema schema = context.schema().get(); + downstream.onArrayEnd(schema.getFullPath()); } } @Override public void onGeometry(ModifiableContext context) { - int pos = context.pos(); - if (pos > -1) { - - if (context.schema().filter(FeatureSchema::isSpatial).isPresent()) { - FeatureSchema schema = context.schema().get(); - Geometry geometry = context.geometry(); - downstream.onGeometry(schema.getFullPath(), geometry); - } + if (context.schema().filter(FeatureSchema::isSpatial).isPresent()) { + FeatureSchema schema = context.schema().get(); + Geometry geometry = context.geometry(); + downstream.onGeometry(schema.getFullPath(), geometry); } } @Override public void onValue(ModifiableContext context) { - int pos = context.pos(); - if (pos > -1) { + if (context.schema().filter(FeatureSchema::isValue).isPresent()) { + FeatureSchema schema = context.schema().get(); - if (context.schema().filter(FeatureSchema::isValue).isPresent()) { - FeatureSchema schema = context.schema().get(); + Type valueType = + schema.isSpatial() ? context.valueType() : schema.getValueType().orElse(schema.getType()); - Type valueType = - schema.isSpatial() - ? context.valueType() - : schema.getValueType().orElse(schema.getType()); + String path = schema.getFullPathAsString(); + String value = context.value(); - String path = schema.getFullPathAsString(); - String value = context.value(); - - if (Objects.nonNull(value)) { - value = currentValueTransformerChain.transform(path, value); - } - - downstream.onValue(schema.getFullPath(), value, valueType); + if (Objects.nonNull(value)) { + value = currentValueTransformerChain.transform(path, value); } + + downstream.onValue(schema.getFullPath(), value, valueType); } } }