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 + } }