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 07287a6a2..0904560f5 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
@@ -230,7 +230,10 @@ private enum FrameKind {
* Element of an {@code xmlPaths} chain that represents a property as a nested element structure
* (see {@link #structuralChainsByOwnerPath}). Contributes no path segment of its own; its child
* elements continue the chain until its innermost segment is reached, which resolves to the
- * mapped property and is decoded as a {@link #VALUE_PROPERTY}.
+ * mapped property — decoded as a {@link #VALUE_PROPERTY} for a value, or as an {@link
+ * #OBJECT_ELEMENT} holding the object's members for a mapped object. For an object array the
+ * segment carrying the repetition marker anchors the ARRAY bracket on this frame, so all
+ * members repeating from that segment land in one ARRAY pair.
*/
XML_PATH_CHAIN,
/** Element with no matching schema property; descendants are ignored. */
@@ -251,7 +254,9 @@ private static final class Frame {
* Resolved source-path segment contributed by this frame to the path tracker, or {@code null}
* when no segment is contributed — this is the case for a transparent OBJECT_PROPERTY
* (no {@code sourcePath}, used to flatten nested objects whose leaves carry columns of the
- * parent table), and for OBJECT_ELEMENT / UNKNOWN frames.
+ * parent table), and for OBJECT_ELEMENT / UNKNOWN frames. Exception: an OBJECT_ELEMENT resolved
+ * from an {@code xmlPaths} chain carries the mapped property's segment (see {@link
+ * #chainObjectElement}).
*/
final String segment;
@@ -363,6 +368,15 @@ static Frame objectElement(FeatureSchema lookupOwner, int pathDepth) {
return new Frame(FrameKind.OBJECT_ELEMENT, null, lookupOwner, null, pathDepth);
}
+ /**
+ * OBJECT_ELEMENT resolved from the innermost segment of an {@code xmlPaths} chain. Unlike a
+ * regular object element it carries the property's segment: no OBJECT_PROPERTY frame encloses
+ * it, so its own END emits the {@code onObjectEnd}, re-tracked at this segment.
+ */
+ static Frame chainObjectElement(FeatureSchema prop, String segment, int pathDepth) {
+ return new Frame(FrameKind.OBJECT_ELEMENT, prop, prop, segment, pathDepth);
+ }
+
static Frame valueWrapper() {
return new Frame(FrameKind.VALUE_WRAPPER, null, null, null, -1);
}
@@ -470,10 +484,18 @@ private static final class XmlPathSegment {
final String namespaceUri;
final boolean emptyElement;
- XmlPathSegment(String localName, String namespaceUri, boolean emptyElement) {
+ /**
+ * {@code true} for the segment marked with a leading {@code *}: the chain repeats from this
+ * segment for each member of a mapped object array, so the ARRAY bracket is anchored at the
+ * frame whose children match this segment.
+ */
+ final boolean repeats;
+
+ XmlPathSegment(String localName, String namespaceUri, boolean emptyElement, boolean repeats) {
this.localName = localName;
this.namespaceUri = namespaceUri;
this.emptyElement = emptyElement;
+ this.repeats = repeats;
}
boolean matches(String wireLocalName, String wireNamespaceUri) {
@@ -534,12 +556,18 @@ private void collectStructuralChains(
/**
* Parses one configured chain segment. Mirrors the encoder's grammar {@code
- * name([attribute=value])*'/'?}: the attribute predicates only affect output and are dropped, a
- * trailing {@code /} marks an injected empty element, and a {@code prefix:} resolves to the
- * expected namespace URI (falling back to the input profile's {@code defaultNamespace}).
+ * '*'?name([attribute=value])*'/'?}: the attribute predicates only affect output and are dropped,
+ * a trailing {@code /} marks an injected empty element, and a {@code prefix:} resolves to the
+ * expected namespace URI (falling back to the input profile's {@code defaultNamespace}). The
+ * leading {@code *} marks the segment from which the chain repeats for each member of a mapped
+ * object array; it anchors the ARRAY bracket in {@link #continueStructuralChain(Frame)}.
*/
private XmlPathSegment parseXmlPathSegment(String configured) {
String segment = configured.trim();
+ boolean repeats = segment.startsWith("*");
+ if (repeats) {
+ segment = segment.substring(1).trim();
+ }
boolean emptyElement = segment.endsWith("/");
if (emptyElement) {
segment = segment.substring(0, segment.length() - 1).trim();
@@ -560,7 +588,7 @@ private XmlPathSegment parseXmlPathSegment(String configured) {
? null
: namespaceNormalizer.getNamespaceURI(defaultPrefix);
}
- return new XmlPathSegment(segment, namespaceUri, emptyElement);
+ return new XmlPathSegment(segment, namespaceUri, emptyElement, repeats);
}
/**
@@ -1063,13 +1091,43 @@ private void continueStructuralChain(Frame parent) {
}
}
context.pathTracker().track(segment, pathDepth);
+ if (prop.isObject() && !prop.isFeatureRef()) {
+ // The innermost element of a mapped object's chain takes the role of the object element:
+ // its children are the object's members, resolved against the property's schema —
+ // including the members' own chains, which are relative to this element.
+ downstream.onObjectStart(context);
+ frames.push(Frame.chainObjectElement(prop, segment, pathDepth));
+ return;
+ }
frames.push(createValueFrame(prop, segment, pathDepth));
return;
}
- closeChainArray(parent);
+ // Descending an intermediate segment. The segment carrying the repetition marker introduces
+ // one member of a mapped object array: the ARRAY bracket opens here, on the frame whose
+ // children repeat, and stays open while the marker element repeats — so all members land in
+ // one ARRAY pair, closed when this frame ends or a sibling of another property arrives.
+ FeatureSchema repeating =
+ matched.size() == 1
+ && resolved.segments.get(matchedIndex).repeats
+ && resolved.property.isArray()
+ ? resolved.property
+ : null;
+ if (repeating != null && !Objects.equals(parent.chainContainerArrayPath, repeating.getName())) {
+ if (!Objects.equals(parent.openArrayChildPath, repeating.getName())) {
+ closeChainArray(parent);
+ context.pathTracker().track(repeating.getName(), parent.chainContainerPathDepth + 1);
+ downstream.onArrayStart(context);
+ parent.openArrayChildPath = repeating.getName();
+ }
+ } else {
+ closeChainArray(parent);
+ }
Frame nested = Frame.xmlPathChain(matched, matchedIndex + 1, parent.chainContainerPathDepth);
- nested.chainContainerArrayPath = parent.chainContainerArrayPath;
+ nested.chainContainerArrayPath =
+ parent.openArrayChildPath != null
+ ? parent.openArrayChildPath
+ : parent.chainContainerArrayPath;
frames.push(nested);
}
@@ -1160,14 +1218,21 @@ private void onEndElement() throws XMLStreamException, java.io.IOException {
// For array non-FEATURE_REF OBJECT_PROPERTYs the per-peer OBJECT pair is closed here, at
// the path of the enclosing OBJECT_PROPERTY (the OBJECT_ELEMENT itself contributes no path
// segment). For non-array OBJECT_PROPERTYs the OBJECT_ELEMENT END is silent — onObjectEnd
- // fires at the enclosing OBJECT_PROPERTY's END above.
- Frame enclosing = frames.peek();
- if (enclosing != null
- && enclosing.kind == FrameKind.OBJECT_PROPERTY
- && enclosing.prop.isArray()
- && !enclosing.prop.isFeatureRef()) {
- context.pathTracker().track(enclosing.segment, enclosing.pathDepth);
+ // fires at the enclosing OBJECT_PROPERTY's END above. An object element resolved from an
+ // xmlPaths chain carries its own segment and has no enclosing OBJECT_PROPERTY, so its END
+ // closes the OBJECT pair itself.
+ if (frame.segment != null) {
+ context.pathTracker().track(frame.segment, frame.pathDepth);
downstream.onObjectEnd(context);
+ } else {
+ Frame enclosing = frames.peek();
+ if (enclosing != null
+ && enclosing.kind == FrameKind.OBJECT_PROPERTY
+ && enclosing.prop.isArray()
+ && !enclosing.prop.isFeatureRef()) {
+ context.pathTracker().track(enclosing.segment, enclosing.pathDepth);
+ downstream.onObjectEnd(context);
+ }
}
}
diff --git a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlSpec.groovy b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlSpec.groovy
index 4250f7e66..608924fa7 100644
--- a/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlSpec.groovy
+++ b/xtraplatform-features-gml/src/test/groovy/de/ii/xtraplatform/features/gml/domain/FeatureTokenDecoderGmlSpec.groovy
@@ -3381,4 +3381,218 @@ class FeatureTokenDecoderGmlSpec extends Specification {
valueAtPath(tokens, ["lzi_beg"]) == "2009-11-04T14:25:08Z"
valueAtPath(tokens, ["gfk"]) == "1000"
}
+
+ // -------------------------------------------------------------------------------------------
+ // xmlPaths chains mapping an object array: the chain carries the ancestor elements of the
+ // dissolved objects, the segment marked '*' repeats per member, the innermost element takes
+ // the role of the object element and the member chains are relative to it (the shape of a
+ // NAS quality group whose process steps live in their own table).
+ // -------------------------------------------------------------------------------------------
+
+ /**
+ * AX_PunktortAU in a flattened provider model: the quality group is dissolved into the flat
+ * {@code q2d_gst} and the joined process-step array is hoisted to the feature type as {@code
+ * q2d_dpl_prs}, mapped back to the NAS structure by an object chain.
+ */
+ static FeatureSchema punktortSchema() {
+ new ImmutableFeatureSchema.Builder()
+ .name("ax_punktortau")
+ .sourcePath("/o14003")
+ .type(SchemaBase.Type.OBJECT)
+ .objectType("AX_PunktortAU")
+ .putProperties2("id", new ImmutableFeatureSchema.Builder()
+ .sourcePath("objid")
+ .type(SchemaBase.Type.STRING)
+ .role(SchemaBase.Role.ID))
+ .putProperties2("q2d_dpl_prs", new ImmutableFeatureSchema.Builder()
+ .sourcePath("[id=rid]o14003__q2d__dpl_prs")
+ .type(SchemaBase.Type.OBJECT_ARRAY)
+ .objectType("LI_ProcessStep")
+ .putProperties2("des", new ImmutableFeatureSchema.Builder()
+ .sourcePath("des")
+ .type(SchemaBase.Type.STRING))
+ .putProperties2("zpe", new ImmutableFeatureSchema.Builder()
+ .sourcePath("dat")
+ .type(SchemaBase.Type.DATETIME))
+ .putProperties2("pro", new ImmutableFeatureSchema.Builder()
+ .sourcePath("pro_resp_org")
+ .type(SchemaBase.Type.STRING))
+ .putProperties2("rol", new ImmutableFeatureSchema.Builder()
+ .sourcePath("pro_resp_rol_cdv")
+ .type(SchemaBase.Type.STRING))
+ .putProperties2("src", new ImmutableFeatureSchema.Builder()
+ .sourcePath("src_des")
+ .type(SchemaBase.Type.STRING)))
+ .putProperties2("q2d_gst", new ImmutableFeatureSchema.Builder()
+ .sourcePath("q2d__gst")
+ .type(SchemaBase.Type.STRING))
+ .build()
+ }
+
+ static FeatureTokenDecoderGmlInputProfile punktortProfile() {
+ ImmutableFeatureTokenDecoderGmlInputProfile.builder()
+ .useAlias(true)
+ .defaultNamespace("adv")
+ .putApplicationNamespaces("adv", ADV_NS)
+ .putApplicationNamespaces("gmd", GMD_NS)
+ .putApplicationNamespaces("gco", GCO_NS)
+ .putXmlPaths("q2d_dpl_prs", ["qualitaetsangaben", "AX_DQPunktort", "herkunft",
+ "gmd:LI_Lineage", "*gmd:processStep", "gmd:LI_ProcessStep"])
+ .putXmlPaths("q2d_dpl_prs.des", ["gmd:description",
+ "AX_LI_ProcessStep_Punktort_Description"])
+ .putXmlPaths("q2d_dpl_prs.zpe", ["gmd:dateTime", "gco:DateTime"])
+ .putXmlPaths("q2d_dpl_prs.pro", ["gmd:processor", "gmd:CI_ResponsibleParty",
+ "gmd:organisationName", "gco:CharacterString"])
+ .putXmlPaths("q2d_dpl_prs.rol", ["gmd:processor", "gmd:CI_ResponsibleParty",
+ "gmd:role", "gmd:CI_RoleCode"])
+ .putXmlPaths("q2d_dpl_prs.src", ["gmd:source", "gmd:LI_Source", "gmd:description",
+ "adv:AX_Datenerhebung_Punktort"])
+ .putXmlPaths("q2d_gst", ["qualitaetsangaben", "AX_DQPunktort", "genauigkeitsstufe"])
+ .build()
+ }
+
+ static FeatureTokenDecoderSimple> newPunktortDecoder() {
+ new FeatureTokenDecoderGml(
+ TEST_NAMESPACES,
+ [new QName(ADV_NS, "AX_PunktortAU")],
+ punktortSchema(),
+ ImmutableFeatureQuery.builder().type("ax_punktortau").build(),
+ Map.of("ax_punktortau",
+ new ImmutableSchemaMapping.Builder()
+ .targetSchema(punktortSchema())
+ .sourcePathTransformer((path, isValue) -> path)
+ .build()),
+ STORAGE_CRS,
+ Optional.empty(),
+ Optional.empty(),
+ punktortProfile())
+ }
+
+ static String punktortXml(String steps) {
+ """
+
+
+
+
+ ${steps}
+
+
+ 2000
+
+
+ """
+ }
+
+ static final String ERHEBUNG_STEP = """
+
+
+ Erhebung
+
+
+ 2008-08-26T00:00:00Z
+
+
+
+
+ Kataster- und Vermessungsamt
+
+
+ processor
+
+
+
+
+
+
+ 4300
+
+
+
+
+ """
+
+ static final String BERECHNUNG_STEP = """
+
+
+ Berechnung
+
+
+ 2015-12-01T00:00:00Z
+
+
+ """
+
+ def 'an object chain with a repetition marker brackets all members in one ARRAY pair'() {
+ given: 'two process steps repeating from the marked gmd:processStep segment'
+ def decoder = newPunktortDecoder()
+
+ when:
+ def tokens = runDecoder(decoder, punktortXml(ERHEBUNG_STEP + "\n" + BERECHNUNG_STEP))
+
+ then: 'one ARRAY pair at the property path, one OBJECT pair per member'
+ indicesOfTokenAtPath(tokens, FeatureTokenType.ARRAY, ["q2d_dpl_prs"]).size() == 1
+ indicesOfTokenAtPath(tokens, FeatureTokenType.ARRAY_END, ["q2d_dpl_prs"]).size() == 1
+ indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT, ["q2d_dpl_prs"]).size() == 2
+ indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT_END, ["q2d_dpl_prs"]).size() == 2
+
+ and: 'both members sit inside the bracket'
+ def arrayStart = indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY, ["q2d_dpl_prs"])
+ def arrayEnd = indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY_END, ["q2d_dpl_prs"])
+ indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT, ["q2d_dpl_prs"]).every {
+ it > arrayStart && it < arrayEnd
+ }
+
+ and: 'the member values arrive at the member paths, one per member'
+ def desValues = indicesOfTokenAtPath(tokens, FeatureTokenType.VALUE, ["q2d_dpl_prs", "des"])
+ .collect { tokens.get(it + 2) as String }
+ desValues == ["Erhebung", "Berechnung"]
+ def zpeValues = indicesOfTokenAtPath(tokens, FeatureTokenType.VALUE, ["q2d_dpl_prs", "zpe"])
+ .collect { tokens.get(it + 2) as String }
+ zpeValues == ["2008-08-26T00:00:00Z", "2015-12-01T00:00:00Z"]
+ }
+
+ def 'the members of a chained object resolve through their own chains'() {
+ given: 'member chains relative to the innermost LI_ProcessStep, incl. a shared processor prefix'
+ def decoder = newPunktortDecoder()
+
+ when:
+ def tokens = runDecoder(decoder, punktortXml(ERHEBUNG_STEP))
+
+ then:
+ valueAtPath(tokens, ["q2d_dpl_prs", "des"]) == "Erhebung"
+ valueAtPath(tokens, ["q2d_dpl_prs", "zpe"]) == "2008-08-26T00:00:00Z"
+ valueAtPath(tokens, ["q2d_dpl_prs", "pro"]) == "Kataster- und Vermessungsamt"
+ valueAtPath(tokens, ["q2d_dpl_prs", "rol"]) == "processor"
+ valueAtPath(tokens, ["q2d_dpl_prs", "src"]) == "4300"
+ }
+
+ def 'a single member still arrives inside an ARRAY pair'() {
+ given:
+ def decoder = newPunktortDecoder()
+
+ when:
+ def tokens = runDecoder(decoder, punktortXml(ERHEBUNG_STEP))
+
+ then:
+ indicesOfTokenAtPath(tokens, FeatureTokenType.ARRAY, ["q2d_dpl_prs"]).size() == 1
+ indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT, ["q2d_dpl_prs"]).size() == 1
+ indicesOfTokenAtPath(tokens, FeatureTokenType.OBJECT_END, ["q2d_dpl_prs"]).size() == 1
+ indicesOfTokenAtPath(tokens, FeatureTokenType.ARRAY_END, ["q2d_dpl_prs"]).size() == 1
+ }
+
+ def 'a flat sibling sharing the leading segments resolves after the object chain closes'() {
+ given: 'q2d_gst shares qualitaetsangaben/AX_DQPunktort with the hoisted array'
+ def decoder = newPunktortDecoder()
+
+ when:
+ def tokens = runDecoder(decoder, punktortXml(ERHEBUNG_STEP + "\n" + BERECHNUNG_STEP))
+
+ then: 'the flat property resolves at its own path, outside the ARRAY pair'
+ valueAtPath(tokens, ["q2d_gst"]) == "2000"
+ def arrayEnd = indexOfTokenAtPath(tokens, FeatureTokenType.ARRAY_END, ["q2d_dpl_prs"])
+ indexOfTokenAtPath(tokens, FeatureTokenType.VALUE, ["q2d_gst"]) > arrayEnd
+ }
}